|
|
@@ -111,6 +111,7 @@ var (
|
|
|
urlRegex = regexp.MustCompile(`^https?://`)
|
|
|
phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`)
|
|
|
templateVarRegex = regexp.MustCompile(`\${([^}]+)}`)
|
|
|
+ templateVarFormat = "${%s}"
|
|
|
|
|
|
//go:embed site
|
|
|
webFs embed.FS
|
|
|
@@ -125,12 +126,12 @@ var (
|
|
|
|
|
|
const (
|
|
|
firebaseControlTopic = "~control" // See Android if changed
|
|
|
- firebasePollTopic = "~poll" // See iOS if changed
|
|
|
+ firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
|
|
|
emptyMessageBody = "triggered" // Used if message body is empty
|
|
|
newMessageBody = "New message" // Used in poll requests as generic message
|
|
|
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
|
|
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
|
|
- jsonBodyBytesLimit = 16384 // Max number of bytes for a JSON request body
|
|
|
+ httpBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher)
|
|
|
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
|
|
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
|
|
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
|
|
@@ -675,7 +676,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
|
|
// - avoid abuse (e.g. 1 uploader, 1k downloaders)
|
|
|
// - and also uses the higher bandwidth limits of a paying user
|
|
|
m, err := s.messageCache.Message(messageID)
|
|
|
- if err == errMessageNotFound {
|
|
|
+ if errors.Is(err, errMessageNotFound) {
|
|
|
if s.config.CacheBatchTimeout > 0 {
|
|
|
// Strange edge case: If we immediately after upload request the file (the web app does this for images),
|
|
|
// and messages are persisted asynchronously, retry fetching from the database
|
|
|
@@ -874,7 +875,7 @@ func (s *Server) sendToFirebase(v *visitor, m *message) {
|
|
|
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
|
|
if err := s.firebaseClient.Send(v, m); err != nil {
|
|
|
minc(metricFirebasePublishedFailure)
|
|
|
- if err == errFirebaseTemporarilyBanned {
|
|
|
+ if errors.Is(err, errFirebaseTemporarilyBanned) {
|
|
|
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
|
|
|
} else {
|
|
|
logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error())
|
|
|
@@ -1036,37 +1037,30 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|
|
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
|
|
|
// If a message is flagged as poll request, the body does not matter and is discarded
|
|
|
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
|
|
-// If body is binary, encode as base64, if not do not encode
|
|
|
+// If UnifiedPush is enabled, encode as base64 if body is binary, and do not trim
|
|
|
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
|
|
// Body must be a message, because we attached an external URL
|
|
|
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
|
|
// Body must be attachment, because we passed a filename
|
|
|
-// 5. curl -T file.txt ntfy.sh/mytopic
|
|
|
+// 5. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic
|
|
|
+// If templating is enabled, read up to 32k and treat message body as JSON
|
|
|
+// 6. curl -T file.txt ntfy.sh/mytopic
|
|
|
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
|
|
-// 6. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic
|
|
|
-// If file.txt is < 4096*2 (message limit*2) and a template is used, try parsing under the assumption
|
|
|
-// that the message generated by the template will be less than 4096
|
|
|
// 7. curl -T file.txt ntfy.sh/mytopic
|
|
|
// If file.txt is > message limit or template && file.txt > message limit*2, treat it as an attachment
|
|
|
-func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template bool, unifiedpush bool) error {
|
|
|
+func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error {
|
|
|
if m.Event == pollRequestEvent { // Case 1
|
|
|
return s.handleBodyDiscard(body)
|
|
|
} else if unifiedpush {
|
|
|
return s.handleBodyAsMessageAutoDetect(m, body) // Case 2
|
|
|
} else if m.Attachment != nil && m.Attachment.URL != "" {
|
|
|
- return s.handleBodyAsTextMessage(m, body, template) // Case 3
|
|
|
+ return s.handleBodyAsTextMessage(m, body) // Case 3
|
|
|
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
|
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
|
|
- } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
|
|
- return s.handleBodyAsTextMessage(m, body, template) // Case 5
|
|
|
} else if template {
|
|
|
- templateBody, err := util.Peek(body, s.config.MessageSizeLimit*2)
|
|
|
- if err != nil {
|
|
|
- return err
|
|
|
- }
|
|
|
- if !templateBody.LimitReached {
|
|
|
- return s.handleBodyAsTextMessage(m, templateBody, template) // Case 6
|
|
|
- }
|
|
|
+ return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
|
|
|
+ } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
|
|
+ return s.handleBodyAsTextMessage(m, body) // Case 6
|
|
|
}
|
|
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 7
|
|
|
}
|
|
|
@@ -1087,34 +1081,32 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
-func replaceGJSONTemplate(template string, source string) string {
|
|
|
- matches := templateVarRegex.FindAllStringSubmatch(template, -1)
|
|
|
- for _, v := range matches {
|
|
|
- query := v[1]
|
|
|
- if result := gjson.Get(source, query); result.Exists() {
|
|
|
- template = strings.ReplaceAll(template, fmt.Sprintf("${%s}", query), result.String())
|
|
|
- }
|
|
|
- }
|
|
|
- return template
|
|
|
-}
|
|
|
-
|
|
|
-func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser, template bool) error {
|
|
|
+func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
|
|
|
if !utf8.Valid(body.PeekedBytes) {
|
|
|
return errHTTPBadRequestMessageNotUTF8.With(m)
|
|
|
}
|
|
|
if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!)
|
|
|
- peekedBody := strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required
|
|
|
- if template && gjson.Valid(peekedBody) {
|
|
|
- m.Message = replaceGJSONTemplate(m.Message, peekedBody)
|
|
|
- m.Title = replaceGJSONTemplate(m.Title, peekedBody)
|
|
|
- } else {
|
|
|
- m.Message = peekedBody
|
|
|
- }
|
|
|
+ m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required
|
|
|
}
|
|
|
if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" {
|
|
|
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
|
|
|
}
|
|
|
- // Ensure message is less than message limit after templating
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
|
|
|
+ body, err := util.Peek(body, httpBodyBytesLimit)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ } else if body.LimitReached {
|
|
|
+ return errHTTPEntityTooLargeJSONBody
|
|
|
+ }
|
|
|
+ peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
|
|
+ if !gjson.Valid(peekedBody) {
|
|
|
+ return errHTTPBadRequestTemplatedMessageNotJSON
|
|
|
+ }
|
|
|
+ m.Message = replaceGJSONTemplate(m.Message, peekedBody)
|
|
|
+ m.Title = replaceGJSONTemplate(m.Title, peekedBody)
|
|
|
if len(m.Message) > s.config.MessageSizeLimit {
|
|
|
return errHTTPBadRequestTemplatedMessageTooLarge
|
|
|
}
|
|
|
@@ -1163,7 +1155,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
|
|
util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
|
|
|
}
|
|
|
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
|
|
- if err == util.ErrLimitReached {
|
|
|
+ if errors.Is(err, util.ErrLimitReached) {
|
|
|
return errHTTPEntityTooLargeAttachment.With(m)
|
|
|
} else if err != nil {
|
|
|
return err
|
|
|
@@ -1171,6 +1163,16 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
+func replaceGJSONTemplate(template string, source string) string {
|
|
|
+ matches := templateVarRegex.FindAllStringSubmatch(template, -1)
|
|
|
+ for _, m := range matches {
|
|
|
+ if result := gjson.Get(source, m[1]); result.Exists() {
|
|
|
+ template = strings.ReplaceAll(template, fmt.Sprintf(templateVarFormat, m[1]), result.String())
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return template
|
|
|
+}
|
|
|
+
|
|
|
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
|
encoder := func(msg *message) (string, error) {
|
|
|
var buf bytes.Buffer
|