Add Nextcloud recipe display and controller
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user