// FeedPost Worker v2.0
export default {
async fetch(request, env) {
const url = new URL(request.url);
const path = url.pathname;
// 1. CORS PREFLIGHT (must be first, before auth)
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Admin-Key",
"Access-Control-Max-Age": "86400",
},
});
}
// 2. IMAGE PROXY (public, no auth — URL must be accessible by Buffer)
if (path.startsWith("/image/")) {
const key = decodeURIComponent(path.replace("/image/", ""));
const object = await env.BUCKET.get(key);
if (!object) return new Response("Not Found", { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
// Remove content-disposition so Buffer sees a hosted image, not a download
headers.delete("content-disposition");
headers.set("Access-Control-Allow-Origin", "*");
headers.set("Cache-Control", "public, max-age=86400");
return new Response(object.body, { headers });
}
// 3. API (requires X-Admin-Key header)
if (path.startsWith("/api/")) {
const auth = request.headers.get("X-Admin-Key");
if (auth !== env.ADMIN_KEY) {
return new Response("Unauthorized", { status: 401, headers: { "Access-Control-Allow-Origin": "*" } });
}
// Update base_host whenever it changes (e.g. custom domain added later)
const existingHost = await env.KV.get("settings:base_host");
if (existingHost !== url.host) {
await env.KV.put("settings:base_host", url.host);
}
const method = request.method;
if (path === "/api/outputs") {
if (method === "GET") return serveJson(JSON.parse(await env.KV.get("outputs") || "[]"));
if (method === "POST") { await env.KV.put("outputs", await request.text()); return serveJson({ success: true }); }
}
if (path === "/api/workflows") {
if (method === "GET") return serveJson(JSON.parse(await env.KV.get("workflows") || "[]"));
if (method === "POST") { await env.KV.put("workflows", await request.text()); return serveJson({ success: true }); }
}
if (path === "/api/list-stored") {
const workflowId = url.searchParams.get("workflow");
const list = await env.BUCKET.list({ prefix: `${workflowId}/` });
return serveJson(list.objects);
}
if (path === "/api/get-next-prefix") {
const workflowId = url.searchParams.get("workflow");
const list = await env.BUCKET.list({ prefix: `${workflowId}/` });
let max = 0;
list.objects.forEach(obj => {
const match = obj.key.match(/\/(\d{3})/);
if (match) max = Math.max(max, parseInt(match[1]));
});
return serveJson({ next: (max + 1).toString().padStart(3, "0") });
}
if (path === "/api/upload") {
const formData = await request.formData();
const workflowId = formData.get("workflow");
const prefix = formData.get("prefix");
const text = formData.get("text");
const image = formData.get("image");
await env.BUCKET.put(`${workflowId}/${prefix}.txt`, text, {
httpMetadata: { contentType: "text/plain" }
});
if (image && image.size > 0) {
// Store with correct MIME type so Buffer receives a proper image URL
await env.BUCKET.put(`${workflowId}/${prefix}.img`, image, {
httpMetadata: { contentType: image.type || "image/jpeg" }
});
}
return serveJson({ success: true });
}
if (path === "/api/delete-stored") {
const payload = await request.json();
await env.BUCKET.delete(payload.key);
const pair = payload.key.endsWith(".txt")
? payload.key.replace(".txt", ".img")
: payload.key.replace(".img", ".txt");
await env.BUCKET.delete(pair);
return serveJson({ success: true });
}
if (path === "/api/post-now") {
const payload = await request.json();
const res = await runWorkflow(payload.config, env, payload.item, url.host);
return serveJson({ result: res });
}
}
// 4. WEBHOOK INPUT (uses query key auth, not header)
if (path.startsWith("/webhook/")) {
const workflowId = path.split("/")[2];
if (url.searchParams.get("key") !== env.ADMIN_KEY) return new Response("Forbidden", { status: 403 });
const workflows = JSON.parse(await env.KV.get("workflows") || "[]");
const config = workflows.find(w => w.id === workflowId);
if (!config) return new Response("Not Found", { status: 404 });
const res = await runWorkflow(config, env, await request.json(), url.host);
return serveJson({ result: res });
}
return new Response("FeedPost Worker v2.0 Active", { status: 200 });
},
async scheduled(event, env) {
const workflows = JSON.parse(await env.KV.get("workflows") || "[]");
const baseHost = await env.KV.get("settings:base_host") || "";
for (const w of workflows) {
if (w.type === "rss") await handleRssWorkflow(w, env, baseHost);
if (w.type === "cycle") await handleCycleWorkflow(w, env, baseHost);
}
}
};
// --- SCHEDULED HANDLERS ---
async function handleRssWorkflow(config, env, baseHost) {
try {
const res = await fetch(config.source);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const item = parseXmlFeed(await res.text());
if (!item) return;
const lastId = await env.KV.get(`state:rss:${config.id}`);
if ((item.link || item.title) === lastId) return;
// Only advance state if at least one post succeeded
const results = await runWorkflow(config, env, item, baseHost);
if (results.some(r => r.success)) {
await env.KV.put(`state:rss:${config.id}`, item.link || item.title);
}
} catch (e) { console.error(`RSS Error [${config.name}]:`, e.message); }
}
async function handleCycleWorkflow(config, env, baseHost) {
const lastRun = parseInt(await env.KV.get(`state:run:${config.id}`) || "0");
const intervalMs = (parseInt(config.interval) || 1) * 3600000;
if (Date.now() - lastRun < intervalMs) return;
const list = await env.BUCKET.list({ prefix: `${config.id}/` });
const textFiles = list.objects
.filter(o => o.key.endsWith(".txt"))
.sort((a, b) => a.key.localeCompare(b.key));
if (textFiles.length === 0) return;
const idx = parseInt(await env.KV.get(`state:idx:${config.id}`) || "0");
const safeIdx = idx % textFiles.length;
// Guard against R2 returning null for a listed object (eventual consistency)
const textObj = await env.BUCKET.get(textFiles[safeIdx].key);
if (!textObj) return;
const text = await textObj.text();
const imgKey = textFiles[safeIdx].key.replace(".txt", ".img");
const hasImg = list.objects.find(o => o.key === imgKey);
const item = {
text,
image: (hasImg && baseHost) ? `https://${baseHost}/image/${encodeURIComponent(imgKey)}` : null
};
// Only advance state if at least one post succeeded
const results = await runWorkflow(config, env, item, baseHost);
if (results.some(r => r.success)) {
await env.KV.put(`state:idx:${config.id}`, (safeIdx + 1).toString());
await env.KV.put(`state:run:${config.id}`, Date.now().toString());
}
}
// --- CORE POST RUNNER ---
async function runWorkflow(config, env, item, baseHost) {
const outputs = JSON.parse(await env.KV.get("outputs") || "[]");
let body = `${config.header || ""} ${item.title || ""} ${item.text || ""} ${item.link || ""} ${config.footer || ""}`;
body = decodeEntities(body.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
if (config.maxLen) body = body.slice(0, parseInt(config.maxLen));
const results = [];
const destinations = config.destinations || [];
for (const outId of destinations) {
const out = outputs.find(o => o.id === outId);
if (!out) continue;
const success = await postToBuffer(body, item.image || null, out.profileId, out.token);
results.push({ name: out.name, success });
}
return results;
}
// --- BUFFER API ---
async function postToBuffer(text, imageUrl, profileId, token) {
const input = { channelId: profileId, text, schedulingType: "automatic", mode: "shareNow" };
if (imageUrl) input.assets = [{ image: { url: imageUrl } }];
try {
const res = await fetch("https://api.buffer.com/graphql", {
method: "POST",
headers: { "Authorization": "Bearer " + token, "Content-Type": "application/json" },
body: JSON.stringify({
query: `mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
... on PostActionSuccess { post { id } }
... on MutationError { message }
}
}`,
variables: { input }
})
});
const data = await res.json();
return !!data.data?.createPost?.post?.id;
} catch (e) { return false; }
}
// --- FEED PARSING ---
function parseXmlFeed(xml) {
const itemMatch = xml.match(/<(item|entry)>([\s\S]*?)<\/\1>/i);
if (!itemMatch) return null;
const block = itemMatch[2];
const getTag = (tags) => {
for (const t of tags) {
const re = new RegExp(
"<" + t.replace(":", "\\:") + "[^>]*>([\\s\\S]*?)<\\/" + t.replace(":", "\\:") + ">", "i"
);
const val = block.match(re);
if (val) return val[1].replace(//g, "$1").trim();
}
return "";
};
const link = (
block.match(/]+rel=["']alternate["'][^>]+href=["']([^"']+)["']/i) ||
block.match(/]+href=["']([^"']+)["']/i) ||
block.match(/([^<]+)<\/link>/i) ||
["", ""]
)[1];
const desc = getTag(["description", "summary", "content"]);
const imgMatch =
block.match(/]+url="([^"]+)"/i) ||
block.match(/]+url="([^"]+)"/i) ||
desc.match(/src="([^"]+)"/i);
return {
title: getTag(["title"]),
text: desc,
link,
image: imgMatch ? imgMatch[1] : null
};
}
// --- UTILITIES ---
function decodeEntities(s) {
if (!s) return "";
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/ /g, " ")
.replace(/(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
.replace(/([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)));
}
function serveJson(obj) {
return new Response(JSON.stringify(obj), {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
});
}