368 lines
9.7 KiB
Go
368 lines
9.7 KiB
Go
package assets
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha1"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.handmade.network/hmn/hmn/src/config"
|
|
"git.handmade.network/hmn/hmn/src/db"
|
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
|
"git.handmade.network/hmn/hmn/src/jobs"
|
|
"git.handmade.network/hmn/hmn/src/logging"
|
|
"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"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
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
|
|
ContentType 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", 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.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))
|
|
|
|
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,
|
|
})
|
|
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")
|
|
}
|
|
}
|
|
|
|
var thumbnailKey *string
|
|
|
|
width := in.Width
|
|
height := in.Height
|
|
if previewBytes, thumbWidth, thumbHeight, err := ExtractPreview(ctx, in.ContentType, in.Content); err != nil {
|
|
logging.Error().Err(err).Msg("Failed to generate preview for asset")
|
|
} else if len(previewBytes) > 0 {
|
|
keyStr := AssetKey(id.String(), fmt.Sprintf("%s_thumb.jpg", id.String()))
|
|
thumbnailType := "image/jpeg"
|
|
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
|
|
Key: &keyStr,
|
|
Body: bytes.NewReader(previewBytes),
|
|
ACL: types.ObjectCannedACLPublicRead,
|
|
ContentType: &thumbnailType,
|
|
})
|
|
if err != nil {
|
|
logging.Error().Err(err).Msg("Failed to upload thumbnail for video")
|
|
} else {
|
|
thumbnailKey = &keyStr
|
|
}
|
|
|
|
if width == 0 || height == 0 {
|
|
width = thumbWidth
|
|
height = thumbHeight
|
|
}
|
|
}
|
|
|
|
// Save a record in our database
|
|
// TODO(db): Would be convient to use RETURNING here...
|
|
_, err = dbConn.Exec(ctx,
|
|
`
|
|
INSERT INTO asset (id, s3_key, thumbnail_s3_key, filename, size, mime_type, sha1sum, width, height, uploader_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
`,
|
|
id,
|
|
key,
|
|
thumbnailKey,
|
|
filename,
|
|
len(in.Content),
|
|
in.ContentType,
|
|
checksum,
|
|
width,
|
|
height,
|
|
in.UploaderID,
|
|
)
|
|
if err != nil {
|
|
return nil, oops.New(err, "failed to save asset record")
|
|
}
|
|
|
|
// Fetch and return the new record
|
|
asset, err := db.QueryOne[models.Asset](ctx, dbConn,
|
|
`
|
|
SELECT $columns
|
|
FROM asset
|
|
WHERE id = $1
|
|
`,
|
|
id,
|
|
)
|
|
if err != nil {
|
|
return nil, oops.New(err, "failed to fetch newly-created asset")
|
|
}
|
|
|
|
return asset, nil
|
|
}
|
|
|
|
func getFFMpegPath() string {
|
|
path := config.Config.PreviewGeneration.FFMpegPath
|
|
if path != "" {
|
|
return path
|
|
}
|
|
var err error
|
|
path, err = exec.LookPath("ffmpeg")
|
|
if err == nil {
|
|
return path
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func ExtractPreview(ctx context.Context, mimeType string, inBytes []byte) ([]byte, int, int, error) {
|
|
log := logging.ExtractLogger(ctx)
|
|
|
|
execPath := getFFMpegPath()
|
|
if execPath == "" {
|
|
return nil, 0, 0, nil
|
|
}
|
|
|
|
if !strings.HasPrefix(mimeType, "video") {
|
|
return nil, 0, 0, nil
|
|
}
|
|
|
|
file, err := os.CreateTemp("", "hmnasset")
|
|
if err != nil {
|
|
return nil, 0, 0, oops.New(err, "Failed to create temp file for preview generation")
|
|
}
|
|
defer os.Remove(file.Name())
|
|
_, err = file.Write(inBytes)
|
|
if err != nil {
|
|
return nil, 0, 0, oops.New(err, "Failed to write to temp file for preview generation")
|
|
}
|
|
err = file.Close()
|
|
if err != nil {
|
|
return nil, 0, 0, oops.New(err, "Failed to close temp file for preview generation")
|
|
}
|
|
|
|
args := fmt.Sprintf("-i %s -filter_complex [0]select=gte(n\\,1)[s0] -map [s0] -c:v mjpeg -f mjpeg -vframes 1 pipe:1", file.Name())
|
|
if config.Config.PreviewGeneration.CPULimitPath != "" {
|
|
args = fmt.Sprintf("-l 10 -- %s %s", execPath, args)
|
|
execPath = config.Config.PreviewGeneration.CPULimitPath
|
|
}
|
|
ffmpegCmd := exec.CommandContext(ctx, execPath, strings.Split(args, " ")...)
|
|
|
|
var output bytes.Buffer
|
|
var errorOut bytes.Buffer
|
|
ffmpegCmd.Stdout = &output
|
|
ffmpegCmd.Stderr = &errorOut
|
|
err = ffmpegCmd.Run()
|
|
if err != nil {
|
|
log.Error().Str("ffmpeg output", errorOut.String()).Msg("FFMpeg returned error while generating preview thumbnail")
|
|
return nil, 0, 0, oops.New(err, "FFMpeg failed for preview generation")
|
|
}
|
|
|
|
imageBytes := output.Bytes()
|
|
cfg, _, err := image.DecodeConfig(bytes.NewBuffer(imageBytes))
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to get width/height from video thumbnail")
|
|
return nil, 0, 0, oops.New(err, "FFMpeg failed for preview generation")
|
|
}
|
|
|
|
return imageBytes, cfg.Width, cfg.Height, nil
|
|
}
|
|
|
|
func BackgroundPreviewGeneration(ctx context.Context, conn *pgxpool.Pool) jobs.Job {
|
|
log := logging.ExtractLogger(ctx).With().Str("module", "preview_gen").Logger()
|
|
job := jobs.New()
|
|
|
|
go func() {
|
|
defer job.Done()
|
|
log.Debug().Msg("Starting preview gen job")
|
|
|
|
if getFFMpegPath() == "" {
|
|
log.Warn().Msg("Couldn't find ffmpeg! No thumbnails will be generated.")
|
|
return
|
|
}
|
|
|
|
assets, err := db.Query[models.Asset](ctx, conn,
|
|
`
|
|
SELECT $columns
|
|
FROM asset
|
|
WHERE
|
|
mime_type LIKE 'video%'
|
|
AND (
|
|
thumbnail_s3_key IS NULL
|
|
OR thumbnail_s3_key = ''
|
|
OR thumbnail_s3_key LIKE '%.png'
|
|
OR width = 0
|
|
OR height = 0
|
|
)
|
|
`,
|
|
)
|
|
if err != nil {
|
|
log.Error().Err(oops.New(err, "Failed to fetch assets for preview generation")).Msg("Asset preview generation job failed")
|
|
return
|
|
}
|
|
|
|
log.Debug().Int("Num assets", len(assets)).Msg("Processing...")
|
|
|
|
for _, asset := range assets {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
log := log.With().Str("AssetID", asset.ID.String()).Logger()
|
|
ctx := logging.AttachLoggerToContext(&log, ctx)
|
|
|
|
log.Debug().Msg("Generating preview")
|
|
assetUrl := hmnurl.BuildS3Asset(asset.S3Key)
|
|
resp, err := http.Get(assetUrl)
|
|
if err != nil || resp.StatusCode != 200 {
|
|
log.Error().Err(err).Msg("Failed to fetch asset file for preview generation")
|
|
continue
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to read asset body for preview generation")
|
|
continue
|
|
}
|
|
thumbBytes, width, height, err := ExtractPreview(ctx, asset.MimeType, body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to run extraction for preview generation")
|
|
continue
|
|
} else if len(thumbBytes) > 0 {
|
|
keyStr := AssetKey(asset.ID.String(), fmt.Sprintf("%s_thumb.jpg", asset.ID.String()))
|
|
thumbnailType := "image/jpeg"
|
|
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
|
|
Key: &keyStr,
|
|
Body: bytes.NewReader(thumbBytes),
|
|
ACL: types.ObjectCannedACLPublicRead,
|
|
ContentType: &thumbnailType,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to upload thumbnail for video")
|
|
continue
|
|
}
|
|
|
|
_, err = conn.Exec(ctx,
|
|
`
|
|
UPDATE asset
|
|
SET
|
|
thumbnail_s3_key = $1,
|
|
width = $2,
|
|
height = $3
|
|
WHERE asset.id = $4
|
|
`,
|
|
keyStr,
|
|
width,
|
|
height,
|
|
asset.ID,
|
|
)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to update asset for preview generation")
|
|
continue
|
|
}
|
|
log.Debug().Msg("Generated preview successfully!")
|
|
} else {
|
|
log.Debug().Msg("No error, but no thumbnail was generated, skipping")
|
|
}
|
|
}
|
|
log.Debug().Msg("No more previews to generate")
|
|
}()
|
|
|
|
return job
|
|
}
|