3 Commits

Author SHA1 Message Date
4f89f59232 fix(cluster-3): http hardening (M15/M16/M14/C2)
- pagination: clamp per_page to [1,100] and page to >=1 in the parser,
  guard the TotalPages division against per_page=0 (panic), and clamp the
  current page to [1,totalPages]; preserves cursor (next-token) mode
- middleware: add panic-recovery as the outermost middleware so handler
  panics return a 500 instead of crashing the process; re-panics
  http.ErrAbortHandler to keep file serving intact
- index: bound the index page query (Limit:200) so it no longer drains
  the entire courses table in 1000-row batches
2026-06-28 04:31:21 +00:00
40e5621eb9 fix(cluster-2): error contract unification (C1/M10/M11/M12)
- http: handleError now recognizes domain.ErrNotFound in addition to the
  common errors.ErrNotFound sentinel, so repo-not-found maps to 404
  instead of 500 (the two packages use distinct error types)
- sqlite_course_repository: propagate listCount errors instead of logging
  and swallowing them, which left callers with a silent Count=0
- synchandler: collect course/organization insert failures into a
  function-scoped error via errors.Join and return it; previously the
  loop-local err was overwritten and the handler always returned nil,
  hiding all insert failures
2026-06-28 04:28:48 +00:00
23c29aba1d fix(cluster-1): data corruption fixes (C4/C5/C8)
- synchandler: combine course date with clock time via time.Date instead
  of adding two absolute Unix epochs, which produced corrupt start times
- tracing: make DeploymentEnvironment configurable via config.Trace
  (defaults to development instead of hardcoded production)
- http: align course handler tracer name to 'kuriweb.http' to match the
  request middleware instrument so spans share the same tracer
2026-06-27 23:54:07 +00:00
7 changed files with 82 additions and 28 deletions

View File

