Philipp Heckel 4 лет назад
Родитель
Сommit
38788bb2e9
9 измененных файлов с 290 добавлено и 129 удалено
  1. 1 1
      cmd/serve.go
  2. 21 0
      docs/publish.md
  3. 2 0
      go.mod
  4. 5 0
      go.sum
  5. 85 17
      server/cache_sqlite.go
  6. 74 64
      server/config.go
  7. 5 3
      server/message.go
  8. 92 41
      server/server.go
  9. 5 3
      server/visitor.go

+ 1 - 1
cmd/serve.go

@@ -30,7 +30,7 @@ var flagsServe = []cli.Flag{
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
-	altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultGlobalTopicLimit, Usage: "total number of topics allowed"}),
+	altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),

+ 21 - 0
docs/publish.md

@@ -592,6 +592,26 @@ Here's an example with a custom message, tags and a priority:
     file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
     ```
 
+## Send files + URLs
+```
+curl -T image.jpg ntfy.sh/howdy
+
+curl \
+    -T flower.jpg \
+    -H "Message: Here's a flower for you" \
+    -H "Filename: flower.jpg" \
+    ntfy.sh/howdy
+
+curl \
+    -T files.zip \
+    "ntfy.sh/howdy?m=Important+documents+attached"
+
+curl \
+    -d "A link for you" \
+    -H "Link: https://unifiedpush.org" \
+    "ntfy.sh/howdy"
+```
+
 ## E-mail notifications
 You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that 
 you'd like to persist longer, or to blast-notify yourself on all possible channels. 
@@ -883,6 +903,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
 | `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
 | `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
 | `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
+| `X-Filename` | `Filename`, `file`, `f` | XXXXXXXXXXXXXXXX |
 | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
 | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
 | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |

+ 2 - 0
go.mod

