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 submit it for approval 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 }