Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/assets/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand All @@ -88,22 +90,20 @@ 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{
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
Key: &key,
Body: bytes.NewReader(in.Content),
ACL: types.ObjectCannedACLPublicRead,
ContentType: &in.ContentType,
ContentType: &contentType,
})
return err
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
97 changes: 97 additions & 0 deletions src/migration/migrations/2026-07-25T024801Z_DeleteImageFile.go
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 3 additions & 3 deletions src/models/podcast.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
16 changes: 8 additions & 8 deletions src/templates/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down
Loading
Loading