106 lines
3.5 KiB
JavaScript
106 lines
3.5 KiB
JavaScript
const { XMLParser } = require("fast-xml-parser");
|
|
|
|
class NextcloudPublicShare {
|
|
constructor({ shareUrl, password = "", fetchImpl = global.fetch }) {
|
|
if (!shareUrl) throw new Error("Nextcloud Cookbook share URL is not configured");
|
|
if (typeof fetchImpl !== "function") throw new Error("A fetch implementation is required");
|
|
|
|
const parsed = new URL(shareUrl);
|
|
const tokenMatch = parsed.pathname.match(/\/s\/([^/]+)/);
|
|
if (!tokenMatch) throw new Error("Nextcloud public share URL must contain /s/<token>");
|
|
|
|
this.origin = parsed.origin;
|
|
this.token = decodeURIComponent(tokenMatch[1]);
|
|
this.password = password;
|
|
this.fetch = fetchImpl;
|
|
this.webdavUrl = new URL(`/public.php/dav/files/${encodeURIComponent(this.token)}/`, this.origin);
|
|
this.parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true });
|
|
}
|
|
|
|
get headers() {
|
|
if (!this.password) return {};
|
|
return { Authorization: `Basic ${Buffer.from(`anonymous:${this.password}`).toString("base64")}` };
|
|
}
|
|
|
|
async listRecipes() {
|
|
const response = await this.fetch(this.webdavUrl, {
|
|
method: "PROPFIND",
|
|
headers: {
|
|
...this.headers,
|
|
Depth: "infinity",
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
"Content-Type": "application/xml; charset=utf-8"
|
|
},
|
|
body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:resourcetype/></d:prop></d:propfind>"
|
|
});
|
|
if (!response.ok && response.status !== 207) {
|
|
throw new Error(`Nextcloud recipe listing failed with HTTP ${response.status}`);
|
|
}
|
|
|
|
const parsed = this.parser.parse(await response.text());
|
|
const responses = this.asArray(parsed.multistatus?.response);
|
|
const recipePaths = responses
|
|
.map((entry) => decodeURIComponent(entry.href || ""))
|
|
.filter((href) => href.toLowerCase().endsWith("/recipe.json"));
|
|
|
|
const recipes = await this.mapWithConcurrency(recipePaths, 6, async (path) => {
|
|
const recipe = await this.fetchJson(path);
|
|
return {
|
|
id: Buffer.from(path).toString("base64url"),
|
|
path,
|
|
name: recipe.name || this.folderName(path),
|
|
description: recipe.description || ""
|
|
};
|
|
});
|
|
return recipes.sort((left, right) => left.name.localeCompare(right.name));
|
|
}
|
|
|
|
async getRecipe(id) {
|
|
let path;
|
|
try {
|
|
path = Buffer.from(id, "base64url").toString("utf8");
|
|
} catch {
|
|
throw new Error("Invalid recipe identifier");
|
|
}
|
|
if (!path.startsWith(this.webdavUrl.pathname) || !path.toLowerCase().endsWith("/recipe.json")) {
|
|
throw new Error("Invalid recipe identifier");
|
|
}
|
|
return this.fetchJson(path);
|
|
}
|
|
|
|
async fetchJson(path) {
|
|
const response = await this.fetch(new URL(path, this.origin), {
|
|
headers: this.headers
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Nextcloud recipe fetch failed with HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
folderName(path) {
|
|
const parts = path.split("/").filter(Boolean);
|
|
return parts.length > 1 ? parts.at(-2) : "Untitled recipe";
|
|
}
|
|
|
|
asArray(value) {
|
|
if (!value) return [];
|
|
return Array.isArray(value) ? value : [value];
|
|
}
|
|
|
|
async mapWithConcurrency(items, limit, mapper) {
|
|
const results = new Array(items.length);
|
|
let next = 0;
|
|
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
while (next < items.length) {
|
|
const index = next++;
|
|
results[index] = await mapper(items[index]);
|
|
}
|
|
});
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|
|
}
|
|
|
|
module.exports = NextcloudPublicShare;
|