Skip to content

Commit 3ebcbc6

Browse files
r8a5claude
andcommitted
release(1.1.0): Claude Fable 5, WebP hero pipeline, security hardening, redesign
- Anthropic provider defaults to claude-fable-5 (pricing snapshot updated) - Hero image step sniffs real byte format (JPEG/WebP/PNG) instead of assuming PNG; scripts/optimize-hero-images.sh recompresses existing R2 heroes to WebP (~97% smaller) and repoints blog_posts - Proper 404.html (was SPA-fallback soft-404s with HTTP 200) - All public pages self-host fonts from /_fonts/ (Google Fonts removed) - Blog index: eager fetchpriority=high first card, og:image, preloads - Security: constant-time admin-token comparison; nosniff + Referrer-Policy on all pages; X-Frame-Options DENY + no-store on admin/sign-in - Marketing site redesign: editorial serif display, highlighter-yellow marks, provider ticker, folio numerals, full-bleed nav, outro band Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 36c7cc5 commit 3ebcbc6

26 files changed

Lines changed: 697 additions & 183 deletions

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
# WORKERS_AI_IMAGE_MODEL=@cf/black-forest-labs/flux-1-schnell
7373
# OPENAI_TEXT_MODEL=gpt-5
7474
# OPENAI_IMAGE_MODEL=gpt-image-1
75-
# ANTHROPIC_TEXT_MODEL=claude-opus-4-7
75+
# ANTHROPIC_TEXT_MODEL=claude-fable-5
7676
# GEMINI_TEXT_MODEL=gemini-2.5-pro
7777
# GEMINI_IMAGE_MODEL=imagen-4.0-generate-001
7878
# GROQ_TEXT_MODEL=llama-3.3-70b-versatile

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,44 @@ version.
77

88
The format is loosely Keep-a-Changelog, dates in ISO order.
99

10+
## 1.1.0 — 2026-06-10
11+
12+
### Added
13+
- **Claude Fable 5 support.** The Anthropic provider now defaults to
14+
`claude-fable-5`, Anthropic's newest model ($10/M in, $50/M out —
15+
bundled price snapshot updated to match). Override with
16+
`ANTHROPIC_TEXT_MODEL` as before.
17+
- **`scripts/optimize-hero-images.sh`** — one-command maintenance
18+
script that recompresses existing AI hero PNGs in R2 to WebP
19+
(~97% smaller; a 10-post /blog page drops from ~14 MB of images
20+
to under 600 KB) and repoints `blog_posts` at the new keys.
21+
- **Proper 404s.** A branded `public/404.html` replaces the previous
22+
SPA fallback that returned the homepage with HTTP 200 for unknown
23+
URLs (a classic soft-404 that wastes crawl budget).
24+
- **Marketing site redesign.** Editorial press-sheet look: serif
25+
display headlines, highlighter-yellow accents, provider ticker,
26+
ghost folio numerals, full-bleed nav, closing CTA band, dark mode.
27+
28+
### Changed
29+
- **Hero images store their real format.** The image step now sniffs
30+
the provider's bytes (JPEG/WebP/PNG) and writes the matching
31+
extension + content-type instead of assuming PNG.
32+
- **All public pages self-host fonts.** Blog index, posts,
33+
programmatic pages, and docs no longer call Google Fonts — fonts
34+
load from `/_fonts/` with preload hints (faster first paint, one
35+
fewer third-party origin).
36+
- **Blog index LCP.** The first card image loads eagerly with
37+
`fetchpriority=high` (plus a preload hint and og:image); the rest
38+
stay lazy. Page-1 now ships `twitter:card=summary_large_image`.
39+
40+
### Security
41+
- **Constant-time admin-token comparison.** Bearer/X-Admin-Token
42+
checks no longer short-circuit on the first differing byte.
43+
- **Baseline security headers.** `X-Content-Type-Options: nosniff` +
44+
`Referrer-Policy` on static assets and rendered pages;
45+
`X-Frame-Options: DENY` + `Cache-Control: no-store` on the admin
46+
SPA and sign-in page (clickjacking defence).
47+
1048
## 1.0.6 — 2026-06-07
1149

