diff --git a/go.mod b/go.mod index 14bda8f..06bf83b 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.2.0 // indirect github.com/huandu/xstrings v1.3.2 // indirect github.com/imdario/mergo v0.3.12 // indirect + github.com/jackc/pgtype v1.6.2 github.com/jackc/pgx/v4 v4.10.1 github.com/julienschmidt/httprouter v1.3.0 github.com/mitchellh/copystructure v1.1.1 // indirect diff --git a/src/auth/session.go b/src/auth/session.go index ae4adca..a41e7e5 100644 --- a/src/auth/session.go +++ b/src/auth/session.go @@ -34,8 +34,7 @@ func makeSessionId() string { var ErrNoSession = errors.New("no session found") func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) { - var sess models.Session - err := db.QueryOneToStruct(ctx, conn, &sess, "SELECT $columns FROM sessions WHERE id = $1", id) + row, err := db.QueryOne(ctx, conn, models.Session{}, "SELECT $columns FROM sessions WHERE id = $1", id) if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { return nil, ErrNoSession @@ -43,8 +42,9 @@ func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Ses return nil, oops.New(err, "failed to get session") } } + sess := row.(*models.Session) - return &sess, nil + return sess, nil } func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*models.Session, error) { diff --git a/src/db/db.go b/src/db/db.go index 22863ca..0f3404e 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -5,15 +5,19 @@ import ( "errors" "reflect" "strings" + "time" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/log/zerologadapter" "github.com/jackc/pgx/v4/pgxpool" "github.com/rs/zerolog/log" ) +var connInfo = pgtype.NewConnInfo() + func NewConn() *pgx.Conn { conn, err := pgx.Connect(context.Background(), config.Config.Postgres.DSN()) if err != nil { @@ -40,20 +44,18 @@ func NewConnPool(minConns, maxConns int32) *pgxpool.Pool { } type StructQueryIterator struct { - fieldIndices []int - rows pgx.Rows + fieldPaths [][]int + rows pgx.Rows + destType reflect.Type } -func (it *StructQueryIterator) Next(dest interface{}) bool { +func (it *StructQueryIterator) Next() (interface{}, bool) { hasNext := it.rows.Next() if !hasNext { - return false + return nil, false } - v := reflect.ValueOf(dest) - if v.Kind() != reflect.Ptr { - panic(oops.New(nil, "Next requires a pointer type; got %v", v.Kind())) - } + result := reflect.New(it.destType) vals, err := it.rows.Values() if err != nil { @@ -61,47 +63,70 @@ func (it *StructQueryIterator) Next(dest interface{}) bool { } for i, val := range vals { - field := v.Elem().Field(it.fieldIndices[i]) + if val == nil { + continue + } + + field := followPathThroughStructs(result, it.fieldPaths[i]) + if field.Kind() == reflect.Ptr { + field.Set(reflect.New(field.Type().Elem())) + field = field.Elem() + } + switch field.Kind() { case reflect.Int: field.SetInt(reflect.ValueOf(val).Int()) - case reflect.Ptr: - // TODO: I'm pretty sure we don't handle nullable ints correctly lol. Maybe this needs to be a function somehow, and recurse onto itself?? Reflection + recursion sounds like a great idea - if val != nil { - field.Set(reflect.New(field.Type().Elem())) - field.Elem().Set(reflect.ValueOf(val)) - } default: field.Set(reflect.ValueOf(val)) } } - return true + return result.Interface(), true } func (it *StructQueryIterator) Close() { it.rows.Close() } -func QueryToStructs(ctx context.Context, conn *pgxpool.Pool, destType interface{}, query string, args ...interface{}) (StructQueryIterator, error) { - var fieldIndices []int - var columnNames []string - - t := reflect.TypeOf(destType) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - - if t.Kind() != reflect.Struct { - return StructQueryIterator{}, oops.New(nil, "QueryToStructs requires a struct type or a pointer to a struct type") - } - - for i := 0; i < t.NumField(); i++ { - f := t.Field(i) - if columnName := f.Tag.Get("db"); columnName != "" { - fieldIndices = append(fieldIndices, i) - columnNames = append(columnNames, columnName) +func (it *StructQueryIterator) ToSlice() []interface{} { + defer it.Close() + var result []interface{} + for { + row, ok := it.Next() + if !ok { + break } + result = append(result, row) + } + return result +} + +func followPathThroughStructs(structVal reflect.Value, path []int) reflect.Value { + if len(path) < 1 { + panic("can't follow an empty path") + } + + val := structVal + for _, i := range path { + if val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct { + if val.IsNil() { + val.Set(reflect.New(val.Type())) + } + val = val.Elem() + } + val = val.Field(i) + } + return val +} + +func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (StructQueryIterator, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + destType := reflect.TypeOf(destExample) + columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "") + if err != nil { + return StructQueryIterator{}, oops.New(err, "failed to generate column names") } columnNamesString := strings.Join(columnNames, ", ") @@ -109,28 +134,74 @@ func QueryToStructs(ctx context.Context, conn *pgxpool.Pool, destType interface{ rows, err := conn.Query(ctx, query, args...) if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + panic("query exceeded its deadline") + } return StructQueryIterator{}, err } return StructQueryIterator{ - fieldIndices: fieldIndices, - rows: rows, + fieldPaths: fieldPaths, + rows: rows, + destType: destType, }, nil } +func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) ([]string, [][]int, error) { + var columnNames []string + var fieldPaths [][]int + + if destType.Kind() == reflect.Ptr { + destType = destType.Elem() + } + + if destType.Kind() != reflect.Struct { + return nil, nil, oops.New(nil, "can only get column names and paths from a struct, got type '%v' (at prefix '%v')", destType.Name(), prefix) + } + + for i := 0; i < destType.NumField(); i++ { + field := destType.Field(i) + path := append(pathSoFar, i) + + if columnName := field.Tag.Get("db"); columnName != "" { + fieldType := field.Type + if destType.Kind() == reflect.Ptr { + fieldType = destType.Elem() + } + + _, isRecognizedByPgtype := connInfo.DataTypeForValue(reflect.New(fieldType)) // if pgtype recognizes it, we don't need to dig in further for more `db` tags + // NOTE: boy it would be nice if we didn't have to do reflect.New here, considering that pgtype is just doing reflection on the value anyway + + if fieldType.Kind() == reflect.Struct && !isRecognizedByPgtype { + subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, columnName+".") + if err != nil { + return nil, nil, err + } + columnNames = append(columnNames, subCols...) + fieldPaths = append(fieldPaths, subPaths...) + } else { + columnNames = append(columnNames, prefix+columnName) + fieldPaths = append(fieldPaths, path) + } + } + } + + return columnNames, fieldPaths, nil +} + var ErrNoMatchingRows = errors.New("no matching rows") -func QueryOneToStruct(ctx context.Context, conn *pgxpool.Pool, dest interface{}, query string, args ...interface{}) error { - rows, err := QueryToStructs(ctx, conn, dest, query, args...) +func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (interface{}, error) { + rows, err := Query(ctx, conn, destExample, query, args...) if err != nil { - return err + return nil, err } defer rows.Close() - hasRow := rows.Next(dest) + result, hasRow := rows.Next() if !hasRow { - return ErrNoMatchingRows + return nil, ErrNoMatchingRows } - return nil + return result, nil } diff --git a/src/models/category.go b/src/models/category.go new file mode 100644 index 0000000..ec0b14d --- /dev/null +++ b/src/models/category.go @@ -0,0 +1,27 @@ +package models + +type CategoryType int + +const ( + CatTypeBlog CategoryType = iota + 1 + CatTypeForum + CatTypeStatic + CatTypeAnnotation + CatTypeWiki + CatTypeLibraryResource +) + +type Category struct { + ID int `db:"id"` + + ParentID *int `db:"parent_id"` + ProjectID *int `db:"project_id"` + + Slug *string `db:"slug"` + Name *string `db:"name"` + Blurb *string `db:"blurb"` + Kind CategoryType `db:"kind"` + Color1 string `db:"color_1"` + Color2 string `db:"color_2"` + Depth int `db:"depth"` +} diff --git a/src/models/post.go b/src/models/post.go new file mode 100644 index 0000000..c88d403 --- /dev/null +++ b/src/models/post.go @@ -0,0 +1,31 @@ +package models + +import ( + "net" + "time" +) + +type Post struct { + ID int `db:"id"` + + // TODO: Document each of these + AuthorID *int `db:"author_id"` + CategoryID int `db:"category_id"` + ParentID *int `db:"parent_id"` + ThreadID *int `db:"thread_id"` + CurrentID int `db:"current_id"` + + Depth int `db:"depth"` + Slug string `db:"slug"` + AuthorName string `db:"author_name"` + PostDate time.Time `db:"postdate"` + IP net.IPNet `db:"ip"` + Sticky bool `db:"sticky"` + Moderated bool `db:"moderated"` // TODO: I'm not sure this is ever meaningfully used. It always seems to be 0 / false? + Hits int `db:"hits"` + Featured bool `db:"featured"` + FeatureVotes int `db:"featurevotes"` // TODO: Remove this column from the db, it's never used + + Preview string `db:"preview"` + ReadOnly bool `db:"readonly"` +} diff --git a/src/models/project.go b/src/models/project.go index 0cf88d1..f84c051 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -1,14 +1,18 @@ package models +import "reflect" + const HMNProjectID = 1 +var ProjectType = reflect.TypeOf(Project{}) + type Project struct { ID int `db:"id"` - Slug string `db:"slug"` - Name string `db:"name"` - Blurb string `db:"blurb"` - Description string `db:"description"` + Slug *string `db:"slug"` // TODO: Migrate these to NOT NULL + Name *string `db:"name"` + Blurb *string `db:"blurb"` + Description *string `db:"description"` Color1 string `db:"color_1"` Color2 string `db:"color_2"` diff --git a/src/models/thread.go b/src/models/thread.go new file mode 100644 index 0000000..0929e30 --- /dev/null +++ b/src/models/thread.go @@ -0,0 +1,17 @@ +package models + +type Thread struct { + ID int `db:"id"` + + CategoryID int `db:"category_id"` + + Title string `db:"title"` + Hits int `db:"hits"` + ReplyCount int `db:"reply_count"` + Sticky bool `db:"sticky"` + Locked bool `db:"locked"` + Moderated int `db:"moderated"` + + FirstID *int `db:"first_id"` + LastID *int `db:"last_id"` +} diff --git a/src/models/user.go b/src/models/user.go index 8db8b0a..0152e76 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -1,6 +1,11 @@ package models -import "time" +import ( + "reflect" + "time" +) + +var UserType = reflect.TypeOf(User{}) type User struct { ID int `db:"id"` diff --git a/src/templates/src/index.html b/src/templates/src/index.html index abddc79..bf5419c 100644 --- a/src/templates/src/index.html +++ b/src/templates/src/index.html @@ -3,3 +3,200 @@ {{ define "content" }} This is the index page. {{ end }} + +{{/* +{{ define "extrahead" }} + + + + + + +{{ end }} + +{% block columns %} +{% include "showcase/js_templates.html" %} +{% include "timeline/js_templates.html" %} +
+
+

