// =====================================================
// 🔐 PASSWORD LOGIN — GANTI DI SINI
const ADMIN_PASSWORD = "admin123";
// =====================================================
const COOKIE = "auth";
const UI_HTML = `
Cron URL Manager
`;
const LOGIN_HTML = (error = "") => `
Login
`;
// ============ 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");
}
},
};