diff --git a/src/assets/assets.go b/src/assets/assets.go index 2ee75213..c4c85656 100644 --- a/src/assets/assets.go +++ b/src/assets/assets.go @@ -21,6 +21,7 @@ import ( "git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/utils" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" @@ -58,12 +59,13 @@ func init() { } type CreateInput struct { - Content []byte - Filename string - ContentType string + Content []byte + Filename string // Optional params - UploaderID *int // HMN user id + + ContentType string // Defaults to http.DetectContentType(Content) + UploaderID *int // HMN user id Width, Height int } @@ -88,14 +90,12 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As if len(in.Content) == 0 { return nil, InvalidAssetError(fmt.Errorf("could not upload asset '%s': no bytes of data were provided", filename)) } - if in.ContentType == "" { - return nil, InvalidAssetError(fmt.Errorf("could not upload asset '%s': no content type provided", filename)) - } // Upload the asset to the DO space id := uuid.New() key := AssetKey(id.String(), filename) checksum := fmt.Sprintf("%x", sha1.Sum(in.Content)) + contentType := utils.OrDefault(in.ContentType, http.DetectContentType(in.Content)) upload := func() error { _, err := client.PutObject(ctx, &s3.PutObjectInput{ @@ -103,7 +103,7 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As Key: &key, Body: bytes.NewReader(in.Content), ACL: types.ObjectCannedACLPublicRead, - ContentType: &in.ContentType, + ContentType: &contentType, }) return err } diff --git a/src/migration/migrations/2026-07-25T020616Z_ConvertImageFilesToAssets.go b/src/migration/migrations/2026-07-25T020616Z_ConvertImageFilesToAssets.go new file mode 100644 index 00000000..1a5aace6 --- /dev/null +++ b/src/migration/migrations/2026-07-25T020616Z_ConvertImageFilesToAssets.go @@ -0,0 +1,121 @@ +package migrations + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "git.handmade.network/hmn/hmn/src/assets" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/models" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(ConvertImageFilesToAssets{}) +} + +type ConvertImageFilesToAssets struct{} + +func (m ConvertImageFilesToAssets) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 7, 25, 2, 6, 16, 0, time.UTC)) +} + +func (m ConvertImageFilesToAssets) Name() string { + return "ConvertImageFilesToAssets" +} + +func (m ConvertImageFilesToAssets) Description() string { + return "Uploads all of the image files in the db to S3 and tracks their IDs" +} + +// Copied here from `models` because, well, we're about to delete it +type ImageFile struct { + ID int `db:"id"` + File string `db:"file"` // relative to public/media + Size int `db:"size"` + Sha1Sum string `db:"sha1sum"` + Protected bool `db:"protected"` + Height int `db:"height"` + Width int `db:"width"` +} + +func (m ConvertImageFilesToAssets) Up(ctx context.Context, tx pgx.Tx) error { + files, err := db.Query[ImageFile](ctx, tx, `SELECT $columns FROM image_file`) + if err != nil { + return err + } + + // NOTE(ben): Upload all image files as assets. If somehow this fails and we + // have to roll back the transaction, we will have created a few unused + // assets. OH WELL + newAssets := make(map[int]*models.Asset) + for i, file := range files { + fmt.Printf("Uploading %d of %d: %s...\n", i+1, len(files), file.File) + contents, err := os.ReadFile(filepath.Join("public", "media", file.File)) + if err != nil { + return err + } + + asset, err := assets.Create(ctx, tx, assets.CreateInput{ + Content: contents, + Filename: filepath.Base(file.File), + + Width: file.Width, + Height: file.Height, + }) + if err != nil { + return err + } + + newAssets[file.ID] = asset + } + + _, err = tx.Exec(ctx, + ` + ALTER TABLE image_file + ADD COLUMN asset_id UUID REFERENCES asset (id) ON DELETE SET NULL; + `, + ) + if err != nil { + return err + } + + // NOTE(ben): Feels dumb, but we're just going to set all the new IDs using + // one query each. Who cares. + for fileID, asset := range newAssets { + _, err := tx.Exec(ctx, + ` + UPDATE image_file SET asset_id = $1 WHERE id = $2 + `, + asset.ID, fileID, + ) + if err != nil { + return err + } + } + + // NOTE(ben): Sanity check + numNull, err := db.QueryOneScalar[int](ctx, tx, `SELECT COUNT(*) FROM image_file WHERE asset_id IS NULL`) + if err != nil { + return err + } + if numNull != 0 { + return fmt.Errorf("expected all image files to get assets") + } + + return nil +} + +func (m ConvertImageFilesToAssets) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + ALTER TABLE image_file + DROP COLUMN asset_id; + `, + ) + return err +} diff --git a/src/migration/migrations/2026-07-25T024801Z_DeleteImageFile.go b/src/migration/migrations/2026-07-25T024801Z_DeleteImageFile.go new file mode 100644 index 00000000..cc3fc2dc --- /dev/null +++ b/src/migration/migrations/2026-07-25T024801Z_DeleteImageFile.go @@ -0,0 +1,97 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(DeleteImageFile{}) +} + +type DeleteImageFile struct{} + +func (m DeleteImageFile) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 7, 25, 2, 48, 1, 0, time.UTC)) +} + +func (m DeleteImageFile) Name() string { + return "DeleteImageFile" +} + +func (m DeleteImageFile) Description() string { + return "Removes the imagefile table, replacing all uses with assets IDs" +} + +func (m DeleteImageFile) Up(ctx context.Context, tx pgx.Tx) error { + // NOTE(ben): Only project screenshots and podcast art uses image files. + _, err := tx.Exec(ctx, + ` + ALTER TABLE project_screenshot + ADD COLUMN asset_id UUID REFERENCES asset (id) ON DELETE CASCADE; + ALTER TABLE podcast + ADD COLUMN image_asset UUID REFERENCES asset (id) ON DELETE SET NULL; + + UPDATE project_screenshot + SET asset_id = image_file.asset_id + FROM image_file + WHERE project_screenshot.imagefile_id = image_file.id; + + UPDATE podcast + SET image_asset = image_file.asset_id + FROM image_file + WHERE podcast.image_id = image_file.id; + `, + ) + if err != nil { + return err + } + + _, err = tx.Exec(ctx, ` + ALTER TABLE project_screenshot + DROP COLUMN imagefile_id; + ALTER TABLE podcast + DROP COLUMN image_id; + DROP TABLE image_file; + `) + if err != nil { + return err + } + + return nil +} + +func (m DeleteImageFile) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE project_screenshot DROP COLUMN asset_id; + ALTER TABLE podcast DROP COLUMN image_asset; + + CREATE TABLE image_file ( + id INTEGER NOT NULL PRIMARY KEY, + file VARCHAR(255) NOT NULL, + size INTEGER NOT NULL, + sha1sum VARCHAR(40) NOT NULL, + protected BOOLEAN NOT NULL, + height INTEGER NOT NULL, + width INTEGER NOT NULL, + asset_id UUID REFERENCES asset (id) ON DELETE SET NULL + ); + CREATE SEQUENCE image_file_id_seq OWNED BY image_file.id; + ALTER TABLE image_file ALTER COLUMN id SET DEFAULT nextval('image_file_id_seq'); + + ALTER TABLE project_screenshot + ADD COLUMN imagefile_id INTEGER REFERENCES image_file (id); + ALTER TABLE podcast + ADD COLUMN image_id INTEGER REFERENCES image_file (id); + + CREATE INDEX ON project_screenshot (imagefile_id); + ALTER TABLE project_screenshot + ADD CONSTRAINT project_screenshot_project_id_imagefile_id_uniq UNIQUE (project_id, imagefile_id); + + CREATE INDEX ON podcast (image_id); + `) + return err +} diff --git a/src/models/podcast.go b/src/models/podcast.go index 5ec615bd..d9326aa0 100644 --- a/src/models/podcast.go +++ b/src/models/podcast.go @@ -7,9 +7,9 @@ import ( ) type Podcast struct { - ID int `db:"id"` - ImageID int `db:"image_id"` - ProjectID int `db:"project_id"` + ID int `db:"id"` + ImageID *uuid.UUID `db:"image_asset"` + ProjectID int `db:"project_id"` Title string `db:"title"` Description string `db:"description"` diff --git a/src/templates/mapping.go b/src/templates/mapping.go index c5e06741..63466b57 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -430,10 +430,10 @@ func SnippetEditProjectsToJSON(projects []Project) string { return builder.String() } -func PodcastToTemplate(podcast *models.Podcast, imageFilename string) Podcast { - imageUrl := "" - if imageFilename != "" { - imageUrl = hmnurl.BuildUserFile(imageFilename) +func PodcastToTemplate(podcast *models.Podcast, image *models.Asset) Podcast { + var imageUrl string + if image != nil { + imageUrl = hmnurl.BuildS3Asset(image.S3Key) } return Podcast{ Title: podcast.Title, @@ -450,10 +450,10 @@ func PodcastToTemplate(podcast *models.Podcast, imageFilename string) Podcast { } } -func PodcastEpisodeToTemplate(episode *models.PodcastEpisode, audioFileSize int64, imageFilename string) PodcastEpisode { - imageUrl := "" - if imageFilename != "" { - imageUrl = hmnurl.BuildUserFile(imageFilename) +func PodcastEpisodeToTemplate(episode *models.PodcastEpisode, image *models.Asset, audioFileSize int64) PodcastEpisode { + var imageUrl string + if image != nil { + imageUrl = hmnurl.BuildS3Asset(image.S3Key) } return PodcastEpisode{ GUID: episode.GUID.String(), diff --git a/src/website/form_image.go b/src/website/form_image.go new file mode 100644 index 00000000..556fd859 --- /dev/null +++ b/src/website/form_image.go @@ -0,0 +1,91 @@ +package website + +import ( + "context" + "errors" + "image" + "io" + "net/http" + "path" + "strings" + + "git.handmade.network/hmn/hmn/src/assets" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/utils" +) + +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 SaveFormImage(ctx context.Context, dbConn db.ConnOrTx, img FormImage, uploaderID *int) (*models.Asset, error) { + utils.Assert(img.Exists) + return assets.Create(ctx, dbConn, assets.CreateInput{ + Content: img.Content, + Filename: img.Filename, + ContentType: img.Mime, + UploaderID: uploaderID, + Width: img.Width, + Height: img.Height, + }) +} diff --git a/src/website/imagefile_helper.go b/src/website/imagefile_helper.go deleted file mode 100644 index 4bb92f0a..00000000 --- a/src/website/imagefile_helper.go +++ /dev/null @@ -1,117 +0,0 @@ -package website - -import ( - "crypto/sha1" - "encoding/hex" - "errors" - "fmt" - "image" - "io" - "net/http" - "os" - - "git.handmade.network/hmn/hmn/src/db" - "git.handmade.network/hmn/hmn/src/models" - "git.handmade.network/hmn/hmn/src/oops" -) - -type SaveImageFileResult struct { - ImageFile *models.ImageFile - ValidationError string - FatalError error -} - -/* -Reads an image file from form data and saves it to the filesystem and the database. -If the file doesn't exist, this does nothing and returns 0 for the image file id. - -NOTE(ben): Someday we should replace this with the asset system. -*/ -func SaveImageFile(c *RequestContext, dbConn db.ConnOrTx, fileFieldName string, maxSize int64, filepath string) SaveImageFileResult { - img, header, err := c.Req.FormFile(fileFieldName) - filename := "" - width := 0 - height := 0 - if err != nil && !errors.Is(err, http.ErrMissingFile) { - return SaveImageFileResult{ - FatalError: oops.New(err, "failed to read uploaded file"), - } - } - - if header != nil { - if header.Size > maxSize { - return SaveImageFileResult{ - ValidationError: fmt.Sprintf("Image filesize too big. Max size: %d bytes", maxSize), - } - } else { - b := c.Perf.StartBlock("IMAGE", "Decoding image") - config, format, err := image.DecodeConfig(img) - b.End() - if err != nil { - return SaveImageFileResult{ - ValidationError: "Image type not supported", - } - } - - width = config.Width - height = config.Height - if width == 0 || height == 0 { - return SaveImageFileResult{ - ValidationError: "Image has zero size", - } - } - - filename = fmt.Sprintf("%s.%s", filepath, format) - storageFilename := fmt.Sprintf("public/media/%s", filename) - { - b := c.Perf.StartBlock("IMAGE", "Writing image file") - defer b.End() - - file, err := os.Create(storageFilename) - if err != nil { - return SaveImageFileResult{ - FatalError: oops.New(err, "Failed to create local image file"), - } - } - img.Seek(0, io.SeekStart) - _, err = io.Copy(file, img) - if err != nil { - return SaveImageFileResult{ - FatalError: oops.New(err, "Failed to write image to file"), - } - } - file.Close() - img.Close() - - b.End() - } - } - } - - if filename != "" { - hasher := sha1.New() - img.Seek(0, io.SeekStart) - io.Copy(hasher, img) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs - sha1sum := hasher.Sum(nil) - imageFile, err := db.QueryOne[models.ImageFile](c, dbConn, - ` - ---- Save image file - INSERT INTO image_file (file, size, sha1sum, protected, width, height) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING $columns - `, - filename, header.Size, hex.EncodeToString(sha1sum), false, width, height, - ) - if err != nil { - return SaveImageFileResult{ - FatalError: oops.New(err, "Failed to insert image file row"), - } - } - - return SaveImageFileResult{ - ImageFile: imageFile, - } - } - - return SaveImageFileResult{} -} diff --git a/src/website/podcast.go b/src/website/podcast.go index 97f8c7f3..783d091c 100644 --- a/src/website/podcast.go +++ b/src/website/podcast.go @@ -31,7 +31,7 @@ type PodcastIndexData struct { } func PodcastIndex(c *RequestContext) ResponseData { - podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, "") + podcastResult, err := FetchPodcastAndEpisodes(c, c.CurrentProject.ID, true, "") if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -46,7 +46,7 @@ func PodcastIndex(c *RequestContext) ResponseData { podcastIndexData := PodcastIndexData{ BaseData: baseData, - Podcast: templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.ImageFile), + Podcast: templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.Image), } if canEdit { @@ -55,7 +55,7 @@ func PodcastIndex(c *RequestContext) ResponseData { } for _, episode := range podcastResult.Episodes { - podcastIndexData.Episodes = append(podcastIndexData.Episodes, templates.PodcastEpisodeToTemplate(episode, 0, podcastResult.ImageFile)) + podcastIndexData.Episodes = append(podcastIndexData.Episodes, templates.PodcastEpisodeToTemplate(episode, podcastResult.Image, 0)) } var res ResponseData err = res.WriteTemplate("podcast_index.html", podcastIndexData, c.Perf) @@ -71,7 +71,7 @@ type PodcastEditData struct { } func PodcastEdit(c *RequestContext) ResponseData { - podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "") + podcastResult, err := FetchPodcastAndEpisodes(c, c.CurrentProject.ID, false, "") if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -82,7 +82,7 @@ func PodcastEdit(c *RequestContext) ResponseData { return FourOhFour(c) } - podcast := templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.ImageFile) + podcast := templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.Image) baseData := getBaseData(c, fmt.Sprintf("Edit %s", podcast.Title), nil) podcastEditData := PodcastEditData{ BaseData: baseData, @@ -98,7 +98,7 @@ func PodcastEdit(c *RequestContext) ResponseData { } func PodcastEditSubmit(c *RequestContext) ResponseData { - podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "") + podcastResult, err := FetchPodcastAndEpisodes(c, c.CurrentProject.ID, false, "") if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -145,45 +145,41 @@ func PodcastEditSubmit(c *RequestContext) ResponseData { } defer tx.Rollback(c) - imageSaveResult := SaveImageFile(c, tx, "podcast_image", maxFileSize, fmt.Sprintf("podcast/%s/logo%d", c.CurrentProject.Slug, time.Now().UTC().Unix())) - if imageSaveResult.ValidationError != "" { - return c.RejectRequest(imageSaveResult.ValidationError) - } else if imageSaveResult.FatalError != nil { - return c.ErrorResponse(http.StatusInternalServerError, oops.New(imageSaveResult.FatalError, "Failed to save podcast image")) + _, err = tx.Exec(c, + ` + UPDATE podcast + SET + title = $1, + description = $2 + WHERE id = $3 + `, + title, + description, + podcastResult.Podcast.ID, + ) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update podcast")) } - if imageSaveResult.ImageFile != nil { - _, err = tx.Exec(c, - ` - UPDATE podcast - SET - title = $1, - description = $2, - image_id = $3 - WHERE id = $4 - `, - title, - description, - imageSaveResult.ImageFile.ID, - podcastResult.Podcast.ID, - ) + image, err := GetFormImage(c, "podcast_image") + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read image from form")) + } + if image.Exists { + imageAsset, err := SaveFormImage(c, tx, image, &c.CurrentUser.ID) if err != nil { - return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to update podcast")) + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save podcast image")) } - } else { _, err = tx.Exec(c, ` - UPDATE podcast - SET - title = $1, - description = $2 - WHERE id = $3 + UPDATE podcast SET image_asset = $1 + WHERE id = $2 `, - title, - description, + imageAsset.ID, podcastResult.Podcast.ID, ) } + err = tx.Commit(c) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to commit db transaction")) @@ -204,7 +200,7 @@ type PodcastEpisodeData struct { func PodcastEpisode(c *RequestContext) ResponseData { episodeGUIDStr := c.PathParams["episodeid"] - podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, episodeGUIDStr) + podcastResult, err := FetchPodcastAndEpisodes(c, c.CurrentProject.ID, true, episodeGUIDStr) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -220,8 +216,8 @@ func PodcastEpisode(c *RequestContext) ResponseData { editUrl = hmnurl.BuildPodcastEpisodeEdit(podcastResult.Episodes[0].GUID.String()) } - podcast := templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.ImageFile) - episode := templates.PodcastEpisodeToTemplate(podcastResult.Episodes[0], 0, podcastResult.ImageFile) + podcast := templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.Image) + episode := templates.PodcastEpisodeToTemplate(podcastResult.Episodes[0], podcastResult.Image, 0) baseData := getBaseData(c, fmt.Sprintf("%s | %s", episode.Title, podcast.Title), nil) podcastEpisodeData := PodcastEpisodeData{ @@ -251,7 +247,7 @@ type PodcastEpisodeEditData struct { } func PodcastEpisodeNew(c *RequestContext) ResponseData { - podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "") + podcastResult, err := FetchPodcastAndEpisodes(c, c.CurrentProject.ID, false, "") if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -267,7 +263,7 @@ func PodcastEpisodeNew(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list")) } - podcast := templates.PodcastToTemplate(podcastResult.Podcast, "") + podcast := templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.Image) var res ResponseData baseData := getBaseData(c, fmt.Sprintf("New episode | %s", podcast.Title), nil) err = res.WriteTemplate("podcast_episode_edit.html", PodcastEpisodeEditData{ @@ -287,7 +283,7 @@ func PodcastEpisodeEdit(c *RequestContext) ResponseData { return FourOhFour(c) } - podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, episodeGUIDStr) + podcastResult, err := FetchPodcastAndEpisodes(c, c.CurrentProject.ID, true, episodeGUIDStr) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -304,8 +300,8 @@ func PodcastEpisodeEdit(c *RequestContext) ResponseData { } episode := podcastResult.Episodes[0] - podcast := templates.PodcastToTemplate(podcastResult.Podcast, "") - podcastEpisode := templates.PodcastEpisodeToTemplate(episode, 0, "") + podcast := templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.Image) + podcastEpisode := templates.PodcastEpisodeToTemplate(episode, podcastResult.Image, 0) baseData := getBaseData(c, fmt.Sprintf("Edit episode %s | %s", podcastEpisode.Title, podcast.Title), nil) podcastEpisodeEditData := PodcastEpisodeEditData{ BaseData: baseData, @@ -330,7 +326,7 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData { episodeGUIDStr, found := c.PathParams["episodeid"] isEdit := found && episodeGUIDStr != "" - podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, isEdit, episodeGUIDStr) + podcastResult, err := FetchPodcastAndEpisodes(c, c.CurrentProject.ID, isEdit, episodeGUIDStr) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -484,7 +480,7 @@ type PodcastRSSData struct { } func PodcastRSS(c *RequestContext) ResponseData { - podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, "") + podcastResult, err := FetchPodcastAndEpisodes(c, c.CurrentProject.ID, true, "") if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -494,7 +490,7 @@ func PodcastRSS(c *RequestContext) ResponseData { } podcastRSSData := PodcastRSSData{ - Podcast: templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.ImageFile), + Podcast: templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.Image), } for _, episode := range podcastResult.Episodes { @@ -505,7 +501,7 @@ func PodcastRSS(c *RequestContext) ResponseData { } else { filesize = stat.Size() } - podcastRSSData.Episodes = append(podcastRSSData.Episodes, templates.PodcastEpisodeToTemplate(episode, filesize, podcastResult.ImageFile)) + podcastRSSData.Episodes = append(podcastRSSData.Episodes, templates.PodcastEpisodeToTemplate(episode, podcastResult.Image, filesize)) } var res ResponseData @@ -517,24 +513,22 @@ func PodcastRSS(c *RequestContext) ResponseData { } type PodcastResult struct { - Podcast *models.Podcast - ImageFile string - Episodes []*models.PodcastEpisode + Podcast *models.Podcast + Episodes []*models.PodcastEpisode + Image *models.Asset } -func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeGUID string) (PodcastResult, error) { +func FetchPodcastAndEpisodes( + c *RequestContext, + projectId int, + fetchEpisodes bool, + episodeGUID string, +) (PodcastResult, error) { var result PodcastResult - type podcastQuery struct { - Podcast models.Podcast `db:"podcast"` - ImageFilename string `db:"imagefile.file"` - } - podcastQueryResult, err := db.QueryOne[podcastQuery](c, c.Conn, + podcast, err := db.QueryOne[models.Podcast](c, c.Conn, ` ---- Fetch podcast - SELECT $columns - FROM - podcast - LEFT JOIN image_file AS imagefile ON imagefile.id = podcast.image_id + SELECT $columns FROM podcast WHERE podcast.project_id = $1 `, projectId, @@ -546,10 +540,7 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG return result, oops.New(err, "failed to fetch podcast") } } - podcast := podcastQueryResult.Podcast - podcastImageFilename := podcastQueryResult.ImageFilename - result.Podcast = &podcast - result.ImageFile = podcastImageFilename + result.Podcast = podcast if fetchEpisodes { if episodeGUID == "" { @@ -593,5 +584,16 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG } } + if podcast.ImageID != nil { + imageAsset, err := db.QueryOne[models.Asset](c, c.Conn, + `SELECT $columns FROM asset WHERE id = $1`, + podcast.ImageID, + ) + if err != nil { + return result, oops.New(err, "failed to fetch podcast image") + } + result.Image = imageAsset + } + return result, nil } diff --git a/src/website/projects.go b/src/website/projects.go index 2af39423..dbe8f8fd 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -3,20 +3,15 @@ package website import ( "context" "encoding/json" - "errors" "fmt" "html/template" - "image" - "io" "net/http" - "path" "slices" "sort" "strconv" "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" @@ -168,13 +163,13 @@ func ProjectHomepage(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, err) } - screenshotFilenames, err := db.QueryScalar[string](c, c.Conn, + screenshotAssets, err := db.Query[models.Asset](c, c.Conn, ` ---- Fetching screenshots - SELECT screenshot.file + SELECT $columns{asset} FROM - image_file AS screenshot - INNER JOIN project_screenshot ON screenshot.id = project_screenshot.imagefile_id + project_screenshot + JOIN asset ON project_screenshot.asset_id = asset.id WHERE project_screenshot.project_id = $1 `, @@ -278,8 +273,8 @@ func ProjectHomepage(c *RequestContext) ResponseData { } } - for _, screenshotFilename := range screenshotFilenames { - templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildUserFile(screenshotFilename)) + for _, screenshot := range screenshotAssets { + templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildS3Asset(screenshot.S3Key)) } if c.CurrentProject.HasBlog() { @@ -782,15 +777,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult { func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *ProjectPayload) error { var logoUUID *uuid.UUID if payload.Logo.Exists { - logo := &payload.Logo - logoAsset, err := assets.Create(ctx, tx, assets.CreateInput{ - Content: logo.Content, - Filename: logo.Filename, - ContentType: logo.Mime, - UploaderID: &user.ID, - Width: logo.Width, - Height: logo.Height, - }) + logoAsset, err := SaveFormImage(ctx, tx, payload.Logo, &user.ID) if err != nil { return oops.New(err, "Failed to save asset") } @@ -799,15 +786,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P var headerImageUUID *uuid.UUID if payload.HeaderImage.Exists { - headerImage := &payload.HeaderImage - headerImageAsset, err := assets.Create(ctx, tx, assets.CreateInput{ - Content: headerImage.Content, - Filename: headerImage.Filename, - ContentType: headerImage.Mime, - UploaderID: &user.ID, - Width: headerImage.Width, - Height: headerImage.Height, - }) + headerImageAsset, err := SaveFormImage(ctx, tx, payload.HeaderImage, &user.ID) if err != nil { return oops.New(err, "Failed to save asset") } @@ -1046,69 +1025,6 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P 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 { diff --git a/src/website/user.go b/src/website/user.go index 85627013..449b3eb0 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -8,7 +8,6 @@ import ( "strconv" "strings" - "git.handmade.network/hmn/hmn/src/assets" "git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/db" @@ -433,14 +432,7 @@ func UserSettingsSave(c *RequestContext) ResponseData { } var avatarUUID *uuid.UUID if newAvatar.Exists { - avatarAsset, err := assets.Create(c, tx, assets.CreateInput{ - Content: newAvatar.Content, - Filename: newAvatar.Filename, - ContentType: newAvatar.Mime, - UploaderID: &c.CurrentUser.ID, - Width: newAvatar.Width, - Height: newAvatar.Height, - }) + avatarAsset, err := SaveFormImage(c, tx, newAvatar, &c.CurrentUser.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to upload avatar")) }