// ===================================================== // 🔐 PASSWORD LOGIN — GANTI DI SINI const ADMIN_PASSWORD = "admin123"; // ===================================================== const COOKIE = "auth"; const UI_HTML = ` Cron URL Manager
`; const LOGIN_HTML = (error = "") => ` Login

🔒 Cron URL Manager

${error ? `
${error}
` : ""}
`; // ============ URL NORMALIZER ============ function normalizeUrl(input) { if (!input) return null; let u = String(input).trim().replace(/\s+/g, ""); if (!u) return null; if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) u = "https://" + u; try { const parsed = new URL(u); if (!parsed.hostname) return null; if ( parsed.hostname !== "localhost" && !parsed.hostname.includes(".") && !/^\d+\.\d+\.\d+\.\d+$/.test(parsed.hostname) ) return null; if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; return parsed.toString(); } catch { return null; } } // ============ NAME FORMATTER ============ function formatName(pattern, url, index) { let u; try { u = new URL(url); } catch { return url; } if (!pattern || !pattern.trim()) { const path = u.pathname && u.pathname !== "/" ? u.pathname : ""; return u.hostname + path; } const now = new Date(); const pad = (n) => String(n).padStart(2, "0"); return pattern .replace(/\{host\}/g, u.hostname) .replace(/\{path\}/g, u.pathname) .replace(/\{query\}/g, u.search) .replace(/\{n\}/g, String(index + 1)) .replace(/\{date\}/g, `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`) .replace(/\{time\}/g, `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`) .replace(/\{rand\}/g, Math.random().toString(36).slice(2, 6)) .trim() || u.hostname; } // ============ CRON MATCHER ============ function fieldMatch(field, value, min, max) { if (field === "*" || field === "?") return true; for (const part of field.split(",")) { let step = 1; let range = part; if (part.includes("/")) { const [r, s] = part.split("/"); range = r; step = parseInt(s, 10); if (!step || step < 1) continue; } let start, end; if (range === "*" || range === "") { start = min; end = max; } else if (range.includes("-")) { const [a, b] = range.split("-").map(Number); start = a; end = b; } else { start = end = parseInt(range, 10); } for (let i = start; i <= end; i += step) if (i === value) return true; } return false; } function cronMatches(expr, date) { const p = expr.trim().split(/\s+/); if (p.length !== 5) return false; return ( fieldMatch(p[0], date.getUTCMinutes(), 0, 59) && fieldMatch(p[1], date.getUTCHours(), 0, 23) && fieldMatch(p[2], date.getUTCDate(), 1, 31) && fieldMatch(p[3], date.getUTCMonth() + 1, 1, 12) && fieldMatch(p[4], date.getUTCDay(), 0, 6) ); } function minuteKey(date) { return date.toISOString().slice(0, 16); } // ============ AUTH ============ async function sha256(text) { const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); } async function checkAuth(request) { const cookie = request.headers.get("Cookie") || ""; const m = cookie.match(new RegExp(COOKIE + "=([^;]+)")); if (!m) return false; return m[1] === (await sha256(ADMIN_PASSWORD)); } // ============ KV HELPERS ============ const DEFAULT_CONFIG = { mode: "interval", interval_minutes: 30, cron_expression: "0 * * * *", enabled: true, last_run: 0, last_run_minute: "", }; const getConfig = async (env) => ({ ...DEFAULT_CONFIG, ...JSON.parse((await env.KV.get("config")) || "{}"), }); const getUrls = async (env) => JSON.parse((await env.KV.get("urls")) || "[]"); const getLogs = async (env) => JSON.parse((await env.KV.get("logs")) || "[]"); // ============ RUN ============ async function runNow(env, trigger = "manual") { const urls = (await getUrls(env)).filter((u) => u.enabled); const results = []; for (const u of urls) { try { const res = await fetch(u.url, { method: "GET", redirect: "follow", headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", Accept: "text/html,application/xhtml+xml,*/*;q=0.8", "Accept-Language": "id-ID,id;q=0.9,en;q=0.8", }, }); results.push({ id: u.id, url: u.url, status: res.status, ok: res.ok }); } catch (e) { results.push({ id: u.id, url: u.url, status: 0, ok: false, error: e.message }); } } const config = await getConfig(env); config.last_run = Date.now(); config.last_run_minute = minuteKey(new Date()); await env.KV.put("config", JSON.stringify(config)); const logs = await getLogs(env); logs.unshift({ ts: Date.now(), trigger, results }); await env.KV.put("logs", JSON.stringify(logs.slice(0, 20))); return { ok: true, results }; } // ============ API ============ async function handleApi(request, env, url) { const path = url.pathname; const method = request.method; const json = (data, status = 200) => new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" }, }); if (path === "/api/state" && method === "GET") { const [config, urls, logs] = await Promise.all([ getConfig(env), getUrls(env), getLogs(env), ]); return json({ config, urls, logs }); } if (path === "/api/config" && method === "POST") { const body = await request.json(); const config = await getConfig(env); if (body.mode === "cron" || body.mode === "interval") config.mode = body.mode; if (body.interval_minutes !== undefined) config.interval_minutes = Math.max(1, parseInt(body.interval_minutes) || 30); if (body.cron_expression !== undefined) config.cron_expression = String(body.cron_expression).trim().slice(0, 100); if (body.enabled !== undefined) config.enabled = !!body.enabled; await env.KV.put("config", JSON.stringify(config)); return json({ ok: true, config }); } if (path === "/api/urls" && method === "POST") { const body = await request.json(); const normalized = normalizeUrl(body.url); if (!normalized) return json({ error: "URL tidak valid" }, 400); const urls = await getUrls(env); urls.push({ id: crypto.randomUUID(), name: (body.name || "").trim() || normalized, url: normalized, enabled: true, }); await env.KV.put("urls", JSON.stringify(urls)); return json({ ok: true, urls }); } // ============ BULK ADD ============ if (path === "/api/urls/bulk" && method === "POST") { const body = await request.json(); const list = Array.isArray(body.urls) ? body.urls : []; const pattern = String(body.pattern || "").trim(); if (list.length === 0) return json({ error: "Tidak ada URL dikirim" }, 400); if (list.length > 500) return json({ error: "Maks 500 URL per bulk (dikirim " + list.length + ")" }, 400); const urls = await getUrls(env); const existing = new Set(urls.map((u) => u.url)); let added = 0, skipped = 0; const errors = []; for (let i = 0; i < list.length; i++) { const raw = String(list[i]).trim(); if (!raw) continue; const normalized = normalizeUrl(raw); if (!normalized) { skipped++; errors.push("Invalid: " + raw.slice(0, 80)); continue; } if (existing.has(normalized)) { skipped++; errors.push("Duplikat: " + normalized.slice(0, 80)); continue; } urls.push({ id: crypto.randomUUID(), name: formatName(pattern, normalized, i), url: normalized, enabled: true, }); existing.add(normalized); added++; } await env.KV.put("urls", JSON.stringify(urls)); return json({ ok: true, added, skipped, errors: errors.slice(0, 20), urls, }); } const m = path.match(/^\/api\/urls\/([^/]+)$/); if (m) { const id = m[1]; const urls = await getUrls(env); const i = urls.findIndex((u) => u.id === id); if (i === -1) return json({ error: "Tidak ditemukan" }, 404); if (method === "PUT") { const body = await request.json(); const patch = { ...body }; if (patch.url !== undefined) { const normalized = normalizeUrl(patch.url); if (!normalized) return json({ error: "URL tidak valid" }, 400); patch.url = normalized; } if (patch.name !== undefined) patch.name = String(patch.name).trim() || patch.url || urls[i].url; urls[i] = { ...urls[i], ...patch, id }; await env.KV.put("urls", JSON.stringify(urls)); return json({ ok: true, urls }); } if (method === "DELETE") { urls.splice(i, 1); await env.KV.put("urls", JSON.stringify(urls)); return json({ ok: true, urls }); } } if (path === "/api/run" && method === "POST") { return json(await runNow(env, "manual")); } if (path === "/api/logout" && method === "POST") { return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json", "Set-Cookie": "auth=; Path=/; Max-Age=0", }, }); } return json({ error: "Not found" }, 404); } // ============ MAIN ============ export default { async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname === "/login" && request.method === "POST") { const form = await request.formData(); if (form.get("password") === ADMIN_PASSWORD) { const token = await sha256(ADMIN_PASSWORD); return new Response(null, { status: 302, headers: { Location: "/", "Set-Cookie": `${COOKIE}=${token}; HttpOnly; Path=/; Max-Age=604800; SameSite=Lax`, }, }); } return new Response(LOGIN_HTML("Password salah"), { status: 401, headers: { "Content-Type": "text/html;charset=utf-8" }, }); } if (!(await checkAuth(request))) { return new Response(LOGIN_HTML(), { headers: { "Content-Type": "text/html;charset=utf-8" }, }); } if (url.pathname.startsWith("/api/")) return handleApi(request, env, url); return new Response(UI_HTML, { headers: { "Content-Type": "text/html;charset=utf-8" }, }); }, async scheduled(event, env, ctx) { const config = await getConfig(env); if (!config.enabled) return; const now = new Date(); const thisMinute = minuteKey(now); if (config.last_run_minute === thisMinute) return; let shouldRun = false; if (config.mode === "cron") { shouldRun = cronMatches(config.cron_expression, now); } else { const elapsed = Date.now() - (config.last_run || 0); shouldRun = elapsed >= config.interval_minutes * 60_000; } if (shouldRun) { await runNow(env, config.mode === "cron" ? "cron" : "interval"); } }, };