บทเรียน 103 · Rails · Day 1

View — จัดจานด้วย ERB, layout และ helper ที่กันภัยให้

~20 นาที · ควรผ่าน บทเรียน 101 (routes · _path) และ บทเรียน 102 (controller · @ตัวแปร) มาก่อน

บทที่แล้ว controller (หัวหน้าเชฟ) เตรียมข้อมูลเสร็จแล้ว แต่ลูกค้ายังไม่เห็นอะไรเลย · บทนี้คือขั้นสุดท้าย: เอาข้อมูลมาจัดเป็นหน้าเว็บ (HTML) แล้วส่งให้ลูกค้า
อ่านจบแล้วคุณจะเขียนหน้า "รายการบทความ" ได้เอง · รู้ว่าทำไมทุกหน้ามีหัวเว็บเหมือนกันโดยไม่ต้องเขียนซ้ำ · และรู้ว่า Rails กันคนร้ายสองแบบให้เราฟรี ๆ ยังไง

📌 ทวนก่อนเริ่ม — คำจากบทก่อนที่บทนี้ใช้

🧰 ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 (สไลด์ใช้ 4.0.6 / 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน) · แอป wongnok_blog ที่สร้างด้วย rails new (บท 98) · ผลทุกก้อนได้จากการเปิด server จริงที่พอร์ต 3000 แล้วดึงหน้าเว็บมาดูจริง

View คือขั้นสุดท้าย — การจัดจานก่อนเสิร์ฟ

