ความคืบหน้างาน ไฟล์ต้นฉบับ

tracker/main.go

// Command tracker serves the TNSU EMS project overview.
//
// Pages are rendered on the server with html/template. A watcher polls the
// JSON data files; on any change it pushes a Server-Sent Event,
// and the browser re-fetches the server-rendered page and swaps the content.
//
//	cd tracker && go run .
package main

import (
	"bytes"
	"context"
	"crypto/sha256"
	"embed"
	"encoding/hex"
	"errors"
	"flag"
	"html/template"
	"io/fs"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"
	"time"
)

//go:embed templates/*.html
var templateFS embed.FS

//go:embed static
var staticFS embed.FS

// staticVersion is a hash of the embedded static files. Pages link to
// /static/<file>?v=<hash>, so the browser can cache CSS and JS for good and
// still gets the new files after a rebuild.
var staticVersion = func() string {
	h := sha256.New()
	fs.WalkDir(staticFS, "static", func(p string, d fs.DirEntry, err error) error {
		if err == nil && !d.IsDir() {
			b, _ := staticFS.ReadFile(p)
			h.Write([]byte(p))
			h.Write(b)
		}
		return nil
	})
	return hex.EncodeToString(h.Sum(nil))[:10]
}()

func assetURL(name string) string { return "/static/" + name + "?v=" + staticVersion }

// cacheStatic lets the browser keep versioned static files; anything else is
// revalidated on every request.
func cacheStatic(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Query().Get("v") == staticVersion {
			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		} else {
			w.Header().Set("Cache-Control", "no-cache")
		}
		h.ServeHTTP(w, r)
	})
}

type route struct {
	path, page, title string
}

var routes = []route{
	{"/", "overview", "ภาพรวม"},
	{"/milestones", "milestones", "Milestone & Tasks"},
	{"/timeline", "timeline", "Timeline"},
	{"/checkpoints", "checkpoints", "ประวัติ Checkpoint"},
	{"/tests", "tests", "ผลตรวจ Test"},
	{"/problems", "problems", "Problem solving"},
	{"/reviews", "reviews", "Wait for review"},
}

func main() {
	addr := flag.String("addr", ":8099", "listen address")
	dataDir := flag.String("data", "data", "directory with milestones.json, checkpoints.json, tests.json, problems.json")
	poll := flag.Duration("poll", time.Second, "how often to check for changes")
	flag.StringVar(&repoRoot, "root", "..", "repository root that /files/ may show (refs open there)")
	flag.Parse()

	absData, _ := filepath.Abs(*dataDir)
	store := NewStore(absData)
	if _, err, _ := store.Get(); err != nil {
		log.Printf("warning: %v", err)
	}
	hub := NewHub()

	mux := newMux(store, hub)

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	go Watch(ctx, store, hub, absData, *poll)

	// No WriteTimeout: /events is a long-lived stream.
	// BaseContext ties request contexts to shutdown so open event streams end.
	srv := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second,
		BaseContext: func(net.Listener) context.Context { return ctx }}
	go func() {
		<-ctx.Done()
		shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		srv.Shutdown(shutdown)
	}()
	log.Printf("tracker on http://localhost%s (data: %s)", *addr, absData)
	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		log.Fatal(err)
	}
}

