// 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(/&#x([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": "*" } }); }