// 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/?v=, 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 }