1250
### Removed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Workers AI is bound automatically and is the default. Every other provider is op
152152
|---|---|---|---|
153153
| Cloudflare Workers AI | _(binding)_ | ✅ Llama 3.3 70B | ✅ Flux 1 schnell |
154154
| OpenAI | `OPENAI_API_KEY` | ✅ gpt-5 | ✅ gpt-image-1 |
155-
| Anthropic | `ANTHROPIC_API_KEY` | ✅ Claude ||
155+
| Anthropic | `ANTHROPIC_API_KEY` | ✅ Claude Fable 5 ||
156156
| Google Gemini | `GEMINI_API_KEY` | ✅ Gemini 2.5 Pro | ✅ Imagen 4 |
157157
| Groq | `GROQ_API_KEY` | ✅ Llama 3.3 70B ||
158158
| DeepSeek | `DEEPSEEK_API_KEY` | ✅ deepseek-chat ||

functions/_lib/ai.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,7 @@ async function openAIImage(env, prompt) {
630630
// ── Anthropic (Claude) ─────────────────────────────────────────────────
631631

632632
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
633-
const ANTHROPIC_TEXT_MODEL = 'claude-opus-4-7';
633+
const ANTHROPIC_TEXT_MODEL = 'claude-fable-5';
634634

635635
async function anthropicText(env, prompt) {
636636
if (!env?.ANTHROPIC_API_KEY) throw new Error('anthropic_not_configured');
@@ -645,8 +645,8 @@ async function anthropicText(env, prompt) {
645645
body: JSON.stringify({
646646
model,
647647
// 8192 fits a 2500-4000-word article + JSON wrapper without
648-
// truncation. Anthropic's max for Opus is 32k+; 8k is the
649-
// sweet spot for cost/latency on long-form articles.
648+
// truncation. Fable 5 supports far more; 8k is the sweet spot
649+
// for cost/latency on long-form articles.
650650
max_tokens: 8192,
651651
system: SYSTEM_JSON_ONLY,
652652
messages: [{ role: 'user', content: prompt }],

functions/_lib/auth.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,25 @@ import { missingConfig, configError } from './config.js';
1818
import { SESSION_COOKIE, readCookie, verifySessionToken } from './passwords.js';
1919
import { getAdminToken } from './admin_token.js';
2020

21+
// Constant-time string comparison. A plain === short-circuits on the
22+
// first differing byte, which leaks token prefixes through response
23+
// timing. The edge runtime adds jitter that makes exploitation hard,
24+
// but constant-time costs nothing.
25+
function timingSafeEqual(a, b) {
26+
const ea = new TextEncoder().encode(String(a));
27+
const eb = new TextEncoder().encode(String(b));
28+
let diff = ea.length === eb.length ? 0 : 1;
29+
for (let i = 0; i < ea.length; i++) diff |= ea[i] ^ (i < eb.length ? eb[i] : 0);
30+
return diff === 0;
31+
}
32+
2133
async function bearerToken(env, request) {
2234
const token = await getAdminToken(env);
2335
if (!token) return false;
2436
const bearer = (request.headers.get('Authorization') || '').match(/^Bearer\s+(.+)$/i);
25-
if (bearer && bearer[1].trim() === token) return true;
37+
if (bearer && timingSafeEqual(bearer[1].trim(), token)) return true;
2638
const hdr = (request.headers.get('X-Admin-Token') || '').trim();
27-
return !!(hdr && hdr === token);
39+
return !!(hdr && timingSafeEqual(hdr, token));
2840
}
2941

3042
async function sessionAuth(env, request) {

functions/_lib/page_render.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,8 @@ ${verifyMetas}
230230
<meta name="twitter:description" content="${esc(post.meta_description)}" />
231231
<meta name="twitter:image" content="https://${host}${heroSrc}" />
232232
${preloadHero}
233-
<link rel="preconnect" href="https://fonts.googleapis.com" />
234-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
235-
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap" />
233+
<link rel="preload" href="/_fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin />
234+
<link rel="preload" href="/_fonts/instrument-serif-400.woff2" as="font" type="font/woff2" crossorigin />
236235
<link rel="stylesheet" href="/style.css" />
237236
<script type="application/ld+json">${jsonLD({ site, post: { ...post, urlPath }, host, kind, settings })}</script>
238237
</head>

functions/_lib/prices.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { nowSec } from './util.js';
2222
export const BUNDLED_PRICES = {
2323
'workers-ai': { in: 0, out: 0, image: 0 },
2424
'openai': { in: 1.25, out: 10.00, image: 0.04 }, // gpt-5
25-
'anthropic': { in: 15.00,out: 75.00, image: null }, // claude-opus-4-7
25+
'anthropic': { in: 10.00,out: 50.00, image: null }, // claude-fable-5
2626
'gemini': { in: 1.25, out: 10.00, image: 0.04 }, // gemini-2.5-pro / imagen-4
2727
'groq': { in: 0.59, out: 0.79, image: null }, // llama-3.3-70b
2828
'deepseek': { in: 0.27, out: 1.10, image: null },
@@ -38,7 +38,7 @@ export const BUNDLED_PRICES = {
3838
// default models.
3939
const MODELS_DEV_MAP = {
4040
'openai': { provider: 'openai', model: 'gpt-5' },
41-
'anthropic': { provider: 'anthropic', model: 'claude-opus-4-7' },
41+
'anthropic': { provider: 'anthropic', model: 'claude-fable-5' },
4242
'gemini': { provider: 'google', model: 'gemini-2.5-pro' },
4343
'groq': { provider: 'groq', model: 'llama-3.3-70b-versatile' },
4444
'deepseek': { provider: 'deepseek', model: 'deepseek-chat' },

functions/_lib/raw_llm.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ async function runProvider(env, name, prompt, sys) {
8080
throw new Error('openai_empty');
8181
}
8282
case 'anthropic': {
83-
const model = env.ANTHROPIC_TEXT_MODEL || 'claude-opus-4-7';
83+
const model = env.ANTHROPIC_TEXT_MODEL || 'claude-fable-5';
8484
const r = await fetch('https://api.anthropic.com/v1/messages', {
8585
method: 'POST',
8686
headers: { 'x-api-key': env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' },

functions/api/admin/blog/image.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ import { adminGate } from '../../../_lib/auth.js';
2020
import { generateImage } from '../../../_lib/ai.js';
2121
import { loadSettings } from '../../../_lib/settings.js';
2222

23+
function sniffImageFormat(bytes) {
24+
if (bytes?.length > 2 && bytes[0] === 0xff && bytes[1] === 0xd8) return { ext: 'jpg', type: 'image/jpeg' };
25+
if (bytes?.length > 11 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return { ext: 'webp', type: 'image/webp' };
26+
return { ext: 'png', type: 'image/png' };
27+
}
28+
2329
export const onRequestPost = async ({ request, env }) => {
2430
const gate = await adminGate(env, request); if (gate) return gate;
2531
let body;
@@ -103,10 +109,15 @@ export const onRequestPost = async ({ request, env }) => {
103109
const source = request.headers.get('X-Source-Cron') === '1' ? 'cron-blog' : 'admin-blog';
104110
const r = await generateImage(env, { prompt: job.hero_image_prompt, provider: body.provider, source });
105111
imageProvider = r.ai_provider;
106-
imageKey = `${job.slug}-${Date.now()}.png`;
112+
// Sniff the actual byte format rather than assuming PNG — some
113+
// providers return JPEG or WebP, and serving them with the right
114+
// extension + content-type keeps caches and <img> decoding honest
115+
// (JPEG/WebP heroes are also ~10x smaller than RGBA PNGs).
116+
const fmt = sniffImageFormat(r.bytes);
117+
imageKey = `${job.slug}-${Date.now()}.${fmt.ext}`;
107118
if (!env.IMAGES) throw new Error('r2_binding_missing');
108119
await env.IMAGES.put(imageKey, r.bytes, {
109-
httpMetadata: { contentType: 'image/png', cacheControl: 'public, max-age=31536000, immutable' },
120+
httpMetadata: { contentType: fmt.type, cacheControl: 'public, max-age=31536000, immutable' },
110121
});
111122
} catch (e) {
112123
imageError = String(e.message || e).slice(0, 800);

functions/api/admin/brand-dna.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ async function runProvider(env, name, prompt) {
160160
throw new Error('openai_empty');
161161
}
162162
case 'anthropic': {
163-
const model = env.ANTHROPIC_TEXT_MODEL || 'claude-opus-4-7';
163+
const model = env.ANTHROPIC_TEXT_MODEL || 'claude-fable-5';
164164
const r = await fetch('https://api.anthropic.com/v1/messages', {
165165
method: 'POST',
166166
headers: { 'x-api-key': env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' },

0 commit comments

Comments
 (0)