feat: publish the Sandwich Hime tutorial starter
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
// Package server owns the example application's HTTP policy and request data.
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gamertan.com/sandwich-hime/sando"
|
||||
"gitea.speelman.ca/gamertan/sandwich-hime-tutorial/internal/views"
|
||||
)
|
||||
|
||||
const maxVisitorRunes = 80
|
||||
|
||||
// New returns the complete example application using the system clock.
|
||||
func New() http.Handler {
|
||||
return NewWithClock(time.Now)
|
||||
}
|
||||
|
||||
// NewWithClock returns the application with an injectable request clock.
|
||||
// It is exported from this internal package so tests can make time exact.
|
||||
func NewWithClock(now func() time.Time) http.Handler {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
application := &application{now: now}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", application.home)
|
||||
return mux
|
||||
}
|
||||
|
||||
type application struct {
|
||||
now func() time.Time
|
||||
requests atomic.Uint64
|
||||
}
|
||||
|
||||
func (a *application) home(w http.ResponseWriter, r *http.Request) {
|
||||
renderedAt := a.now().UTC()
|
||||
requestNumber := a.requests.Add(1)
|
||||
|
||||
body := views.Home(views.HomeView{
|
||||
Visitor: normalizeVisitor(r.URL.Query().Get("name")),
|
||||
Trails: trailsForRequest(),
|
||||
})
|
||||
page := views.Layout(views.LayoutView{
|
||||
Title: "A small Sandwich Hime site",
|
||||
Body: body,
|
||||
RenderedAtUTC: renderedAt.Format(time.RFC3339Nano),
|
||||
RequestNumber: requestNumber,
|
||||
})
|
||||
|
||||
var output bytes.Buffer
|
||||
renderStarted := time.Now()
|
||||
if err := sando.Render(r.Context(), &output, page); err != nil {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Error(w, "could not render page", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderDuration := time.Since(renderStarted)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(output.Len()))
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Server-Timing", fmt.Sprintf(`sando;dur=%.3f;desc="buffered component render"`, float64(renderDuration)/float64(time.Millisecond)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(output.Bytes())
|
||||
}
|
||||
|
||||
func trailsForRequest() []views.Trail {
|
||||
return []views.Trail{
|
||||
{
|
||||
Label: "Read the official tutorial",
|
||||
Description: "Walk through the source one typed component at a time.",
|
||||
URL: "https://sandwichhime.com/docs/tutorial/",
|
||||
},
|
||||
{
|
||||
Label: "Study the language and safety boundary",
|
||||
Description: "See where contextual escaping succeeds and where compilation deliberately stops.",
|
||||
URL: "https://sandwichhime.com/docs/",
|
||||
},
|
||||
{
|
||||
Label: "Render another visitor",
|
||||
Description: "This local link sends different untrusted request data through the same compiled templates.",
|
||||
URL: "/?name=friend",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeVisitor(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "traveler"
|
||||
}
|
||||
if utf8.RuneCountInString(value) <= maxVisitorRunes {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:maxVisitorRunes])
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHomeIsDynamicEscapedAndNotCached(t *testing.T) {
|
||||
base := time.Date(2026, time.August, 11, 14, 30, 0, 0, time.UTC)
|
||||
clockCalls := 0
|
||||
handler := NewWithClock(func() time.Time {
|
||||
result := base.Add(time.Duration(clockCalls) * time.Second)
|
||||
clockCalls++
|
||||
return result
|
||||
})
|
||||
|
||||
first := request(t, handler, http.MethodGet, `/?name=%3Cscript%3Ealert%281%29%3C%2Fscript%3E`)
|
||||
second := request(t, handler, http.MethodGet, "/?name=friend")
|
||||
|
||||
if first.Code != http.StatusOK || second.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected statuses: first=%d second=%d", first.Code, second.Code)
|
||||
}
|
||||
if first.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q", first.Header().Get("Cache-Control"))
|
||||
}
|
||||
if timing := first.Header().Get("Server-Timing"); !strings.HasPrefix(timing, "sando;dur=") || !strings.Contains(timing, "buffered component render") {
|
||||
t.Fatalf("Server-Timing = %q", timing)
|
||||
}
|
||||
if strings.Contains(first.Body.String(), "<script>") || !strings.Contains(first.Body.String(), "<script>") {
|
||||
t.Fatalf("visitor was not safely escaped: %s", first.Body.String())
|
||||
}
|
||||
if !strings.Contains(first.Body.String(), "2026-08-11T14:30:00Z") || !strings.Contains(first.Body.String(), "#1") {
|
||||
t.Fatalf("first response is missing its dynamic proof: %s", first.Body.String())
|
||||
}
|
||||
if !strings.Contains(second.Body.String(), "2026-08-11T14:30:01Z") || !strings.Contains(second.Body.String(), "#2") {
|
||||
t.Fatalf("second response is missing fresh dynamic proof: %s", second.Body.String())
|
||||
}
|
||||
if first.Body.String() == second.Body.String() {
|
||||
t.Fatal("separate requests produced identical bodies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadHasGetHeadersAndNoBody(t *testing.T) {
|
||||
handler := NewWithClock(func() time.Time {
|
||||
return time.Date(2026, time.August, 11, 14, 30, 0, 0, time.UTC)
|
||||
})
|
||||
|
||||
response := request(t, handler, http.MethodHead, "/")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if response.Body.Len() != 0 {
|
||||
t.Fatalf("HEAD wrote %d body bytes", response.Body.Len())
|
||||
}
|
||||
length, err := strconv.Atoi(response.Header().Get("Content-Length"))
|
||||
if err != nil || length <= 0 {
|
||||
t.Fatalf("Content-Length = %q, err = %v", response.Header().Get("Content-Length"), err)
|
||||
}
|
||||
if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Server-Timing") == "" {
|
||||
t.Fatalf("HEAD omitted dynamic response headers: %v", response.Header())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterOwnsMethodAndPathPolicy(t *testing.T) {
|
||||
handler := New()
|
||||
|
||||
if response := request(t, handler, http.MethodPost, "/"); response.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("POST / status = %d", response.Code)
|
||||
}
|
||||
if response := request(t, handler, http.MethodGet, "/missing"); response.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /missing status = %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func request(t *testing.T, handler http.Handler, method, target string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(method, target, nil))
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestGetContentLengthMatchesBufferedBody(t *testing.T) {
|
||||
handler := New()
|
||||
response := request(t, handler, http.MethodGet, "/")
|
||||
want, err := strconv.Atoi(response.Header().Get("Content-Length"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(response.Result().Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body) != want {
|
||||
t.Fatalf("body length = %d, Content-Length = %d", len(body), want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user