Site kurmak

Formlar ve action'lar

Form gönderimlerini, fetch çağrılarını ve webhook'ları action'larla karşılamak ve onları istek sahteciliğine karşı korumak.

Bir sayfa GET ve HEAD isteklerine yanıt verir. Geri kalan her şey — bir form gönderimi, bir fetch() çağrısından gelen DELETE, bir ödeme sağlayıcısının webhook'u — bir action'dır: isteği alan ve neyle yanıt verileceğini söyleyen bir fonksiyon.

Hiçbir şeyin yanıt vermediği bir metotla gelen istek, hiçbir şey olmamış gibi render edilen sayfayı değil, Allow header'ı taşıyan bir 405 alır; hata hook'ları bunu collage.ErrMethodNotAllowed olarak görür. OPTIONS da aynı listeden yanıtlanır.

Bir sayfadaki action

Olağan durum, üzerinde bulunduğu sayfaya gönderilen bir formdur. Sayfaya o metot için bir action verin:

collage.NewPage("contact").
	WithLayout(layout).
	WithContent(contactForm).
	WithPath("en", "/contact").
	WithAction("POST", sendMessage).
	Build()
<form method="post" action="{{pageURL "contact"}}">
  {{csrfToken}}
  <input type="email" name="email">
  <textarea name="message"></textarea>
  <button type="submit">Send</button>
</form>

Action, sayfanın yollarını sayfanın bildirdiği her locale'de devralır; böylece form hem /contact'ta hem /tr/iletisim'de aynı şekilde çalışır.

Bir action handler'ı şu biçimdedir:

type ActionHandlerFunc func(ctx context.Context, rc *collage.RenderContext) (*collage.ActionResult, error)

rc.Request istektir ve gövdesi zaten sınırlandırılmıştır (bkz. İstek gövdeleri); rc.Locale ve rc.Param bir data handler'da nasıl çalışıyorsa öyle çalışır.

WithAction, tek bir metot için bir kısaltmadır. Birden fazla metot, kendine ait bir gövde sınırı ya da sahtecilik denetiminin olmaması için action'ı NewAction ile kurun ve WithActionFor ile bağlayın — action'ın kendi yolları sayfanınkilerle değiştirilir:

collage.NewPage("post").
	WithContent(post).
	WithPath("en", "/posts/{slug}").
	WithActionFor(collage.NewAction("post-edit").
		WithMethods(http.MethodPut, http.MethodDelete).
		WithMaxBodyBytes(64 << 10).
		WithHandler(editPost).
		Build()).
	Build()

Kendi URL'sindeki action

Bir sayfayla ilgili olmayan bir action — bir JSON endpoint'i, bir webhook — tek başına kaydedilir:

err := app.RegisterAction(collage.NewAction("like").
	WithPath("en", "/api/like").
	WithMethods(http.MethodPost).
	WithHandler(like).
	Build())

Bir action bir sayfayla aynı yolu paylaşabilir — WithAction'ın yaptığı tam olarak budur — ama iki action aynı yolda aynı metoda yanıt veremez. Adı, yolu, metodu ya da handler'ı olmayan bir action'ı RegisterAction reddeder; zaten alınmış bir adla gelen ikinci bir action'ı da. Her birinin kendi hatası vardır ve hepsi Hatalar sayfasında listelenir.

Action neyle yanıt verir

Yanıtı bir ActionResult belirler. Onu bir yardımcı fonksiyonla oluşturun:

Yardımcı Yanıt
collage.SeeOther(url) url'ye 303 See Other
collage.RenderPage(page) Bu isteğin RenderContext'iyle render edilmiş bütün bir sayfa
collage.RenderFragment(f) Layout olmadan, tek bir fragment'in işaretlemesi
collage.JSON(status, body) application/json olarak body
collage.JSONOf(status, v) application/json olarak marshal edilmiş v; (*ActionResult, error) döner
collage.NoContent(status) Yalnızca bir durum kodu, gövde yok