// newMux wires every route. Pages are rendered on the server for each request.
func newMux(store *Store, hub *Hub) *http.ServeMux {
	pages := map[string]*template.Template{}
	for _, r := range routes {
		pages[r.page] = template.Must(template.New("").Funcs(templateFuncs()).ParseFS(templateFS,
			"templates/layout.html", "templates/partials.html", "templates/"+r.page+".html"))
	}
	for _, name := range []string{"milestone", "epic", "task", "notfound"} {
		pages[name] = template.Must(template.New("").Funcs(templateFuncs()).ParseFS(templateFS,
			"templates/layout.html", "templates/partials.html", "templates/"+name+".html"))
	}
	notFound := pages["notfound"]

	render := func(w http.ResponseWriter, t *template.Template, p Page, status int) {
		var buf bytes.Buffer
		if err := t.ExecuteTemplate(&buf, "layout", p); err != nil {
			log.Printf("render %s: %v", p.Active, err)
			http.Error(w, "render error", http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.Header().Set("Cache-Control", "no-store")
		w.WriteHeader(status)
		buf.WriteTo(w)
	}

	mux := http.NewServeMux()
	registerUseCases(mux, store)
	registerMockups(mux, store)
	for _, r := range routes {
		pattern := "GET " + r.path
		if r.path == "/" {
			pattern = "GET /{$}"
		}
		mux.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) {
			p := buildPage(store, r.page, r.title)
			if r.page == "tests" {
				p.SetTestView(req.URL.Query().Get("view"))
			}
			if r.page == "timeline" {
				tl := buildTimeline(p.Milestones, req.URL.Query().Get("scale"), time.Now())
				p.Timeline = &tl
			}
			if r.page == "reviews" {
				p.SetReviewState(req.URL.Query().Get("state"))
				p.Saved = req.URL.Query().Get("saved")
			}
			render(w, pages[r.page], p, http.StatusOK)
		})
	}
	// Milestone -> epic -> task sub-pages. Unknown ids answer 404.
	detail := func(page string, sel func(p *Page, r *http.Request) bool) http.HandlerFunc {
		return func(w http.ResponseWriter, req *http.Request) {
			p := buildPage(store, page, "")
			if !sel(&p, req) {
				render(w, notFound, buildPage(store, "", "ไม่พบหน้า"), http.StatusNotFound)
				return
			}
			render(w, pages[page], p, http.StatusOK)
		}
	}
	mux.HandleFunc("GET /milestones/{mid}", detail("milestone", func(p *Page, r *http.Request) bool {
		return p.SelectMilestone(r.PathValue("mid"))
	}))
	mux.HandleFunc("GET /milestones/{mid}/{eid}", detail("epic", func(p *Page, r *http.Request) bool {
		return p.SelectEpic(r.PathValue("mid"), r.PathValue("eid"))
	}))
	mux.HandleFunc("GET /tasks/{tid}", detail("task", func(p *Page, r *http.Request) bool {
		p.Saved = r.URL.Query().Get("saved")
		return p.SelectTask(r.PathValue("tid"))
	}))

	// Review form. Writes go to milestones.json; the watcher then pushes the
	// change to every open page.
	mux.HandleFunc("POST /reviews/{tid}", func(w http.ResponseWriter, req *http.Request) {
		if !sameOrigin(req) {
			http.Error(w, "forbidden: the form must be sent from this tracker", http.StatusForbidden)
			return
		}
		req.Body = http.MaxBytesReader(w, req.Body, 64<<10)
		if err := req.ParseForm(); err != nil {
			http.Error(w, "bad form", http.StatusBadRequest)
			return
		}
		tid := req.PathValue("tid")
		sub := ReviewSubmission{
			TaskID:   tid,
			Outcome:  req.FormValue("outcome"),
			Decision: strings.TrimSpace(req.FormValue("decision")),
			Reviewer: strings.TrimSpace(req.FormValue("reviewer")),
			Checked:  map[int]bool{},
			Now:      time.Now(),
		}
		for _, v := range req.Form["checklist"] {
			if i, err := strconv.Atoi(v); err == nil {
				sub.Checked[i] = true
			}
		}
		fromTask := req.FormValue("return") == "task"
		state, err := store.SubmitReview(sub)
		var fe formError
		switch {
		case err == nil:
			target := "/reviews?state=" + url.QueryEscape(state) + "&saved=" + url.QueryEscape(tid) + "#" + url.PathEscape(tid)
			if fromTask {
				target = taskHref(tid) + "?saved=" + url.QueryEscape(tid)
			}
			http.Redirect(w, req, target, http.StatusSeeOther)
		case errors.Is(err, errTaskNotFound):
			render(w, notFound, buildPage(store, "", "ไม่พบหน้า"), http.StatusNotFound)
		case errors.As(err, &fe):
			// Show the same page again with the reviewer's input kept.
			page := "reviews"
			p := buildPage(store, page, "Wait for review")
			p.Form = &ReviewForm{TaskID: tid, Outcome: sub.Outcome, Decision: sub.Decision, Reviewer: sub.Reviewer, Checked: sub.Checked, Error: fe.Error()}
			if fromTask && p.SelectTask(tid) {
				page = "task"
			} else if t, ok := p.tasks[tid]; ok && t.Review != nil {
				p.SetReviewState(t.Review.StateOrTodo())
			} else {
				p.SetReviewState(ReviewTodo)
			}
			render(w, pages[page], p, http.StatusBadRequest)
		default:
			log.Printf("review %s: %v", tid, err)
			http.Error(w, "บันทึกไม่สำเร็จ: "+err.Error(), http.StatusInternalServerError)
		}
	})
	// File viewer for refs: opens repo files in a new tab.
	fileTpl := template.Must(template.New("").Funcs(templateFuncs()).ParseFS(templateFS,
		"templates/partials.html", "templates/file.html"))
	mux.HandleFunc("GET /files/{path...}", filesHandler(fileTpl, func(w http.ResponseWriter) {
		render(w, notFound, buildPage(store, "", "ไม่พบหน้า"), http.StatusNotFound)
	}))
	mux.Handle("GET /events", hub)
	static, _ := fs.Sub(staticFS, "static")
	mux.Handle("GET /static/", cacheStatic(http.StripPrefix("/static/", http.FileServerFS(static))))
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) })
	mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
		render(w, notFound, buildPage(store, "", "ไม่พบหน้า"), http.StatusNotFound)
	})

	return mux
}

// sameOrigin accepts a form post only when its Origin (or, failing that,
// Referer) is this server, so other sites cannot submit reviews through a
// visitor's browser (CSRF).
func sameOrigin(r *http.Request) bool {
	check := func(raw string) bool {
		u, err := url.Parse(raw)
		return err == nil && u.Host == r.Host
	}
	if o := r.Header.Get("Origin"); o != "" {
		return check(o)
	}
	if ref := r.Header.Get("Referer"); ref != "" {
		return check(ref)
	}
	return false
}