hmn/src/website/projects.go

963 lines
26 KiB
Go

package website
import (
"context"
"errors"
"fmt"
"image"
"io"
"math"
"math/rand"
"net/http"
"path"
"sort"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/assets"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmndata"
"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/parsing"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/twitch"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v4"
)
const maxPersonalProjects = 5
const maxProjectOwners = 5
type ProjectTemplateData struct {
templates.BaseData
Pagination templates.Pagination
CarouselProjects []templates.Project
Projects []templates.Project
PersonalProjects []templates.Project
ProjectAtomFeedUrl string
WIPForumUrl string
}
func ProjectIndex(c *RequestContext) ResponseData {
const projectsPerPage = 20
const maxCarouselProjects = 10
const maxPersonalProjects = 10
officialProjects, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
Types: hmndata.OfficialProjects,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects"))
}
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{
Current: page,
Total: numPages,
FirstUrl: hmnurl.BuildProjectIndex(1),
LastUrl: hmnurl.BuildProjectIndex(numPages),
NextUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page-1, numPages)),
}
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 officialProjects {
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.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 p.Project.Featured {
featuredProjects = append(featuredProjects, templateProject)
} else if now.Sub(p.Project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec {
recentProjects = append(recentProjects, templateProject)
} else {
restProjects = append(restProjects, templateProject)
}
}
_, randSeed := now.ISOWeek()
random := rand.New(rand.NewSource(int64(randSeed)))
random.Shuffle(len(featuredProjects), func(i, j int) { featuredProjects[i], featuredProjects[j] = featuredProjects[j], featuredProjects[i] })
random.Shuffle(len(recentProjects), func(i, j int) { recentProjects[i], recentProjects[j] = recentProjects[j], recentProjects[i] })
random.Shuffle(len(restProjects), func(i, j int) { restProjects[i], restProjects[j] = restProjects[j], restProjects[i] })
if handmadeHero != nil {
// NOTE(asaf): As mentioned above, inserting HMH first.
featuredProjects = append([]templates.Project{*handmadeHero}, featuredProjects...)
}
orderedProjects := make([]templates.Project, 0, len(featuredProjects)+len(recentProjects)+len(restProjects))
orderedProjects = append(orderedProjects, featuredProjects...)
orderedProjects = append(orderedProjects, recentProjects...)
orderedProjects = append(orderedProjects, restProjects...)
firstProjectIndex := (page - 1) * projectsPerPage
endIndex := utils.IntMin(firstProjectIndex+projectsPerPage, len(orderedProjects))
pageProjects := orderedProjects[firstProjectIndex:endIndex]
var carouselProjects []templates.Project
if page == 1 {
carouselProjects = featuredProjects[:utils.IntMin(len(featuredProjects), maxCarouselProjects)]
}
c.Perf.EndBlock()
// Fetch and highlight a random selection of personal projects
var personalProjects []templates.Project
{
projects, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
Types: hmndata.PersonalProjects,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal projects"))
}
sort.Slice(projects, func(i, j int) bool {
p1 := projects[i].Project
p2 := projects[j].Project
return p2.AllLastUpdated.Before(p1.AllLastUpdated) // sort backwards - recent first
})
for i, p := range projects {
if i >= maxPersonalProjects {
break
}
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
personalProjects = append(personalProjects, templateProject)
}
}
baseData := getBaseDataAutocrumb(c, "Projects")
var res ResponseData
res.MustWriteTemplate("project_index.html", ProjectTemplateData{
BaseData: baseData,
Pagination: pagination,
CarouselProjects: carouselProjects,
Projects: pageProjects,
PersonalProjects: personalProjects,
ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(),
WIPForumUrl: hmnurl.HMNProjectContext.BuildForum([]string{"wip"}, 1),
}, c.Perf)
return res
}
type ProjectHomepageData struct {
templates.BaseData
Project templates.Project
Owners []templates.User
Screenshots []string
ProjectLinks []templates.Link
Licenses []templates.Link
RecentActivity []templates.TimelineItem
}
func ProjectHomepage(c *RequestContext) ResponseData {
maxRecentActivity := 15
if c.CurrentProject == nil {
return FourOhFour(c)
}
// There are no further permission checks to do, because permissions are
// checked whatever way we fetch the project.
owners, err := hmndata.FetchProjectOwners(c.Context(), c.Conn, c.CurrentUser, c.CurrentProject.ID)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
c.Perf.StartBlock("SQL", "Fetching screenshots")
screenshotFilenames, err := db.QueryScalar[string](c.Context(), c.Conn,
`
SELECT screenshot.file
FROM
image_file AS screenshot
INNER JOIN project_screenshot ON screenshot.id = project_screenshot.imagefile_id
WHERE
project_screenshot.project_id = $1
`,
c.CurrentProject.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project"))
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetching project links")
projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
`
SELECT $columns
FROM
link as link
WHERE
link.project_id = $1
ORDER BY link.ordering ASC
`,
c.CurrentProject.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetching project timeline")
type postQuery struct {
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
Author models.User `db:"author"`
}
posts, err := db.Query[postQuery](c.Context(), c.Conn,
`
SELECT $columns
FROM
post
INNER JOIN thread ON thread.id = post.thread_id
INNER JOIN hmn_user AS author ON author.id = post.author_id
LEFT JOIN asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
WHERE
post.project_id = $1
ORDER BY post.postdate DESC
LIMIT $2
`,
c.CurrentProject.ID,
maxRecentActivity,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project posts"))
}
c.Perf.EndBlock()
var templateData ProjectHomepageData
templateData.BaseData = getBaseData(c, c.CurrentProject.Name, nil)
templateData.BaseData.OpenGraphItems = append(templateData.BaseData.OpenGraphItems, templates.OpenGraphItem{
Property: "og:description",
Value: c.CurrentProject.Blurb,
})
p, err := hmndata.FetchProject(c.Context(), c.Conn, c.CurrentUser, c.CurrentProject.ID, hmndata.ProjectsQuery{
Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details"))
}
templateData.Project = templates.ProjectAndStuffToTemplate(&p, c.UrlContext.BuildHomepage(), c.Theme)
for _, owner := range owners {
templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme))
}
if c.CurrentProject.Hidden {
templateData.BaseData.AddImmediateNotice(
"hidden",
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
)
}
if c.CurrentProject.Lifecycle != models.ProjectLifecycleActive {
switch c.CurrentProject.Lifecycle {
case models.ProjectLifecycleUnapproved:
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.",
c.UrlContext.BuildProjectEdit("submit"),
),
)
case models.ProjectLifecycleApprovalRequired:
templateData.BaseData.AddImmediateNotice(
"unapproved",
"NOTICE: This project is awaiting approval. It is only visible to owners and site admins.",
)
case models.ProjectLifecycleHiatus:
templateData.BaseData.AddImmediateNotice(
"hiatus",
"NOTICE: This project is on hiatus and may not update for a while.",
)
case models.ProjectLifecycleDead:
templateData.BaseData.AddImmediateNotice(
"dead",
"NOTICE: This project is has been marked dead and is only visible to owners and site admins.",
)
case models.ProjectLifecycleLTSRequired:
templateData.BaseData.AddImmediateNotice(
"lts-reqd",
"NOTICE: This project is awaiting approval for maintenance-mode status.",
)
}
}
for _, screenshotFilename := range screenshotFilenames {
templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildUserFile(screenshotFilename))
}
for _, link := range projectLinks {
templateData.ProjectLinks = append(templateData.ProjectLinks, templates.LinkToTemplate(link))
}
for _, post := range posts {
templateData.RecentActivity = append(templateData.RecentActivity, PostToTimelineItem(
c.UrlContext,
lineageBuilder,
&post.Post,
&post.Thread,
&post.Author,
c.Theme,
))
}
tagId := -1
if c.CurrentProject.TagID != nil {
tagId = *c.CurrentProject.TagID
}
snippets, err := hmndata.FetchSnippets(c.Context(), c.Conn, c.CurrentUser, hmndata.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", templateData, c.Perf)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render project homepage template"))
}
return res
}
var ProjectLogoMaxFileSize = 2 * 1024 * 1024
type ProjectEditData struct {
templates.BaseData
Editing bool
ProjectSettings templates.ProjectSettings
MaxOwners int
APICheckUsernameUrl string
LogoMaxFileSize int
}
func ProjectNew(c *RequestContext) ResponseData {
numProjects, err := hmndata.CountProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
OwnerIDs: []int{c.CurrentUser.ID},
Types: hmndata.PersonalProjects,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check number of personal projects"))
}
if numProjects >= maxPersonalProjects {
return RejectRequest(c, fmt.Sprintf("You have already reached the maximum of %d personal projects.", maxPersonalProjects))
}
var project templates.ProjectSettings
project.Owners = append(project.Owners, templates.UserToTemplate(c.CurrentUser, c.Theme))
project.Personal = true
var res ResponseData
res.MustWriteTemplate("project_edit.html", ProjectEditData{
BaseData: getBaseDataAutocrumb(c, "New Project"),
Editing: false,
ProjectSettings: project,
MaxOwners: maxProjectOwners,
APICheckUsernameUrl: hmnurl.BuildAPICheckUsername(),
LogoMaxFileSize: ProjectLogoMaxFileSize,
}, c.Perf)
return res
}
func ProjectNewSubmit(c *RequestContext) ResponseData {
formResult := ParseProjectEditForm(c)
if formResult.Error != nil {
return c.ErrorResponse(http.StatusInternalServerError, formResult.Error)
}
if len(formResult.RejectionReason) != 0 {
return RejectRequest(c, formResult.RejectionReason)
}
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
}
defer tx.Rollback(c.Context())
numProjects, err := hmndata.CountProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
OwnerIDs: []int{c.CurrentUser.ID},
Types: hmndata.PersonalProjects,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check number of personal projects"))
}
if numProjects >= maxPersonalProjects {
return RejectRequest(c, fmt.Sprintf("You have already reached the maximum of %d personal projects.", maxPersonalProjects))
}
var projectId int
err = tx.QueryRow(c.Context(),
`
INSERT INTO project
(name, blurb, description, descparsed, lifecycle, date_created, all_last_updated)
VALUES
($1, $2, $3, $4, $5, $6, $6)
RETURNING id
`,
"",
"",
"",
"",
models.ProjectLifecycleUnapproved,
time.Now(), // NOTE(asaf): Using this param twice.
).Scan(&projectId)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert new project"))
}
formResult.Payload.ProjectID = projectId
err = updateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
tx.Commit(c.Context())
urlContext := &hmnurl.UrlContext{
PersonalProject: true,
ProjectID: projectId,
ProjectName: formResult.Payload.Name,
}
return c.Redirect(urlContext.BuildHomepage(), http.StatusSeeOther)
}
func ProjectEdit(c *RequestContext) ResponseData {
if !c.CurrentUserCanEditCurrentProject {
return FourOhFour(c)
}
p, err := hmndata.FetchProject(
c.Context(), c.Conn,
c.CurrentUser, c.CurrentProject.ID,
hmndata.ProjectsQuery{
Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true,
},
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
c.Perf.StartBlock("SQL", "Fetching project links")
projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
`
SELECT $columns
FROM
link as link
WHERE
link.project_id = $1
ORDER BY link.ordering ASC
`,
p.Project.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
}
c.Perf.EndBlock()
lightLogoUrl := templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, "light")
darkLogoUrl := templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, "dark")
projectSettings := templates.ProjectToProjectSettings(
&p.Project,
p.Owners,
p.TagText(),
lightLogoUrl, darkLogoUrl,
c.Theme,
)
projectSettings.LinksText = LinksToText(projectLinks)
var res ResponseData
res.MustWriteTemplate("project_edit.html", ProjectEditData{
BaseData: getBaseDataAutocrumb(c, "Edit Project"),
Editing: true,
ProjectSettings: projectSettings,
MaxOwners: maxProjectOwners,
APICheckUsernameUrl: hmnurl.BuildAPICheckUsername(),
LogoMaxFileSize: ProjectLogoMaxFileSize,
}, c.Perf)
return res
}
func ProjectEditSubmit(c *RequestContext) ResponseData {
if !c.CurrentUserCanEditCurrentProject {
return FourOhFour(c)
}
formResult := ParseProjectEditForm(c)
if formResult.Error != nil {
return c.ErrorResponse(http.StatusInternalServerError, formResult.Error)
}
if len(formResult.RejectionReason) != 0 {
return RejectRequest(c, formResult.RejectionReason)
}
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
}
defer tx.Rollback(c.Context())
formResult.Payload.ProjectID = c.CurrentProject.ID
err = updateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
tx.Commit(c.Context())
urlContext := &hmnurl.UrlContext{
PersonalProject: formResult.Payload.Personal,
ProjectSlug: formResult.Payload.Slug,
ProjectID: formResult.Payload.ProjectID,
ProjectName: formResult.Payload.Name,
}
return c.Redirect(urlContext.BuildHomepage(), http.StatusSeeOther)
}
type ProjectPayload struct {
ProjectID int
Name string
Blurb string
Links []ParsedLink
Description string
ParsedDescription string
Lifecycle models.ProjectLifecycle
Hidden bool
OwnerUsernames []string
LightLogo FormImage
DarkLogo FormImage
Tag string
Slug string
Featured bool
Personal bool
}
type ProjectEditFormResult struct {
Payload ProjectPayload
RejectionReason string
Error error
}
func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
var res ProjectEditFormResult
maxBodySize := int64(ProjectLogoMaxFileSize*2 + 1024*1024)
c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize)
err := c.Req.ParseMultipartForm(maxBodySize)
if err != nil {
// NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here.
res.Error = oops.New(err, "failed to parse form")
return res
}
projectName := strings.TrimSpace(c.Req.Form.Get("project_name"))
if len(projectName) == 0 {
res.RejectionReason = "Project name is empty"
return res
}
shortDesc := strings.TrimSpace(c.Req.Form.Get("shortdesc"))
if len(shortDesc) == 0 {
res.RejectionReason = "Projects must have a short description"
return res
}
links := ParseLinks(c.Req.Form.Get("links"))
description := c.Req.Form.Get("description")
parsedDescription := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
lifecycleStr := c.Req.Form.Get("lifecycle")
lifecycle, found := templates.ProjectLifecycleFromValue(lifecycleStr)
if !found {
res.RejectionReason = "Project status is invalid"
return res
}
tag := c.Req.Form.Get("tag")
if !models.ValidateTagText(tag) {
res.RejectionReason = "Project tag is invalid"
return res
}
hiddenStr := c.Req.Form.Get("hidden")
hidden := len(hiddenStr) > 0
lightLogo, err := GetFormImage(c, "light_logo")
if err != nil {
res.Error = oops.New(err, "Failed to read image from form")
return res
}
darkLogo, err := GetFormImage(c, "dark_logo")
if err != nil {
res.Error = oops.New(err, "Failed to read image from form")
return res
}
owners := c.Req.Form["owners"]
if len(owners) > maxProjectOwners {
res.RejectionReason = fmt.Sprintf("Projects can have at most %d owners", maxProjectOwners)
return res
}
slug := strings.TrimSpace(c.Req.Form.Get("slug"))
officialStr := c.Req.Form.Get("official")
official := len(officialStr) > 0
featuredStr := c.Req.Form.Get("featured")
featured := len(featuredStr) > 0
if official && len(slug) == 0 {
res.RejectionReason = "Official projects must have a slug"
return res
}
res.Payload = ProjectPayload{
Name: projectName,
Blurb: shortDesc,
Links: links,
Description: description,
ParsedDescription: parsedDescription,
Lifecycle: lifecycle,
Hidden: hidden,
OwnerUsernames: owners,
LightLogo: lightLogo,
DarkLogo: darkLogo,
Tag: tag,
Slug: slug,
Personal: !official,
Featured: featured,
}
return res
}
func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *ProjectPayload) error {
var lightLogoUUID *uuid.UUID
if payload.LightLogo.Exists {
lightLogo := &payload.LightLogo
lightLogoAsset, err := assets.Create(ctx, tx, assets.CreateInput{
Content: lightLogo.Content,
Filename: lightLogo.Filename,
ContentType: lightLogo.Mime,
UploaderID: &user.ID,
Width: lightLogo.Width,
Height: lightLogo.Height,
})
if err != nil {
return oops.New(err, "Failed to save asset")
}
lightLogoUUID = &lightLogoAsset.ID
}
var darkLogoUUID *uuid.UUID
if payload.DarkLogo.Exists {
darkLogo := &payload.DarkLogo
darkLogoAsset, err := assets.Create(ctx, tx, assets.CreateInput{
Content: darkLogo.Content,
Filename: darkLogo.Filename,
ContentType: darkLogo.Mime,
UploaderID: &user.ID,
Width: darkLogo.Width,
Height: darkLogo.Height,
})
if err != nil {
return oops.New(err, "Failed to save asset")
}
darkLogoUUID = &darkLogoAsset.ID
}
hasSelf := false
selfUsername := strings.ToLower(user.Username)
for i, _ := range payload.OwnerUsernames {
payload.OwnerUsernames[i] = strings.ToLower(payload.OwnerUsernames[i])
if payload.OwnerUsernames[i] == selfUsername {
hasSelf = true
}
}
if !hasSelf && !user.IsStaff {
payload.OwnerUsernames = append(payload.OwnerUsernames, selfUsername)
}
_, err := tx.Exec(ctx,
`
UPDATE project SET
name = $2,
blurb = $3,
description = $4,
descparsed = $5,
lifecycle = $6
WHERE id = $1
`,
payload.ProjectID,
payload.Name,
payload.Blurb,
payload.Description,
payload.ParsedDescription,
payload.Lifecycle,
)
if err != nil {
return oops.New(err, "Failed to update project")
}
_, err = hmndata.SetProjectTag(ctx, tx, user, payload.ProjectID, payload.Tag)
if err != nil {
return err
}
if user.IsStaff {
_, err = tx.Exec(ctx,
`
UPDATE project SET
slug = $2,
featured = $3,
personal = $4,
hidden = $5
WHERE
id = $1
`,
payload.ProjectID,
payload.Slug,
payload.Featured,
payload.Personal,
payload.Hidden,
)
if err != nil {
return oops.New(err, "Failed to update project with admin fields")
}
}
if payload.LightLogo.Exists || payload.LightLogo.Remove {
_, err = tx.Exec(ctx,
`
UPDATE project
SET
logolight_asset_id = $2
WHERE
id = $1
`,
payload.ProjectID,
lightLogoUUID,
)
if err != nil {
return oops.New(err, "Failed to update project's light logo")
}
}
if payload.DarkLogo.Exists || payload.DarkLogo.Remove {
_, err = tx.Exec(ctx,
`
UPDATE project
SET
logodark_asset_id = $2
WHERE
id = $1
`,
payload.ProjectID,
darkLogoUUID,
)
if err != nil {
return oops.New(err, "Failed to update project's dark logo")
}
}
owners, err := db.Query[models.User](ctx, tx,
`
SELECT $columns
FROM hmn_user
WHERE LOWER(username) = ANY ($1)
`,
payload.OwnerUsernames,
)
if err != nil {
return oops.New(err, "Failed to query users")
}
_, err = tx.Exec(ctx,
`
DELETE FROM user_project
WHERE project_id = $1
`,
payload.ProjectID,
)
if err != nil {
return oops.New(err, "Failed to delete project owners")
}
for _, owner := range owners {
_, err = tx.Exec(ctx,
`
INSERT INTO user_project
(user_id, project_id)
VALUES
($1, $2)
`,
owner.ID,
payload.ProjectID,
)
if err != nil {
return oops.New(err, "Failed to insert project owner")
}
}
twitchLoginsPreChange, preErr := hmndata.FetchTwitchLoginsForUserOrProject(ctx, tx, nil, &payload.ProjectID)
_, err = tx.Exec(ctx, `DELETE FROM link WHERE project_id = $1`, payload.ProjectID)
if err != nil {
return oops.New(err, "Failed to delete project links")
}
for i, link := range payload.Links {
_, err = tx.Exec(ctx,
`
INSERT INTO link (name, url, ordering, project_id)
VALUES ($1, $2, $3, $4)
`,
link.Name,
link.Url,
i,
payload.ProjectID,
)
if err != nil {
return oops.New(err, "Failed to insert new project link")
}
}
twitchLoginsPostChange, postErr := hmndata.FetchTwitchLoginsForUserOrProject(ctx, tx, nil, &payload.ProjectID)
if preErr == nil && postErr == nil {
twitch.UserOrProjectLinksUpdated(twitchLoginsPreChange, twitchLoginsPostChange)
}
return nil
}
type FormImage struct {
Exists bool
Remove bool
Filename string
Mime string
Content []byte
Width int
Height int
Size int64
}
// NOTE(asaf): This assumes that you already called ParseMultipartForm (which is why there's no size limit here).
func GetFormImage(c *RequestContext, fieldName string) (FormImage, error) {
var res FormImage
res.Exists = false
removeStr := c.Req.Form.Get("remove_" + fieldName)
res.Remove = (removeStr == "true")
img, header, err := c.Req.FormFile(fieldName)
if err != nil {
if errors.Is(err, http.ErrMissingFile) {
return res, nil
} else {
return FormImage{}, err
}
}
if header != nil {
res.Exists = true
res.Size = header.Size
res.Filename = header.Filename
res.Content = make([]byte, res.Size)
img.Read(res.Content)
img.Seek(0, io.SeekStart)
fileExtensionOverrides := []string{".svg"}
fileExt := strings.ToLower(path.Ext(res.Filename))
tryDecode := true
for _, ext := range fileExtensionOverrides {
if fileExt == ext {
tryDecode = false
}
}
if tryDecode {
config, _, err := image.DecodeConfig(img)
if err != nil {
return FormImage{}, err
}
res.Width = config.Width
res.Height = config.Height
res.Mime = http.DetectContentType(res.Content)
} else {
if fileExt == ".svg" {
res.Mime = "image/svg+xml"
}
}
}
return res, nil
}
func CanEditProject(user *models.User, owners []*models.User) bool {
if user != nil {
if user.IsStaff {
return true
} else {
for _, owner := range owners {
if owner.ID == user.ID {
return true
}
}
}
}
return false
}