my personal site
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Gym Tracker Ads API, admin page, and .gitignore

- Cloudflare Worker at gymtracker.jackhannon.net/api/ads
- Admin UI at gymtracker-ads-admin.html
- KV storage with seed config
- CORS for jackhannon.net and jackhannon.me
- Link to admin from portfolio index

+656
+19
.gitignore
··· 1 + # Dependencies 2 + node_modules/ 3 + 4 + # Wrangler / Cloudflare 5 + .wrangler/ 6 + .dev.vars 7 + .env 8 + .env.* 9 + !.env.example 10 + .admin-api-key.txt 11 + 12 + # Build output 13 + dist/ 14 + *.min.js 15 + *.min.js.map 16 + 17 + # OS 18 + .DS_Store 19 + Thumbs.db
+330
gymtracker-ads-admin.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <meta name="color-scheme" content="light dark"> 7 + <link rel="stylesheet" href="style.css"> 8 + <title>Gym Tracker Ads Admin</title> 9 + </head> 10 + <body> 11 + <main class="admin-main"> 12 + <article class="admin-card"> 13 + <h1 class="admin-title">Gym Tracker Ads</h1> 14 + <p class="admin-subtitle">Manage sponsor ad config for VT Gym Tracker</p> 15 + 16 + <section class="admin-section"> 17 + <h2>API Key</h2> 18 + <p class="admin-hint">Required for saving. Stored in sessionStorage for this tab.</p> 19 + <input type="password" id="apiKey" placeholder="Enter ADMIN_API_KEY" class="admin-input" autocomplete="off"> 20 + <button type="button" id="saveKey" class="admin-btn">Save key for session</button> 21 + </section> 22 + 23 + <section class="admin-section"> 24 + <h2>Schedule</h2> 25 + <div class="admin-row"> 26 + <button type="button" id="fetchScheduleBtn" class="admin-btn">Fetch schedule</button> 27 + <span id="fetchStatus" class="admin-status"></span> 28 + </div> 29 + <div id="scheduleList" class="admin-schedule-list" hidden> 30 + <label class="admin-label">Edit ad</label> 31 + <select id="adSelector" class="admin-input"> 32 + <option value="">— New ad —</option> 33 + </select> 34 + </div> 35 + </section> 36 + 37 + <form id="adForm" class="admin-form"> 38 + <section class="admin-section"> 39 + <h2>Ad Config</h2> 40 + <label class="admin-label">ID (unique, e.g. sponsor-2025-q1)</label> 41 + <input type="text" id="id" name="id" class="admin-input" required> 42 + 43 + <label class="admin-label">Tier</label> 44 + <select id="tier" name="tier" class="admin-input"> 45 + <option value="text">text</option> 46 + <option value="banner">banner</option> 47 + <option value="feature">feature</option> 48 + </select> 49 + 50 + <label class="admin-label">Active</label> 51 + <input type="checkbox" id="active" name="active" class="admin-checkbox"> 52 + 53 + <label class="admin-label">Sponsor</label> 54 + <input type="text" id="sponsor" name="sponsor" class="admin-input" required> 55 + 56 + <label class="admin-label">Headline</label> 57 + <input type="text" id="headline" name="headline" class="admin-input" required> 58 + 59 + <label class="admin-label">Subline (optional)</label> 60 + <input type="text" id="subline" name="subline" class="admin-input"> 61 + 62 + <label class="admin-label">CTA</label> 63 + <input type="text" id="cta" name="cta" class="admin-input" required> 64 + 65 + <label class="admin-label">Destination URL (HTTPS)</label> 66 + <input type="url" id="destination_url" name="destination_url" class="admin-input" required placeholder="https://"> 67 + 68 + <label class="admin-label">Image URL (required for banner/feature)</label> 69 + <input type="url" id="image_url" name="image_url" class="admin-input" placeholder="https://"> 70 + 71 + <label class="admin-label">Logo URL (optional)</label> 72 + <input type="url" id="logo_url" name="logo_url" class="admin-input" placeholder="https://"> 73 + 74 + <label class="admin-label">Placement (default: home_feed)</label> 75 + <input type="text" id="placement" name="placement" class="admin-input" value="home_feed"> 76 + 77 + <label class="admin-label">Creative version (optional)</label> 78 + <input type="text" id="creative_version" name="creative_version" class="admin-input"> 79 + 80 + <label class="admin-label">Start at (optional)</label> 81 + <input type="datetime-local" id="start_at" name="start_at" class="admin-input"> 82 + 83 + <label class="admin-label">End at (optional)</label> 84 + <input type="datetime-local" id="end_at" name="end_at" class="admin-input"> 85 + </section> 86 + 87 + <div class="admin-form-actions"> 88 + <button type="submit" id="saveBtn" class="admin-btn admin-btn-primary">Save config</button> 89 + <span id="saveStatus" class="admin-status"></span> 90 + </div> 91 + </form> 92 + 93 + <p class="admin-footer"><a href="index.html">Back to jackhannon.net</a></p> 94 + </article> 95 + </main> 96 + 97 + <script> 98 + const API_URL = "https://gymtracker.jackhannon.net/api/ads"; 99 + const KEY_STORAGE = "gymtracker_ads_api_key"; 100 + 101 + const apiKeyInput = document.getElementById("apiKey"); 102 + const saveKeyBtn = document.getElementById("saveKey"); 103 + const fetchScheduleBtn = document.getElementById("fetchScheduleBtn"); 104 + const fetchStatus = document.getElementById("fetchStatus"); 105 + const scheduleList = document.getElementById("scheduleList"); 106 + const adSelector = document.getElementById("adSelector"); 107 + const saveBtn = document.getElementById("saveBtn"); 108 + const saveStatus = document.getElementById("saveStatus"); 109 + const form = document.getElementById("adForm"); 110 + 111 + let scheduledAds = []; 112 + 113 + if (sessionStorage.getItem(KEY_STORAGE)) { 114 + apiKeyInput.placeholder = "•••••••••••• (saved)"; 115 + } 116 + 117 + saveKeyBtn.addEventListener("click", () => { 118 + const key = apiKeyInput.value.trim(); 119 + if (key) { 120 + sessionStorage.setItem(KEY_STORAGE, key); 121 + apiKeyInput.value = ""; 122 + apiKeyInput.placeholder = "•••••••••••• (saved)"; 123 + } 124 + }); 125 + 126 + function getApiKey() { 127 + return sessionStorage.getItem(KEY_STORAGE); 128 + } 129 + 130 + function setStatus(el, msg, ok) { 131 + el.textContent = msg; 132 + el.className = "admin-status " + (ok ? "admin-status-ok" : "admin-status-err"); 133 + } 134 + 135 + function adStatus(ad) { 136 + const now = new Date(); 137 + const start = ad.start_at ? new Date(ad.start_at) : null; 138 + const end = ad.end_at ? new Date(ad.end_at) : null; 139 + if (!ad.active) return "paused"; 140 + if (start && now < start) return "scheduled"; 141 + if (end && now > end) return "ended"; 142 + return "live"; 143 + } 144 + 145 + function formatAdOption(ad) { 146 + const status = adStatus(ad); 147 + const label = status === "live" ? "●" : status === "scheduled" ? "○" : status === "ended" ? "—" : "‖"; 148 + const startStr = ad.start_at ? new Date(ad.start_at).toLocaleDateString() : "…"; 149 + const endStr = ad.end_at ? new Date(ad.end_at).toLocaleDateString() : "…"; 150 + const range = ad.start_at || ad.end_at ? ` (${startStr}–${endStr})` : ""; 151 + return `${label} ${ad.id}${range} [${status}]`; 152 + } 153 + 154 + fetchScheduleBtn.addEventListener("click", async () => { 155 + const key = getApiKey(); 156 + if (!key) { 157 + setStatus(fetchStatus, "Set API key first", false); 158 + return; 159 + } 160 + setStatus(fetchStatus, "Fetching…", true); 161 + try { 162 + const res = await fetch(API_URL + "?schedule=1", { 163 + headers: { "X-API-Key": key }, 164 + }); 165 + const data = await res.json(); 166 + if (!res.ok) { 167 + setStatus(fetchStatus, data.error || res.statusText, false); 168 + return; 169 + } 170 + scheduledAds = data.ads || []; 171 + adSelector.innerHTML = '<option value="">— New ad —</option>'; 172 + scheduledAds.forEach((ad, i) => { 173 + const opt = document.createElement("option"); 174 + opt.value = String(i); 175 + opt.textContent = formatAdOption(ad); 176 + adSelector.appendChild(opt); 177 + }); 178 + scheduleList.hidden = scheduledAds.length === 0; 179 + if (scheduledAds.length > 0) { 180 + adSelector.hidden = false; 181 + adSelector.value = "0"; 182 + populateForm(scheduledAds[0]); 183 + } else { 184 + clearForm(); 185 + } 186 + setStatus(fetchStatus, `Loaded ${scheduledAds.length} ad(s)`, true); 187 + } catch (err) { 188 + setStatus(fetchStatus, err.message || "Network error", false); 189 + } 190 + }); 191 + 192 + adSelector.addEventListener("change", () => { 193 + const val = adSelector.value; 194 + if (val === "") { 195 + clearForm(); 196 + } else { 197 + const ad = scheduledAds[parseInt(val, 10)]; 198 + if (ad) populateForm(ad); 199 + } 200 + }); 201 + 202 + function clearForm() { 203 + document.getElementById("id").value = ""; 204 + document.getElementById("tier").value = "banner"; 205 + document.getElementById("active").checked = true; 206 + document.getElementById("sponsor").value = ""; 207 + document.getElementById("headline").value = ""; 208 + document.getElementById("subline").value = ""; 209 + document.getElementById("cta").value = ""; 210 + document.getElementById("destination_url").value = ""; 211 + document.getElementById("image_url").value = ""; 212 + document.getElementById("logo_url").value = ""; 213 + document.getElementById("placement").value = "home_feed"; 214 + document.getElementById("creative_version").value = ""; 215 + document.getElementById("start_at").value = ""; 216 + document.getElementById("end_at").value = ""; 217 + } 218 + 219 + function isoToDatetimeLocal(iso) { 220 + if (!iso) return ""; 221 + const d = new Date(iso); 222 + if (Number.isNaN(d.getTime())) return ""; 223 + const pad = (n) => String(n).padStart(2, "0"); 224 + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; 225 + } 226 + 227 + function datetimeLocalToIso(value) { 228 + if (!value) return undefined; 229 + const d = new Date(value); 230 + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); 231 + } 232 + 233 + function populateForm(data) { 234 + document.getElementById("id").value = data.id || ""; 235 + document.getElementById("tier").value = (data.tier || "banner").toLowerCase(); 236 + document.getElementById("active").checked = !!data.active; 237 + document.getElementById("sponsor").value = data.sponsor || ""; 238 + document.getElementById("headline").value = data.headline || ""; 239 + document.getElementById("subline").value = data.subline || ""; 240 + document.getElementById("cta").value = data.cta || ""; 241 + document.getElementById("destination_url").value = data.destination_url || ""; 242 + document.getElementById("image_url").value = data.image_url || ""; 243 + document.getElementById("logo_url").value = data.logo_url || ""; 244 + document.getElementById("placement").value = data.placement || "home_feed"; 245 + document.getElementById("creative_version").value = data.creative_version || ""; 246 + document.getElementById("start_at").value = isoToDatetimeLocal(data.start_at); 247 + document.getElementById("end_at").value = isoToDatetimeLocal(data.end_at); 248 + } 249 + 250 + form.addEventListener("submit", async (e) => { 251 + e.preventDefault(); 252 + const key = getApiKey(); 253 + if (!key) { 254 + setStatus(saveStatus, "Set API key first", false); 255 + return; 256 + } 257 + 258 + const tier = document.getElementById("tier").value; 259 + const payload = { 260 + id: document.getElementById("id").value.trim(), 261 + tier, 262 + active: document.getElementById("active").checked, 263 + sponsor: document.getElementById("sponsor").value.trim(), 264 + headline: document.getElementById("headline").value.trim(), 265 + subline: document.getElementById("subline").value.trim() || null, 266 + cta: document.getElementById("cta").value.trim(), 267 + destination_url: document.getElementById("destination_url").value.trim(), 268 + image_url: document.getElementById("image_url").value.trim() || null, 269 + logo_url: document.getElementById("logo_url").value.trim() || null, 270 + placement: document.getElementById("placement").value.trim() || "home_feed", 271 + creative_version: document.getElementById("creative_version").value.trim() || "", 272 + start_at: datetimeLocalToIso(document.getElementById("start_at").value.trim()), 273 + end_at: datetimeLocalToIso(document.getElementById("end_at").value.trim()), 274 + }; 275 + 276 + if ((tier === "banner" || tier === "feature") && !payload.image_url) { 277 + setStatus(saveStatus, "Banner/feature tier requires image_url", false); 278 + return; 279 + } 280 + 281 + setStatus(saveStatus, "Saving…", true); 282 + try { 283 + const res = await fetch(API_URL, { 284 + method: "PUT", 285 + headers: { 286 + "Content-Type": "application/json", 287 + "X-API-Key": key, 288 + }, 289 + body: JSON.stringify(payload), 290 + }); 291 + const data = await res.json(); 292 + if (!res.ok) { 293 + setStatus(saveStatus, data.error || res.statusText, false); 294 + return; 295 + } 296 + setStatus(saveStatus, "Saved", true); 297 + if (scheduledAds.length > 0) fetchScheduleBtn.click(); 298 + } catch (err) { 299 + setStatus(saveStatus, err.message || "Network error", false); 300 + } 301 + }); 302 + </script> 303 + 304 + <style> 305 + .admin-main { min-height: 100dvh; padding: 2rem; display: flex; justify-content: center; align-items: flex-start; } 306 + .admin-card { max-width: 32rem; width: 100%; display: flex; flex-direction: column; gap: 1.5rem; } 307 + .admin-title { font-size: 1.5rem; font-weight: 600; } 308 + .admin-subtitle { color: var(--color-text-muted); font-size: 0.9rem; } 309 + .admin-section { display: flex; flex-direction: column; gap: 0.5rem; } 310 + .admin-section h2 { font-size: 1rem; font-weight: 600; margin-bottom: 0.25rem; } 311 + .admin-label { font-size: 0.875rem; color: var(--color-text-muted); } 312 + .admin-input { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-bg); color: var(--color-text); font: inherit; } 313 + .admin-input:focus { outline: 2px solid var(--color-vt-maroon); outline-offset: 2px; } 314 + .admin-checkbox { width: 1.25rem; height: 1.25rem; } 315 + .admin-btn { padding: 0.5rem 1rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-bg); color: var(--color-text); font: inherit; cursor: pointer; } 316 + .admin-btn:hover { background: var(--color-border); } 317 + .admin-btn-primary { background: var(--color-vt-maroon); color: white; border-color: var(--color-vt-maroon); } 318 + .admin-btn-primary:hover { opacity: 0.9; } 319 + .admin-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } 320 + .admin-form-actions { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--color-border); } 321 + .admin-status { font-size: 0.875rem; color: var(--color-text-muted); } 322 + .admin-status-ok { color: oklch(50% 0.15 145); } 323 + .admin-status-err { color: oklch(55% 0.2 25); } 324 + .admin-hint { font-size: 0.8rem; color: var(--color-text-muted); } 325 + .admin-schedule-list { display: flex; flex-direction: column; gap: 0.5rem; } 326 + .admin-footer { margin-top: 1rem; font-size: 0.875rem; } 327 + .admin-footer a { text-decoration: underline; } 328 + </style> 329 + </body> 330 + </html>
+8
index.html
··· 126 126 <span>Resume</span> 127 127 </a> 128 128 </li> 129 + <li> 130 + <a class="social-link" href="gymtracker-ads-admin.html"> 131 + <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"> 132 + <path fill="currentColor" d="M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6m-5 9a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v1H7z"/> 133 + </svg> 134 + <span>Gym Tracker Ads</span> 135 + </a> 136 + </li> 129 137 </ul> 130 138 </article> 131 139 </main>
+34
workers/gymtracker-ads-api/README.md
··· 1 + # Gym Tracker Ads API 2 + 3 + Cloudflare Worker serving ad config at `gymtracker.jackhannon.net/api/ads`. 4 + 5 + ## Setup 6 + 7 + 1. **Install dependencies:** `npm install` 8 + 9 + 2. **Set admin API key** (required for PUT): 10 + ```bash 11 + npx wrangler secret put ADMIN_API_KEY 12 + ``` 13 + Generate a key: `openssl rand -hex 24` 14 + 15 + 3. **Deploy:** `npm run deploy` 16 + 17 + ## Endpoints 18 + 19 + | Method | Path | Auth | Description | 20 + |--------|------|------|-------------| 21 + | GET | /api/ads | None | Return current active ad (filtered by start_at/end_at) | 22 + | GET | /api/ads?schedule=1 | X-API-Key header | Return all scheduled ads (for admin) | 23 + | PUT | /api/ads | X-API-Key header | Upsert ad config (by id) | 24 + | OPTIONS | /api/ads | None | CORS preflight | 25 + 26 + ## Scheduling 27 + 28 + - Use `start_at` and `end_at` (ISO8601) to schedule when an ad is live. 29 + - Multiple ads can be scheduled; the API returns the one active "now" on public GET. 30 + - Ads without dates are always eligible (if active). Overlapping windows: most recently started wins. 31 + 32 + ## Admin UI 33 + 34 + Manage ads at https://jackhannon.net/gymtracker-ads-admin.html
+13
workers/gymtracker-ads-api/package.json
··· 1 + { 2 + "name": "gymtracker-ads-api", 3 + "version": "1.0.0", 4 + "private": true, 5 + "scripts": { 6 + "dev": "wrangler dev", 7 + "deploy": "wrangler deploy" 8 + }, 9 + "devDependencies": { 10 + "wrangler": "^3.0.0", 11 + "typescript": "^5.0.0" 12 + } 13 + }
+12
workers/gymtracker-ads-api/seed-ad.json
··· 1 + { 2 + "id": "placeholder_001", 3 + "tier": "text", 4 + "sponsor": "Placeholder", 5 + "headline": "Configure your first sponsor ad in the admin.", 6 + "subline": "Visit jackhannon.net/gymtracker-ads-admin.html", 7 + "cta": "Get started", 8 + "destination_url": "https://gymtracker.jackhannon.net/docs/privacy-policy.html", 9 + "active": false, 10 + "placement": "home_feed", 11 + "creative_version": "" 12 + }
+217
workers/gymtracker-ads-api/src/index.ts
··· 1 + /** 2 + * Gym Tracker Ads API 3 + * GET /api/ads — return active ad config from KV 4 + * PUT /api/ads — update config (requires X-API-Key) 5 + * OPTIONS /api/ads — CORS preflight 6 + */ 7 + 8 + const KV_KEY = "active_ad"; 9 + 10 + const ALLOWED_ORIGINS = [ 11 + "https://jackhannon.net", 12 + "https://www.jackhannon.net", 13 + "https://jackhannon.me", 14 + "https://www.jackhannon.me", 15 + ]; 16 + 17 + function corsHeaders(request: Request): Record<string, string> { 18 + const origin = request.headers.get("Origin"); 19 + const allowOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]; 20 + return { 21 + "Access-Control-Allow-Origin": allowOrigin, 22 + "Access-Control-Allow-Methods": "GET, PUT, OPTIONS", 23 + "Access-Control-Allow-Headers": "Content-Type, X-API-Key", 24 + "Access-Control-Max-Age": "86400", 25 + }; 26 + } 27 + 28 + const NO_CACHE_HEADERS = { 29 + "Cache-Control": "no-store, no-cache, must-revalidate", 30 + "Pragma": "no-cache", 31 + }; 32 + 33 + interface AdConfig { 34 + id: string; 35 + active: boolean; 36 + sponsor: string; 37 + headline: string; 38 + subline?: string | null; 39 + cta: string; 40 + destination_url: string; 41 + image_url?: string | null; 42 + logo_url?: string | null; 43 + creative_version?: string; 44 + placement?: string; 45 + start_at?: string; 46 + end_at?: string; 47 + tier?: string; 48 + } 49 + 50 + function jsonResponse( 51 + body: object, 52 + status = 200, 53 + headers: Record<string, string> = {}, 54 + request?: Request 55 + ): Response { 56 + const cors = request ? corsHeaders(request) : corsHeaders(new Request("https://jackhannon.net")); 57 + return new Response(JSON.stringify(body), { 58 + status, 59 + headers: { 60 + "Content-Type": "application/json", 61 + ...NO_CACHE_HEADERS, 62 + ...cors, 63 + ...headers, 64 + }, 65 + }); 66 + } 67 + 68 + function corsPreflight(request: Request): Response { 69 + return new Response(null, { 70 + status: 204, 71 + headers: { 72 + ...corsHeaders(request), 73 + "Content-Length": "0", 74 + }, 75 + }); 76 + } 77 + 78 + function isValidUrl(s: string): boolean { 79 + try { 80 + const u = new URL(s); 81 + return u.protocol === "https:"; 82 + } catch { 83 + return false; 84 + } 85 + } 86 + 87 + function validateAdConfig(obj: unknown): { valid: true; config: AdConfig } | { valid: false; error: string } { 88 + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { 89 + return { valid: false, error: "Invalid JSON: expected object" }; 90 + } 91 + 92 + const o = obj as Record<string, unknown>; 93 + 94 + const id = o.id; 95 + if (typeof id !== "string" || !id.trim()) { 96 + return { valid: false, error: "Missing or invalid required field: id" }; 97 + } 98 + 99 + if (typeof o.active !== "boolean") { 100 + return { valid: false, error: "Missing or invalid required field: active (must be boolean)" }; 101 + } 102 + 103 + const sponsor = o.sponsor; 104 + if (typeof sponsor !== "string" || !sponsor.trim()) { 105 + return { valid: false, error: "Missing or invalid required field: sponsor" }; 106 + } 107 + 108 + const headline = o.headline; 109 + if (typeof headline !== "string" || !headline.trim()) { 110 + return { valid: false, error: "Missing or invalid required field: headline" }; 111 + } 112 + 113 + const cta = o.cta; 114 + if (typeof cta !== "string" || !cta.trim()) { 115 + return { valid: false, error: "Missing or invalid required field: cta" }; 116 + } 117 + 118 + const destination_url = o.destination_url; 119 + if (typeof destination_url !== "string" || !destination_url.trim()) { 120 + return { valid: false, error: "Missing or invalid required field: destination_url" }; 121 + } 122 + if (!isValidUrl(destination_url)) { 123 + return { valid: false, error: "destination_url must be a valid HTTPS URL" }; 124 + } 125 + 126 + const tier = typeof o.tier === "string" ? o.tier.toLowerCase() : "banner"; 127 + if (tier !== "text" && tier !== "banner" && tier !== "feature") { 128 + return { valid: false, error: "tier must be 'text', 'banner', or 'feature'" }; 129 + } 130 + 131 + if (tier === "banner" || tier === "feature") { 132 + const image_url = o.image_url; 133 + if (image_url != null && typeof image_url !== "string") { 134 + return { valid: false, error: "image_url must be string or null for banner/feature tier" }; 135 + } 136 + if (typeof image_url !== "string" || !image_url.trim()) { 137 + return { valid: false, error: `tier '${tier}' requires image_url` }; 138 + } 139 + if (!isValidUrl(image_url)) { 140 + return { valid: false, error: "image_url must be a valid HTTPS URL" }; 141 + } 142 + } 143 + 144 + if (o.logo_url != null && typeof o.logo_url === "string" && o.logo_url.trim() && !isValidUrl(o.logo_url)) { 145 + return { valid: false, error: "logo_url must be a valid HTTPS URL or empty" }; 146 + } 147 + 148 + const config: AdConfig = { 149 + id: String(id).trim(), 150 + active: Boolean(o.active), 151 + sponsor: String(sponsor).trim(), 152 + headline: String(headline).trim(), 153 + subline: o.subline != null && o.subline !== "" ? String(o.subline).trim() : null, 154 + cta: String(cta).trim(), 155 + destination_url: String(destination_url).trim(), 156 + image_url: o.image_url != null && o.image_url !== "" ? String(o.image_url).trim() : null, 157 + logo_url: o.logo_url != null && o.logo_url !== "" ? String(o.logo_url).trim() : null, 158 + creative_version: typeof o.creative_version === "string" ? o.creative_version : "", 159 + placement: typeof o.placement === "string" && o.placement.trim() ? o.placement.trim() : "home_feed", 160 + start_at: typeof o.start_at === "string" && o.start_at.trim() ? o.start_at.trim() : undefined, 161 + end_at: typeof o.end_at === "string" && o.end_at.trim() ? o.end_at.trim() : undefined, 162 + tier, 163 + }; 164 + 165 + return { valid: true, config }; 166 + } 167 + 168 + export default { 169 + async fetch(request: Request, env: { AD_CONFIG: KVNamespace; ADMIN_API_KEY?: string }): Promise<Response> { 170 + const url = new URL(request.url); 171 + if (url.pathname !== "/api/ads") { 172 + return jsonResponse({ error: "Not found" }, 404, {}, request); 173 + } 174 + 175 + if (request.method === "OPTIONS") { 176 + return corsPreflight(request); 177 + } 178 + 179 + if (request.method === "GET") { 180 + const value = await env.AD_CONFIG.get(KV_KEY); 181 + if (!value) { 182 + return jsonResponse({ error: "No ad config found" }, 404, {}, request); 183 + } 184 + try { 185 + const config = JSON.parse(value) as AdConfig; 186 + return jsonResponse(config, 200, {}, request); 187 + } catch { 188 + return jsonResponse({ error: "Invalid stored config" }, 500, {}, request); 189 + } 190 + } 191 + 192 + if (request.method === "PUT") { 193 + const apiKey = request.headers.get("X-API-Key"); 194 + const expectedKey = env.ADMIN_API_KEY; 195 + if (!expectedKey || apiKey !== expectedKey) { 196 + return jsonResponse({ error: "Unauthorized" }, 401, {}, request); 197 + } 198 + 199 + let body: unknown; 200 + try { 201 + body = await request.json(); 202 + } catch { 203 + return jsonResponse({ error: "Invalid JSON body" }, 400, {}, request); 204 + } 205 + 206 + const result = validateAdConfig(body); 207 + if (!result.valid) { 208 + return jsonResponse({ error: result.error }, 400, {}, request); 209 + } 210 + 211 + await env.AD_CONFIG.put(KV_KEY, JSON.stringify(result.config)); 212 + return jsonResponse(result.config, 200, {}, request); 213 + } 214 + 215 + return jsonResponse({ error: "Method not allowed" }, 405, {}, request); 216 + }, 217 + };
+23
workers/gymtracker-ads-api/wrangler.jsonc
··· 1 + { 2 + "name": "gymtracker-ads-api", 3 + "main": "src/index.ts", 4 + "compatibility_date": "2025-03-07", 5 + "compatibility_flags": ["nodejs_compat"], 6 + "observability": { 7 + "enabled": true, 8 + "head_sampling_rate": 1 9 + }, 10 + "kv_namespaces": [ 11 + { 12 + "binding": "AD_CONFIG", 13 + "id": "bd8619902c0f4b2c87e4d99b71167dbc", 14 + "preview_id": "2cc6fba1dfe249c7876ee0b2e49600a2" 15 + } 16 + ], 17 + "routes": [ 18 + { 19 + "pattern": "gymtracker.jackhannon.net/api/*", 20 + "zone_name": "jackhannon.net" 21 + } 22 + ] 23 + }