Something went wrong. Try again.
A simple DAV sync application / cli
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251import { randomUUID } from "node:crypto";import { mkdir, readFile, stat, writeFile } from "node:fs/promises";import { homedir } from "node:os";import { dirname, isAbsolute, join, relative, resolve } from "node:path";import { encodePath } from "@ordner.fyi/dav-core/wire";import { keychain } from "./keychain.ts";import type { FolderConfig } from "./sync/types.ts";
function platformDir(kind: "config" | "state"): string { const override = kind === "config" ? process.env.DAVSYNC_CONFIG_DIR : process.env.DAVSYNC_STATE_DIR; if (override) return override; if (process.platform === "win32") { const base = kind === "config" ? process.env.APPDATA : process.env.LOCALAPPDATA; return join(base ?? join(homedir(), "AppData", kind === "config" ? "Roaming" : "Local"), "davsync"); } if (process.platform === "darwin") return join(homedir(), "Library", "Application Support", "davsync"); return kind === "config" ? join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "davsync") : join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "davsync");}
/** Where everything lives. Overridable with DAVSYNC_CONFIG_DIR / DAVSYNC_STATE_DIR. */export const paths = { get config() { return join(platformDir("config"), "config.json"); }, get database() { return join(platformDir("state"), "state.db"); }, get locks() { return join(platformDir("state"), "locks"); }, get servicePid() { return join(platformDir("state"), "service.pid"); },};
export const DEFAULT_INTERVAL = 300;const MIN_INTERVAL = 30;
/** A folder as the setup window sends it: the password may be left out to keep the stored one. */export type FolderInput = Omit<FolderConfig, "id" | "hasPassword" | "remoteUrl"> & { id?: string; password?: string };
/** A folder has a password on record, but the keychain has nothing under its name. */export class PasswordMissing extends Error {}
/** * Throws unless `url` is a WebDAV address davsync will send a password to: * https, or plain http only to this machine (where nothing travels the network). */export function checkServerUrl(url: string): URL { let parsed: URL; try { parsed = new URL(url); } catch { throw new Error("Enter the server's WebDAV address, starting with https://"); } const loopback = /^(localhost|127(\.\d+){3}|\[::1\])$/.test(parsed.hostname); if (parsed.protocol === "https:" || (parsed.protocol === "http:" && loopback)) return parsed; throw new Error("The WebDAV address must start with https://: over plain http the password and files would travel unencrypted");}
/** The URL of the collection at `remotePath` below the server root `serverUrl`. */export function remoteUrlOf(serverUrl: string, remotePath: string): string { const root = serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`; const path = remotePath.split("/").filter(Boolean); return path.length ? new URL(`${encodePath(path.join("/"))}/`, root).href : root;}
/** * The sync folders, persisted as JSON (owner-only, though it holds no secrets). * Passwords live in the system keychain, keyed by folder id; a config written by * an earlier version, with passwords in the file, is moved over when loaded. */export class ConfigStore { readonly path: string; #folders: FolderConfig[] = []; #loadedAt = 0; /** Plaintext passwords from an older config that could not be moved to the keychain yet. */ #legacy = new Map<string, string>();
constructor(path = paths.config) { this.path = path; }
async load(): Promise<this> { try { const stored = JSON.parse(await readFile(this.path, "utf8")) as { folders?: (Partial<FolderConfig> & { password?: string })[] }; this.#folders = (stored.folders ?? []).map(withDefaults); this.#loadedAt = Date.now(); this.#legacy.clear(); let moved = false; for (const { id, password } of stored.folders ?? []) { const folder = this.#folders.find((candidate) => candidate.id === id); if (!folder || !password) continue; if (moveToKeychain(folder, password)) { folder.hasPassword = true; moved = true; } else { // The keychain cannot take it right now: keep using it, and try again next load. this.#legacy.set(folder.id, password); folder.hasPassword = true; } } if (moved) await this.#write(); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error(`Cannot read ${this.path}: ${(error as Error).message}`); this.#folders = []; } return this; }
/** Re-reads the file if something else (the setup window) changed it since. */ async reloadIfChanged(): Promise<boolean> { const modified = await stat(this.path).then((stats) => stats.mtimeMs, () => 0); if (modified <= this.#loadedAt) return false; await this.load(); return true; }
folders(): FolderConfig[] { return this.#folders; }
find(idOrName: string): FolderConfig | undefined { return this.#folders.find((folder) => folder.id === idOrName) ?? this.#folders.find((folder) => folder.name === idOrName); }
/** * The folder's password, from the keychain. "" when it has none. Throws * KeychainUnavailable when the keychain cannot be asked right now, and * PasswordMissing when it has nothing for this folder. */ password(folder: FolderConfig): string { const legacy = this.#legacy.get(folder.id); if (legacy !== undefined) return legacy; if (!folder.hasPassword) return ""; const secret = keychain().get(secretKey(folder.id)); if (secret === undefined) { throw new PasswordMissing(`No password for “${folder.name}” in ${keychain().description}: enter it again in davsync's window`); } return secret; }
async save(input: FolderInput): Promise<FolderConfig> { const existing = input.id ? this.#folders.find((folder) => folder.id === input.id) : undefined; const { password, ...rest } = input; const folder = withDefaults({ ...rest, id: existing?.id ?? randomUUID().slice(0, 8), hasPassword: existing?.hasPassword ?? false }); await validate(folder, this.#folders.filter((other) => other.id !== folder.id)); if (password) { // Into the keychain before the config says it is there; if that fails, nothing changes. keychain().set(secretKey(folder.id), password, secretLabel(folder)); this.#legacy.delete(folder.id); folder.hasPassword = true; } this.#folders = existing ? this.#folders.map((other) => (other.id === folder.id ? folder : other)) : [...this.#folders, folder]; await this.#write(); return folder; }
async remove(id: string): Promise<void> { const folder = this.#folders.find((candidate) => candidate.id === id); this.#folders = this.#folders.filter((candidate) => candidate.id !== id); await this.#write(); if (folder?.hasPassword) { try { keychain().delete(secretKey(id)); } catch { // A keychain that cannot be reached now keeps an entry nothing refers to; harmless. } } }
async #write(): Promise<void> { await mkdir(dirname(this.path), { recursive: true }); await writeFile(this.path, JSON.stringify({ folders: this.#folders }, null, 2) + "\n", { mode: 0o600 }); this.#loadedAt = Date.now(); }}
function secretKey(folderId: string): string { return `folder:${folderId}`;}
function secretLabel(folder: FolderConfig): string { let host = folder.serverUrl; try { host = new URL(folder.serverUrl).host; } catch {} return `davsync: ${folder.name} (${folder.username} on ${host})`;}
/** Stores an older config's plaintext password in the keychain, and checks it reads back. */function moveToKeychain(folder: FolderConfig, password: string): boolean { try { keychain().set(secretKey(folder.id), password, secretLabel(folder)); return keychain().get(secretKey(folder.id)) === password; } catch { return false; }}
function withDefaults(folder: Partial<FolderConfig>): FolderConfig { // Folders saved before the server root and the path were kept apart have only a URL. const serverUrl = String(folder.serverUrl ?? folder.remoteUrl ?? "").trim(); const remotePath = String(folder.serverUrl ? (folder.remotePath ?? "") : "").split("/").filter(Boolean).join("/"); let remoteUrl = ""; try { remoteUrl = serverUrl ? remoteUrlOf(serverUrl, remotePath) : ""; } catch { remoteUrl = serverUrl; } return { id: folder.id ?? randomUUID().slice(0, 8), name: String(folder.name ?? "").trim(), enabled: folder.enabled ?? true, localPath: folder.localPath ? resolve(expandHome(String(folder.localPath).trim())) : "", serverUrl: serverUrl && !serverUrl.endsWith("/") ? `${serverUrl}/` : serverUrl, remotePath, remoteUrl, username: String(folder.username ?? ""), hasPassword: folder.hasPassword === true, interval: Math.max(MIN_INTERVAL, Math.round(Number(folder.interval) || DEFAULT_INTERVAL)), ignore: (folder.ignore ?? []).map(String).map((pattern) => pattern.trim()).filter(Boolean), trustCollectionEtags: folder.trustCollectionEtags ?? false, };}
async function validate(folder: FolderConfig, others: FolderConfig[]): Promise<void> { if (!folder.name) throw new Error("Give the folder a name"); if (!folder.localPath || !isAbsolute(folder.localPath)) throw new Error("Choose a local folder"); const stats = await stat(folder.localPath).catch(() => null); if (!stats?.isDirectory()) throw new Error(`${folder.localPath} is not a folder`); checkServerUrl(folder.serverUrl); for (const other of others) { if (other.name === folder.name) throw new Error(`There is already a folder called “${folder.name}”`); if (overlaps(other.localPath, folder.localPath)) throw new Error(`${folder.localPath} overlaps “${other.name}” (${other.localPath})`); }}
/** Two sync roots must never contain one another: each would sync the other's changes back. */function overlaps(a: string, b: string): boolean { const ab = relative(a, b); const ba = relative(b, a); return ab === "" || (!ab.startsWith("..") && !isAbsolute(ab)) || (!ba.startsWith("..") && !isAbsolute(ba));}
function expandHome(path: string): string { return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;}