Community Showcase

+ +
+ +
+
+ + +
+ +
+ This is a selection of recent work done by community members. Want to participate? Join us on Discord. +
+
+ + +
+
+
+

Around the Network

+ +
+
+
+{% spaceless %} +
+ {% for col in recent_post_columns %} +
+
+ {% if forloop.counter == 1 %} +
+ {% include "blog_index_thread_list_entry.html" with post=featured_post align_top=True %} +
+ {% endif %} + + {% for entry in col %} + {% with proj=entry.project posts=entry.posts %} +
+ +

{{ proj.name }}

+
+ + {% if entry.featured and proj.slug != "hmn" %} + {% with post=entry.featured.0 has_read=entry.featured.1 %} + {% if post.category.kind == 5 and post.parent == None %} + {% include "thread_list_entry.html" with thread=post.thread %} + {% else %} + {% include "blog_index_thread_list_entry.html" with align_top=True %} + {% endif %} + {% endwith %} + {% endif %} + + {% for post, has_read in posts %} + {% if forloop.counter0 < max_posts %} + {% include "thread_list_entry.html" with thread=post.thread %} + {% endif %} + {% endfor %} + {% with more=posts|length|add:-5|clamp_lower:0 %} + {% if more > 0 %} + + {% endif %} + {% endwith %} +
+ {% endwith %} + {% endfor %} +
+
+ {% endfor %} +
+{% endspaceless %} +{% endblock %} +*/}} \ No newline at end of file diff --git a/src/templates/src/layouts/base.html b/src/templates/src/layouts/base.html index 734c5ca..032433e 100644 --- a/src/templates/src/layouts/base.html +++ b/src/templates/src/layouts/base.html @@ -63,7 +63,7 @@
{{ template "header.html" . }} - based + {{ block "content" . }}{{ end }} {{ template "footer.html" . }}
diff --git a/src/templates/types.go b/src/templates/types.go index cc88ace..1c88c27 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -42,3 +42,6 @@ type BackgroundImage struct { Url string Size string // A valid CSS background-size value } + +type Post struct { +} diff --git a/src/website/landing.go b/src/website/landing.go new file mode 100644 index 0000000..da1a123 --- /dev/null +++ b/src/website/landing.go @@ -0,0 +1,148 @@ +package website + +import ( + "net/http" + "time" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/templates" + "github.com/julienschmidt/httprouter" +) + +type LandingTemplateData struct { + templates.BaseData + + PostColumns [][]LandingPageProject + ShowcaseTimelineJson string +} + +type LandingPageProject struct { + Project templates.Project + FeaturedPost *LandingPagePost + Posts []LandingPagePost +} + +type LandingPagePost struct { + Post templates.Post + HasRead bool +} + +func (s *websiteRoutes) Index(c *RequestContext, p httprouter.Params) { + const maxPosts = 5 + const numProjectsToGet = 7 + + iterProjects, err := db.Query(c.Context(), s.conn, models.Project{}, + "SELECT $columns FROM handmade_project WHERE flags = 0 OR id = $1", + models.HMNProjectID, + ) + if err != nil { + c.Errored(http.StatusInternalServerError, oops.New(err, "failed to get projects for home page")) + return + } + defer iterProjects.Close() + + var pageProjects []LandingPageProject + _ = pageProjects // TODO: NO + + for _, projRow := range iterProjects.ToSlice() { + proj := projRow.(*models.Project) + + type ProjectPost struct { + Post models.Post `db:"post"` + ThreadLastReadTime *time.Time `db:"tlri.lastread"` + CatLastReadTime *time.Time `db:"clri.lastread"` + } + + memberId := 3 // TODO: NO + projectPostIter, err := db.Query(c.Context(), s.conn, ProjectPost{}, + ` + SELECT $columns + FROM + handmade_post AS post + JOIN handmade_thread AS thread ON thread.id = post.thread_id + JOIN handmade_category AS cat ON cat.id = thread.category_id + LEFT OUTER JOIN handmade_threadlastreadinfo AS tlri ON ( + tlri.thread_id = thread.id + AND tlri.member_id = $1 + ) + LEFT OUTER JOIN handmade_categorylastreadinfo AS clri ON ( + clri.category_id = cat.id + AND clri.member_id = $1 + ) + WHERE + cat.project_id = $2 + AND cat.kind IN ($3, $4, $5, $6) + AND post.moderated = FALSE + AND post.thread_id IS NOT NULL + ORDER BY postdate DESC + LIMIT $7 + `, + memberId, + proj.ID, + models.CatTypeBlog, models.CatTypeForum, models.CatTypeWiki, models.CatTypeLibraryResource, + maxPosts, + ) + if err != nil { + c.Logger.Error().Err(err).Msg("failed to fetch project posts") + continue + } + projectPosts := projectPostIter.ToSlice() + + landingPageProject := LandingPageProject{ + Project: templates.Project{ // TODO: Use a common function to map from model to template data + Name: *proj.Name, + Subdomain: *proj.Slug, + // ... + }, + } + + for _, projectPostRow := range projectPosts { + projectPost := projectPostRow.(*ProjectPost) + + hasRead := false + if projectPost.ThreadLastReadTime != nil && projectPost.ThreadLastReadTime.After(projectPost.Post.PostDate) { + hasRead = true + } else if projectPost.CatLastReadTime != nil && projectPost.CatLastReadTime.After(projectPost.Post.PostDate) { + hasRead = true + } + + landingPageProject.Posts = append(landingPageProject.Posts, LandingPagePost{ + Post: templates.Post{}, // TODO: Use a common function to map from model to template again + HasRead: hasRead, + }) + } + } + + type newsThreadQuery struct { + Thread models.Thread `db:"thread"` + } + newsThreadRow, err := db.QueryOne(c.Context(), s.conn, newsThreadQuery{}, + ` + SELECT $columns + FROM + handmade_thread as thread + JOIN handmade_category AS cat ON thread.category_id = cat.id + WHERE + cat.project_id = $1 + AND cat.kind = $2 + `, + models.HMNProjectID, + models.CatTypeBlog, + ) + if err != nil { + c.Errored(http.StatusInternalServerError, oops.New(err, "failed to fetch latest news post")) + return + } + newsThread := newsThreadRow.(*newsThreadQuery) + _ = newsThread // TODO: NO + + baseData := s.getBaseData(c) + baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more? + + err = c.WriteTemplate("index.html", s.getBaseData(c)) + if err != nil { + panic(err) + } +} diff --git a/src/website/routes.go b/src/website/routes.go index abba5ca..fc92ad9 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -33,7 +33,13 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler { } mainRoutes := routes.WithWrappers(routes.CommonWebsiteDataWrapper) - mainRoutes.GET("/", routes.Index) + mainRoutes.GET("/", func(c *RequestContext, p httprouter.Params) { + if c.currentProject.ID == models.HMNProjectID { + routes.Index(c, p) + } else { + // TODO: Return the project landing page + } + }) mainRoutes.GET("/project/:id", routes.Project) mainRoutes.GET("/assets/project.css", routes.ProjectCSS) @@ -58,8 +64,8 @@ func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData { return templates.BaseData{ Project: templates.Project{ - Name: c.currentProject.Name, - Subdomain: c.currentProject.Slug, + Name: *c.currentProject.Name, + Subdomain: *c.currentProject.Slug, Color: c.currentProject.Color1, IsHMN: c.currentProject.IsHMN(), @@ -75,16 +81,15 @@ func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData { } func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) { - var subdomainProject models.Project - err := db.QueryOneToStruct(ctx, conn, &subdomainProject, "SELECT $columns FROM handmade_project WHERE slug = $1", slug) + subdomainProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE slug = $1", slug) if err == nil { + subdomainProject := subdomainProjectRow.(models.Project) return &subdomainProject, nil } else if !errors.Is(err, db.ErrNoMatchingRows) { return nil, oops.New(err, "failed to get projects by slug") } - var defaultProject models.Project - err = db.QueryOneToStruct(ctx, conn, &defaultProject, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID) + defaultProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID) if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { return nil, oops.New(nil, "default project didn't exist in the database") @@ -92,15 +97,9 @@ func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (* return nil, oops.New(err, "failed to get default project") } } + defaultProject := defaultProjectRow.(*models.Project) - return &defaultProject, nil -} - -func (s *websiteRoutes) Index(c *RequestContext, p httprouter.Params) { - err := c.WriteTemplate("index.html", s.getBaseData(c)) - if err != nil { - panic(err) - } + return defaultProject, nil } func (s *websiteRoutes) Project(c *RequestContext, p httprouter.Params) { @@ -159,8 +158,7 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) { redirect = "/" } - var user models.User - err = db.QueryOneToStruct(c.Context(), s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", username) + userRow, err := db.QueryOne(c.Context(), s.conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", username) if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { c.StatusCode = http.StatusUnauthorized @@ -169,6 +167,7 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) { } return } + user := userRow.(models.User) hashed, err := auth.ParsePasswordString(user.Password) if err != nil { @@ -285,8 +284,7 @@ func (s *websiteRoutes) getCurrentUserAndMember(ctx context.Context, sessionId s } } - var user models.User - err = db.QueryOneToStruct(ctx, s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", session.Username) + userRow, err := db.QueryOne(ctx, s.conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", session.Username) if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found") @@ -295,8 +293,9 @@ func (s *websiteRoutes) getCurrentUserAndMember(ctx context.Context, sessionId s return nil, oops.New(err, "failed to get user for session") } } + user := userRow.(*models.User) // TODO: Also get the member model - return &user, nil + return user, nil } diff --git a/src/website/website.go b/src/website/website.go index 8727289..4d15022 100644 --- a/src/website/website.go +++ b/src/website/website.go @@ -25,7 +25,7 @@ var WebsiteCommand = &cobra.Command{ logging.Info().Msg("Hello, HMN!") - conn := db.NewConnPool(4, 8) + conn := db.NewConnPool(4, 128) server := http.Server{ Addr: config.Config.Addr, @@ -42,10 +42,12 @@ var WebsiteCommand = &cobra.Command{ go func() { <-signals logging.Info().Msg("Shutting down the website") - timeout, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - server.Shutdown(timeout) - cancelBackgroundJobs() + go func() { + timeout, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + server.Shutdown(timeout) + cancelBackgroundJobs() + }() <-signals logging.Warn().Msg("Forcibly killed the website")