Ya da struct'ı kendiniz doldurun. Struct'ın gövde üretmenin dört yolu vardır — Location, Fragment, Page ve Body — ve bu sırayla, ilk ayarlanan kazanır. Status, her birinin seçeceği durum kodunun yerine geçer; Header yanıta yazılır (Set-Cookie'nin yeri burasıdır) ve ContentType, Body ile birlikte kullanılır. Boş bir ContentType application/octet-stream demektir; asla byte'lardan tahmin edilmez. nil bir sonuç 204'tür.

func like(ctx context.Context, rc *collage.RenderContext) (*collage.ActionResult, error) {
	n, err := store.Like(ctx, rc.Request.PostFormValue("post"))
	if err != nil {
		return nil, err
	}
	return collage.JSONOf(http.StatusOK, likeResponse{Count: n})
}

Handler'dan dönen bir hata 500'dür; collage.ErrNotFound'u sarmalıyorsa 404. Bir action'ın yanıtı, header'ı kendiniz ayarlamadıkça Cache-Control: no-store ile gönderilir ve asla sayfa önbelleğine girmez: tek bir gönderimden, onu gönderen kişi için üretilmiştir.

Başarısızlık render eder, başarı yönlendirir

RenderPage da SeeOther da bir forma verilebilecek iyi yanıtlardır. İkisi arasındaki seçimi, tarayıcının ardından ne yapacağı belirler.

Bir POST'a sayfayla yanıt vermek, adres çubuğunu gönderilen URL'de ve geçmiş kaydını bir POST olarak bırakır; bu yüzden sayfayı yenilemek formu yeniden gönderir. 303 ile yanıt vermek ise tarayıcının bir sonraki isteğini başka bir yere yapılan bir GET yapar ve onu yenilemek zararsızdır. Dolayısıyla:

Reddedilen bir gönderim sayfayı render eder, 422 durum koduyla. Okuyucu düzeltip yeniden gönderecektir — amaç zaten yeniden göndermektir — ve reddin nedenini ve yazdıklarını okuyucunun önüne geri getirmenin yolu render etmektir.

Kabul edilen bir gönderim yönlendirir, 303 ile. İş yapılmıştır ve bir yenileme onu ikinci kez yapmamalıdır.

Doğrulama için yeniden render

Handler ve yanıt olarak verdiği sayfa tek bir RenderContext paylaşır. Handler'ın oraya rc.Set ile koyduğu her şeyi sayfanın data handler'ları collage.Get ile okuyabilir. Oturum yok, flash mesajı yok, query string'de hiçbir şey yok: ikisi aynı istektir.

type contactView struct {
	Error string
	Email string
}

func ContactPage() *collage.Page {
	form := collage.NewFragment("contact-form", "pages/contact.html").
		WithDataHandler(collage.DataHandler(contactData)).
		Build()

	var page *collage.Page
	page = collage.NewPage("contact").
		WithLayout(layouts.Layout()).
		WithContent(form).
		WithPath("en", "/contact").
		WithAction("POST", func(ctx context.Context, rc *collage.RenderContext) (*collage.ActionResult, error) {
			email := strings.TrimSpace(rc.Request.PostFormValue("email"))
			if !strings.Contains(email, "@") {
				rc.Set("contact:form", contactView{Error: "That does not look like an email address.", Email: email})
				result := collage.RenderPage(page)
				result.Status = http.StatusUnprocessableEntity
				return result, nil
			}
			if err := messages.Send(ctx, email, rc.Request.PostFormValue("message")); err != nil {
				return nil, err
			}
			return collage.SeeOther("/contact/thanks"), nil
		}).
		Build()
	return page
}

func contactData(_ context.Context, rc *collage.RenderContext) (contactView, []string, error) {
	// Set by the action when this render is its answer; empty on an ordinary GET.
	view, _ := collage.Get[contactView](rc, "contact:form")
	return view, nil, nil
}
<form method="post" action="{{pageURL "contact"}}">
  {{csrfToken}}
  {{with .Error}}<p class="error">{{.}}</p>{{end}}
  <input type="email" name="email" value="{{.Email}}">
  <textarea name="message"></textarea>
  <button type="submit">Send</button>
</form>

Sayfa, her sayfa gibi, render hook'ları dahil render edilir: bir plugin'in OnAfterRender'ı — bir küçültücü (minifier), bir görsel yeniden yazıcı — doğrulama sayfasını da bir GET'in aldığı sayfayı biçimlendirdiği gibi biçimlendirir. Bkz. Plugin yazmak.

Yanıt olarak verdiğiniz sayfa, app.RegisterPage ile kaydettiğiniz değer olmalıdır. Bir sayfanın içeriğini layout'una yerleştiren şey kayıttır; bu yüzden handler içinde kurulan bir sayfa, hiçbir şeyin etrafında duran bir layout olarak render edilirdi. collage böyle bir sayfayı, adını vererek collage.ErrUnregisteredPage ile reddeder. Yukarıdaki page üzerindeki closure, action'a kendi sayfasını vermenin en basit yoludur.

Bir sayfaya yolu yerine adıyla yönlendirmek için app.URL kullanın.

Sahteciliğe karşı koruma

Güvenli olmayan bir metotla (GET, HEAD ve OPTIONS dışındaki her şey) bir action'a gelen her istek, handler çalışmadan önce bir sahtecilik token'ı açısından denetlenir. Geçerli bir token taşımayan istek 403 alır ve handler onu hiç görmez. Hata hook'ları nedenini görür: token ya da çerez yoksa collage.ErrCSRFMissing, token çerezinkiyle eşleşmiyorsa collage.ErrCSRFMismatch, token'ı bu uygulama imzalamamışsa collage.ErrCSRFInvalid.

Formda

{{csrfToken}}'ı <form>'un içine koyun. Alan adı dahil gizli input'un tamamını render eder; yani yanlış yapılabilecek bir şey kalmaz:

<form method="post">
  {{csrfToken}}
  ...
</form>

Şema, imzalı bir double-submit çerezidir: token rastgele bir değer ve bu değerin Security.CSRFKey ile atılmış imzasıdır; hem bir çerezde hem de formda gönderilir ve denetlemek için anahtardan başka hiçbir şey gerekmez. Oturum deposu yok, örnekler (instance) arasında paylaşılan hiçbir şey yok.

Formlu sayfalar yine de önbelleğe alınır

Bir token tek bir okuyucuya aittir, önbellekteki bir sayfa ise herkesle paylaşılır. Bu yüzden {{csrfToken}} bir token render etmez: bir işaretçi render eder ve önbelleğin sakladığı şey bu işaretçidir. Her yanıtta işaretçi, çıkış sırasında o okuyucunun kendi token'ıyla değiştirilir ve yanıt, token'ın çereziyle birlikte private, no-store olarak gönderilir. Render paylaşılır; okuyucuya özgü tek dize paylaşılmaz.

Bu olmasaydı, bir sitenin alt bilgisindeki bir bülten formu bütün sitede önbelleği kapatırdı. Değiştirdiği tek şey şu: formlu bir sayfa statik bir dosyaya dönüştüğünde formu gönderecek bir sunucusu kalmaz; bu yüzden statik dışa aktarma böyle bir sayfayı atlar ve nedenini söyler.

Anahtarı ayarlayın

Security: collage.SecurityConfig{
	CSRFKey: []byte(os.Getenv("COLLAGE_CSRF_KEY")), // e.g. openssl rand -hex 32
},

Boş bırakırsanız başlangıçta bir anahtar üretilir. collage bunu söyler — production'da bir uyarıyla, geliştirmede düz bir log satırıyla — ama yalnızca uygulamada güvenli olmayan bir metodu kabul eden ve token doğrulayan bir action varsa; böyle bir action'ı olmayan ya da bu tür action'larının hepsi WithoutCSRF ile muaf tutulmuş bir uygulama hiçbir token denetlemez ve anahtar konusunda uyarılmaz. Bu, ilk çalıştırma için sorun değildir ama yayına almak için yanlıştır: üretilen anahtar her süreçte farklıdır; bu yüzden yeniden başlatmadan önce yüklenen bir form sonrasında reddedilir, bir örneğin sunduğu form da bir sonrakinde reddedilir. Daha önceki bir anahtarla saklanmış, form içeren önbellekteki bir sayfa, hiçbir şeyin doğrulayamayacağı bir token'la sunulmak yerine yeniden render edilir — bkz. disk önbelleğinin namespace'i.

fetch'ten

Form olmadan yapılan bir fetch(), token'ı X-CSRF-Token header'ında gönderir. Token'ı sayfadaki herhangi bir {{csrfToken}} input'undan okuyun — sayfa geldiğinde bu input okuyucunun kendi token'ını taşır. (_csrf alanın varsayılan adıdır; değiştirmek için aşağıya bakın.)

const token = document.querySelector('input[name="_csrf"]')?.value ?? "";
await fetch("/api/like", {
  method: "POST",
  headers: { "X-CSRF-Token": token },
  body: new URLSearchParams({ post: "hello-world" }),
});

Form gönderen bir fetch() için ek bir şey gerekmez. new FormData(form) multipart/form-data olarak gönderilir ve collage bunu URL-encoded bir gövde gibi okur; böylece gizli input diğer alanlarla birlikte gider:

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const response = await fetch(form.action, { method: "POST", body: new FormData(form) });
  // The action answered with RenderFragment: the form's own fragment, re-rendered.
  form.outerHTML = await response.text();
});

