2021-08-21 16:15:27 +00:00
|
|
|
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 {
|
2021-08-23 21:52:57 +00:00
|
|
|
Content []byte
|
|
|
|
Filename string
|
|
|
|
ContentType string
|
2021-08-21 16:15:27 +00:00
|
|
|
|
|
|
|
// Optional params
|
|
|
|
UploaderID *int // HMN user id
|
|
|
|
Width, Height int
|
|
|
|
}
|
|
|
|
|
2021-09-22 19:18:39 +00:00
|
|
|
var REIllegalFilenameChars = regexp.MustCompile(`[^\w\-.]`)
|
2021-08-21 16:15:27 +00:00
|
|
|
|
|
|
|
func SanitizeFilename(filename string) string {
|
|
|
|
if filename == "" {
|
|
|
|
return "unnamed"
|
|
|
|
}
|
2021-09-22 19:18:39 +00:00
|
|
|
return REIllegalFilenameChars.ReplaceAllString(filename, "_")
|
2021-08-21 16:15:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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))
|
|
|
|
}
|
2021-08-23 21:52:57 +00:00
|
|
|
if in.ContentType == "" {
|
|
|
|
return nil, InvalidAssetError(fmt.Errorf("could not upload asset '%s': no content type provided", filename))
|
2021-08-21 16:15:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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{
|
2021-10-24 23:30:28 +00:00
|
|
|
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
|
|
|
|
Key: &key,
|
|
|
|
Body: bytes.NewReader(in.Content),
|
|
|
|
ACL: types.ObjectCannedACLPublicRead,
|
|
|
|
ContentType: &in.ContentType,
|
2021-08-21 16:15:27 +00:00
|
|
|
})
|
|
|
|
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,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
INSERT INTO asset (id, s3_key, filename, size, mime_type, sha1sum, width, height, uploader_id)
|
2021-08-21 16:15:27 +00:00
|
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
|
|
`,
|
|
|
|
id,
|
|
|
|
key,
|
|
|
|
filename,
|
|
|
|
len(in.Content),
|
2021-08-23 21:52:57 +00:00
|
|
|
in.ContentType,
|
2021-08-21 16:15:27 +00:00
|
|
|
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
|
2022-04-16 17:49:29 +00:00
|
|
|
asset, err := db.QueryOne[models.Asset](ctx, dbConn,
|
2021-08-21 16:15:27 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM asset
|
2021-08-21 16:15:27 +00:00
|
|
|
WHERE id = $1
|
|
|
|
`,
|
|
|
|
id,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return nil, oops.New(err, "failed to fetch newly-created asset")
|
|
|
|
}
|
|
|
|
|
2022-04-16 17:49:29 +00:00
|
|
|
return asset, nil
|
2021-08-21 16:15:27 +00:00
|
|
|
}
|