Files
kurious/internal/kurious/ports/http/course.go
Aleksandr Trushkin 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

419 lines
11 KiB
Go

package http
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"slices"
"sync"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/app/query"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"git.loyso.art/frx/kurious/internal/kurious/ports/http/bootstrap"
"git.loyso.art/frx/kurious/internal/kurious/service"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
var (
paramsAttr = attribute.Key("params")
webtracer = otel.Tracer("kuriweb.http")
)
type courseTemplServer struct {
app service.Application
log *slog.Logger
}
func makeTemplListCoursesParams(counts map[string]domain.LearningTypeStat, in ...domain.Course) bootstrap.ListCoursesParams {
coursesBySubcategory := make(map[string][]bootstrap.CourseInfo, len(in))
subcategoriesByCategories := make(map[string]map[string]struct{}, len(in))
categoryByID := make(map[string]bootstrap.CategoryBaseInfo, len(in))
seenCourses := make(map[string]struct{}, len(in))
var out bootstrap.ListCoursesParams
xslices.ForEach(in, func(c domain.Course) {
courseInfo := bootstrap.CourseInfo{
ID: c.ID,
Name: c.Name,
FullPrice: int(c.FullPrice),
ImageLink: c.ImageLink,
OriginLink: c.OriginLink,
}
coursesBySubcategory[c.ThematicID] = append(coursesBySubcategory[c.ThematicID], courseInfo)
if _, ok := subcategoriesByCategories[c.LearningTypeID]; !ok {
subcategoriesByCategories[c.LearningTypeID] = map[string]struct{}{}
}
subcategoriesByCategories[c.LearningTypeID][c.ThematicID] = struct{}{}
if _, ok := categoryByID[c.LearningTypeID]; !ok {
categoryByID[c.LearningTypeID] = bootstrap.CategoryBaseInfo{
ID: c.LearningTypeID,
Name: c.LearningType,
}
}
if _, ok := categoryByID[c.ThematicID]; !ok {
categoryByID[c.ThematicID] = bootstrap.CategoryBaseInfo{
ID: c.ThematicID,
Name: c.Thematic,
Count: counts[c.LearningTypeID].CourseThematic[c.ThematicID],
}
}
if _, ok := seenCourses[c.ExternalID.Value()]; ok && c.ExternalID.Valid() {
return
}
out.Courses = append(out.Courses, courseInfo)
seenCourses[c.ExternalID.Value()] = struct{}{}
})
for categoryID, subcategoriesID := range subcategoriesByCategories {
outCategory := bootstrap.CategoryContainer{
CategoryBaseInfo: categoryByID[categoryID],
}
for subcategoryID := range subcategoriesID {
outSubcategory := bootstrap.SubcategoryContainer{
CategoryBaseInfo: categoryByID[subcategoryID],
Courses: coursesBySubcategory[subcategoryID],
}
outCategory.Subcategories = append(outCategory.Subcategories, outSubcategory)
}
out.Categories = append(out.Categories, outCategory)
}
return out
}
func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var span trace.Span
ctx, span = webtracer.Start(ctx, "http_server.list")
defer func() {
span.End()
}()
stats := bootstrap.MakeNewStats(10_240, 2_560_000, 1800)
pathParams, err := parseListCoursesParams(r)
if handleError(ctx, err, w, c.log, "unable to parse list courses params") {
return
}
jsonParams, _ := json.Marshal(pathParams)
span.SetAttributes(paramsAttr.String(string(jsonParams)))
var offset int
if pathParams.Page > 0 {
offset = (pathParams.Page - 1) * pathParams.PerPage
}
listCoursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
CourseThematic: pathParams.CourseThematic,
LearningType: pathParams.LearningType,
OrganizationID: pathParams.School,
OrderBy: orderByListing.getField(pathParams.OrderBy),
Ascending: pathParams.Ascending,
Limit: pathParams.PerPage,
NextPageToken: pathParams.NextPageToken,
Offset: offset,
})
if handleError(ctx, err, w, c.log, "unable to list courses") {
return
}
statsresult, err := c.app.Queries.ListCourseStatistics.Handle(ctx, query.ListCoursesStats{
LearningTypeID: pathParams.LearningType,
CourseThematicsID: pathParams.CourseThematic,
OrganizationID: pathParams.School,
})
if handleError(ctx, err, w, c.log, "unable to load stats") {
return
}
params := makeTemplListCoursesParams(statsresult.StatsByLearningType, listCoursesResult.Courses...)
learningTypeResult, err := c.app.Queries.ListLearningTypes.Handle(ctx, query.ListLearningTypes{})
if handleError(ctx, err, w, c.log, "unable to list learning types") {
return
}
params.FilterForm.AvailableLearningTypes = xslices.Map(learningTypeResult.LearningTypes, func(in query.LearningType) bootstrap.Category {
outcategory := bootstrap.Category{
ID: in.ID,
Name: in.Name,
}
if in.ID == pathParams.LearningType {
params.FilterForm.ActiveLearningType = outcategory
}
return outcategory
})
if pathParams.LearningType != "" {
courseThematicsResult, err := c.app.Queries.ListCourseThematics.Handle(ctx, query.ListCourseThematics{
LearningTypeID: pathParams.LearningType,
})
if handleError(ctx, err, w, c.log, "unable to list course thematics") {
return
}
params.FilterForm.AvailableCourseThematics = xslices.Map(courseThematicsResult.CourseThematics, func(in query.CourseThematic) bootstrap.Category {
outcategory := bootstrap.Category{
ID: in.ID,
Name: in.Name,
}
if pathParams.CourseThematic == in.ID {
params.FilterForm.ActiveCourseThematic = outcategory
}
return outcategory
})
}
organizaions, err := c.app.Queries.ListOrganizationsStats.Handle(ctx, query.ListOrganizationsStats{
LearningTypeID: pathParams.LearningType,
CourseThematicID: pathParams.CourseThematic,
})
if handleError(ctx, err, w, c.log, "unable to list organizations") {
return
}
organizationStatSortFunc := func(lhs, rhs domain.OrganizationStat) int {
if lhs.CoursesCount > rhs.CoursesCount {
return -1
} else if lhs.CoursesCount < rhs.CoursesCount {
return 1
}
if lhs.ID > rhs.ID {
return 1
}
return -1
}
slices.SortFunc(organizaions, organizationStatSortFunc)
schools := xslices.Map(organizaions, func(in domain.OrganizationStat) bootstrap.NameIDPair {
return bootstrap.NameIDPair{
ID: in.ID,
Name: fmt.Sprintf("%s (count: %d)", in.Name, in.CoursesCount),
}
})
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,
BreadcrumbsParams: bootstrap.BreadcrumbsParams{
ActiveLearningType: params.FilterForm.ActiveLearningType,
ActiveCourseThematic: params.FilterForm.ActiveCourseThematic,
},
AvailableLearningTypes: params.FilterForm.AvailableLearningTypes,
AvailableCourseThematics: params.FilterForm.AvailableCourseThematics,
Schools: bootstrap.CoursesFilterViewParams{
SelectedSchoolID: pathParams.School,
Schools: schools,
Ascending: pathParams.Ascending,
OrderBy: pathParams.OrderBy,
OrderFields: orderByListing.asNameIDPair(),
},
},
Courses: params.Courses,
Categories: params.Categories,
Pagination: bootstrap.Pagination{
Page: currentPage,
TotalPages: totalPages,
BaseURL: r.URL.Path,
},
}
c.log.DebugContext(
ctx, "params rendered",
slog.Int("course_thematic", len(params.FilterForm.AvailableCourseThematics)),
slog.Int("learning_type", len(params.FilterForm.AvailableLearningTypes)),
slog.Int("items", len(listCoursesResult.Courses)),
slog.Int("page", params.Pagination.Page),
slog.Int("total_pages", params.Pagination.TotalPages),
)
slices.SortFunc(params.Categories, func(lhs, rhs bootstrap.CategoryContainer) int {
if lhs.Count > rhs.Count {
return 1
} else if lhs.Count < rhs.Count {
return -1
} else {
return 0
}
})
span.AddEvent("starting to render")
if pathParams.CourseThematic == "" {
params.FilterForm.Render = true
err = bootstrap.ListCourseThematics(bootstrap.PageCourses, stats, params).Render(ctx, w)
} else {
err = bootstrap.ListCourses(bootstrap.PageCourses, stats, params).Render(ctx, w)
}
span.AddEvent("render finished")
if handleError(ctx, err, w, c.log, "unable to render list courses") {
return
}
span.SetStatus(codes.Ok, "request completed")
}
func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var span trace.Span
ctx, span = webtracer.Start(ctx, "http_server.index")
defer func() {
span.End()
}()
stats := bootstrap.MakeNewStats(1, 2, 3)
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
}
params := bootstrap.MainPageParams{
Categories: []bootstrap.IndexCourseCategoryItem{},
}
coursesByLearningType := make(map[IDNamePair][]domain.Course)
xslices.ForEach(coursesResult.Courses, func(in domain.Course) {
pair := IDNamePair{
ID: in.LearningTypeID,
Name: in.LearningType,
}
coursesByLearningType[pair] = append(coursesByLearningType[pair], in)
})
for learningTypeInfo, courses := range coursesByLearningType {
category := bootstrap.IndexCourseCategoryItem{
ID: learningTypeInfo.ID,
Name: learningTypeInfo.Name,
Count: len(courses),
}
xslices.Shuffle(courses)
if len(courses) > 3 {
courses = courses[:3]
}
names := xslices.Map(courses, func(in domain.Course) string {
return in.Name
})
category.ExampleThemes = names
params.Categories = append(params.Categories, category)
}
slices.SortFunc(params.Categories, func(lhs, rhs bootstrap.IndexCourseCategoryItem) int {
if lhs.Count < rhs.Count {
return 1
} else if lhs.Count > rhs.Count {
return -1
}
return 0
})
span.AddEvent("starting to render")
err = bootstrap.MainPage(bootstrap.PageIndex, stats, params).Render(ctx, w)
span.AddEvent("render finished")
if handleError(ctx, err, w, c.log, "rendeting template") {
return
}
span.SetStatus(codes.Ok, "request completed")
}
var orderByListing = newOrderableContainer(
newOrderableUnit("pr", "Price", "full_price"),
newOrderableUnit("na", "Name", "name"),
newOrderableUnit("di", "Discount", "discount"),
newOrderableUnit("du", "Duration", "duration"),
newOrderableUnit("st", "Starts At", "starts_at"),
)
type orderableUnit struct {
ID string
Name string
Field string
}
func newOrderableUnit(id, name, field string) orderableUnit {
return orderableUnit{
ID: id,
Name: name,
Field: field,
}
}
type orderableContainer struct {
nameByID map[string]string
fieldByID map[string]string
cachedNameIDPair []bootstrap.NameIDPair
makeCache sync.Once
}
func (c *orderableContainer) asNameIDPair() []bootstrap.NameIDPair {
c.makeCache.Do(func() {
c.cachedNameIDPair = make([]bootstrap.NameIDPair, 0, len(c.nameByID))
for id, name := range c.nameByID {
c.cachedNameIDPair = append(c.cachedNameIDPair, bootstrap.NameIDPair{
ID: id,
Name: name,
})
}
})
return c.cachedNameIDPair
}
func (c *orderableContainer) getField(id string) string {
return c.fieldByID[id]
}
func newOrderableContainer(units ...orderableUnit) *orderableContainer {
nameByID := make(map[string]string, len(units))
fieldByID := make(map[string]string, len(units))
xslices.ForEach(units, func(u orderableUnit) {
nameByID[u.ID] = u.Name
fieldByID[u.ID] = u.Field
})
return &orderableContainer{
nameByID: nameByID,
fieldByID: fieldByID,
}
}