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