Merge branch 'personal-projects'

This commit is contained in:
Ben Visness 2021-11-11 12:09:08 -08:00
commit 908fa4368f
42 changed files with 1704 additions and 823 deletions

View File

@ -19,4 +19,5 @@ pushd $BETA_PATH
docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\""
popd
#go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06
go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23

View File

@ -7,6 +7,7 @@ const TimelineMediaTypes = {
const showcaseItemTemplate = makeTemplateCloner("showcase_item");
const modalTemplate = makeTemplateCloner("timeline_modal");
const tagTemplate = makeTemplateCloner("timeline_item_tag");
function showcaseTimestamp(rawDate) {
const date = new Date(rawDate*1000);
@ -95,6 +96,17 @@ function makeShowcaseItem(timelineItem) {
modalEl.date.textContent = timestamp;
modalEl.date.setAttribute("href", timelineItem.snippet_url);
if (timelineItem.tags.length === 0) {
modalEl.tags.remove();
} else {
for (const tag of timelineItem.tags) {
const tagItem = tagTemplate();
tagItem.tag.innerText = tag.text;
modalEl.tags.appendChild(tagItem.root);
}
}
modalEl.discord_link.href = timelineItem.discord_message_url;
function close() {

View File

@ -60,6 +60,12 @@ type ConnOrTx interface {
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error)
// Both raw database connections and transactions in pgx can begin/commit
// transactions. For database connections it does the obvious thing; for
// transactions it creates a "pseudo-nested transaction" but conceptually
// works the same. See the documentation of pgx.Tx.Begin.
Begin(ctx context.Context) (pgx.Tx, error)
}
var connInfo = pgtype.NewConnInfo()

View File

@ -64,7 +64,12 @@ func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message, is
}
if isJam {
_, err := tx.Exec(ctx, `UPDATE handmade_snippet SET is_jam = TRUE WHERE id = $1`, snippet.ID)
tagId, err := db.QueryInt(ctx, tx, `SELECT id FROM tags WHERE text = 'wheeljam'`)
if err != nil {
return oops.New(err, "failed to fetch id of jam tag")
}
_, err = tx.Exec(ctx, `INSERT INTO snippet_tags (snippet_id, tag_id) VALUES ($1, $2)`, snippet.ID, tagId)
if err != nil {
return oops.New(err, "failed to mark snippet as a jam snippet")
}

View File

@ -6,8 +6,9 @@ import (
"regexp"
"time"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/oops"
)
@ -62,34 +63,31 @@ func GetBaseHost() string {
return baseUrlParsed.Host
}
type UrlContext struct {
PersonalProject bool
ProjectID int
ProjectSlug string
ProjectName string
}
var HMNProjectContext = UrlContext{
PersonalProject: false,
ProjectID: models.HMNProjectID,
ProjectSlug: models.HMNProjectSlug,
}
func Url(path string, query []Q) string {
return ProjectUrl(path, query, "")
return UrlWithFragment(path, query, "")
}
func ProjectUrl(path string, query []Q, slug string) string {
return ProjectUrlWithFragment(path, query, slug, "")
func UrlWithFragment(path string, query []Q, fragment string) string {
return HMNProjectContext.UrlWithFragment(path, query, fragment)
}
func ProjectUrlWithFragment(path string, query []Q, slug string, fragment string) string {
subdomain := slug
if slug == models.HMNProjectSlug {
subdomain = ""
}
host := baseUrlParsed.Host
if len(subdomain) > 0 {
host = slug + "." + host
}
url := url.URL{
Scheme: baseUrlParsed.Scheme,
Host: host,
Path: trim(path),
RawQuery: encodeQuery(query),
Fragment: fragment,
}
return url.String()
func (c *UrlContext) RewriteProjectUrl(u *url.URL) string {
// we need to strip anything matching the personal project regex to get the base path
match := RegexPersonalProject.FindString(u.Path)
return c.Url(u.Path[len(match):], QFromURL(u))
}
func trim(path string) string {

View File

@ -21,12 +21,11 @@ var RegexOldHome = regexp.MustCompile("^/home$")
var RegexHomepage = regexp.MustCompile("^/$")
func BuildHomepage() string {
return Url("/", nil)
return HMNProjectContext.BuildHomepage()
}
func BuildProjectHomepage(projectSlug string) string {
defer CatchPanic()
return ProjectUrl("/", nil, projectSlug)
func (c *UrlContext) BuildHomepage() string {
return c.Url("/", nil)
}
var RegexShowcase = regexp.MustCompile("^/showcase$")
@ -97,8 +96,6 @@ func BuildRegistrationSuccess() string {
return Url("/registered_successfully", nil)
}
// TODO(asaf): Delete the old version a bit after launch
var RegexOldEmailConfirmation = regexp.MustCompile(`^/_register/confirm/(?P<username>[\w\ \.\,\-@\+\_]+)/(?P<hash>[\d\w]+)/(?P<nonce>.+)[\/]?$`)
var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P<username>[^/]+)/(?P<token>[^/]+)$")
func BuildEmailConfirmation(username, token string) string {
@ -198,7 +195,7 @@ func BuildUserProfile(username string) string {
var RegexUserSettings = regexp.MustCompile(`^/settings$`)
func BuildUserSettings(section string) string {
return ProjectUrlWithFragment("/settings", nil, "", section)
return UrlWithFragment("/settings", nil, section)
}
/*
@ -295,20 +292,19 @@ func BuildProjectNew() string {
return Url("/projects/new", nil)
}
var RegexProjectNotApproved = regexp.MustCompile("^/p/(?P<slug>.+)$")
var RegexPersonalProject = regexp.MustCompile("^/p/(?P<projectid>[0-9]+)(/(?P<projectslug>[a-zA-Z0-9-]+))?")
func BuildProjectNotApproved(slug string) string {
func BuildPersonalProject(id int, slug string) string {
defer CatchPanic()
return Url(fmt.Sprintf("/p/%s", slug), nil)
return Url(fmt.Sprintf("/p/%d/%s", id, slug), nil)
}
var RegexProjectEdit = regexp.MustCompile("^/p/(?P<slug>.+)/edit$")
var RegexProjectEdit = regexp.MustCompile("^/edit$")
func BuildProjectEdit(slug string, section string) string {
func (c *UrlContext) BuildProjectEdit(section string) string {
defer CatchPanic()
return ProjectUrlWithFragment(fmt.Sprintf("/p/%s/edit", slug), nil, "", section)
return c.UrlWithFragment("/edit", nil, section)
}
/*
@ -370,7 +366,50 @@ func BuildPodcastEpisodeFile(filename string) string {
// Make sure to match Thread before Subforum in the router.
var RegexForum = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?(/(?P<page>\d+))?$`)
func BuildForum(projectSlug string, subforums []string, page int) string {
func (c *UrlContext) Url(path string, query []Q) string {
return c.UrlWithFragment(path, query, "")
}
func (c *UrlContext) UrlWithFragment(path string, query []Q, fragment string) string {
if c == nil {
logging.Warn().Stack().Msg("URL context was nil; defaulting to the HMN URL context")
c = &HMNProjectContext
}
if c.PersonalProject {
url := url.URL{
Scheme: baseUrlParsed.Scheme,
Host: baseUrlParsed.Host,
Path: fmt.Sprintf("p/%d/%s/%s", c.ProjectID, models.GeneratePersonalProjectSlug(c.ProjectName), trim(path)),
RawQuery: encodeQuery(query),
Fragment: fragment,
}
return url.String()
} else {
subdomain := c.ProjectSlug
if c.ProjectSlug == models.HMNProjectSlug {
subdomain = ""
}
host := baseUrlParsed.Host
if len(subdomain) > 0 {
host = c.ProjectSlug + "." + host
}
url := url.URL{
Scheme: baseUrlParsed.Scheme,
Host: host,
Path: trim(path),
RawQuery: encodeQuery(query),
Fragment: fragment,
}
return url.String()
}
}
func (c *UrlContext) BuildForum(subforums []string, page int) string {
defer CatchPanic()
if page < 1 {
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
@ -383,13 +422,13 @@ func BuildForum(projectSlug string, subforums []string, page int) string {
builder.WriteString(strconv.Itoa(page))
}
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new$`)
var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new/submit$`)
func BuildForumNewThread(projectSlug string, subforums []string, submit bool) string {
func (c *UrlContext) BuildForumNewThread(subforums []string, submit bool) string {
defer CatchPanic()
builder := buildSubforumPath(subforums)
builder.WriteString("/t/new")
@ -397,59 +436,59 @@ func BuildForumNewThread(projectSlug string, subforums []string, submit bool) st
builder.WriteString("/submit")
}
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexForumThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`)
func BuildForumThread(projectSlug string, subforums []string, threadId int, title string, page int) string {
func (c *UrlContext) BuildForumThread(subforums []string, threadId int, title string, page int) string {
defer CatchPanic()
builder := buildForumThreadPath(subforums, threadId, title, page)
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
func BuildForumThreadWithPostHash(projectSlug string, subforums []string, threadId int, title string, page int, postId int) string {
func (c *UrlContext) BuildForumThreadWithPostHash(subforums []string, threadId int, title string, page int, postId int) string {
defer CatchPanic()
builder := buildForumThreadPath(subforums, threadId, title, page)
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
return c.UrlWithFragment(builder.String(), nil, strconv.Itoa(postId))
}
var RegexForumPost = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`)
func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string {
func (c *UrlContext) BuildForumPost(subforums []string, threadId int, postId int) string {
defer CatchPanic()
builder := buildForumPostPath(subforums, threadId, postId)
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexForumPostDelete = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/delete$`)
func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string {
func (c *UrlContext) BuildForumPostDelete(subforums []string, threadId int, postId int) string {
defer CatchPanic()
builder := buildForumPostPath(subforums, threadId, postId)
builder.WriteString("/delete")
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexForumPostEdit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/edit$`)
func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string {
func (c *UrlContext) BuildForumPostEdit(subforums []string, threadId int, postId int) string {
defer CatchPanic()
builder := buildForumPostPath(subforums, threadId, postId)
builder.WriteString("/edit")
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexForumPostReply = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/reply$`)
func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string {
func (c *UrlContext) BuildForumPostReply(subforums []string, threadId int, postId int) string {
defer CatchPanic()
builder := buildForumPostPath(subforums, threadId, postId)
builder.WriteString("/reply")
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexWikiArticle = regexp.MustCompile(`^/wiki/(?P<threadid>\d+)(-([^/]+))?$`)
@ -462,7 +501,7 @@ var RegexBlogsRedirect = regexp.MustCompile(`^/blogs(?P<remainder>.*)`)
var RegexBlog = regexp.MustCompile(`^/blog(/(?P<page>\d+))?$`)
func BuildBlog(projectSlug string, page int) string {
func (c *UrlContext) BuildBlog(page int) string {
defer CatchPanic()
if page < 1 {
panic(oops.New(nil, "Invalid blog page (%d), must be >= 1", page))
@ -473,63 +512,63 @@ func BuildBlog(projectSlug string, page int) string {
path += "/" + strconv.Itoa(page)
}
return ProjectUrl(path, nil, projectSlug)
return c.Url(path, nil)
}
var RegexBlogThread = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)(-([^/]+))?$`)
func BuildBlogThread(projectSlug string, threadId int, title string) string {
func (c *UrlContext) BuildBlogThread(threadId int, title string) string {
defer CatchPanic()
builder := buildBlogThreadPath(threadId, title)
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string, postId int) string {
func (c *UrlContext) BuildBlogThreadWithPostHash(threadId int, title string, postId int) string {
defer CatchPanic()
builder := buildBlogThreadPath(threadId, title)
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
return c.UrlWithFragment(builder.String(), nil, strconv.Itoa(postId))
}
var RegexBlogNewThread = regexp.MustCompile(`^/blog/new$`)
func BuildBlogNewThread(projectSlug string) string {
func (c *UrlContext) BuildBlogNewThread() string {
defer CatchPanic()
return ProjectUrl("/blog/new", nil, projectSlug)
return c.Url("/blog/new", nil)
}
var RegexBlogPost = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)$`)
func BuildBlogPost(projectSlug string, threadId int, postId int) string {
func (c *UrlContext) BuildBlogPost(threadId int, postId int) string {
defer CatchPanic()
builder := buildBlogPostPath(threadId, postId)
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexBlogPostDelete = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/delete$`)
func BuildBlogPostDelete(projectSlug string, threadId int, postId int) string {
func (c *UrlContext) BuildBlogPostDelete(threadId int, postId int) string {
defer CatchPanic()
builder := buildBlogPostPath(threadId, postId)
builder.WriteString("/delete")
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexBlogPostEdit = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/edit$`)
func BuildBlogPostEdit(projectSlug string, threadId int, postId int) string {
func (c *UrlContext) BuildBlogPostEdit(threadId int, postId int) string {
defer CatchPanic()
builder := buildBlogPostPath(threadId, postId)
builder.WriteString("/edit")
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexBlogPostReply = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/reply$`)
func BuildBlogPostReply(projectSlug string, threadId int, postId int) string {
func (c *UrlContext) BuildBlogPostReply(threadId int, postId int) string {
defer CatchPanic()
builder := buildBlogPostPath(threadId, postId)
builder.WriteString("/reply")
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
/*
@ -583,7 +622,7 @@ func BuildLibraryResource(resourceId int) string {
var RegexEpisodeList = regexp.MustCompile(`^/episode(/(?P<topic>[^/]+))?$`)
func BuildEpisodeList(projectSlug string, topic string) string {
func (c *UrlContext) BuildEpisodeList(topic string) string {
defer CatchPanic()
var builder strings.Builder
@ -592,21 +631,21 @@ func BuildEpisodeList(projectSlug string, topic string) string {
builder.WriteString("/")
builder.WriteString(topic)
}
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexEpisode = regexp.MustCompile(`^/episode/(?P<topic>[^/]+)/(?P<episode>[^/]+)$`)
func BuildEpisode(projectSlug string, topic string, episode string) string {
func (c *UrlContext) BuildEpisode(topic string, episode string) string {
defer CatchPanic()
return ProjectUrl(fmt.Sprintf("/episode/%s/%s", topic, episode), nil, projectSlug)
return c.Url(fmt.Sprintf("/episode/%s/%s", topic, episode), nil)
}
var RegexCineraIndex = regexp.MustCompile(`^/(?P<topic>[^/]+).index$`)
func BuildCineraIndex(projectSlug string, topic string) string {
func (c *UrlContext) BuildCineraIndex(topic string) string {
defer CatchPanic()
return ProjectUrl(fmt.Sprintf("/%s.index", topic), nil, projectSlug)
return c.Url(fmt.Sprintf("/%s.index", topic), nil)
}
/*
@ -638,8 +677,8 @@ func BuildDiscordShowcaseBacklog() string {
var RegexAssetUpload = regexp.MustCompile("^/upload_asset$")
// NOTE(asaf): Providing the projectSlug avoids any CORS problems.
func BuildAssetUpload(projectSlug string) string {
return ProjectUrl("/upload_asset", nil, projectSlug)
func (c *UrlContext) BuildAssetUpload() string {
return c.Url("/upload_asset", nil)
}
/*
@ -718,7 +757,7 @@ func BuildUserFile(filepath string) string {
var RegexForumMarkRead = regexp.MustCompile(`^/markread/(?P<sfid>\d+)$`)
// NOTE(asaf): subforumId == 0 means ALL SUBFORUMS
func BuildForumMarkRead(projectSlug string, subforumId int) string {
func (c *UrlContext) BuildForumMarkRead(subforumId int) string {
defer CatchPanic()
if subforumId < 0 {
panic(oops.New(nil, "Invalid subforum ID (%d), must be >= 0", subforumId))
@ -728,10 +767,10 @@ func BuildForumMarkRead(projectSlug string, subforumId int) string {
builder.WriteString("/markread/")
builder.WriteString(strconv.Itoa(subforumId))
return ProjectUrl(builder.String(), nil, projectSlug)
return c.Url(builder.String(), nil)
}
var RegexCatchAll = regexp.MustCompile("")
var RegexCatchAll = regexp.MustCompile("^")
/*
* Helper functions

View File

@ -5,7 +5,6 @@ import (
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)

View File

@ -0,0 +1,68 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(CleanUpProjects{})
}
type CleanUpProjects struct{}
func (m CleanUpProjects) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 11, 6, 3, 38, 35, 0, time.UTC))
}
func (m CleanUpProjects) Name() string {
return "CleanUpProjects"
}
func (m CleanUpProjects) Description() string {
return "Clean up projects with data violating our new constraints"
}
func (m CleanUpProjects) Up(ctx context.Context, tx pgx.Tx) error {
var err error
_, err = tx.Exec(ctx, `
DELETE FROM handmade_project WHERE id IN (91, 92);
DELETE FROM handmade_communicationchoicelist
WHERE project_id IN (91, 92);
DELETE FROM handmade_project_languages
WHERE project_id IN (91, 92);
DELETE FROM handmade_project_screenshots
WHERE project_id IN (91, 92);
UPDATE handmade_project
SET slug = 'hmh-notes'
WHERE slug = 'hmh_notes';
`)
if err != nil {
return oops.New(err, "failed to patch up project slugs")
}
return nil
}
func (m CleanUpProjects) Down(ctx context.Context, tx pgx.Tx) error {
var err error
_, err = tx.Exec(ctx, `
-- Don't bother restoring those old projects
UPDATE handmade_project
SET slug = 'hmh_notes'
WHERE slug = 'hmh-notes';
`)
if err != nil {
return oops.New(err, "failed to restore project slugs")
}
return nil
}

View File

@ -0,0 +1,205 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(PersonalProjects{})
}
type PersonalProjects struct{}
func (m PersonalProjects) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 11, 6, 3, 39, 30, 0, time.UTC))
}
func (m PersonalProjects) Name() string {
return "PersonalProjects"
}
func (m PersonalProjects) Description() string {
return "Add data model for personal projects / tags"
}
func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error {
var err error
_, err = tx.Exec(ctx, `
CREATE TABLE tags (
id SERIAL NOT NULL PRIMARY KEY,
text VARCHAR(20) NOT NULL
);
CREATE INDEX tags_by_text ON tags (text);
ALTER TABLE tags
ADD CONSTRAINT tag_syntax CHECK (
text ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$'
);
CREATE TABLE snippet_tags (
snippet_id INT NOT NULL REFERENCES handmade_snippet (id) ON DELETE CASCADE,
tag_id INT NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
PRIMARY KEY (snippet_id, tag_id)
);
`)
if err != nil {
return oops.New(err, "failed to add tags tables")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_snippet
DROP CONSTRAINT handmade_snippet_owner_id_fkey,
ALTER owner_id DROP NOT NULL,
ADD CONSTRAINT handmade_snippet_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth_user (id) ON DELETE SET NULL;
`)
if err != nil {
return oops.New(err, "failed to update snippet constraints")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_project
DROP featurevotes,
DROP parent_id,
DROP quota,
DROP quota_used,
DROP standalone,
ALTER flags TYPE BOOLEAN USING flags > 0;
ALTER TABLE handmade_project RENAME flags TO hidden;
`)
if err != nil {
return oops.New(err, "failed to clean up existing fields")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_project
ADD personal BOOLEAN NOT NULL DEFAULT TRUE,
ADD tag INT REFERENCES tags (id);
`)
if err != nil {
return oops.New(err, "failed to add new fields")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_project
ADD CONSTRAINT slug_syntax CHECK (
slug ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$'
);
`)
if err != nil {
return oops.New(err, "failed to add check constraints")
}
_, err = tx.Exec(ctx, `
UPDATE handmade_project
SET personal = FALSE;
`)
if err != nil {
return oops.New(err, "failed to make existing projects official")
}
//
// Port "jam snippets" to use a tag
//
jamTagId, err := db.QueryInt(ctx, tx, `INSERT INTO tags (text) VALUES ('wheeljam') RETURNING id`)
if err != nil {
return oops.New(err, "failed to create jam tag")
}
_, err = tx.Exec(ctx,
`
INSERT INTO snippet_tags
SELECT id, $1
FROM handmade_snippet
WHERE is_jam
`,
jamTagId,
)
if err != nil {
return oops.New(err, "failed to add jam tag to jam snippets")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_snippet
DROP is_jam;
`)
if err != nil {
return oops.New(err, "failed to drop is_jam column from snippets")
}
return nil
}
func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error {
var err error
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_snippet
ADD is_jam BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE handmade_snippet
SET is_jam = TRUE
WHERE id IN (
SELECT snippet.id
FROM
handmade_snippet AS snippet
JOIN snippet_tags ON snippet.id = snippet_tags.snippet_id
JOIN tags ON snippet_tags.tag_id = tags.id
WHERE
tags.text = 'wheeljam'
);
DELETE FROM tags WHERE text = 'wheeljam';
`)
if err != nil {
return oops.New(err, "failed to revert jam snippets")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_project
DROP CONSTRAINT slug_syntax,
DROP personal,
DROP tag,
ADD featurevotes INT NOT NULL DEFAULT 0,
-- no projects actually have a parent id so thankfully no further updates to do
ADD parent_id INT REFERENCES handmade_project (id) ON DELETE SET NULL,
ADD quota INT NOT NULL DEFAULT 0,
ADD quota_used INT NOT NULL DEFAULT 0,
ADD standalone BOOLEAN NOT NULL DEFAULT FALSE,
ALTER hidden TYPE INT USING CASE WHEN hidden THEN 1 ELSE 0 END;
ALTER TABLE handmade_project RENAME hidden TO flags;
`)
if err != nil {
return oops.New(err, "failed to revert personal project changes")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_snippet
DROP CONSTRAINT handmade_snippet_owner_id_fkey,
ALTER owner_id SET NOT NULL,
ADD CONSTRAINT handmade_snippet_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth_user (id) ON DELETE CASCADE;
`)
if err != nil {
return oops.New(err, "failed to revert snippet constraint changes")
}
_, err = tx.Exec(ctx, `
DROP TABLE snippet_tags;
DROP TABLE tags;
`)
if err != nil {
return oops.New(err, "failed to drop tags table")
}
return nil
}

View File

@ -8,7 +8,7 @@ import (
"github.com/jackc/pgx/v4"
)
var All map[types.MigrationVersion]types.Migration = make(map[types.MigrationVersion]types.Migration)
var All = make(map[types.MigrationVersion]types.Migration)
func registerMigration(m types.Migration) {
All[m.Version()] = m

View File

@ -2,6 +2,8 @@ package models
import (
"reflect"
"regexp"
"strings"
"time"
)
@ -15,7 +17,7 @@ var ProjectType = reflect.TypeOf(Project{})
type ProjectLifecycle int
const (
ProjectLifecycleUnapproved = iota
ProjectLifecycleUnapproved ProjectLifecycle = iota
ProjectLifecycleApprovalRequired
ProjectLifecycleActive
ProjectLifecycleHiatus
@ -41,6 +43,7 @@ type Project struct {
Slug string `db:"slug"`
Name string `db:"name"`
TagID *int `db:"tag"`
Blurb string `db:"blurb"`
Description string `db:"description"`
ParsedDescription string `db:"descparsed"`
@ -53,7 +56,8 @@ type Project struct {
LogoLight string `db:"logolight"`
LogoDark string `db:"logodark"`
Flags int `db:"flags"` // NOTE(asaf): Flags is currently only used to mark a project as hidden. Flags == 1 means hidden. Flags == 0 means visible.
Personal bool `db:"personal"`
Hidden bool `db:"hidden"`
Featured bool `db:"featured"`
DateApproved time.Time `db:"date_approved"`
AllLastUpdated time.Time `db:"all_last_updated"`
@ -63,7 +67,7 @@ type Project struct {
ForumEnabled bool `db:"forum_enabled"`
BlogEnabled bool `db:"blog_enabled"`
LibraryEnabled bool `db:"library_enabled"`
LibraryEnabled bool `db:"library_enabled"` // TODO: Delete this field from the db
}
func (p *Project) IsHMN() bool {
@ -77,3 +81,30 @@ func (p *Project) Subdomain() string {
return p.Slug
}
// Checks whether the project has forums enabled. This should restrict the creation of new forum
// content, but it should NOT prevent the viewing of existing forum content. (Projects may at one
// point have forums enabled, write some stuff, and then later disable forums, and we want that
// content to stay accessible.) Hiding the navigation is ok.
func (p *Project) HasForums() bool {
return !p.Personal && p.ForumEnabled
}
// Same as HasForums, but for blogs.
func (p *Project) HasBlog() bool {
return !p.Personal && p.BlogEnabled
}
var slugUnsafeChars = regexp.MustCompile(`[^a-zA-Z0-9-]`)
var slugHyphenRun = regexp.MustCompile(`-+`)
// Generates a URL-safe version of a personal project's name.
func GeneratePersonalProjectSlug(name string) string {
slug := name
slug = slugUnsafeChars.ReplaceAllLiteralString(slug, "-")
slug = slugHyphenRun.ReplaceAllLiteralString(slug, "-")
slug = strings.Trim(slug, "-")
slug = strings.ToLower(slug)
return slug
}

View File

@ -0,0 +1,18 @@
package models
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGenerateSlug(t *testing.T) {
assert.Equal(t, "godspeed-you-black-emperor", GeneratePersonalProjectSlug("Godspeed You! Black Emperor"))
assert.Equal(t, "", GeneratePersonalProjectSlug("!@#$%^&"))
assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("-- Foo Bar --"))
assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("--foo-bar"))
assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("foo--bar"))
assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("foo-bar--"))
assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug(" Foo Bar "))
assert.Equal(t, "20-000-leagues-under-the-sea", GeneratePersonalProjectSlug("20,000 Leagues Under the Sea"))
}

6
src/models/tag.go Normal file
View File

@ -0,0 +1,6 @@
package models
type Tag struct {
ID int `db:"id"`
Text string `db:"text"`
}

View File

@ -59,22 +59,11 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{
models.ProjectLifecycleLTS: "Complete",
}
func ProjectUrl(p *models.Project) string {
var url string
if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired {
url = hmnurl.BuildProjectNotApproved(p.Slug)
} else {
url = hmnurl.BuildProjectHomepage(p.Slug)
}
return url
}
func ProjectToTemplate(p *models.Project, theme string) Project {
func ProjectToTemplate(p *models.Project, url string, theme string) Project {
logo := p.LogoLight
if theme == "dark" {
logo = p.LogoDark
}
url := ProjectUrl(p)
return Project{
Name: p.Name,
Subdomain: p.Subdomain(),
@ -91,9 +80,8 @@ func ProjectToTemplate(p *models.Project, theme string) Project {
IsHMN: p.IsHMN(),
HasBlog: p.BlogEnabled,
HasForum: p.ForumEnabled,
HasLibrary: false, // TODO: port the library lol
HasBlog: p.HasBlog(),
HasForum: p.HasForums(),
DateApproved: p.DateApproved,
}
@ -319,8 +307,24 @@ func TimelineItemsToJSON(items []TimelineItem) string {
builder.WriteString(`"discord_message_url":"`)
builder.WriteString(item.DiscordMessageUrl)
builder.WriteString(`",`)
builder.WriteString(`"tags":[`)
for _, tag := range item.Tags {
builder.WriteString(`{`)
builder.WriteString(`"text":"`)
builder.WriteString(tag.Text)
builder.WriteString(`",`)
builder.WriteString(`"url":"`)
builder.WriteString(tag.Url)
builder.WriteString(`"`)
builder.WriteString(`}`)
}
builder.WriteString(`]`)
builder.WriteRune('}')
}
builder.WriteRune(']')
@ -380,6 +384,13 @@ func DiscordUserToTemplate(d *models.DiscordUser) DiscordUser {
}
}
func TagToTemplate(t *models.Tag) Tag {
return Tag{
Text: t.Text,
// TODO: Url
}
}
func maybeString(s *string) string {
if s == nil {
return ""

View File

@ -20,7 +20,7 @@
</div>
</div>
<div>
{{ if $.User }}
{{ if and $.User $.Project.HasBlog }}
<div class="flex">
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">&#10006;</a>&nbsp;
@ -81,7 +81,7 @@
</div>
</div>
<div>
{{ if $.User }}
{{ if and $.User $.Project.HasBlog }}
<div class="flex">
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">&#10006;</a>&nbsp;
@ -111,11 +111,13 @@
<div class="optionbar bottom">
<div class="options">
{{ if .Project.HasBlog }}
{{ if $.User }}
<a class="button" href="{{ .ReplyLink }}"><span class="big pr1">+</span> Add Comment</a>
{{ else }}
<a class="button" href="{{ .LoginLink }}">Log in to comment</a>
{{ end }}
{{ end }}
</div>
</div>
</div>

View File

@ -34,14 +34,18 @@
{{ define "subforum_options" }}
<div class="options">
{{ if .Project.HasForum }}
{{ if .User }}
<a class="button new-thread" href="{{ .NewThreadUrl }}"><span class="big pr1">+</span> New Thread</a>
{{ else }}
<a class="button" href="{{ .LoginPageUrl }}">Log in to post a new thread</a>
{{ end }}
{{ end }}
{{ if .User }}
<form method="POST" action="{{ .MarkReadUrl }}">
{{ csrftoken .Session }}
<button type="submit"><span class="big pr1">&#x2713;</span> Mark threads here as read</button>
</form>
{{ else }}
<a class="button" href="{{ .LoginPageUrl }}">Log in to post a new thread</a>
{{ end }}
</div>
<div class="options">

View File

@ -61,7 +61,7 @@
<div class="postid">
<a name="{{ .ID }}" href="{{ .Url }}">#{{ .ID }}</a>
</div>
{{ if $.User }}
{{ if and $.User $.Project.HasForum }}
<div class="flex pr3">
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">&#10006;</a>&nbsp;
@ -120,11 +120,13 @@
<a class="button" href="{{ .SubforumUrl }}">&larr; Back to index</a>
{{ if .Thread.Locked }}
<span>Thread is locked.</span>
{{ else if .User }}
{{ else if .Project.HasForum }}
{{ if .User }}
<a class="button" href="{{ .ReplyUrl }}">&#10551; Reply to Thread</a>
{{ else }}
<span class="pa2"><a href="{{ .LoginPageUrl }}">Log in</a> to reply</span>
{{ end }}
{{ end }}
</div>
<div class="options order-0 order-last-ns">
{{ template "pagination.html" .Pagination }}

View File

@ -30,6 +30,7 @@
<div data-tmpl="description">
Unknown description
</div>
<div data-tmpl="tags" class="pt2 flex"></div>
<div class="i f7 pt2">
<a data-tmpl="discord_link" target="_blank">View original message on Discord</a>
</div>
@ -38,3 +39,7 @@
</div>
</div>
</template>
<template id="timeline_item_tag">
<div data-tmpl="tag" class="bg-theme-dimmer ph2 pv1 br2"></div>
</template>

View File

@ -59,4 +59,14 @@
{{ end }}
</div>
{{ end }}
{{ with .Tags }}
<div class="mt3 flex">
{{ range $i, $tag := . }}
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
{{ $tag.Text }}
</div>
{{ end }}
</div>
{{ end }}
</div>

View File

@ -1,11 +1,10 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block no-bg-image">
<div>
{{ with .CarouselProjects }}
<div class="carousel-container mw-100 mv2 mv3-ns margin-center">
<div class="carousel-container mw-100 mv2 mv3-ns margin-center dn db-ns">
<div class="carousel pa3 h5 overflow-hidden bg--dim br2-ns">
<div class="dn db-l"> <!-- desktop carousel -->
{{ range $index, $project := . }}
<div class="carousel-item flex pa3 w-100 h-100 bg--dim items-center {{ if eq $index 0 }}active{{ end }}">
<div class="flex-grow-1 pr3 relative flex flex-column h-100 justify-center">
@ -25,14 +24,6 @@
</div>
{{ end }}
</div>
<div class="db dn-l"> <!-- mobile/tablet carousel -->
{{ range $index, $project := . }}
<div class="carousel-item-small {{ if eq $index 0 }}active{{ end }}">
{{ template "project_card.html" projectcarddata $project "h-100" }}
</div>
{{ end }}
</div>
</div>
<div class="flex justify-center pv2">
{{ range $index, $project := . }}
<div
@ -47,9 +38,13 @@
<div class="bg--dim-ns br2">
<div class="clear"></div>
<div class="optionbar pv2 ph3">
<div class="options">
<a href="{{ .ProjectAtomFeedUrl }}"><span class="icon big">4</span> RSS Feed &ndash; New Projects</span></a>
</div>
<div class="options">
{{ template "pagination.html" .Pagination }}
</div>
</div>
<div class="projectlist ph3">
{{ range .Projects }}
@ -66,40 +61,14 @@
</div>
<div class="w-100 w-40-l ph3 ph0-ns flex-shrink-0">
<div class="ml3-l mt3 mt0-l pa3 bg--dim br2">
{{ if not .UserPendingProject }}
<div class="content-block new-project p-spaced">
<h2>Project submissions are closed</h2>
<p>
We are reworking the way we approach projects on the network. In the meantime feel free to share your work on the <a href="{{ .WIPForumUrl }}">forums</a> or on our <a href="https://discord.gg/hxWxDee">Discord</a>.
</p>
</div>
{{ else }}
<div class="content-block single">
<h2>Project pending</h2>
<p>Thanks for considering us as a home for<br /><a href="{{ .UserPendingProject.Url }}">{{ .UserPendingProject.Name }}</a>!</p>
<br />
{{ if .UserPendingProjectUnderReview }}
<p>We see it's ready for review by an administrator, great! We'll try and get back to you in a timely manner.</p>
{{ else }}
<p>When you're ready for us to review it, let us know using the checkbox on {{ .UserPendingProject.Name }}'s profile editor.</p>
{{ end }}
</div>
{{ end }}
{{ if .UserApprovedProjects }}
<div class="content-block single projectlist">
{{ if .UserPendingProject }}
<h2>Your other projects</h2>
{{ else }}
<h2>Your projects</h2>
{{ end }}
{{ range .UserApprovedProjects }}
<h2>Personal Projects</h2>
<p>Many community members have projects of their own that are currently works in progress. Here's a few:</p>
{{ range .PersonalProjects }}
<div class="mv3">
{{ template "project_card.html" projectcarddata . "" }}
</div>
{{ end }}
</div>
{{ end }}
</div>
</div>
</div>
</div>

View File

@ -24,6 +24,15 @@
{{ end }}
{{ end }}
</div>
{{ with .Snippet.Tags }}
<div class="mt3 flex">
{{ range $i, $tag := . }}
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
{{ $tag.Text }}
</div>
{{ end }}
</div>
{{ end }}
</div>
</div>
{{ end }}

View File

@ -128,7 +128,6 @@ type Project struct {
HasBlog bool
HasForum bool
HasLibrary bool
UUID string
DateApproved time.Time
@ -269,6 +268,7 @@ type TimelineItem struct {
OwnerName string
OwnerUrl string
Tags []Tag
Description template.HTML
PreviewMedia TimelineItemMedia
@ -331,3 +331,8 @@ type DiscordUser struct {
Discriminator string
Avatar string
}
type Tag struct {
Text string
Url string
}

View File

@ -137,7 +137,7 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
for _, p := range posts {
post := templates.PostToTemplate(&p.Post, &p.Author, c.Theme)
post.AddContentVersion(p.CurrentVersion, &p.Author) // NOTE(asaf): Don't care about editors here
post.Url = UrlForGenericPost(&p.Thread, &p.Post, lineageBuilder, p.Project.Slug)
post.Url = UrlForGenericPost(UrlContextForProject(&p.Project), &p.Thread, &p.Post, lineageBuilder)
data.Posts = append(data.Posts, postWithTitle{
Post: post,
Title: p.Thread.Title,

View File

@ -14,6 +14,11 @@ func getBaseDataAutocrumb(c *RequestContext, title string) templates.BaseData {
// NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary.
// If you pass nil, no breadcrumbs will be created.
func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData {
var project models.Project
if c.CurrentProject != nil {
project = *c.CurrentProject
}
var templateUser *templates.User
var templateSession *templates.Session
if c.CurrentUser != nil {
@ -26,10 +31,10 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
notices := getNoticesFromCookie(c)
if len(breadcrumbs) > 0 {
projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug)
projectUrl := c.UrlContext.BuildHomepage()
if breadcrumbs[0].Url != projectUrl {
rootBreadcrumb := templates.Breadcrumb{
Name: c.CurrentProject.Name,
Name: project.Name,
Url: projectUrl,
}
breadcrumbs = append([]templates.Breadcrumb{rootBreadcrumb}, breadcrumbs...)
@ -42,20 +47,20 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
Breadcrumbs: breadcrumbs,
CurrentUrl: c.FullUrl(),
CurrentProjectUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
CurrentProjectUrl: c.UrlContext.BuildHomepage(),
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1),
ProjectCSSUrl: hmnurl.BuildProjectCSS(project.Color1),
Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme),
Project: templates.ProjectToTemplate(&project, c.UrlContext.BuildHomepage(), c.Theme),
User: templateUser,
Session: templateSession,
Notices: notices,
ReportIssueMailto: "team@handmade.network",
OpenGraphItems: buildDefaultOpenGraphItems(c.CurrentProject, title),
OpenGraphItems: buildDefaultOpenGraphItems(&project, title),
IsProjectPage: !c.CurrentProject.IsHMN(),
IsProjectPage: !project.IsHMN(),
Header: templates.Header{
AdminUrl: hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page
UserSettingsUrl: hmnurl.BuildUserSettings(""),
@ -67,7 +72,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
HMNHomepageUrl: hmnurl.BuildHomepage(),
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
PodcastUrl: hmnurl.BuildPodcast(),
ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1),
ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1),
LibraryUrl: hmnurl.BuildLibrary(),
},
Footer: templates.Footer{
@ -77,7 +82,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
CodeOfConductUrl: hmnurl.BuildCodeOfConduct(),
CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(),
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1),
ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1),
ContactUrl: hmnurl.BuildContactPage(),
},
}
@ -86,19 +91,19 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
}
if !c.CurrentProject.IsHMN() {
if !project.IsHMN() {
episodeGuideUrl := ""
defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[c.CurrentProject.Slug]
defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[project.Slug]
if hasAnnotations {
episodeGuideUrl = hmnurl.BuildEpisodeList(c.CurrentProject.Slug, defaultTopic)
episodeGuideUrl = c.UrlContext.BuildEpisodeList(defaultTopic)
}
baseData.Header.Project = &templates.ProjectHeader{
HasForums: c.CurrentProject.ForumEnabled,
HasBlog: c.CurrentProject.BlogEnabled,
HasForums: project.HasForums(),
HasBlog: project.HasBlog(),
HasEpisodeGuide: hasAnnotations,
ForumsUrl: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
ForumsUrl: c.UrlContext.BuildForum(nil, 1),
BlogUrl: c.UrlContext.BuildBlog(1),
EpisodeGuideUrl: episodeGuideUrl,
}
}

View File

@ -35,7 +35,7 @@ func BlogIndex(c *RequestContext) ResponseData {
const postsPerPage = 5
numPosts, err := CountPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
numThreads, err := CountThreads(c.Context(), c.Conn, c.CurrentUser, ThreadsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
})
@ -43,10 +43,10 @@ func BlogIndex(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch total number of blog posts"))
}
numPages := utils.NumPages(numPosts, postsPerPage)
numPages := utils.NumPages(numThreads, postsPerPage)
page, ok := ParsePageNumber(c, "page", numPages)
if !ok {
c.Redirect(hmnurl.BuildBlog(c.CurrentProject.Slug, page), http.StatusSeeOther)
c.Redirect(c.UrlContext.BuildBlog(page), http.StatusSeeOther)
}
threads, err := FetchThreads(c.Context(), c.Conn, c.CurrentUser, ThreadsQuery{
@ -63,19 +63,19 @@ func BlogIndex(c *RequestContext) ResponseData {
for _, thread := range threads {
entries = append(entries, blogIndexEntry{
Title: thread.Thread.Title,
Url: hmnurl.BuildBlogThread(c.CurrentProject.Slug, thread.Thread.ID, thread.Thread.Title),
Url: c.UrlContext.BuildBlogThread(thread.Thread.ID, thread.Thread.Title),
Author: templates.UserToTemplate(thread.FirstPostAuthor, c.Theme),
Date: thread.FirstPost.PostDate,
Content: template.HTML(thread.FirstPostCurrentVersion.TextParsed),
})
}
baseData := getBaseData(c, fmt.Sprintf("%s Blog", c.CurrentProject.Name), []templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)})
baseData := getBaseData(c, fmt.Sprintf("%s Blog", c.CurrentProject.Name), []templates.Breadcrumb{BlogBreadcrumb(c.UrlContext)})
canCreate := false
if c.CurrentUser != nil {
if c.CurrentProject.HasBlog() && c.CurrentUser != nil {
isProjectOwner := false
owners, err := FetchProjectOwners(c, c.CurrentProject.ID)
owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners"))
}
@ -97,14 +97,14 @@ func BlogIndex(c *RequestContext) ResponseData {
Current: page,
Total: numPages,
FirstUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
LastUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, numPages),
PreviousUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, utils.IntClamp(1, page-1, numPages)),
NextUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, utils.IntClamp(1, page+1, numPages)),
FirstUrl: c.UrlContext.BuildBlog(1),
LastUrl: c.UrlContext.BuildBlog(numPages),
PreviousUrl: c.UrlContext.BuildBlog(utils.IntClamp(1, page-1, numPages)),
NextUrl: c.UrlContext.BuildBlog(utils.IntClamp(1, page+1, numPages)),
},
CanCreatePost: canCreate,
NewPostUrl: hmnurl.BuildBlogNewThread(c.CurrentProject.Slug),
NewPostUrl: c.UrlContext.BuildBlogNewThread(),
}, c.Perf)
return res
}
@ -138,11 +138,11 @@ func BlogThread(c *RequestContext) ResponseData {
for _, p := range posts {
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
post.AddContentVersion(p.CurrentVersion, p.Editor)
addBlogUrlsToPost(&post, c.CurrentProject.Slug, &p.Thread, p.Post.ID)
addBlogUrlsToPost(c.UrlContext, &post, &p.Thread, p.Post.ID)
if p.ReplyPost != nil {
reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme)
addBlogUrlsToPost(&reply, c.CurrentProject.Slug, &p.Thread, p.Post.ID)
addBlogUrlsToPost(c.UrlContext, &reply, &p.Thread, p.Post.ID)
post.ReplyPost = &reply
}
@ -168,7 +168,7 @@ func BlogThread(c *RequestContext) ResponseData {
}
}
baseData := getBaseData(c, thread.Title, []templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)})
baseData := getBaseData(c, thread.Title, []templates.Breadcrumb{BlogBreadcrumb(c.UrlContext)})
baseData.OpenGraphItems = append(baseData.OpenGraphItems, templates.OpenGraphItem{
Property: "og:description",
Value: posts[0].Post.Preview,
@ -180,7 +180,7 @@ func BlogThread(c *RequestContext) ResponseData {
Thread: templates.ThreadToTemplate(&thread),
MainPost: templatePosts[0],
Comments: templatePosts[1:],
ReplyLink: hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, posts[0].Post.ID),
ReplyLink: c.UrlContext.BuildBlogPostReply(cd.ThreadID, posts[0].Post.ID),
LoginLink: hmnurl.BuildLoginPage(c.FullUrl()),
}, c.Perf)
return res
@ -202,7 +202,7 @@ func BlogPostRedirectToThread(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread for blog redirect"))
}
threadUrl := hmnurl.BuildBlogThreadWithPostHash(c.CurrentProject.Slug, cd.ThreadID, thread.Thread.Title, cd.PostID)
threadUrl := c.UrlContext.BuildBlogThreadWithPostHash(cd.ThreadID, thread.Thread.Title, cd.PostID)
return c.Redirect(threadUrl, http.StatusFound)
}
@ -210,11 +210,11 @@ func BlogNewThread(c *RequestContext) ResponseData {
baseData := getBaseData(
c,
fmt.Sprintf("Create New Post | %s", c.CurrentProject.Name),
[]templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)},
[]templates.Breadcrumb{BlogBreadcrumb(c.UrlContext)},
)
editData := getEditorDataForNew(c.CurrentUser, baseData, nil)
editData.SubmitUrl = hmnurl.BuildBlogNewThread(c.CurrentProject.Slug)
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, nil)
editData.SubmitUrl = c.UrlContext.BuildBlogNewThread()
editData.SubmitLabel = "Create Post"
var res ResponseData
@ -268,7 +268,7 @@ func BlogNewThreadSubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new blog post"))
}
newThreadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, threadId, title)
newThreadUrl := c.UrlContext.BuildBlogThread(threadId, title)
return c.Redirect(newThreadUrl, http.StatusSeeOther)
}
@ -301,11 +301,11 @@ func BlogPostEdit(c *RequestContext) ResponseData {
baseData := getBaseData(
c,
title,
BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread),
BlogThreadBreadcrumbs(c.UrlContext, &post.Thread),
)
editData := getEditorDataForEdit(c.CurrentUser, baseData, post)
editData.SubmitUrl = hmnurl.BuildBlogPostEdit(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
editData := getEditorDataForEdit(c.UrlContext, c.CurrentUser, baseData, post)
editData.SubmitUrl = c.UrlContext.BuildBlogPostEdit(cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Edited Post"
if post.Thread.FirstID != post.Post.ID {
editData.SubmitLabel = "Submit Edited Comment"
@ -373,7 +373,7 @@ func BlogPostEditSubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit blog post"))
}
postUrl := hmnurl.BuildBlogThreadWithPostHash(c.CurrentProject.Slug, cd.ThreadID, post.Thread.Title, cd.PostID)
postUrl := c.UrlContext.BuildBlogThreadWithPostHash(cd.ThreadID, post.Thread.Title, cd.PostID)
return c.Redirect(postUrl, http.StatusSeeOther)
}
@ -396,14 +396,14 @@ func BlogPostReply(c *RequestContext) ResponseData {
baseData := getBaseData(
c,
fmt.Sprintf("Replying to comment in \"%s\" | %s", post.Thread.Title, c.CurrentProject.Name),
BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread),
BlogThreadBreadcrumbs(c.UrlContext, &post.Thread),
)
replyPost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
replyPost.AddContentVersion(post.CurrentVersion, post.Editor)
editData := getEditorDataForNew(c.CurrentUser, baseData, &replyPost)
editData.SubmitUrl = hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, &replyPost)
editData.SubmitUrl = c.UrlContext.BuildBlogPostReply(cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Reply"
var res ResponseData
@ -439,7 +439,7 @@ func BlogPostReplySubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to blog post"))
}
newPostUrl := hmnurl.BuildBlogPost(c.CurrentProject.Slug, cd.ThreadID, newPostId)
newPostUrl := c.UrlContext.BuildBlogPost(cd.ThreadID, newPostId)
return c.Redirect(newPostUrl, http.StatusSeeOther)
}
@ -472,7 +472,7 @@ func BlogPostDelete(c *RequestContext) ResponseData {
baseData := getBaseData(
c,
title,
BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread),
BlogThreadBreadcrumbs(c.UrlContext, &post.Thread),
)
templatePost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
@ -487,7 +487,7 @@ func BlogPostDelete(c *RequestContext) ResponseData {
var res ResponseData
res.MustWriteTemplate("blog_post_delete.html", blogPostDeleteData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildBlogPostDelete(c.CurrentProject.Slug, cd.ThreadID, cd.PostID),
SubmitUrl: c.UrlContext.BuildBlogPostDelete(cd.ThreadID, cd.PostID),
Post: templatePost,
}, c.Perf)
return res
@ -517,8 +517,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData {
}
if threadDeleted {
projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug)
return c.Redirect(projectUrl, http.StatusSeeOther)
return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther)
} else {
thread, err := FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, ThreadsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
@ -529,7 +528,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData {
} else if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread after blog post delete"))
}
threadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, thread.Thread.ID, thread.Thread.Title)
threadUrl := c.UrlContext.BuildBlogThread(thread.Thread.ID, thread.Thread.Title)
return c.Redirect(threadUrl, http.StatusSeeOther)
}
}
@ -608,9 +607,9 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
return res, true
}
func addBlogUrlsToPost(p *templates.Post, projectSlug string, thread *models.Thread, postId int) {
p.Url = hmnurl.BuildBlogThreadWithPostHash(projectSlug, thread.ID, thread.Title, postId)
p.DeleteUrl = hmnurl.BuildBlogPostDelete(projectSlug, thread.ID, postId)
p.EditUrl = hmnurl.BuildBlogPostEdit(projectSlug, thread.ID, postId)
p.ReplyUrl = hmnurl.BuildBlogPostReply(projectSlug, thread.ID, postId)
func addBlogUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, thread *models.Thread, postId int) {
p.Url = urlContext.BuildBlogThreadWithPostHash(thread.ID, thread.Title, postId)
p.DeleteUrl = urlContext.BuildBlogPostDelete(thread.ID, postId)
p.EditUrl = urlContext.BuildBlogPostEdit(thread.ID, postId)
p.ReplyUrl = urlContext.BuildBlogPostReply(thread.ID, postId)
}

View File

@ -6,58 +6,58 @@ import (
"git.handmade.network/hmn/hmn/src/templates"
)
func ProjectBreadcrumb(project *models.Project) templates.Breadcrumb {
func ProjectBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb {
return templates.Breadcrumb{
Name: project.Name,
Url: hmnurl.BuildProjectHomepage(project.Slug),
Name: projectUrlContext.ProjectName,
Url: projectUrlContext.BuildHomepage(),
}
}
func ForumBreadcrumb(projectSlug string) templates.Breadcrumb {
func ForumBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb {
return templates.Breadcrumb{
Name: "Forums",
Url: hmnurl.BuildForum(projectSlug, nil, 1),
Url: projectUrlContext.BuildForum(nil, 1),
}
}
func SubforumBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, subforumID int) []templates.Breadcrumb {
func SubforumBreadcrumbs(projectUrlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, subforumID int) []templates.Breadcrumb {
var result []templates.Breadcrumb
result = []templates.Breadcrumb{
ProjectBreadcrumb(project),
ForumBreadcrumb(project.Slug),
ProjectBreadcrumb(projectUrlContext),
ForumBreadcrumb(projectUrlContext),
}
subforums := lineageBuilder.GetSubforumLineage(subforumID)
slugs := lineageBuilder.GetSubforumLineageSlugs(subforumID)
for i, subforum := range subforums {
result = append(result, templates.Breadcrumb{
Name: subforum.Name,
Url: hmnurl.BuildForum(project.Slug, slugs[0:i+1], 1),
Url: projectUrlContext.BuildForum(slugs[0:i+1], 1),
})
}
return result
}
func ForumThreadBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, thread *models.Thread) []templates.Breadcrumb {
result := SubforumBreadcrumbs(lineageBuilder, project, *thread.SubforumID)
func ForumThreadBreadcrumbs(projectUrlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, thread *models.Thread) []templates.Breadcrumb {
result := SubforumBreadcrumbs(projectUrlContext, lineageBuilder, *thread.SubforumID)
result = append(result, templates.Breadcrumb{
Name: thread.Title,
Url: hmnurl.BuildForumThread(project.Slug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1),
Url: projectUrlContext.BuildForumThread(lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1),
})
return result
}
func BlogBreadcrumb(projectSlug string) templates.Breadcrumb {
func BlogBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb {
return templates.Breadcrumb{
Name: "Blog",
Url: hmnurl.BuildBlog(projectSlug, 1),
Url: projectUrlContext.BuildBlog(1),
}
}
func BlogThreadBreadcrumbs(projectSlug string, thread *models.Thread) []templates.Breadcrumb {
func BlogThreadBreadcrumbs(projectUrlContext *hmnurl.UrlContext, thread *models.Thread) []templates.Breadcrumb {
result := []templates.Breadcrumb{
BlogBreadcrumb(projectSlug),
{Name: thread.Title, Url: hmnurl.BuildBlogThread(projectSlug, thread.ID, thread.Title)},
BlogBreadcrumb(projectUrlContext),
{Name: thread.Title, Url: projectUrlContext.BuildBlogThread(thread.ID, thread.Title)},
}
return result
}

View File

@ -11,7 +11,6 @@ import (
"strings"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/templates"
)
@ -53,11 +52,11 @@ func EpisodeList(c *RequestContext) ResponseData {
defaultTopic, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
if !hasEpisodeGuide {
return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther)
return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther)
}
if topic == "" {
return c.Redirect(hmnurl.BuildEpisodeList(slug, defaultTopic), http.StatusSeeOther)
return c.Redirect(c.UrlContext.BuildEpisodeList(defaultTopic), http.StatusSeeOther)
}
allTopics, foundTopic := topicsForProject(slug, topic)
@ -82,7 +81,7 @@ func EpisodeList(c *RequestContext) ResponseData {
for _, t := range allTopics {
url := ""
if t != foundTopic {
url = hmnurl.BuildEpisodeList(slug, t)
url = c.UrlContext.BuildEpisodeList(t)
}
topicLinks = append(topicLinks, templates.Link{LinkText: t, Url: url})
}
@ -114,7 +113,7 @@ func Episode(c *RequestContext) ResponseData {
_, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
if !hasEpisodeGuide {
return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther)
return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther)
}
_, foundTopic := topicsForProject(slug, topic)
@ -150,7 +149,7 @@ func Episode(c *RequestContext) ResponseData {
baseData := getBaseData(
c,
title,
[]templates.Breadcrumb{{Name: "Episode Guide", Url: hmnurl.BuildEpisodeList(c.CurrentProject.Slug, foundTopic)}},
[]templates.Breadcrumb{{Name: "Episode Guide", Url: c.UrlContext.BuildEpisodeList(foundTopic)}},
)
res.MustWriteTemplate("episode.html", EpisodeData{
BaseData: baseData,

View File

@ -71,7 +71,7 @@ func Feed(c *RequestContext) ResponseData {
BaseData: baseData,
AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.BuildForumMarkRead(c.CurrentProject.Slug, 0),
MarkAllReadUrl: c.UrlContext.BuildForumMarkRead(0),
Posts: posts,
Pagination: pagination,
}, c.Perf)
@ -167,7 +167,7 @@ func AtomFeed(c *RequestContext) ResponseData {
handmade_project AS project
WHERE
project.lifecycle = ANY($1)
AND project.flags = 0
AND NOT project.hidden
ORDER BY date_approved DESC
LIMIT $2
`,
@ -181,7 +181,7 @@ func AtomFeed(c *RequestContext) ResponseData {
projectMap := make(map[int]int) // map[project id]index in slice
for _, p := range projects.ToSlice() {
project := p.(*projectResult).Project
templateProject := templates.ProjectToTemplate(&project, c.Theme)
templateProject := templates.ProjectToTemplate(&project, UrlContextForProject(&project).BuildHomepage(), c.Theme)
templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN()
projectIds = append(projectIds, project.ID)
@ -228,35 +228,14 @@ func AtomFeed(c *RequestContext) ResponseData {
feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForShowcase()
feedData.FeedUrl = hmnurl.BuildShowcase()
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
type snippetQuery struct {
Owner models.User `db:"owner"`
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
NOT snippet.is_jam
ORDER BY snippet.when DESC
LIMIT $1
`,
itemsPerFeed,
)
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
Limit: itemsPerFeed,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
}
snippetQuerySlice := snippetQueryResult.ToSlice()
for _, s := range snippetQuerySlice {
row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
feedData.Snippets = append(feedData.Snippets, timelineItem)
}
c.Perf.EndBlock()

View File

@ -35,8 +35,6 @@ type forumSubforumData struct {
TotalThreads int
}
type editActionType string
type editorData struct {
templates.BaseData
SubmitUrl string
@ -54,13 +52,13 @@ type editorData struct {
UploadUrl string
}
func getEditorDataForNew(currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData {
func getEditorDataForNew(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData {
result := editorData{
BaseData: baseData,
CanEditTitle: replyPost == nil,
PostReplyingTo: replyPost,
MaxFileSize: AssetMaxSize(currentUser),
UploadUrl: hmnurl.BuildAssetUpload(baseData.Project.Subdomain),
UploadUrl: urlContext.BuildAssetUpload(),
}
if replyPost != nil {
@ -70,7 +68,7 @@ func getEditorDataForNew(currentUser *models.User, baseData templates.BaseData,
return result
}
func getEditorDataForEdit(currentUser *models.User, baseData templates.BaseData, p PostAndStuff) editorData {
func getEditorDataForEdit(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, p PostAndStuff) editorData {
return editorData{
BaseData: baseData,
Title: p.Thread.Title,
@ -78,7 +76,7 @@ func getEditorDataForEdit(currentUser *models.User, baseData templates.BaseData,
IsEditing: true,
EditInitialContents: p.CurrentVersion.TextRaw,
MaxFileSize: AssetMaxSize(currentUser),
UploadUrl: hmnurl.BuildAssetUpload(baseData.Project.Subdomain),
UploadUrl: urlContext.BuildAssetUpload(),
}
}
@ -104,7 +102,7 @@ func Forum(c *RequestContext) ResponseData {
numPages := utils.NumPages(numThreads, threadsPerPage)
page, ok := ParsePageNumber(c, "page", numPages)
if !ok {
c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, page), http.StatusSeeOther)
c.Redirect(c.UrlContext.BuildForum(currentSubforumSlugs, page), http.StatusSeeOther)
}
howManyThreadsToSkip := (page - 1) * threadsPerPage
@ -119,7 +117,7 @@ func Forum(c *RequestContext) ResponseData {
makeThreadListItem := func(row ThreadAndStuff) templates.ThreadListItem {
return templates.ThreadListItem{
Title: row.Thread.Title,
Url: hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(*row.Thread.SubforumID), row.Thread.ID, row.Thread.Title, 1),
Url: c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(*row.Thread.SubforumID), row.Thread.ID, row.Thread.Title, 1),
FirstUser: templates.UserToTemplate(row.FirstPostAuthor, c.Theme),
FirstDate: row.FirstPost.PostDate,
LastUser: templates.UserToTemplate(row.LastPostAuthor, c.Theme),
@ -165,7 +163,7 @@ func Forum(c *RequestContext) ResponseData {
subforums = append(subforums, forumSubforumData{
Name: sfNode.Name,
Url: hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(sfNode.ID), 1),
Url: c.UrlContext.BuildForum(cd.LineageBuilder.GetSubforumLineageSlugs(sfNode.ID), 1),
Threads: threads,
TotalThreads: numThreads,
})
@ -179,23 +177,23 @@ func Forum(c *RequestContext) ResponseData {
baseData := getBaseData(
c,
fmt.Sprintf("%s Forums", c.CurrentProject.Name),
SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID),
SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID),
)
var res ResponseData
res.MustWriteTemplate("forum.html", forumData{
BaseData: baseData,
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
MarkReadUrl: hmnurl.BuildForumMarkRead(c.CurrentProject.Slug, cd.SubforumID),
NewThreadUrl: c.UrlContext.BuildForumNewThread(currentSubforumSlugs, false),
MarkReadUrl: c.UrlContext.BuildForumMarkRead(cd.SubforumID),
Threads: threads,
Pagination: templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
LastUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
NextUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
FirstUrl: c.UrlContext.BuildForum(currentSubforumSlugs, 1),
LastUrl: c.UrlContext.BuildForum(currentSubforumSlugs, numPages),
NextUrl: c.UrlContext.BuildForum(currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: c.UrlContext.BuildForum(currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
},
Subforums: subforums,
}, c.Perf)
@ -308,7 +306,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
if sfId == 0 {
redirUrl = hmnurl.BuildFeed()
} else {
redirUrl = hmnurl.BuildForum(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(sfId), 1)
redirUrl = c.UrlContext.BuildForum(lineageBuilder.GetSubforumLineageSlugs(sfId), 1)
}
return c.Redirect(redirUrl, http.StatusSeeOther)
}
@ -358,17 +356,17 @@ func ForumThread(c *RequestContext) ResponseData {
}
page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, threadPostsPerPage)
if !ok {
urlNoPage := hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1)
urlNoPage := c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1)
return c.Redirect(urlNoPage, http.StatusSeeOther)
}
pagination := templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1),
LastUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, numPages),
NextUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page-1, numPages)),
FirstUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1),
LastUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, numPages),
NextUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page-1, numPages)),
}
postsAndStuff, err := FetchPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
@ -385,11 +383,11 @@ func ForumThread(c *RequestContext) ResponseData {
for _, p := range postsAndStuff {
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
post.AddContentVersion(p.CurrentVersion, p.Editor)
addForumUrlsToPost(&post, c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID)
addForumUrlsToPost(c.UrlContext, &post, currentSubforumSlugs, thread.ID, post.ID)
if p.ReplyPost != nil {
reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme)
addForumUrlsToPost(&reply, c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, reply.ID)
addForumUrlsToPost(c.UrlContext, &reply, currentSubforumSlugs, thread.ID, reply.ID)
post.ReplyPost = &reply
}
@ -418,7 +416,7 @@ func ForumThread(c *RequestContext) ResponseData {
}
}
baseData := getBaseData(c, thread.Title, SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID))
baseData := getBaseData(c, thread.Title, SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID))
baseData.OpenGraphItems = append(baseData.OpenGraphItems, templates.OpenGraphItem{
Property: "og:description",
Value: threadResult.FirstPost.Preview,
@ -429,8 +427,8 @@ func ForumThread(c *RequestContext) ResponseData {
BaseData: baseData,
Thread: templates.ThreadToTemplate(&thread),
Posts: posts,
SubforumUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.FirstID),
SubforumUrl: c.UrlContext.BuildForum(currentSubforumSlugs, 1),
ReplyUrl: c.UrlContext.BuildForumPostReply(currentSubforumSlugs, thread.ID, thread.FirstID),
Pagination: pagination,
}, c.Perf)
return res
@ -466,8 +464,7 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
page := (postIdx / threadPostsPerPage) + 1
return c.Redirect(hmnurl.BuildForumThreadWithPostHash(
c.CurrentProject.Slug,
return c.Redirect(c.UrlContext.BuildForumThreadWithPostHash(
cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID),
cd.ThreadID,
post.Thread.Title,
@ -482,9 +479,9 @@ func ForumNewThread(c *RequestContext) ResponseData {
return FourOhFour(c)
}
baseData := getBaseData(c, "Create New Thread", SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID))
editData := getEditorDataForNew(c.CurrentUser, baseData, nil)
editData.SubmitUrl = hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true)
baseData := getBaseData(c, "Create New Thread", SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID))
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, nil)
editData.SubmitUrl = c.UrlContext.BuildForumNewThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true)
editData.SubmitLabel = "Post New Thread"
var res ResponseData
@ -549,7 +546,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread"))
}
newThreadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), threadId, title, 1)
newThreadUrl := c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), threadId, title, 1)
return c.Redirect(newThreadUrl, http.StatusSeeOther)
}
@ -572,14 +569,14 @@ func ForumPostReply(c *RequestContext) ResponseData {
baseData := getBaseData(
c,
fmt.Sprintf("Replying to post | %s", cd.SubforumTree[cd.SubforumID].Name),
ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &post.Thread),
ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread),
)
replyPost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
replyPost.AddContentVersion(post.CurrentVersion, post.Editor)
editData := getEditorDataForNew(c.CurrentUser, baseData, &replyPost)
editData.SubmitUrl = hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, &replyPost)
editData.SubmitUrl = c.UrlContext.BuildForumPostReply(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Reply"
var res ResponseData
@ -629,7 +626,7 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
}
newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, newPostId)
newPostUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, newPostId)
return c.Redirect(newPostUrl, http.StatusSeeOther)
}
@ -659,10 +656,10 @@ func ForumPostEdit(c *RequestContext) ResponseData {
} else {
title = fmt.Sprintf("Editing Post | %s", cd.SubforumTree[cd.SubforumID].Name)
}
baseData := getBaseData(c, title, ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &post.Thread))
baseData := getBaseData(c, title, ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread))
editData := getEditorDataForEdit(c.CurrentUser, baseData, post)
editData.SubmitUrl = hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
editData := getEditorDataForEdit(c.UrlContext, c.CurrentUser, baseData, post)
editData.SubmitUrl = c.UrlContext.BuildForumPostEdit(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Edited Post"
var res ResponseData
@ -727,7 +724,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
}
postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
postUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
return c.Redirect(postUrl, http.StatusSeeOther)
}
@ -754,7 +751,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
baseData := getBaseData(
c,
fmt.Sprintf("Deleting post in \"%s\" | %s", post.Thread.Title, cd.SubforumTree[cd.SubforumID].Name),
ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &post.Thread),
ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread),
)
templatePost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
@ -769,7 +766,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
var res ResponseData
res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
SubmitUrl: c.UrlContext.BuildForumPostDelete(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
Post: templatePost,
}, c.Perf)
return res
@ -799,10 +796,10 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
}
if threadDeleted {
forumUrl := hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
forumUrl := c.UrlContext.BuildForum(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
return c.Redirect(forumUrl, http.StatusSeeOther)
} else {
threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
threadUrl := c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
return c.Redirect(threadUrl, http.StatusSeeOther)
}
}
@ -829,7 +826,7 @@ func WikiArticleRedirect(c *RequestContext) ResponseData {
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
dest := UrlForGenericThread(&thread.Thread, lineageBuilder, c.CurrentProject.Slug)
dest := UrlForGenericThread(c.UrlContext, &thread.Thread, lineageBuilder)
return c.Redirect(dest, http.StatusFound)
}
@ -928,11 +925,11 @@ func validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *m
return subforumId, valid
}
func addForumUrlsToPost(p *templates.Post, projectSlug string, subforums []string, threadId int, postId int) {
p.Url = hmnurl.BuildForumPost(projectSlug, subforums, threadId, postId)
p.DeleteUrl = hmnurl.BuildForumPostDelete(projectSlug, subforums, threadId, postId)
p.EditUrl = hmnurl.BuildForumPostEdit(projectSlug, subforums, threadId, postId)
p.ReplyUrl = hmnurl.BuildForumPostReply(projectSlug, subforums, threadId, postId)
func addForumUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, subforums []string, threadId int, postId int) {
p.Url = urlContext.BuildForumPost(subforums, threadId, postId)
p.DeleteUrl = urlContext.BuildForumPostDelete(subforums, threadId, postId)
p.EditUrl = urlContext.BuildForumPostEdit(subforums, threadId, postId)
p.ReplyUrl = urlContext.BuildForumPostReply(subforums, threadId, postId)
}
// Takes a template post and adds information about how many posts the user has made

View File

@ -4,9 +4,7 @@ import (
"net/http"
"time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
)
@ -20,35 +18,25 @@ func JamIndex(c *RequestContext) ResponseData {
daysUntil = 0
}
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
type snippetQuery struct {
Owner models.User `db:"owner"`
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
tagId := -1
jamTag, err := FetchTag(c.Context(), c.Conn, TagQuery{
Text: []string{"wheeljam"},
})
if err == nil {
tagId = jamTag.ID
} else {
c.Logger.Warn().Err(err).Msg("failed to fetch jam tag; will fetch all snippets as a result")
}
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
snippet.is_jam
ORDER BY snippet.when DESC
LIMIT 20
`,
)
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
Tags: []int{tagId},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam snippets"))
}
snippetQuerySlice := snippetQueryResult.ToSlice()
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
for _, s := range snippetQuerySlice {
row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem)
}

View File

@ -5,7 +5,6 @@ import (
"math"
"net/http"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
@ -74,7 +73,7 @@ func Index(c *RequestContext) ResponseData {
c.Logger.Warn().Err(err).Msg("failed to fetch latest posts")
}
for _, p := range posts {
item := PostToTimelineItem(lineageBuilder, &p.Post, &p.Thread, &p.Project, p.Author, c.Theme)
item := PostToTimelineItem(UrlContextForProject(&p.Project), lineageBuilder, &p.Post, &p.Thread, p.Author, c.Theme)
if p.Thread.Type == models.ThreadTypeProjectBlogPost && p.Post.ID == p.Thread.FirstID {
// blog post
item.Description = template.HTML(p.CurrentVersion.TextParsed)
@ -95,7 +94,7 @@ func Index(c *RequestContext) ResponseData {
var newsPostItem *templates.TimelineItem
if len(newsThreads) > 0 {
t := newsThreads[0]
item := PostToTimelineItem(lineageBuilder, &t.FirstPost, &t.Thread, &t.Project, t.FirstPostAuthor, c.Theme)
item := PostToTimelineItem(UrlContextForProject(&t.Project), lineageBuilder, &t.FirstPost, &t.Thread, t.FirstPostAuthor, c.Theme)
item.OwnerAvatarUrl = ""
item.Breadcrumbs = nil
item.TypeTitle = ""
@ -106,35 +105,15 @@ func Index(c *RequestContext) ResponseData {
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
type snippetQuery struct {
Owner models.User `db:"owner"`
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
NOT snippet.is_jam
ORDER BY snippet.when DESC
LIMIT 40
`,
)
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
Limit: 40,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
}
snippetQuerySlice := snippetQueryResult.ToSlice()
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
for _, s := range snippetQuerySlice {
row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem)
}
@ -167,7 +146,7 @@ func Index(c *RequestContext) ResponseData {
StreamsUrl: hmnurl.BuildStreams(),
ShowcaseUrl: hmnurl.BuildShowcase(),
AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.BuildForumMarkRead(models.HMNProjectSlug, 0),
MarkAllReadUrl: hmnurl.HMNProjectContext.BuildForumMarkRead(0),
WheelJamUrl: hmnurl.BuildJamIndex(),
}, c.Perf)

View File

@ -7,26 +7,26 @@ import (
)
// NOTE(asaf): Please don't use these if you already know the kind of the thread beforehand. Just call the appropriate build function.
func UrlForGenericThread(thread *models.Thread, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
func UrlForGenericThread(urlContext *hmnurl.UrlContext, thread *models.Thread, lineageBuilder *models.SubforumLineageBuilder) string {
switch thread.Type {
case models.ThreadTypeProjectBlogPost:
return hmnurl.BuildBlogThread(projectSlug, thread.ID, thread.Title)
return urlContext.BuildBlogThread(thread.ID, thread.Title)
case models.ThreadTypeForumPost:
return hmnurl.BuildForumThread(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1)
return urlContext.BuildForumThread(lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1)
}
return hmnurl.BuildProjectHomepage(projectSlug)
return urlContext.BuildHomepage()
}
func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
func UrlForGenericPost(urlContext *hmnurl.UrlContext, thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder) string {
switch post.ThreadType {
case models.ThreadTypeProjectBlogPost:
return hmnurl.BuildBlogThreadWithPostHash(projectSlug, post.ThreadID, thread.Title, post.ID)
return urlContext.BuildBlogThreadWithPostHash(post.ThreadID, thread.Title, post.ID)
case models.ThreadTypeForumPost:
return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
return urlContext.BuildForumPost(lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
}
return hmnurl.BuildProjectHomepage(projectSlug)
return urlContext.BuildHomepage()
}
var PostTypeMap = map[models.ThreadType][]templates.PostType{
@ -47,33 +47,33 @@ var ThreadTypeDisplayNames = map[models.ThreadType]string{
models.ThreadTypeForumPost: "Forums",
}
func GenericThreadBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, thread *models.Thread) []templates.Breadcrumb {
func GenericThreadBreadcrumbs(urlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, thread *models.Thread) []templates.Breadcrumb {
var result []templates.Breadcrumb
if thread.Type == models.ThreadTypeForumPost {
result = SubforumBreadcrumbs(lineageBuilder, project, *thread.SubforumID)
result = SubforumBreadcrumbs(urlContext, lineageBuilder, *thread.SubforumID)
} else {
result = []templates.Breadcrumb{
{
Name: project.Name,
Url: hmnurl.BuildProjectHomepage(project.Slug),
Name: urlContext.ProjectName,
Url: urlContext.BuildHomepage(),
},
{
Name: ThreadTypeDisplayNames[thread.Type],
Url: BuildProjectRootResourceUrl(project.Slug, thread.Type),
Url: BuildProjectRootResourceUrl(urlContext, thread.Type),
},
}
}
return result
}
func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) string {
func BuildProjectRootResourceUrl(urlContext *hmnurl.UrlContext, kind models.ThreadType) string {
switch kind {
case models.ThreadTypeProjectBlogPost:
return hmnurl.BuildBlog(projectSlug, 1)
return urlContext.BuildBlog(1)
case models.ThreadTypeForumPost:
return hmnurl.BuildForum(projectSlug, nil, 1)
return urlContext.BuildForum(nil, 1)
}
return hmnurl.BuildProjectHomepage(projectSlug)
return urlContext.BuildHomepage()
}
func MakePostListItem(
@ -88,11 +88,13 @@ func MakePostListItem(
) templates.PostListItem {
var result templates.PostListItem
urlContext := UrlContextForProject(project)
result.Title = thread.Title
result.User = templates.UserToTemplate(user, currentTheme)
result.Date = post.PostDate
result.Unread = unread
result.Url = UrlForGenericPost(thread, post, lineageBuilder, project.Slug)
result.Url = UrlForGenericPost(urlContext, thread, post, lineageBuilder)
result.Preview = post.Preview
postType := templates.PostTypeUnknown
@ -108,7 +110,7 @@ func MakePostListItem(
result.PostTypePrefix = PostTypePrefix[result.PostType]
if includeBreadcrumbs {
result.Breadcrumbs = GenericThreadBreadcrumbs(lineageBuilder, project, thread)
result.Breadcrumbs = GenericThreadBreadcrumbs(urlContext, lineageBuilder, thread)
}
return result

View File

@ -1,17 +1,246 @@
package website
import (
"context"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
)
type ProjectTypeQuery int
const (
PersonalProjects ProjectTypeQuery = 1 << iota
OfficialProjects
)
type ProjectsQuery struct {
// Available on all project queries
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles
Types ProjectTypeQuery // bitfield
IncludeHidden bool
// Ignored when using FetchProject
ProjectIDs []int // if empty, all projects
Slugs []string // if empty, all projects
// Ignored when using CountProjects
Limit, Offset int // if empty, no pagination
}
type ProjectAndStuff struct {
Project models.Project
Owners []*models.User
}
func FetchProjects(
ctx context.Context,
dbConn db.ConnOrTx,
currentUser *models.User,
q ProjectsQuery,
) ([]ProjectAndStuff, error) {
perf := ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch projects")
defer perf.EndBlock()
var currentUserID *int
if currentUser != nil {
currentUserID = &currentUser.ID
}
tx, err := dbConn.Begin(ctx)
if err != nil {
return nil, oops.New(err, "failed to start transaction")
}
defer tx.Rollback(ctx)
// Fetch all valid projects (not yet subject to user permission checks)
var qb db.QueryBuilder
qb.Add(`
SELECT $columns
FROM
handmade_project AS project
WHERE
TRUE
`)
if !q.IncludeHidden {
qb.Add(`AND NOT hidden`)
}
if len(q.ProjectIDs) > 0 {
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
}
if len(q.Slugs) > 0 {
qb.Add(`AND project.slug = ANY ($?)`, q.Slugs)
}
if len(q.Lifecycles) > 0 {
qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles)
} else {
qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles)
}
if q.Types != 0 {
qb.Add(`AND (FALSE`)
if q.Types&PersonalProjects != 0 {
qb.Add(`OR project.personal`)
}
if q.Types&OfficialProjects != 0 {
qb.Add(`OR NOT project.personal`)
}
qb.Add(`)`)
}
if q.Limit > 0 {
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
}
itProjects, err := db.Query(ctx, dbConn, models.Project{}, qb.String(), qb.Args()...)
if err != nil {
return nil, oops.New(err, "failed to fetch projects")
}
iprojects := itProjects.ToSlice()
// Fetch project owners to do permission checks
projectIds := make([]int, len(iprojects))
for i, iproject := range iprojects {
projectIds[i] = iproject.(*models.Project).ID
}
projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds)
if err != nil {
return nil, err
}
var res []ProjectAndStuff
for i, iproject := range iprojects {
project := iproject.(*models.Project)
owners := projectOwners[i].Owners
/*
Per our spec, a user can see a project if:
- The project is official
- The project is personal and all of the project's owners are approved
- The project is personal and the current user is a collaborator (regardless of user status)
See https://www.notion.so/handmade-network/Technical-Plan-a11aaa9ea2f14d9a95f7d7780edd789c
*/
var projectVisible bool
if project.Personal {
allOwnersApproved := true
for _, owner := range owners {
if owner.Status != models.UserStatusApproved {
allOwnersApproved = false
}
if currentUserID != nil && *currentUserID == owner.ID {
projectVisible = true
}
}
if allOwnersApproved {
projectVisible = true
}
} else {
projectVisible = true
}
if projectVisible {
res = append(res, ProjectAndStuff{
Project: *project,
Owners: owners,
})
}
}
err = tx.Commit(ctx)
if err != nil {
return nil, oops.New(err, "failed to commit transaction")
}
return res, nil
}
/*
Fetches a single project. A wrapper around FetchProjects.
Returns db.NotFound if no result is found.
*/
func FetchProject(
ctx context.Context,
dbConn db.ConnOrTx,
currentUser *models.User,
projectID int,
q ProjectsQuery,
) (ProjectAndStuff, error) {
q.ProjectIDs = []int{projectID}
q.Limit = 1
q.Offset = 0
res, err := FetchProjects(ctx, dbConn, currentUser, q)
if err != nil {
return ProjectAndStuff{}, oops.New(err, "failed to fetch project")
}
if len(res) == 0 {
return ProjectAndStuff{}, db.NotFound
}
return res[0], nil
}
/*
Fetches a single project by slug. A wrapper around FetchProjects.
Returns db.NotFound if no result is found.
*/
func FetchProjectBySlug(
ctx context.Context,
dbConn db.ConnOrTx,
currentUser *models.User,
projectSlug string,
q ProjectsQuery,
) (ProjectAndStuff, error) {
q.Slugs = []string{projectSlug}
q.Limit = 1
q.Offset = 0
res, err := FetchProjects(ctx, dbConn, currentUser, q)
if err != nil {
return ProjectAndStuff{}, oops.New(err, "failed to fetch project")
}
if len(res) == 0 {
return ProjectAndStuff{}, db.NotFound
}
return res[0], nil
}
func CountProjects(
ctx context.Context,
dbConn db.ConnOrTx,
currentUser *models.User,
q ProjectsQuery,
) (int, error) {
perf := ExtractPerf(ctx)
perf.StartBlock("SQL", "Count projects")
defer perf.EndBlock()
q.Limit = 0
q.Offset = 0
// I'm lazy and there probably won't ever be that many projects.
projects, err := FetchProjects(ctx, dbConn, currentUser, q)
if err != nil {
return 0, oops.New(err, "failed to fetch projects")
}
return len(projects), nil
}
func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, error) {
if user != nil {
if user.IsStaff {
return true, nil
} else {
owners, err := FetchProjectOwners(c, projectId)
owners, err := FetchProjectOwners(c.Context(), c.Conn, projectId)
if err != nil {
return false, err
}
@ -25,29 +254,146 @@ func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool,
return false, nil
}
func FetchProjectOwners(c *RequestContext, projectId int) ([]*models.User, error) {
var result []*models.User
c.Perf.StartBlock("SQL", "Fetching project owners")
type ownerQuery struct {
Owner models.User `db:"auth_user"`
type ProjectOwners struct {
ProjectID int
Owners []*models.User
}
ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{},
// Fetches all owners for multiple projects. Does NOT check permissions on the
// project IDs, since the assumption is that you will check permissions on the
// projects themselves before using any of this data.
//
// The returned slice will always have one entry for each project ID given, in
// the same order as they were provided. If there are duplicate project IDs in
// projectIds, the results will be wrong, so don't do that.
//
// This function does not verify that the requested projects do in fact exist.
func FetchMultipleProjectsOwners(
ctx context.Context,
dbConn db.ConnOrTx,
projectIds []int,
) ([]ProjectOwners, error) {
perf := ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch owners for multiple projects")
defer perf.EndBlock()
tx, err := dbConn.Begin(ctx)
if err != nil {
return nil, oops.New(err, "failed to start transaction")
}
defer tx.Rollback(ctx)
// Fetch all user/project pairs for the given projects
type userProject struct {
UserID int `db:"user_id"`
ProjectID int `db:"project_id"`
}
it, err := db.Query(ctx, tx, userProject{},
`
SELECT $columns
FROM
auth_user
INNER JOIN handmade_user_projects AS uproj ON uproj.user_id = auth_user.id
WHERE
uproj.project_id = $1
FROM handmade_user_projects
WHERE project_id = ANY($1)
`,
projectId,
projectIds,
)
c.Perf.EndBlock()
if err != nil {
return result, oops.New(err, "failed to fetch owners for project")
return nil, oops.New(err, "failed to fetch project IDs")
}
for _, ownerRow := range ownerQueryResult.ToSlice() {
result = append(result, &ownerRow.(*ownerQuery).Owner)
iuserprojects := it.ToSlice()
// Get the unique user IDs from this set and fetch the users from the db
var userIds []int
for _, iuserproject := range iuserprojects {
userProject := iuserproject.(*userProject)
addUserId := true
for _, uid := range userIds {
if uid == userProject.UserID {
addUserId = false
}
}
if addUserId {
userIds = append(userIds, userProject.UserID)
}
}
it, err = db.Query(ctx, tx, models.User{},
`
SELECT $columns
FROM auth_user
WHERE
id = ANY($1)
`,
userIds,
)
if err != nil {
return nil, oops.New(err, "failed to fetch users for projects")
}
iusers := it.ToSlice()
// Build the final result set with real user data
res := make([]ProjectOwners, len(projectIds))
for i, pid := range projectIds {
res[i] = ProjectOwners{ProjectID: pid}
}
for _, iuserproject := range iuserprojects {
userProject := iuserproject.(*userProject)
// Get a pointer to the existing record in the result
var projectOwners *ProjectOwners
for i := range res {
if res[i].ProjectID == userProject.ProjectID {
projectOwners = &res[i]
}
}
// Get the full user record we fetched
var user *models.User
for _, iuser := range iusers {
u := iuser.(*models.User)
if u.ID == userProject.UserID {
user = u
}
}
if user == nil {
panic("we apparently failed to fetch a project's owner")
}
// Slam 'em together
projectOwners.Owners = append(projectOwners.Owners, user)
}
err = tx.Commit(ctx)
if err != nil {
return nil, oops.New(err, "failed to commit transaction")
}
return res, nil
}
// Fetches project owners for a single project. It is subject to all the same
// restrictions as FetchMultipleProjectsOwners.
func FetchProjectOwners(
ctx context.Context,
dbConn db.ConnOrTx,
projectId int,
) ([]*models.User, error) {
perf := ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch owners for project")
defer perf.EndBlock()
projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, []int{projectId})
if err != nil {
return nil, err
}
return projectOwners[0].Owners, nil
}
func UrlContextForProject(p *models.Project) *hmnurl.UrlContext {
return &hmnurl.UrlContext{
PersonalProject: p.Personal,
ProjectID: p.ID,
ProjectSlug: p.Slug,
ProjectName: p.Name,
}
return result, nil
}

View File

@ -1,13 +1,11 @@
package website
import (
"errors"
"fmt"
"math"
"math/rand"
"net/http"
"strconv"
"strings"
"sort"
"time"
"git.handmade.network/hmn/hmn/src/db"
@ -24,10 +22,7 @@ type ProjectTemplateData struct {
Pagination templates.Pagination
CarouselProjects []templates.Project
Projects []templates.Project
UserPendingProjectUnderReview bool
UserPendingProject *templates.Project
UserApprovedProjects []templates.Project
PersonalProjects []templates.Project
ProjectAtomFeedUrl string
WIPForumUrl string
@ -36,47 +31,19 @@ type ProjectTemplateData struct {
func ProjectIndex(c *RequestContext) ResponseData {
const projectsPerPage = 20
const maxCarouselProjects = 10
const maxPersonalProjects = 10
page := 1
pageString, hasPage := c.PathParams["page"]
if hasPage && pageString != "" {
if pageParsed, err := strconv.Atoi(pageString); err == nil {
page = pageParsed
} else {
return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
}
}
if page < 1 {
return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
}
c.Perf.StartBlock("SQL", "Fetching all visible projects")
type projectResult struct {
Project models.Project `db:"project"`
}
allProjects, err := db.Query(c.Context(), c.Conn, projectResult{},
`
SELECT $columns
FROM
handmade_project AS project
WHERE
project.lifecycle = ANY($1)
AND project.flags = 0
ORDER BY project.date_approved ASC
`,
models.VisibleProjectLifecycles,
)
officialProjects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{
Types: OfficialProjects,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects"))
}
allProjectsSlice := allProjects.ToSlice()
c.Perf.EndBlock()
numPages := int(math.Ceil(float64(len(allProjectsSlice)) / projectsPerPage))
if page > numPages {
return c.Redirect(hmnurl.BuildProjectIndex(numPages), http.StatusSeeOther)
numPages := int(math.Ceil(float64(len(officialProjects)) / projectsPerPage))
page, numPages, ok := getPageInfo(c.PathParams["page"], len(officialProjects), feedPostsPerPage)
if !ok {
return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
}
pagination := templates.Pagination{
@ -89,63 +56,22 @@ func ProjectIndex(c *RequestContext) ResponseData {
PreviousUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page-1, numPages)),
}
var userApprovedProjects []templates.Project
var userPendingProject *templates.Project
userPendingProjectUnderReview := false
if c.CurrentUser != nil {
c.Perf.StartBlock("SQL", "fetching user projects")
type UserProjectQuery struct {
Project models.Project `db:"project"`
}
userProjectsResult, err := db.Query(c.Context(), c.Conn, UserProjectQuery{},
`
SELECT $columns
FROM
handmade_project AS project
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
WHERE
uproj.user_id = $1
`,
c.CurrentUser.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects"))
}
for _, project := range userProjectsResult.ToSlice() {
p := project.(*UserProjectQuery).Project
if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired {
if userPendingProject == nil {
// NOTE(asaf): Technically a user could have more than one pending project.
// For example, if they created one project themselves and were added as an additional owner to another user's project.
// So we'll just take the first one. I don't think it matters. I guess it especially won't matter after Projects 2.0.
tmplProject := templates.ProjectToTemplate(&p, c.Theme)
userPendingProject = &tmplProject
userPendingProjectUnderReview = (p.Lifecycle == models.ProjectLifecycleApprovalRequired)
}
} else {
userApprovedProjects = append(userApprovedProjects, templates.ProjectToTemplate(&p, c.Theme))
}
}
c.Perf.EndBlock()
}
c.Perf.StartBlock("PROJECTS", "Grouping and sorting")
var handmadeHero *templates.Project
var featuredProjects []templates.Project
var recentProjects []templates.Project
var restProjects []templates.Project
now := time.Now()
for _, p := range allProjectsSlice {
project := &p.(*projectResult).Project
templateProject := templates.ProjectToTemplate(project, c.Theme)
if project.Slug == "hero" {
for _, p := range officialProjects {
templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
if p.Project.Slug == "hero" {
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
handmadeHero = &templateProject
continue
}
if project.Featured {
if p.Project.Featured {
featuredProjects = append(featuredProjects, templateProject)
} else if now.Sub(project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec {
} else if now.Sub(p.Project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec {
recentProjects = append(recentProjects, templateProject)
} else {
restProjects = append(restProjects, templateProject)
@ -178,6 +104,32 @@ func ProjectIndex(c *RequestContext) ResponseData {
}
c.Perf.EndBlock()
// Fetch and highlight a random selection of personal projects
var personalProjects []templates.Project
{
projects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{
Types: PersonalProjects,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal projects"))
}
randSeed := now.YearDay()
random := rand.New(rand.NewSource(int64(randSeed)))
random.Shuffle(len(projects), func(i, j int) { projects[i], projects[j] = projects[j], projects[i] })
for i, p := range projects {
if i >= maxPersonalProjects {
break
}
personalProjects = append(personalProjects, templates.ProjectToTemplate(
&p.Project,
UrlContextForProject(&p.Project).BuildHomepage(),
c.Theme,
))
}
}
baseData := getBaseDataAutocrumb(c, "Projects")
var res ResponseData
res.MustWriteTemplate("project_index.html", ProjectTemplateData{
@ -186,13 +138,10 @@ func ProjectIndex(c *RequestContext) ResponseData {
Pagination: pagination,
CarouselProjects: carouselProjects,
Projects: pageProjects,
UserPendingProjectUnderReview: userPendingProjectUnderReview,
UserPendingProject: userPendingProject,
UserApprovedProjects: userApprovedProjects,
PersonalProjects: personalProjects,
ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(),
WIPForumUrl: hmnurl.BuildForum(models.HMNProjectSlug, []string{"wip"}, 1),
WIPForumUrl: hmnurl.HMNProjectContext.BuildForum([]string{"wip"}, 1),
}, c.Perf)
return res
}
@ -209,86 +158,19 @@ type ProjectHomepageData struct {
func ProjectHomepage(c *RequestContext) ResponseData {
maxRecentActivity := 15
var project *models.Project
if c.CurrentProject.IsHMN() {
slug, hasSlug := c.PathParams["slug"]
if hasSlug && slug != "" {
slug = strings.ToLower(slug)
if slug == models.HMNProjectSlug {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
c.Perf.StartBlock("SQL", "Fetching project by slug")
type projectQuery struct {
Project models.Project `db:"Project"`
}
projectQueryResult, err := db.QueryOne(c.Context(), c.Conn, projectQuery{},
`
SELECT $columns
FROM
handmade_project AS project
WHERE
LOWER(project.slug) = $1
`,
slug,
)
c.Perf.EndBlock()
if err != nil {
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project by slug"))
}
}
project = &projectQueryResult.(*projectQuery).Project
if project.Lifecycle != models.ProjectLifecycleUnapproved && project.Lifecycle != models.ProjectLifecycleApprovalRequired {
return c.Redirect(hmnurl.BuildProjectHomepage(project.Slug), http.StatusSeeOther)
}
}
} else {
project = c.CurrentProject
}
if project == nil {
if c.CurrentProject == nil {
return FourOhFour(c)
}
owners, err := FetchProjectOwners(c, project.ID)
// There are no further permission checks to do, because permissions are
// checked whatever way we fetch the project.
owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
canView := false
canEdit := false
if c.CurrentUser != nil {
if c.CurrentUser.IsStaff {
canView = true
canEdit = true
} else {
for _, owner := range owners {
if owner.ID == c.CurrentUser.ID {
canView = true
canEdit = true
break
}
}
}
}
if !canView {
if project.Flags == 0 {
for _, lc := range models.VisibleProjectLifecycles {
if project.Lifecycle == lc {
canView = true
break
}
}
}
}
if !canView {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetching screenshots")
type screenshotQuery struct {
Filename string `db:"screenshot.file"`
@ -302,7 +184,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
WHERE
handmade_project_screenshots.project_id = $1
`,
project.ID,
c.CurrentProject.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project"))
@ -322,7 +204,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
link.project_id = $1
ORDER BY link.ordering ASC
`,
project.ID,
c.CurrentProject.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
@ -352,7 +234,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
ORDER BY post.postdate DESC
LIMIT $2
`,
project.ID,
c.CurrentProject.ID,
maxRecentActivity,
)
if err != nil {
@ -360,62 +242,62 @@ func ProjectHomepage(c *RequestContext) ResponseData {
}
c.Perf.EndBlock()
var projectHomepageData ProjectHomepageData
var templateData ProjectHomepageData
projectHomepageData.BaseData = getBaseData(c, project.Name, nil)
if canEdit {
// TODO: Move to project-specific navigation
// projectHomepageData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "")
}
projectHomepageData.BaseData.OpenGraphItems = append(projectHomepageData.BaseData.OpenGraphItems, templates.OpenGraphItem{
templateData.BaseData = getBaseData(c, c.CurrentProject.Name, nil)
//if canEdit {
// // TODO: Move to project-specific navigation
// // templateData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "")
//}
templateData.BaseData.OpenGraphItems = append(templateData.BaseData.OpenGraphItems, templates.OpenGraphItem{
Property: "og:description",
Value: project.Blurb,
Value: c.CurrentProject.Blurb,
})
projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme)
templateData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage(), c.Theme)
for _, owner := range owners {
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme))
}
if project.Flags == 1 {
projectHomepageData.BaseData.AddImmediateNotice(
if c.CurrentProject.Hidden {
templateData.BaseData.AddImmediateNotice(
"hidden",
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
)
}
if project.Lifecycle != models.ProjectLifecycleActive {
switch project.Lifecycle {
if c.CurrentProject.Lifecycle != models.ProjectLifecycleActive {
switch c.CurrentProject.Lifecycle {
case models.ProjectLifecycleUnapproved:
projectHomepageData.BaseData.AddImmediateNotice(
templateData.BaseData.AddImmediateNotice(
"unapproved",
fmt.Sprintf(
"NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please <a href=\"%s\">submit it for approval</a> when the project content is ready for review.",
hmnurl.BuildProjectEdit(project.Slug, "submit"),
c.UrlContext.BuildProjectEdit("submit"),
),
)
case models.ProjectLifecycleApprovalRequired:
projectHomepageData.BaseData.AddImmediateNotice(
templateData.BaseData.AddImmediateNotice(
"unapproved",
"NOTICE: This project is awaiting approval. It is only visible to owners and site admins.",
)
case models.ProjectLifecycleHiatus:
projectHomepageData.BaseData.AddImmediateNotice(
templateData.BaseData.AddImmediateNotice(
"hiatus",
"NOTICE: This project is on hiatus and may not update for a while.",
)
case models.ProjectLifecycleDead:
projectHomepageData.BaseData.AddImmediateNotice(
templateData.BaseData.AddImmediateNotice(
"dead",
"NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.",
)
case models.ProjectLifecycleLTSRequired:
projectHomepageData.BaseData.AddImmediateNotice(
templateData.BaseData.AddImmediateNotice(
"lts-reqd",
"NOTICE: This project is awaiting approval for maintenance-mode status.",
)
case models.ProjectLifecycleLTS:
projectHomepageData.BaseData.AddImmediateNotice(
templateData.BaseData.AddImmediateNotice(
"lts",
"NOTICE: This project has reached a state of completion.",
)
@ -423,26 +305,56 @@ func ProjectHomepage(c *RequestContext) ResponseData {
}
for _, screenshot := range screenshotQueryResult.ToSlice() {
projectHomepageData.Screenshots = append(projectHomepageData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename))
templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename))
}
for _, link := range projectLinkResult.ToSlice() {
projectHomepageData.ProjectLinks = append(projectHomepageData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link))
templateData.ProjectLinks = append(templateData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link))
}
for _, post := range postQueryResult.ToSlice() {
projectHomepageData.RecentActivity = append(projectHomepageData.RecentActivity, PostToTimelineItem(
templateData.RecentActivity = append(templateData.RecentActivity, PostToTimelineItem(
c.UrlContext,
lineageBuilder,
&post.(*postQuery).Post,
&post.(*postQuery).Thread,
project,
&post.(*postQuery).Author,
c.Theme,
))
}
tagId := -1
if c.CurrentProject.TagID != nil {
tagId = *c.CurrentProject.TagID
}
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
Tags: []int{tagId},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project snippets"))
}
for _, s := range snippets {
item := SnippetToTimelineItem(
&s.Snippet,
s.Asset,
s.DiscordMessage,
s.Tags,
s.Owner,
c.Theme,
)
item.SmallInfo = true
templateData.RecentActivity = append(templateData.RecentActivity, item)
}
c.Perf.StartBlock("PROFILE", "Sort timeline")
sort.Slice(templateData.RecentActivity, func(i, j int) bool {
return templateData.RecentActivity[j].Date.Before(templateData.RecentActivity[i].Date)
})
c.Perf.EndBlock()
var res ResponseData
err = res.WriteTemplate("project_homepage.html", projectHomepageData, c.Perf)
err = res.WriteTemplate("project_homepage.html", templateData, c.Perf)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render project homepage template"))
}

View File

@ -30,12 +30,21 @@ type Router struct {
type Route struct {
Method string
Regex *regexp.Regexp
Regexes []*regexp.Regexp
Handler Handler
}
func (r *Route) String() string {
var routeStrings []string
for _, regex := range r.Regexes {
routeStrings = append(routeStrings, regex.String())
}
return fmt.Sprintf("%s %v", r.Method, routeStrings)
}
type RouteBuilder struct {
Router *Router
Prefixes []*regexp.Regexp
Middleware Middleware
}
@ -44,11 +53,17 @@ type Handler func(c *RequestContext) ResponseData
type Middleware func(h Handler) Handler
func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) {
// Ensure that this regex matches the start of the string
regexStr := regex.String()
if len(regexStr) == 0 || regexStr[0] != '^' {
panic("All routing regexes must begin with '^'")
}
h = rb.Middleware(h)
for _, method := range methods {
rb.Router.Routes = append(rb.Router.Routes, Route{
Method: method,
Regex: regex,
Regexes: append(rb.Prefixes, regex),
Handler: h,
})
}
@ -66,49 +81,72 @@ func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) {
rb.Handle([]string{http.MethodPost}, regex, h)
}
func (rb *RouteBuilder) Group(regex *regexp.Regexp, addRoutes func(rb *RouteBuilder)) {
newRb := *rb
newRb.Prefixes = append(newRb.Prefixes, regex)
addRoutes(&newRb)
}
func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
path := req.URL.Path
nextroute:
for _, route := range r.Routes {
if route.Method != "" && req.Method != route.Method {
continue
}
path = strings.TrimSuffix(path, "/")
if path == "" {
path = "/"
currentPath := strings.TrimSuffix(req.URL.Path, "/")
if currentPath == "" {
currentPath = "/"
}
match := route.Regex.FindStringSubmatch(path)
if match == nil {
continue
var params map[string]string
for _, regex := range route.Regexes {
match := regex.FindStringSubmatch(currentPath)
if len(match) == 0 {
continue nextroute
}
c := &RequestContext{
Route: route.Regex.String(),
Logger: logging.GlobalLogger(),
Req: req,
Res: rw,
if params == nil {
params = map[string]string{}
}
if len(match) > 0 {
params := map[string]string{}
subexpNames := route.Regex.SubexpNames()
subexpNames := regex.SubexpNames()
for i, paramValue := range match {
paramName := subexpNames[i]
if paramName == "" {
continue
}
if _, alreadyExists := params[paramName]; alreadyExists {
logging.Warn().
Str("route", route.String()).
Str("paramName", paramName).
Msg("duplicate names for path parameters; last one wins")
}
params[paramName] = paramValue
}
c.PathParams = params
// Make sure that we never consume trailing slashes even if the route regex matches them
toConsume := strings.TrimSuffix(match[0], "/")
currentPath = currentPath[len(toConsume):]
if currentPath == "" {
currentPath = "/"
}
}
c := &RequestContext{
Route: route.String(),
Logger: logging.GlobalLogger(),
Req: req,
Res: rw,
PathParams: params,
}
c.PathParams = params
doRequest(rw, c, route.Handler)
return
}
panic(fmt.Sprintf("Path '%s' did not match any routes! Make sure to register a wildcard route to act as a 404.", path))
panic(fmt.Sprintf("Path '%s' did not match any routes! Make sure to register a wildcard route to act as a 404.", req.URL))
}
type RequestContext struct {
@ -126,6 +164,7 @@ type RequestContext struct {
CurrentUser *models.User
CurrentSession *models.Session
Theme string
UrlContext *hmnurl.UrlContext
Perf *perf.RequestPerf

View File

@ -8,6 +8,8 @@ import (
"math/rand"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
@ -154,14 +156,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
return res
})
anyProject.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
if c.CurrentProject.IsHMN() {
return Index(c)
} else {
return ProjectHomepage(c)
}
})
// NOTE(asaf): HMN-only routes:
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
hmnOnly.GET(hmnurl.RegexAbout, About)
@ -175,14 +169,13 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
hmnOnly.GET(hmnurl.RegexOldHome, Index)
hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login)) // TODO(asaf): Adjust this after launch
hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login))
hmnOnly.GET(hmnurl.RegexLogoutAction, Logout)
hmnOnly.GET(hmnurl.RegexLoginPage, LoginPage)
hmnOnly.GET(hmnurl.RegexRegister, RegisterNewUser)
hmnOnly.POST(hmnurl.RegexRegister, securityTimerMiddleware(email.ExpectedEmailSendDuration, RegisterNewUserSubmit))
hmnOnly.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess)
hmnOnly.GET(hmnurl.RegexOldEmailConfirmation, EmailConfirmation) // TODO(asaf): Delete this a bit after launch
hmnOnly.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
hmnOnly.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
@ -202,7 +195,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
hmnOnly.GET(hmnurl.RegexShowcase, Showcase)
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
hmnOnly.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
@ -224,38 +216,122 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
// NOTE(asaf): Any-project routes:
anyProject.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
anyProject.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
anyProject.GET(hmnurl.RegexForumThread, ForumThread)
anyProject.GET(hmnurl.RegexForum, Forum)
anyProject.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead)))
anyProject.GET(hmnurl.RegexForumPost, ForumPostRedirect)
anyProject.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
anyProject.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit)))
anyProject.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
anyProject.POST(hmnurl.RegexForumPostEdit, authMiddleware(csrfMiddleware(ForumPostEditSubmit)))
anyProject.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
anyProject.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
anyProject.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect)
attachProjectRoutes := func(rb *RouteBuilder) {
rb.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
if c.CurrentProject.IsHMN() {
return Index(c)
} else {
return ProjectHomepage(c)
}
})
anyProject.GET(hmnurl.RegexBlog, BlogIndex)
anyProject.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread))
anyProject.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit)))
anyProject.GET(hmnurl.RegexBlogThread, BlogThread)
anyProject.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
anyProject.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply))
anyProject.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit)))
anyProject.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit))
anyProject.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit)))
anyProject.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete))
anyProject.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit)))
anyProject.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.ProjectUrl(
// Middleware used for forum action routes - anything related to actually creating or editing forum content
needsForums := func(h Handler) Handler {
return func(c *RequestContext) ResponseData {
// 404 if the project has forums disabled
if !c.CurrentProject.HasForums() {
return FourOhFour(c)
}
// Require auth if forums are enabled
return authMiddleware(h)(c)
}
}
rb.POST(hmnurl.RegexForumNewThreadSubmit, needsForums(csrfMiddleware(ForumNewThreadSubmit)))
rb.GET(hmnurl.RegexForumNewThread, needsForums(ForumNewThread))
rb.GET(hmnurl.RegexForumThread, ForumThread)
rb.GET(hmnurl.RegexForum, Forum)
rb.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead))) // needs auth but doesn't need forums enabled
rb.GET(hmnurl.RegexForumPost, ForumPostRedirect)
rb.GET(hmnurl.RegexForumPostReply, needsForums(ForumPostReply))
rb.POST(hmnurl.RegexForumPostReply, needsForums(csrfMiddleware(ForumPostReplySubmit)))
rb.GET(hmnurl.RegexForumPostEdit, needsForums(ForumPostEdit))
rb.POST(hmnurl.RegexForumPostEdit, needsForums(csrfMiddleware(ForumPostEditSubmit)))
rb.GET(hmnurl.RegexForumPostDelete, needsForums(ForumPostDelete))
rb.POST(hmnurl.RegexForumPostDelete, needsForums(csrfMiddleware(ForumPostDeleteSubmit)))
rb.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect)
// Middleware used for blog action routes - anything related to actually creating or editing blog content
needsBlogs := func(h Handler) Handler {
return func(c *RequestContext) ResponseData {
// 404 if the project has blogs disabled
if !c.CurrentProject.HasBlog() {
return FourOhFour(c)
}
// Require auth if blogs are enabled
return authMiddleware(h)(c)
}
}
rb.GET(hmnurl.RegexBlog, BlogIndex)
rb.GET(hmnurl.RegexBlogNewThread, needsBlogs(BlogNewThread))
rb.POST(hmnurl.RegexBlogNewThread, needsBlogs(csrfMiddleware(BlogNewThreadSubmit)))
rb.GET(hmnurl.RegexBlogThread, BlogThread)
rb.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
rb.GET(hmnurl.RegexBlogPostReply, needsBlogs(BlogPostReply))
rb.POST(hmnurl.RegexBlogPostReply, needsBlogs(csrfMiddleware(BlogPostReplySubmit)))
rb.GET(hmnurl.RegexBlogPostEdit, needsBlogs(BlogPostEdit))
rb.POST(hmnurl.RegexBlogPostEdit, needsBlogs(csrfMiddleware(BlogPostEditSubmit)))
rb.GET(hmnurl.RegexBlogPostDelete, needsBlogs(BlogPostDelete))
rb.POST(hmnurl.RegexBlogPostDelete, needsBlogs(csrfMiddleware(BlogPostDeleteSubmit)))
rb.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData {
return c.Redirect(c.UrlContext.Url(
fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil,
c.CurrentProject.Slug,
), http.StatusMovedPermanently)
})
}
hmnOnly.Group(hmnurl.RegexPersonalProject, func(rb *RouteBuilder) {
// TODO(ben): Perhaps someday we can make this middleware modification feel better? It seems
// pretty common to run the outermost middleware first before doing other stuff, but having
// to nest functions this way feels real bad.
rb.Middleware = func(h Handler) Handler {
return hmnOnly.Middleware(func(c *RequestContext) ResponseData {
// At this point we are definitely on the plain old HMN subdomain.
// Fetch personal project and do whatever
id, err := strconv.Atoi(c.PathParams["projectid"])
if err != nil {
panic(oops.New(err, "project id was not numeric (bad regex in routing)"))
}
p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{})
if err != nil {
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal project"))
}
}
c.CurrentProject = &p.Project
c.UrlContext = UrlContextForProject(c.CurrentProject)
if !p.Project.Personal {
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
}
if c.PathParams["projectslug"] != models.GeneratePersonalProjectSlug(p.Project.Name) {
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
}
return h(c)
})
}
attachProjectRoutes(rb)
})
anyProject.Group(regexp.MustCompile("^"), func(rb *RouteBuilder) {
rb.Middleware = func(h Handler) Handler {
return anyProject.Middleware(func(c *RequestContext) ResponseData {
// We could be on any project's subdomain.
// Check if the current project (matched by subdomain) is actually no longer official
// and therefore needs to be redirected to the personal project version of the route.
if c.CurrentProject.Personal {
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
}
return h(c)
})
}
attachProjectRoutes(rb)
})
anyProject.POST(hmnurl.RegexAssetUpload, AssetUpload)
@ -277,31 +353,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
return router
}
func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) {
if len(slug) > 0 && slug != models.HMNProjectSlug {
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.NotFound) {
return nil, oops.New(err, "failed to get projects by slug")
} else {
return nil, nil
}
} else {
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.NotFound) {
return nil, oops.New(nil, "default project didn't exist in the database")
} else {
return nil, oops.New(err, "failed to get default project")
}
}
defaultProject := defaultProjectRow.(*models.Project)
return defaultProject, nil
}
}
func ProjectCSS(c *RequestContext) ResponseData {
color := c.URL().Query().Get("color")
if color == "" {
@ -382,22 +433,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
defer c.Perf.EndBlock()
// get project
{
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
slug := strings.TrimRight(hostPrefix, ".")
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, slug)
if err != nil {
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
}
if dbProject == nil {
return false, c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
c.CurrentProject = dbProject
}
// get user
{
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
if err == nil {
@ -412,12 +448,43 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
}
theme := "light"
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
theme = "dark"
// get official project
{
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
slug := strings.TrimRight(hostPrefix, ".")
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{})
if err == nil {
c.CurrentProject = &dbProject.Project
} else {
if errors.Is(err, db.NotFound) {
// do nothing, this is fine
} else {
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
}
}
c.Theme = theme
if c.CurrentProject == nil {
dbProject, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, ProjectsQuery{
IncludeHidden: true,
})
if err != nil {
panic(oops.New(err, "failed to fetch HMN project"))
}
c.CurrentProject = &dbProject.Project
}
if c.CurrentProject == nil {
panic("failed to load project data")
}
c.UrlContext = UrlContextForProject(c.CurrentProject)
}
c.Theme = "light"
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
c.Theme = "dark"
}
return true, ResponseData{}
}

View File

@ -3,9 +3,7 @@ package website
import (
"net/http"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
)
@ -17,34 +15,14 @@ type ShowcaseData struct {
}
func Showcase(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
type snippetQuery struct {
Owner models.User `db:"owner"`
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
NOT snippet.is_jam
ORDER BY snippet.when DESC
`,
)
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
}
snippetQuerySlice := snippetQueryResult.ToSlice()
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
for _, s := range snippetQuerySlice {
row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem)
}

View File

@ -7,7 +7,6 @@ import (
"strconv"
"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"
)
@ -30,25 +29,7 @@ func Snippet(c *RequestContext) ResponseData {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetch snippet")
type snippetQuery struct {
Owner models.User `db:"owner"`
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
snippetQueryResult, err := db.QueryOne(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE snippet.id = $1
`,
snippetId,
)
s, err := FetchSnippet(c.Context(), c.Conn, c.CurrentUser, snippetId, SnippetQuery{})
if err != nil {
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
@ -58,9 +39,7 @@ func Snippet(c *RequestContext) ResponseData {
}
c.Perf.EndBlock()
snippetData := snippetQueryResult.(*snippetQuery)
snippet := SnippetToTimelineItem(&snippetData.Snippet, snippetData.Asset, snippetData.DiscordMessage, &snippetData.Owner, c.Theme)
snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
opengraph := []templates.OpenGraphItem{
{Property: "og:site_name", Value: "Handmade.Network"},

View File

@ -0,0 +1,211 @@
package website
import (
"context"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
)
type SnippetQuery struct {
IDs []int
OwnerIDs []int
Tags []int
Limit, Offset int // if empty, no pagination
}
type SnippetAndStuff struct {
Snippet models.Snippet
Owner *models.User
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
Tags []*models.Tag
}
func FetchSnippets(
ctx context.Context,
dbConn db.ConnOrTx,
currentUser *models.User,
q SnippetQuery,
) ([]SnippetAndStuff, error) {
perf := ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch snippets")
defer perf.EndBlock()
tx, err := dbConn.Begin(ctx)
if err != nil {
return nil, oops.New(err, "failed to start transaction")
}
defer tx.Rollback(ctx)
var qb db.QueryBuilder
qb.Add(
`
SELECT $columns
FROM
handmade_snippet AS snippet
LEFT JOIN auth_user AS owner ON snippet.owner_id = owner.id
LEFT JOIN handmade_asset AS asset ON snippet.asset_id = asset.id
LEFT JOIN handmade_discordmessage AS discord_message ON snippet.discord_message_id = discord_message.id
LEFT JOIN snippet_tags ON snippet.id = snippet_tags.snippet_id
LEFT JOIN tags ON snippet_tags.tag_id = tags.id
WHERE
TRUE
`,
)
if len(q.IDs) > 0 {
qb.Add(`AND snippet.id = ANY ($?)`, q.IDs)
}
if len(q.OwnerIDs) > 0 {
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
}
if len(q.Tags) > 0 {
qb.Add(`AND snippet_tags.tag_id = ANY ($?)`, q.Tags)
}
if currentUser == nil {
qb.Add(
`AND owner.status = $? -- snippet owner is Approved`,
models.UserStatusApproved,
)
} else if !currentUser.IsStaff {
qb.Add(
`
AND (
owner.status = $? -- snippet owner is Approved
OR owner.id = $? -- current user is the snippet owner
)
`,
models.UserStatusApproved,
currentUser.ID,
)
}
qb.Add(`ORDER BY snippet.when DESC, snippet.id ASC`)
if q.Limit > 0 {
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
}
type resultRow struct {
Snippet models.Snippet `db:"snippet"`
Owner *models.User `db:"owner"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
Tag *models.Tag `db:"tags"`
}
it, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
if err != nil {
return nil, oops.New(err, "failed to fetch threads")
}
iresults := it.ToSlice()
result := make([]SnippetAndStuff, 0, len(iresults)) // allocate extra space because why not
currentSnippetId := -1
for _, iresult := range iresults {
row := *iresult.(*resultRow)
if row.Snippet.ID != currentSnippetId {
// we have moved onto a new snippet; make a new entry
result = append(result, SnippetAndStuff{
Snippet: row.Snippet,
Owner: row.Owner,
Asset: row.Asset,
DiscordMessage: row.DiscordMessage,
// no tags! tags next
})
}
if row.Tag != nil {
result[len(result)-1].Tags = append(result[len(result)-1].Tags, row.Tag)
}
}
err = tx.Commit(ctx)
if err != nil {
return nil, oops.New(err, "failed to commit transaction")
}
return result, nil
}
func FetchSnippet(
ctx context.Context,
dbConn db.ConnOrTx,
currentUser *models.User,
snippetID int,
q SnippetQuery,
) (SnippetAndStuff, error) {
q.IDs = []int{snippetID}
q.Limit = 1
q.Offset = 0
res, err := FetchSnippets(ctx, dbConn, currentUser, q)
if err != nil {
return SnippetAndStuff{}, oops.New(err, "failed to fetch snippet")
}
if len(res) == 0 {
return SnippetAndStuff{}, db.NotFound
}
return res[0], nil
}
type TagQuery struct {
IDs []int
Text []string
Limit, Offset int
}
func FetchTags(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) ([]*models.Tag, error) {
perf := ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch snippets")
defer perf.EndBlock()
var qb db.QueryBuilder
qb.Add(
`
SELECT $columns
FROM tags
WHERE
TRUE
`,
)
if len(q.IDs) > 0 {
qb.Add(`AND id = ANY ($?)`, q.IDs)
}
if len(q.Text) > 0 {
qb.Add(`AND text = ANY ($?)`, q.Text)
}
if q.Limit > 0 {
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
}
it, err := db.Query(ctx, dbConn, models.Tag{}, qb.String(), qb.Args()...)
if err != nil {
return nil, oops.New(err, "failed to fetch tags")
}
itags := it.ToSlice()
res := make([]*models.Tag, len(itags))
for i, itag := range itags {
tag := itag.(*models.Tag)
res[i] = tag
}
return res, nil
}
func FetchTag(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) (*models.Tag, error) {
tags, err := FetchTags(ctx, dbConn, q)
if err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, db.NotFound
}
return tags[0], nil
}

View File

@ -88,7 +88,7 @@ func FetchThreads(
WHERE
NOT thread.deleted
AND ( -- project has valid lifecycle
project.flags = 0 AND project.lifecycle = ANY($?)
NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $?
)
`,
@ -219,7 +219,7 @@ func CountThreads(
WHERE
NOT thread.deleted
AND ( -- project has valid lifecycle
project.flags = 0 AND project.lifecycle = ANY($?)
NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $?
)
`,
@ -343,7 +343,7 @@ func FetchPosts(
NOT thread.deleted
AND NOT post.deleted
AND ( -- project has valid lifecycle
project.flags = 0 AND project.lifecycle = ANY($?)
NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $?
)
`,
@ -543,7 +543,7 @@ func CountPosts(
NOT thread.deleted
AND NOT post.deleted
AND ( -- project has valid lifecycle
project.flags = 0 AND project.lifecycle = ANY($?)
NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $?
)
`,

View File

@ -4,6 +4,7 @@ import (
"fmt"
"html/template"
"regexp"
"sort"
"strings"
"git.handmade.network/hmn/hmn/src/hmnurl"
@ -24,18 +25,18 @@ var TimelineTypeTitleMap = map[models.ThreadType]TimelineTypeTitles{
}
func PostToTimelineItem(
urlContext *hmnurl.UrlContext,
lineageBuilder *models.SubforumLineageBuilder,
post *models.Post,
thread *models.Thread,
project *models.Project,
owner *models.User,
currentTheme string,
) templates.TimelineItem {
item := templates.TimelineItem{
Date: post.PostDate,
Title: thread.Title,
Breadcrumbs: GenericThreadBreadcrumbs(lineageBuilder, project, thread),
Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug),
Breadcrumbs: GenericThreadBreadcrumbs(urlContext, lineageBuilder, thread),
Url: UrlForGenericPost(urlContext, thread, post, lineageBuilder),
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
OwnerName: owner.BestName(),
@ -63,6 +64,7 @@ func SnippetToTimelineItem(
snippet *models.Snippet,
asset *models.Asset,
discordMessage *models.DiscordMessage,
tags []*models.Tag,
owner *models.User,
currentTheme string,
) templates.TimelineItem {
@ -106,6 +108,13 @@ func SnippetToTimelineItem(
item.DiscordMessageUrl = discordMessage.Url
}
sort.Slice(tags, func(i, j int) bool {
return tags[i].Text < tags[j].Text
})
for _, tag := range tags {
item.Tags = append(item.Tags, templates.TagToTemplate(tag))
}
return item
}

View File

@ -108,7 +108,7 @@ func UserProfile(c *RequestContext) ResponseData {
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
WHERE
uproj.user_id = $1
AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3)))
AND ($2 OR (NOT project.hidden AND project.lifecycle = ANY ($3)))
`,
profileUser.ID,
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),
@ -121,7 +121,11 @@ func UserProfile(c *RequestContext) ResponseData {
templateProjects := make([]templates.Project, 0, len(projectQuerySlice))
for _, projectRow := range projectQuerySlice {
projectData := projectRow.(*projectQuery)
templateProjects = append(templateProjects, templates.ProjectToTemplate(&projectData.Project, c.Theme))
templateProjects = append(templateProjects, templates.ProjectToTemplate(
&projectData.Project,
UrlContextForProject(&projectData.Project).BuildHomepage(),
c.Theme,
))
}
c.Perf.EndBlock()
@ -132,29 +136,12 @@ func UserProfile(c *RequestContext) ResponseData {
})
c.Perf.EndBlock()
type snippetQuery struct {
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
c.Perf.StartBlock("SQL", "Fetch snippets")
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
snippet.owner_id = $1
`,
profileUser.ID,
)
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
OwnerIDs: []int{profileUser.ID},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
}
snippetQuerySlice := snippetQueryResult.ToSlice()
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
@ -162,25 +149,25 @@ func UserProfile(c *RequestContext) ResponseData {
c.Perf.EndBlock()
c.Perf.StartBlock("PROFILE", "Construct timeline items")
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippetQuerySlice))
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippets))
for _, post := range posts {
timelineItems = append(timelineItems, PostToTimelineItem(
UrlContextForProject(&post.Project),
lineageBuilder,
&post.Post,
&post.Thread,
&post.Project,
profileUser,
c.Theme,
))
}
for _, snippetRow := range snippetQuerySlice {
snippetData := snippetRow.(*snippetQuery)
for _, s := range snippets {
item := SnippetToTimelineItem(
&snippetData.Snippet,
snippetData.Asset,
snippetData.DiscordMessage,
&s.Snippet,
s.Asset,
s.DiscordMessage,
s.Tags,
profileUser,
c.Theme,
)