diff --git a/cmd/serve.go b/cmd/serve.go index f4161e1a..c5e3718e 100644 --- a/cmd/serve.go +++ b/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)"}), diff --git a/docs/publish.md b/docs/publish.md index 46a5a334..4bcbcbaa 100644 --- a/docs/publish.md +++ b/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) | diff --git a/go.mod b/go.mod index fad88a46..6f620033 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 91718f40..07ff72f4 100644 --- a/go.sum +++ b/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= diff --git a/server/cache_sqlite.go b/server/cache_sqlite.go index b0572895..352ad1af 100644 --- a/server/cache_sqlite.go +++ b/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, ×tamp, &topic, &msg, &title, &priority, &tagsStr); err != nil { + var id, topic, msg, title, tagsStr, attachmentName, attachmentType, attachmentURL string + if err := rows.Scan(&id, ×tamp, &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 } diff --git a/server/config.go b/server/config.go index 68a911fb..d23109b3 100644 --- a/server/config.go +++ b/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, } } diff --git a/server/message.go b/server/message.go index 2c3fb198..d7baf59e 100644 --- a/server/message.go +++ b/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 diff --git a/server/server.go b/server/server.go index b8ca70f7..c5db6e38 100644 --- a/server/server.go +++ b/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 } diff --git a/server/visitor.go b/server/visitor.go index a1bab367..f772f2c0 100644 --- a/server/visitor.go +++ b/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(), } }