ทวนเส้นทางของใบสั่งหนึ่งใบ: ลูกค้า (browser) ขอหน้า /posts → พนักงานต้อนรับ (routes) ชี้ไปแผนกบทความ → หัวหน้าเชฟ (PostsController#index) เตรียมข้อมูล

แต่ข้อมูลดิบ ๆ ส่งให้ลูกค้าไม่ได้ ต้องจัดให้เป็นหน้าเว็บก่อน — งานนี้คือหน้าที่ของ view · view คือไฟล์แม่แบบ HTML ที่มีช่องให้เติมข้อมูล

ลูกค้า ──ใบสั่ง GET /posts──▶ routes ──▶ PostsController#index │ ตั้ง @posts = [...] ▼ ลูกค้า ◀──── จาน (HTML) ─────────── view: app/views/posts/index.html.erb

ไฟล์ view อยู่ที่ไหน? Rails หาเองจากชื่อ (กฎตั้งชื่อในบท 99): action index ของ PostsController → ไฟล์ app/views/posts/index.html.erb · controller ไม่ต้องเขียนสั่งเลยว่าใช้ไฟล์ไหน

🍽 เทียบให้เห็นภาพ — การจัดจานก่อนเสิร์ฟ

หัวหน้าเชฟ (controller) ทำวัตถุดิบเสร็จแล้ววางไว้บนถาดหน้าครัว (@posts — ถาดเดียวกับบท 102) · คนจัดจาน (view) หยิบของจากถาดมาจัดลงจานตามแบบที่ร้านกำหนด แล้วยกออกไปให้ลูกค้า (HTML)
ลูกค้าไม่เคยเห็นครัว เห็นแค่จานที่จัดเสร็จ

ที่ไม่เหมือนกัน: คนจัดจานจริงเป็นคน คิดเองได้ · แต่ view เป็นแค่ไฟล์แบบจัดจาน ที่ Rails อ่านแล้วเติมข้อมูลใหม่ทุกครั้งที่มีใบสั่ง · และ view ควรใช้แค่ของที่ controller วางไว้ (@ตัวแปร) ไม่ควรวิ่งไปหยิบวัตถุดิบเอง

เทียบกับ Go: ใน Gin คุณต้องบอกเองทุกครั้งว่าใช้แม่แบบไฟล์ไหนและส่งข้อมูลอะไรไป เช่น c.HTML(200, "index.tmpl", gin.H{"posts": posts}) · Rails เดาชื่อไฟล์ให้ และ @ตัวแปร ทุกตัวของ controller ส่งถึง view เอง

🗣 ศัพท์ที่เพิ่งเรียนไป
view
ไฟล์ที่เปลี่ยนข้อมูลให้เป็นหน้าเว็บ · ตัว V ใน MVC · ถาม senior ได้ว่า "หน้า /posts ใช้ view ไฟล์ไหน" — คำตอบมักเป็น app/views/posts/index.html.erb
template (แม่แบบ)
ไฟล์ที่มีโครงตายตัวกับช่องว่างให้เติม · view ของ Rails คือ template ชนิดหนึ่ง
render
แปลว่า "เอาแม่แบบมาเติมข้อมูลจนได้ HTML" · พิมพ์บอก AI ได้ว่า "the index action renders app/views/posts/index.html.erb by default"

ERB — HTML ที่มีช่องให้ Ruby เติม

ไฟล์ view ลงท้ายด้วย .html.erb — อ่านว่า "ไฟล์นี้ให้ ERB จัดการก่อน แล้วจะได้ HTML" · เนื้อในเป็น HTML ธรรมดาเกือบทั้งหมด มีแค่บางจุดที่ครอบด้วย <% … %> เพื่อให้ Ruby ทำงาน

ช่องแบบนี้มี 3 แบบ ต่างกันที่ตัวอักษรหลัง <%:

แท็กทำอะไรลูกค้าเห็นไหม
<%= … %>รัน Ruby แล้วพิมพ์ผลลงตรงนั้น✅ เห็นผล
<% … %>รัน Ruby เฉย ๆ ไม่พิมพ์ — ใช้กับ if · each · end❌ ตัวมันไม่เห็น (แต่ HTML ที่มันคุมอยู่เห็นได้)
<%# … %>comment — ไม่รันเลย❌ ไม่เห็นแม้แต่ตัวเดียว

วิธีจำ: เครื่องหมาย = คือ "เอาออกไปโชว์" · ไม่มี = คือ "ทำงานหลังบ้าน"

เทียบกับ Go: Go มีแพ็กเกจแม่แบบในตัวชื่อ html/template (Gin ใช้ตัวนี้อยู่เบื้องหลังตอนเรียก c.HTML) · ฝั่ง BACK ไม่ได้สอนตรง ๆ ไม่เป็นไร แค่ดูว่าหน้าตาคล้ายกัน — Go ใช้ {{ }} ส่วน ERB ใช้ <% %>:

อยากทำGo (html/template)ERB (Rails)
โชว์ค่า{{.Title}}<%= @post[:title] %>
ถ้า… ค่อยโชว์{{if .Published}}…{{end}}<% if … %> … <% end %>
วนทุกชิ้น{{range .}}…{{end}}<% @posts.each do |post| %> … <% end %>
📋 เทียบให้เห็นภาพ — ใบแบบจัดจานที่ติดไว้ในครัว

ใบแบบจัดจานมีข้อความสองชนิด:
· <%= %> = "วางสิ่งนี้ลงจาน" เช่น "วางชื่อเมนูตรงกลาง" — ของลงจานจริง ลูกค้าเห็น
· <% %> = คำสั่งถึงคนจัด เช่น "ถ้าเป็นเมนูแนะนำ ให้วางดอกไม้" หรือ "ทำซ้ำทุกชิ้น" — ตัวคำสั่งไม่ได้ลงจาน แต่ของที่มันคุมอยู่อาจลงจาน
· <%# %> = โน้ตดินสอของเชฟ ที่มุมใบแบบ — ไม่มีวันไปถึงลูกค้า

ที่ไม่เหมือนกัน: คำสั่งในใบแบบจริงทำได้แค่ "วาง/ไม่วาง" · แต่ในช่อง <% %> คุณเขียน Ruby อะไรก็ได้ ไม่มีใครห้าม · กติกาของทีมคือใช้แค่ if กับ each งานคิดหนัก ๆ ให้อยู่ใน controller

🗣 ศัพท์ที่เพิ่งเรียนไป
ERB (Embedded Ruby)
"Ruby ที่ฝังอยู่ในข้อความ" · ระบบแม่แบบของ view ใน Rails · ถาม senior ได้ว่า "โปรเจกต์นี้เขียน view เป็น ERB ทั้งหมดไหม" — บางทีมใช้ระบบอื่นเสริม
output tag · <%= %>
แท็กที่พิมพ์ผลออกหน้าเว็บ
execution tag · <% %>
แท็กที่รันแต่ไม่พิมพ์ · พิมพ์บอก AI ได้ว่า "this value doesn't show up — I used an execution tag instead of an output tag"

มาลองของจริง · ก่อนอื่นดู controller — action show ตั้ง @post เป็นบทความหนึ่งชิ้น (ยังไม่มีฐานข้อมูล เรื่องนั้นรอ Day 2 · ตอนนี้จำลองบทความด้วย Hash):

# app/controllers/posts_controller.rb (ท่อน show)
def show
  @post = { id: params[:id], title: "ข้าวมันไก่ร้านดัง", published: true }
end

⚠️ ถ้าดูสไลด์ไปด้วย: สไลด์เขียน @post.title (มีจุด) แต่บทนี้ @post เป็น Hash จำลอง ต้องเขียน @post[:title] · พิมพ์แบบสไลด์ตอนนี้จะได้ undefined method 'title' for an instance of Hash — แบบมีจุดใช้ได้ตอนมี model จริงใน Day 2

<%# app/views/posts/show.html.erb %>
<h1><%= @post[:title] %></h1>
<% if @post[:published] %>
  <p>เผยแพร่แล้ว</p>
<% end %>
<%# โน้ตของเชฟ: ลูกค้าไม่เห็นบรรทัดนี้ %>
<p>รหัสบทความ: <% @post[:id] %></p>

เปิด /posts/42 แล้ว HTML ที่ลูกค้าได้มีอะไรบ้าง? บรรทัดไหนหาย · แล้วบรรทัดสุดท้ายจะโชว์เลข 42 ไหม (ดูให้ดีว่าใช้แท็กแบบไหน)

ใช้ curl (คำสั่งใน terminal ที่ดึงหน้าเว็บมาเป็นข้อความ เหมือน browser แต่ไม่วาดภาพ) · ท่อนหลัง | sed -n '…' แค่ตัดมาเฉพาะช่วง <main> ถึง </main> จะได้ไม่ต้องดู HTML ยาวทั้งหน้า (ไม่ต้องจำคำสั่งนี้):

$ curl -s http://localhost:3000/posts/42 | sed -n '/<main/,/<\/main>/p'
    <main class="container mx-auto mt-28 px-5 flex">
      <!-- BEGIN app/views/posts/show.html.erb
--><h1>ข้าวมันไก่ร้านดัง</h1>
  <p>เผยแพร่แล้ว</p>
<p>รหัสบทความ: </p>
<!-- END app/views/posts/show.html.erb -->
    </main>
  • <%= @post[:title] %> → ชื่อบทความโผล่ เพราะมี =
  • บรรทัด <% if %> กับ <% end %> หายไปทั้งบรรทัด แต่ <p>เผยแพร่แล้ว</p> ที่มันคุมอยู่โผล่ เพราะเงื่อนไขเป็นจริง
  • โน้ต <%# หายไปทั้งบรรทัด
  • "รหัสบทความ:" ว่างเปล่า ไม่มี 42 — เพราะลืม = · Ruby รันจริงแต่ไม่พิมพ์ และไม่มี error เตือน

สองบรรทัด BEGIN / END เราไม่ได้เขียน — Rails ใส่ให้เองเฉพาะตอนพัฒนา เพื่อบอกว่า HTML ท่อนนี้มาจากไฟล์ไหน (มีประโยชน์มากเวลาหาว่าต้องไปแก้ไฟล์ไหน) · ส่วน <main class="…"> ที่ครอบอยู่ก็ไม่ได้มาจากไฟล์เรา — มาจาก layout ซึ่งเจอในอีกสองหัวข้อ

วนรายการ — โชว์บทความหลายชิ้นด้วย each

หน้า /posts ต้องโชว์บทความทั้งหมด · controller ตั้ง @posts (มี s = หลายชิ้น) เป็น Array ของ Hash — ยังจำลองเหมือนเดิม และแอบใส่ชื่อบทความแปลก ๆ ไว้ชิ้นหนึ่ง (เดี๋ยวใช้ในหัวข้อความปลอดภัย):

# app/controllers/posts_controller.rb (ท่อน index)
def index
  # ยังไม่มีฐานข้อมูล (รอ Day 2) — จำลองบทความด้วย Hash
  @posts = [
    { id: 1, title: "ข้าวมันไก่ร้านดัง" },
    { id: 2, title: "ส้มตำปูปลาร้า" },
    { id: 3, title: "<script>alert('hack')</script>" }
  ]
end

[ … ] คือ Array (บท 92) = กล่องที่เรียงของไว้หลายชิ้น · ข้างในมี Hash 3 ก้อน คั่นด้วยจุลภาค = บทความปลอม 3 ชิ้น · แล้ว view ก็วนทีละชิ้น:

<%# app/views/posts/index.html.erb %>
<h1>บทความทั้งหมด</h1>
<ul>
  <% @posts.each do |post| %>
    <li><%= link_to post[:title], post_path(post[:id]) %></li>
  <% end %>
</ul>

ทีละบรรทัด:

$ curl -s http://localhost:3000/posts | sed -n '/<main/,/<\/main>/p'
    <main class="container mx-auto mt-28 px-5 flex">
      <!-- BEGIN app/views/posts/index.html.erb
--><h1>บทความทั้งหมด</h1>
<ul>
    <li><a href="/posts/1">ข้าวมันไก่ร้านดัง</a></li>
    <li><a href="/posts/2">ส้มตำปูปลาร้า</a></li>
    <li><a href="/posts/3">&lt;script&gt;alert(&#39;hack&#39;)&lt;/script&gt;</a></li>
</ul>
<!-- END app/views/posts/index.html.erb -->
    </main>

ได้ <li> 3 อัน ตรงกับบทความ 3 ชิ้น · บรรทัดที่ 3 หน้าตาแปลก ๆ (&lt; …) — เก็บไว้ก่อน หัวข้อ XSS จะเฉลย

เทียบกับ Go: โปรแกรม Go สั้น ๆ ข้างล่างทำแบบเดียวกันด้วย html/template · ไม่ต้องพิมพ์ตาม แค่ดูว่าใน Go ต้องทำอะไรเองบ้าง (go build ผ่าน · Go 1.26.5):

package main

import (
	"html/template"
	"os"
)

type Post struct {
	Title string
}

func main() {
	tmpl := template.Must(template.New("list").Parse(
		"<ul>\n{{range .}}  <li>{{.Title}}</li>\n{{end}}</ul>\n"))
	posts := []Post{
		{Title: "ข้าวมันไก่ร้านดัง"},
		{Title: "<script>alert('hack')</script>"},
	}
	tmpl.Execute(os.Stdout, posts)
}
$ go run main.go
<ul>
  <li>ข้าวมันไก่ร้านดัง</li>
  <li>&lt;script&gt;alert(&#39;hack&#39;)&lt;/script&gt;</li>
</ul>

บทความที่มี <script> ก็ออกมาเป็น &lt; เหมือน Rails — เก็บไว้ก่อน เฉลยในหัวข้อ XSS

⚠️ กับดักที่มือใหม่เจอบ่อย: ใส่ = ที่บรรทัด each

ถ้าเผลอเขียน <%= @posts.each do |post| %> (มี =) ผลจริงคือ:

<ul>
  
    <li><a href="/posts/1">ข้าวมันไก่ร้านดัง</a></li>

    <li><a href="/posts/2">ส้มตำปูปลาร้า</a></li>

    <li><a href="/posts/3">&lt;script&gt;alert(&#39;hack&#39;)&lt;/script&gt;</a></li>
[{id: 1, title: &quot;ข้าวมันไก่ร้านดัง&quot;}, {id: 2, title: &quot;ส้มตำปูปลาร้า&quot;}, {id: 3, title: &quot;&lt;script&gt;alert(&#39;hack&#39;)&lt;/script&gt;&quot;}]</ul>

รายการครบ แต่มีข้อมูลดิบทั้งก้อนโผล่ท้ายรายการ — เพราะ each ทำงานเสร็จแล้วคืน array ตัวเดิมกลับมา แล้ว = ก็พิมพ์มันออกมาตามหน้าที่ · แก้โดยลบ = ที่บรรทัด each

layout — จานรองที่ทุกหน้าใช้ร่วมกัน

แปลกไหม: index.html.erb ที่เราเขียนมีแค่ 6 บรรทัด ไม่มี <html> ไม่มี <title> · แต่ HTML ที่ได้จริงขึ้นต้นแบบนี้ (head -5 = ดูแค่ 5 บรรทัดแรก):

$ curl -s http://localhost:3000/posts | head -5
<!-- BEGIN app/views/layouts/application.html.erb
--><!DOCTYPE html>
<html>
  <head>
    <title>Wongnok Blog</title>

ดูบรรทัดแรก — มาจากไฟล์ app/views/layouts/application.html.erb ที่ rails new สร้างให้ · ไฟล์นี้คือ layout: กรอบที่ครอบทุกหน้าของแอปอัตโนมัติ · ของจริงย่อเหลือส่วนสำคัญ:

<%# app/views/layouts/application.html.erb (ของจริงจาก rails new · ย่อ) %>
<!DOCTYPE html>
<html>
  <head>
    <title><%= content_for(:title) || "Wongnok Blog" %></title>
    <%= csrf_meta_tags %>
    … (meta · ไอคอน · CSS · JavaScript อีกหลายบรรทัด)
  </head>

  <body>
    <main class="container mx-auto mt-28 px-5 flex">
      <%= yield %>
    </main>
  </body>
</html>
🍽 เทียบให้เห็นภาพ — จานรองแบบเดียวกันทุกจาน

ร้านมีจานรองลายเดียวกันทุกจาน มีโลโก้ร้าน มีขอบจาน (<head> · <title>) · อาหารแต่ละเมนู (view ของแต่ละหน้า) ถูกวางลงตรงกลางจานรอง — ตรงกลางนั่นคือ <%= yield %>
อยากเปลี่ยนโลโก้ร้าน → เปลี่ยนจานรองแบบเดียว ทุกเมนูเปลี่ยนตาม ไม่ต้องไล่แก้ทีละจาน

ที่ไม่เหมือนกัน: ในร้านจริงต้องมีจานรองก่อนถึงจะวางอาหาร · แต่ Rails จัดอาหาร (view) เสร็จก่อน แล้วค่อยวางลงจานรอง · และร้านหนึ่งมีจานรองได้หลายแบบ (เช่นหน้า admin ใช้อีกแบบ) — เรื่องนั้นรอวันหลัง

เทียบกับ Go: Go ไม่มี layout ให้อัตโนมัติ ต้องจัดเอง เช่นแยกแม่แบบส่วนหัว/ท้ายแล้วเรียกใช้ในทุกหน้าเอง · Rails ครอบให้ทุกหน้าโดยไม่ต้องสั่ง (ถ้าเคยเรียนฝั่ง FRONT: {children} ใน app/layout.tsx ของ Next.js บท 83 = <%= yield %>)

🗣 ศัพท์ที่เพิ่งเรียนไป
layout
แม่แบบที่ครอบทุก view · ตัวหลักชื่อ app/views/layouts/application.html.erb · ถาม senior ได้ว่า "ถ้าจะเพิ่มเมนูด้านบนให้ทุกหน้า ต้องแก้ layout ไฟล์ไหน"
yield (ใน layout)
ช่องที่ view ของหน้านั้นถูกวางลงไป · พิมพ์บอก AI ได้ว่า "add a navbar above the yield in application.html.erb"

link_to — helper ที่สร้างลิงก์ให้

helper คือ method สำเร็จรูปที่ Rails เตรียมไว้ให้เรียกใน view แล้วคืน HTML ออกมา · ตัวที่ใช้บ่อยที่สุดคือ link_to — สร้างแท็กลิงก์ <a>

<%= link_to post[:title], post_path(post[:id]) %>
#        ↑ ข้อความที่จะโชว์   ↑ ลิงก์ไปที่ไหน

ทำไมไม่เขียน <a href="/posts/1"> เอง? เพราะถ้าวันหนึ่งเปลี่ยน URL ใน config/routes.rb ลิงก์ที่ใช้ post_path ทุกอันเปลี่ยนตามเอง · ลิงก์ที่เขียนมือต้องไล่แก้ทีละไฟล์ (ใน Go ก็ต้องไล่แก้แบบนั้น)

🪧 เทียบให้เห็นภาพ — ป้ายบอกทางที่พิมพ์จากระบบของร้าน

ป้ายบอกทางในร้านไม่ได้เขียนมือ แต่สั่งพิมพ์จากผังร้าน (routes) · วันไหนย้ายห้อง แก้ผังร้านที่เดียว ป้ายทุกใบพิมพ์ใหม่ตามเอง · link_to คือเครื่องพิมพ์ป้าย post_path คือการเปิดดูผังร้าน

ที่ไม่เหมือนกัน: เครื่องพิมพ์ป้ายจริงพิมพ์อะไรก็ได้ที่สั่ง · แต่ถ้าผังร้านไม่มีห้องชื่อนั้น (ไม่มี route) post_path พังทันที หน้าเว็บขึ้น error — ดีกว่าป้ายที่ชี้ไปห้องที่ไม่มีจริง

🗣 ศัพท์ที่เพิ่งเรียนไป
helper (view helper)
method สำเร็จรูปที่เรียกใน view ได้ · link_to เป็นหนึ่งในนั้น มีอีกเยอะ · ถาม AI ได้ว่า "is there a Rails view helper for formatting money?" ก่อนเขียนเอง
route helper · _path
method ที่ Rails สร้างจาก routes ให้ เช่น post_path(1) → "/posts/1" · พิมพ์บอก AI ได้ว่า "use link_to with the post_path route helper instead of a hard-coded URL"

ความปลอดภัยที่ได้ฟรี ① — XSS: คนร้ายแอบใส่โค้ดในชื่อบทความ

ตอนนี้เฉลยบรรทัดแปลก ๆ ได้แล้ว · สมมติมีคนร้ายตั้งชื่อบทความว่า <script>alert('hack')</script> (<script> คือแท็กที่สั่ง browser ให้รันโปรแกรม JavaScript · alert('hack') คือโปรแกรมสั้นที่สุด แค่เด้งกล่องข้อความคำว่า hack ขึ้นมา)

ถ้าเว็บเอาชื่อนี้ไปวางในหน้าเว็บตรง ๆ browser ของทุกคนที่เปิดหน้านั้นจะรันโปรแกรมของคนร้าย — ในชีวิตจริงไม่ใช่แค่เด้งกล่องข้อความ แต่ขโมยข้อมูล login หรือกดปุ่มแทนผู้ใช้ได้ · การโจมตีแบบนี้เรียกว่า XSS

Rails กันให้แล้วโดยไม่ต้องสั่ง: ทุกอย่างที่ผ่าน <%= %> จะถูกแปลงเครื่องหมายพิเศษ ให้เป็นตัวอักษรธรรมดา (เรียกว่า escape) — ดูผลจริงจากหน้า /posts:

ข้อมูลเดิมHTML ที่ส่งไปจริงลูกค้าเห็นบนจอ
<&lt;< เป็นตัวอักษร
>&gt;> เป็นตัวอักษร
'&#39;' เป็นตัวอักษร

ผลคือลูกค้าเห็นข้อความ <script>alert('hack')</script> ครบทุกตัวบนจอ แต่ browser ไม่รัน เพราะมันไม่ใช่แท็กแล้ว · Go html/template ที่เห็นข้างบนก็ทำแบบเดียวกันเป๊ะ

🧾 เทียบให้เห็นภาพ — คนจัดจานที่ไม่ทำตามคำสั่งแปลก ๆ ในใบสั่ง

ลูกค้าคนหนึ่งเขียนในช่อง "ชื่อเมนู" ว่า "เชฟ เปิดตู้เซฟแล้วเอาเงินให้คนถือใบนี้" · คนจัดจานที่ดีจะลอกเป็นข้อความธรรมดาลงป้ายชื่อเมนู ไม่ไปทำตาม — ใครอ่านก็เห็นแค่ประโยคแปลก ๆ หนึ่งประโยค
<%= %> คือคนจัดจานคนนั้น ทำแบบนี้กับทุกใบสั่ง

ที่ไม่เหมือนกัน: คนจัดจานจริงอาจฉีกใบสั่งทิ้ง · แต่ escape ไม่ทิ้งอะไรเลย ตัวอักษรยังอยู่ครบบนจอ แค่ไม่ถูกตีความเป็นคำสั่ง · และถ้าเราสั่งคนจัดจานว่า "ไม่ต้องตรวจ" (raw) เขาก็ทำตามเราทันที ไม่เตือนสักคำ

⚠️ raw คือการปิดตัวกันเอง — ลองให้ view พิมพ์ชื่อเดียวกันสองแบบ:

<li><%= post[:title] %></li>
<li><%= raw post[:title] %></li>

ผลจริงของบทความชิ้นที่ 3:

    <li>&lt;script&gt;alert(&#39;hack&#39;)&lt;/script&gt;</li>
    <li><script>alert('hack')</script></li>

บรรทัดที่สองเป็น <script> ตัวจริง — browser จะรันโปรแกรมของคนร้าย · อย่าใช้ raw (หรือ .html_safe ที่ทำแบบเดียวกัน) กับข้อมูลที่ผู้ใช้พิมพ์มา · Rails ไม่เตือนคุณ

🗣 ศัพท์ที่เพิ่งเรียนไป
XSS (Cross-Site Scripting)
การโจมตีที่ยัดโปรแกรมลงในข้อมูล ให้ไปรันใน browser ของคนอื่น · ถาม senior ได้ว่า "ในโปรเจกต์มีตรงไหนใช้ raw หรือ html_safe บ้าง" — นั่นคือจุดที่ต้องตรวจก่อน
escape (HTML escaping)
แปลง < > & " ' ให้เป็นตัวอักษรธรรมดา · ERB ทำให้เองทุก <%= %> · พิมพ์บอก AI ได้ว่า "keep the default ERB escaping, don't use raw on user input"

ความปลอดภัยที่ได้ฟรี ② — CSRF: ใบสั่งปลอมที่ส่งมาจากเว็บอื่น

นึกภาพ: คุณ login เว็บบล็อกค้างไว้ แล้วเผลอเปิดเว็บของคนร้ายในอีกแท็บ · เว็บคนร้ายแอบส่งคำสั่ง "ลบบทความ 1" มาที่เว็บบล็อกของเรา · browser ของคุณแนบ cookie ของเว็บบล็อกไปด้วยอัตโนมัติ (cookie คือข้อมูลที่ browser จำไว้แล้วแนบไปทุกครั้งที่คุยกับเว็บนั้น — เช่นสถานะ login · คือ "บัตรสมาชิก" ที่ด่านหน้าครัวอ่านในบท 100)

เว็บบล็อกเห็น cookie ถูกต้อง เลยนึกว่าคุณกดเอง → บทความหาย · การโจมตีนี้เรียกว่า CSRF

วิธีกันของ Rails: ทุกหน้าที่เว็บเราส่งออกไปจะมีรหัสลับ (token) ฝังไว้ · คำสั่งที่เปลี่ยนแปลงข้อมูล (POST · PATCH · DELETE — HTTP method ที่เห็นในตาราง resources บท 101) ต้องแนบรหัสนี้มาด้วย · เว็บคนร้ายอ่านรหัสในหน้าเว็บของเราไม่ได้ จึงแนบไม่ได้

รหัสอยู่ใน <head> ทุกหน้า (มาจาก csrf_meta_tags ใน layout) · grep csrf = เลือกเฉพาะบรรทัดที่มีคำว่า csrf:

$ curl -s http://localhost:3000/posts | grep csrf
    <meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="6e6uhu9U0vaKkwzpc8AlH2I6EZbbipkLjmQ0L2txoAXHEpPcBGXLnjU4xtiSLVAHWSIgez5ZrubgGAHKmENXng" />

(รหัสสุ่มใหม่ทุกครั้ง ของคุณจะต่างจากนี้) · จะทดลองได้ ต้องมีคำสั่ง "ลบ" ให้คนร้ายยิงก่อน · ผมเพิ่ม :destroy ใน routes กับ action destroy ที่ลบแบบจำลอง (ยังไม่มีฐานข้อมูล จึงแค่ตอบข้อความกลับ):

# config/routes.rb — เพิ่ม :destroy เข้าไป
resources :posts, only: [:index, :show, :destroy]

# app/controllers/posts_controller.rb — action ใหม่
def destroy
  # ยังไม่ลบจริง แค่ตอบข้อความกลับไป
  render plain: "ลบบทความ #{params[:id]} แล้ว (จำลอง)"
end

ทีนี้ลองเป็นคนร้าย — ส่งคำสั่ง DELETE ตรง ๆ ไม่แนบรหัส (-X DELETE = ส่งเป็น method DELETE · -o /dev/null -w '%{http_code}' = ไม่ต้องโชว์เนื้อหา พิมพ์แค่เลขสถานะ):

$ curl -s -o /dev/null -w '%{http_code}\n' -X DELETE http://localhost:3000/posts/1
422

ใน terminal ที่เปิด server ไว้ (หรือไฟล์ log/development.log) เขียนเหตุผลไว้ (ตัดมาเฉพาะต้น ๆ · ใต้บรรทัดสุดท้ายยังมีรายชื่อไฟล์ของ Rails อีกยาวมาก ไม่ต้องอ่าน):

Started DELETE "/posts/1" for ::1 at 2026-11-03 21:11:51 +0700
Processing by PostsController#destroy as */*
  Parameters: {"id" => "1"}
Can't verify CSRF token authenticity.
Completed 422 Unprocessable Content in 0ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)


  
ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.):

422 = "ไม่รับใบสั่งนี้" · บรรทัด Can't verify CSRF token authenticity. แปลว่า "ตรวจรหัสไม่ผ่าน" · และข้อความ "ลบบทความ…" ไม่โผล่ = action destroy ไม่ได้ทำงานเลย ทั้งที่ใน PostsController ไม่ได้เขียนอะไรเรื่อง token สักบรรทัด (เวลาในบรรทัดแรกของคุณจะต่างจากนี้)

เทียบกับ "ลูกค้าตัวจริง" ที่เปิดหน้าเว็บเราก่อน แล้วถือรหัสกับ cookie กลับมา (สองบรรทัดนี้ยาว ไม่ต้องพิมพ์ตาม ดูแค่ผล):

$ TOKEN=$(curl -s -c jar.txt http://localhost:3000/posts | grep -o 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"$//')
$ curl -s -b jar.txt -X DELETE -H "X-CSRF-Token: $TOKEN" http://localhost:3000/posts/1
ลบบทความ 1 แล้ว (จำลอง)
🎫 เทียบให้เห็นภาพ — บัตรคิวที่ร้านแจกหน้าร้าน

ร้านแจกบัตรคิวให้เฉพาะลูกค้าที่เดินเข้ามาในร้านจริง · ใบสั่งที่ "เปลี่ยนของ" (สั่งเพิ่ม · ยกเลิก) ต้องแนบบัตรคิวมาด้วย · ใบสั่งที่มีคนฝากมาจากร้านอื่นไม่มีบัตร → พนักงานไม่รับ (422)

ที่ไม่เหมือนกัน: ร้านจริงอาจขอบัตรทุกครั้ง · แต่ Rails ไม่ขอรหัสตอนแค่ดูหน้า (GET) ขอเฉพาะคำสั่งที่เปลี่ยนข้อมูล · และบัตรนี้ไม่ได้บอกว่าคุณเป็นใคร บอกแค่ว่า "ใบสั่งนี้มาจากหน้าเว็บของร้านเราจริง" — เรื่องยืนยันตัวตน (login) รอ Day 4

เทียบกับ Go: ใน Gin ที่เรียนมา เรื่องนี้ไม่ได้เปิดให้เอง ต้องเลือกเครื่องมือมาเสียบเพิ่ม · Rails เปิดไว้ตั้งแต่ rails new · ข้อดีคือไม่ลืม · ข้อควรระวังคือถ้าไปปิดเอง ก็ไม่มีใครเตือนเหมือนกัน

🗣 ศัพท์ที่เพิ่งเรียนไป
CSRF (Cross-Site Request Forgery)
การปลอมคำสั่งจากเว็บอื่นโดยอาศัย cookie ของเหยื่อ · ถาม senior ได้ว่า "API ตัวนี้ปิดการตรวจ CSRF ไว้หรือเปล่า เพราะอะไร"
authenticity token · CSRF token
รหัสลับที่ Rails ฝังในหน้าเว็บ แล้วตรวจกับทุกคำสั่งที่เปลี่ยนข้อมูล
422 Unprocessable Content
เลขสถานะที่ Rails ตอบเมื่อรหัสไม่ผ่าน · เห็นคู่กับ ActionController::InvalidAuthenticityToken ใน log · พิมพ์บอก AI ได้ว่า "my DELETE request gets 422 InvalidAuthenticityToken — the CSRF token is missing"

อีกเรื่องที่สไลด์หน้า 66 พูดถึง: SQL injection (คนร้ายยัดคำสั่งฐานข้อมูลลงในช่องค้นหา) · บทนี้ยังไม่มีฐานข้อมูลเลย จึงยกไป Day 2 ที่เรียน Active Record (ส่วนที่คุยกับฐานข้อมูล) — ตอนนั้นจะลองโจมตีให้ดูจริง

ภารกิจ — หน้ารายการบทความของคุณเอง

🎯 ภารกิจ (~10 นาที) · ทำในแอป wongnok_blog ของคุณ (บท 98)

  1. เปิด config/routes.rb เพิ่มบรรทัด resources :posts, only: [:index, :show] ไว้ใต้ Rails.application.routes.draw do
  2. สร้างไฟล์ app/controllers/posts_controller.rb ใส่ action index (ตั้ง @posts 3 ชิ้น ชิ้นหนึ่งชื่อ <script>alert('hack')</script>) และ action show ตามที่เห็นในบท
  3. สร้างโฟลเดอร์ app/views/posts/ แล้วสร้าง index.html.erb (วนด้วย each + link_to) กับ show.html.erb (โชว์ชื่อ + รหัสบทความ — คราวนี้อย่าลืม =)
  4. รัน bin/rails server แล้วเปิด http://localhost:3000/posts ใน browser
  5. ควรเห็น: ลิงก์ 3 อัน · อันที่ 3 เป็นตัวอักษร <script>… และไม่มีกล่อง alert เด้ง · กดลิงก์แรกแล้วไปหน้า /posts/1 ที่มีชื่อบทความกับ "รหัสบทความ: 1"
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    # ยังไม่มีฐานข้อมูล (รอ Day 2) — จำลองบทความด้วย Hash
    @posts = [
      { id: 1, title: "ข้าวมันไก่ร้านดัง" },
      { id: 2, title: "ส้มตำปูปลาร้า" },
      { id: 3, title: "<script>alert('hack')</script>" }
    ]
  end

  def show
    @post = { id: params[:id], title: "ข้าวมันไก่ร้านดัง", published: true }
  end
end
<%# app/views/posts/index.html.erb %>
<h1>บทความทั้งหมด</h1>
<ul>
  <% @posts.each do |post| %>
    <li><%= link_to post[:title], post_path(post[:id]) %></li>
  <% end %>
</ul>
<%# app/views/posts/show.html.erb %>
<h1><%= @post[:title] %></h1>
<p>รหัสบทความ: <%= @post[:id] %></p>

บรรทัดแรก class PostsController < ApplicationController = "สร้างหัวหน้าเชฟแผนกบทความ ที่ได้ความสามารถทั้งหมดของเชฟประจำร้านมาด้วย" (< คือการสืบทอด บท 95 · ApplicationController มีอยู่แล้วตั้งแต่ rails new)

ถ้าใช้ข้อมูลชุดเดียวกับบท curl -s http://localhost:3000/posts | sed -n '/<main/,/<\/main>/p' จะได้ HTML เหมือนหัวข้อ "วนรายการ" ทุกตัวอักษร · ถ้าเปลี่ยนชื่อบทความเป็นของคุณเอง ข้อความก็เปลี่ยนตาม แต่ < ยังกลายเป็น &lt; เหมือนเดิม

ถ้าเจอ error นี้ แปลว่า…

ActionView::Template::Error (undefined method 'each' for nil)

view หา @posts ไม่เจอ (ได้ nil = ไม่มีค่า) — ส่วนมากเพราะใน controller สะกดชื่อตัวแปรไม่ตรง เช่นเขียน @post = [...] (ขาด s) ใน action index · ผมลองทำพลาดแบบนี้จริงแล้วได้ข้อความนี้เป๊ะ

ActionView::Template::Error (undefined method 'post_path' for an instance of #<Class:0x000000012bb7e068>)

ยังไม่มี route ของหน้า show — ลืมใส่ :show ใน only: [...] · Rails จึงไม่ได้สร้าง post_path ให้ (เลข 0x0000… ของคุณจะต่างจากนี้ ไม่ต้องสนใจ)

ทดสอบความเข้าใจ

controller ตั้ง @author = "สมชาย" · view เขียน <p>ผู้เขียน: <% @author %></p> · ลูกค้าได้ HTML อะไร?

ตัวเลือกที่ 2 ถูก · ผลรันจริงคือ <p>ผู้เขียน: </p>
ตัวเลือกที่ 1 ผิดเพราะต้องมี = ถึงจะพิมพ์ · ตัวเลือกที่ 3 ผิดเพราะไม่ผิดไวยากรณ์ Ruby รันได้ปกติ แค่ไม่โชว์ · ตัวเลือกที่ 4 ผิดเพราะ ERB จัดการทุกแท็ก <% %> ที่ฝั่ง server ก่อนส่ง ลูกค้าไม่เคยเห็นตัวแท็ก
จุดที่ต้องจำ: ค่าไม่โผล่และไม่มี error → เช็คก่อนเลยว่าลืม = หรือเปล่า

ไฟล์ index.html.erb ไม่มีแท็ก <title> เลย แต่ HTML ที่ได้มี <title>Wongnok Blog</title> · มาจากไหน?

ตัวเลือกที่ 3 ถูก · บรรทัดแรกของ HTML จริงคือ <!-- BEGIN app/views/layouts/application.html.erb และ layout มี content_for(:title) || "Wongnok Blog"
ตัวเลือกที่ 1 ผิดเพราะ browser แค่แสดงสิ่งที่ได้รับ · ตัวเลือกที่ 2 ผิดเพราะ PostsController ไม่มีบรรทัดไหนพูดถึง title · ตัวเลือกที่ 4 ผิดเพราะ Tailwind เป็นแค่ CSS (เรื่องหน้าตา) ไม่ได้สร้าง HTML
จุดที่ต้องจำ: ของที่เหมือนกันทุกหน้าอยู่ใน layout · ของเฉพาะหน้าอยู่ใน view และ view ถูกวางตรง yield

มีคนตั้งชื่อบทความว่า <b>อร่อย</b> แล้ว view พิมพ์ด้วย <%= %> · ลูกค้าเห็นอะไรบนจอ?

ตัวเลือกที่ 1 ถูก · ผลรันจริงคือ <p>&lt;b&gt;อร่อย&lt;/b&gt;</p> — < กลายเป็น &lt; browser จึงโชว์เป็นตัวอักษร
ตัวเลือกที่ 2 จะเกิดเฉพาะเมื่อใช้ raw · ตัวเลือกที่ 3 และ 4 ผิดเพราะ escape ไม่ลบอะไรเลย แค่เปลี่ยนให้เป็นตัวอักษรธรรมดา
จุดที่ต้องจำ: <%= %> escape ให้เสมอ · ข้อความยังอยู่ครบ แต่ไม่ถูกตีความเป็นแท็ก

ส่ง curl -X DELETE http://localhost:3000/posts/1 โดยไม่แนบรหัส token · เกิดอะไรขึ้น?

ตัวเลือกที่ 4 ถูก · ผลรันจริงคือ 422 และ log เขียน Can't verify CSRF token authenticity. ต่อด้วย Completed 422 ทันที ไม่มีข้อความจาก destroy เลย
ตัวเลือกที่ 1 คือสิ่งที่คนร้ายอยากให้เกิด · ตัวเลือกที่ 2 ผิดเพราะ route ยังอยู่ แค่ถูกปฏิเสธ · ตัวเลือกที่ 3 ผิดเพราะ action ไม่ได้เริ่มทำงานด้วยซ้ำ
จุดที่ต้องจำ: คำสั่งที่เปลี่ยนข้อมูล (POST · PATCH · DELETE) ต้องมี token · ไม่มี = 422 โดยที่เราไม่ต้องเขียนโค้ดตรวจเอง

ติดตรงไหน ถามกลับได้เลย — เอาโค้ด view ของคุณกับ HTML ที่ curl ได้มาวางคู่กัน จะเห็นเร็วมากว่าแท็กไหนผิด

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริง (Rails 8.1.4)ต้องสนใจไหม
64layout มี <%= yield %> อยู่ใน <body> ตรง ๆห่อด้วย <main class="container mx-auto mt-28 px-5 flex">นิดเดียว — ระยะขอบบนกว้าง ๆ มาจากตรงนี้
64stylesheet_link_tag "application"ไฟล์จริงใช้ :app = รวม CSS ทุกไฟล์ (รวม Tailwind)สนใจ — อย่าแก้ตามสไลด์ ไม่งั้น Tailwind หาย
64–65@post.title · post_path(@post)ใช้ได้เมื่อ @post เป็น model (Day 2) · ตอนนี้เป็น Hash ต้องใช้ @post[:title] · post_path(@post[:id])สนใจ — @post.title ได้ error · post_path(@post) ได้ URL แปลก ๆ มี ?title=… ต่อท้าย
65helper อีกหลายตัว (image_tag · time_ago_in_words · form_with …)บทนี้สอนแค่ link_toไม่ต้องรีบ — เจอตอนใช้จริง Day 2–3
66escape แปลง < > &แปลง ' กับ " ด้วย (&#39; · &quot; ในผลรันข้างบน)ไม่ต้อง — ปลอดภัยกว่าที่บอก
66Rails ตรวจ token "ทุก POST"ตรวจทุกคำสั่งที่ไม่ใช่ GET/HEAD — DELETE ก็ได้ 422สนใจ — ลอง DELETE ด้วย curl แล้วได้ 422 ไม่ใช่บั๊ก

อ้างอิง