1/* 2Copyright 2011 The Perkeep Authors 3 4Licensed under the Apache License, Version 2.0 (the "License"); 5you may not use this file except in compliance with the License. 6You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10Unless required by applicable law or agreed to in writing, software 11distributed under the License is distributed on an "AS IS" BASIS, 12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13See the License for the specific language governing permissions and 14limitations under the License. 15*/ 16 17package server 18 19import ( 20 "bytes" 21 "context" 22 "errors" 23 "expvar" 24 "fmt" 25 "image" 26 "image/jpeg" 27 "image/png" 28 "io" 29 "io/ioutil" 30 "log" 31 "net/http" 32 "strconv" 33 "strings" 34 "time" 35 36 _ "github.com/nf/cr2" 37 "go4.org/readerutil" 38 "go4.org/syncutil" 39 "go4.org/syncutil/singleflight" 40 "go4.org/types" 41 "perkeep.org/internal/httputil" 42 "perkeep.org/internal/images" 43 "perkeep.org/internal/magic" 44 "perkeep.org/pkg/blob" 45 "perkeep.org/pkg/blobserver" 46 "perkeep.org/pkg/constants" 47 "perkeep.org/pkg/schema" 48 "perkeep.org/pkg/search" 49) 50 51const imageDebug = false 52 53var ( 54 imageBytesServedVar = expvar.NewInt("image-bytes-served") 55 imageBytesFetchedVar = expvar.NewInt("image-bytes-fetched") 56 thumbCacheMiss = expvar.NewInt("thumbcache-miss") 57 thumbCacheHitFull = expvar.NewInt("thumbcache-hit-full") 58 thumbCacheHitFile = expvar.NewInt("thumbcache-hit-file") 59 thumbCacheHeader304 = expvar.NewInt("thumbcache-header-304") 60) 61 62type ImageHandler struct { 63 Fetcher blob.Fetcher 64 Search *search.Handler // optional 65 Cache blobserver.Storage // optional 66 MaxWidth, MaxHeight int 67 Square bool 68 ThumbMeta *ThumbMeta // optional cache index for scaled images 69 ResizeSem *syncutil.Sem // Limit peak RAM used by concurrent image thumbnail calls. 70} 71 72type subImager interface { 73 SubImage(image.Rectangle) image.Image 74} 75 76func squareImage(i image.Image) image.Image { 77 si, ok := i.(subImager) 78 if !ok { 79 log.Fatalf("image %T isn't a subImager", i) 80 } 81 b := i.Bounds() 82 if b.Dx() > b.Dy() { 83 thin := (b.Dx() - b.Dy()) / 2 84 newB := b 85 newB.Min.X += thin 86 newB.Max.X -= thin 87 return si.SubImage(newB) 88 } 89 thin := (b.Dy() - b.Dx()) / 2 90 newB := b 91 newB.Min.Y += thin 92 newB.Max.Y -= thin 93 return si.SubImage(newB) 94} 95 96func writeToCache(ctx context.Context, cache blobserver.Storage, thumbBytes []byte, name string) (br blob.Ref, err error) { 97 tr := bytes.NewReader(thumbBytes) 98 if len(thumbBytes) < constants.MaxBlobSize { 99 br = blob.RefFromBytes(thumbBytes) 100 _, err = blobserver.Receive(ctx, cache, br, tr) 101 } else { 102 // TODO: don't use rolling checksums when writing this. Tell 103 // the filewriter to use 16 MB chunks instead. 104 br, err = schema.WriteFileFromReader(ctx, cache, name, tr) 105 } 106 if err != nil { 107 return br, errors.New("failed to cache " + name + ": " + err.Error()) 108 } 109 if imageDebug { 110 log.Printf("Image Cache: saved as %v\n", br) 111 } 112 return br, nil 113} 114 115// cacheScaled saves in the image handler's cache the scaled image bytes 116// in thumbBytes, and puts its blobref in the scaledImage under the key name. 117func (ih *ImageHandler) cacheScaled(ctx context.Context, thumbBytes []byte, name string) error { 118 br, err := writeToCache(ctx, ih.Cache, thumbBytes, name) 119 if err != nil { 120 return err 121 } 122 ih.ThumbMeta.Put(name, br) 123 return nil 124} 125 126// cached returns a FileReader for the given blobref, which may 127// point to either a blob representing the entire thumbnail (max 128// 16MB) or a file schema blob. 129// 130// The ReadCloser should be closed when done reading. 131func (ih *ImageHandler) cached(ctx context.Context, br blob.Ref) (io.ReadCloser, error) { 132 rsc, _, err := ih.Cache.Fetch(ctx, br) 133 if err != nil { 134 return nil, err 135 } 136 slurp, err := ioutil.ReadAll(rsc) 137 rsc.Close() 138 if err != nil { 139 return nil, err 140 } 141 // In the common case, when the scaled image itself is less than 16 MB, it's 142 // all together in one blob. 143 if strings.HasPrefix(magic.MIMEType(slurp), "image/") { 144 thumbCacheHitFull.Add(1) 145 if imageDebug { 146 log.Printf("Image Cache: hit: %v\n", br) 147 } 148 return ioutil.NopCloser(bytes.NewReader(slurp)), nil 149 } 150 151 // For large scaled images, the cached blob is a file schema blob referencing 152 // the sub-chunks. 153 fileBlob, err := schema.BlobFromReader(br, bytes.NewReader(slurp)) 154 if err != nil { 155 log.Printf("Failed to parse non-image thumbnail cache blob %v: %v", br, err) 156 return nil, err 157 } 158 fr, err := fileBlob.NewFileReader(ih.Cache) 159 if err != nil { 160 log.Printf("cached(%v) NewFileReader = %v", br, err) 161 return nil, err 162 } 163 thumbCacheHitFile.Add(1) 164 if imageDebug { 165 log.Printf("Image Cache: fileref hit: %v\n", br) 166 } 167 return fr, nil 168} 169 170// Key format: "scaled:" + bref + ":" + width "x" + height 171// where bref is the blobref of the unscaled image. 172func cacheKey(bref string, width int, height int) string { 173 return fmt.Sprintf("scaled:%v:%dx%d:tv%v", bref, width, height, images.ThumbnailVersion()) 174} 175 176// ScaledCached reads the scaled version of the image in file, 177// if it is in cache and writes it to buf. 178// 179// On successful read and population of buf, the returned format is non-empty. 180// Almost all errors are not interesting. Real errors will be logged. 181func (ih *ImageHandler) scaledCached(ctx context.Context, buf *bytes.Buffer, file blob.Ref) (format string) { 182 key := cacheKey(file.String(), ih.MaxWidth, ih.MaxHeight) 183 br, err := ih.ThumbMeta.Get(key) 184 if err == errCacheMiss { 185 return 186 } 187 if err != nil { 188 log.Printf("Warning: thumbnail cachekey(%q)->meta lookup error: %v", key, err) 189 return 190 } 191 fr, err := ih.cached(ctx, br) 192 if err != nil { 193 if imageDebug { 194 log.Printf("Could not get cached image %v: %v\n", br, err) 195 } 196 return 197 } 198 defer fr.Close() 199 _, err = io.Copy(buf, fr) 200 if err != nil { 201 return 202 } 203 mime := magic.MIMEType(buf.Bytes()) 204 if format = strings.TrimPrefix(mime, "image/"); format == mime { 205 log.Printf("Warning: unescaped MIME type %q of %v file for thumbnail %q", mime, br, key) 206 return 207 } 208 return format 209} 210 211// Gate the number of concurrent image resizes to limit RAM & CPU use. 212 213type formatAndImage struct { 214 format string 215 image []byte 216} 217 218// imageConfigFromReader calls image.DecodeConfig on r. It returns an 219// io.Reader that is the concatentation of the bytes read and the remaining r, 220// the image configuration, and the error from image.DecodeConfig. 221// If the image is HEIC, and its config was decoded properly (but partially, 222// because we don't do ColorModel yet), it returns images.ErrHEIC. 223func imageConfigFromReader(r io.Reader) (io.Reader, image.Config, error) { 224 header := new(bytes.Buffer) 225 tr := io.TeeReader(r, header) 226 // We just need width & height for memory considerations, so we use the 227 // standard library's DecodeConfig, skipping the EXIF parsing and 228 // orientation correction for images.DecodeConfig. 229 // image.DecodeConfig is able to deal with HEIC because we registered it 230 // in internal/images. 231 conf, format, err := image.DecodeConfig(tr) 232 if err == nil && format == "heic" { 233 err = images.ErrHEIC 234 } 235 return io.MultiReader(header, r), conf, err 236} 237 238func (ih *ImageHandler) newFileReader(ctx context.Context, fileRef blob.Ref) (io.ReadCloser, error) { 239 fi, ok := fileInfoPacked(ctx, ih.Search, ih.Fetcher, nil, fileRef) 240 if debugPack { 241 log.Printf("pkg/server/image.go: fileInfoPacked: ok=%v, %+v", ok, fi) 242 } 243 if ok { 244 // This would be less gross if fileInfoPacked just 245 // returned an io.ReadCloser, but then the download 246 // handler would need more invasive changes for 247 // ServeContent. So tolerate this for now. 248 return struct { 249 io.Reader 250 io.Closer 251 }{ 252 fi.rs, 253 types.CloseFunc(fi.close), 254 }, nil 255 } 256 // Default path, not going through blobpacked's fast path: 257 return schema.NewFileReader(ctx, ih.Fetcher, fileRef) 258} 259 260func (ih *ImageHandler) scaleImage(ctx context.Context, fileRef blob.Ref) (*formatAndImage, error) { 261 fr, err := ih.newFileReader(ctx, fileRef) 262 if err != nil { 263 return nil, err 264 } 265 defer fr.Close() 266 267 sr := readerutil.NewStatsReader(imageBytesFetchedVar, fr) 268 sr, conf, err := imageConfigFromReader(sr) 269 if err == images.ErrHEIC { 270 jpegBytes, err := images.HEIFToJPEG(sr, &images.Dimensions{MaxWidth: ih.MaxWidth, MaxHeight: ih.MaxHeight}) 271 if err != nil { 272 log.Printf("cannot convert with heiftojpeg: %v", err) 273 return nil, errors.New("error converting HEIC image to jpeg") 274 } 275 return &formatAndImage{format: "jpeg", image: jpegBytes}, nil 276 } 277 if err != nil { 278 return nil, err 279 } 280 281 // TODO(wathiede): build a size table keyed by conf.ColorModel for 282 // common color models for a more exact size estimate. 283 284 // This value is an estimate of the memory required to decode an image. 285 // PNGs range from 1-64 bits per pixel (not all of which are supported by 286 // the Go standard parser). JPEGs encoded in YCbCr 4:4:4 are 3 byte/pixel. 287 // For all other JPEGs this is an overestimate. For GIFs it is 3x larger 288 // than needed. How accurate this estimate is depends on the mix of 289 // images being resized concurrently. 290 ramSize := int64(conf.Width) * int64(conf.Height) * 3 291 292 if err = ih.ResizeSem.Acquire(ramSize); err != nil { 293 return nil, err 294 } 295 defer ih.ResizeSem.Release(ramSize) 296 297 i, imConfig, err := images.Decode(sr, &images.DecodeOpts{ 298 MaxWidth: ih.MaxWidth, 299 MaxHeight: ih.MaxHeight, 300 }) 301 if err != nil { 302 return nil, err 303 } 304 b := i.Bounds() 305 format := imConfig.Format 306 307 isSquare := b.Dx() == b.Dy() 308 if ih.Square && !isSquare { 309 i = squareImage(i) 310 b = i.Bounds() 311 } 312 313 // Encode as a new image 314 var buf bytes.Buffer 315 switch format { 316 case "png": 317 err = png.Encode(&buf, i) 318 case "cr2": 319 // Recompress CR2 files as JPEG 320 format = "jpeg" 321 fallthrough 322 default: 323 err = jpeg.Encode(&buf, i, &jpeg.Options{ 324 Quality: 90, 325 }) 326 } 327 if err != nil { 328 return nil, err 329 } 330 331 return &formatAndImage{format: format, image: buf.Bytes()}, nil 332} 333 334// singleResize prevents generating the same thumbnail at once from 335// two different requests. (e.g. sending out a link to a new photo 336// gallery to a big audience) 337var singleResize singleflight.Group 338 339func (ih *ImageHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request, file blob.Ref) { 340 ctx := req.Context() 341 if !httputil.IsGet(req) { 342 http.Error(rw, "Invalid method", 400) 343 return 344 } 345 mw, mh := ih.MaxWidth, ih.MaxHeight 346 if mw == 0 || mh == 0 || mw > search.MaxImageSize || mh > search.MaxImageSize { 347 http.Error(rw, "bogus dimensions", 400) 348 return 349 } 350 351 key := cacheKey(file.String(), mw, mh) 352 etag := blob.RefFromString(key).String()[5:] 353 inm := req.Header.Get("If-None-Match") 354 if inm != "" { 355 if strings.Trim(inm, `"`) == etag { 356 thumbCacheHeader304.Add(1) 357 rw.WriteHeader(http.StatusNotModified) 358 return 359 } 360 } else { 361 if !disableThumbCache && req.Header.Get("If-Modified-Since") != "" { 362 thumbCacheHeader304.Add(1) 363 rw.WriteHeader(http.StatusNotModified) 364 return 365 } 366 } 367 368 var imageData []byte 369 format := "" 370 cacheHit := false 371 if ih.ThumbMeta != nil && !disableThumbCache { 372 var buf bytes.Buffer 373 format = ih.scaledCached(ctx, &buf, file) 374 if format != "" { 375 cacheHit = true 376 imageData = buf.Bytes() 377 } 378 } 379 380 if !cacheHit { 381 thumbCacheMiss.Add(1) 382 imi, err := singleResize.Do(key, func() (interface{}, error) { 383 return ih.scaleImage(ctx, file) 384 }) 385 if err != nil { 386 http.Error(rw, err.Error(), 500) 387 return 388 } 389 im := imi.(*formatAndImage) 390 imageData = im.image 391 format = im.format 392 if ih.ThumbMeta != nil { 393 err := ih.cacheScaled(ctx, imageData, key) 394 if err != nil { 395 log.Printf("image resize: %v", err) 396 } 397 } 398 } 399 400 h := rw.Header() 401 if !disableThumbCache { 402 h.Set("Expires", time.Now().Add(oneYear).Format(http.TimeFormat)) 403 h.Set("Last-Modified", time.Now().Format(http.TimeFormat)) 404 h.Set("Etag", strconv.Quote(etag)) 405 } 406 h.Set("Content-Type", imageContentTypeOfFormat(format)) 407 size := len(imageData) 408 h.Set("Content-Length", fmt.Sprint(size)) 409 imageBytesServedVar.Add(int64(size)) 410 411 if req.Method == "GET" { 412 n, err := rw.Write(imageData) 413 if err != nil { 414 if strings.Contains(err.Error(), "broken pipe") { 415 // boring. 416 return 417 } 418 // TODO: vlog this: 419 log.Printf("error serving thumbnail of file schema %s: %v", file, err) 420 return 421 } 422 if n != size { 423 log.Printf("error serving thumbnail of file schema %s: sent %d, expected size of %d", 424 file, n, size) 425 return 426 } 427 } 428} 429 430func imageContentTypeOfFormat(format string) string { 431 if format == "jpeg" { 432 return "image/jpeg" 433 } 434 return "image/png" 435} 436