Start porting landing page; rework db layer a bit

This commit is contained in:
Ben Visness 2021-03-30 22:55:19 -05:00
parent f7ac023c44
commit 8929a5d749
14 changed files with 581 additions and 76 deletions

1
go.mod
View File

@ -10,6 +10,7 @@ require (
github.com/google/uuid v1.2.0 // indirect github.com/google/uuid v1.2.0 // indirect
github.com/huandu/xstrings v1.3.2 // indirect github.com/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.12 // 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/jackc/pgx/v4 v4.10.1
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
github.com/mitchellh/copystructure v1.1.1 // indirect github.com/mitchellh/copystructure v1.1.1 // indirect

View File

@ -34,8 +34,7 @@ func makeSessionId() string {
var ErrNoSession = errors.New("no session found") var ErrNoSession = errors.New("no session found")
func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) { func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) {
var sess models.Session row, err := db.QueryOne(ctx, conn, models.Session{}, "SELECT $columns FROM sessions WHERE id = $1", id)
err := db.QueryOneToStruct(ctx, conn, &sess, "SELECT $columns FROM sessions WHERE id = $1", id)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) { if errors.Is(err, db.ErrNoMatchingRows) {
return nil, ErrNoSession 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") 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) { func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*models.Session, error) {

View File

@ -5,15 +5,19 @@ import (
"errors" "errors"
"reflect" "reflect"
"strings" "strings"
"time"
"git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgtype"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/log/zerologadapter" "github.com/jackc/pgx/v4/log/zerologadapter"
"github.com/jackc/pgx/v4/pgxpool" "github.com/jackc/pgx/v4/pgxpool"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
var connInfo = pgtype.NewConnInfo()
func NewConn() *pgx.Conn { func NewConn() *pgx.Conn {
conn, err := pgx.Connect(context.Background(), config.Config.Postgres.DSN()) conn, err := pgx.Connect(context.Background(), config.Config.Postgres.DSN())
if err != nil { if err != nil {
@ -40,20 +44,18 @@ func NewConnPool(minConns, maxConns int32) *pgxpool.Pool {
} }
type StructQueryIterator struct { type StructQueryIterator struct {
fieldIndices []int fieldPaths [][]int
rows pgx.Rows rows pgx.Rows
destType reflect.Type
} }
func (it *StructQueryIterator) Next(dest interface{}) bool { func (it *StructQueryIterator) Next() (interface{}, bool) {
hasNext := it.rows.Next() hasNext := it.rows.Next()
if !hasNext { if !hasNext {
return false return nil, false
} }
v := reflect.ValueOf(dest) result := reflect.New(it.destType)
if v.Kind() != reflect.Ptr {
panic(oops.New(nil, "Next requires a pointer type; got %v", v.Kind()))
}
vals, err := it.rows.Values() vals, err := it.rows.Values()
if err != nil { if err != nil {
@ -61,47 +63,70 @@ func (it *StructQueryIterator) Next(dest interface{}) bool {
} }
for i, val := range vals { 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() { switch field.Kind() {
case reflect.Int: case reflect.Int:
field.SetInt(reflect.ValueOf(val).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: default:
field.Set(reflect.ValueOf(val)) field.Set(reflect.ValueOf(val))
} }
} }
return true return result.Interface(), true
} }
func (it *StructQueryIterator) Close() { func (it *StructQueryIterator) Close() {
it.rows.Close() it.rows.Close()
} }
func QueryToStructs(ctx context.Context, conn *pgxpool.Pool, destType interface{}, query string, args ...interface{}) (StructQueryIterator, error) { func (it *StructQueryIterator) ToSlice() []interface{} {
var fieldIndices []int defer it.Close()
var columnNames []string var result []interface{}
for {
t := reflect.TypeOf(destType) row, ok := it.Next()
if t.Kind() == reflect.Ptr { if !ok {
t = t.Elem() break
}
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)
} }
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, ", ") 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...) rows, err := conn.Query(ctx, query, args...)
if err != nil { if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
panic("query exceeded its deadline")
}
return StructQueryIterator{}, err return StructQueryIterator{}, err
} }
return StructQueryIterator{ return StructQueryIterator{
fieldIndices: fieldIndices, fieldPaths: fieldPaths,
rows: rows, rows: rows,
destType: destType,
}, nil }, 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") var ErrNoMatchingRows = errors.New("no matching rows")
func QueryOneToStruct(ctx context.Context, conn *pgxpool.Pool, dest interface{}, query string, args ...interface{}) error { func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (interface{}, error) {
rows, err := QueryToStructs(ctx, conn, dest, query, args...) rows, err := Query(ctx, conn, destExample, query, args...)
if err != nil { if err != nil {
return err return nil, err
} }
defer rows.Close() defer rows.Close()
hasRow := rows.Next(dest) result, hasRow := rows.Next()
if !hasRow { if !hasRow {
return ErrNoMatchingRows return nil, ErrNoMatchingRows
} }
return nil return result, nil
} }

27
src/models/category.go Normal file
View File

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

31
src/models/post.go Normal file
View File

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

View File

@ -1,14 +1,18 @@
package models package models
import "reflect"
const HMNProjectID = 1 const HMNProjectID = 1
var ProjectType = reflect.TypeOf(Project{})
type Project struct { type Project struct {
ID int `db:"id"` ID int `db:"id"`
Slug string `db:"slug"` Slug *string `db:"slug"` // TODO: Migrate these to NOT NULL
Name string `db:"name"` Name *string `db:"name"`
Blurb string `db:"blurb"` Blurb *string `db:"blurb"`
Description string `db:"description"` Description *string `db:"description"`
Color1 string `db:"color_1"` Color1 string `db:"color_1"`
Color2 string `db:"color_2"` Color2 string `db:"color_2"`

17
src/models/thread.go Normal file
View File

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

View File

@ -1,6 +1,11 @@
package models package models
import "time" import (
"reflect"
"time"
)
var UserType = reflect.TypeOf(User{})
type User struct { type User struct {
ID int `db:"id"` ID int `db:"id"`

View File

@ -3,3 +3,200 @@
{{ define "content" }} {{ define "content" }}
This is the index page. This is the index page.
{{ end }} {{ end }}
{{/*
{{ define "extrahead" }}
<link rel="stylesheet" type="text/css" href="{{ static "landing.css" }}"/>
<script type="text/javascript" src="{{ static "util.js" }}"></script>
<style type="text/css">
{{ range _, $col := .RecentPostColumns }}
{{ range _, $entry := $col }}
{{ $themeDim := eq .Theme "dark" | ternary (darken .Color 0.5) (brighten .Color 0.2) }}
{{ $themeDimmer := eq .Theme "dark" | ternary (darken .Color 0.65) (brighten .Color 0.4) }}
{{ $themeDimmest := eq .Theme "dark" | ternary (darken .Color 0.8) (brighten .Color 0.6) }}
{{ $linkColor := eq .Theme "dark" | ternary (brighten .Color 0.1) (darken .Color 0.2) }}
{{ $linkHoverColor := eq .Theme "dark" | ternary (brighten .Color 0.2) (darken .Color 0.1) }}
{{ eq .Theme "dark" }}
#p{{ .Project.Subdomain }} .unread a { color: #{% rgb_accent entry.project.color_1 0.55 %}; }
#p{{ .Project.Subdomain }} .unread a:hover { color: #{% rgb_accent entry.project.color_1 0.65 %}; }
#p{{ .Project.Subdomain }} .unread .avatar-icon { border: 2px solid #{% rgb_accent entry.project.color_1 0.55 %}; }
#p{{ .Project.Subdomain }} .thread:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.14 False 0.03%}; }
#p{{ .Project.Subdomain }} .forum .post:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.14 False 0.03 %}; }
#p{{ .Project.Subdomain }} .blog .post:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.14 False 0.03 %}; }
{{ else }}
#p{{ .Project.Subdomain }} .unread a { color: #{% rgb_accent entry.project.color_1 0.35 %}; }
#p{{ .Project.Subdomain }} .unread a:hover { color: #{% rgb_accent entry.project.color_1 0.45 %}; }
#p{{ .Project.Subdomain }} .unread .avatar-icon { border: 2px solid #{% rgb_accent entry.project.color_1 0.35 %}; }
#p{{ .Project.Subdomain }} .thread:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.94 False 0.2 %}; }
#p{{ .Project.Subdomain }} .forum .post:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.94 False 0.2 %}; }
#p{{ .Project.Subdomain }} .blog .post:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.94 False 0.2 %}; }
{{ end }}
#p{{ .Project.Subdomain }} .thread.more { background-color:transparent; }
{{ end }}
{{ end }}
</style>
<script type="text/javascript" src="{% static 'templates.js' %}?v={% cachebust %}"></script>
<script type="text/javascript" src="{% static 'timeline.js' %}?v={% cachebust %}"></script>
<script type="text/javascript" src="{% static 'showcase.js' %}?v={% cachebust %}"></script>
{{ end }}
{% block columns %}
{% include "showcase/js_templates.html" %}
{% include "timeline/js_templates.html" %}
<div class="content-block pb3">
<div class="tc tl-l w-100 pb2">
<h2 class="di-l mr2-l">Community Showcase</h2>
<ul class="list dib-l">
<li class="dib-ns ma0 ph2">
<a href="{% url 'showcase' %}">View all</a>
</li>
</ul>
</div>
<div class="showcase relative overflow-hidden">
<div id="showcase-items" class="flex relative pl3 pl0-ns"></div>
<div class="arrow-container left">
<a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('left')">{% svg 'chevron-left' %}</a>
</div>
<div class="arrow-container right">
<a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('right')">{% svg 'chevron-right' %}</a>
</div>
</div>
<div class="c--dimmer i pv2 ph3 ph0-ns">
This is a selection of recent work done by community members. Want to participate? <a href="https://discord.gg/hxWxDee" target="_blank">Join us on Discord.</a>
</div>
</div>
<script>
const timelineData = JSON.parse("{{ showcase_timeline_json|escapejs }}");
const showcaseEl = document.querySelector('#showcase-items');
for (const item of timelineData.items) {
const [itemEl, addThumbnail] = makeShowcaseItem(item);
addThumbnail();
itemEl.container.classList.add('mr3');
showcaseEl.appendChild(itemEl.root);
}
function rem2px(rem) {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
}
function scrollShowcase(direction = null) {
const ITEM_WIDTH = showcaseEl.querySelector('.showcase-item').getBoundingClientRect().width;
const ITEM_SPACING = rem2px(1);
const showcaseWidth = showcaseEl.getBoundingClientRect().width;
const numVisible = showcaseWidth / (ITEM_WIDTH + ITEM_SPACING);
const scrollMagnitude = Math.floor(numVisible) - 1;
const scrollDirection = (direction === 'right' ? 1 : (direction === 'left' ? -1 : 0));
const scrollAmount = scrollMagnitude * scrollDirection;
const minIndex = 0;
const maxIndex = timelineData.items.length - Math.floor(numVisible);
const currentScrollIndex = parseInt(showcaseEl.getAttribute('data-scroll-index'), 10) || 0;
const newScrollIndex = Math.max(minIndex, Math.min(maxIndex, currentScrollIndex + scrollAmount));
showcaseEl.style.transform = `translateX(${-newScrollIndex * (ITEM_WIDTH + ITEM_SPACING)}px)`;
showcaseEl.setAttribute('data-scroll-index', newScrollIndex);
const leftArrowEl = document.querySelector('.arrow-container.left');
const rightArrowEl = document.querySelector('.arrow-container.right');
leftArrowEl.classList.toggle('hide', newScrollIndex === minIndex);
rightArrowEl.classList.toggle('hide', newScrollIndex === maxIndex);
}
scrollShowcase(); // force a scroll as an easy way to initialize styles
window.addEventListener('resize', () => scrollShowcase());
</script>
<div class="content-block">
<div class="optionbar pb2">
<div class="tc tl-l w-100">
<h2 class="di-l mr2-l">Around the Network</h2>
<ul class="list dib-l">
<li class="dib-ns ma0 ph2">
<a href="{% url 'feed' %}">View all posts on HMN</a>
</li>
<li class="dib-ns ma0 ph2">
<a href="{% url 'podcast' %}">Podcast</a>
</li>
<!-- <li class="dib-ns ma0 ph2">
<a href="{% url 'streams' %}">See who's live</a>
</li> -->
<li class="dib-ns ma0 ph2">
<a href="/blogs/p/1138-%5Btutorial%5D_handmade_network_irc" target="_blank">Chat in IRC</a>
</li>
<li class="dib-ns ma0 ph2">
<a href="https://discord.gg/hxWxDee" target="_blank">Chat on Discord</a>
</li>
<li class="dib-ns ma0 ph2">
<a href="https://handmadedev.show/" target="_blank">See the Show</a>
</li>
</ul>
</div>
</div>
</div>
{% spaceless %}
<div class="content-block news cf">
{% for col in recent_post_columns %}
<div class="fl w-100 w-50-l">
<div class="mw7 mw-none-l center-layout">
{% if forloop.counter == 1 %}
<div class="pt3">
{% include "blog_index_thread_list_entry.html" with post=featured_post align_top=True %}
</div>
{% endif %}
{% for entry in col %}
{% with proj=entry.project posts=entry.posts %}
<div class="pt3" id="p{{proj.slug}}">
<a
href="{% url 'cover_page' subdomain=proj.slug %}"
{% if user|get_theme == 'dark' %}
style="color:#{% rgb_accent proj.color_1 0.55 %};"
{% else %}
style="color:#{% rgb_accent proj.color_1 0.25 %};"
{% endif %}
>
<h2 class="ph3">{{ proj.name }}</h2>
</a>
{% 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 %}
<div class="ph3 thread unread more">
<a class="title"
href="{% url 'project_forum' subdomain=proj.slug %}"
>{{ more }} more recently &rarr;</a>
</div>
{% endif %}
{% endwith %}
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endspaceless %}
{% endblock %}
*/}}

View File

@ -63,7 +63,7 @@
<body class="{{ join " " .BodyClasses }}"> <body class="{{ join " " .BodyClasses }}">
<div class="content mw-site ph3-m ph4-l"> <div class="content mw-site ph3-m ph4-l">
{{ template "header.html" . }} {{ template "header.html" . }}
based {{ block "content" . }}{{ end }}
{{ template "footer.html" . }} {{ template "footer.html" . }}
</div> </div>
</body> </body>

View File

@ -42,3 +42,6 @@ type BackgroundImage struct {
Url string Url string
Size string // A valid CSS background-size value Size string // A valid CSS background-size value
} }
type Post struct {
}

148
src/website/landing.go Normal file
View File

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

View File

@ -33,7 +33,13 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
} }
mainRoutes := routes.WithWrappers(routes.CommonWebsiteDataWrapper) 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("/project/:id", routes.Project)
mainRoutes.GET("/assets/project.css", routes.ProjectCSS) mainRoutes.GET("/assets/project.css", routes.ProjectCSS)
@ -58,8 +64,8 @@ func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData {
return templates.BaseData{ return templates.BaseData{
Project: templates.Project{ Project: templates.Project{
Name: c.currentProject.Name, Name: *c.currentProject.Name,
Subdomain: c.currentProject.Slug, Subdomain: *c.currentProject.Slug,
Color: c.currentProject.Color1, Color: c.currentProject.Color1,
IsHMN: c.currentProject.IsHMN(), 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) { func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) {
var subdomainProject models.Project subdomainProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
err := db.QueryOneToStruct(ctx, conn, &subdomainProject, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
if err == nil { if err == nil {
subdomainProject := subdomainProjectRow.(models.Project)
return &subdomainProject, nil return &subdomainProject, nil
} else if !errors.Is(err, db.ErrNoMatchingRows) { } else if !errors.Is(err, db.ErrNoMatchingRows) {
return nil, oops.New(err, "failed to get projects by slug") return nil, oops.New(err, "failed to get projects by slug")
} }
var defaultProject models.Project defaultProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID)
err = db.QueryOneToStruct(ctx, conn, &defaultProject, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) { if errors.Is(err, db.ErrNoMatchingRows) {
return nil, oops.New(nil, "default project didn't exist in the database") 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") return nil, oops.New(err, "failed to get default project")
} }
} }
defaultProject := defaultProjectRow.(*models.Project)
return &defaultProject, nil 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)
}
} }
func (s *websiteRoutes) Project(c *RequestContext, p httprouter.Params) { func (s *websiteRoutes) Project(c *RequestContext, p httprouter.Params) {
@ -159,8 +158,7 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) {
redirect = "/" redirect = "/"
} }
var user models.User userRow, err := db.QueryOne(c.Context(), s.conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", username)
err = db.QueryOneToStruct(c.Context(), s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", username)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) { if errors.Is(err, db.ErrNoMatchingRows) {
c.StatusCode = http.StatusUnauthorized c.StatusCode = http.StatusUnauthorized
@ -169,6 +167,7 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) {
} }
return return
} }
user := userRow.(models.User)
hashed, err := auth.ParsePasswordString(user.Password) hashed, err := auth.ParsePasswordString(user.Password)
if err != nil { if err != nil {
@ -285,8 +284,7 @@ func (s *websiteRoutes) getCurrentUserAndMember(ctx context.Context, sessionId s
} }
} }
var user models.User userRow, err := db.QueryOne(ctx, s.conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", session.Username)
err = db.QueryOneToStruct(ctx, s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", session.Username)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) { 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") 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") return nil, oops.New(err, "failed to get user for session")
} }
} }
user := userRow.(*models.User)
// TODO: Also get the member model // TODO: Also get the member model
return &user, nil return user, nil
} }

View File

@ -25,7 +25,7 @@ var WebsiteCommand = &cobra.Command{
logging.Info().Msg("Hello, HMN!") logging.Info().Msg("Hello, HMN!")
conn := db.NewConnPool(4, 8) conn := db.NewConnPool(4, 128)
server := http.Server{ server := http.Server{
Addr: config.Config.Addr, Addr: config.Config.Addr,
@ -42,10 +42,12 @@ var WebsiteCommand = &cobra.Command{
go func() { go func() {
<-signals <-signals
logging.Info().Msg("Shutting down the website") logging.Info().Msg("Shutting down the website")
timeout, cancel := context.WithTimeout(context.Background(), 30*time.Second) go func() {
defer cancel() timeout, cancel := context.WithTimeout(context.Background(), 30*time.Second)
server.Shutdown(timeout) defer cancel()
cancelBackgroundJobs() server.Shutdown(timeout)
cancelBackgroundJobs()
}()
<-signals <-signals
logging.Warn().Msg("Forcibly killed the website") logging.Warn().Msg("Forcibly killed the website")