Bir action'ın yanıt olarak verdiği fragment ya da sayfa diğerleri gibi render edilir; dolayısıyla içindeki form da taze bir token taşır: kendini yenisiyle değiştiren bir form çalışmaya devam eder.

Token taşıyamayan istekler

Bir ödeme sağlayıcısının webhook'u ya da tarayıcı olmayan bir şeyin bearer token ile çağırdığı bir API token taşıyamaz. Denetimi yalnızca o action için kapatın:

app.RegisterAction(collage.NewAction("stripe-webhook").
	WithPath("en", "/hooks/stripe").
	WithMethods(http.MethodPost).
	WithoutCSRF().
	WithHandler(onStripe).
	Build())

Ardından isteğin kimliğini başka bir yolla doğrulayın — bir webhook'un imza header'ı, bir bearer token. Bir tarayıcının gönderdiği herhangi bir şeyde WithoutCSRF() korumayı elden çıkarmak demektir. (Security.DisableCSRF korumayı bütün uygulama için kapatır; bu yalnızca hiç tarayıcı formu olmayan bir uygulama için doğrudur.)

Çerez, alan ve header adları Security.CSRFCookieName, CSRFFieldName ve CSRFHeaderName ile değiştirilebilir. Adı değiştirilen alan her iki tarafta da değişir: {{csrfToken}} denetimin okuduğu adı render eder, dolayısıyla formda değişiklik gerekmez.

