Get the feed largely complete

This commit is contained in:
Ben Visness 2021-04-25 14:33:22 -05:00
parent ed6ce26697
commit e7ff342842
13 changed files with 457 additions and 120 deletions

View File

@ -8570,13 +8570,13 @@ input[type=submit] {
.forum-narrow .forum-narrow-hide { .forum-narrow .forum-narrow-hide {
display: none; } display: none; }
.post-bg-alternate:nth-of-type(odd) {
background-color: #f0f0f0;
background-color: var(--forum-even-background); }
.thread { .thread {
color: black; color: black;
color: var(--fg-font-color); } color: var(--fg-font-color); }
.forum .thread:nth-of-type(odd),
.feed .thread:nth-of-type(odd) {
background-color: #f0f0f0;
background-color: var(--forum-even-background); }
.profile .thread { .profile .thread {
padding-left: 15px; } padding-left: 15px; }
.thread .title { .thread .title {

View File

@ -212,3 +212,44 @@ func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{},
return result, nil return result, nil
} }
func QueryScalar(ctx context.Context, conn *pgxpool.Pool, query string, args ...interface{}) (interface{}, error) {
rows, err := conn.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
vals, err := rows.Values()
if err != nil {
panic(err)
}
if len(vals) != 1 {
return nil, oops.New(nil, "you must query exactly one field with QueryScalar, not %v", len(vals))
}
return vals[0], nil
}
return nil, ErrNoMatchingRows
}
func QueryInt(ctx context.Context, conn *pgxpool.Pool, query string, args ...interface{}) (int, error) {
result, err := QueryScalar(ctx, conn, query, args...)
if err != nil {
return 0, err
}
switch r := result.(type) {
case int:
return r, nil
case int32:
return int(r), nil
case int64:
return int(r), nil
default:
return 0, oops.New(nil, "QueryInt got a non-int result: %v", result)
}
}

View File

