hmn/src/assets/assets.go

156 lines
3.8 KiB
Go

package assets
import (
"bytes"
"context"
"crypto/sha1"
"errors"
"fmt"
"regexp"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"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"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"github.com/google/uuid"
)
var client *s3.Client
func init() {
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(
config.Config.DigitalOcean.AssetsSpacesKey,
config.Config.DigitalOcean.AssetsSpacesSecret,
"",
),
),
awsconfig.WithRegion(config.Config.DigitalOcean.AssetsSpacesRegion),
awsconfig.WithEndpointResolver(aws.EndpointResolverFunc(func(service, region string) (aws.Endpoint, error) {
return aws.Endpoint{
URL: config.Config.DigitalOcean.AssetsSpacesEndpoint,
}, nil
})),
)
if err != nil {
panic(err)
}
client = s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
}
type CreateInput struct {
Content []byte
Filename string
MimeType string
// Optional params
UploaderID *int // HMN user id
Width, Height int
}
var REIllegalFilenameChars = regexp.MustCompile(`[^\w \-.]`)
func SanitizeFilename(filename string) string {
if filename == "" {
return "unnamed"
}
return REIllegalFilenameChars.ReplaceAllString(filename, "")
}
func AssetKey(id, filename string) string {
return fmt.Sprintf("%s%s/%s", config.Config.DigitalOcean.AssetsPathPrefix, id, filename)
}
type InvalidAssetError error
func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.Asset, error) {
filename := SanitizeFilename(in.Filename)
if len(in.Content) == 0 {
return nil, InvalidAssetError(fmt.Errorf("could not upload asset '%s': no bytes of data were provided", filename))
}
if in.MimeType == "" {
return nil, InvalidAssetError(fmt.Errorf("could not upload asset '%s': no mime 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))
upload := func() error {
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
Key: &key,
Body: bytes.NewReader(in.Content),
ACL: types.ObjectCannedACLPublicRead,
})
return err
}
err := upload()
if err != nil {
var apiError smithy.APIError
if errors.As(err, &apiError) && apiError.ErrorCode() == "NoSuchBucket" {
_, err := client.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
})
if err != nil {
return nil, oops.New(err, "failed to create assets bucket")
}
err = upload()
if err != nil {
return nil, oops.New(err, "failed to upload asset")
}
} else {
return nil, oops.New(err, "failed to upload asset")
}
}
// Save a record in our database
// TODO(db): Would be convient to use RETURNING here...
_, err = dbConn.Exec(ctx,
`
INSERT INTO handmade_asset (id, s3_key, filename, size, mime_type, sha1sum, width, height, uploader_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`,
id,
key,
filename,
len(in.Content),
in.MimeType,
checksum,
in.Width,
in.Height,
in.UploaderID,
)
if err != nil {
return nil, oops.New(err, "failed to save asset record")
}
// Fetch and return the new record
iasset, err := db.QueryOne(ctx, dbConn, models.Asset{},
`
SELECT $columns
FROM handmade_asset
WHERE id = $1
`,
id,
)
if err != nil {
return nil, oops.New(err, "failed to fetch newly-created asset")
}
return iasset.(*models.Asset), nil
}