diff --git a/config.go b/config.go index 7ab1fcf..93cebf0 100644 --- a/config.go +++ b/config.go @@ -174,6 +174,7 @@ type configMicropubMedia struct { BunnyStorageKey string `mapstructure:"bunnyStorageKey"` BunnyStorageName string `mapstructure:"bunnyStorageName"` TinifyKey string `mapstructure:"tinifyKey"` + ShortPixelKey string `mapstructure:"shortPixelKey"` } type configRegexRedirect struct { diff --git a/micropubMedia.go b/micropubMedia.go index 8aa3d43..d804eee 100644 --- a/micropubMedia.go +++ b/micropubMedia.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "crypto/sha256" + "encoding/json" "errors" "fmt" "io" @@ -25,10 +27,6 @@ func serveMicropubMedia(w http.ResponseWriter, r *http.Request) { serveError(w, r, "media scope missing", http.StatusForbidden) return } - if appConfig.Micropub.MediaStorage == nil { - serveError(w, r, "Not configured", http.StatusNotImplemented) - return - } if ct := r.Header.Get(contentType); !strings.Contains(ct, contentTypeMultipartForm) { serveError(w, r, "wrong content-type", http.StatusBadRequest) return @@ -70,15 +68,30 @@ func serveMicropubMedia(w http.ResponseWriter, r *http.Request) { return } // Try to compress file - if ms := appConfig.Micropub.MediaStorage; ms != nil && ms.TinifyKey != "" { - compressedLocation, err := tinify(location, ms) - if err != nil { - serveError(w, r, "failed to compress file: "+err.Error(), http.StatusInternalServerError) + if ms := appConfig.Micropub.MediaStorage; ms != nil { + serveCompressionError := func(ce error) { + serveError(w, r, "failed to compress file: "+ce.Error(), http.StatusInternalServerError) + } + var compressedLocation string + var compressionErr error + // Default ShortPixel + if ms.ShortPixelKey != "" { + compressedLocation, compressionErr = shortPixel(location, ms) + } + if compressionErr != nil { + serveCompressionError(compressionErr) return - } else if compressedLocation != "" { + } + // Fallback Tinify + if compressedLocation == "" && ms.TinifyKey != "" { + compressedLocation, compressionErr = tinify(location, ms) + } + if compressionErr != nil { + serveCompressionError(compressionErr) + return + } + if compressedLocation != "" { location = compressedLocation - } else { - serveError(w, r, "No compressed location", http.StatusInternalServerError) } } http.Redirect(w, r, location, http.StatusCreated) @@ -96,10 +109,13 @@ func uploadFile(filename string, f io.Reader) (string, error) { if ms != nil && ms.MediaURL != "" { return ms.MediaURL + loc, nil } - return loc, nil + return appConfig.Server.PublicAddress + loc, nil } func uploadToBunny(filename string, f io.Reader, config *configMicropubMedia) (location string, err error) { + if config == nil || config.BunnyStorageName == "" || config.BunnyStorageKey == "" || config.MediaURL == "" { + return "", errors.New("Bunny storage not completely configured") + } req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("https://storage.bunnycdn.com/%s/%s", url.PathEscape(config.BunnyStorageName), url.PathEscape(filename)), f) req.Header.Add("AccessKey", config.BunnyStorageKey) resp, err := http.DefaultClient.Do(req) @@ -110,53 +126,118 @@ func uploadToBunny(filename string, f io.Reader, config *configMicropubMedia) (l } func tinify(url string, config *configMicropubMedia) (location string, err error) { - fileExtension := func() string { - spliced := strings.Split(url, ".") - return spliced[len(spliced)-1] - }() - supportedTypes := []string{"jpg", "jpeg", "png"} - sort.Strings(supportedTypes) - i := sort.SearchStrings(supportedTypes, strings.ToLower(fileExtension)) - if !(i < len(supportedTypes) && supportedTypes[i] == strings.ToLower(fileExtension)) { + // Check config + if config == nil || config.TinifyKey == "" { + return "", errors.New("Tinify not configured") + } + // Check url + fileExtension, allowed := compressionIsSupported(url, "jpg", "jpeg", "png") + if !allowed { return "", nil } + // Compress tfgo.SetKey(config.TinifyKey) s, err := tfgo.FromUrl(url) if err != nil { return "", err } - err = s.Resize(&tfgo.ResizeOption{ + if err = s.Resize(&tfgo.ResizeOption{ Method: tfgo.ResizeMethodScale, Width: 2000, - }) - if err != nil { + }); err != nil { return "", err } - file, err := ioutil.TempFile("", "tiny-*."+fileExtension) + tmpFile, err := ioutil.TempFile("", "tiny-*."+fileExtension) if err != nil { return "", err } defer func() { - _ = file.Close() - _ = os.Remove(file.Name()) + _ = tmpFile.Close() + _ = os.Remove(tmpFile.Name()) }() - err = s.ToFile(file.Name()) + if err = s.ToFile(tmpFile.Name()); err != nil { + return "", err + } + fileName, err := hashFile(tmpFile.Name()) if err != nil { return "", err } - hashFile, err := os.Open(file.Name()) - defer func() { _ = hashFile.Close() }() - if err != nil { - return "", err - } - fileName, err := getSHA256(hashFile) - if err != nil { - return "", err - } - location, err = uploadFile(fileName+"."+fileExtension, file) + // Upload compressed file + location, err = uploadFile(fileName+"."+fileExtension, tmpFile) return } +func shortPixel(url string, config *configMicropubMedia) (location string, err error) { + // Check config + if config == nil || config.ShortPixelKey == "" { + return "", errors.New("ShortPixel not configured") + } + // Check url + fileExtension, allowed := compressionIsSupported(url, "jpg", "jpeg", "png") + if !allowed { + return "", nil + } + // Compress + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(map[string]interface{}{ + "key": config.ShortPixelKey, + "plugin_version": "GB001", + "lossy": 1, + "resize": 3, + "resize_width": 2000, + "resize_height": 3000, + "cmyk2rgb": 1, + "keep_exif": 0, + "url": url, + }) + req, err := http.NewRequest(http.MethodPut, "https://api.shortpixel.com/v2/reducer-sync.php", &buf) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } else if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to compress image, status code %d", resp.StatusCode) + } + tmpFile, err := ioutil.TempFile("", "tiny-*."+fileExtension) + if err != nil { + return "", err + } + tmpFileName := tmpFile.Name() + defer func() { + _ = resp.Body.Close() + _ = tmpFile.Close() + _ = os.Remove(tmpFileName) + }() + if _, err = io.Copy(tmpFile, resp.Body); err != nil { + return "", err + } + fileName, err := hashFile(tmpFileName) + if err != nil { + return "", err + } + // Reopen tmp file + _ = tmpFile.Close() + tmpFile, err = os.Open(tmpFileName) + if err != nil { + return "", err + } + // Upload compressed file + location, err = uploadFile(fileName+"."+fileExtension, tmpFile) + return +} + +func compressionIsSupported(url string, allowed ...string) (string, bool) { + spliced := strings.Split(url, ".") + ext := spliced[len(spliced)-1] + sort.Strings(allowed) + if i := sort.SearchStrings(allowed, strings.ToLower(ext)); i >= len(allowed) || allowed[i] != strings.ToLower(ext) { + return ext, false + } + return ext, true +} + func getSHA256(file multipart.File) (filename string, err error) { h := sha256.New() if _, err = io.Copy(h, file); err != nil { @@ -164,3 +245,18 @@ func getSHA256(file multipart.File) (filename string, err error) { } return fmt.Sprintf("%x", h.Sum(nil)), nil } + +func hashFile(filename string) (string, error) { + hashFile, err := os.Open(filename) + if err != nil { + return "", err + } + defer func() { + _ = hashFile.Close() + }() + fn, err := getSHA256(hashFile) + if err != nil { + return "", err + } + return fn, nil +}