@ -1,6 +1,7 @@
package main
import (
"fmt"
"log/slog"
"net/http"
"strings"
@ -54,6 +55,7 @@ func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server
router := mux.NewRouter()
router.Use(
middlewareRecovery(log),
middlewareCustomWriterInjector(),
mux.CORSMethodMiddleware(router),
middlewareLogger(log),
@ -101,6 +103,29 @@ func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server
}
}
func middlewareRecovery(log *slog.Logger) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
if rec == http.ErrAbortHandler {
panic(rec)
}
xcontext.LogWithError(
r.Context(), log, fmt.Errorf("%v", rec), "recovered from panic",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}()
next.ServeHTTP(w, r)
})
}
}
func middlewareCustomWriterInjector() mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -50,7 +50,7 @@ func setupOtelSDK(ctx context.Context, cfg config.Trace) (shutdown shutdownFunc,
return err
}
resource, err := makeServiceResource(ctx)
resource, err := makeServiceResource(ctx, cfg.Environment)
if err != nil {
return shutdown, fmt.Errorf("making service resource: %w", err)
}
@ -102,7 +102,10 @@ type TraceProviderParams struct {
Type config.TraceClientType
}
func makeServiceResource(ctx context.Context) (*resource.Resource, error) {
func makeServiceResource(ctx context.Context, environment string) (*resource.Resource, error) {
if environment == "" {
environment = "development"
}
r, err := resource.New(
ctx,
resource.WithDetectors(
@ -113,7 +116,7 @@ func makeServiceResource(ctx context.Context) (*resource.Resource, error) {
resource.WithHost(),
resource.WithAttributes(
semconv.ServiceName("bigstats:kuriweb"),
semconv.DeploymentEnvironment("production"),
semconv.DeploymentEnvironment(environment),
),
)
if err != nil {

View File

@ -33,6 +33,7 @@ type Trace struct {
APIKey string `json:"api_key"`
APIHeader string `json:"api_header"`
Type TraceClientType `json:"type"`
Environment string `json:"environment"`
ShowMetrics bool `json:"show_metrics"`
}

View File

@ -10,7 +10,6 @@ import (
"time"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain"
@ -112,7 +111,7 @@ func (r *sqliteCourseRepository) List(
result.Count, err = r.listCount(ctx, params)
if err != nil {
xcontext.LogWithWarnError(ctx, r.log, err, "unable to list count")
return result, fmt.Errorf("listing count: %w", err)
}
span.SetAttributes(

View File

@ -85,6 +85,7 @@ func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
courses := make([]sravni.Course, 0, 1024)
buffer := make([]sravni.Course, 0, 512)
organizations := make([]sravni.Organization, 0, 256)
var insertErr error
for _, learningType := range learningTypes.Fields {
select {
case <-ctx.Done():
@ -174,22 +175,22 @@ func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
var insertCourseSuccess bool
if len(courses) > 0 {
err = h.insertCourses(lctx, courses)
if err != nil {
xcontext.LogWithError(lctx, h.log, err, "unable to insert courses")
if cerr := h.insertCourses(lctx, courses); cerr != nil {
xcontext.LogWithError(lctx, h.log, cerr, "unable to insert courses")
insertErr = errors.Join(insertErr, cerr)
} else {
insertCourseSuccess = true
}
insertCourseSuccess = err == nil
}
var insertOrgsSuccess bool
if len(organizations) > 0 {
err = h.insertOrganizations(lctx, organizations)
if err != nil {
xcontext.LogWithError(lctx, h.log, err, "unable to insert courses")
if oerr := h.insertOrganizations(lctx, organizations); oerr != nil {
xcontext.LogWithError(lctx, h.log, oerr, "unable to insert organizations")
insertErr = errors.Join(insertErr, oerr)
} else {
insertOrgsSuccess = true
}
insertOrgsSuccess = err == nil
}
elapsed = time.Since(start) - elapsed
@ -205,7 +206,7 @@ func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
)
}
return nil
return insertErr
}
func (h *syncSravniHandler) loadEducationalProducts(ctx context.Context, learningType, courseThematic string, buf []sravni.Course) ([]sravni.Course, map[string]sravni.Organization, error) {
@ -384,8 +385,12 @@ func courseAsCreateCourseParams(course sravni.Course) command.CreateCourse {
startAt = *course.DateStart
}
if course.TimeStart != nil {
startAtUnix := startAt.Unix() + course.TimeStart.Unix()
startAt = time.Unix(startAtUnix, 0)
clock := *course.TimeStart
startAt = time.Date(
startAt.Year(), startAt.Month(), startAt.Day(),
clock.Hour(), clock.Minute(), clock.Second(), clock.Nanosecond(),
startAt.Location(),
)
}
var courseDuration time.Duration

View File

@ -23,7 +23,7 @@ import (
var (
paramsAttr = attribute.Key("params")
webtracer = otel.Tracer("http")
webtracer = otel.Tracer("kuriweb.http")
)
type courseTemplServer struct {
@ -213,6 +213,15 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
}
})
totalPages := 0
if pathParams.PerPage > 0 {
totalPages = listCoursesResult.Count / pathParams.PerPage
}
currentPage := pathParams.Page
if currentPage > 0 && totalPages > 0 && currentPage > totalPages {
currentPage = totalPages
}
params = bootstrap.ListCoursesParams{
FilterForm: bootstrap.FilterFormParams{
Render: true,
@ -233,8 +242,8 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
Courses: params.Courses,
Categories: params.Categories,
Pagination: bootstrap.Pagination{
Page: pathParams.Page,
TotalPages: listCoursesResult.Count / pathParams.PerPage,
Page: currentPage,
TotalPages: totalPages,
BaseURL: r.URL.Path,
},
}
@ -285,7 +294,10 @@ func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) {
stats := bootstrap.MakeNewStats(1, 2, 3)
coursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{})
const indexCoursesLimit = 200
coursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
Limit: indexCoursesLimit,
})
if handleError(ctx, err, w, c.log, "unable to list courses") {
return
}

View File

@ -9,6 +9,7 @@ import (
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"git.loyso.art/frx/kurious/internal/kurious/service"
"git.loyso.art/frx/kurious/pkg/xdefault"
@ -49,7 +50,7 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slo
case stderrors.As(err, &valErr):
errorString = valErr.Error()
code = http.StatusBadRequest
case stderrors.Is(err, errors.ErrNotFound):
case stderrors.Is(err, errors.ErrNotFound), stderrors.Is(err, domain.ErrNotFound):
errorString = err.Error()
code = http.StatusNotFound
default:
@ -87,14 +88,22 @@ func parsePaginationFromQuery(r *http.Request) (out pagination, err error) {
} else {
out.PerPage = 20
}
if out.PerPage < 1 {
out.PerPage = 1
} else if out.PerPage > 100 {
out.PerPage = 100
}
if query.Has("page") {
out.Page, err = strconv.Atoi(query.Get("page"))
if err != nil {
return out, errors.NewValidationError("page", "bad per_page value")
return out, errors.NewValidationError("page", "bad page value")
}
} else if !query.Has("next") {
out.Page = 1
}
if out.Page < 1 && !query.Has("next") {
out.Page = 1
}
return out, nil
}