Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 18 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,19 @@ jobs:

strategy:
matrix:
node-version: [18, 20, 22, 24]
node-version: [18, 20, 22, 24, 26]

environment:
name: Production

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6

- name: Use test/build Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version < 22 && 22 || matrix.node-version }}
cache: 'pnpm'
Expand Down Expand Up @@ -80,7 +80,7 @@ jobs:

- name: Use runtime Node.js
if: matrix.node-version < 22
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}

Expand All @@ -96,18 +96,18 @@ jobs:

- name: Test Digest over HTTP on runtime Node.js
if: matrix.node-version < 22
run: pnpm test:runtime:digest
run: node tests/digest-runtime.mjs

bun-smoke-test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6

- name: Use Node.js 22
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 22
cache: 'pnpm'
Expand Down Expand Up @@ -157,13 +157,13 @@ jobs:
deno-smoke-test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6

- name: Use Node.js 22
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 22
cache: 'pnpm'
Expand Down Expand Up @@ -243,9 +243,9 @@ jobs:
digest-browser-test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: 'pnpm'
Expand All @@ -260,9 +260,9 @@ jobs:
run:
working-directory: docs
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: 'pnpm'
Expand Down
18 changes: 9 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,18 @@ jobs:
name: Production

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6

- name: use node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
registry-url: 'https://registry.npmjs.org'
# Publishing jobs must not restore a shared cache.
package-manager-cache: false

- name: install dependencies
run: pnpm i --frozen-lockfile
Expand Down Expand Up @@ -72,10 +74,8 @@ jobs:
pnpm exec playwright install --with-deps chromium
pnpm test:browser:digest

# Publish to npm with provenance statements
# Requires NPM_TOKEN secret to be configured in repository secrets
# or in the Production environment secrets
# Publish through npm trusted publishing (OIDC, npm >= 11.5.1), which also
# attaches provenance. Requires a trusted publisher on npmjs.com that
# matches this repository, `release.yml`, and the Production environment.
- name: publish package
run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ Guidance for AI coding agents working in this repository. The architecture overv
## Tests And Credentials

- Unit tests must not require real provider credentials.
- Integration tests load `.env` via `dotenv/config`; start from `.env.example`
when credentials are needed.
- Integration tests load `.env` via `process.loadEnvFile` in `vitest.config.ts`;
start from `.env.example` when credentials are needed.
- Use existing `MOCK_FETCH` and `RECORD_NETWORK_REQUESTS` conventions when
working with provider tests. Do not record or commit secrets.
- If credentials are missing, state that integration verification was skipped
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
## v2.5.0

##### bug fixes