İstek gövdeleri sınırlıdır

Bir action'ın istek gövdesi varsayılan olarak 4 MiB ile sınırlıdır. Bunu uygulama için Server.MaxBodyBytes ile, tek bir action için WithMaxBodyBytes ile değiştirin; negatif bir değer sınır yok demektir.

Sınırı handler'ınız uygulamaz; sınır, handler'ınız çalışmadan önce uygulanır. Her handler'ın hatırlaması gereken bir sınır, unutan handler'da bulunmayan bir sınırdır — ve anonim bir çağıranın bulacağı handler da tam olarak odur. Sınırın ötesini okuyan bir handler bir *http.MaxBytesError alır; bu hatayı döndürmek 413 ile yanıt verir:

if err := rc.Request.ParseForm(); err != nil {
	return nil, err // a body over the limit becomes a 413
}

Token formdan denetlendiğinde denetim önce gövdeyi okur; bu yüzden aşırı büyük bir form orada, handler'ınız çalışmadan önce reddedilir — yine 403 ile değil, 413 ile: okunamayacak kadar büyük bir gövde sahtecilik değildir.

Bir action'ın değiştirdiğini geçersiz kılmak

İçeriği değiştiren bir action genellikle önbellekteki bazı sayfaları yanlış hâle getirir. Hangileri olduğunu sonuçta belirtin:

