package photo import ( "bytes" "errors" "fmt" "image" "image/jpeg" "image/png" "io" "os" "path/filepath" "strconv" "strings" "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" "github.com/edwvee/exiffix" "golang.org/x/image/draw" ) type UploadConfig struct { User *models.User Extension string // 'jpg' or 'png' only. Data []byte Crop []int // x, y, w, h } // UploadPhoto handles an incoming photo to add to a user's account. // // Returns: // - NewFilename() of the created photo file on disk. // - NewFilename() of the cropped version, or "" if not cropping. // - error on errors func UploadPhoto(cfg UploadConfig) (string, string, error) { // Validate and normalize the extension. var ( extension = strings.ToLower(cfg.Extension) dbExtension = extension ) switch extension { case ".jpg", ".jpe", ".jpeg": extension = ".jpg" dbExtension = ".jpg" case ".png": extension = ".png" dbExtension = ".jpg" case ".gif": extension = ".gif" dbExtension = ".mp4" default: return "", "", errors.New("unsupported image extension, must be jpg or png") } // Decide on a filename for this photo. var ( filename = NewFilename(dbExtension) cropFilename = NewFilename(dbExtension) ) // Decode the image using exiffix, which will auto-rotate jpeg images // based on their EXIF tags. reader := bytes.NewReader(cfg.Data) origImage, _, err := exiffix.Decode(reader) if err != nil { return "", "", err } // Read the config to get the image width. reader.Seek(0, io.SeekStart) var width, height = origImage.Bounds().Max.X, origImage.Bounds().Max.Y // Find the longest edge, if it's too large (over 1280px) // cap it to the max and scale the other dimension proportionally. log.Debug("UploadPhoto: taking a w=%d by h=%d image to name it %s", width, height, filename) if width >= height { log.Debug("Its width(%d) is >= its height (%d)", width, height) if width > config.MaxPhotoWidth { newWidth := config.MaxPhotoWidth log.Debug("\tnewWidth=%d", newWidth) log.Debug("\tnewHeight=(%d / %d) * %d", width, height, newWidth) height = int((float64(height) / float64(width)) * float64(newWidth)) width = newWidth log.Debug("Its longest is width, scale to %dx%d", width, height) } } else { if height > config.MaxPhotoWidth { newHeight := config.MaxPhotoWidth width = int((float64(width) / float64(height)) * float64(newHeight)) height = newHeight log.Debug("Its longest is height, scale to %dx%d", width, height) } } // Scale the image. scaledImg := Scale(origImage, image.Rect(0, 0, width, height), draw.ApproxBiLinear) // Write the image to disk. if err := ToDisk(filename, extension, scaledImg, &cfg); err != nil { return "", "", err } // Are we producing a cropped image, too? log.Error("Are we to crop? %+v", cfg.Crop) if cfg.Crop != nil && len(cfg.Crop) >= 4 { log.Debug("Also cropping this image to %+v", cfg.Crop) var ( x = cfg.Crop[0] y = cfg.Crop[1] w = cfg.Crop[2] h = cfg.Crop[3] ) croppedImg, err := Crop(origImage, x, y, w, h) if err != nil { // Error during the crop: return it and just the original image filename log.Error("Couldn't crop new profile photo: %s", err) return filename, "", nil } // Write that to disk, too. log.Debug("Writing cropped image to disk: %s", cropFilename) if err := ToDisk(cropFilename, extension, croppedImg, &cfg); err != nil { log.Error("Couldn't write cropped photo to disk: %s", err) return filename, "", nil } // Return both filenames! return filename, cropFilename, nil } // Not cropping, return only the first filename. return filename, "", nil } // Scale down an image. Example: // // scaled := Scale(src, image.Rect(0, 0, 200, 200), draw.ApproxBiLinear) func Scale(src image.Image, rect image.Rectangle, scale draw.Scaler) image.Image { dst := image.NewRGBA(rect) copyRect := image.Rect( rect.Min.X, rect.Min.Y, rect.Min.X+rect.Max.X, rect.Min.Y+rect.Max.Y, ) scale.Scale(dst, copyRect, src, src.Bounds(), draw.Over, nil) return dst } // Crop an image, returning the new image. Example: // // cropped := Crop() func Crop(src image.Image, x, y, w, h int) (image.Image, error) { // Sanity check the crop constraints, e.g. sometimes the front-end might send "203 -1 738 738" with a negative x/y value if x < 0 { log.Debug("Crop(%d, %d, %d, %d): x value %d too low, cap to zero", x, y, w, h, x) x = 0 } if y < 0 { log.Debug("Crop(%d, %d, %d, %d): y value %d too low, cap to zero", x, y, w, h, y) y = 0 } if x+w > src.Bounds().Dx() { log.Debug("Crop(%d, %d, %d, %d): width is too wide", x, y, w, h) w = src.Bounds().Dx() - x } if y+h > src.Bounds().Dy() { log.Debug("Crop(%d, %d, %d, %d): height is too tall", x, y, w, h) h = src.Bounds().Dy() - y } // If they are trying to crop a 0x0 image, return an error. if w == 0 || h == 0 { return nil, errors.New("can't crop to a 0x0 resolution image") } log.Debug("Crop(): running draw.Copy with dimensions %d, %d, %d, %d", x, y, w, h) dst := image.NewRGBA(image.Rect(0, 0, w, h)) srcrect := image.Rect(x, y, x+w, y+h) draw.Copy(dst, image.Point{}, src, srcrect, draw.Over, nil) return dst, nil } // ReCrop an image, loading the original image from disk. Returns the newly created filename. func ReCrop(filename string, x, y, w, h int) (string, error) { var ( ext = filepath.Ext(filename) cropFilename = NewFilename(ext) ) fh, err := os.Open(DiskPath(filename)) if err != nil { return "", err } // Decode the image. var img image.Image switch ext { case ".jpg", ".jpeg", ".jpe": // NOTE: new uploads enforce .jpg extension, some legacy pics may have slipped thru img, err = jpeg.Decode(fh) if err != nil { return "", err } case ".png": img, err = png.Decode(fh) if err != nil { return "", err } default: return "", errors.New("unsupported file type") } // Crop it. croppedImg, err := Crop(img, x, y, w, h) if err != nil { return "", err } // Write it. err = ToDisk(cropFilename, ext, croppedImg, nil) return cropFilename, err } // ParseCropCoords splits a string of x,y,w,h values into proper crop coordinates, or nil. func ParseCropCoords(coords string) []int { // Parse and validate crop coordinates. var crop []int if len(coords) > 0 { aints := strings.Split(coords, ",") if len(aints) >= 4 { crop = []int{} for i, aint := range aints { if number, err := strconv.Atoi(strings.TrimSpace(aint)); err == nil { crop = append(crop, number) } else { log.Error("Failure to parse crop coordinates ('%s') at number %d: %s", coords, i, err) } } } } return crop } // ToDisk commits a photo image to disk in the right file format. // // Filename is like NewFilename() and it goes to e.g. "./web/static/photos/" // // Encoding rules: // - JPEG and PNG uploads are saved as JPEG // - GIF uploads are transmuted to MP4 func ToDisk(filename string, extension string, img image.Image, cfg *UploadConfig) error { // GIF path handled specially (note: it will come in with extension=".mp4") if extension == ".gif" || extension == ".mp4" { // Requires an upload config (ReCrop not supported) if cfg == nil { return errors.New("can't update your GIF after original upload") } return GifToMP4(filename, cfg) } if path, err := EnsurePath(filename); err == nil { fh, err := os.Create(path) if err != nil { return err } defer fh.Close() switch extension { case ".jpg", ".jpe", ".jpeg", ".png": // NOTE: new uploads enforce .jpg extension always, some legacy pics (.png too) may have slipped thru jpeg.Encode(fh, img, &jpeg.Options{ Quality: config.JpegQuality, }) } } else { return fmt.Errorf("couldn't EnsurePath: %s", err) } return nil } // Delete a photo from disk. func Delete(filename string) error { if len(filename) > 0 { return os.Remove(DiskPath(filename)) } return errors.New("filename is required") }