@ -1,5 +1,12 @@
package models package models
import (
"context"
"git.handmade.network/hmn/hmn/src/db"
"github.com/jackc/pgx/v4/pgxpool"
)
type CategoryType int type CategoryType int
const ( const (
@ -23,5 +30,50 @@ type Category struct {
Kind CategoryType `db:"kind"` Kind CategoryType `db:"kind"`
Color1 string `db:"color_1"` Color1 string `db:"color_1"`
Color2 string `db:"color_2"` Color2 string `db:"color_2"`
Depth int `db:"depth"` Depth int `db:"depth"` // TODO: What is this?
} }
func (c *Category) GetParents(ctx context.Context, conn *pgxpool.Pool) []Category {
type breadcrumbRow struct {
Cat Category `db:"cats"`
}
rows, err := db.Query(ctx, conn, breadcrumbRow{},
`
WITH RECURSIVE cats AS (
SELECT *
FROM handmade_category AS cat
WHERE cat.id = $1
UNION ALL
SELECT parentcat.*
FROM
handmade_category AS parentcat
JOIN cats ON cats.parent_id = parentcat.id
)
SELECT $columns FROM cats;
`,
c.ID,
)
if err != nil {
panic(err)
}
var result []Category
for _, irow := range rows.ToSlice()[1:] {
row := irow.(*breadcrumbRow)
result = append(result, row.Cat)
}
return result
}
// func GetCategoryUrls(cats ...*Category) map[int]string {
// }
// func makeCategoryUrl(cat *Category, subdomain string) string {
// switch cat.Kind {
// case CatTypeBlog:
// case CatTypeForum:
// }
// return hmnurl.ProjectUrl("/flooger", nil, subdomain)
// }

View File

@ -12,7 +12,7 @@ type Post struct {
AuthorID *int `db:"author_id"` AuthorID *int `db:"author_id"`
CategoryID int `db:"category_id"` CategoryID int `db:"category_id"`
ParentID *int `db:"parent_id"` ParentID *int `db:"parent_id"`
ThreadID *int `db:"thread_id"` ThreadID *int `db:"thread_id"` // TODO: This is only null for posts that are actually static pages. Which probably shouldn't be posts anyway. Plz make not null thanks
CurrentID int `db:"current_id"` CurrentID int `db:"current_id"`
Depth int `db:"depth"` Depth int `db:"depth"`

View File

@ -17,21 +17,15 @@
display: none; display: none;
} }
.post-bg-alternate:nth-of-type(odd) {
@include usevar('background-color', 'forum-even-background');
}
.thread { .thread {
@include usevar('color', 'fg-font-color'); @include usevar('color', 'fg-font-color');
@extend .ma0; @extend .ma0;
.forum &:nth-of-type(odd),
.feed &:nth-of-type(odd),
{
@include usevar('background-color', 'forum-even-background');
}
&.more {
// background-color: transparent;
}
.profile & { .profile & {
padding-left:15px; padding-left:15px;
} }

View File

@ -41,6 +41,8 @@ func ProjectToTemplate(p *models.Project) Project {
} }
func UserToTemplate(u *models.User) User { func UserToTemplate(u *models.User) User {
// TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function.
avatar := "" avatar := ""
if u.Avatar != nil { if u.Avatar != nil {
avatar = hmnurl.StaticUrl(*u.Avatar, nil) avatar = hmnurl.StaticUrl(*u.Avatar, nil)

View File

@ -0,0 +1,33 @@
{{ template "base.html" . }}
{{ define "extrahead" }}
{{/* TODO
<script type="text/javascript" src="{% static 'util.js' %}?v={% cachebust %}"></script>
*/}}
{{ end }}
{{ define "content" }}
<div class="content-block">
<div class="optionbar">
<div class="options">
<a class="button" href="{{ url "/atom" }}"><span class="icon big">4</span> RSS Feed</span></a>
{{ if .User }}
<a class="button" href="{{ url "/markread" }}"><span class="big">&#x2713;</span> Mark all posts on site as read</a>
{{ end }}
</div>
<div class="options">
{{ template "pagination.html" .Pagination }}
</div>
</div>
{{ range .Posts }}
{{ template "post_list_item.html" . }}
{{ end }}
<div class="optionbar bottom">
<div>
</div>
<div class="options">
{{ template "pagination.html" .Pagination }}
</div>
</div>
</div>
{{ end }}

View File

@ -0,0 +1,31 @@
<div class="pagination">
{{ if gt .Current 1 }}
<a class="button" href="{{ .PreviousUrl }}">Prev</a>
{{ end }}
{{ if gt .Total 1 }}
{{ if gt .Current 1 }}
{{ if gt .Current 2 }}
<a class="page button" href="{{ .FirstUrl }}">1</a>
{{ end }}
{{ if gt .Current 3 }}
<a class="page button">&nbsp;&nbsp;...&nbsp;&nbsp;</a>
{{ end }}
<a class="page button" href="{{ .PreviousUrl }}">{{ sub .Current 1 }}</a>
{{ end }}
<a class="page button current">{{ .Current }}</a>
{{ if lt .Current .Total }}
<a class="page button" href="{{ .NextUrl }}">{{ add .Current 1 }}</a>
{{ if lt .Current (sub .Total 2) }}
<a class="page button">&nbsp;&nbsp;...&nbsp;&nbsp;</a>
{{ end }}
{{ if lt .Current (sub .Total 1) }}
<a class="page button" href="{{ .LastUrl }}">{{ .Total }}</a>
{{ end }}
{{ end }}
{{ else }}
&nbsp;
{{ end }}
{{ if lt .Current .Total }}
<a class="button" href="{{ .NextUrl }}">Next</a>
{{ end }}
</div>

View File

@ -4,7 +4,7 @@ This template is intended to display a single post or thread in the context of a
It should be called with PostListItem. It should be called with PostListItem.
*/}} */}}
<div class="post-list-item flex items-center ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }}"> <div class="post-list-item flex items-center ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }} {{ .Classes }}">
<img class="avatar-icon mr2" src="{{ .User.AvatarUrl }}"> <img class="avatar-icon mr2" src="{{ .User.AvatarUrl }}">
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
<div class="breadcrumbs"> <div class="breadcrumbs">
@ -17,6 +17,11 @@ It should be called with PostListItem.
<div class="details"> <div class="details">
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> &mdash; <span class="datetime">{{ relativedate .Date }}</span> <a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> &mdash; <span class="datetime">{{ relativedate .Date }}</span>
</div> </div>
{{ with .Content }}
<div class="mt2">
{{ . }}
</div>
{{ end }}
</div> </div>
<div class="goto"> <div class="goto">
<a href="{{ .Url }}">&raquo;</a> <a href="{{ .Url }}">&raquo;</a>

View File

@ -87,8 +87,20 @@ type PostListItem struct {
User User User User
Date time.Time Date time.Time
Unread bool Unread bool
Classes string
Content string
} }
type Breadcrumb struct { type Breadcrumb struct {
Name, Url string Name, Url string
} }
type Pagination struct {
Current int
Total int
FirstUrl string
LastUrl string
PreviousUrl string
NextUrl string
}

169
src/website/feed.go Normal file
View File

@ -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
}

98
src/website/login.go Normal file
View File

@ -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
}

View File

@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
@ -49,7 +48,9 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
panic("route not implemented") panic("route not implemented")
} }
}) })
mainRoutes.GET("/project/:id", Project) mainRoutes.GET("/feed", Feed)
mainRoutes.GET("/feed/:page", Feed)
mainRoutes.GET("/assets/project.css", ProjectCSS) mainRoutes.GET("/assets/project.css", ProjectCSS)
router.NotFound = mainRoutes.ChainHandlers(FourOhFour) router.NotFound = mainRoutes.ChainHandlers(FourOhFour)
@ -119,21 +120,6 @@ func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*
return defaultProject, nil 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 { func ProjectCSS(c *RequestContext) ResponseData {
color := c.URL().Query().Get("color") color := c.URL().Query().Get("color")
if color == "" { if color == "" {
@ -158,92 +144,6 @@ func ProjectCSS(c *RequestContext) ResponseData {
return res 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 { func FourOhFour(c *RequestContext) ResponseData {
return ResponseData{ return ResponseData{
StatusCode: http.StatusNotFound, StatusCode: http.StatusNotFound,