mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
250 lines
7.0 KiB
TypeScript
250 lines
7.0 KiB
TypeScript
import { checkRemoteVersion, PouchDB } from "./livesync-commonlib.ts";
|
|
|
|
export interface CouchDBProvisioningOptions {
|
|
hostname: string;
|
|
username: string;
|
|
password: string;
|
|
node?: string;
|
|
database?: string;
|
|
origins?: string;
|
|
retryCount?: number;
|
|
retryDelayMs?: number;
|
|
}
|
|
|
|
interface ProvisioningDependencies {
|
|
fetch: typeof fetch;
|
|
sleep: (milliseconds: number) => Promise<void>;
|
|
initialiseDatabaseVersion: (
|
|
databaseURL: string,
|
|
username: string,
|
|
password: string,
|
|
) => Promise<void>;
|
|
}
|
|
|
|
const DEFAULT_ORIGINS =
|
|
"app://obsidian.md,capacitor://localhost,http://localhost";
|
|
|
|
function requireValue(value: string, name: string): string {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) throw new Error(`${name} is required`);
|
|
return trimmed;
|
|
}
|
|
|
|
function normaliseHostname(hostname: string): string {
|
|
const parsed = new URL(requireValue(hostname, "hostname"));
|
|
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
parsed.search = "";
|
|
parsed.hash = "";
|
|
return parsed.href.replace(/\/$/, "");
|
|
}
|
|
|
|
function validateDatabaseName(database: string): string {
|
|
const trimmed = database.trim();
|
|
if (!/^[a-z][a-z0-9_$()+-]*$/.test(trimmed)) {
|
|
throw new Error(
|
|
"database must begin with a lower-case letter and contain only lower-case letters, digits, _, $, (, ), +, or -",
|
|
);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function basicAuthorisation(username: string, password: string): string {
|
|
return `Basic ${btoa(`${username}:${password}`)}`;
|
|
}
|
|
|
|
async function requestWithRetry(
|
|
dependencies: ProvisioningDependencies,
|
|
label: string,
|
|
url: string,
|
|
init: RequestInit,
|
|
accept: (response: Response, body: string) => boolean,
|
|
retryCount: number,
|
|
retryDelayMs: number,
|
|
): Promise<void> {
|
|
let lastError: unknown;
|
|
for (let attempt = 1; attempt <= retryCount; attempt++) {
|
|
try {
|
|
const response = await dependencies.fetch(url, init);
|
|
const body = await response.text();
|
|
if (accept(response, body)) return;
|
|
const error = new Error(
|
|
`${label} failed with HTTP ${response.status}: ${body}`,
|
|
);
|
|
if (response.status < 500) throw error;
|
|
lastError = error;
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (
|
|
error instanceof Error &&
|
|
error.message.startsWith(`${label} failed with HTTP 4`)
|
|
) {
|
|
throw error;
|
|
}
|
|
}
|
|
if (attempt < retryCount) await dependencies.sleep(retryDelayMs);
|
|
}
|
|
throw lastError instanceof Error
|
|
? lastError
|
|
: new Error(`${label} failed after ${retryCount} attempts`);
|
|
}
|
|
|
|
export async function initialiseLiveSyncDatabaseVersion(
|
|
databaseURL: string,
|
|
username: string,
|
|
password: string,
|
|
): Promise<void> {
|
|
const database = new PouchDB(databaseURL, {
|
|
adapter: "http",
|
|
auth: { username, password },
|
|
skip_setup: true,
|
|
});
|
|
try {
|
|
const compatible = await checkRemoteVersion(
|
|
database,
|
|
async () => false,
|
|
);
|
|
if (!compatible) {
|
|
throw new Error(
|
|
"the remote database uses an incompatible LiveSync database version",
|
|
);
|
|
}
|
|
} finally {
|
|
await database.close();
|
|
}
|
|
}
|
|
|
|
export async function provisionCouchDB(
|
|
options: CouchDBProvisioningOptions,
|
|
overrides: Partial<ProvisioningDependencies> = {},
|
|
): Promise<void> {
|
|
const hostname = normaliseHostname(options.hostname);
|
|
const username = requireValue(options.username, "username");
|
|
const password = requireValue(options.password, "password");
|
|
const node = encodeURIComponent(options.node?.trim() || "_local");
|
|
const origins = options.origins?.trim() || DEFAULT_ORIGINS;
|
|
const retryCount = options.retryCount ?? 12;
|
|
const retryDelayMs = options.retryDelayMs ?? 5_000;
|
|
if (!Number.isInteger(retryCount) || retryCount < 1) {
|
|
throw new Error("retryCount must be a positive integer");
|
|
}
|
|
if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
|
|
throw new Error("retryDelayMs must be zero or greater");
|
|
}
|
|
|
|
const dependencies: ProvisioningDependencies = {
|
|
fetch,
|
|
sleep: (milliseconds) =>
|
|
new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
initialiseDatabaseVersion: initialiseLiveSyncDatabaseVersion,
|
|
...overrides,
|
|
};
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
Authorization: basicAuthorisation(username, password),
|
|
};
|
|
const configure = async (
|
|
label: string,
|
|
path: string,
|
|
body: string,
|
|
method = "PUT",
|
|
accept: (response: Response, body: string) => boolean = (response) =>
|
|
response.ok,
|
|
) =>
|
|
await requestWithRetry(
|
|
dependencies,
|
|
label,
|
|
`${hostname}${path}`,
|
|
{ method, headers, body },
|
|
accept,
|
|
retryCount,
|
|
retryDelayMs,
|
|
);
|
|
|
|
await configure(
|
|
"single-node cluster setup",
|
|
"/_cluster_setup",
|
|
JSON.stringify({
|
|
action: "enable_single_node",
|
|
username,
|
|
password,
|
|
bind_address: "0.0.0.0",
|
|
port: 5984,
|
|
singlenode: true,
|
|
}),
|
|
"POST",
|
|
(response, body) =>
|
|
response.ok ||
|
|
((response.status === 400 || response.status === 409) &&
|
|
/already|finished/i.test(body)),
|
|
);
|
|
|
|
const settings: Array<[string, string, string]> = [
|
|
["require authenticated HTTP users", "chttpd/require_valid_user", '"true"'],
|
|
[
|
|
"require authenticated HTTP users for authentication",
|
|
"chttpd_auth/require_valid_user",
|
|
'"true"',
|
|
],
|
|
[
|
|
"set the HTTP authentication challenge",
|
|
"httpd/WWW-Authenticate",
|
|
'"Basic realm=\\"couchdb\\""',
|
|
],
|
|
["enable HTTP CORS", "httpd/enable_cors", '"true"'],
|
|
["enable clustered HTTP CORS", "chttpd/enable_cors", '"true"'],
|
|
[
|
|
"set the maximum HTTP request size",
|
|
"chttpd/max_http_request_size",
|
|
'"4294967296"',
|
|
],
|
|
[
|
|
"set the maximum document size",
|
|
"couchdb/max_document_size",
|
|
'"50000000"',
|
|
],
|
|
["enable CORS credentials", "cors/credentials", '"true"'],
|
|
["set allowed CORS origins", "cors/origins", JSON.stringify(origins)],
|
|
];
|
|
for (const [label, key, body] of settings) {
|
|
await configure(label, `/_node/${node}/_config/${key}`, body);
|
|
}
|
|
|
|
if (options.database?.trim()) {
|
|
const database = validateDatabaseName(options.database);
|
|
const databaseURL = `${hostname}/${encodeURIComponent(database)}`;
|
|
await requestWithRetry(
|
|
dependencies,
|
|
"create database",
|
|
databaseURL,
|
|
{ method: "PUT", headers },
|
|
(response) => response.ok || response.status === 412,
|
|
retryCount,
|
|
retryDelayMs,
|
|
);
|
|
await dependencies.initialiseDatabaseVersion(
|
|
databaseURL,
|
|
username,
|
|
password,
|
|
);
|
|
}
|
|
}
|
|
|
|
function optionalNumber(name: string): number | undefined {
|
|
const value = Deno.env.get(name)?.trim();
|
|
return value ? Number(value) : undefined;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
await provisionCouchDB({
|
|
hostname: Deno.env.get("hostname") ?? "",
|
|
username: Deno.env.get("username") ?? "",
|
|
password: Deno.env.get("password") ?? "",
|
|
node: Deno.env.get("node"),
|
|
database: Deno.env.get("database"),
|
|
origins: Deno.env.get("origins"),
|
|
retryCount: optionalNumber("retry_count"),
|
|
retryDelayMs: optionalNumber("retry_delay_ms"),
|
|
});
|
|
console.log("CouchDB provisioning completed.");
|
|
}
|