Browse Source

feat(avatar): added server side proxy and cache of gravatar requests

Torkel Ödegaard 9 years ago
parent
commit
08f7ccff38

+ 5 - 0
pkg/api/api.go

@@ -2,6 +2,7 @@ package api
 
 import (
 	"github.com/go-macaron/binding"
+	"github.com/grafana/grafana/pkg/api/avatar"
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
@@ -224,6 +225,10 @@ func Register(r *macaron.Macaron) {
 	// rendering
 	r.Get("/render/*", reqSignedIn, RenderToPng)
 
+	// Gravatar service.
+	avt := avatar.CacheServer()
+	r.Get("/avatar/:hash", avt.ServeHTTP)
+
 	InitAppPluginRoutes(r)
 
 	r.NotFound(NotFoundHandler)

+ 253 - 0
pkg/api/avatar/avatar.go

@@ -0,0 +1,253 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+// Code from https://github.com/gogits/gogs/blob/v0.7.0/modules/avatar/avatar.go
+
+package avatar
+
+import (
+	"bufio"
+	"bytes"
+	"crypto/md5"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+var gravatarSource string
+
+func UpdateGravatarSource() {
+	srcCfg := "//secure.gravatar.com/avatar/"
+
+	gravatarSource = srcCfg
+	if strings.HasPrefix(gravatarSource, "//") {
+		gravatarSource = "http:" + gravatarSource
+	} else if !strings.HasPrefix(gravatarSource, "http://") &&
+		!strings.HasPrefix(gravatarSource, "https://") {
+		gravatarSource = "http://" + gravatarSource
+	}
+}
+
+// hash email to md5 string
+// keep this func in order to make this package independent
+func HashEmail(email string) string {
+	// https://en.gravatar.com/site/implement/hash/
+	email = strings.TrimSpace(email)
+	email = strings.ToLower(email)
+
+	h := md5.New()
+	h.Write([]byte(email))
+	return hex.EncodeToString(h.Sum(nil))
+}
+
+// Avatar represents the avatar object.
+type Avatar struct {
+	hash      string
+	reqParams string
+	data      *bytes.Buffer
+	notFound  bool
+	timestamp time.Time
+}
+
+func New(hash string) *Avatar {
+	return &Avatar{
+		hash: hash,
+		reqParams: url.Values{
+			"d":    {"404"},
+			"size": {"200"},
+			"r":    {"pg"}}.Encode(),
+	}
+}
+
+func (this *Avatar) Expired() bool {
+	return time.Since(this.timestamp) > (time.Minute * 10)
+}
+
+func (this *Avatar) Encode(wr io.Writer) error {
+	_, err := wr.Write(this.data.Bytes())
+	return err
+}
+
+func (this *Avatar) Update() (err error) {
+	select {
+	case <-time.After(time.Second * 3):
+		err = fmt.Errorf("get gravatar image %s timeout", this.hash)
+	case err = <-thunder.GoFetch(gravatarSource+this.hash+"?"+this.reqParams, this):
+	}
+	return err
+}
+
+type service struct {
+	notFound *Avatar
+	cache    map[string]*Avatar
+}
+
+func (this *service) mustInt(r *http.Request, defaultValue int, keys ...string) (v int) {
+	for _, k := range keys {
+		if _, err := fmt.Sscanf(r.FormValue(k), "%d", &v); err == nil {
+			defaultValue = v
+		}
+	}
+	return defaultValue
+}
+
+func (this *service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	urlPath := r.URL.Path
+	hash := urlPath[strings.LastIndex(urlPath, "/")+1:]
+
+	var avatar *Avatar
+
+	if avatar, _ = this.cache[hash]; avatar == nil {
+		avatar = New(hash)
+	}
+
+	if avatar.Expired() {
+		if err := avatar.Update(); err != nil {
+			log.Trace("avatar update error: %v", err)
+		}
+	}
+
+	if avatar.notFound {
+		avatar = this.notFound
+	} else {
+		this.cache[hash] = avatar
+	}
+
+	w.Header().Set("Content-Type", "image/jpeg")
+	w.Header().Set("Content-Length", strconv.Itoa(len(avatar.data.Bytes())))
+	w.Header().Set("Cache-Control", "private, max-age=3600")
+
+	if err := avatar.Encode(w); err != nil {
+		log.Warn("avatar encode error: %v", err)
+		w.WriteHeader(500)
+	}
+}
+
+func CacheServer() http.Handler {
+	UpdateGravatarSource()
+
+	return &service{
+		notFound: newNotFound(),
+		cache:    make(map[string]*Avatar),
+	}
+}
+
+func newNotFound() *Avatar {
+	avatar := &Avatar{}
+
+	// load transparent png into buffer
+	path := filepath.Join(setting.StaticRootPath, "img", "transparent.png")
+
+	if data, err := ioutil.ReadFile(path); err != nil {
+		log.Error(3, "Failed to read transparent.png, %v", path)
+	} else {
+		avatar.data = bytes.NewBuffer(data)
+	}
+
+	return avatar
+}
+
+// thunder downloader
+var thunder = &Thunder{QueueSize: 10}
+
+type Thunder struct {
+	QueueSize int // download queue size
+	q         chan *thunderTask
+	once      sync.Once
+}
+
+func (t *Thunder) init() {
+	if t.QueueSize < 1 {
+		t.QueueSize = 1
+	}
+	t.q = make(chan *thunderTask, t.QueueSize)
+	for i := 0; i < t.QueueSize; i++ {
+		go func() {
+			for {
+				task := <-t.q
+				task.Fetch()
+			}
+		}()
+	}
+}
+
+func (t *Thunder) Fetch(url string, avatar *Avatar) error {
+	t.once.Do(t.init)
+	task := &thunderTask{
+		Url:    url,
+		Avatar: avatar,
+	}
+	task.Add(1)
+	t.q <- task
+	task.Wait()
+	return task.err
+}
+
+func (t *Thunder) GoFetch(url string, avatar *Avatar) chan error {
+	c := make(chan error)
+	go func() {
+		c <- t.Fetch(url, avatar)
+	}()
+	return c
+}
+
+// thunder download
+type thunderTask struct {
+	Url    string
+	Avatar *Avatar
+	sync.WaitGroup
+	err error
+}
+
+func (this *thunderTask) Fetch() {
+	this.err = this.fetch()
+	this.Done()
+}
+
+var client = &http.Client{}
+
+func (this *thunderTask) fetch() error {
+	this.Avatar.timestamp = time.Now()
+
+	log.Debug("avatar.fetch(fetch new avatar): %s", this.Url)
+	req, _ := http.NewRequest("GET", this.Url, nil)
+	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/jpeg,image/png,*/*;q=0.8")
+	req.Header.Set("Accept-Encoding", "deflate,sdch")
+	req.Header.Set("Accept-Language", "zh-CN,zh;q=0.8")
+	req.Header.Set("Cache-Control", "no-cache")
+	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.154 Safari/537.36")
+	resp, err := client.Do(req)
+
+	if err != nil {
+		this.Avatar.notFound = true
+		return fmt.Errorf("gravatar unreachable, %v", err)
+	}
+
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		this.Avatar.notFound = true
+		return fmt.Errorf("status code: %d", resp.StatusCode)
+	}
+
+	this.Avatar.data = &bytes.Buffer{}
+	writer := bufio.NewWriter(this.Avatar.data)
+
+	if _, err = io.Copy(writer, resp.Body); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 2 - 1
pkg/api/dtos/models.go

@@ -7,6 +7,7 @@ import (
 	"time"
 
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
 )
 
 type LoginCommand struct {
@@ -89,5 +90,5 @@ func GetGravatarUrl(text string) string {
 
 	hasher := md5.New()
 	hasher.Write([]byte(strings.ToLower(text)))
-	return fmt.Sprintf("https://secure.gravatar.com/avatar/%x?s=90&default=mm", hasher.Sum(nil))
+	return fmt.Sprintf(setting.AppSubUrl+"/avatar/%x", hasher.Sum(nil))
 }

+ 1 - 1
pkg/api/index.go

@@ -36,7 +36,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 	}
 
 	if setting.DisableGravatar {
-		data.User.GravatarUrl = setting.AppSubUrl + "/public/img/user_profile.png"
+		data.User.GravatarUrl = setting.AppSubUrl + "/public/img/transparent.png"
 	}
 
 	if len(data.User.Name) == 0 {

+ 1 - 1
public/app/core/components/sidemenu/sidemenu.html

@@ -4,7 +4,7 @@
 		<div class="sidemenu-org">
 			<div class="sidemenu-org-avatar">
 				<img ng-if="ctrl.user.gravatarUrl" ng-src="{{ctrl.user.gravatarUrl}}">
-				<span class="sidemenu-org-avatar--missing" ng-if="!ctrl.user.gravatarUrl">
+				<span class="sidemenu-org-avatar--missing">
 					<i class="fa fa-fw fa-user"></i>
 				</span>
 			</div>

BIN
public/img/transparent.png


+ 2 - 0
public/sass/components/_sidemenu.scss

@@ -210,9 +210,11 @@
   text-align: center;
 
   >img {
+    position: absolute;
     width: 40px;
     height: 40px;
     border-radius: 50%;
+    left: 14px;
   }
 }