📌 ทวนก่อนเริ่ม — คำจากบทก่อนที่บทนี้ใช้
- controller / action (บท 102) — หัวหน้าเชฟของแผนก / เมนูแต่ละอย่าง เช่น
PostsControllerกับ actionindex @ตัวแปร(บท 95, 102) — ตัวแปรที่ controller ตั้งไว้ แล้ว view หยิบไปใช้ได้- Hash กับ symbol (บท 92) —
post[:title]แปลว่า "หยิบค่าที่ติดป้าย:titleออกมาจากกล่องpost" each do |post| … end(บท 92–93) — ทำซ้ำทีละชิ้น ชิ้นที่กำลังทำชื่อpostpost_path(1)(บท 101) — method ที่ Rails สร้างจาก routes ให้ คืนค่าเป็น"/posts/1"
🧰 ใช้ตรวจ: 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 ที่มีช่องให้เติมข้อมูล
ไฟล์ 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
def show … end— action ชื่อshow(บท 102) ทำงานเมื่อมีคนเปิดหน้าบทความทีละชิ้น{ id: …, title: …, published: … }— Hash (บท 92) ·title:คือป้ายชื่อ:titleแล้วตามด้วยค่า · ทั้งก้อนคือ "บทความปลอม 1 ชิ้น"params[:id]— เลขจาก URL (บท 102) · เปิด/posts/42ก็ได้"42"published: true— แปลว่า "บทความนี้เผยแพร่แล้ว" (เดี๋ยวใช้กับif)
⚠️ ถ้าดูสไลด์ไปด้วย: สไลด์เขียน @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>
ทีละบรรทัด:
<% @posts.each do |post| %>— "ทำซ้ำทุกชิ้นใน@postsชิ้นที่กำลังทำเรียกว่าpost" · ไม่มี=เพราะเป็นคำสั่งถึงคนจัด ไม่ใช่ของที่จะวางลงจาน<li>…</li>— HTML ธรรมดา อยู่ในวง จึงถูกพิมพ์ซ้ำทุกรอบ<%= link_to … %>— สร้างลิงก์ (อธิบายในอีกสองหัวข้อ) · มี=เพราะอยากให้ลิงก์โผล่<% end %>— ปิดวง (คู่กับdo)
$ 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"><script>alert('hack')</script></a></li>
</ul>
<!-- END app/views/posts/index.html.erb -->
</main>
ได้ <li> 3 อัน ตรงกับบทความ 3 ชิ้น · บรรทัดที่ 3 หน้าตาแปลก ๆ (< …) — เก็บไว้ก่อน หัวข้อ 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><script>alert('hack')</script></li>
</ul>
type Post struct— Go ต้องประกาศหน้าตาบทความเองก่อน (Rails ตอนนี้ใช้ Hash แทน)template.Must(template.New("list").Parse(…))— สร้างแม่แบบจากข้อความ ·Mustแปลว่า "ถ้าแม่แบบเขียนผิด ให้หยุดโปรแกรมเลย"{{range .}} … {{end}}— วนทุกชิ้น ·.คือข้อมูลที่ส่งเข้ามา =<% @posts.each do |post| %> … <% end %>tmpl.Execute(os.Stdout, posts)— เติมข้อมูลแล้วพิมพ์ออกจอ · ต้องส่งpostsเข้าไปเอง ส่วน Rails หยิบ@postsจาก controller ให้เอง
บทความที่มี <script> ก็ออกมาเป็น < เหมือน 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"><script>alert('hack')</script></a></li>
[{id: 1, title: "ข้าวมันไก่ร้านดัง"}, {id: 2, title: "ส้มตำปูปลาร้า"}, {id: 3, title: "<script>alert('hack')</script>"}]</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>
<%= yield %>— ช่องที่ view ของแต่ละหน้าจะถูกวางลงไป ·yieldตัวเดียวกับบท 97 ที่แปลว่า "ตรงนี้ให้ของที่ส่งเข้ามาทำงาน" · เปิด/postsช่องนี้ก็เป็นรายการบทความ เปิด/posts/42ก็เป็นหน้าบทความcontent_for(:title) || "Wongnok Blog"— "ถ้าหน้านั้นไม่ได้ตั้งชื่อเอง ให้ใช้ Wongnok Blog" (||= "ถ้าไม่มี ใช้ตัวขวา" จากบท 94) · เรื่องตั้งชื่อหน้าเองรอวันหลังcsrf_meta_tags— "บัตรคิว" ที่อีกสองหัวข้อจะอธิบาย · อยู่ใน layout จึงมีในทุกหน้าclass="container mx-auto mt-28 …"— class ของ Tailwind (เพราะrails new … --css=tailwind) · หน้าเว็บมีระยะขอบบนกว้าง ๆ มาจากตรงนี้
ร้านมีจานรองลายเดียวกันทุกจาน มีโลโก้ร้าน มีขอบจาน (<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]) %>
# ↑ ข้อความที่จะโชว์ ↑ ลิงก์ไปที่ไหน
link_toรับ 2 อย่างคั่นด้วยจุลภาค: ข้อความ กับ ที่อยู่ (ละวงเล็บได้ — บท 97)post_path(post[:id])— route helper จากบท 101 · ส่ง 1 เข้าไปได้"/posts/1"- ผลจริงจากข้างบน:
<a href="/posts/1">ข้าวมันไก่ร้านดัง</a>
ทำไมไม่เขียน <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 ที่ส่งไปจริง | ลูกค้าเห็นบนจอ |
|---|---|---|
< | < | < เป็นตัวอักษร |
> | > | > เป็นตัวอักษร |
' | ' | ' เป็นตัวอักษร |
ผลคือลูกค้าเห็นข้อความ <script>alert('hack')</script> ครบทุกตัวบนจอ
แต่ browser ไม่รัน เพราะมันไม่ใช่แท็กแล้ว · Go html/template ที่เห็นข้างบนก็ทำแบบเดียวกันเป๊ะ
ลูกค้าคนหนึ่งเขียนในช่อง "ชื่อเมนู" ว่า "เชฟ เปิดตู้เซฟแล้วเอาเงินให้คนถือใบนี้" ·
คนจัดจานที่ดีจะลอกเป็นข้อความธรรมดาลงป้ายชื่อเมนู ไม่ไปทำตาม — ใครอ่านก็เห็นแค่ประโยคแปลก ๆ หนึ่งประโยค
<%= %> คือคนจัดจานคนนั้น ทำแบบนี้กับทุกใบสั่ง
ที่ไม่เหมือนกัน: คนจัดจานจริงอาจฉีกใบสั่งทิ้ง · แต่ escape ไม่ทิ้งอะไรเลย
ตัวอักษรยังอยู่ครบบนจอ แค่ไม่ถูกตีความเป็นคำสั่ง ·
และถ้าเราสั่งคนจัดจานว่า "ไม่ต้องตรวจ" (raw) เขาก็ทำตามเราทันที ไม่เตือนสักคำ
⚠️ raw คือการปิดตัวกันเอง — ลองให้ view พิมพ์ชื่อเดียวกันสองแบบ:
<li><%= post[:title] %></li>
<li><%= raw post[:title] %></li>
ผลจริงของบทความชิ้นที่ 3:
<li><script>alert('hack')</script></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
:destroyใน routes (บท 101) — เปิดทางDELETE /posts/1ให้ไปที่ actiondestroyrender plain: "…"— ตอบกลับเป็นข้อความเปล่า ๆ ไม่ใช้ไฟล์ view (render จากบท 102) ·#{params[:id]}= เอาเลขจาก URL มาแทรกใน string (บท 91)
ทีนี้ลองเป็นคนร้าย — ส่งคำสั่ง 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 แล้ว (จำลอง)
- บรรทัดแรก — เปิดหน้า
/postsเหมือนลูกค้าจริง · เก็บ cookie ลงไฟล์jar.txt(-c) · แล้วตัดเอาเฉพาะรหัสใส่ตัวแปรTOKEN - บรรทัดที่สอง — ส่ง DELETE พร้อม cookie (
-b) และรหัส (-H= แนบไว้ในหัวจดหมาย) → ผ่าน ได้ข้อความจากdestroy - ในเว็บจริงคุณไม่ต้องทำเองแบบนี้ ฟอร์มและปุ่มของ Rails แนบรหัสให้เอง (เจอใน Day 2–3)
ร้านแจกบัตรคิวให้เฉพาะลูกค้าที่เดินเข้ามาในร้านจริง · ใบสั่งที่ "เปลี่ยนของ" (สั่งเพิ่ม · ยกเลิก) ต้องแนบบัตรคิวมาด้วย · ใบสั่งที่มีคนฝากมาจากร้านอื่นไม่มีบัตร → พนักงานไม่รับ (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)
- เปิด
config/routes.rbเพิ่มบรรทัดresources :posts, only: [:index, :show]ไว้ใต้Rails.application.routes.draw do - สร้างไฟล์
app/controllers/posts_controller.rbใส่ actionindex(ตั้ง@posts3 ชิ้น ชิ้นหนึ่งชื่อ<script>alert('hack')</script>) และ actionshowตามที่เห็นในบท - สร้างโฟลเดอร์
app/views/posts/แล้วสร้างindex.html.erb(วนด้วยeach+link_to) กับshow.html.erb(โชว์ชื่อ + รหัสบทความ — คราวนี้อย่าลืม=) - รัน
bin/rails serverแล้วเปิดhttp://localhost:3000/postsใน browser - ควรเห็น: ลิงก์ 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 เหมือนหัวข้อ "วนรายการ" ทุกตัวอักษร · ถ้าเปลี่ยนชื่อบทความเป็นของคุณเอง ข้อความก็เปลี่ยนตาม
แต่ < ยังกลายเป็น < เหมือนเดิม
ถ้าเจอ 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><b>อร่อย</b></p> — < กลายเป็น < 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) | ต้องสนใจไหม |
|---|---|---|---|
| 64 | layout มี <%= yield %> อยู่ใน <body> ตรง ๆ | ห่อด้วย <main class="container mx-auto mt-28 px-5 flex"> | นิดเดียว — ระยะขอบบนกว้าง ๆ มาจากตรงนี้ |
| 64 | stylesheet_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=… ต่อท้าย |
| 65 | helper อีกหลายตัว (image_tag · time_ago_in_words · form_with …) | บทนี้สอนแค่ link_to | ไม่ต้องรีบ — เจอตอนใช้จริง Day 2–3 |
| 66 | escape แปลง < > & | แปลง ' กับ " ด้วย (' · " ในผลรันข้างบน) | ไม่ต้อง — ปลอดภัยกว่าที่บอก |
| 66 | Rails ตรวจ token "ทุก POST" | ตรวจทุกคำสั่งที่ไม่ใช่ GET/HEAD — DELETE ก็ได้ 422 | สนใจ — ลอง DELETE ด้วย curl แล้วได้ 422 ไม่ใช่บั๊ก |
อ้างอิง
- Rails Guides — Action View Overview —
อ่านหัวข้อ 3.1 ERB (แท็ก
<% %>กับ<%= %>) และ หัวข้อ 5 Layouts - Rails Guides — Layouts and Rendering in Rails — อ่านหัวข้อ 2.1 (ทำไม action หา view เองได้) และ หัวข้อ 3.2 Understanding yield
- Rails API — ActionView::Helpers::UrlHelper —
ดูเฉพาะ method
link_toตัวอย่างแรก ๆ พอ - Rails Guides — Securing Rails Applications — อ่านหัวข้อ 4 Cross-Site Request Forgery (CSRF) และ 7.3 Cross-Site Scripting (XSS) · หัวข้อ 7.2 SQL Injection เก็บไว้อ่านตอน Day 2
- Go — package html/template — อ่านย่อหน้าแรกของ Overview ที่บอกว่า escape ให้อัตโนมัติ — กติกาเดียวกับ ERB