package website import ( "bytes" "errors" "fmt" "image" "io" "net/http" "strconv" "strings" "time" "git.handmade.network/hmn/hmn/src/assets" "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/embed" "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" "github.com/google/uuid" "mvdan.cc/xurls/v2" ) type SnippetData struct { templates.BaseData Snippet templates.TimelineItem CanEditSnippet bool SnippetEdit templates.SnippetEdit } func Snippet(c *RequestContext) ResponseData { snippetId := -1 snippetIdStr, found := c.PathParams["snippetid"] if found && snippetIdStr != "" { var err error if snippetId, err = strconv.Atoi(snippetIdStr); err != nil { return FourOhFour(c) } } if snippetId < 1 { return FourOhFour(c) } s, err := hmndata.FetchSnippet(c, c.Conn, c.CurrentUser, snippetId, hmndata.SnippetQuery{}) if err != nil { if errors.Is(err, db.NotFound) { return FourOhFour(c) } else { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippet")) } } c.Perf.EndBlock() canEdit := (c.CurrentUser != nil && (c.CurrentUser.IsStaff || c.CurrentUser.ID == s.Owner.ID)) snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, canEdit) snippet.SmallInfo = true opengraph := []templates.OpenGraphItem{ {Property: "og:site_name", Value: "Handmade.Network"}, {Property: "og:type", Value: "article"}, {Property: "og:url", Value: snippet.Url}, {Property: "og:title", Value: fmt.Sprintf("Snippet by %s", snippet.OwnerName)}, {Property: "og:description", Value: string(snippet.Description)}, } if len(snippet.EmbedMedia) > 0 { media := snippet.EmbedMedia[0] switch media.Type { case templates.TimelineItemMediaTypeImage: opengraph = append(opengraph, templates.OpenGraphItem{Property: "og:image", Value: media.AssetUrl}, templates.OpenGraphItem{Property: "og:image:width", Value: strconv.Itoa(media.Width)}, templates.OpenGraphItem{Property: "og:image:height", Value: strconv.Itoa(media.Height)}, templates.OpenGraphItem{Property: "og:image:type", Value: media.MimeType}, templates.OpenGraphItem{Name: "twitter:card", Value: "summary_large_image"}, ) case templates.TimelineItemMediaTypeVideo: opengraph = append(opengraph, templates.OpenGraphItem{Property: "og:video", Value: media.AssetUrl}, templates.OpenGraphItem{Property: "og:video:width", Value: strconv.Itoa(media.Width)}, templates.OpenGraphItem{Property: "og:video:height", Value: strconv.Itoa(media.Height)}, templates.OpenGraphItem{Property: "og:video:type", Value: media.MimeType}, templates.OpenGraphItem{Name: "twitter:card", Value: "player"}, ) case templates.TimelineItemMediaTypeAudio: opengraph = append(opengraph, templates.OpenGraphItem{Property: "og:audio", Value: media.AssetUrl}, templates.OpenGraphItem{Property: "og:audio:type", Value: media.MimeType}, templates.OpenGraphItem{Name: "twitter:card", Value: "player"}, ) } opengraph = append(opengraph, media.ExtraOpenGraphItems...) } baseData := getBaseData( c, fmt.Sprintf("Snippet by %s", snippet.OwnerName), []templates.Breadcrumb{{Name: snippet.OwnerName, Url: snippet.OwnerUrl}}, ) baseData.OpenGraphItems = opengraph // NOTE(asaf): We're overriding the defaults on purpose. snippetEdit := templates.SnippetEdit{} if c.CurrentUser != nil { userProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{ OwnerIDs: []int{c.CurrentUser.ID}, }) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects")) } templateProjects := make([]templates.Project, 0, len(userProjects)) for _, p := range userProjects { templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme) templateProjects = append(templateProjects, templateProject) } snippetEdit = templates.SnippetEdit{ AvailableProjectsJSON: templates.SnippetEditProjectsToJSON(templateProjects), SubmitUrl: hmnurl.BuildSnippetSubmit(), AssetMaxSize: AssetMaxSize(c.CurrentUser), } } var res ResponseData err = res.WriteTemplate("snippet.html", SnippetData{ BaseData: baseData, Snippet: snippet, CanEditSnippet: canEdit, SnippetEdit: snippetEdit, }, c.Perf) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render snippet template")) } return res } func SnippetEditSubmit(c *RequestContext) ResponseData { maxUploadSize := AssetMaxSize(c.CurrentUser) maxBodySize := int64(maxUploadSize + 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. return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form")) } form := c.Req.PostForm redirect := form.Get("redirect") action := form.Get("action") existingSnippetIdStr := strings.TrimSpace(form.Get("snippet_id")) var existingSnippet *hmndata.SnippetAndStuff originalText := "" var embedUrl *string var assetID *uuid.UUID if len(existingSnippetIdStr) > 0 { existingSnippetId, err := strconv.Atoi(existingSnippetIdStr) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse snippet id")) } query := hmndata.SnippetQuery{} if !c.CurrentUser.IsStaff { query.OwnerIDs = []int{c.CurrentUser.ID} } snip, err := hmndata.FetchSnippet(c, c.Conn, c.CurrentUser, existingSnippetId, query) if err != nil { if errors.Is(err, db.NotFound) { return FourOhFour(c) } else { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch existing snippet for edit")) } } originalText = snip.Snippet.Description embedUrl = snip.Snippet.Url assetID = snip.Snippet.AssetID if snip.Snippet.Url != nil { embedUrl = snip.Snippet.Url } existingSnippet = &snip } if strings.ToLower(action) == "delete" { if existingSnippet != nil { _, err = c.Conn.Exec(c, ` DELETE FROM snippet WHERE snippet.id = $1 `, existingSnippet.Snippet.ID, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch existing snippet for edit")) } } else { return FourOhFour(c) } } else { if form.Get("remove_attachment") == "true" { embedUrl = nil assetID = nil } text := strings.TrimSpace(form.Get("text")) textHtml := parsing.ParseMarkdown(text, parsing.DiscordMarkdown) projectAssociations := form["project_id"] var assetData *assets.CreateInput file, header, err := c.Req.FormFile("file") if err != nil && !errors.Is(err, http.ErrMissingFile) { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get form file")) } if header != nil { content := make([]byte, header.Size) _, err = file.Read(content) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read uploaded file")) } contentType := header.Header.Get("Content-Type") if contentType == "" { contentType = http.DetectContentType(content) } width := 0 height := 0 if strings.HasPrefix(contentType, "image/") && contentType != "image/svg+xml" { file.Seek(0, io.SeekStart) config, _, err := image.DecodeConfig(file) if err == nil { width = config.Width height = config.Height } } assetData = &assets.CreateInput{ Content: content, Filename: header.Filename, ContentType: contentType, UploaderID: &c.CurrentUser.ID, Width: width, Height: height, } } if originalText != text && assetData == nil && embedUrl == nil && assetID == nil { urls := xurls.Relaxed().FindAllString(text, -1) if urls != nil { embeddable, err := embed.GetEmbeddableFromUrls(c, urls, maxUploadSize, time.Second*10, 3) if err != nil { if !errors.Is(err, embed.DownloadTooBigError) && !errors.Is(err, embed.NoEmbedFound) { c.Logger.Error().Err(err).Msg("failed to fetch embeddable for snippet") } } else { if embeddable.Url != "" { embedUrl = &embeddable.Url } else { width := 0 height := 0 if strings.HasPrefix(embeddable.File.ContentType, "image/") && embeddable.File.ContentType != "image/svg+xml" { reader := bytes.NewReader(embeddable.File.Data) config, _, err := image.DecodeConfig(reader) if err == nil { width = config.Width height = config.Height } } assetData = &assets.CreateInput{ Content: embeddable.File.Data, Filename: embeddable.File.Filename, ContentType: embeddable.File.ContentType, UploaderID: &c.CurrentUser.ID, Width: width, Height: height, } } } } } if text == "" && assetData == nil && embedUrl == nil && assetID == nil { return c.RejectRequest("You must provide a description or a file attachment.") } tx, err := c.Conn.Begin(c) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start transaction")) } defer tx.Rollback(c) var asset *models.Asset if assetData != nil { asset, err = assets.Create(c, tx, *assetData) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create asset")) } assetID = &asset.ID } snippetId := 0 if existingSnippet != nil { _, err = tx.Exec(c, ` UPDATE snippet SET url = $2, description = $3, _description_html = $4, asset_id = $5, edited_on_website = $6 WHERE id = $1 `, existingSnippet.Snippet.ID, embedUrl, text, textHtml, assetID, true, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update snippet")) } snippetId = existingSnippet.Snippet.ID } else { newSnippetId, err := db.QueryOne[int](c, tx, ` INSERT INTO snippet (url, "when", description, _description_html, asset_id, owner_id, edited_on_website) VALUES ($1, $2, $3, $4, $5, $6 ,$7) RETURNING id `, embedUrl, time.Now(), text, textHtml, assetID, c.CurrentUser.ID, true, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to insert snippet")) } snippetId = *newSnippetId } _, err = tx.Exec(c, ` DELETE FROM snippet_project WHERE snippet_id = $1 `, snippetId, ) if len(projectAssociations) > 0 { var projectIds []int for _, pidStr := range projectAssociations { projId, err := strconv.Atoi(pidStr) if err != nil { continue } projectIds = append(projectIds, projId) } if len(projectIds) > 0 { projectQuery := hmndata.ProjectsQuery{ ProjectIDs: projectIds, } if !c.CurrentUser.IsStaff { projectQuery.OwnerIDs = []int{c.CurrentUser.ID} } projects, err := hmndata.FetchProjects(c, tx, c.CurrentUser, projectQuery) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects for snippet")) } for _, p := range projects { _, err = tx.Exec(c, ` INSERT INTO snippet_project (snippet_id, project_id, kind) VALUES ($1, $2, $3) `, snippetId, p.Project.ID, models.SnippetProjectKindWebsite, ) } } } err = tx.Commit(c) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit transaction")) } } return c.Redirect(redirect, http.StatusSeeOther) }