Files
MMM-NextcloudCookbook/lib/nextcloud-public-share.js

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;