1
0
mirror of https://github.com/raseels-repos/golang-saas-starter-kit.git synced 2025-06-06 23:46:29 +02:00
2019-07-13 11:42:37 -08:00

393 lines
11 KiB
Go

package img_resize
import (
"bytes"
"context"
"crypto/md5"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io/ioutil"
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/nfnt/resize"
"github.com/pkg/errors"
"github.com/sethgrid/pester"
redistrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
)
// S3ImgUrl parses the original url from an srcset
func S3ImgUrl(ctx context.Context, redisClient *redistrace.Client, s3UrlFormatter func(string) string, awsSession *session.Session, s3Bucket, S3KeyPrefix, p string, size int) (string, error) {
src, err := S3ImgSrc(ctx, redisClient, s3UrlFormatter, awsSession, s3Bucket, S3KeyPrefix, p, []int{size}, true)
if err != nil {
return "", err
}
var imgUrl string
if strings.Contains(src, "srcset=\"") {
imgUrl = strings.Split(src, "srcset=\"")[1]
imgUrl = strings.Trim(strings.Split(imgUrl, ",")[0], "\"")
} else if strings.Contains(src, "src=\"") {
imgUrl = strings.Split(src, "src=\"")[1]
imgUrl = strings.Trim(strings.Split(imgUrl, ",")[0], "\"")
} else {
imgUrl = src
}
if strings.Contains(imgUrl, " ") {
imgUrl = strings.Split(imgUrl, " ")[0]
}
return imgUrl, nil
}
// S3ImgSrc returns an srcset for a given image url and defined sizes
// Format the local image path to the fully qualified image URL,
// on stage and prod the app will not have access to the local image
// files if App.StaticS3 is enabled.
func S3ImgSrc(ctx context.Context, redisClient *redistrace.Client, s3UrlFormatter func(string) string, awsSession *session.Session, s3Bucket, s3KeyPrefix, imgUrlStr string, sizes []int, includeOrig bool) (string, error) {
// Default return value on error.
defaultSrc := fmt.Sprintf(`src="%s"`, imgUrlStr)
// Only fully qualified image URLS are supported. On dev the app host should
// still be included as this lacks the concept of the static directory.
if !strings.HasPrefix(imgUrlStr, "http") {
return defaultSrc, nil
}
// Extract the image path from the URL.
imgUrl, err := url.Parse(imgUrlStr)
if err != nil {
return defaultSrc, errors.WithStack(err)
}
// Determine the file extension for the image path.
pts := strings.Split(imgUrl.Path, ".")
filExt := strings.ToLower(pts[len(pts)-1])
if filExt == "jpg" {
filExt = ".jpg"
} else if filExt == "jpeg" {
filExt = ".jpeg"
} else if filExt == "gif" {
filExt = ".gif"
} else if filExt == "png" {
filExt = ".png"
} else {
return defaultSrc, nil
}
// Cache Key used by Redis for storing the resulting image src to avoid having to
// regenerate on each page load.
data := []byte(fmt.Sprintf("S3ImgSrc:%s:%v:%v", imgUrlStr, sizes, includeOrig))
ck := fmt.Sprintf("%x", md5.Sum(data))
// Check redis for the cache key.
var imgSrc string
cv, err := redisClient.WithContext(ctx).Get(ck).Result()
if err != nil {
// TODO: log the error as a warning
} else if len(cv) > 0 {
imgSrc = string(cv)
}
if imgSrc == "" {
// Make the http request to retrieve the image.
res, err := pester.Get(imgUrl.String())
if err != nil {
return imgSrc, errors.WithStack(err)
}
defer res.Body.Close()
// Validate the http status is OK and request did not fail.
if res.StatusCode != http.StatusOK {
err = errors.Errorf("Request failed with statusCode %v for %s", res.StatusCode, imgUrlStr)
return defaultSrc, errors.WithStack(err)
}
// Read all the image bytes.
dat, err := ioutil.ReadAll(res.Body)
if err != nil {
return defaultSrc, errors.WithStack(err)
}
//if hv, ok := res.Request.Response.Header["Last-Modified"]; ok && len(hv) > 0 {
// // Expires: Sun, 03 May 2015 23:02:37 GMT
// http.ParseTime(hv[0])
//}
// s3Path is the base s3 key to store all the associated resized images.
// Store the by the image host + path
s3Path := filepath.Join(s3KeyPrefix, fmt.Sprintf("%x", md5.Sum([]byte(imgUrl.Host+imgUrl.Path))))
// baseImgName is the base image filename
// Extract the image filename from the url
baseImgName := filepath.Base(imgUrl.Path)
// If the image has a query string, append md5 and append to s3Path
if len(imgUrl.Query()) > 0 {
qh := fmt.Sprintf("%x", md5.Sum([]byte(imgUrl.Query().Encode())))
s3Path = s3Path + "q" + qh
// Update the base image name to include the query string hash
pts := strings.Split(baseImgName, ".")
if len(pts) >= 2 {
pts[len(pts)-2] = pts[len(pts)-2] + "-" + qh
baseImgName = strings.Join(pts, ".")
} else {
baseImgName = baseImgName + "-" + qh
}
}
// checkSum is used to determine if the contents of the src file changed.
var checkSum string
// Try to pull a value from the response headers to be used as a checksum
if hv, ok := res.Header["ETag"]; ok && len(hv) > 0 {
// ETag: "5485fac7-ae74"
checkSum = strings.Trim(hv[0], "\"")
} else if hv, ok := res.Header["Last-Modified"]; ok && len(hv) > 0 {
// Last-Modified: Mon, 08 Dec 2014 19:23:51 GMT
checkSum = fmt.Sprintf("%x", md5.Sum([]byte(hv[0])))
} else {
checkSum = fmt.Sprintf("%x", md5.Sum(dat))
}
// Append the checkSum to the s3Path
s3Path = filepath.Join(s3Path, checkSum)
// Init new CloudFront using provided AWS session.
s3srv := s3.New(awsSession)
// List all the current images that exist on s3 for the s3 path.
// New files will have none until they are generated below and uploaded.
listRes, err := s3srv.ListObjects(&s3.ListObjectsInput{
Bucket: aws.String(s3Bucket),
Prefix: aws.String(s3Path),
})
if err != nil {
return defaultSrc, errors.WithStack(err)
}
// Loop through all the S3 objects and store by in map by
// filename with its current lastModified time
curFiles := make(map[string]time.Time)
if listRes != nil && listRes.Contents != nil {
for _, obj := range listRes.Contents {
fname := filepath.Base(*obj.Key)
curFiles[fname] = obj.LastModified.UTC()
}
}
pts := strings.Split(baseImgName, ".")
var uidx int
if len(pts) >= 2 {
uidx = len(pts) - 2
}
var maxSize int
expFiles := make(map[int]string)
for _, s := range sizes {
spts := pts
spts[uidx] = fmt.Sprintf("%s-%dw", spts[uidx], s)
nname := strings.Join(spts, ".")
expFiles[s] = nname
if s > maxSize {
maxSize = s
}
}
renderFiles := make(map[int]string)
for s, fname := range expFiles {
if _, ok := curFiles[fname]; !ok {
// Image does not exist, render
renderFiles[s] = fname
}
}
if len(renderFiles) > 0 {
uploader := s3manager.NewUploaderWithClient(s3srv, func(d *s3manager.Uploader) {
//d.PartSize = s.UploadPartSize
//d.Concurrency = s.UploadConcurrency
})
for s, fname := range renderFiles {
// Render new image with specified width, height of
// of 0 will preserve the current aspect ratio.
var (
contentType string
uploadBytes []byte
)
if filExt == ".gif" {
contentType = "image/gif"
uploadBytes, err = ResizeGif(dat, uint(s), 0)
} else if filExt == ".png" {
contentType = "image/png"
uploadBytes, err = ResizePng(dat, uint(s), 0)
} else {
contentType = "image/jpeg"
uploadBytes, err = ResizeJpg(dat, uint(s), 0)
}
if err != nil {
return defaultSrc, errors.WithStack(err)
}
// The s3 key for the newly resized image file.
renderedS3Key := filepath.Join(s3Path, fname)
// Upload the s3 key with the resized image bytes.
p := &s3manager.UploadInput{
Bucket: aws.String(s3Bucket),
Key: aws.String(renderedS3Key),
Body: bytes.NewReader(uploadBytes),
Metadata: map[string]*string{
"Content-Type": aws.String(contentType),
"Cache-Control": aws.String("max-age=604800"),
},
}
_, err = uploader.Upload(p)
if err != nil {
return defaultSrc, errors.WithStack(err)
}
// Grant public read access to the uploaded image file.
_, err = s3srv.PutObjectAcl(&s3.PutObjectAclInput{
Bucket: aws.String(s3Bucket),
Key: aws.String(renderedS3Key),
ACL: aws.String("public-read"),
})
if err != nil {
return defaultSrc, errors.WithStack(err)
}
}
}
// Determine the current width of the image, don't need height since will be using
// maintain the current aspect ratio.
lw, _, err := getImageDimension(dat)
if includeOrig {
if lw > maxSize && (!strings.HasPrefix(imgUrlStr, "http") || strings.HasPrefix(imgUrlStr, "https:")) {
maxSize = lw
sizes = append(sizes, lw)
}
} else {
maxSize = sizes[len(sizes)-1]
}
sort.Ints(sizes)
var srcUrl string
srcSets := []string{}
srcSizes := []string{}
for _, s := range sizes {
var nu string
if lw == s {
nu = imgUrlStr
} else {
fname := expFiles[s]
nk := filepath.Join(s3Path, fname)
nu = s3UrlFormatter(nk)
}
srcSets = append(srcSets, fmt.Sprintf("%s %dw", nu, s))
if s == maxSize {
srcSizes = append(srcSizes, fmt.Sprintf("%dpx", s))
srcUrl = nu
} else {
srcSizes = append(srcSizes, fmt.Sprintf("(max-width: %dpx) %dpx", s, s))
}
}
imgSrc = fmt.Sprintf(`srcset="%s" sizes="%s" src="%s"`, strings.Join(srcSets, ","), strings.Join(srcSizes, ","), srcUrl)
}
err = redisClient.WithContext(ctx).Set(ck, imgSrc, 0).Err()
if err != nil {
return imgSrc, errors.WithStack(err)
}
return imgSrc, nil
}
// ResizeJpg resizes a JPG image file to specified width and height using
// lanczos resampling and preserving the aspect ratio.
func ResizeJpg(dat []byte, width, height uint) ([]byte, error) {
// decode jpeg into image.Image
img, err := jpeg.Decode(bytes.NewReader(dat))
if err != nil {
return []byte{}, errors.WithStack(err)
}
// resize to width 1000 using Lanczos resampling
// and preserve aspect ratio
m := resize.Resize(width, height, img, resize.NearestNeighbor)
// write new image to file
var out = new(bytes.Buffer)
jpeg.Encode(out, m, nil)
return out.Bytes(), nil
}
// ResizeGif resizes a GIF image file to specified width and height using
// lanczos resampling and preserving the aspect ratio.
func ResizeGif(dat []byte, width, height uint) ([]byte, error) {
// decode gif into image.Image
img, err := gif.Decode(bytes.NewReader(dat))
if err != nil {
return []byte{}, errors.WithStack(err)
}
// resize to width 1000 using Lanczos resampling
// and preserve aspect ratio
m := resize.Resize(width, height, img, resize.NearestNeighbor)
// write new image to file
var out = new(bytes.Buffer)
gif.Encode(out, m, nil)
return out.Bytes(), nil
}
// ResizePng resizes a PNG image file to specified width and height using
// lanczos resampling and preserving the aspect ratio.
func ResizePng(dat []byte, width, height uint) ([]byte, error) {
// decode png into image.Image
img, err := png.Decode(bytes.NewReader(dat))
if err != nil {
return []byte{}, errors.WithStack(err)
}
// resize to width 1000 using Lanczos resampling
// and preserve aspect ratio
m := resize.Resize(width, height, img, resize.NearestNeighbor)
// write new image to file
var out = new(bytes.Buffer)
png.Encode(out, m)
return out.Bytes(), nil
}
// getImageDimension returns the width and height for a given local file path
func getImageDimension(dat []byte) (int, int, error) {
image, _, err := image.DecodeConfig(bytes.NewReader(dat))
if err != nil {
return 0, 0, errors.WithStack(err)
}
return image.Width, image.Height, nil
}