mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-29 10:57:44 +09:00 
			
		
		
		
	Add EventSource support (#11235)
If the browser supports EventSource switch to use this instead of polling notifications. Signed-off-by: Andrew Thornton art27@cantab.net
This commit is contained in:
		| @@ -202,12 +202,15 @@ DESCRIPTION = Gitea (Git with a cup of tea) is a painless self-hosted Git servic | |||||||
| KEYWORDS = go,git,self-hosted,gitea | KEYWORDS = go,git,self-hosted,gitea | ||||||
|  |  | ||||||
| [ui.notification] | [ui.notification] | ||||||
| ; Control how often notification is queried to update the notification | ; Control how often the notification endpoint is polled to update the notification | ||||||
| ; The timeout will increase to MAX_TIMEOUT in TIMEOUT_STEPs if the notification count is unchanged | ; The timeout will increase to MAX_TIMEOUT in TIMEOUT_STEPs if the notification count is unchanged | ||||||
| ; Set MIN_TIMEOUT to 0 to turn off | ; Set MIN_TIMEOUT to 0 to turn off | ||||||
| MIN_TIMEOUT = 10s | MIN_TIMEOUT = 10s | ||||||
| MAX_TIMEOUT = 60s | MAX_TIMEOUT = 60s | ||||||
| TIMEOUT_STEP = 10s | TIMEOUT_STEP = 10s | ||||||
|  | ; This setting determines how often the db is queried to get the latest notification counts. | ||||||
|  | ; If the browser client supports EventSource, it will be used in preference to polling notification. | ||||||
|  | EVENT_SOURCE_UPDATE_TIME = 10s | ||||||
|  |  | ||||||
| [markdown] | [markdown] | ||||||
| ; Render soft line breaks as hard line breaks, which means a single newline character between | ; Render soft line breaks as hard line breaks, which means a single newline character between | ||||||
|   | |||||||
| @@ -144,9 +144,10 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. | |||||||
|  |  | ||||||
| ### UI - Notification (`ui.notification`) | ### UI - Notification (`ui.notification`) | ||||||
|  |  | ||||||
| - `MIN_TIMEOUT`: **10s**: These options control how often notification is queried to update the notification count. On page load the notification count will be checked after `MIN_TIMEOUT`. The timeout will increase to `MAX_TIMEOUT` by `TIMEOUT_STEP` if the notification count is unchanged. Set MIN_TIMEOUT to 0 to turn off. | - `MIN_TIMEOUT`: **10s**: These options control how often notification endpoint is polled to update the notification count. On page load the notification count will be checked after `MIN_TIMEOUT`. The timeout will increase to `MAX_TIMEOUT` by `TIMEOUT_STEP` if the notification count is unchanged. Set MIN_TIMEOUT to 0 to turn off. | ||||||
| - `MAX_TIMEOUT`: **60s**. | - `MAX_TIMEOUT`: **60s**. | ||||||
| - `TIMEOUT_STEP`: **10s**. | - `TIMEOUT_STEP`: **10s**. | ||||||
|  | - `EVENT_SOURCE_UPDATE_TIME`: **10s**: This setting determines how often the database is queried to update notification counts. If the browser client supports `EventSource`, it will be used in preference to polling notification endpoint. | ||||||
|  |  | ||||||
|  |  | ||||||
| ## Markdown (`markdown`) | ## Markdown (`markdown`) | ||||||
|   | |||||||
							
								
								
									
										78
									
								
								integrations/eventsource_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								integrations/eventsource_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | |||||||
|  | // Copyright 2020 The Gitea Authors. All rights reserved. | ||||||
|  | // Use of this source code is governed by a MIT-style | ||||||
|  | // license that can be found in the LICENSE file. | ||||||
|  |  | ||||||
|  | package integrations | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 	"testing" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/models" | ||||||
|  | 	"code.gitea.io/gitea/modules/eventsource" | ||||||
|  | 	api "code.gitea.io/gitea/modules/structs" | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func TestEventSourceManagerRun(t *testing.T) { | ||||||
|  | 	defer prepareTestEnv(t)() | ||||||
|  | 	manager := eventsource.GetManager() | ||||||
|  |  | ||||||
|  | 	eventChan := manager.Register(2) | ||||||
|  | 	defer func() { | ||||||
|  | 		manager.Unregister(2, eventChan) | ||||||
|  | 		// ensure the eventChan is closed | ||||||
|  | 		for { | ||||||
|  | 			_, ok := <-eventChan | ||||||
|  | 			if !ok { | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
|  | 	expectNotificationCountEvent := func(count int64) func() bool { | ||||||
|  | 		return func() bool { | ||||||
|  | 			select { | ||||||
|  | 			case event, ok := <-eventChan: | ||||||
|  | 				if !ok { | ||||||
|  | 					return false | ||||||
|  | 				} | ||||||
|  | 				data, ok := event.Data.(models.UserIDCount) | ||||||
|  | 				if !ok { | ||||||
|  | 					return false | ||||||
|  | 				} | ||||||
|  | 				return event.Name == "notification-count" && data.Count == count | ||||||
|  | 			default: | ||||||
|  | 				return false | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) | ||||||
|  | 	repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) | ||||||
|  | 	thread5 := models.AssertExistsAndLoadBean(t, &models.Notification{ID: 5}).(*models.Notification) | ||||||
|  | 	assert.NoError(t, thread5.LoadAttributes()) | ||||||
|  | 	session := loginUser(t, user2.Name) | ||||||
|  | 	token := getTokenForLoggedInUser(t, session) | ||||||
|  |  | ||||||
|  | 	var apiNL []api.NotificationThread | ||||||
|  |  | ||||||
|  | 	// -- mark notifications as read -- | ||||||
|  | 	req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?token=%s", token)) | ||||||
|  | 	resp := session.MakeRequest(t, req, http.StatusOK) | ||||||
|  |  | ||||||
|  | 	DecodeJSON(t, resp, &apiNL) | ||||||
|  | 	assert.Len(t, apiNL, 2) | ||||||
|  |  | ||||||
|  | 	lastReadAt := "2000-01-01T00%3A50%3A01%2B00%3A00" //946687801 <- only Notification 4 is in this filter ... | ||||||
|  | 	req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?last_read_at=%s&token=%s", user2.Name, repo1.Name, lastReadAt, token)) | ||||||
|  | 	resp = session.MakeRequest(t, req, http.StatusResetContent) | ||||||
|  |  | ||||||
|  | 	req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?token=%s", token)) | ||||||
|  | 	resp = session.MakeRequest(t, req, http.StatusOK) | ||||||
|  | 	DecodeJSON(t, resp, &apiNL) | ||||||
|  | 	assert.Len(t, apiNL, 1) | ||||||
|  |  | ||||||
|  | 	assert.Eventually(t, expectNotificationCountEvent(1), 30*time.Second, 1*time.Second) | ||||||
|  | } | ||||||
| @@ -718,6 +718,21 @@ func getNotificationCount(e Engine, user *User, status NotificationStatus) (coun | |||||||
| 	return | 	return | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // UserIDCount is a simple coalition of UserID and Count | ||||||
|  | type UserIDCount struct { | ||||||
|  | 	UserID int64 | ||||||
|  | 	Count  int64 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetUIDsAndNotificationCounts between the two provided times | ||||||
|  | func GetUIDsAndNotificationCounts(since, until timeutil.TimeStamp) ([]UserIDCount, error) { | ||||||
|  | 	sql := `SELECT user_id, count(*) AS count FROM notification ` + | ||||||
|  | 		`WHERE user_id IN (SELECT user_id FROM notification WHERE updated_unix >= ? AND ` + | ||||||
|  | 		`updated_unix < ?) AND status = ? GROUP BY user_id` | ||||||
|  | 	var res []UserIDCount | ||||||
|  | 	return res, x.SQL(sql, since, until, NotificationStatusUnread).Find(&res) | ||||||
|  | } | ||||||
|  |  | ||||||
| func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error { | func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error { | ||||||
| 	notification, err := getIssueNotification(e, userID, issueID) | 	notification, err := getIssueNotification(e, userID, issueID) | ||||||
| 	// ignore if not exists | 	// ignore if not exists | ||||||
|   | |||||||
							
								
								
									
										119
									
								
								modules/eventsource/event.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								modules/eventsource/event.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | |||||||
|  | // Copyright 2020 The Gitea Authors. All rights reserved. | ||||||
|  | // Use of this source code is governed by a MIT-style | ||||||
|  | // license that can be found in the LICENSE file. | ||||||
|  |  | ||||||
|  | package eventsource | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"bytes" | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"strings" | ||||||
|  | 	"time" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func wrapNewlines(w io.Writer, prefix []byte, value []byte) (sum int64, err error) { | ||||||
|  | 	if len(value) == 0 { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	n := 0 | ||||||
|  | 	last := 0 | ||||||
|  | 	for j := bytes.IndexByte(value, '\n'); j > -1; j = bytes.IndexByte(value[last:], '\n') { | ||||||
|  | 		n, err = w.Write(prefix) | ||||||
|  | 		sum += int64(n) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		n, err = w.Write(value[last : last+j+1]) | ||||||
|  | 		sum += int64(n) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		last += j + 1 | ||||||
|  | 	} | ||||||
|  | 	n, err = w.Write(prefix) | ||||||
|  | 	sum += int64(n) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	n, err = w.Write(value[last:]) | ||||||
|  | 	sum += int64(n) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	n, err = w.Write([]byte("\n")) | ||||||
|  | 	sum += int64(n) | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Event is an eventsource event, not all fields need to be set | ||||||
|  | type Event struct { | ||||||
|  | 	// Name represents the value of the event: tag in the stream | ||||||
|  | 	Name string | ||||||
|  | 	// Data is either JSONified []byte or interface{} that can be JSONd | ||||||
|  | 	Data interface{} | ||||||
|  | 	// ID represents the ID of an event | ||||||
|  | 	ID string | ||||||
|  | 	// Retry tells the receiver only to attempt to reconnect to the source after this time | ||||||
|  | 	Retry time.Duration | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // WriteTo writes data to w until there's no more data to write or when an error occurs. | ||||||
|  | // The return value n is the number of bytes written. Any error encountered during the write is also returned. | ||||||
|  | func (e *Event) WriteTo(w io.Writer) (int64, error) { | ||||||
|  | 	sum := int64(0) | ||||||
|  | 	nint := 0 | ||||||
|  | 	n, err := wrapNewlines(w, []byte("event: "), []byte(e.Name)) | ||||||
|  | 	sum += n | ||||||
|  | 	if err != nil { | ||||||
|  | 		return sum, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if e.Data != nil { | ||||||
|  | 		var data []byte | ||||||
|  | 		switch v := e.Data.(type) { | ||||||
|  | 		case []byte: | ||||||
|  | 			data = v | ||||||
|  | 		case string: | ||||||
|  | 			data = []byte(v) | ||||||
|  | 		default: | ||||||
|  | 			var err error | ||||||
|  | 			data, err = json.Marshal(e.Data) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return sum, err | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		n, err := wrapNewlines(w, []byte("data: "), data) | ||||||
|  | 		sum += n | ||||||
|  | 		if err != nil { | ||||||
|  | 			return sum, err | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	n, err = wrapNewlines(w, []byte("id: "), []byte(e.ID)) | ||||||
|  | 	sum += n | ||||||
|  | 	if err != nil { | ||||||
|  | 		return sum, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if e.Retry != 0 { | ||||||
|  | 		nint, err = fmt.Fprintf(w, "retry: %d\n", int64(e.Retry/time.Millisecond)) | ||||||
|  | 		sum += int64(nint) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return sum, err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	nint, err = w.Write([]byte("\n")) | ||||||
|  | 	sum += int64(nint) | ||||||
|  |  | ||||||
|  | 	return sum, err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (e *Event) String() string { | ||||||
|  | 	buf := new(strings.Builder) | ||||||
|  | 	_, _ = e.WriteTo(buf) | ||||||
|  | 	return buf.String() | ||||||
|  | } | ||||||
							
								
								
									
										54
									
								
								modules/eventsource/event_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								modules/eventsource/event_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | |||||||
|  | // Copyright 2020 The Gitea Authors. All rights reserved. | ||||||
|  | // Use of this source code is governed by a MIT-style | ||||||
|  | // license that can be found in the LICENSE file. | ||||||
|  |  | ||||||
|  | package eventsource | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"bytes" | ||||||
|  | 	"testing" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func Test_wrapNewlines(t *testing.T) { | ||||||
|  | 	tests := []struct { | ||||||
|  | 		name   string | ||||||
|  | 		prefix string | ||||||
|  | 		value  string | ||||||
|  | 		output string | ||||||
|  | 	}{ | ||||||
|  | 		{ | ||||||
|  | 			"check no new lines", | ||||||
|  | 			"prefix: ", | ||||||
|  | 			"value", | ||||||
|  | 			"prefix: value\n", | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			"check simple newline", | ||||||
|  | 			"prefix: ", | ||||||
|  | 			"value1\nvalue2", | ||||||
|  | 			"prefix: value1\nprefix: value2\n", | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			"check pathological newlines", | ||||||
|  | 			"p: ", | ||||||
|  | 			"\n1\n\n2\n3\n", | ||||||
|  | 			"p: \np: 1\np: \np: 2\np: 3\np: \n", | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	for _, tt := range tests { | ||||||
|  | 		t.Run(tt.name, func(t *testing.T) { | ||||||
|  | 			w := &bytes.Buffer{} | ||||||
|  | 			gotSum, err := wrapNewlines(w, []byte(tt.prefix), []byte(tt.value)) | ||||||
|  | 			if err != nil { | ||||||
|  | 				t.Errorf("wrapNewlines() error = %v", err) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 			if gotSum != int64(len(tt.output)) { | ||||||
|  | 				t.Errorf("wrapNewlines() = %v, want %v", gotSum, int64(len(tt.output))) | ||||||
|  | 			} | ||||||
|  | 			if gotW := w.String(); gotW != tt.output { | ||||||
|  | 				t.Errorf("wrapNewlines() = %v, want %v", gotW, tt.output) | ||||||
|  | 			} | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										84
									
								
								modules/eventsource/manager.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								modules/eventsource/manager.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | |||||||
|  | // Copyright 2020 The Gitea Authors. All rights reserved. | ||||||
|  | // Use of this source code is governed by a MIT-style | ||||||
|  | // license that can be found in the LICENSE file. | ||||||
|  |  | ||||||
|  | package eventsource | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"sync" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // Manager manages the eventsource Messengers | ||||||
|  | type Manager struct { | ||||||
|  | 	mutex sync.Mutex | ||||||
|  |  | ||||||
|  | 	messengers map[int64]*Messenger | ||||||
|  | } | ||||||
|  |  | ||||||
|  | var manager *Manager | ||||||
|  |  | ||||||
|  | func init() { | ||||||
|  | 	manager = &Manager{ | ||||||
|  | 		messengers: make(map[int64]*Messenger), | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetManager returns a Manager and initializes one as singleton if there's none yet | ||||||
|  | func GetManager() *Manager { | ||||||
|  | 	return manager | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Register message channel | ||||||
|  | func (m *Manager) Register(uid int64) <-chan *Event { | ||||||
|  | 	m.mutex.Lock() | ||||||
|  | 	messenger, ok := m.messengers[uid] | ||||||
|  | 	if !ok { | ||||||
|  | 		messenger = NewMessenger(uid) | ||||||
|  | 		m.messengers[uid] = messenger | ||||||
|  | 	} | ||||||
|  | 	m.mutex.Unlock() | ||||||
|  | 	return messenger.Register() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Unregister message channel | ||||||
|  | func (m *Manager) Unregister(uid int64, channel <-chan *Event) { | ||||||
|  | 	m.mutex.Lock() | ||||||
|  | 	defer m.mutex.Unlock() | ||||||
|  | 	messenger, ok := m.messengers[uid] | ||||||
|  | 	if !ok { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if messenger.Unregister(channel) { | ||||||
|  | 		delete(m.messengers, uid) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // UnregisterAll message channels | ||||||
|  | func (m *Manager) UnregisterAll() { | ||||||
|  | 	m.mutex.Lock() | ||||||
|  | 	defer m.mutex.Unlock() | ||||||
|  | 	for _, messenger := range m.messengers { | ||||||
|  | 		messenger.UnregisterAll() | ||||||
|  | 	} | ||||||
|  | 	m.messengers = map[int64]*Messenger{} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SendMessage sends a message to a particular user | ||||||
|  | func (m *Manager) SendMessage(uid int64, message *Event) { | ||||||
|  | 	m.mutex.Lock() | ||||||
|  | 	messenger, ok := m.messengers[uid] | ||||||
|  | 	m.mutex.Unlock() | ||||||
|  | 	if ok { | ||||||
|  | 		messenger.SendMessage(message) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SendMessageBlocking sends a message to a particular user | ||||||
|  | func (m *Manager) SendMessageBlocking(uid int64, message *Event) { | ||||||
|  | 	m.mutex.Lock() | ||||||
|  | 	messenger, ok := m.messengers[uid] | ||||||
|  | 	m.mutex.Unlock() | ||||||
|  | 	if ok { | ||||||
|  | 		messenger.SendMessageBlocking(message) | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										50
									
								
								modules/eventsource/manager_run.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								modules/eventsource/manager_run.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | |||||||
|  | // Copyright 2020 The Gitea Authors. All rights reserved. | ||||||
|  | // Use of this source code is governed by a MIT-style | ||||||
|  | // license that can be found in the LICENSE file. | ||||||
|  |  | ||||||
|  | package eventsource | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/models" | ||||||
|  | 	"code.gitea.io/gitea/modules/graceful" | ||||||
|  | 	"code.gitea.io/gitea/modules/log" | ||||||
|  | 	"code.gitea.io/gitea/modules/setting" | ||||||
|  | 	"code.gitea.io/gitea/modules/timeutil" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // Init starts this eventsource | ||||||
|  | func (m *Manager) Init() { | ||||||
|  | 	go graceful.GetManager().RunWithShutdownContext(m.Run) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Run runs the manager within a provided context | ||||||
|  | func (m *Manager) Run(ctx context.Context) { | ||||||
|  | 	then := timeutil.TimeStampNow().Add(-2) | ||||||
|  | 	timer := time.NewTicker(setting.UI.Notification.EventSourceUpdateTime) | ||||||
|  | loop: | ||||||
|  | 	for { | ||||||
|  | 		select { | ||||||
|  | 		case <-ctx.Done(): | ||||||
|  | 			timer.Stop() | ||||||
|  | 			break loop | ||||||
|  | 		case <-timer.C: | ||||||
|  | 			now := timeutil.TimeStampNow().Add(-2) | ||||||
|  |  | ||||||
|  | 			uidCounts, err := models.GetUIDsAndNotificationCounts(then, now) | ||||||
|  | 			if err != nil { | ||||||
|  | 				log.Error("Unable to get UIDcounts: %v", err) | ||||||
|  | 			} | ||||||
|  | 			for _, uidCount := range uidCounts { | ||||||
|  | 				m.SendMessage(uidCount.UserID, &Event{ | ||||||
|  | 					Name: "notification-count", | ||||||
|  | 					Data: uidCount, | ||||||
|  | 				}) | ||||||
|  | 			} | ||||||
|  | 			then = now | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	m.UnregisterAll() | ||||||
|  | } | ||||||
							
								
								
									
										78
									
								
								modules/eventsource/messenger.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								modules/eventsource/messenger.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | |||||||
|  | // Copyright 2020 The Gitea Authors. All rights reserved. | ||||||
|  | // Use of this source code is governed by a MIT-style | ||||||
|  | // license that can be found in the LICENSE file. | ||||||
|  |  | ||||||
|  | package eventsource | ||||||
|  |  | ||||||
|  | import "sync" | ||||||
|  |  | ||||||
|  | // Messenger is a per uid message store | ||||||
|  | type Messenger struct { | ||||||
|  | 	mutex    sync.Mutex | ||||||
|  | 	uid      int64 | ||||||
|  | 	channels []chan *Event | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // NewMessenger creates a messenger for a particular uid | ||||||
|  | func NewMessenger(uid int64) *Messenger { | ||||||
|  | 	return &Messenger{ | ||||||
|  | 		uid:      uid, | ||||||
|  | 		channels: [](chan *Event){}, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Register returns a new chan []byte | ||||||
|  | func (m *Messenger) Register() <-chan *Event { | ||||||
|  | 	m.mutex.Lock() | ||||||
|  | 	// TODO: Limit the number of messengers per uid | ||||||
|  | 	channel := make(chan *Event, 1) | ||||||
|  | 	m.channels = append(m.channels, channel) | ||||||
|  | 	m.mutex.Unlock() | ||||||
|  | 	return channel | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Unregister removes the provider chan []byte | ||||||
|  | func (m *Messenger) Unregister(channel <-chan *Event) bool { | ||||||
|  | 	m.mutex.Lock() | ||||||
|  | 	defer m.mutex.Unlock() | ||||||
|  | 	for i, toRemove := range m.channels { | ||||||
|  | 		if channel == toRemove { | ||||||
|  | 			m.channels = append(m.channels[:i], m.channels[i+1:]...) | ||||||
|  | 			close(toRemove) | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return len(m.channels) == 0 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // UnregisterAll removes all chan []byte | ||||||
|  | func (m *Messenger) UnregisterAll() { | ||||||
|  | 	m.mutex.Lock() | ||||||
|  | 	defer m.mutex.Unlock() | ||||||
|  | 	for _, channel := range m.channels { | ||||||
|  | 		close(channel) | ||||||
|  | 	} | ||||||
|  | 	m.channels = nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SendMessage sends the message to all registered channels | ||||||
|  | func (m *Messenger) SendMessage(message *Event) { | ||||||
|  | 	m.mutex.Lock() | ||||||
|  | 	defer m.mutex.Unlock() | ||||||
|  | 	for i := range m.channels { | ||||||
|  | 		channel := m.channels[i] | ||||||
|  | 		select { | ||||||
|  | 		case channel <- message: | ||||||
|  | 		default: | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SendMessageBlocking sends the message to all registered channels and ensures it gets sent | ||||||
|  | func (m *Messenger) SendMessageBlocking(message *Event) { | ||||||
|  | 	m.mutex.Lock() | ||||||
|  | 	defer m.mutex.Unlock() | ||||||
|  | 	for i := range m.channels { | ||||||
|  | 		m.channels[i] <- message | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -182,9 +182,10 @@ var ( | |||||||
| 		UseServiceWorker      bool | 		UseServiceWorker      bool | ||||||
|  |  | ||||||
| 		Notification struct { | 		Notification struct { | ||||||
| 			MinTimeout  time.Duration | 			MinTimeout            time.Duration | ||||||
| 			TimeoutStep time.Duration | 			TimeoutStep           time.Duration | ||||||
| 			MaxTimeout  time.Duration | 			MaxTimeout            time.Duration | ||||||
|  | 			EventSourceUpdateTime time.Duration | ||||||
| 		} `ini:"ui.notification"` | 		} `ini:"ui.notification"` | ||||||
|  |  | ||||||
| 		Admin struct { | 		Admin struct { | ||||||
| @@ -216,13 +217,15 @@ var ( | |||||||
| 		Themes:              []string{`gitea`, `arc-green`}, | 		Themes:              []string{`gitea`, `arc-green`}, | ||||||
| 		Reactions:           []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, | 		Reactions:           []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, | ||||||
| 		Notification: struct { | 		Notification: struct { | ||||||
| 			MinTimeout  time.Duration | 			MinTimeout            time.Duration | ||||||
| 			TimeoutStep time.Duration | 			TimeoutStep           time.Duration | ||||||
| 			MaxTimeout  time.Duration | 			MaxTimeout            time.Duration | ||||||
|  | 			EventSourceUpdateTime time.Duration | ||||||
| 		}{ | 		}{ | ||||||
| 			MinTimeout:  10 * time.Second, | 			MinTimeout:            10 * time.Second, | ||||||
| 			TimeoutStep: 10 * time.Second, | 			TimeoutStep:           10 * time.Second, | ||||||
| 			MaxTimeout:  60 * time.Second, | 			MaxTimeout:            60 * time.Second, | ||||||
|  | 			EventSourceUpdateTime: 10 * time.Second, | ||||||
| 		}, | 		}, | ||||||
| 		Admin: struct { | 		Admin: struct { | ||||||
| 			UserPagingNum   int | 			UserPagingNum   int | ||||||
|   | |||||||
| @@ -284,9 +284,10 @@ func NewFuncMap() []template.FuncMap { | |||||||
| 		}, | 		}, | ||||||
| 		"NotificationSettings": func() map[string]int { | 		"NotificationSettings": func() map[string]int { | ||||||
| 			return map[string]int{ | 			return map[string]int{ | ||||||
| 				"MinTimeout":  int(setting.UI.Notification.MinTimeout / time.Millisecond), | 				"MinTimeout":            int(setting.UI.Notification.MinTimeout / time.Millisecond), | ||||||
| 				"TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond), | 				"TimeoutStep":           int(setting.UI.Notification.TimeoutStep / time.Millisecond), | ||||||
| 				"MaxTimeout":  int(setting.UI.Notification.MaxTimeout / time.Millisecond), | 				"MaxTimeout":            int(setting.UI.Notification.MaxTimeout / time.Millisecond), | ||||||
|  | 				"EventSourceUpdateTime": int(setting.UI.Notification.EventSourceUpdateTime / time.Millisecond), | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 		"contain": func(s []int64, id int64) bool { | 		"contain": func(s []int64, id int64) bool { | ||||||
|   | |||||||
							
								
								
									
										112
									
								
								routers/events/events.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								routers/events/events.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | |||||||
|  | // Copyright 2020 The Gitea Authors. All rights reserved. | ||||||
|  | // Use of this source code is governed by a MIT-style | ||||||
|  | // license that can be found in the LICENSE file. | ||||||
|  |  | ||||||
|  | package events | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"net/http" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/modules/context" | ||||||
|  | 	"code.gitea.io/gitea/modules/eventsource" | ||||||
|  | 	"code.gitea.io/gitea/modules/graceful" | ||||||
|  | 	"code.gitea.io/gitea/modules/log" | ||||||
|  | 	"code.gitea.io/gitea/routers/user" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // Events listens for events | ||||||
|  | func Events(ctx *context.Context) { | ||||||
|  | 	// FIXME: Need to check if resp is actually a http.Flusher! - how though? | ||||||
|  |  | ||||||
|  | 	// Set the headers related to event streaming. | ||||||
|  | 	ctx.Resp.Header().Set("Content-Type", "text/event-stream") | ||||||
|  | 	ctx.Resp.Header().Set("Cache-Control", "no-cache") | ||||||
|  | 	ctx.Resp.Header().Set("Connection", "keep-alive") | ||||||
|  | 	ctx.Resp.Header().Set("X-Accel-Buffering", "no") | ||||||
|  | 	ctx.Resp.WriteHeader(http.StatusOK) | ||||||
|  |  | ||||||
|  | 	// Listen to connection close and un-register messageChan | ||||||
|  | 	notify := ctx.Req.Context().Done() | ||||||
|  | 	ctx.Resp.Flush() | ||||||
|  |  | ||||||
|  | 	shutdownCtx := graceful.GetManager().ShutdownContext() | ||||||
|  |  | ||||||
|  | 	uid := ctx.User.ID | ||||||
|  |  | ||||||
|  | 	messageChan := eventsource.GetManager().Register(uid) | ||||||
|  |  | ||||||
|  | 	unregister := func() { | ||||||
|  | 		eventsource.GetManager().Unregister(uid, messageChan) | ||||||
|  | 		// ensure the messageChan is closed | ||||||
|  | 		for { | ||||||
|  | 			_, ok := <-messageChan | ||||||
|  | 			if !ok { | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if _, err := ctx.Resp.Write([]byte("\n")); err != nil { | ||||||
|  | 		log.Error("Unable to write to EventStream: %v", err) | ||||||
|  | 		unregister() | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	timer := time.NewTicker(30 * time.Second) | ||||||
|  |  | ||||||
|  | loop: | ||||||
|  | 	for { | ||||||
|  | 		select { | ||||||
|  | 		case <-timer.C: | ||||||
|  | 			event := &eventsource.Event{ | ||||||
|  | 				Name: "ping", | ||||||
|  | 			} | ||||||
|  | 			_, err := event.WriteTo(ctx.Resp) | ||||||
|  | 			if err != nil { | ||||||
|  | 				log.Error("Unable to write to EventStream for user %s: %v", ctx.User.Name, err) | ||||||
|  | 				go unregister() | ||||||
|  | 				break loop | ||||||
|  | 			} | ||||||
|  | 			ctx.Resp.Flush() | ||||||
|  | 		case <-notify: | ||||||
|  | 			go unregister() | ||||||
|  | 			break loop | ||||||
|  | 		case <-shutdownCtx.Done(): | ||||||
|  | 			go unregister() | ||||||
|  | 			break loop | ||||||
|  | 		case event, ok := <-messageChan: | ||||||
|  | 			if !ok { | ||||||
|  | 				break loop | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Handle logout | ||||||
|  | 			if event.Name == "logout" { | ||||||
|  | 				if ctx.Session.ID() == event.Data { | ||||||
|  | 					_, _ = (&eventsource.Event{ | ||||||
|  | 						Name: "logout", | ||||||
|  | 						Data: "here", | ||||||
|  | 					}).WriteTo(ctx.Resp) | ||||||
|  | 					ctx.Resp.Flush() | ||||||
|  | 					go unregister() | ||||||
|  | 					user.HandleSignOut(ctx) | ||||||
|  | 					break loop | ||||||
|  | 				} | ||||||
|  | 				// Replace the event - we don't want to expose the session ID to the user | ||||||
|  | 				event = (&eventsource.Event{ | ||||||
|  | 					Name: "logout", | ||||||
|  | 					Data: "elsewhere", | ||||||
|  | 				}) | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			_, err := event.WriteTo(ctx.Resp) | ||||||
|  | 			if err != nil { | ||||||
|  | 				log.Error("Unable to write to EventStream for user %s: %v", ctx.User.Name, err) | ||||||
|  | 				go unregister() | ||||||
|  | 				break loop | ||||||
|  | 			} | ||||||
|  | 			ctx.Resp.Flush() | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	timer.Stop() | ||||||
|  | } | ||||||
| @@ -15,6 +15,7 @@ import ( | |||||||
| 	"code.gitea.io/gitea/modules/auth/sso" | 	"code.gitea.io/gitea/modules/auth/sso" | ||||||
| 	"code.gitea.io/gitea/modules/cache" | 	"code.gitea.io/gitea/modules/cache" | ||||||
| 	"code.gitea.io/gitea/modules/cron" | 	"code.gitea.io/gitea/modules/cron" | ||||||
|  | 	"code.gitea.io/gitea/modules/eventsource" | ||||||
| 	"code.gitea.io/gitea/modules/git" | 	"code.gitea.io/gitea/modules/git" | ||||||
| 	"code.gitea.io/gitea/modules/highlight" | 	"code.gitea.io/gitea/modules/highlight" | ||||||
| 	code_indexer "code.gitea.io/gitea/modules/indexer/code" | 	code_indexer "code.gitea.io/gitea/modules/indexer/code" | ||||||
| @@ -123,6 +124,7 @@ func GlobalInit(ctx context.Context) { | |||||||
| 		if err := task.Init(); err != nil { | 		if err := task.Init(); err != nil { | ||||||
| 			log.Fatal("Failed to initialize task scheduler: %v", err) | 			log.Fatal("Failed to initialize task scheduler: %v", err) | ||||||
| 		} | 		} | ||||||
|  | 		eventsource.GetManager().Init() | ||||||
| 	} | 	} | ||||||
| 	if setting.EnableSQLite3 { | 	if setting.EnableSQLite3 { | ||||||
| 		log.Info("SQLite3 Supported") | 		log.Info("SQLite3 Supported") | ||||||
|   | |||||||
| @@ -27,6 +27,7 @@ import ( | |||||||
| 	"code.gitea.io/gitea/routers/admin" | 	"code.gitea.io/gitea/routers/admin" | ||||||
| 	apiv1 "code.gitea.io/gitea/routers/api/v1" | 	apiv1 "code.gitea.io/gitea/routers/api/v1" | ||||||
| 	"code.gitea.io/gitea/routers/dev" | 	"code.gitea.io/gitea/routers/dev" | ||||||
|  | 	"code.gitea.io/gitea/routers/events" | ||||||
| 	"code.gitea.io/gitea/routers/org" | 	"code.gitea.io/gitea/routers/org" | ||||||
| 	"code.gitea.io/gitea/routers/private" | 	"code.gitea.io/gitea/routers/private" | ||||||
| 	"code.gitea.io/gitea/routers/repo" | 	"code.gitea.io/gitea/routers/repo" | ||||||
| @@ -340,6 +341,8 @@ func RegisterRoutes(m *macaron.Macaron) { | |||||||
| 		}) | 		}) | ||||||
| 	}, reqSignOut) | 	}, reqSignOut) | ||||||
|  |  | ||||||
|  | 	m.Any("/user/events", reqSignIn, events.Events) | ||||||
|  |  | ||||||
| 	m.Group("/login/oauth", func() { | 	m.Group("/login/oauth", func() { | ||||||
| 		m.Get("/authorize", bindIgnErr(auth.AuthorizationForm{}), user.AuthorizeOAuth) | 		m.Get("/authorize", bindIgnErr(auth.AuthorizationForm{}), user.AuthorizeOAuth) | ||||||
| 		m.Post("/grant", bindIgnErr(auth.GrantApplicationForm{}), user.GrantApplicationOAuth) | 		m.Post("/grant", bindIgnErr(auth.GrantApplicationForm{}), user.GrantApplicationOAuth) | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ import ( | |||||||
| 	"code.gitea.io/gitea/modules/auth/oauth2" | 	"code.gitea.io/gitea/modules/auth/oauth2" | ||||||
| 	"code.gitea.io/gitea/modules/base" | 	"code.gitea.io/gitea/modules/base" | ||||||
| 	"code.gitea.io/gitea/modules/context" | 	"code.gitea.io/gitea/modules/context" | ||||||
|  | 	"code.gitea.io/gitea/modules/eventsource" | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
| 	"code.gitea.io/gitea/modules/password" | 	"code.gitea.io/gitea/modules/password" | ||||||
| 	"code.gitea.io/gitea/modules/recaptcha" | 	"code.gitea.io/gitea/modules/recaptcha" | ||||||
| @@ -991,7 +992,8 @@ func LinkAccountPostRegister(ctx *context.Context, cpt *captcha.Captcha, form au | |||||||
| 	ctx.Redirect(setting.AppSubURL + "/user/login") | 	ctx.Redirect(setting.AppSubURL + "/user/login") | ||||||
| } | } | ||||||
|  |  | ||||||
| func handleSignOut(ctx *context.Context) { | // HandleSignOut resets the session and sets the cookies | ||||||
|  | func HandleSignOut(ctx *context.Context) { | ||||||
| 	_ = ctx.Session.Delete("uid") | 	_ = ctx.Session.Delete("uid") | ||||||
| 	_ = ctx.Session.Delete("uname") | 	_ = ctx.Session.Delete("uname") | ||||||
| 	_ = ctx.Session.Delete("socialId") | 	_ = ctx.Session.Delete("socialId") | ||||||
| @@ -1006,7 +1008,13 @@ func handleSignOut(ctx *context.Context) { | |||||||
|  |  | ||||||
| // SignOut sign out from login status | // SignOut sign out from login status | ||||||
| func SignOut(ctx *context.Context) { | func SignOut(ctx *context.Context) { | ||||||
| 	handleSignOut(ctx) | 	if ctx.User != nil { | ||||||
|  | 		eventsource.GetManager().SendMessageBlocking(ctx.User.ID, &eventsource.Event{ | ||||||
|  | 			Name: "logout", | ||||||
|  | 			Data: ctx.Session.ID(), | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 	HandleSignOut(ctx) | ||||||
| 	ctx.Redirect(setting.AppSubURL + "/") | 	ctx.Redirect(setting.AppSubURL + "/") | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -98,6 +98,7 @@ | |||||||
| 				MinTimeout: {{NotificationSettings.MinTimeout}}, | 				MinTimeout: {{NotificationSettings.MinTimeout}}, | ||||||
| 				TimeoutStep:  {{NotificationSettings.TimeoutStep}}, | 				TimeoutStep:  {{NotificationSettings.TimeoutStep}}, | ||||||
| 				MaxTimeout: {{NotificationSettings.MaxTimeout}}, | 				MaxTimeout: {{NotificationSettings.MaxTimeout}}, | ||||||
|  | 				EventSourceUpdateTime: {{NotificationSettings.EventSourceUpdateTime}}, | ||||||
| 			}, | 			}, | ||||||
|       {{if .RequireTribute}} |       {{if .RequireTribute}} | ||||||
| 			tributeValues: [ | 			tributeValues: [ | ||||||
|   | |||||||
| @@ -19,21 +19,53 @@ export function initNotificationsTable() { | |||||||
| } | } | ||||||
|  |  | ||||||
| export function initNotificationCount() { | export function initNotificationCount() { | ||||||
|  |   const notificationCount = $('.notification_count'); | ||||||
|  |  | ||||||
|  |   if (!notificationCount.length) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (NotificationSettings.EventSourceUpdateTime > 0 && !!window.EventSource) { | ||||||
|  |     // Try to connect to the event source first | ||||||
|  |     const source = new EventSource(`${AppSubUrl}/user/events`); | ||||||
|  |     source.addEventListener('notification-count', async (e) => { | ||||||
|  |       try { | ||||||
|  |         const data = JSON.parse(e.data); | ||||||
|  |  | ||||||
|  |         const notificationCount = $('.notification_count'); | ||||||
|  |         if (data.Count === 0) { | ||||||
|  |           notificationCount.addClass('hidden'); | ||||||
|  |         } else { | ||||||
|  |           notificationCount.removeClass('hidden'); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         notificationCount.text(`${data.Count}`); | ||||||
|  |         await updateNotificationTable(); | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error(error); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     source.addEventListener('logout', async (e) => { | ||||||
|  |       if (e.data !== 'here') { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       source.close(); | ||||||
|  |       window.location.href = AppSubUrl; | ||||||
|  |     }); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   if (NotificationSettings.MinTimeout <= 0) { |   if (NotificationSettings.MinTimeout <= 0) { | ||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const notificationCount = $('.notification_count'); |   const fn = (timeout, lastCount) => { | ||||||
|  |     setTimeout(async () => { | ||||||
|  |       await updateNotificationCountWithCallback(fn, timeout, lastCount); | ||||||
|  |     }, timeout); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   if (notificationCount.length > 0) { |   fn(NotificationSettings.MinTimeout, notificationCount.text()); | ||||||
|     const fn = (timeout, lastCount) => { |  | ||||||
|       setTimeout(async () => { |  | ||||||
|         await updateNotificationCountWithCallback(fn, timeout, lastCount); |  | ||||||
|       }, timeout); |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     fn(NotificationSettings.MinTimeout, notificationCount.text()); |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| async function updateNotificationCountWithCallback(callback, timeout, lastCount) { | async function updateNotificationCountWithCallback(callback, timeout, lastCount) { | ||||||
| @@ -54,9 +86,14 @@ async function updateNotificationCountWithCallback(callback, timeout, lastCount) | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   callback(timeout, newCount); |   callback(timeout, newCount); | ||||||
|  |   if (needsUpdate) { | ||||||
|  |     await updateNotificationTable(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function updateNotificationTable() { | ||||||
|   const notificationDiv = $('#notification_div'); |   const notificationDiv = $('#notification_div'); | ||||||
|   if (notificationDiv.length > 0 && needsUpdate) { |   if (notificationDiv.length > 0) { | ||||||
|     const data = await $.ajax({ |     const data = await $.ajax({ | ||||||
|       type: 'GET', |       type: 'GET', | ||||||
|       url: `${AppSubUrl}/notifications?${notificationDiv.data('params')}`, |       url: `${AppSubUrl}/notifications?${notificationDiv.data('params')}`, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user