Skip to content

Commit 4be9e5d

Browse files
authored
Convert from imagefiles to assets (#40)
1 parent 60ff4bc commit 4be9e5d

10 files changed

Lines changed: 403 additions & 301 deletions

File tree

src/assets/assets.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"git.handmade.network/hmn/hmn/src/logging"
2222
"git.handmade.network/hmn/hmn/src/models"
2323
"git.handmade.network/hmn/hmn/src/oops"
24+
"git.handmade.network/hmn/hmn/src/utils"
2425
"github.com/aws/aws-sdk-go-v2/aws"
2526
awsconfig "github.com/aws/aws-sdk-go-v2/config"
2627
"github.com/aws/aws-sdk-go-v2/credentials"
@@ -58,12 +59,13 @@ func init() {
5859
}
5960

6061
type CreateInput struct {
61-
Content []byte
62-
Filename string
63-
ContentType string
62+
Content []byte
63+
Filename string
6464

6565
// Optional params
66-
UploaderID *int // HMN user id
66+
67+
ContentType string // Defaults to http.DetectContentType(Content)
68+
UploaderID *int // HMN user id
6769
Width, Height int
6870
}
6971

@@ -88,22 +90,20 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As
8890
if len(in.Content) == 0 {
8991
return nil, InvalidAssetError(fmt.Errorf("could not upload asset '%s': no bytes of data were provided", filename))
9092
}
91-
if in.ContentType == "" {
92-
return nil, InvalidAssetError(fmt.Errorf("could not upload asset '%s': no content type provided", filename))
93-
}
9493

9594
// Upload the asset to the DO space
9695
id := uuid.New()
9796
key := AssetKey(id.String(), filename)
9897
checksum := fmt.Sprintf("%x", sha1.Sum(in.Content))
98+
contentType := utils.OrDefault(in.ContentType, http.DetectContentType(in.Content))
9999

100100
upload := func() error {
101101
_, err := client.PutObject(ctx, &s3.PutObjectInput{
102102
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
103103
Key: &key,
104104
Body: bytes.NewReader(in.Content),
105105
ACL: types.ObjectCannedACLPublicRead,
106-
ContentType: &in.ContentType,
106+
ContentType: &contentType,
107107
})
108108
return err
109109
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package migrations
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
10+
"git.handmade.network/hmn/hmn/src/assets"
11+
"git.handmade.network/hmn/hmn/src/db"
12+
"git.handmade.network/hmn/hmn/src/migration/types"
13+
"git.handmade.network/hmn/hmn/src/models"
14+
"github.com/jackc/pgx/v5"
15+
)
16+
17+
func init() {
18+
registerMigration(ConvertImageFilesToAssets{})
19+
}
20+
21+
type ConvertImageFilesToAssets struct{}
22+
23+
func (m ConvertImageFilesToAssets) Version() types.MigrationVersion {
24+
return types.MigrationVersion(time.Date(2026, 7, 25, 2, 6, 16, 0, time.UTC))
25+
}
26+
27+
func (m ConvertImageFilesToAssets) Name() string {
28+
return "ConvertImageFilesToAssets"
29+
}
30+
31+
func (m ConvertImageFilesToAssets) Description() string {
32+
return "Uploads all of the image files in the db to S3 and tracks their IDs"
33+
}
34+
35+
// Copied here from `models` because, well, we're about to delete it
36+
type ImageFile struct {
37+
ID int `db:"id"`
38+
File string `db:"file"` // relative to public/media
39+
Size int `db:"size"`
40+
Sha1Sum string `db:"sha1sum"`
41+
Protected bool `db:"protected"`
42+
Height int `db:"height"`
43+
Width int `db:"width"`
44+
}
45+
46+
func (m ConvertImageFilesToAssets) Up(ctx context.Context, tx pgx.Tx) error {
47+
files, err := db.Query[ImageFile](ctx, tx, `SELECT $columns FROM image_file`)
48+
if err != nil {
49+
return err
50+
}
51+
52+
// NOTE(ben): Upload all image files as assets. If somehow this fails and we
53+
// have to roll back the transaction, we will have created a few unused
54+
// assets. OH WELL
55+
newAssets := make(map[int]*models.Asset)
56+
for i, file := range files {
57+
fmt.Printf("Uploading %d of %d: %s...\n", i+1, len(files), file.File)
58+
contents, err := os.ReadFile(filepath.Join("public", "media", file.File))
59+
if err != nil {
60+
return err
61+
}
62+
63+
asset, err := assets.Create(ctx, tx, assets.CreateInput{
64+
Content: contents,
65+
Filename: filepath.Base(file.File),
66+
67+
Width: file.Width,
68+
Height: file.Height,
69+
})
70+
if err != nil {
71+
return err
72+
}
73+
74+
newAssets[file.ID] = asset
75+
}
76+
77+
_, err = tx.Exec(ctx,
78+
`
79+
ALTER TABLE image_file
80+
ADD COLUMN asset_id UUID REFERENCES asset (id) ON DELETE SET NULL;
81+
`,
82+
)
83+
if err != nil {
84+
return err
85+
}
86+
87+
// NOTE(ben): Feels dumb, but we're just going to set all the new IDs using
88+
// one query each. Who cares.
89+
for fileID, asset := range newAssets {
90+
_, err := tx.Exec(ctx,
91+
`
92+
UPDATE image_file SET asset_id = $1 WHERE id = $2
93+
`,
94+
asset.ID, fileID,
95+
)
96+
if err != nil {
97+
return err
98+
}
99+
}
100+
101+
// NOTE(ben): Sanity check
102+
numNull, err := db.QueryOneScalar[int](ctx, tx, `SELECT COUNT(*) FROM image_file WHERE asset_id IS NULL`)
103+
if err != nil {
104+
return err
105+
}
106+
if numNull != 0 {
107+
return fmt.Errorf("expected all image files to get assets")
108+
}
109+
110+
return nil
111+
}
112+
113+
func (m ConvertImageFilesToAssets) Down(ctx context.Context, tx pgx.Tx) error {
114+
_, err := tx.Exec(ctx,
115+
`
116+
ALTER TABLE image_file
117+
DROP COLUMN asset_id;
118+
`,
119+
)
120+
return err
121+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package migrations
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"git.handmade.network/hmn/hmn/src/migration/types"
8+
"github.com/jackc/pgx/v5"
9+
)
10+
11+
func init() {
12+
registerMigration(DeleteImageFile{})
13+
}
14+
15+
type DeleteImageFile struct{}
16+
17+
func (m DeleteImageFile) Version() types.MigrationVersion {
18+
return types.MigrationVersion(time.Date(2026, 7, 25, 2, 48, 1, 0, time.UTC))
19+
}
20+
21+
func (m DeleteImageFile) Name() string {
22+
return "DeleteImageFile"
23+
}
24+
25+
func (m DeleteImageFile) Description() string {
26+
return "Removes the imagefile table, replacing all uses with assets IDs"
27+
}
28+
29+
func (m DeleteImageFile) Up(ctx context.Context, tx pgx.Tx) error {
30+
// NOTE(ben): Only project screenshots and podcast art uses image files.
31+
_, err := tx.Exec(ctx,
32+
`
33+
ALTER TABLE project_screenshot
34+
ADD COLUMN asset_id UUID REFERENCES asset (id) ON DELETE CASCADE;
35+
ALTER TABLE podcast
36+
ADD COLUMN image_asset UUID REFERENCES asset (id) ON DELETE SET NULL;
37+
38+
UPDATE project_screenshot
39+
SET asset_id = image_file.asset_id
40+
FROM image_file
41+
WHERE project_screenshot.imagefile_id = image_file.id;
42+
43+
UPDATE podcast
44+
SET image_asset = image_file.asset_id
45+
FROM image_file
46+
WHERE podcast.image_id = image_file.id;
47+
`,
48+
)
49+
if err != nil {
50+
return err
51+
}
52+
53+
_, err = tx.Exec(ctx, `
54+
ALTER TABLE project_screenshot
55+
DROP COLUMN imagefile_id;
56+
ALTER TABLE podcast
57+
DROP COLUMN image_id;
58+
DROP TABLE image_file;
59+
`)
60+
if err != nil {
61+
return err
62+
}
63+
64+
return nil
65+
}
66+
67+
func (m DeleteImageFile) Down(ctx context.Context, tx pgx.Tx) error {
68+
_, err := tx.Exec(ctx, `
69+
ALTER TABLE project_screenshot DROP COLUMN asset_id;
70+
ALTER TABLE podcast DROP COLUMN image_asset;
71+
72+
CREATE TABLE image_file (
73+
id INTEGER NOT NULL PRIMARY KEY,
74+
file VARCHAR(255) NOT NULL,
75+
size INTEGER NOT NULL,
76+
sha1sum VARCHAR(40) NOT NULL,
77+
protected BOOLEAN NOT NULL,
78+
height INTEGER NOT NULL,
79+
width INTEGER NOT NULL,
80+
asset_id UUID REFERENCES asset (id) ON DELETE SET NULL
81+
);
82+
CREATE SEQUENCE image_file_id_seq OWNED BY image_file.id;
83+
ALTER TABLE image_file ALTER COLUMN id SET DEFAULT nextval('image_file_id_seq');
84+
85+
ALTER TABLE project_screenshot
86+
ADD COLUMN imagefile_id INTEGER REFERENCES image_file (id);
87+
ALTER TABLE podcast
88+
ADD COLUMN image_id INTEGER REFERENCES image_file (id);
89+
90+
CREATE INDEX ON project_screenshot (imagefile_id);
91+
ALTER TABLE project_screenshot
92+
ADD CONSTRAINT project_screenshot_project_id_imagefile_id_uniq UNIQUE (project_id, imagefile_id);
93+
94+
CREATE INDEX ON podcast (image_id);
95+
`)
96+
return err
97+
}

src/models/podcast.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import (
77
)
88

99
type Podcast struct {
10-
ID int `db:"id"`
11-
ImageID int `db:"image_id"`
12-
ProjectID int `db:"project_id"`
10+
ID int `db:"id"`
11+
ImageID *uuid.UUID `db:"image_asset"`
12+
ProjectID int `db:"project_id"`
1313

1414
Title string `db:"title"`
1515
Description string `db:"description"`

src/templates/mapping.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -430,10 +430,10 @@ func SnippetEditProjectsToJSON(projects []Project) string {
430430
return builder.String()
431431
}
432432

433-
func PodcastToTemplate(podcast *models.Podcast, imageFilename string) Podcast {
434-
imageUrl := ""
435-
if imageFilename != "" {
436-
imageUrl = hmnurl.BuildUserFile(imageFilename)
433+
func PodcastToTemplate(podcast *models.Podcast, image *models.Asset) Podcast {
434+
var imageUrl string
435+
if image != nil {
436+
imageUrl = hmnurl.BuildS3Asset(image.S3Key)
437437
}
438438
return Podcast{
439439
Title: podcast.Title,
@@ -450,10 +450,10 @@ func PodcastToTemplate(podcast *models.Podcast, imageFilename string) Podcast {
450450
}
451451
}
452452

453-
func PodcastEpisodeToTemplate(episode *models.PodcastEpisode, audioFileSize int64, imageFilename string) PodcastEpisode {
454-
imageUrl := ""
455-
if imageFilename != "" {
456-
imageUrl = hmnurl.BuildUserFile(imageFilename)
453+
func PodcastEpisodeToTemplate(episode *models.PodcastEpisode, image *models.Asset, audioFileSize int64) PodcastEpisode {
454+
var imageUrl string
455+
if image != nil {
456+
imageUrl = hmnurl.BuildS3Asset(image.S3Key)
457457
}
458458
return PodcastEpisode{
459459
GUID: episode.GUID.String(),

0 commit comments

Comments
 (0)