package web import ( "encoding/json" "fmt" "html/template" "io" "mime" "net/http" "net/url" "strconv" "strings" "time" "cpu-benchmark-server/lib/model" "cpu-benchmark-server/lib/store" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) const maxSubmissionBytes = 4 << 20 type App struct { store *store.Store templates *template.Template pageSize int } type indexPageData struct { Submissions []model.Submission QueryText string QueryCPU string Page int TotalPages int TotalCount int ShowingFrom int ShowingTo int PrevURL string NextURL string } type jsonSubmissionEnvelope struct { Submitter string `json:"submitter"` Benchmark *model.BenchmarkResult `json:"benchmark"` Result *model.BenchmarkResult `json:"result"` Data *model.BenchmarkResult `json:"data"` } type flatSubmissionEnvelope struct { Submitter string `json:"submitter"` model.BenchmarkResult } type errorResponse struct { Error string `json:"error"` } func New(store *store.Store, pageSize int) (*App, error) { funcs := template.FuncMap{ "formatInt64": formatInt64, "formatFloat": formatFloat, "formatTime": formatTime, "modeLabel": model.ThreadModeLabel, } templates, err := template.New("index.html").Funcs(funcs).ParseFiles("templates/index.html") if err != nil { return nil, err } return &App{ store: store, templates: templates, pageSize: pageSize, }, nil } func (a *App) Routes() http.Handler { router := chi.NewRouter() router.Use(middleware.RequestID) router.Use(middleware.RealIP) router.Use(middleware.Logger) router.Use(middleware.Recoverer) router.Use(middleware.Timeout(30 * time.Second)) router.Get("/", a.handleIndex) router.Get("/healthz", a.handleHealth) router.Get("/api/search", a.handleSearch) router.Post("/api/submit", a.handleSubmit) return router } func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { page := parsePositiveInt(r.URL.Query().Get("page"), 1) text := strings.TrimSpace(r.URL.Query().Get("text")) cpu := strings.TrimSpace(r.URL.Query().Get("cpu")) var ( submissions []model.Submission totalCount int ) if text != "" || cpu != "" { matches := a.store.SearchSubmissions(text, cpu) totalCount = len(matches) start, end, normalizedPage := pageBounds(page, a.pageSize, totalCount) page = normalizedPage submissions = matches[start:end] } else { var count int submissions, count = a.store.ListSubmissions(page, a.pageSize) totalCount = count _, _, page = pageBounds(page, a.pageSize, totalCount) } totalPageCount := totalPages(totalCount, a.pageSize) showingFrom, showingTo := visibleBounds(page, a.pageSize, len(submissions), totalCount) data := indexPageData{ Submissions: submissions, QueryText: text, QueryCPU: cpu, Page: page, TotalPages: totalPageCount, TotalCount: totalCount, ShowingFrom: showingFrom, ShowingTo: showingTo, PrevURL: buildIndexURL(max(1, page-1), text, cpu), NextURL: buildIndexURL(max(1, min(totalPageCount, page+1)), text, cpu), } if err := a.templates.ExecuteTemplate(w, "index.html", data); err != nil { http.Error(w, fmt.Sprintf("render template: %v", err), http.StatusInternalServerError) } } func (a *App) handleHealth(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "submissions": a.store.Count(), }) } func (a *App) handleSearch(w http.ResponseWriter, r *http.Request) { results := a.store.SearchSubmissions(r.URL.Query().Get("text"), r.URL.Query().Get("cpu")) writeJSON(w, http.StatusOK, results) } func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxSubmissionBytes) result, submitter, err := parseSubmissionRequest(r) if err != nil { writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) return } if err := result.Validate(); err != nil { writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) return } submission, err := a.store.SaveSubmission(result, submitter) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{Error: fmt.Sprintf("store submission: %v", err)}) return } writeJSON(w, http.StatusCreated, map[string]any{ "success": true, "submissionID": submission.SubmissionID, "submitter": submission.Submitter, "submittedAt": submission.SubmittedAt, }) } func parseSubmissionRequest(r *http.Request) (model.BenchmarkResult, string, error) { contentType := r.Header.Get("Content-Type") mediaType, _, err := mime.ParseMediaType(contentType) if err != nil && contentType != "" { return model.BenchmarkResult{}, "", fmt.Errorf("parse content type: %w", err) } switch mediaType { case "", "application/json": return parseJSONSubmission(r) case "multipart/form-data": return parseMultipartSubmission(r) default: return model.BenchmarkResult{}, "", fmt.Errorf("unsupported content type %q", mediaType) } } func parseJSONSubmission(r *http.Request) (model.BenchmarkResult, string, error) { body, err := io.ReadAll(r.Body) if err != nil { return model.BenchmarkResult{}, "", fmt.Errorf("read request body: %w", err) } submitter := firstNonEmpty( r.URL.Query().Get("submitter"), r.Header.Get("X-Submitter"), ) var nested jsonSubmissionEnvelope if err := json.Unmarshal(body, &nested); err == nil { submitter = firstNonEmpty(nested.Submitter, submitter) for _, candidate := range []*model.BenchmarkResult{nested.Benchmark, nested.Result, nested.Data} { if candidate != nil { return *candidate, submitter, nil } } } var flat flatSubmissionEnvelope if err := json.Unmarshal(body, &flat); err != nil { return model.BenchmarkResult{}, "", fmt.Errorf("decode benchmark JSON: %w", err) } return flat.BenchmarkResult, firstNonEmpty(flat.Submitter, submitter), nil } func parseMultipartSubmission(r *http.Request) (model.BenchmarkResult, string, error) { if err := r.ParseMultipartForm(maxSubmissionBytes); err != nil { return model.BenchmarkResult{}, "", fmt.Errorf("parse multipart form: %w", err) } payload, err := readMultipartPayload(r) if err != nil { return model.BenchmarkResult{}, "", err } var result model.BenchmarkResult if err := json.Unmarshal(payload, &result); err != nil { return model.BenchmarkResult{}, "", fmt.Errorf("decode benchmark JSON: %w", err) } return result, r.FormValue("submitter"), nil } func readMultipartPayload(r *http.Request) ([]byte, error) { for _, field := range []string{"benchmark", "file", "benchmarkFile"} { file, _, err := r.FormFile(field) if err == nil { defer file.Close() payload, readErr := io.ReadAll(file) if readErr != nil { return nil, fmt.Errorf("read multipart benchmark file: %w", readErr) } return payload, nil } if err != http.ErrMissingFile { return nil, fmt.Errorf("read multipart benchmark file: %w", err) } } for _, field := range []string{"benchmark", "payload", "result", "data"} { if value := strings.TrimSpace(r.FormValue(field)); value != "" { return []byte(value), nil } } return nil, fmt.Errorf("multipart request must include benchmark JSON in a file field or text field named benchmark") } func parsePositiveInt(raw string, fallback int) int { if raw == "" { return fallback } value, err := strconv.Atoi(raw) if err != nil || value <= 0 { return fallback } return value } func pageBounds(page, pageSize, total int) (int, int, int) { if pageSize <= 0 { pageSize = 50 } totalPages := totalPages(total, pageSize) if totalPages == 0 { return 0, 0, 1 } if page < 1 { page = 1 } if page > totalPages { page = totalPages } start := (page - 1) * pageSize end := min(total, start+pageSize) return start, end, page } func totalPages(total, pageSize int) int { if total == 0 || pageSize <= 0 { return 0 } pages := total / pageSize if total%pageSize != 0 { pages++ } return pages } func visibleBounds(page, pageSize, visibleCount, total int) (int, int) { if total == 0 || visibleCount == 0 { return 0, 0 } from := (page-1)*pageSize + 1 to := from + visibleCount - 1 return from, to } func buildIndexURL(page int, text, cpu string) string { if page < 1 { page = 1 } values := url.Values{} values.Set("page", strconv.Itoa(page)) if strings.TrimSpace(text) != "" { values.Set("text", text) } if strings.TrimSpace(cpu) != "" { values.Set("cpu", cpu) } return "/?" + values.Encode() } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) encoder := json.NewEncoder(w) encoder.SetIndent("", " ") _ = encoder.Encode(payload) } func firstNonEmpty(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" } func formatInt64(value int64) string { negative := value < 0 if negative { value = -value } digits := strconv.FormatInt(value, 10) if len(digits) <= 3 { if negative { return "-" + digits } return digits } var builder strings.Builder if negative { builder.WriteByte('-') } pre := len(digits) % 3 if pre > 0 { builder.WriteString(digits[:pre]) if len(digits) > pre { builder.WriteByte(',') } } for i := pre; i < len(digits); i += 3 { builder.WriteString(digits[i : i+3]) if i+3 < len(digits) { builder.WriteByte(',') } } return builder.String() } func formatFloat(value float64) string { return fmt.Sprintf("%.2f", value) } func formatTime(value time.Time) string { if value.IsZero() { return "-" } return value.Format("2006-01-02 15:04:05 MST") } func min(a, b int) int { if a < b { return a } return b } func max(a, b int) int { if a > b { return a } return b }