# Labs เสริม: Cloudflare Pages สำหรับ brochure และ R2 สำหรับ media ที่มี policy

> ตรวจสอบกับเอกสาร Cloudflare ทางการ: 9 กันยายน 2026. Labs นี้เสริม deployment หลักใน [บทเรียน 08](../lessons/08-cloudflare-deploy.md) ซึ่งต้องเป็น Worker + D1 ที่ deploy จริง; ไม่ใช่ทางเลี่ยง requirement นั้น

## เลือก service ให้ตรงงาน

Cloudflare แนะนำ Workers สำหรับโปรเจกต์ใหม่ที่เป็น static, SPA หรือ full-stack เพราะมีความสามารถกว้างกว่า; Pages ยังเหมาะกับ static brochure ที่แยกเป็น artifact อย่างชัดเจน ([Workers best practices](https://developers.cloudflare.com/workers/best-practices/workers-best-practices/), [Pages getting started](https://developers.cloudflare.com/pages/get-started/)).

| งาน | เลือก | เหตุผล |
| --- | --- | --- |
| เว็บไซต์หลักของหลักสูตร: static UI + `/api/*` + D1 | Worker Static Assets | `src/worker.ts` และ D1 binding deploy พร้อม static assets |
| Landing/company brochure ที่ไม่มี form/API | Pages Direct Upload | deploy โฟลเดอร์ static เดียวได้ง่าย |
| รูปสินค้า/เอกสารที่ต้องรักษา policy | R2 ผ่าน Worker | R2 เก็บ bytes; Worker ตรวจสิทธิ์/key ก่อนตอบ |

ดู [แผนภาพ Cloudflare deployment แบบเต็มหน้า](/diagrams/cloudflare-deployment.html) เพื่อเห็น topology หลักของ Worker, Static Assets และ D1 ก่อนเลือก lab; Pages กับ R2 เป็นทางเลือกเสริมที่อ่านจากตารางนี้ ตารางยังคงไว้เพราะคำถามส่วนนี้คือการเปรียบเทียบตัวเลือก ไม่ใช่การติดตามลำดับ request

## Lab A — Pages: single-file company brochure ที่ static ล้วน

Pages Direct Upload upload build asset ไปยัง Pages; เริ่มด้วย `wrangler pages project create` แล้วใช้ `wrangler pages deploy <directory>`. โปรเจกต์แบบ Direct Upload เปลี่ยนเป็น Git integration ภายหลังไม่ได้ จึงเลือกวิธีนี้เมื่อเป็น artifact static ที่ตั้งใจ deploy จากเครื่อง/CI ([Pages Direct Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/)).

สร้างโฟลเดอร์ที่แยกจาก project Worker เพื่อไม่ให้เข้าใจผิดว่า Pages กำลัง deploy API ของหลักสูตร:

```bash
mkdir -p pages-brochure
```

สร้าง `pages-brochure/index.html` ด้วยเนื้อหาต่อไปนี้. ตัวอย่างนี้มี **ไม่มี form, login, fetch หรือ secret** เพื่อให้ browser ไม่เรียก `/api/*`:

```html
<!doctype html>
<html lang="th">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="บริษัทสาธิตสำหรับ AI Web Studio">
  <title>เส้นตั้ง สตูดิโอ</title>
  <style>
    :root { color-scheme: dark; font-family: system-ui, sans-serif; }
    body { max-width: 70rem; margin: auto; padding: 3rem 1.25rem; background: #10120f; color: #eef0e9; }
    .eyebrow { color: #bdc89b; letter-spacing: .12em; text-transform: uppercase; }
    h1 { font-size: clamp(2.5rem, 10vw, 6rem); margin: .2em 0; line-height: .9; }
    main { display: grid; gap: 2rem; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
    section { border-top: 1px solid #72815f; padding-top: 1rem; }
    a { color: #dce9b8; }
  </style>
</head>
<body>
  <p class="eyebrow">Bangkok · Architecture & Interior</p>
  <h1>พื้นที่ที่ดี<br>เริ่มจากการฟังชีวิต</h1>
  <p>เราสร้างบ้าน ที่ทำงาน และพื้นที่ค้าปลีกจากเรื่องจริงของผู้ใช้งาน</p>
  <main>
    <section><h2>งานของเรา</h2><p>บ้านพักอาศัย · พื้นที่ทำงาน · ร้านค้าขนาดเล็ก</p></section>
    <section><h2>ติดต่อ</h2><p><a href="mailto:hello@example.test">hello@example.test</a></p></section>
  </main>
</body>
</html>
```

จากนั้น login/create project/deploy ด้วย account ของผู้เรียนเอง:

```bash
npx wrangler whoami
npx wrangler pages project create
npx wrangler pages deploy ./pages-brochure --project-name=<your-pages-project>
curl -fsSI "https://<your-pages-project>.pages.dev/"
```

Wrangler จะถามชื่อ project และ production branch เมื่อสร้าง project; deployment production อยู่ที่ `https://<project>.pages.dev`, และ preview ใช้ `--branch=<branch>` ได้ ([Pages Direct Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/)). บันทึก URL กับเวลา deploy เป็นหลักฐาน lab.

### Caveat สำคัญ

ห้าม deploy `src/worker.ts` ของหลักสูตรไป Pages Direct Upload แล้วหวังว่า `/api/catalog`, `/api/contact` หรือ D1 จะทำงาน: Direct Upload ใน lab นี้ส่งเฉพาะ static files, จึงไม่มี Worker handler ของ repository และ `/api/*` จะ fail/404. Pages Functions เป็น integration อีกแบบที่ต้องสร้าง function layout และ test ใหม่; อย่าเรียกว่า “นำ Worker เดิมไปใช้ได้ทันที”. เว็บไซต์หลักของคอร์สจึงยังใช้ Worker Static Assets + D1.

## Lab B — R2: media ของสินค้าโดยไม่เปิด bucket write ต่อสาธารณะ

R2 bucket ไม่ public โดย default. การ bind R2 เข้ากับ Worker ทำให้ Worker อ่าน/เขียน object ได้ แต่ Cloudflare เตือนว่า route ที่เปิด bucket operations ต่อ incoming request ทุกคนจะ expose data; application ต้อง implement authorization เอง ([create a bucket](https://developers.cloudflare.com/r2/buckets/create-buckets/), [R2 from Workers](https://developers.cloudflare.com/r2/api/workers/workers-api-usage/)).

### 1. สร้าง bucket และวาง object สาธิตใน account ของตน

```bash
npx wrangler whoami
npx wrangler r2 bucket create vibe-academy-<your-initials>-media
npx wrangler r2 bucket list
mkdir -p fixtures
printf '%s\n' 'Demo media only; no customer data.' > fixtures/camera.txt
npx wrangler r2 object put vibe-academy-<your-initials>-media/products/camera.txt --file ./fixtures/camera.txt
```

ชื่อ bucket ใช้ lowercase, number และ hyphen เท่านั้น และยาว 3–63 ตัวอักษร ([R2 bucket creation](https://developers.cloudflare.com/r2/buckets/create-buckets/)). สองคำสั่งก่อน `object put` สร้าง fixture สาธิตในเครื่องผู้เรียน; คำสั่ง upload เป็นการเขียน account จริง จึงใช้ไฟล์ demo ที่ไม่มี PII และไม่ใส่ `PUT` endpoint public ใน Worker.

### 2. เพิ่ม binding และ D1 metadata ใน branch ที่ review ได้

เพิ่ม binding ต่อไปนี้ใน `wrangler.jsonc` ของ branch media (เปลี่ยนชื่อ bucket ให้ตรงของตน) แล้วรัน `npm run types`/`npm run typecheck`:

```jsonc
{
  "r2_buckets": [{
    "binding": "MEDIA",
    "bucket_name": "vibe-academy-<your-initials>-media"
  }]
}
```

เพิ่ม migration แทนการ hard-code object key กระจัดกระจาย:

```sql
CREATE TABLE media_assets (
  id TEXT PRIMARY KEY,
  product_id TEXT NOT NULL REFERENCES products(id),
  object_key TEXT NOT NULL UNIQUE,
  content_type TEXT NOT NULL,
  visibility TEXT NOT NULL CHECK (visibility IN ('public'))
) STRICT;

INSERT INTO media_assets (id, product_id, object_key, content_type, visibility)
VALUES ('camera-demo', 'headphones', 'products/camera.txt', 'text/plain', 'public');
```

แยก metadata ใน D1 ออกจาก bytes ใน R2: D1 เป็น source of truth ว่า key ใดถูกเผยแพร่สำหรับสินค้าใด; R2 เก็บ object. Binding ถูกประกาศใน Wrangler config และ type ของ binding ต้อง generate ใหม่ ([R2 Workers API](https://developers.cloudflare.com/r2/get-started/workers-api/)).

เมื่อต้องอธิบาย boundary หลัก ให้อ่าน Worker → D1 ใน [แผนภาพ deployment](/diagrams/cloudflare-deployment.html) แล้วต่อแขนง R2 จาก code และ policy ใน lab นี้; ภาพตั้งใจไม่วาด extension ที่ยังไม่มีใน starter และ code block ด้านล่างยังเป็นหลักฐาน contract ที่แผนภาพแทนไม่ได้

### 3. อนุญาตเฉพาะ read route ที่ validate key จาก D1

เพิ่ม route `GET /media/:id` ใน Worker—not generic `GET /media/:key`, and never public `PUT`/`DELETE`. ตัวอย่างนี้รับ `id` ที่จำกัดรูปแบบ, lookup metadata แบบ bound parameter, และตอบ object เฉพาะ row ที่ `visibility='public'`:

```ts
async function getPublicMedia(request: Request, env: Env): Promise<Response> {
  if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405 });
  const id = new URL(request.url).pathname.slice("/media/".length);
  if (!/^[a-z0-9-]{1,64}$/.test(id)) return new Response("Not Found", { status: 404 });

  const media = await env.DB.prepare(
    "SELECT object_key, content_type FROM media_assets WHERE id = ? AND visibility = 'public'",
  ).bind(id).first<{ object_key: string; content_type: string }>();
  if (!media) return new Response("Not Found", { status: 404 });

  const object = await env.MEDIA.get(media.object_key);
  if (!object) return new Response("Not Found", { status: 404 });
  return new Response(object.body, {
    headers: { "Content-Type": media.content_type, "X-Content-Type-Options": "nosniff" },
  });
}
```

Merge this route only with test cases for unknown ID, malformed ID, missing object and allowed object. Upload/delete belongs to an authenticated admin/control-plane endpoint with explicit file-size/MIME/key policy, or to a short-lived presigned URL flow—never to `PUT /media/:key` on the public website. Presigned URLs are bearer tokens and need expiry, content-type constraints and CORS rules ([R2 presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)).

### 4. Verify and deploy the media extension

Run local migration/tests, deploy the same Worker extension, then test only the allowlisted media ID:

```bash
npm run db:local
npm run types
npm run typecheck
npm test
npx wrangler d1 migrations apply vibe-academy-<your-initials>-prod --remote
npm run deploy
curl -fsSI "$WORKER_URL/media/camera-demo"
curl -i "$WORKER_URL/media/not-a-real-id"
```

Record URL, Worker version and 200/404 outcomes. Do not treat this optional R2 lab as complete if bucket, binding, migration, route and authorization tests were not all performed.