func publish(ctx context.Context, rc *collage.RenderContext) (*collage.ActionResult, error) {
	slug := rc.Param("slug")
	if err := store.Publish(ctx, slug); err != nil {
		return nil, err
	}
	result := collage.SeeOther("/posts/" + slug)
	result.InvalidateTags = []string{"post:" + slug, "blog:posts"}
	return result, nil
}

Etiketler yanıt yazılmadan önce geçersiz kılınır. Asıl mesele bu sıradır: okuyucu yönlendirmeyi izleyerek az önce değiştirdiği sayfaya gider ve o sayfa, değişikliğin bayatlattığı bir önbellek girdisinden sunulmamalıdır. Geçersiz kılma başarısız olursa yanıt başarısız olmaz, hata loglanır — değişiklik zaten gerçekleşmiştir.

Etiketler tam olarak Önbellekleme sayfasında anlatıldığı gibi çalışır ve collage.Cached ile saklanan değerlere de ulaşır.

Kendi URL'sindeki fragment

Bir istemci framework'ü olmadan sayfanın bir kısmını yenilemek iki şey gerektirir: yalnızca o kısımla yanıt veren bir URL ve onu yerine koyacak birkaç satır JavaScript. Birincisi WithFragmentPath'tir:

collage.NewPage("search").
	WithLayout(layout).
	WithContent(searchContent).
	WithPath("en", "/search").
	WithFragmentPath("en", "/search/results", results).
	Dynamic().
	Build()

GET /search/results?q=grid results fragment'ini render eder, başka hiçbir şeyi değil. Data handler'ı çalışır, kendi slot'ları doldurulur ve hata politikası uygulanır — aynı render'dır, yalnızca daha aşağıdan başlatılmıştır. Etrafında layout olmadığından hoist ettiklerinin gidecek bir yeri yoktur ve yanıtı önbelleğe alınmaz.

const input = document.querySelector('input[name="q"]');
input.addEventListener("input", async () => {
  const response = await fetch("/search/results?q=" + encodeURIComponent(input.value));
  document.querySelector("#results").outerHTML = await response.text();
});

Bildirilmemiş hiçbir şeye erişilemez. Her fragment'i otomatik olarak dışa açan bir framework, her sayfanın her iç parçasını herkese açık web'e koymuş olurdu.

RenderFragment ile yanıt veren bir action'la birleştiğinde, bir form gönderilebilir ve yalnızca değişen kısımla yanıtlanabilir:

WithAction("POST", func(ctx context.Context, rc *collage.RenderContext) (*collage.ActionResult, error) {
	if err := store.AddComment(ctx, rc.Param("slug"), rc.Request.PostFormValue("body")); err != nil {
		return nil, err
	}
	result := collage.RenderFragment(comments)
	result.InvalidateTags = []string{"comments:" + rc.Param("slug")}
	return result, nil
})

Fragment'in data handler'ı action'ın RenderContext'iyle çalışır; böylece az önce eklenen yorumu — ve handler'ın oraya rc.Set ile koyduğu her şeyi — görür.