18/8/26 see feedwrite_readme.txt for licence, setup instructions, and terms of use. // ===================================================================== // FEEDWRITE v1.3 - Scheduled Sequence Poster (Text + Image Slots) // ===================================================================== // ===================================================================== // CLOUDFLARE WORKER (single file -- includes the built-in Admin UI) // Save as worker.js and deploy via the Cloudflare Workers dashboard. // // Bindings required (set in the Worker's Settings > Variables/Bindings): // FEEDWRITE_KV -- KV Namespace binding (process config + schedule state) // BUCKET -- R2 bucket binding (stores slot text/images + library) // ADMIN_KEY -- Secret (password for the Admin UI / API) // FEEDPOST -- (optional) Service Binding to another Worker, if your // webhook receiver is also a Cloudflare Worker in the // same account. Not required -- FeedWrite falls back to // a normal fetch() if this binding isn't present. // // Also add a Cron Trigger (e.g. 0 * * * * for hourly) so scheduled // processes are checked and posted on time. // ===================================================================== // FeedWrite Worker v1.3 // ═══════════════════════════════════════════════════════════════════════════ // Changes in v1.3 (code review nice-to-haves): // - Slot prefixes are now generated with crypto.randomUUID() instead of // Date.now().toString(), avoiding a collision if "Add Slot" is clicked // twice in quick succession. // - Deleting a slot now also removes its prefix from state:slot_order, // instead of leaving it for the next /api/slots call to self-heal. // - The frontend req() helper no longer assumes every successful response // is JSON, avoiding a crash on empty/204 responses. // - The library upload file input now clears after a successful upload. // - Process names, folder names, webhook URLs, slot text previews, and // library keys are now HTML-escaped before being inserted into the // admin UI, reducing the risk of stored content injecting markup. // ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════ // Changes in v1.1: // - Deleting a process now also deletes its slots from R2 and its // KV sequence-index/order state, via a new /api/delete-process endpoint. // - Slots can now be reordered (Up/Down in the UI). Order is tracked in // KV (state:slot_order:) and self-heals: new slots append // to the end automatically, deleted slots drop out automatically. // ═══════════════════════════════════════════════════════════════════════════ // New standalone tool: stores paired text+image "slots" and posts them in // sequence, at a configurable interval, by calling an existing FeedPost // webhook-trigger process (via Service Binding — not a raw fetch, since // same-account workers.dev-to-workers.dev fetch calls are unreliable on the // free tier). FeedWrite never posts to Buffer directly; FeedPost's existing // webhook-trigger process type does that. // // Bindings required: // env.FEEDWRITE_KV - KV namespace for process config + schedule state // env.BUCKET - R2 bucket ("content-library"), shared across tools // env.FEEDPOST - Service Binding to the FeedPost worker // env.ADMIN_KEY - this worker's own admin key (secret) // // R2 key layout in the shared content-library bucket: // library// - reusable picker images (e.g. library/acorn/xyz.jpg) // slots//.txt - queued post text (read-only once uploaded) // slots//.img - queued post image (uploaded fresh or copied from library) // // Sequence behaviour: loops through a process's slots in order, one per // scheduled tick, wrapping back to the start indefinitely (matches FeedPost's // old handleSequence pattern) — slots are never auto-deleted after posting. // ═══════════════════════════════════════════════════════════════════════════ const DEFAULT_INTERVAL_HOURS = 24; // Guards against path traversal / malformed keys when client-supplied // id/prefix values are used to build R2 keys. function isSafeId(s) { return typeof s === "string" && s.length > 0 && !s.includes("/") && !s.includes(".."); } export default { async fetch(request, env) { const url = new URL(request.url), path = url.pathname; const respond = (data, status = 200, type = "application/json") => { const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, X-Admin-Key", }; headers["Content-Type"] = type; const body = (status === 204 || status === 304) ? null : (type === "application/json" ? JSON.stringify(data) : data); return new Response(body, { status, headers }); }; if (request.method === "OPTIONS") return respond(null, 204); // Public image proxy (serves both library/ and slots/ prefixes) if (path.startsWith("/image/")) { const key = decodeURIComponent(path.replace("/image/", "")); const object = await env.BUCKET.get(key); if (!object) return respond("Not Found", 404, "text/plain"); const h = new Headers(); object.writeHttpMetadata(h); h.set("Access-Control-Allow-Origin", "*"); return new Response(object.body, { headers: h }); } if (path.startsWith("/api/")) { const auth = request.headers.get("X-Admin-Key"); if (auth !== env.ADMIN_KEY) return respond({ error: "Unauthorized" }, 401); const storedHost = await env.FEEDWRITE_KV.get("settings:base_host"); if (storedHost !== url.host) await env.FEEDWRITE_KV.put("settings:base_host", url.host); if (path === "/api/processes") { if (request.method === "GET") return respond(JSON.parse(await env.FEEDWRITE_KV.get("processes") || "[]")); const raw = await request.text(); let parsed; try { parsed = JSON.parse(raw); } catch (e) { return respond({ error: "Invalid JSON" }, 400); } if (!Array.isArray(parsed)) return respond({ error: "Processes must be an array" }, 400); await env.FEEDWRITE_KV.put("processes", JSON.stringify(parsed)); return respond({ success: true }); } // ---- Shared image library (folder-scoped) ---- if (path === "/api/library") { const folder = url.searchParams.get("folder") || ""; if (request.method === "GET") { if (!folder) return respond({ error: "folder required" }, 400); const list = await env.BUCKET.list({ prefix: `library/${folder}/` }); const files = list.objects.map(o => ({ key: o.key, url: `https://${url.host}/image/${encodeURIComponent(o.key)}` })); return respond(files); } if (request.method === "POST") { const fd = await request.formData(), img = fd.get("image"), fol = fd.get("folder"); if (!img || img.size === 0) return respond({ error: "No image" }, 400); if (!fol) return respond({ error: "No folder" }, 400); const key = `library/${fol}/${Date.now()}_${img.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`; await env.BUCKET.put(key, img, { httpMetadata: { contentType: img.type } }); return respond({ success: true }); } if (request.method === "DELETE") { const { key } = await request.json(); if (key.startsWith("library/")) await env.BUCKET.delete(key); return respond({ success: true }); } } // ---- Slots (queued sequence content per process) ---- if (path === "/api/slots") { const pid = url.searchParams.get("id"); if (!isSafeId(pid)) return respond({ error: "Invalid id" }, 400); const list = await env.BUCKET.list({ prefix: `slots/${pid}/` }); const slots = {}; const txtKeys = []; for (const o of list.objects) { const pre = o.key.split("/")[2].split(".")[0]; if (!slots[pre]) slots[pre] = { prefix: pre, hasText: false, hasImage: false, textPreview: "", imageUrl: "" }; if (o.key.endsWith(".txt")) { slots[pre].hasText = true; txtKeys.push({ pre, key: o.key }); } if (o.key.endsWith(".img")) { slots[pre].hasImage = true; slots[pre].imageUrl = `https://${url.host}/image/${encodeURIComponent(o.key)}`; } } await Promise.all(txtKeys.map(async ({ pre, key }) => { const obj = await env.BUCKET.get(key); const txt = obj ? await obj.text() : ""; slots[pre].textPreview = txt.length > 80 ? txt.slice(0, 80) + '…' : txt; })); const order = await getOrderedPrefixes(env, pid, list); return respond(order.map(pre => slots[pre]).filter(Boolean)); } if (path === "/api/reorder-slots") { const { id, order } = await request.json(); if (!isSafeId(id) || !Array.isArray(order) || !order.every(isSafeId)) return respond({ error: "Invalid id or order" }, 400); await env.FEEDWRITE_KV.put(`state:slot_order:${id}`, JSON.stringify(order)); return respond({ success: true }); } if (path === "/api/upload-slot") { const fd = await request.formData(); const pid = fd.get("id"), prefix = fd.get("prefix") || Date.now().toString(); const textFile = fd.get("textFile"), img = fd.get("image"), libraryKey = fd.get("libraryKey"); if (!isSafeId(pid) || !isSafeId(prefix)) return respond({ error: "Invalid id or prefix" }, 400); if (textFile && textFile.size > 0) { await env.BUCKET.put(`slots/${pid}/${prefix}.txt`, await textFile.text()); } if (img && img.size > 0) { await env.BUCKET.put(`slots/${pid}/${prefix}.img`, img, { httpMetadata: { contentType: img.type } }); } else if (libraryKey) { const libObj = await env.BUCKET.get(libraryKey); if (!libObj) return respond({ error: "Library image not found" }, 404); await env.BUCKET.put(`slots/${pid}/${prefix}.img`, libObj.body, { httpMetadata: libObj.httpMetadata }); } return respond({ success: true }); } if (path === "/api/delete-slot") { const { id, prefix } = await request.json(); if (!isSafeId(id) || !isSafeId(prefix)) return respond({ error: "Invalid id or prefix" }, 400); await env.BUCKET.delete([`slots/${id}/${prefix}.txt`, `slots/${id}/${prefix}.img`]); const orderStr = await env.FEEDWRITE_KV.get(`state:slot_order:${id}`); if (orderStr) { const order = JSON.parse(orderStr).filter(p => p !== prefix); await env.FEEDWRITE_KV.put(`state:slot_order:${id}`, JSON.stringify(order)); } return respond({ success: true }); } if (path === "/api/delete-process") { const { id } = await request.json(); if (!isSafeId(id)) return respond({ error: "Invalid id" }, 400); const processes = JSON.parse(await env.FEEDWRITE_KV.get("processes") || "[]").filter(p => p.id !== id); await env.FEEDWRITE_KV.put("processes", JSON.stringify(processes)); const list = await env.BUCKET.list({ prefix: `slots/${id}/` }); if (list.objects.length) await env.BUCKET.delete(list.objects.map(o => o.key)); await env.FEEDWRITE_KV.delete(`state:sequence:index:${id}`); await env.FEEDWRITE_KV.delete(`state:slot_order:${id}`); return respond({ success: true, processes }); } if (path === "/api/run-process") { const { id } = await request.json(); const proc = JSON.parse(await env.FEEDWRITE_KV.get("processes") || "[]").find(p => p.id === id); if (!proc) return respond({ error: "Not found" }, 404); return respond({ result: await runSequence(proc, env, url.host, true) }); } return respond({ error: "Not Found" }, 404); } return respond(renderUI(), 200, "text/html;charset=UTF-8"); }, async scheduled(event, env) { const host = await env.FEEDWRITE_KV.get("settings:base_host") || ""; const processes = JSON.parse(await env.FEEDWRITE_KV.get("processes") || "[]"); const now = Date.now(); for (const proc of processes) { if (!proc.schedule || !proc.schedule.startAt) continue; const startAtMs = new Date(proc.schedule.startAt).getTime(); if (now < startAtMs) continue; const intervalMs = (parseFloat(proc.schedule.intervalHours) || DEFAULT_INTERVAL_HOURS) * 3600000; const currentPeriod = Math.floor((now - startAtMs) / intervalMs); const lastPeriodStr = await env.FEEDWRITE_KV.get(`state:period:${proc.id}:${proc.schedule.startAt}`); const lastPeriod = lastPeriodStr ? parseInt(lastPeriodStr) : -1; if (currentPeriod <= lastPeriod) continue; const res = await runSequence(proc, env, host, false); if (res && res.success) { await env.FEEDWRITE_KV.put(`state:period:${proc.id}:${proc.schedule.startAt}`, currentPeriod.toString()); } } } }; // Returns slot prefixes for a process in persisted display/posting order, // self-healing by appending any new prefixes at the end and dropping any // that no longer exist in R2 (persisting the corrected order back to KV). async function getOrderedPrefixes(env, pid, list) { const allPrefixes = [...new Set(list.objects.map(o => o.key.split("/")[2].split(".")[0]))]; const orderStr = await env.FEEDWRITE_KV.get(`state:slot_order:${pid}`); let order = orderStr ? JSON.parse(orderStr) : []; order = order.filter(p => allPrefixes.includes(p)); const missing = allPrefixes.filter(p => !order.includes(p)).sort(); order = order.concat(missing); if (orderStr !== JSON.stringify(order)) { await env.FEEDWRITE_KV.put(`state:slot_order:${pid}`, JSON.stringify(order)); } return order; } // Advances one step through a process's slot sequence and posts it to // FeedPost's webhook-trigger endpoint via Service Binding. async function runSequence(proc, env, host, isTest) { try { if (!proc.webhookUrl) return { success: false, error: "No webhook URL configured for this process." }; const list = await env.BUCKET.list({ prefix: `slots/${proc.id}/` }); const prefixes = await getOrderedPrefixes(env, proc.id, list); if (!prefixes.length) return { success: false, error: "No slots uploaded for this process." }; const curIdx = parseInt(await env.FEEDWRITE_KV.get(`state:sequence:index:${proc.id}`) || "0"); const prefix = prefixes[curIdx % prefixes.length]; const txtObj = await env.BUCKET.get(`slots/${proc.id}/${prefix}.txt`); const hasImg = list.objects.find(o => o.key === `slots/${proc.id}/${prefix}.img`); const title = txtObj ? (await txtObj.text()).trim() : ""; const imageUrl = hasImg ? `https://${host}/image/${encodeURIComponent(`slots/${proc.id}/${prefix}.img`)}` : ""; if (!title) return { success: false, error: `Slot ${prefix} has no text.` }; const payload = { title, imageUrl }; let whUrl; try { const u = new URL(proc.webhookUrl); if (proc.webhookSecret) u.searchParams.set("key", proc.webhookSecret); whUrl = u.toString(); } catch (e) { return { success: false, error: "Invalid webhook URL." }; } let whRes; try { whRes = env.FEEDPOST ? await env.FEEDPOST.fetch(new Request(whUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })) : await fetch(whUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); } catch (e) { return { success: false, error: `Webhook call failed: ${e.message}` }; } if (whRes.ok) { await env.FEEDWRITE_KV.put(`state:sequence:index:${proc.id}`, (curIdx + 1).toString()); } return { success: whRes.ok, slot: prefix, title, imageUrl, webhookStatus: whRes.status }; } catch (e) { return { success: false, error: e.message }; } } // ─── INTEGRATED ADMIN UI ──────────────────────────────────────────────────── function renderUI() { return ` FeedWrite v1.3

FeedWrite v1.3

`; }