@@ -27,6 +27,7 @@ require (
 	github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
 	github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/disintegration/imaging v1.6.2 // indirect
 	github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
 	github.com/envoyproxy/go-control-plane v0.10.1 // indirect
 	github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
@@ -38,6 +39,7 @@ require (
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
+	golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
 	golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
 	golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect
 	golang.org/x/text v0.3.7 // indirect

+ 5 - 0
go.sum

@@ -89,6 +89,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
 github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
@@ -264,6 +266,9 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH
 golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
+golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=

+ 85 - 17
server/cache_sqlite.go

@@ -22,27 +22,35 @@ const (
 			title TEXT NOT NULL,
 			priority INT NOT NULL,
 			tags TEXT NOT NULL,
+			attachment_name TEXT NOT NULL,
+			attachment_type TEXT NOT NULL,
+			attachment_size INT NOT NULL,
+			attachment_expires INT NOT NULL,
+			attachment_url TEXT NOT NULL,
 			published INT NOT NULL
 		);
 		CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
 		COMMIT;
 	`
-	insertMessageQuery           = `INSERT INTO messages (id, time, topic, message, title, priority, tags, published) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
+	insertMessageQuery = `
+		INSERT INTO messages (id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, published) 
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+	`
 	pruneMessagesQuery           = `DELETE FROM messages WHERE time < ? AND published = 1`
 	selectMessagesSinceTimeQuery = `
-		SELECT id, time, topic, message, title, priority, tags
+		SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url
 		FROM messages 
 		WHERE topic = ? AND time >= ? AND published = 1
 		ORDER BY time ASC
 	`
 	selectMessagesSinceTimeIncludeScheduledQuery = `
-		SELECT id, time, topic, message, title, priority, tags
+		SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url
 		FROM messages 
 		WHERE topic = ? AND time >= ?
 		ORDER BY time ASC
 	`
 	selectMessagesDueQuery = `
-		SELECT id, time, topic, message, title, priority, tags
+		SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url
 		FROM messages 
 		WHERE time <= ? AND published = 0
 	`
@@ -54,7 +62,7 @@ const (
 
 // Schema management queries
 const (
-	currentSchemaVersion          = 2
+	currentSchemaVersion          = 3
 	createSchemaVersionTableQuery = `
 		CREATE TABLE IF NOT EXISTS schemaVersion (
 			id INT PRIMARY KEY,
@@ -78,6 +86,17 @@ const (
 	migrate1To2AlterMessagesTableQuery = `
 		ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
 	`
+
+	// 2 -> 3
+	migrate2To3AlterMessagesTableQuery = `
+		BEGIN;
+		ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL;
+		ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL;
+		ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL;
+		ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL;
+		ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL;
+		COMMIT;
+	`
 )
 
 type sqliteCache struct {
@@ -104,7 +123,32 @@ func (c *sqliteCache) AddMessage(m *message) error {
 		return errUnexpectedMessageType
 	}
 	published := m.Time <= time.Now().Unix()
-	_, err := c.db.Exec(insertMessageQuery, m.ID, m.Time, m.Topic, m.Message, m.Title, m.Priority, strings.Join(m.Tags, ","), published)
+	tags := strings.Join(m.Tags, ",")
+	var attachmentName, attachmentType, attachmentURL string
+	var attachmentSize, attachmentExpires int64
+	if m.Attachment != nil {
+		attachmentName = m.Attachment.Name
+		attachmentType = m.Attachment.Type
+		attachmentSize = m.Attachment.Size
+		attachmentExpires = m.Attachment.Expires
+		attachmentURL = m.Attachment.URL
+	}
+	_, err := c.db.Exec(
+		insertMessageQuery,
+		m.ID,
+		m.Time,
+		m.Topic,
+		m.Message,
+		m.Title,
+		m.Priority,
+		tags,
+		attachmentName,
+		attachmentType,
+		attachmentSize,
+		attachmentExpires,
+		attachmentURL,
+		published,
+	)
 	return err
 }
 
@@ -185,25 +229,36 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
 	defer rows.Close()
 	messages := make([]*message, 0)
 	for rows.Next() {
-		var timestamp int64
+		var timestamp, attachmentSize, attachmentExpires int64
 		var priority int
-		var id, topic, msg, title, tagsStr string
-		if err := rows.Scan(&id, &timestamp, &topic, &msg, &title, &priority, &tagsStr); err != nil {
+		var id, topic, msg, title, tagsStr, attachmentName, attachmentType, attachmentURL string
+		if err := rows.Scan(&id, &timestamp, &topic, &msg, &title, &priority, &tagsStr, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentURL); err != nil {
 			return nil, err
 		}
 		var tags []string
 		if tagsStr != "" {
 			tags = strings.Split(tagsStr, ",")
 		}
+		var att *attachment
+		if attachmentName != "" && attachmentURL != "" {
+			att = &attachment{
+				Name:    attachmentName,
+				Type:    attachmentType,
+				Size:    attachmentSize,
+				Expires: attachmentExpires,
+				URL:     attachmentURL,
+			}
+		}
 		messages = append(messages, &message{
-			ID:       id,
-			Time:     timestamp,
-			Event:    messageEvent,
-			Topic:    topic,
-			Message:  msg,
-			Title:    title,
-			Priority: priority,
-			Tags:     tags,
+			ID:         id,
+			Time:       timestamp,
+			Event:      messageEvent,
+			Topic:      topic,
+			Message:    msg,
+			Title:      title,
+			Priority:   priority,
+			Tags:       tags,
+			Attachment: att,
 		})
 	}
 	if err := rows.Err(); err != nil {
@@ -241,6 +296,8 @@ func setupDB(db *sql.DB) error {
 		return migrateFrom0(db)
 	} else if schemaVersion == 1 {
 		return migrateFrom1(db)
+	} else if schemaVersion == 2 {
+		return migrateFrom2(db)
 	}
 	return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
 }
@@ -280,5 +337,16 @@ func migrateFrom1(db *sql.DB) error {
 	if _, err := db.Exec(updateSchemaVersion, 2); err != nil {
 		return err
 	}
+	return migrateFrom2(db)
+}
+
+func migrateFrom2(db *sql.DB) error {
+	log.Print("Migrating cache database schema: from 2 to 3")
+	if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
+		return err
+	}
+	if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
+		return err
+	}
 	return nil // Update this when a new version is added
 }

+ 74 - 64
server/config.go

@@ -15,85 +15,95 @@ const (
 	DefaultMaxDelay                  = 3 * 24 * time.Hour
 	DefaultMessageLimit              = 4096
 	DefaultAttachmentSizeLimit       = 5 * 1024 * 1024
+	DefaultAttachmentExpiryDuration  = 3 * time.Hour
 	DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery
 )
 
 // Defines all the limits
-// - global topic limit: max number of topics overall
+// - total topic limit: max number of topics overall
+// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
 // - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds)
 // - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour)
-// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
+// - per visitor attachment size limit:
 const (
-	DefaultGlobalTopicLimit             = 5000
-	DefaultVisitorRequestLimitBurst     = 60
-	DefaultVisitorRequestLimitReplenish = 10 * time.Second
-	DefaultVisitorEmailLimitBurst       = 16
-	DefaultVisitorEmailLimitReplenish   = time.Hour
-	DefaultVisitorSubscriptionLimit     = 30
+	DefaultTotalTopicLimit                      = 5000
+	DefaultVisitorSubscriptionLimit             = 30
+	DefaultVisitorRequestLimitBurst             = 60
+	DefaultVisitorRequestLimitReplenish         = 10 * time.Second
+	DefaultVisitorEmailLimitBurst               = 16
+	DefaultVisitorEmailLimitReplenish           = time.Hour
+	DefaultVisitorAttachmentBytesLimitBurst     = 50 * 1024 * 1024
+	DefaultVisitorAttachmentBytesLimitReplenish = time.Hour
 )
 
 // Config is the main config struct for the application. Use New to instantiate a default config struct.
 type Config struct {
-	BaseURL                      string
-	ListenHTTP                   string
-	ListenHTTPS                  string
-	KeyFile                      string
-	CertFile                     string
-	FirebaseKeyFile              string
-	CacheFile                    string
-	CacheDuration                time.Duration
-	AttachmentCacheDir           string
-	AttachmentSizeLimit          int64
-	KeepaliveInterval            time.Duration
-	ManagerInterval              time.Duration
-	AtSenderInterval             time.Duration
-	FirebaseKeepaliveInterval    time.Duration
-	SMTPSenderAddr               string
-	SMTPSenderUser               string
-	SMTPSenderPass               string
-	SMTPSenderFrom               string
-	SMTPServerListen             string
-	SMTPServerDomain             string
-	SMTPServerAddrPrefix         string
-	MessageLimit                 int
-	MinDelay                     time.Duration
-	MaxDelay                     time.Duration
-	TotalTopicLimit              int
-	TotalAttachmentSizeLimit     int64
-	VisitorRequestLimitBurst     int
-	VisitorRequestLimitReplenish time.Duration
-	VisitorEmailLimitBurst       int
-	VisitorEmailLimitReplenish   time.Duration
-	VisitorSubscriptionLimit     int
-	BehindProxy                  bool
+	BaseURL                              string
+	ListenHTTP                           string
+	ListenHTTPS                          string
+	KeyFile                              string
+	CertFile                             string
+	FirebaseKeyFile                      string
+	CacheFile                            string
+	CacheDuration                        time.Duration
+	AttachmentCacheDir                   string
+	AttachmentSizeLimit                  int64
+	AttachmentExpiryDuration             time.Duration
+	KeepaliveInterval                    time.Duration
+	ManagerInterval                      time.Duration
+	AtSenderInterval                     time.Duration
+	FirebaseKeepaliveInterval            time.Duration
+	SMTPSenderAddr                       string
+	SMTPSenderUser                       string
+	SMTPSenderPass                       string
+	SMTPSenderFrom                       string
+	SMTPServerListen                     string
+	SMTPServerDomain                     string
+	SMTPServerAddrPrefix                 string
+	MessageLimit                         int
+	MinDelay                             time.Duration
+	MaxDelay                             time.Duration
+	TotalTopicLimit                      int
+	TotalAttachmentSizeLimit             int64
+	VisitorSubscriptionLimit             int
+	VisitorRequestLimitBurst             int
+	VisitorRequestLimitReplenish         time.Duration
+	VisitorEmailLimitBurst               int
+	VisitorEmailLimitReplenish           time.Duration
+	VisitorAttachmentBytesLimitBurst     int64
+	VisitorAttachmentBytesLimitReplenish time.Duration
+	BehindProxy                          bool
 }
 
 // NewConfig instantiates a default new server config
 func NewConfig() *Config {
 	return &Config{
-		BaseURL:                      "",
-		ListenHTTP:                   DefaultListenHTTP,
-		ListenHTTPS:                  "",
-		KeyFile:                      "",
-		CertFile:                     "",
-		FirebaseKeyFile:              "",
-		CacheFile:                    "",
-		CacheDuration:                DefaultCacheDuration,
-		AttachmentCacheDir:           "",
-		AttachmentSizeLimit:          DefaultAttachmentSizeLimit,
-		KeepaliveInterval:            DefaultKeepaliveInterval,
-		ManagerInterval:              DefaultManagerInterval,
-		MessageLimit:                 DefaultMessageLimit,
-		MinDelay:                     DefaultMinDelay,
-		MaxDelay:                     DefaultMaxDelay,
-		AtSenderInterval:             DefaultAtSenderInterval,
-		FirebaseKeepaliveInterval:    DefaultFirebaseKeepaliveInterval,
-		TotalTopicLimit:              DefaultGlobalTopicLimit,
-		VisitorRequestLimitBurst:     DefaultVisitorRequestLimitBurst,
-		VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
-		VisitorEmailLimitBurst:       DefaultVisitorEmailLimitBurst,
-		VisitorEmailLimitReplenish:   DefaultVisitorEmailLimitReplenish,
-		VisitorSubscriptionLimit:     DefaultVisitorSubscriptionLimit,
-		BehindProxy:                  false,
+		BaseURL:                              "",
+		ListenHTTP:                           DefaultListenHTTP,
+		ListenHTTPS:                          "",
+		KeyFile:                              "",
+		CertFile:                             "",
+		FirebaseKeyFile:                      "",
+		CacheFile:                            "",
+		CacheDuration:                        DefaultCacheDuration,
+		AttachmentCacheDir:                   "",
+		AttachmentSizeLimit:                  DefaultAttachmentSizeLimit,
+		AttachmentExpiryDuration:             DefaultAttachmentExpiryDuration,
+		KeepaliveInterval:                    DefaultKeepaliveInterval,
+		ManagerInterval:                      DefaultManagerInterval,
+		MessageLimit:                         DefaultMessageLimit,
+		MinDelay:                             DefaultMinDelay,
+		MaxDelay:                             DefaultMaxDelay,
+		AtSenderInterval:                     DefaultAtSenderInterval,
+		FirebaseKeepaliveInterval:            DefaultFirebaseKeepaliveInterval,
+		TotalTopicLimit:                      DefaultTotalTopicLimit,
+		VisitorSubscriptionLimit:             DefaultVisitorSubscriptionLimit,
+		VisitorRequestLimitBurst:             DefaultVisitorRequestLimitBurst,
+		VisitorRequestLimitReplenish:         DefaultVisitorRequestLimitReplenish,
+		VisitorEmailLimitBurst:               DefaultVisitorEmailLimitBurst,
+		VisitorEmailLimitReplenish:           DefaultVisitorEmailLimitReplenish,
+		VisitorAttachmentBytesLimitBurst:     DefaultVisitorAttachmentBytesLimitBurst,
+		VisitorAttachmentBytesLimitReplenish: DefaultVisitorAttachmentBytesLimitReplenish,
+		BehindProxy:                          false,
 	}
 }

+ 5 - 3
server/message.go

@@ -30,9 +30,11 @@ type message struct {
 }
 
 type attachment struct {
-	Name string `json:"name"`
-	Type string `json:"type"`
-	URL  string `json:"url"`
+	Name    string `json:"name"`
+	Type    string `json:"type"`
+	Size    int64  `json:"size"`
+	Expires int64  `json:"expires"`
+	URL     string `json:"url"`
 }
 
 // messageEncoder is a function that knows how to encode a message

+ 92 - 41
server/server.go

@@ -9,6 +9,7 @@ import (
 	firebase "firebase.google.com/go"
 	"firebase.google.com/go/messaging"
 	"fmt"
+	"github.com/disintegration/imaging"
 	"github.com/emersion/go-smtp"
 	"google.golang.org/api/option"
 	"heckel.io/ntfy/util"
@@ -101,7 +102,8 @@ var (
 	staticRegex      = regexp.MustCompile(`^/static/.+`)
 	docsRegex        = regexp.MustCompile(`^/docs(|/.*)$`)
 	fileRegex        = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
-	disallowedTopics = []string{"docs", "static", "file"}
+	previewRegex     = regexp.MustCompile(`^/preview/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
+	disallowedTopics = []string{"docs", "static", "file", "preview"}
 
 	templateFnMap = template.FuncMap{
 		"durationToHuman": util.DurationToHuman,
@@ -122,26 +124,26 @@ var (
 	docsStaticFs     embed.FS
 	docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
 
-	errHTTPNotFound                               = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
-	errHTTPTooManyRequestsLimitRequests           = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
-	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", ""}
-	errHTTPBadRequestAttachmentsDisallowed        = &errHTTP{40011, http.StatusBadRequest, "attachments disallowed", ""}
-	errHTTPBadRequestAttachmentsPublishDisallowed = &errHTTP{40011, http.StatusBadRequest, "invalid message: invalid encoding or too large, and attachments are not allowed", ""}
-	errHTTPBadRequestMessageTooLarge              = &errHTTP{40013, http.StatusBadRequest, "invalid message: too large", ""}
-	errHTTPInternalError                          = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
-	errHTTPInternalErrorInvalidFilePath           = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
+	errHTTPNotFound                          = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
+	errHTTPNotFoundTooLarge                  = &errHTTP{40402, http.StatusNotFound, "page not found: preview not available, file too large", ""}
+	errHTTPTooManyRequestsLimitRequests      = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
+	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", ""}
+	errHTTPBadRequestInvalidMessage          = &errHTTP{40011, http.StatusBadRequest, "invalid message: invalid encoding or too large, and attachments are not allowed", ""}
+	errHTTPBadRequestMessageTooLarge         = &errHTTP{40012, http.StatusBadRequest, "invalid message: too large", ""}
+	errHTTPInternalError                     = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
+	errHTTPInternalErrorInvalidFilePath      = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
 )
 
 const (
@@ -226,6 +228,13 @@ func createFirebaseSubscriber(conf *Config) (subscriber, error) {
 				"title":    m.Title,
 				"message":  m.Message,
 			}
+			if m.Attachment != nil {
+				data["attachment_name"] = m.Attachment.Name
+				data["attachment_type"] = m.Attachment.Type
+				data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
+				data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
+				data["attachment_url"] = m.Attachment.URL
+			}
 		}
 		_, err := msg.Send(context.Background(), &messaging.Message{
 			Topic: m.Topic,
@@ -316,8 +325,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
 		return s.handleStatic(w, r)
 	} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
 		return s.handleDocs(w, r)
-	} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) {
+	} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
 		return s.handleFile(w, r)
+	} else if r.Method == http.MethodGet && previewRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
+		return s.handlePreview(w, r)
 	} else if r.Method == http.MethodOptions {
 		return s.handleOptions(w, r)
 	} else if r.Method == http.MethodGet && topicPathRegex.MatchString(r.URL.Path) {
@@ -375,7 +386,7 @@ func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error {
 
 func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) error {
 	if s.config.AttachmentCacheDir == "" {
-		return errHTTPBadRequestAttachmentsDisallowed
+		return errHTTPInternalError
 	}
 	matches := fileRegex.FindStringSubmatch(r.URL.Path)
 	if len(matches) != 2 {
@@ -397,6 +408,39 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) error {
 	return err
 }
 
+func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) error {
+	if s.config.AttachmentCacheDir == "" {
+		return errHTTPInternalError
+	}
+	matches := previewRegex.FindStringSubmatch(r.URL.Path)
+	if len(matches) != 2 {
+		return errHTTPInternalErrorInvalidFilePath
+	}
+	messageID := matches[1]
+	file := filepath.Join(s.config.AttachmentCacheDir, messageID)
+	stat, err := os.Stat(file)
+	if err != nil {
+		return errHTTPNotFound
+	}
+	if stat.Size() > 20*1024*1024 {
+		return errHTTPInternalError
+	}
+	img, err := imaging.Open(file)
+	if err != nil {
+		return errHTTPNotFoundTooLarge
+	}
+	var width, height int
+	if width >= height {
+		width = 200
+		height = int(float32(img.Bounds().Dy()) / float32(img.Bounds().Dx()) * float32(width))
+	} else {
+		height = 200
+		width = int(float32(img.Bounds().Dx()) / float32(img.Bounds().Dy()) * float32(height))
+	}
+	preview := imaging.Resize(img, width, height, imaging.Lanczos)
+	return imaging.Encode(w, preview, imaging.PNG)
+}
+
 func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	t, err := s.topicFromPath(r.URL.Path)
 	if err != nil {
@@ -409,8 +453,12 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
 	m := newDefaultMessage(t.ID, "")
 	if !body.LimitReached && utf8.Valid(body.PeakedBytes) {
 		m.Message = strings.TrimSpace(string(body.PeakedBytes))
-	} else if err := s.writeAttachment(v, m, body); err != nil {
-		return err
+	} else if s.config.AttachmentCacheDir != "" {
+		if err := s.writeAttachment(r, v, m, body); err != nil {
+			return err
+		}
+	} else {
+		return errHTTPBadRequestInvalidMessage
 	}
 	cache, firebase, email, err := s.parsePublishParams(r, m)
 	if err != nil {
@@ -522,29 +570,30 @@ func readParam(r *http.Request, names ...string) string {
 	return ""
 }
 
-func (s *Server) writeAttachment(v *visitor, m *message, body *util.PeakedReadCloser) error {
-	if s.config.AttachmentCacheDir == "" || !util.FileExists(s.config.AttachmentCacheDir) {
-		return errHTTPBadRequestAttachmentsPublishDisallowed
+func (s *Server) writeAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error {
+	if s.config.AttachmentCacheDir == "" {
+		return errHTTPBadRequestInvalidMessage
 	}
 	contentType := http.DetectContentType(body.PeakedBytes)
-	exts, err := mime.ExtensionsByType(contentType)
-	if err != nil {
-		return err
-	}
 	ext := ".bin"
-	if len(exts) > 0 {
+	exts, err := mime.ExtensionsByType(contentType)
+	if err == nil && len(exts) > 0 {
 		ext = exts[0]
 	}
-	filename := fmt.Sprintf("attachment%s", ext)
+	filename := readParam(r, "x-filename", "filename", "file", "f")
+	if filename == "" {
+		filename = fmt.Sprintf("attachment%s", ext)
+	}
 	file := filepath.Join(s.config.AttachmentCacheDir, m.ID)
 	f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
 	if err != nil {
 		return err
 	}
 	defer f.Close()
-	fileSizeLimiter := util.NewLimiter(s.config.AttachmentSizeLimit)
-	limitWriter := util.NewLimitWriter(f, fileSizeLimiter)
-	if _, err := io.Copy(limitWriter, body); err != nil {
+	maxSizeLimiter := util.NewLimiter(s.config.AttachmentSizeLimit) //FIXME visitor limit
+	limitWriter := util.NewLimitWriter(f, maxSizeLimiter)
+	size, err := io.Copy(limitWriter, body)
+	if err != nil {
 		os.Remove(file)
 		if err == util.ErrLimitReached {
 			return errHTTPBadRequestMessageTooLarge
@@ -555,11 +604,13 @@ func (s *Server) writeAttachment(v *visitor, m *message, body *util.PeakedReadCl
 		os.Remove(file)
 		return err
 	}
-	m.Message = fmt.Sprintf("You received a file: %s", filename)
+	m.Message = fmt.Sprintf("You received a file: %s", filename) // May be overwritten later
 	m.Attachment = &attachment{
-		Name: filename,
-		Type: contentType,
-		URL:  fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext),
+		Name:    filename,
+		Type:    contentType,
+		Size:    size,
+		Expires: time.Now().Add(s.config.AttachmentExpiryDuration).Unix(),
+		URL:     fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext),
 	}
 	return nil
 }

+ 5 - 3
server/visitor.go

@@ -24,8 +24,9 @@ type visitor struct {
 	config        *Config
 	ip            string
 	requests      *rate.Limiter
-	emails        *rate.Limiter
 	subscriptions *util.Limiter
+	emails        *rate.Limiter
+	attachments   *rate.Limiter
 	seen          time.Time
 	mu            sync.Mutex
 }
@@ -35,9 +36,10 @@ func newVisitor(conf *Config, ip string) *visitor {
 		config:        conf,
 		ip:            ip,
 		requests:      rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
-		emails:        rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
 		subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)),
-		seen:          time.Now(),
+		emails:        rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
+		//attachments:   rate.NewLimiter(rate.Every(conf.VisitorAttachmentBytesLimitReplenish * 1024), conf.VisitorAttachmentBytesLimitBurst),
+		seen: time.Now(),
 	}
 }