@@ -23,7 +23,6 @@ import (
23
23
"strconv"
24
24
"strings"
25
25
"sync"
26
- "text/template"
27
26
"time"
28
27
"unicode/utf8"
29
28
88
87
apiHealthPath = "/v1/health"
89
88
apiStatsPath = "/v1/stats"
90
89
apiWebPushPath = "/v1/webpush"
90
+ apiTemplatesPath = "/v1/templates"
91
91
apiTiersPath = "/v1/tiers"
92
92
apiUsersPath = "/v1/users"
93
93
apiUsersAccessPath = "/v1/users/access"
@@ -505,6 +505,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
505
505
return s .handleStats (w , r , v )
506
506
} else if r .Method == http .MethodGet && r .URL .Path == apiTiersPath {
507
507
return s .ensurePaymentsEnabled (s .handleBillingTiersGet )(w , r , v )
508
+ } else if r .Method == http .MethodGet && strings .HasPrefix (r .URL .Path , apiTemplatesPath ) {
509
+ return s .ensureTemplatesEnabled (s .limitRequests (s .handleTemplates ))(w , r , v )
508
510
} else if r .Method == http .MethodGet && r .URL .Path == matrixPushPath {
509
511
return s .handleMatrixDiscovery (w )
510
512
} else if r .Method == http .MethodGet && r .URL .Path == metricsPath && s .metricsHandler != nil {
@@ -617,6 +619,27 @@ func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *vi
617
619
return s .writeJSONWithContentType (w , response , "application/manifest+json" )
618
620
}
619
621
622
+ // handleTemplates either writes a list of available templates (if only the base API path
623
+ // is requested) or serves the requested template file.
624
+ func (s * Server ) handleTemplates (w http.ResponseWriter , r * http.Request , _ * visitor ) error {
625
+ path := r .URL .Path [len (apiTemplatesPath ):]
626
+
627
+ if path == "" || path == "/" {
628
+ ls , err := s .listTemplates ()
629
+ if err != nil {
630
+ return err
631
+ }
632
+
633
+ response := & templateNamesResponse {
634
+ Templates : ls ,
635
+ }
636
+
637
+ return s .writeJSON (w , response )
638
+ }
639
+
640
+ return s .serveTemplate (w , r , path )
641
+ }
642
+
620
643
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
621
644
// and listen-metrics-http is not set.
622
645
func (s * Server ) handleMetrics (w http.ResponseWriter , r * http.Request , _ * visitor ) error {
@@ -933,7 +956,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
933
956
}
934
957
}
935
958
936
- func (s * Server ) parsePublishParams (r * http.Request , m * message ) (cache bool , firebase bool , email , call string , template bool , unifiedpush bool , err * errHTTP ) {
959
+ func (s * Server ) parsePublishParams (r * http.Request , m * message ) (cache bool , firebase bool , email , call string , template templateFeature , unifiedpush bool , err * errHTTP ) {
937
960
cache = readBoolParam (r , true , "x-cache" , "cache" )
938
961
firebase = readBoolParam (r , true , "x-firebase" , "firebase" )
939
962
m .Title = readParam (r , "x-title" , "title" , "t" )
@@ -949,7 +972,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
949
972
}
950
973
if attach != "" {
951
974
if ! urlRegex .MatchString (attach ) {
952
- return false , false , "" , "" , false , false , errHTTPBadRequestAttachmentURLInvalid
975
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestAttachmentURLInvalid
953
976
}
954
977
m .Attachment .URL = attach
955
978
if m .Attachment .Name == "" {
@@ -967,19 +990,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
967
990
}
968
991
if icon != "" {
969
992
if ! urlRegex .MatchString (icon ) {
970
- return false , false , "" , "" , false , false , errHTTPBadRequestIconURLInvalid
993
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestIconURLInvalid
971
994
}
972
995
m .Icon = icon
973
996
}
974
997
email = readParam (r , "x-email" , "x-e-mail" , "email" , "e-mail" , "mail" , "e" )
975
998
if s .smtpSender == nil && email != "" {
976
- return false , false , "" , "" , false , false , errHTTPBadRequestEmailDisabled
999
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestEmailDisabled
977
1000
}
978
1001
call = readParam (r , "x-call" , "call" )
979
1002
if call != "" && (s .config .TwilioAccount == "" || s .userManager == nil ) {
980
- return false , false , "" , "" , false , false , errHTTPBadRequestPhoneCallsDisabled
1003
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestPhoneCallsDisabled
981
1004
} else if call != "" && ! isBoolValue (call ) && ! phoneNumberRegex .MatchString (call ) {
982
- return false , false , "" , "" , false , false , errHTTPBadRequestPhoneNumberInvalid
1005
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestPhoneNumberInvalid
983
1006
}
984
1007
messageStr := strings .ReplaceAll (readParam (r , "x-message" , "message" , "m" ), "\\ n" , "\n " )
985
1008
if messageStr != "" {
@@ -988,42 +1011,42 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
988
1011
var e error
989
1012
m .Priority , e = util .ParsePriority (readParam (r , "x-priority" , "priority" , "prio" , "p" ))
990
1013
if e != nil {
991
- return false , false , "" , "" , false , false , errHTTPBadRequestPriorityInvalid
1014
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestPriorityInvalid
992
1015
}
993
1016
m .Tags = readCommaSeparatedParam (r , "x-tags" , "tags" , "tag" , "ta" )
994
1017
delayStr := readParam (r , "x-delay" , "delay" , "x-at" , "at" , "x-in" , "in" )
995
1018
if delayStr != "" {
996
1019
if ! cache {
997
- return false , false , "" , "" , false , false , errHTTPBadRequestDelayNoCache
1020
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestDelayNoCache
998
1021
}
999
1022
if email != "" {
1000
- return false , false , "" , "" , false , false , errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
1023
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
1001
1024
}
1002
1025
if call != "" {
1003
- return false , false , "" , "" , false , false , errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
1026
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
1004
1027
}
1005
1028
delay , err := util .ParseFutureTime (delayStr , time .Now ())
1006
1029
if err != nil {
1007
- return false , false , "" , "" , false , false , errHTTPBadRequestDelayCannotParse
1030
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestDelayCannotParse
1008
1031
} else if delay .Unix () < time .Now ().Add (s .config .MessageDelayMin ).Unix () {
1009
- return false , false , "" , "" , false , false , errHTTPBadRequestDelayTooSmall
1032
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestDelayTooSmall
1010
1033
} else if delay .Unix () > time .Now ().Add (s .config .MessageDelayMax ).Unix () {
1011
- return false , false , "" , "" , false , false , errHTTPBadRequestDelayTooLarge
1034
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestDelayTooLarge
1012
1035
}
1013
1036
m .Time = delay .Unix ()
1014
1037
}
1015
1038
actionsStr := readParam (r , "x-actions" , "actions" , "action" )
1016
1039
if actionsStr != "" {
1017
1040
m .Actions , e = parseActions (actionsStr )
1018
1041
if e != nil {
1019
- return false , false , "" , "" , false , false , errHTTPBadRequestActionsInvalid .Wrap (e .Error ())
1042
+ return false , false , "" , "" , templateFeatureDisabled , false , errHTTPBadRequestActionsInvalid .Wrap (e .Error ())
1020
1043
}
1021
1044
}
1022
1045
contentType , markdown := readParam (r , "content-type" , "content_type" ), readBoolParam (r , false , "x-markdown" , "markdown" , "md" )
1023
1046
if markdown || strings .ToLower (contentType ) == "text/markdown" {
1024
1047
m .ContentType = "text/markdown"
1025
1048
}
1026
- template = readBoolParam ( r , false , "x-template" , "template" , "tpl" )
1049
+ template = parseTemplateFeature ( readParam ( r , "x-template" , "template" , "tpl" ) )
1027
1050
unifiedpush = readBoolParam (r , false , "x-unifiedpush" , "unifiedpush" , "up" ) // see GET too!
1028
1051
if unifiedpush {
1029
1052
firebase = false
@@ -1050,11 +1073,13 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
1050
1073
// Body must be attachment, because we passed a filename
1051
1074
// 5. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic
1052
1075
// If templating is enabled, read up to 32k and treat message body as JSON
1053
- // 6. curl -T file.txt ntfy.sh/mytopic
1054
- // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
1076
+ // 6. curl -H "Template: server" - T file.txt ntfy.sh/mytopic?m=foobar
1077
+ // Read foobar template from filesystem and treat message body as JSON
1055
1078
// 7. curl -T file.txt ntfy.sh/mytopic
1079
+ // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
1080
+ // 8. curl -T file.txt ntfy.sh/mytopic
1056
1081
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
1057
- func (s * Server ) handlePublishBody (r * http.Request , v * visitor , m * message , body * util.PeekedReadCloser , template , unifiedpush bool ) error {
1082
+ func (s * Server ) handlePublishBody (r * http.Request , v * visitor , m * message , body * util.PeekedReadCloser , template templateFeature , unifiedpush bool ) error {
1058
1083
if m .Event == pollRequestEvent { // Case 1
1059
1084
return s .handleBodyDiscard (body )
1060
1085
} else if unifiedpush {
@@ -1063,12 +1088,12 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
1063
1088
return s .handleBodyAsTextMessage (m , body ) // Case 3
1064
1089
} else if m .Attachment != nil && m .Attachment .Name != "" {
1065
1090
return s .handleBodyAsAttachment (r , v , m , body ) // Case 4
1066
- } else if template {
1067
- return s .handleBodyAsTemplatedTextMessage (m , body ) // Case 5
1091
+ } else if template != templateFeatureDisabled {
1092
+ return s .handleBodyAsTemplatedTextMessage (template , v , m , body ) // Case 5&6
1068
1093
} else if ! body .LimitReached && utf8 .Valid (body .PeekedBytes ) {
1069
- return s .handleBodyAsTextMessage (m , body ) // Case 6
1094
+ return s .handleBodyAsTextMessage (m , body ) // Case 7
1070
1095
}
1071
- return s .handleBodyAsAttachment (r , v , m , body ) // Case 7
1096
+ return s .handleBodyAsAttachment (r , v , m , body ) // Case 8
1072
1097
}
1073
1098
1074
1099
func (s * Server ) handleBodyDiscard (body * util.PeekedReadCloser ) error {
@@ -1100,45 +1125,51 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
1100
1125
return nil
1101
1126
}
1102
1127
1103
- func (s * Server ) handleBodyAsTemplatedTextMessage (m * message , body * util.PeekedReadCloser ) error {
1128
+ func (s * Server ) handleBodyAsTemplatedTextMessage (t templateFeature , v * visitor , m * message , body * util.PeekedReadCloser ) error {
1129
+ var internalError error
1130
+ defer func () {
1131
+ if internalError != nil {
1132
+ logvm (v , m ).Err (internalError ).Info ("Failed to render %s templated message" , t )
1133
+ }
1134
+ }()
1135
+
1104
1136
body , err := util .Peek (body , max (s .config .MessageSizeLimit , jsonBodyBytesLimit ))
1105
1137
if err != nil {
1106
1138
return err
1107
1139
} else if body .LimitReached {
1108
1140
return errHTTPEntityTooLargeJSONBody
1109
1141
}
1110
- peekedBody := strings .TrimSpace (string (body .PeekedBytes ))
1111
- if m .Message , err = replaceTemplate (m .Message , peekedBody ); err != nil {
1112
- return err
1142
+ peekedBody := bytes .TrimSpace (body .PeekedBytes )
1143
+ var data any
1144
+ if err := json .Unmarshal (peekedBody , & data ); err != nil {
1145
+ return errHTTPBadRequestTemplateMessageNotJSON
1113
1146
}
1114
- if m .Title , err = replaceTemplate (m .Title , peekedBody ); err != nil {
1115
- return err
1147
+
1148
+ if t == templateFeatureInline {
1149
+ if m .Message , internalError , err = s .replaceTemplate (m .Message , data ); err != nil {
1150
+ return err
1151
+ }
1152
+ if m .Title , internalError , err = s .replaceTemplate (m .Title , data ); err != nil {
1153
+ return err
1154
+ }
1155
+ } else if t == templateFeatureServer {
1156
+ if s .config .TemplateDirectory == "" {
1157
+ return errHTTPBadRequestTemplatesNotEnabled
1158
+ }
1159
+ if m .Message , internalError , err = s .replaceTemplateFile (m .Message , data ); err != nil {
1160
+ return err
1161
+ }
1162
+ if m .Title , internalError , err = s .replaceTemplateFile (m .Title , data ); err != nil {
1163
+ return err
1164
+ }
1116
1165
}
1166
+
1117
1167
if len (m .Message ) > s .config .MessageSizeLimit {
1118
1168
return errHTTPBadRequestTemplateMessageTooLarge
1119
1169
}
1120
1170
return nil
1121
1171
}
1122
1172
1123
- func replaceTemplate (tpl string , source string ) (string , error ) {
1124
- if templateDisallowedRegex .MatchString (tpl ) {
1125
- return "" , errHTTPBadRequestTemplateDisallowedFunctionCalls
1126
- }
1127
- var data any
1128
- if err := json .Unmarshal ([]byte (source ), & data ); err != nil {
1129
- return "" , errHTTPBadRequestTemplateMessageNotJSON
1130
- }
1131
- t , err := template .New ("" ).Parse (tpl )
1132
- if err != nil {
1133
- return "" , errHTTPBadRequestTemplateInvalid
1134
- }
1135
- var buf bytes.Buffer
1136
- if err := t .Execute (util .NewTimeoutWriter (& buf , templateMaxExecutionTime ), data ); err != nil {
1137
- return "" , errHTTPBadRequestTemplateExecuteFailed
1138
- }
1139
- return buf .String (), nil
1140
- }
1141
-
1142
1173
func (s * Server ) handleBodyAsAttachment (r * http.Request , v * visitor , m * message , body * util.PeekedReadCloser ) error {
1143
1174
if s .fileCache == nil || s .config .BaseURL == "" || s .config .AttachmentCacheDir == "" {
1144
1175
return errHTTPBadRequestAttachmentsDisallowed .With (m )
0 commit comments