- treat the `filename` of `createCalendarObject` and `createVCard` as one path segment inside the collection: names such as `urn:uuid:….ics` no longer parse as URL schemes, and `?`, `#`, `\`, stray `%`, and whitespace are percent-encoded while existing percent-escapes are kept; names containing `/`, or equal to `.` or `..`, now throw instead of writing outside the collection
- resynchronize from an empty token when a server rejects an expired sync token (`DAV:valid-sync-token`) instead of failing the sync, and report local objects missing from an initial WebDAV sync as deleted
- match DAV hrefs that differ only in percent-encoding (such as `a@b.ics` and `a%40b.ics`), so object fetches no longer fail as incomplete and principal URLs with email addresses still match
- refresh OAuth access tokens a minute before they expire, and accept `expires_in` sent as a numeric string instead of refreshing on every request

##### tooling

- move development to pnpm 12, Vite 8 (via Vitest 5.0.4), and current patch releases of Biome, Rolldown, Knip, and Playwright; drop `dotenv` and the pinned build-tool overrides
- update GitHub Actions, add Node.js 26 to CI, and remove the unused npm token from the trusted-publishing release workflow
- type-check library sources without Node.js globals, so runtime-specific APIs cannot slip into shared code
- upgrade the documentation site to Astro 7 and Starlight 0.42 with pnpm 12

## v2.4.0

##### features
Expand Down
21 changes: 10 additions & 11 deletions dist/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tsdav",
"version": "2.4.0",
"version": "2.5.0",
"description": "WebDAV, CALDAV, and CARDDAV client for Nodejs and the Browser",
"keywords": [
"dav",
Expand Down Expand Up @@ -73,19 +73,18 @@
"xml-js": "1.6.11"
},
"devDependencies": {
"@biomejs/biome": "2.5.12",
"@biomejs/biome": "2.5.15",
"@types/debug": "4.1.13",
"@types/node": "26.4.1",
"@vitest/coverage-v8": "5.0.0",
"dotenv": "17.4.2",
"ical.js": "^2.2.1",
"knip": "^6.39.0",
"playwright": "1.63.0",
"rolldown": "1.2.7",
"@types/node": "26.6.5",
"@vitest/coverage-v8": "5.0.4",
"ical.js": "2.2.1",
"knip": "6.41.0",
"playwright": "1.64.0",
"rolldown": "1.2.14",
"typescript": "7.0.2",
"vitest": "^5.0.0"
"vitest": "5.0.4"
},
"packageManager": "pnpm@10.34.4",
"packageManager": "pnpm@12.11.2",
"engines": {
"node": ">=18"
}
Expand Down
75 changes: 65 additions & 10 deletions dist/tsdav.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,29 @@ const mergeDAVProps = (propStats) => {
return props;
};
//#endregion
//#region src/util/percentEncoding.ts
/**
* Encode every path segment the same way, so hrefs that differ only in
* percent-encoding (`a@b` and `a%40b`, `%2f` and `%2F`) compare equal.
* Only for comparisons: servers may expect the original form in requests.
*/
const normalizePercentEncoding = (href) => {
let url;
try {
url = new URL(href);
} catch {
return href;
}
url.pathname = url.pathname.split("/").map((segment) => {
try {
return encodeURIComponent(decodeURIComponent(segment));
} catch {
return segment;
}
}).join("/");
return url.href;
};
//#endregion
//#region src/util/requestHelpers.ts
var requestHelpers_exports = /* @__PURE__ */ __exportAll({
cleanupFalsy: () => cleanupFalsy,
Expand Down Expand Up @@ -325,7 +348,7 @@ const urlContains = (urlA, urlB) => {
const urlMatches = (urlA, urlB, baseUrl) => {
if (!urlA || !urlB || !baseUrl) return urlEquals(urlA, urlB);
try {
return urlEquals(new URL(urlA, baseUrl).href, new URL(urlB, baseUrl).href);
return urlEquals(normalizePercentEncoding(new URL(urlA, baseUrl).href), normalizePercentEncoding(new URL(urlB, baseUrl).href));
} catch {
return urlEquals(urlA, urlB);
}
Expand Down Expand Up @@ -569,7 +592,7 @@ const deleteObject = async (params) => {
const getDAVUrlKey = (url, baseUrl) => {
let resolved;
try {
resolved = new URL(url, ensureTrailingSlash(baseUrl)).href;
resolved = normalizePercentEncoding(new URL(url, ensureTrailingSlash(baseUrl)).href);
} catch {
resolved = url.trim();
}
Expand Down Expand Up @@ -780,20 +803,27 @@ const smartCollectionSync = async (params) => {
const syncMethod = method ?? (collection.reports?.includes("syncCollection") ? "webdav" : "basic");
debug$6(`smart collection sync with type ${account.accountType} and method ${syncMethod}`);
if (syncMethod === "webdav") {
const result = await syncCollection({
const requestSync = (syncToken) => syncCollection({
url: collection.url,
props: {
[`d:getetag`]: {},
[`${account.accountType === "caldav" ? "c" : "card"}:${account.accountType === "caldav" ? "calendar-data" : "address-data"}`]: {},
[`d:displayname`]: {}
},
syncLevel: 1,
syncToken: collection.syncToken,
syncToken,
headers: excludeHeaders(headers, headersToExclude),
headersToExclude,
fetchOptions,
fetch: fetchOverride
});
let syncToken = collection.syncToken;
let result = await requestSync(syncToken);
if (syncToken && result.length === 1 && result[0].status >= 400 && result[0].status < 500 && typeof result[0].raw === "string" && result[0].raw.includes("valid-sync-token")) {
debug$6("sync token rejected by server, starting a full sync");
syncToken = void 0;
result = await requestSync(syncToken);
}
const isObjectResponse = (r) => {
return typeof r.href === "string" && getDAVUrlKey(r.href, collection.url) !== getDAVUrlKey(collection.url, collection.url) && !r.props?.resourcetype?.collection;
};
Expand Down Expand Up @@ -832,7 +862,7 @@ const smartCollectionSync = async (params) => {
url: resolveDAVHref(url, collection.url),
etag: ""
}));
const { created, updated, deleted, unchanged } = diffDAVObjects(localObjects, remoteObjects, collection.url, true, deletedObjects);
const { created, updated, deleted, unchanged } = diffDAVObjects(localObjects, remoteObjects, collection.url, Boolean(syncToken), deletedObjects);
return {
...collection,
objects: detailedResult ? {
Expand Down Expand Up @@ -901,6 +931,21 @@ const smartCollectionSyncDetailed = async (params) => smartCollectionSync({
detailedResult: true
});
//#endregion
//#region src/util/objectUrl.ts
/**
* Resolve a new object's URL from its collection URL and file name.
*
* The file name is one path segment, not a relative URL: `?`, `#`, `\`, a
* stray `%`, and whitespace or control characters are percent-encoded, and the
* `./` prefix keeps a name like `urn:uuid:1.ics` from parsing as a URL scheme.
* Valid percent-escapes are kept, so already-encoded names are not encoded twice.
*/
const getObjectUrl = (collectionUrl, filename) => {
if (!filename || filename.includes("/") || /^(?:\.|%2e){1,2}$/i.test(filename)) throw new Error(`Invalid object filename "${filename}": expected a single path segment`);
const segment = filename.replace(/%(?![\da-f]{2})|[?#\\\x00-\x20\x7f]/gi, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
return new URL(`./${segment}`, ensureTrailingSlash(collectionUrl)).href;
};
//#endregion
//#region src/addressBook.ts
var addressBook_exports = /* @__PURE__ */ __exportAll({
addressBookMultiGet: () => addressBookMultiGet,
Expand Down Expand Up @@ -1055,7 +1100,7 @@ const fetchVCards = async (params) => {
const createVCard = async (params) => {
const { addressBook, vCardString, filename, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params;
return createObject({
url: new URL(filename, ensureTrailingSlash(addressBook.url)).href,
url: getObjectUrl(addressBook.url, filename),
data: vCardString,
headers: excludeHeaders({
"content-type": "text/vcard; charset=utf-8",
Expand Down Expand Up @@ -1375,7 +1420,7 @@ const fetchCalendarObjects = async (params) => {
const createCalendarObject = async (params) => {
const { calendar, iCalString, filename, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params;
return createObject({
url: new URL(filename, ensureTrailingSlash(calendar.url)).href,
url: getObjectUrl(calendar.url, filename),
data: iCalString,
headers: excludeHeaders({
"content-type": "text/calendar; charset=utf-8",
Expand Down Expand Up @@ -2196,6 +2241,16 @@ const createAccount = async (params) => {
return newAccount;
};
//#endregion
//#region src/util/tokenExpiry.ts
const EXPIRY_MARGIN_MS = 6e4;
/** Whether a token expiring at `expiration` (ms since epoch) can still be sent. */
const isTokenUnexpired = (expiration) => Date.now() < expiration - EXPIRY_MARGIN_MS;
/** Expiration timestamp for an OAuth `expires_in`, which some providers send as a string. */
const getTokenExpiration = (expiresIn) => {
const seconds = typeof expiresIn === "string" && expiresIn.trim() !== "" ? Number(expiresIn) : expiresIn;
return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 ? Date.now() + seconds * 1e3 : void 0;
};
//#endregion
//#region src/util/authHelpers.ts
var authHelpers_exports = /* @__PURE__ */ __exportAll({
defaultParam: () => defaultParam,
Expand Down Expand Up @@ -2338,7 +2393,7 @@ const getOauthHeaders = async (credentials, fetchOptions, fetchOverride) => {
debug$1("Fetching oauth headers");
let tokens = {};
let didRefresh = false;
if (credentials.accessToken && (credentials.expiration == null && !credentials.refreshToken || credentials.expiration != null && Date.now() < credentials.expiration)) tokens = {
if (credentials.accessToken && (credentials.expiration == null && !credentials.refreshToken || credentials.expiration != null && isTokenUnexpired(credentials.expiration))) tokens = {
access_token: credentials.accessToken,
refresh_token: credentials.refreshToken
};
Expand All @@ -2349,7 +2404,7 @@ const getOauthHeaders = async (credentials, fetchOptions, fetchOverride) => {
if (didRefresh) {
if (tokens.access_token) credentials.accessToken = tokens.access_token;
if (tokens.refresh_token) credentials.refreshToken = tokens.refresh_token;
if (tokens.access_token) credentials.expiration = typeof tokens.expires_in === "number" ? Date.now() + tokens.expires_in * 1e3 : void 0;
if (tokens.access_token) credentials.expiration = getTokenExpiration(tokens.expires_in);
}
debug$1("Oauth tokens obtained");
return {
Expand Down Expand Up @@ -2472,7 +2527,7 @@ var DAVClient = class {
}
async authenticate(force = false, fetchOptions = this.fetchOptions, fetchOverride = this.fetchOverride) {
if (!force && this.authMethod !== "Oauth") return;
if (!force && this.authHeaders && this.credentials.accessToken && (this.credentials.expiration == null || Date.now() < this.credentials.expiration)) {
if (!force && this.authHeaders && this.credentials.accessToken && (this.credentials.expiration == null || isTokenUnexpired(this.credentials.expiration))) {
this.authHeaders = { authorization: `Bearer ${this.credentials.accessToken}` };
return;
}
Expand Down
Loading