JSON API errors
This commit is contained in:
		
							parent
							
								
									020c058805
								
							
						
					
					
						commit
						7a23779d07
					
				
					 3 changed files with 66 additions and 35 deletions
				
			
		| 
						 | 
					@ -42,12 +42,19 @@ type Server struct {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// errHTTP is a generic HTTP error for any non-200 HTTP error
 | 
					// errHTTP is a generic HTTP error for any non-200 HTTP error
 | 
				
			||||||
type errHTTP struct {
 | 
					type errHTTP struct {
 | 
				
			||||||
	Code   int
 | 
						Code     int    `json:"code,omitempty"`
 | 
				
			||||||
	Status string
 | 
						HTTPCode int    `json:"http_code"`
 | 
				
			||||||
 | 
						Message  string `json:"error"`
 | 
				
			||||||
 | 
						Link     string `json:"link,omitempty"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (e errHTTP) Error() string {
 | 
					func (e errHTTP) Error() string {
 | 
				
			||||||
	return fmt.Sprintf("http: %s", e.Status)
 | 
						return e.Message
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e errHTTP) JSON() string {
 | 
				
			||||||
 | 
						b, _ := json.Marshal(&e)
 | 
				
			||||||
 | 
						return string(b)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type indexPage struct {
 | 
					type indexPage struct {
 | 
				
			||||||
| 
						 | 
					@ -104,9 +111,22 @@ var (
 | 
				
			||||||
	docsStaticFs     embed.FS
 | 
						docsStaticFs     embed.FS
 | 
				
			||||||
	docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
 | 
						docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	errHTTPBadRequest      = &errHTTP{http.StatusBadRequest, http.StatusText(http.StatusBadRequest)}
 | 
						errHTTPNotFound                          = &errHTTP{40401, http.StatusNotFound, http.StatusText(http.StatusNotFound), ""}
 | 
				
			||||||
	errHTTPNotFound        = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)}
 | 
						errHTTPTooManyRequestsLimitRequests      = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
 | 
				
			||||||
	errHTTPTooManyRequests = &errHTTP{http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)}
 | 
						errHTTPTooManyRequestsLimitEmails        = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
 | 
				
			||||||
 | 
						errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
 | 
				
			||||||
 | 
						errHTTPTooManyRequestsLimitGlobalTopics  = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
 | 
				
			||||||
 | 
						errHTTPBadRequestEmailDisabled           = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
 | 
				
			||||||
 | 
						errHTTPBadRequestDelayNoCache            = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
 | 
				
			||||||
 | 
						errHTTPBadRequestDelayNoEmail            = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
 | 
				
			||||||
 | 
						errHTTPBadRequestDelayCannotParse        = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
 | 
				
			||||||
 | 
						errHTTPBadRequestDelayTooSmall           = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
 | 
				
			||||||
 | 
						errHTTPBadRequestDelayTooLarge           = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
 | 
				
			||||||
 | 
						errHTTPBadRequestPriorityInvalid         = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
 | 
				
			||||||
 | 
						errHTTPBadRequestSinceInvalid            = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"}
 | 
				
			||||||
 | 
						errHTTPBadRequestTopicInvalid            = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
 | 
				
			||||||
 | 
						errHTTPBadRequestTopicDisallowed         = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
 | 
				
			||||||
 | 
						errHTTPInternalError                     = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const (
 | 
					const (
 | 
				
			||||||
| 
						 | 
					@ -240,11 +260,16 @@ func (s *Server) Stop() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
 | 
					func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
 | 
				
			||||||
	if err := s.handleInternal(w, r); err != nil {
 | 
						if err := s.handleInternal(w, r); err != nil {
 | 
				
			||||||
		if e, ok := err.(*errHTTP); ok {
 | 
							var e *errHTTP
 | 
				
			||||||
			s.fail(w, r, e.Code, e)
 | 
							var ok bool
 | 
				
			||||||
		} else {
 | 
							if e, ok = err.(*errHTTP); !ok {
 | 
				
			||||||
			s.fail(w, r, http.StatusInternalServerError, err)
 | 
								e = errHTTPInternalError
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
							log.Printf("[%s] %s - %d - %s", r.RemoteAddr, r.Method, e.HTTPCode, err.Error())
 | 
				
			||||||
 | 
							w.Header().Set("Content-Type", "application/json")
 | 
				
			||||||
 | 
							w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
 | 
				
			||||||
 | 
							w.WriteHeader(e.HTTPCode)
 | 
				
			||||||
 | 
							io.WriteString(w, e.JSON()+"\n")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -320,11 +345,11 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if email != "" {
 | 
						if email != "" {
 | 
				
			||||||
		if err := v.EmailAllowed(); err != nil {
 | 
							if err := v.EmailAllowed(); err != nil {
 | 
				
			||||||
			return err
 | 
								return errHTTPTooManyRequestsLimitEmails
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if s.mailer == nil && email != "" {
 | 
						if s.mailer == nil && email != "" {
 | 
				
			||||||
		return errHTTPBadRequest
 | 
							return errHTTPBadRequestEmailDisabled
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if m.Message == "" {
 | 
						if m.Message == "" {
 | 
				
			||||||
		m.Message = emptyMessageBody
 | 
							m.Message = emptyMessageBody
 | 
				
			||||||
| 
						 | 
					@ -374,7 +399,7 @@ func (s *Server) parseParams(r *http.Request, m *message) (cache bool, firebase
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
 | 
						m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return false, false, "", errHTTPBadRequest
 | 
							return false, false, "", errHTTPBadRequestPriorityInvalid
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
 | 
						tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
 | 
				
			||||||
	if tagsStr != "" {
 | 
						if tagsStr != "" {
 | 
				
			||||||
| 
						 | 
					@ -386,18 +411,18 @@ func (s *Server) parseParams(r *http.Request, m *message) (cache bool, firebase
 | 
				
			||||||
	delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
 | 
						delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
 | 
				
			||||||
	if delayStr != "" {
 | 
						if delayStr != "" {
 | 
				
			||||||
		if !cache {
 | 
							if !cache {
 | 
				
			||||||
			return false, false, "", errHTTPBadRequest
 | 
								return false, false, "", errHTTPBadRequestDelayNoCache
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		if email != "" {
 | 
							if email != "" {
 | 
				
			||||||
			return false, false, "", errHTTPBadRequest // we cannot store the email address (yet)
 | 
								return false, false, "", errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		delay, err := util.ParseFutureTime(delayStr, time.Now())
 | 
							delay, err := util.ParseFutureTime(delayStr, time.Now())
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return false, false, "", errHTTPBadRequest
 | 
								return false, false, "", errHTTPBadRequestDelayCannotParse
 | 
				
			||||||
		} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
 | 
							} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
 | 
				
			||||||
			return false, false, "", errHTTPBadRequest
 | 
								return false, false, "", errHTTPBadRequestDelayTooSmall
 | 
				
			||||||
		} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
 | 
							} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
 | 
				
			||||||
			return false, false, "", errHTTPBadRequest
 | 
								return false, false, "", errHTTPBadRequestDelayTooLarge
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		m.Time = delay.Unix()
 | 
							m.Time = delay.Unix()
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -456,8 +481,8 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visitor, format string, contentType string, encoder messageEncoder) error {
 | 
					func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visitor, format string, contentType string, encoder messageEncoder) error {
 | 
				
			||||||
	if err := v.AddSubscription(); err != nil {
 | 
						if err := v.SubscriptionAllowed(); err != nil {
 | 
				
			||||||
		return errHTTPTooManyRequests
 | 
							return errHTTPTooManyRequestsLimitSubscriptions
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	defer v.RemoveSubscription()
 | 
						defer v.RemoveSubscription()
 | 
				
			||||||
	topicsStr := strings.TrimSuffix(r.URL.Path[1:], "/"+format) // Hack
 | 
						topicsStr := strings.TrimSuffix(r.URL.Path[1:], "/"+format) // Hack
 | 
				
			||||||
| 
						 | 
					@ -603,7 +628,7 @@ func parseSince(r *http.Request, poll bool) (sinceTime, error) {
 | 
				
			||||||
	} else if d, err := time.ParseDuration(since); err == nil {
 | 
						} else if d, err := time.ParseDuration(since); err == nil {
 | 
				
			||||||
		return sinceTime(time.Now().Add(-1 * d)), nil
 | 
							return sinceTime(time.Now().Add(-1 * d)), nil
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return sinceNoMessages, errHTTPBadRequest
 | 
						return sinceNoMessages, errHTTPBadRequestSinceInvalid
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
 | 
					func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
 | 
				
			||||||
| 
						 | 
					@ -615,7 +640,7 @@ func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
 | 
				
			||||||
func (s *Server) topicFromPath(path string) (*topic, error) {
 | 
					func (s *Server) topicFromPath(path string) (*topic, error) {
 | 
				
			||||||
	parts := strings.Split(path, "/")
 | 
						parts := strings.Split(path, "/")
 | 
				
			||||||
	if len(parts) < 2 {
 | 
						if len(parts) < 2 {
 | 
				
			||||||
		return nil, errHTTPBadRequest
 | 
							return nil, errHTTPBadRequestTopicInvalid
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	topics, err := s.topicsFromIDs(parts[1])
 | 
						topics, err := s.topicsFromIDs(parts[1])
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
| 
						 | 
					@ -630,11 +655,11 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
 | 
				
			||||||
	topics := make([]*topic, 0)
 | 
						topics := make([]*topic, 0)
 | 
				
			||||||
	for _, id := range ids {
 | 
						for _, id := range ids {
 | 
				
			||||||
		if util.InStringList(disallowedTopics, id) {
 | 
							if util.InStringList(disallowedTopics, id) {
 | 
				
			||||||
			return nil, errHTTPBadRequest
 | 
								return nil, errHTTPBadRequestTopicDisallowed
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		if _, ok := s.topics[id]; !ok {
 | 
							if _, ok := s.topics[id]; !ok {
 | 
				
			||||||
			if len(s.topics) >= s.config.GlobalTopicLimit {
 | 
								if len(s.topics) >= s.config.GlobalTopicLimit {
 | 
				
			||||||
				return nil, errHTTPTooManyRequests
 | 
									return nil, errHTTPTooManyRequestsLimitGlobalTopics
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
			s.topics[id] = newTopic(id)
 | 
								s.topics[id] = newTopic(id)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
| 
						 | 
					@ -752,7 +777,7 @@ func (s *Server) sendDelayedMessages() error {
 | 
				
			||||||
func (s *Server) withRateLimit(w http.ResponseWriter, r *http.Request, handler func(w http.ResponseWriter, r *http.Request, v *visitor) error) error {
 | 
					func (s *Server) withRateLimit(w http.ResponseWriter, r *http.Request, handler func(w http.ResponseWriter, r *http.Request, v *visitor) error) error {
 | 
				
			||||||
	v := s.visitor(r)
 | 
						v := s.visitor(r)
 | 
				
			||||||
	if err := v.RequestAllowed(); err != nil {
 | 
						if err := v.RequestAllowed(); err != nil {
 | 
				
			||||||
		return err
 | 
							return errHTTPTooManyRequestsLimitRequests
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return handler(w, r, v)
 | 
						return handler(w, r, v)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -784,9 +809,3 @@ func (s *Server) inc(counter *int64) {
 | 
				
			||||||
	defer s.mu.Unlock()
 | 
						defer s.mu.Unlock()
 | 
				
			||||||
	*counter++
 | 
						*counter++
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
func (s *Server) fail(w http.ResponseWriter, r *http.Request, code int, err error) {
 | 
					 | 
				
			||||||
	log.Printf("[%s] %s - %d - %s", r.RemoteAddr, r.Method, code, err.Error())
 | 
					 | 
				
			||||||
	w.WriteHeader(code)
 | 
					 | 
				
			||||||
	_, _ = io.WriteString(w, fmt.Sprintf("%s\n", http.StatusText(code)))
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -252,6 +252,7 @@ func TestServer_PublishAtWithCacheError(t *testing.T) {
 | 
				
			||||||
		"In":    "30 min",
 | 
							"In":    "30 min",
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
	require.Equal(t, 400, response.Code)
 | 
						require.Equal(t, 400, response.Code)
 | 
				
			||||||
 | 
						require.Equal(t, errHTTPBadRequestDelayNoCache, toHTTPError(t, response.Body.String()))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func TestServer_PublishAtTooShortDelay(t *testing.T) {
 | 
					func TestServer_PublishAtTooShortDelay(t *testing.T) {
 | 
				
			||||||
| 
						 | 
					@ -644,6 +645,12 @@ func toMessage(t *testing.T, s string) *message {
 | 
				
			||||||
	return &m
 | 
						return &m
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func toHTTPError(t *testing.T, s string) *errHTTP {
 | 
				
			||||||
 | 
						var e errHTTP
 | 
				
			||||||
 | 
						require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&e))
 | 
				
			||||||
 | 
						return &e
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func firebaseServiceAccountFile(t *testing.T) string {
 | 
					func firebaseServiceAccountFile(t *testing.T) string {
 | 
				
			||||||
	if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
 | 
						if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
 | 
				
			||||||
		return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
 | 
							return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,7 @@
 | 
				
			||||||
package server
 | 
					package server
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"errors"
 | 
				
			||||||
	"golang.org/x/time/rate"
 | 
						"golang.org/x/time/rate"
 | 
				
			||||||
	"heckel.io/ntfy/util"
 | 
						"heckel.io/ntfy/util"
 | 
				
			||||||
	"sync"
 | 
						"sync"
 | 
				
			||||||
| 
						 | 
					@ -14,6 +15,10 @@ const (
 | 
				
			||||||
	visitorExpungeAfter = 24 * time.Hour
 | 
						visitorExpungeAfter = 24 * time.Hour
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var (
 | 
				
			||||||
 | 
						errVisitorLimitReached = errors.New("limit reached")
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
 | 
					// visitor represents an API user, and its associated rate.Limiter used for rate limiting
 | 
				
			||||||
type visitor struct {
 | 
					type visitor struct {
 | 
				
			||||||
	config        *Config
 | 
						config        *Config
 | 
				
			||||||
| 
						 | 
					@ -42,23 +47,23 @@ func (v *visitor) IP() string {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (v *visitor) RequestAllowed() error {
 | 
					func (v *visitor) RequestAllowed() error {
 | 
				
			||||||
	if !v.requests.Allow() {
 | 
						if !v.requests.Allow() {
 | 
				
			||||||
		return errHTTPTooManyRequests
 | 
							return errVisitorLimitReached
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (v *visitor) EmailAllowed() error {
 | 
					func (v *visitor) EmailAllowed() error {
 | 
				
			||||||
	if !v.emails.Allow() {
 | 
						if !v.emails.Allow() {
 | 
				
			||||||
		return errHTTPTooManyRequests
 | 
							return errVisitorLimitReached
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (v *visitor) AddSubscription() error {
 | 
					func (v *visitor) SubscriptionAllowed() error {
 | 
				
			||||||
	v.mu.Lock()
 | 
						v.mu.Lock()
 | 
				
			||||||
	defer v.mu.Unlock()
 | 
						defer v.mu.Unlock()
 | 
				
			||||||
	if err := v.subscriptions.Add(1); err != nil {
 | 
						if err := v.subscriptions.Add(1); err != nil {
 | 
				
			||||||
		return errHTTPTooManyRequests
 | 
							return errVisitorLimitReached
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue