mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
Use Commonlib for self-hosted setup tools
This commit is contained in:
@@ -1,33 +1,26 @@
|
||||
#!/bin/bash
|
||||
if [[ -z "$hostname" ]]; then
|
||||
echo "ERROR: Hostname missing"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$username" ]]; then
|
||||
echo "ERROR: Username missing"
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v deno >/dev/null 2>&1; then
|
||||
echo "ERROR: Deno is required to run the Commonlib-backed CouchDB provisioning tool." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$password" ]]; then
|
||||
echo "ERROR: Password missing"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$node" ]]; then
|
||||
echo "INFO: defaulting to _local"
|
||||
node=_local
|
||||
script_url="${provision_script_url:-https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/provision.ts}"
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)"
|
||||
deno_dependency_options=()
|
||||
if [[ -n "$script_dir" && -f "$script_dir/provision.ts" ]]; then
|
||||
script_url="$script_dir/provision.ts"
|
||||
lockfile="$script_dir/../flyio/deno.lock"
|
||||
deno_config="$script_dir/../flyio/deno.jsonc"
|
||||
if [[ -f "$lockfile" && -f "$deno_config" ]]; then
|
||||
deno_dependency_options+=("--config=$deno_config" --frozen "--lock=$lockfile")
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "-- Configuring CouchDB by REST APIs... -->"
|
||||
|
||||
until (curl -X POST "${hostname}/_cluster_setup" -H "Content-Type: application/json" -d "{\"action\":\"enable_single_node\",\"username\":\"${username}\",\"password\":\"${password}\",\"bind_address\":\"0.0.0.0\",\"port\":5984,\"singlenode\":true}" --user "${username}:${password}"); do sleep 5; done
|
||||
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
|
||||
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd_auth/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
|
||||
until (curl -X PUT "${hostname}/_node/${node}/_config/httpd/WWW-Authenticate" -H "Content-Type: application/json" -d '"Basic realm=\"couchdb\""' --user "${username}:${password}"); do sleep 5; done
|
||||
until (curl -X PUT "${hostname}/_node/${node}/_config/httpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
|
||||
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
|
||||
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/max_http_request_size" -H "Content-Type: application/json" -d '"4294967296"' --user "${username}:${password}"); do sleep 5; done
|
||||
until (curl -X PUT "${hostname}/_node/${node}/_config/couchdb/max_document_size" -H "Content-Type: application/json" -d '"50000000"' --user "${username}:${password}"); do sleep 5; done
|
||||
until (curl -X PUT "${hostname}/_node/${node}/_config/cors/credentials" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
|
||||
until (curl -X PUT "${hostname}/_node/${node}/_config/cors/origins" -H "Content-Type: application/json" -d '"app://obsidian.md,capacitor://localhost,http://localhost"' --user "${username}:${password}"); do sleep 5; done
|
||||
|
||||
echo "<-- Configuring CouchDB by REST APIs Done!"
|
||||
exec deno run \
|
||||
--minimum-dependency-age=0 \
|
||||
"${deno_dependency_options[@]}" \
|
||||
--allow-env \
|
||||
--allow-net \
|
||||
"$script_url"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { provisionCouchDB } from "./provision.ts";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
Deno.test("configures CouchDB and delegates database-version initialisation", async () => {
|
||||
const requests: Array<{ url: string; method: string; body: string }> = [];
|
||||
const initialisations: Array<[string, string, string]> = [];
|
||||
await provisionCouchDB(
|
||||
{
|
||||
hostname: "https://couch.example.test/",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
database: "notes",
|
||||
retryCount: 1,
|
||||
retryDelayMs: 0,
|
||||
},
|
||||
{
|
||||
fetch: async (input, init) => {
|
||||
requests.push({
|
||||
url: String(input),
|
||||
method: init?.method ?? "GET",
|
||||
body: String(init?.body ?? ""),
|
||||
});
|
||||
return new Response("{}", { status: 201 });
|
||||
},
|
||||
sleep: async () => {},
|
||||
initialiseDatabaseVersion: async (...args) => {
|
||||
initialisations.push(args);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert(
|
||||
requests[0].url.endsWith("/_cluster_setup"),
|
||||
"cluster setup was not first",
|
||||
);
|
||||
assert(
|
||||
requests.some((request) =>
|
||||
request.url.endsWith("/_config/cors/origins") &&
|
||||
request.body.includes("app://obsidian.md")
|
||||
),
|
||||
"the Obsidian CORS origins were not configured",
|
||||
);
|
||||
assert(
|
||||
requests.at(-1)?.url === "https://couch.example.test/notes",
|
||||
"the requested database was not created last",
|
||||
);
|
||||
assert(
|
||||
initialisations.length === 1,
|
||||
"database-version initialisation was not delegated once",
|
||||
);
|
||||
assert(
|
||||
initialisations[0][0] === "https://couch.example.test/notes",
|
||||
"database-version initialisation used the wrong URL",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("leaves database creation to the client when no database is supplied", async () => {
|
||||
let initialised = false;
|
||||
await provisionCouchDB(
|
||||
{
|
||||
hostname: "http://127.0.0.1:5984",
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
retryCount: 1,
|
||||
retryDelayMs: 0,
|
||||
},
|
||||
{
|
||||
fetch: async () => new Response("{}", { status: 200 }),
|
||||
sleep: async () => {},
|
||||
initialiseDatabaseVersion: async () => {
|
||||
initialised = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert(
|
||||
!initialised,
|
||||
"database-version initialisation ran without a database",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/pouchdb/pouchdb-browser";
|
||||
import { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/pouchdb/negotiation";
|
||||
|
||||
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.");
|
||||
}
|
||||
Reference in New Issue
Block a user