»
diff --git a/src/templates/types.go b/src/templates/types.go
index e6abaeb1..149913d6 100644
--- a/src/templates/types.go
+++ b/src/templates/types.go
@@ -87,8 +87,20 @@ type PostListItem struct {
User User
Date time.Time
Unread bool
+ Classes string
+ Content string
}
type Breadcrumb struct {
Name, Url string
}
+
+type Pagination struct {
+ Current int
+ Total int
+
+ FirstUrl string
+ LastUrl string
+ PreviousUrl string
+ NextUrl string
+}
diff --git a/src/website/feed.go b/src/website/feed.go
new file mode 100644
index 00000000..7d9600aa
--- /dev/null
+++ b/src/website/feed.go
@@ -0,0 +1,169 @@
+package website
+
+import (
+ "math"
+ "net/http"
+ "strconv"
+ "time"
+
+ "git.handmade.network/hmn/hmn/src/db"
+ "git.handmade.network/hmn/hmn/src/logging"
+ "git.handmade.network/hmn/hmn/src/models"
+ "git.handmade.network/hmn/hmn/src/oops"
+ "git.handmade.network/hmn/hmn/src/templates"
+)
+
+type FeedData struct {
+ templates.BaseData
+
+ Posts []templates.PostListItem
+ Pagination templates.Pagination
+}
+
+func Feed(c *RequestContext) ResponseData {
+ const postsPerPage = 30
+
+ numPosts, err := db.QueryInt(c.Context(), c.Conn,
+ `
+ SELECT COUNT(*)
+ FROM
+ handmade_post AS post
+ JOIN handmade_category AS cat ON cat.id = post.category_id
+ WHERE
+ cat.kind IN ($1, $2, $3, $4)
+ AND NOT moderated
+ `,
+ models.CatTypeForum,
+ models.CatTypeBlog,
+ models.CatTypeWiki,
+ models.CatTypeLibraryResource,
+ ) // TODO(inarray)
+ if err != nil {
+ return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get count of feed posts"))
+ }
+
+ numPages := int(math.Ceil(float64(numPosts) / 30))
+
+ page := 1
+ pageString := c.PathParams.ByName("page")
+ if pageString != "" {
+ if pageParsed, err := strconv.Atoi(pageString); err == nil {
+ page = pageParsed
+ } else {
+ return c.Redirect("/feed", http.StatusSeeOther)
+ }
+ }
+ if page < 1 || numPages < page {
+ return c.Redirect("/feed", http.StatusSeeOther)
+ }
+
+ howManyPostsToSkip := (page - 1) * postsPerPage
+
+ pagination := templates.Pagination{
+ Current: page,
+ Total: numPages,
+
+ // TODO: urls
+ }
+
+ var currentUserId *int
+ if c.CurrentUser != nil {
+ currentUserId = &c.CurrentUser.ID
+ }
+
+ type feedPostQuery struct {
+ Post models.Post `db:"post"`
+ Thread models.Thread `db:"thread"`
+ Cat models.Category `db:"cat"`
+ Proj models.Project `db:"proj"`
+ User models.User `db:"auth_user"`
+ ThreadLastReadTime *time.Time `db:"tlri.lastread"`
+ CatLastReadTime *time.Time `db:"clri.lastread"`
+ }
+ posts, err := db.Query(c.Context(), c.Conn, feedPostQuery{},
+ `
+ 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
+ JOIN handmade_project AS proj ON proj.id = cat.project_id
+ LEFT OUTER JOIN handmade_threadlastreadinfo AS tlri ON (
+ tlri.thread_id = thread.id
+ AND tlri.user_id = $1
+ )
+ LEFT OUTER JOIN handmade_categorylastreadinfo AS clri ON (
+ clri.category_id = cat.id
+ AND clri.user_id = $1
+ )
+ LEFT OUTER JOIN auth_user ON post.author_id = auth_user.id
+ WHERE
+ cat.kind IN ($2, $3, $4, $5)
+ AND post.moderated = FALSE
+ AND post.thread_id IS NOT NULL
+ ORDER BY postdate DESC
+ LIMIT $6 OFFSET $7
+ `,
+ currentUserId,
+ models.CatTypeForum,
+ models.CatTypeBlog,
+ models.CatTypeWiki,
+ models.CatTypeLibraryResource,
+ postsPerPage,
+ howManyPostsToSkip,
+ ) // TODO(inarray)
+ if err != nil {
+ return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts"))
+ }
+
+ var postItems []templates.PostListItem
+ for _, iPostResult := range posts.ToSlice() {
+ postResult := iPostResult.(*feedPostQuery)
+
+ hasRead := false
+ if postResult.ThreadLastReadTime != nil && postResult.ThreadLastReadTime.After(postResult.Post.PostDate) {
+ hasRead = true
+ } else if postResult.CatLastReadTime != nil && postResult.CatLastReadTime.After(postResult.Post.PostDate) {
+ hasRead = true
+ }
+
+ parents := postResult.Cat.GetParents(c.Context(), c.Conn)
+ logging.Debug().Interface("parents", parents).Msg("")
+
+ var breadcrumbs []templates.Breadcrumb
+ breadcrumbs = append(breadcrumbs, templates.Breadcrumb{
+ Name: *postResult.Proj.Name,
+ Url: "nargle", // TODO
+ })
+ for i := len(parents) - 1; i >= 0; i-- {
+ breadcrumbs = append(breadcrumbs, templates.Breadcrumb{
+ Name: *parents[i].Name,
+ Url: "nargle", // TODO
+ })
+ }
+
+ postItems = append(postItems, templates.PostListItem{
+ Title: postResult.Thread.Title,
+ Url: templates.PostUrl(postResult.Post, postResult.Cat.Kind, postResult.Proj.Subdomain()),
+ User: templates.UserToTemplate(&postResult.User),
+ Date: postResult.Post.PostDate,
+ Breadcrumbs: breadcrumbs,
+ Unread: !hasRead,
+ Classes: "post-bg-alternate", // TODO: Should this be the default, and the home page can suppress it?
+ Content: postResult.Post.Preview,
+ })
+ }
+
+ baseData := getBaseData(c)
+ baseData.BodyClasses = append(baseData.BodyClasses, "feed")
+
+ var res ResponseData
+ res.WriteTemplate("feed.html", FeedData{
+ BaseData: baseData,
+
+ Posts: postItems,
+ Pagination: pagination,
+ })
+
+ return res
+}
diff --git a/src/website/login.go b/src/website/login.go
new file mode 100644
index 00000000..f0a7b18d
--- /dev/null
+++ b/src/website/login.go
@@ -0,0 +1,98 @@
+package website
+
+import (
+ "errors"
+ "net/http"
+
+ "git.handmade.network/hmn/hmn/src/auth"
+ "git.handmade.network/hmn/hmn/src/db"
+ "git.handmade.network/hmn/hmn/src/logging"
+ "git.handmade.network/hmn/hmn/src/models"
+ "git.handmade.network/hmn/hmn/src/oops"
+)
+
+func Login(c *RequestContext) ResponseData {
+ // TODO: Update this endpoint to give uniform responses on errors and to be resilient to timing attacks.
+
+ form, err := c.GetFormValues()
+ if err != nil {
+ return ErrorResponse(http.StatusBadRequest, NewSafeError(err, "request must contain form data"))
+ }
+
+ username := form.Get("username")
+ password := form.Get("password")
+ if username == "" || password == "" {
+ return ErrorResponse(http.StatusBadRequest, NewSafeError(err, "you must provide both a username and password"))
+ }
+
+ redirect := form.Get("redirect")
+ if redirect == "" {
+ redirect = "/"
+ }
+
+ userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", username)
+ if err != nil {
+ if errors.Is(err, db.ErrNoMatchingRows) {
+ return ResponseData{
+ StatusCode: http.StatusUnauthorized,
+ }
+ } else {
+ return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
+ }
+ }
+ user := userRow.(*models.User)
+
+ hashed, err := auth.ParsePasswordString(user.Password)
+ if err != nil {
+ return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse password string"))
+ }
+
+ passwordsMatch, err := auth.CheckPassword(password, hashed)
+ if err != nil {
+ return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check password against hash"))
+ }
+
+ if passwordsMatch {
+ // re-hash and save the user's password if necessary
+ if hashed.IsOutdated() {
+ newHashed, err := auth.HashPassword(password)
+ if err == nil {
+ err := auth.UpdatePassword(c.Context(), c.Conn, username, newHashed)
+ if err != nil {
+ c.Logger.Error().Err(err).Msg("failed to update user's password")
+ }
+ } else {
+ c.Logger.Error().Err(err).Msg("failed to re-hash password")
+ }
+ // If errors happen here, we can still continue with logging them in
+ }
+
+ session, err := auth.CreateSession(c.Context(), c.Conn, username)
+ if err != nil {
+ return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create session"))
+ }
+
+ res := c.Redirect(redirect, http.StatusSeeOther)
+ res.SetCookie(auth.NewSessionCookie(session))
+
+ return res
+ } else {
+ return c.Redirect("/", http.StatusSeeOther) // TODO: Redirect to standalone login page with error
+ }
+}
+
+func Logout(c *RequestContext) ResponseData {
+ sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
+ if err == nil {
+ // clear the session from the db immediately, no expiration
+ err := auth.DeleteSession(c.Context(), c.Conn, sessionCookie.Value)
+ if err != nil {
+ logging.Error().Err(err).Msg("failed to delete session on logout")
+ }
+ }
+
+ res := c.Redirect("/", http.StatusSeeOther) // TODO: Redirect to the page the user was currently on, or if not authorized to view that page, immediately to the home page.
+ res.SetCookie(auth.DeleteSessionCookie)
+
+ return res
+}
diff --git a/src/website/routes.go b/src/website/routes.go
index e3c05e00..19872b7a 100644
--- a/src/website/routes.go
+++ b/src/website/routes.go
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"errors"
- "fmt"
"net/http"
"strings"
@@ -49,7 +48,9 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
panic("route not implemented")
}
})
- mainRoutes.GET("/project/:id", Project)
+ mainRoutes.GET("/feed", Feed)
+ mainRoutes.GET("/feed/:page", Feed)
+
mainRoutes.GET("/assets/project.css", ProjectCSS)
router.NotFound = mainRoutes.ChainHandlers(FourOhFour)
@@ -119,21 +120,6 @@ func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*
return defaultProject, nil
}
-func Project(c *RequestContext) ResponseData {
- id := c.PathParams.ByName("id")
- row := c.Conn.QueryRow(context.Background(), "SELECT name FROM handmade_project WHERE id = $1", c.PathParams.ByName("id"))
- var name string
- err := row.Scan(&name)
- if err != nil {
- panic(err)
- }
-
- var res ResponseData
- res.Write([]byte(fmt.Sprintf("(%s) %s\n", id, name)))
-
- return res
-}
-
func ProjectCSS(c *RequestContext) ResponseData {
color := c.URL().Query().Get("color")
if color == "" {
@@ -158,92 +144,6 @@ func ProjectCSS(c *RequestContext) ResponseData {
return res
}
-func Login(c *RequestContext) ResponseData {
- // TODO: Update this endpoint to give uniform responses on errors and to be resilient to timing attacks.
-
- form, err := c.GetFormValues()
- if err != nil {
- return ErrorResponse(http.StatusBadRequest, NewSafeError(err, "request must contain form data"))
- }
-
- username := form.Get("username")
- password := form.Get("password")
- if username == "" || password == "" {
- return ErrorResponse(http.StatusBadRequest, NewSafeError(err, "you must provide both a username and password"))
- }
-
- redirect := form.Get("redirect")
- if redirect == "" {
- redirect = "/"
- }
-
- userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", username)
- if err != nil {
- if errors.Is(err, db.ErrNoMatchingRows) {
- return ResponseData{
- StatusCode: http.StatusUnauthorized,
- }
- } else {
- return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
- }
- }
- user := userRow.(*models.User)
-
- hashed, err := auth.ParsePasswordString(user.Password)
- if err != nil {
- return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse password string"))
- }
-
- passwordsMatch, err := auth.CheckPassword(password, hashed)
- if err != nil {
- return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check password against hash"))
- }
-
- if passwordsMatch {
- // re-hash and save the user's password if necessary
- if hashed.IsOutdated() {
- newHashed, err := auth.HashPassword(password)
- if err == nil {
- err := auth.UpdatePassword(c.Context(), c.Conn, username, newHashed)
- if err != nil {
- c.Logger.Error().Err(err).Msg("failed to update user's password")
- }
- } else {
- c.Logger.Error().Err(err).Msg("failed to re-hash password")
- }
- // If errors happen here, we can still continue with logging them in
- }
-
- session, err := auth.CreateSession(c.Context(), c.Conn, username)
- if err != nil {
- return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create session"))
- }
-
- res := c.Redirect(redirect, http.StatusSeeOther)
- res.SetCookie(auth.NewSessionCookie(session))
-
- return res
- } else {
- return c.Redirect("/", http.StatusSeeOther) // TODO: Redirect to standalone login page with error
- }
-}
-
-func Logout(c *RequestContext) ResponseData {
- sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
- if err == nil {
- // clear the session from the db immediately, no expiration
- err := auth.DeleteSession(c.Context(), c.Conn, sessionCookie.Value)
- if err != nil {
- logging.Error().Err(err).Msg("failed to delete session on logout")
- }
- }
-
- res := c.Redirect("/", http.StatusSeeOther) // TODO: Redirect to the page the user was currently on, or if not authorized to view that page, immediately to the home page.
- res.SetCookie(auth.DeleteSessionCookie)
-
- return res
-}
-
func FourOhFour(c *RequestContext) ResponseData {
return ResponseData{
StatusCode: http.StatusNotFound,