Export the reviewed allowlisted snapshot from private source commit 05928cebd01b586cf9e9d4b8c8537a7605a6068c. This records the exact candidate, bounded capacity result, stateful migration scratch requirement, authenticated batch identity proof, and immediate live acceptance evidence. AI-Assisted: OpenAI Codex Signed-off-by: Cole Speelman <crspeelman@gmail.com>
533 lines
20 KiB
Go
533 lines
20 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
package httpserver
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"gamertan.com/observatory/internal/identity"
|
|
"gamertan.com/observatory/internal/model"
|
|
"gamertan.com/observatory/internal/query"
|
|
"gamertan.com/observatory/internal/site"
|
|
"gamertan.com/sandwich-hime/sando"
|
|
"gamertan.com/web/access"
|
|
"gamertan.com/web/auth"
|
|
"gamertan.com/web/authhttp"
|
|
"gamertan.com/web/websec"
|
|
)
|
|
|
|
const loginCSRFCookieName = "__Host-observatory_login_csrf"
|
|
|
|
const (
|
|
loginFormFailure = "This sign-in form expired or could not be verified. Please try again."
|
|
loginCredentialFailure = "The username or password was not accepted."
|
|
passwordFormFailure = "This password form expired or could not be verified. Please try again."
|
|
passwordMatchFailure = "The new passwords did not match. Please enter them again."
|
|
passwordChangeFailure = "The password could not be changed. Check the temporary password and choose a different password of at least 12 characters."
|
|
)
|
|
|
|
func (s *Server) landing(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := auth.PrincipalFromContext(r.Context()); ok {
|
|
http.Redirect(w, r, "/app/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
view := site.LandingView{Head: s.head("Gamertan Observatory", "A self-hosted observability platform in development for carefully operated Linux systems.", "/")}
|
|
s.renderHTML(w, r, http.StatusOK, site.Landing(view))
|
|
}
|
|
|
|
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
|
if principal, ok := auth.PrincipalFromContext(r.Context()); ok {
|
|
if principal.User.PasswordChangeRequired {
|
|
http.Redirect(w, r, "/account/password/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/app/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
s.renderLogin(w, r, http.StatusOK, "")
|
|
}
|
|
|
|
func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
|
|
values, err := readFormFields(w, r, 16<<10, []string{"identifier", "password"}, []string{"csrf_token"})
|
|
csrfOK := err == nil && validLoginCSRF(r, values.Get("csrf_token"))
|
|
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
|
|
s.renderLogin(w, r, http.StatusForbidden, loginFormFailure)
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.renderLogin(w, r, http.StatusBadRequest, loginFormFailure)
|
|
return
|
|
}
|
|
token, principal, err := s.identity.Auth.Authenticate(r.Context(), values.Get("identifier"), values.Get("password"), s.options.SessionLifetime)
|
|
if err != nil {
|
|
s.renderLogin(w, r, http.StatusUnauthorized, loginCredentialFailure)
|
|
return
|
|
}
|
|
if err = authhttp.SetSession(w, s.cookie, token, s.now()); err != nil {
|
|
_ = s.identity.Auth.RevokeSession(r.Context(), token)
|
|
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
|
return
|
|
}
|
|
if _, cookieErr := r.Cookie(loginCSRFCookieName); cookieErr == nil {
|
|
clearLoginCSRF(w)
|
|
}
|
|
location := "/app/"
|
|
if principal.User.PasswordChangeRequired {
|
|
location = "/account/password/"
|
|
}
|
|
http.Redirect(w, r, location, http.StatusSeeOther)
|
|
}
|
|
|
|
// tokenBoundFormRequest makes the purpose-bound form token the primary CSRF
|
|
// proof. Browser origin metadata is defense in depth: an explicit cross-site
|
|
// or contradictory origin still fails closed, while absent or opaque metadata
|
|
// does not break an otherwise valid ordinary HTML form submission.
|
|
func tokenBoundFormRequest(r *http.Request, publicOrigin string, validToken bool) bool {
|
|
if !validToken {
|
|
return false
|
|
}
|
|
fetchSite := strings.ToLower(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site")))
|
|
if fetchSite != "" && fetchSite != "same-origin" && fetchSite != "none" {
|
|
return false
|
|
}
|
|
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
|
if origin == "" || origin == "null" {
|
|
return true
|
|
}
|
|
return websec.SameOrigin(r, publicOrigin)
|
|
}
|
|
|
|
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, status int, message string) {
|
|
csrf, err := s.issueLoginCSRF(w)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "login unavailable")
|
|
return
|
|
}
|
|
view := site.LoginView{Head: s.head("Sign in — Gamertan Observatory", "Sign in to the local Gamertan Observatory workshop.", "/login/"), CSRFToken: csrf, ErrorMessage: message}
|
|
s.renderHTML(w, r, status, site.Login(view))
|
|
}
|
|
|
|
func (s *Server) issueLoginCSRF(w http.ResponseWriter) (string, error) {
|
|
secret := make([]byte, 32)
|
|
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
|
|
return "", errors.New("generate login CSRF token")
|
|
}
|
|
token := base64.RawURLEncoding.EncodeToString(secret)
|
|
http.SetCookie(w, &http.Cookie{Name: loginCSRFCookieName, Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: s.now().Add(10 * time.Minute), MaxAge: 600})
|
|
return token, nil
|
|
}
|
|
|
|
func validLoginCSRF(r *http.Request, candidate string) bool {
|
|
cookie, err := r.Cookie(loginCSRFCookieName)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
want, wantErr := base64.RawURLEncoding.DecodeString(cookie.Value)
|
|
got, gotErr := base64.RawURLEncoding.DecodeString(candidate)
|
|
return wantErr == nil && gotErr == nil && len(want) == 32 && len(got) == len(want) && subtle.ConstantTimeCompare(want, got) == 1
|
|
}
|
|
|
|
func clearLoginCSRF(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{Name: loginCSRFCookieName, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
|
}
|
|
|
|
func (s *Server) passwordPage(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := auth.PrincipalFromContext(r.Context())
|
|
if !ok {
|
|
http.Redirect(w, r, "/login/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if !principal.User.PasswordChangeRequired {
|
|
http.Redirect(w, r, "/app/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
s.renderPassword(w, r, http.StatusOK, "")
|
|
}
|
|
|
|
func (s *Server) passwordForm(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := auth.PrincipalFromContext(r.Context())
|
|
token, tokenOK := authhttp.SessionToken(r, s.cookie)
|
|
values, err := readForm(w, r, 8<<10, "csrf_token", "current_password", "new_password", "confirm_password")
|
|
csrfOK := err == nil && tokenOK && authhttp.VerifyCSRF(token, "account:password:change", values.Get("csrf_token"))
|
|
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
|
|
if ok && tokenOK && principal.User.PasswordChangeRequired {
|
|
s.renderPassword(w, r, http.StatusForbidden, passwordFormFailure)
|
|
} else {
|
|
http.Redirect(w, r, "/login/", http.StatusSeeOther)
|
|
}
|
|
return
|
|
}
|
|
if !ok || !tokenOK || !principal.User.PasswordChangeRequired {
|
|
writeProblem(w, http.StatusForbidden, "password change authorization required")
|
|
return
|
|
}
|
|
if err != nil || !csrfOK {
|
|
s.renderPassword(w, r, http.StatusBadRequest, passwordFormFailure)
|
|
return
|
|
}
|
|
if values.Get("new_password") != values.Get("confirm_password") {
|
|
s.renderPassword(w, r, http.StatusUnprocessableEntity, passwordMatchFailure)
|
|
return
|
|
}
|
|
if err = s.identity.Auth.ChangePassword(r.Context(), principal.User.ID, values.Get("current_password"), values.Get("new_password")); err != nil {
|
|
s.renderPassword(w, r, http.StatusUnprocessableEntity, passwordChangeFailure)
|
|
return
|
|
}
|
|
if err = authhttp.ClearSession(w, s.cookie); err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/login/?password=changed", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) renderPassword(w http.ResponseWriter, r *http.Request, status int, message string) {
|
|
token, ok := authhttp.SessionToken(r, s.cookie)
|
|
if !ok {
|
|
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
csrf, err := authhttp.CSRFToken(token, "account:password:change")
|
|
if err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
|
return
|
|
}
|
|
view := site.PasswordView{Head: s.head("Choose your password — Gamertan Observatory", "Replace the one-time Observatory credential before continuing.", "/account/password/"), CSRFToken: csrf, ErrorMessage: message}
|
|
s.renderHTML(w, r, status, site.Password(view))
|
|
}
|
|
|
|
func (s *Server) logoutForm(w http.ResponseWriter, r *http.Request) {
|
|
values, err := readForm(w, r, 4<<10, "csrf_token")
|
|
token, ok := authhttp.SessionToken(r, s.cookie)
|
|
csrfOK := err == nil && ok && authhttp.VerifyCSRF(token, "session:delete", values.Get("csrf_token"))
|
|
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
|
|
writeProblem(w, http.StatusForbidden, "valid sign-out form required")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "invalid sign-out request")
|
|
return
|
|
}
|
|
if !csrfOK {
|
|
writeProblem(w, http.StatusForbidden, "valid session CSRF token required")
|
|
return
|
|
}
|
|
if err = s.identity.Auth.RevokeSession(r.Context(), token); err != nil && !errors.Is(err, auth.ErrSessionNotFound) {
|
|
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
|
return
|
|
}
|
|
if err = authhttp.ClearSession(w, s.cookie); err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/login/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) app(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := auth.PrincipalFromContext(r.Context())
|
|
if !ok {
|
|
http.Redirect(w, r, "/login/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
organizations, err := s.identity.OrganizationsForUser(r.Context(), principal.User.ID)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "organization list unavailable")
|
|
return
|
|
}
|
|
if len(organizations) == 0 {
|
|
writeProblem(w, http.StatusForbidden, "organization access required")
|
|
return
|
|
}
|
|
queryValues := r.URL.Query()
|
|
requested := queryValues.Get("organization")
|
|
if len(queryValues) > 0 {
|
|
selectedValues, exists := queryValues["organization"]
|
|
if !exists || len(queryValues) != 1 || len(selectedValues) != 1 || selectedValues[0] == "" {
|
|
writeProblem(w, http.StatusBadRequest, "invalid organization selection")
|
|
return
|
|
}
|
|
}
|
|
selected := organizations[0]
|
|
if requested != "" {
|
|
found := false
|
|
for _, organization := range organizations {
|
|
if organization.ID == requested {
|
|
selected, found = organization, true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
writeProblem(w, http.StatusForbidden, "organization access denied")
|
|
return
|
|
}
|
|
}
|
|
scope := access.Scope{OrganizationID: selected.ID}
|
|
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsRead)
|
|
if err != nil || !decision.Allowed {
|
|
writeProblem(w, http.StatusForbidden, "dashboard access denied")
|
|
return
|
|
}
|
|
token, ok := authhttp.SessionToken(r, s.cookie)
|
|
if !ok {
|
|
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
csrf, err := authhttp.CSRFToken(token, "session:delete")
|
|
if err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
|
return
|
|
}
|
|
view := site.AppView{
|
|
Head: s.head("Overview — Gamertan Observatory", "Recent authorized telemetry and saved Observatory work.", "/app/"),
|
|
DisplayName: principal.User.DisplayName, CSRFToken: csrf,
|
|
EventsURL: "/app/events?organization=" + url.QueryEscape(selected.ID),
|
|
RefreshedAt: s.now().Format("2006-01-02 15:04:05 UTC"),
|
|
Organization: site.OrganizationOption{ID: selected.ID, Name: selected.Name, Selected: true},
|
|
IncidentsURL: "/app/incidents/?organization=" + url.QueryEscape(selected.ID),
|
|
}
|
|
projectionStatus, projectionErr := s.store.OrganizationProjectionStatus(r.Context(), selected.ID, s.now())
|
|
if projectionErr == nil && projectionStatus.PendingSegments > 0 {
|
|
view.PendingBatches = projectionStatus.PendingSegments
|
|
view.ProjectionLag = formatProjectionLag(projectionStatus.OldestPendingLag)
|
|
}
|
|
incidentDecision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsRead)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
|
|
return
|
|
}
|
|
if incidentDecision.Allowed {
|
|
incidents, incidentErr := s.store.Incidents(r.Context(), selected.ID, false, 100)
|
|
if incidentErr != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "incidents unavailable")
|
|
return
|
|
}
|
|
view.OpenIncidents = len(incidents)
|
|
}
|
|
manage, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsManage)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
|
|
return
|
|
}
|
|
view.CanManage = manage.Allowed
|
|
if view.CanManage {
|
|
view.ManageCSRF, err = authhttp.CSRFToken(token, "dashboards:manage")
|
|
if err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
|
return
|
|
}
|
|
}
|
|
for _, organization := range organizations {
|
|
view.Organizations = append(view.Organizations, site.OrganizationOption{ID: organization.ID, Name: organization.Name, Selected: organization.ID == selected.ID})
|
|
}
|
|
view.Signals = s.overviewSignals(r, selected.ID)
|
|
saved, err := s.store.SavedQueries(r.Context(), selected.ID)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "saved queries unavailable")
|
|
return
|
|
}
|
|
for _, item := range saved {
|
|
view.SavedQueries = append(view.SavedQueries, site.SavedQuerySummary{ID: item.ID, Name: item.Name, Description: item.Description, Query: item.Query})
|
|
}
|
|
dashboards, err := s.store.Dashboards(r.Context(), selected.ID)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "dashboards unavailable")
|
|
return
|
|
}
|
|
for _, item := range dashboards {
|
|
view.Dashboards = append(view.Dashboards, site.DashboardSummary{Slug: item.Slug, Name: item.Name, Description: item.Description, PanelCount: len(item.Panels)})
|
|
}
|
|
s.renderHTML(w, r, http.StatusOK, site.App(view))
|
|
}
|
|
|
|
func formatProjectionLag(lag time.Duration) string {
|
|
if lag < time.Second {
|
|
return "less than one second"
|
|
}
|
|
return lag.Round(time.Second).String()
|
|
}
|
|
|
|
func (s *Server) overviewSignals(r *http.Request, organizationID string) []site.SignalView {
|
|
definitions := []struct {
|
|
signal model.Signal
|
|
id, name string
|
|
description string
|
|
}{
|
|
{model.SignalLogs, "logs", "logs", "Recent structured events accepted for this organization."},
|
|
{model.SignalMetrics, "metrics", "metrics", "Recent numeric observations with a table alternative."},
|
|
{model.SignalTraces, "traces", "traces", "Recent spans and their correlation identities."},
|
|
{model.SignalDeployments, "deployments", "deployments", "Recent bounded deployment evidence."},
|
|
}
|
|
views := make([]site.SignalView, 0, len(definitions))
|
|
for _, definition := range definitions {
|
|
text := string(definition.signal) + " | window 1h | limit 20"
|
|
ast, err := query.Parse(text, s.options.MaxQueryRows)
|
|
var result query.Result
|
|
if err == nil {
|
|
result, err = s.store.Query(r.Context(), ast, query.Scope{OrganizationID: organizationID}, s.options.QueryBudget, s.now())
|
|
}
|
|
table := site.TableView{Caption: "Recent " + definition.name, Columns: []site.TableColumn{{Label: "Status"}}, Empty: "No observations are available in the last hour."}
|
|
if err != nil {
|
|
table.Empty = "This bounded query is temporarily unavailable."
|
|
} else {
|
|
table = resultTable("Recent "+definition.name, result)
|
|
}
|
|
views = append(views, site.SignalView{ID: definition.id, Name: definition.name, Description: definition.description, Query: text, Table: table})
|
|
}
|
|
return views
|
|
}
|
|
|
|
func resultTable(caption string, result query.Result) site.TableView {
|
|
table := site.TableView{Caption: caption, Empty: "No observations are available in the last hour."}
|
|
for _, column := range result.Columns {
|
|
table.Columns = append(table.Columns, site.TableColumn{Label: column.Field, Unit: column.Unit})
|
|
}
|
|
for _, row := range result.Rows {
|
|
view := site.TableRow{Values: make([]string, len(result.Columns))}
|
|
for index := range view.Values {
|
|
view.Values[index] = "—"
|
|
if index < len(row.Values) && row.Values[index] != nil {
|
|
view.Values[index] = boundedCell(*row.Values[index])
|
|
}
|
|
}
|
|
table.Rows = append(table.Rows, view)
|
|
}
|
|
return table
|
|
}
|
|
|
|
func boundedCell(value string) string {
|
|
const maxRunes = 256
|
|
if utf8.RuneCountInString(value) <= maxRunes {
|
|
return value
|
|
}
|
|
runes := []rune(value)
|
|
return string(runes[:maxRunes]) + "…"
|
|
}
|
|
|
|
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := auth.PrincipalFromContext(r.Context())
|
|
if !ok {
|
|
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
organizationID := r.URL.Query().Get("organization")
|
|
if len(r.URL.Query()) != 1 || len(r.URL.Query()["organization"]) != 1 {
|
|
writeProblem(w, http.StatusBadRequest, "organization is required")
|
|
return
|
|
}
|
|
scope := access.Scope{OrganizationID: organizationID}
|
|
if err := s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "invalid organization")
|
|
return
|
|
}
|
|
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsRead)
|
|
if err != nil || !decision.Allowed {
|
|
writeProblem(w, http.StatusForbidden, "dashboard access denied")
|
|
return
|
|
}
|
|
updates, remove, err := s.refresh.subscribe(organizationID)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusServiceUnavailable, "live refresh capacity reached")
|
|
return
|
|
}
|
|
defer remove()
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeProblem(w, http.StatusNotImplemented, "streaming unavailable")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
_, _ = io.WriteString(w, "event: ready\ndata: {}\n\n")
|
|
flusher.Flush()
|
|
heartbeat := time.NewTicker(20 * time.Second)
|
|
defer heartbeat.Stop()
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-updates:
|
|
_, _ = io.WriteString(w, "event: refresh\ndata: {}\n\n")
|
|
flusher.Flush()
|
|
case <-heartbeat.C:
|
|
_, _ = io.WriteString(w, ": keepalive\n\n")
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request) {
|
|
body, contentType, ok := site.Asset(r.URL.Path)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
|
if r.Method != http.MethodHead {
|
|
_, _ = w.Write(body)
|
|
}
|
|
}
|
|
|
|
func (s *Server) head(title, description, path string) site.HeadView {
|
|
return site.HeadView{Title: title, Description: description, CanonicalURL: s.options.PublicOrigin + path, Assets: site.AssetPaths()}
|
|
}
|
|
|
|
func (s *Server) renderHTML(w http.ResponseWriter, r *http.Request, status int, component sando.Component) {
|
|
var body bytes.Buffer
|
|
if err := sando.Render(r.Context(), &body, component); err != nil {
|
|
writeProblem(w, http.StatusInternalServerError, "interface render unavailable")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", body.Len()))
|
|
w.WriteHeader(status)
|
|
if r.Method != http.MethodHead {
|
|
_, _ = w.Write(body.Bytes())
|
|
}
|
|
}
|
|
|
|
func readForm(w http.ResponseWriter, r *http.Request, limit int64, fields ...string) (url.Values, error) {
|
|
return readFormFields(w, r, limit, fields, nil)
|
|
}
|
|
|
|
func readFormFields(w http.ResponseWriter, r *http.Request, limit int64, required, optional []string) (url.Values, error) {
|
|
mediaType, parameters, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
if err != nil || mediaType != "application/x-www-form-urlencoded" || len(parameters) > 1 || len(parameters) == 1 && !strings.EqualFold(parameters["charset"], "utf-8") {
|
|
return nil, errors.New("URL-encoded form required")
|
|
}
|
|
body := http.MaxBytesReader(w, r.Body, limit)
|
|
defer body.Close()
|
|
encoded, err := io.ReadAll(body)
|
|
if err != nil || !utf8.Valid(encoded) {
|
|
return nil, errors.New("invalid form body")
|
|
}
|
|
values, err := url.ParseQuery(string(encoded))
|
|
if err != nil || len(values) != len(required)+len(optional) {
|
|
return nil, errors.New("invalid form fields")
|
|
}
|
|
for _, field := range required {
|
|
if len(values[field]) != 1 || values.Get(field) == "" {
|
|
return nil, errors.New("invalid form field")
|
|
}
|
|
}
|
|
for _, field := range optional {
|
|
if len(values[field]) != 1 {
|
|
return nil, errors.New("invalid form field")
|
|
}
|
|
}
|
|
return values, nil
|
|
}
|