Attachment behavior fix for Firefox

pull/180/head
Philipp Heckel 2022-04-03 12:39:52 -04:00
parent f98743dd9b
commit aba7e86cbc
13 changed files with 223 additions and 123 deletions

View File

@ -355,7 +355,7 @@ func (c *messageCache) Prune(olderThan time.Time) error {
return err return err
} }
func (c *messageCache) AttachmentsSize(owner string) (int64, error) { func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) {
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix()) rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
if err != nil { if err != nil {
return 0, err return 0, err

View File

@ -337,11 +337,11 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL) require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner) require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
size, err := c.AttachmentsSize("1.2.3.4") size, err := c.AttachmentBytesUsed("1.2.3.4")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(30000), size) require.Equal(t, int64(30000), size)
size, err = c.AttachmentsSize("5.6.7.8") size, err = c.AttachmentBytesUsed("5.6.7.8")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(0), size) require.Equal(t, int64(0), size)

View File

@ -66,6 +66,7 @@ var (
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
webConfigPath = "/config.js" webConfigPath = "/config.js"
userStatsPath = "/user/stats"
staticRegex = regexp.MustCompile(`^/static/.+`) staticRegex = regexp.MustCompile(`^/static/.+`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
@ -269,6 +270,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleEmpty(w, r, v) return s.handleEmpty(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.handleWebConfig(w, r) return s.handleWebConfig(w, r)
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
return s.handleUserStats(w, r, v)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.handleStatic(w, r) return s.handleStatic(w, r)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
@ -351,6 +354,19 @@ var config = {
return err return err
} }
func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visitor) error {
stats, err := v.Stats()
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(stats); err != nil {
return err
}
return nil
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
r.URL.Path = webSiteDir + r.URL.Path r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r) util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
@ -395,8 +411,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
if err != nil { if err != nil {
return err return err
} }
return errHTTPEntityTooLargeAttachmentTooLarge body, err := util.Peek(r.Body, s.config.MessageLimit)
body, err := util.Peak(r.Body, s.config.MessageLimit)
if err != nil { if err != nil {
return err return err
} }
@ -540,35 +555,35 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 5. curl -T file.txt ntfy.sh/mytopic // 5. curl -T file.txt ntfy.sh/mytopic
// If file.txt is > message limit, treat it as an attachment // If file.txt is > message limit, treat it as an attachment
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser, unifiedpush bool) error { func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
if unifiedpush { if unifiedpush {
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1 return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
} else if m.Attachment != nil && m.Attachment.URL != "" { } else if m.Attachment != nil && m.Attachment.URL != "" {
return s.handleBodyAsTextMessage(m, body) // Case 2 return s.handleBodyAsTextMessage(m, body) // Case 2
} else if m.Attachment != nil && m.Attachment.Name != "" { } else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 3 return s.handleBodyAsAttachment(r, v, m, body) // Case 3
} else if !body.LimitReached && utf8.Valid(body.PeakedBytes) { } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 4 return s.handleBodyAsTextMessage(m, body) // Case 4
} }
return s.handleBodyAsAttachment(r, v, m, body) // Case 5 return s.handleBodyAsAttachment(r, v, m, body) // Case 5
} }
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeakedReadCloser) error { func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
if utf8.Valid(body.PeakedBytes) { if utf8.Valid(body.PeekedBytes) {
m.Message = string(body.PeakedBytes) // Do not trim m.Message = string(body.PeekedBytes) // Do not trim
} else { } else {
m.Message = base64.StdEncoding.EncodeToString(body.PeakedBytes) m.Message = base64.StdEncoding.EncodeToString(body.PeekedBytes)
m.Encoding = encodingBase64 m.Encoding = encodingBase64
} }
return nil return nil
} }
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser) error { func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
if !utf8.Valid(body.PeakedBytes) { if !utf8.Valid(body.PeekedBytes) {
return errHTTPBadRequestMessageNotUTF8 return errHTTPBadRequestMessageNotUTF8
} }
if len(body.PeakedBytes) > 0 { // Empty body should not override message (publish via GET!) if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!)
m.Message = strings.TrimSpace(string(body.PeakedBytes)) // Truncates the message to the peak limit if required 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 == "" { if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" {
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
@ -576,21 +591,20 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser
return nil return nil
} }
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error { func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" { if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
return errHTTPBadRequestAttachmentsDisallowed return errHTTPBadRequestAttachmentsDisallowed
} else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() { } else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() {
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
} }
visitorAttachmentsSize, err := s.messageCache.AttachmentsSize(v.ip) visitorStats, err := v.Stats()
if err != nil { if err != nil {
return err return err
} }
remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize
contentLengthStr := r.Header.Get("Content-Length") contentLengthStr := r.Header.Get("Content-Length")
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64) contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
if err == nil && (contentLength > remainingVisitorAttachmentSize || contentLength > s.config.AttachmentFileSizeLimit) { if err == nil && (contentLength > visitorStats.VisitorAttachmentBytesRemaining || contentLength > s.config.AttachmentFileSizeLimit) {
return errHTTPEntityTooLargeAttachmentTooLarge return errHTTPEntityTooLargeAttachmentTooLarge
} }
} }
@ -600,7 +614,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
var ext string var ext string
m.Attachment.Owner = v.ip // Important for attachment rate limiting m.Attachment.Owner = v.ip // Important for attachment rate limiting
m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix() m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix()
m.Attachment.Type, ext = util.DetectContentType(body.PeakedBytes, m.Attachment.Name) m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name)
m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext) m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext)
if m.Attachment.Name == "" { if m.Attachment.Name == "" {
m.Attachment.Name = fmt.Sprintf("attachment%s", ext) m.Attachment.Name = fmt.Sprintf("attachment%s", ext)
@ -608,7 +622,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
if m.Message == "" { if m.Message == "" {
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
} }
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize)) m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(visitorStats.VisitorAttachmentBytesRemaining))
if err == util.ErrLimitReached { if err == util.ErrLimitReached {
return errHTTPEntityTooLargeAttachmentTooLarge return errHTTPEntityTooLargeAttachmentTooLarge
} else if err != nil { } else if err != nil {
@ -1097,11 +1111,11 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
} }
} }
// transformBodyJSON peaks the request body, reads the JSON, and converts it to headers // transformBodyJSON peeks the request body, reads the JSON, and converts it to headers
// before passing it on to the next handler. This is meant to be used in combination with handlePublish. // before passing it on to the next handler. This is meant to be used in combination with handlePublish.
func (s *Server) transformBodyJSON(next handleFunc) handleFunc { func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error { return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
body, err := util.Peak(r.Body, s.config.MessageLimit) body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil { if err != nil {
return err return err
} }
@ -1217,7 +1231,7 @@ func (s *Server) visitor(r *http.Request) *visitor {
} }
v, exists := s.visitors[ip] v, exists := s.visitors[ip]
if !exists { if !exists {
s.visitors[ip] = newVisitor(s.config, ip) s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
return s.visitors[ip] return s.visitors[ip]
} }
v.Keepalive() v.Keepalive()

View File

@ -938,7 +938,7 @@ func TestServer_PublishAttachment(t *testing.T) {
require.Equal(t, content, response.Body.String()) require.Equal(t, content, response.Body.String())
// Slightly unrelated cross-test: make sure we add an owner for internal attachments // Slightly unrelated cross-test: make sure we add an owner for internal attachments
size, err := s.messageCache.AttachmentsSize("9.9.9.9") // See request() size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(5000), size) require.Equal(t, int64(5000), size)
} }
@ -967,7 +967,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
require.Equal(t, content, response.Body.String()) require.Equal(t, content, response.Body.String())
// Slightly unrelated cross-test: make sure we add an owner for internal attachments // Slightly unrelated cross-test: make sure we add an owner for internal attachments
size, err := s.messageCache.AttachmentsSize("1.2.3.4") size, err := s.messageCache.AttachmentBytesUsed("1.2.3.4")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(21), size) require.Equal(t, int64(21), size)
} }
@ -987,7 +987,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
require.Equal(t, "", msg.Attachment.Owner) require.Equal(t, "", msg.Attachment.Owner)
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments // Slightly unrelated cross-test: make sure we don't add an owner for external attachments
size, err := s.messageCache.AttachmentsSize("127.0.0.1") size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(0), size) require.Equal(t, int64(0), size)
} }

View File

@ -22,6 +22,7 @@ var (
// 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
messageCache *messageCache
ip string ip string
requests *rate.Limiter requests *rate.Limiter
emails *rate.Limiter emails *rate.Limiter
@ -31,9 +32,17 @@ type visitor struct {
mu sync.Mutex mu sync.Mutex
} }
func newVisitor(conf *Config, ip string) *visitor { type visitorStats struct {
AttachmentFileSizeLimit int64 `json:"attachmentFileSizeLimit"`
VisitorAttachmentBytesTotal int64 `json:"visitorAttachmentBytesTotal"`
VisitorAttachmentBytesUsed int64 `json:"visitorAttachmentBytesUsed"`
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
}
func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor {
return &visitor{ return &visitor{
config: conf, config: conf,
messageCache: messageCache,
ip: ip, ip: ip,
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst), requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
@ -91,3 +100,20 @@ func (v *visitor) Stale() bool {
defer v.mu.Unlock() defer v.mu.Unlock()
return time.Since(v.seen) > visitorExpungeAfter return time.Since(v.seen) > visitorExpungeAfter
} }
func (v *visitor) Stats() (*visitorStats, error) {
attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip)
if err != nil {
return nil, err
}
attachmentsBytesRemaining := v.config.VisitorAttachmentTotalSizeLimit - attachmentsBytesUsed
if attachmentsBytesRemaining < 0 {
attachmentsBytesRemaining = 0
}
return &visitorStats{
AttachmentFileSizeLimit: v.config.AttachmentFileSizeLimit,
VisitorAttachmentBytesTotal: v.config.VisitorAttachmentTotalSizeLimit,
VisitorAttachmentBytesUsed: attachmentsBytesUsed,
VisitorAttachmentBytesRemaining: attachmentsBytesRemaining,
}, nil
}

View File

@ -15,6 +15,10 @@ var ErrLimitReached = errors.New("limit reached")
type Limiter interface { type Limiter interface {
// Allow adds n to the limiters internal value, or returns ErrLimitReached if the limit has been reached // Allow adds n to the limiters internal value, or returns ErrLimitReached if the limit has been reached
Allow(n int64) error Allow(n int64) error
// Remaining returns the remaining count until the limit is reached; may return -1 if the implementation
// does not support this operation.
Remaining() int64
} }
// FixedLimiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached // FixedLimiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached
@ -44,6 +48,13 @@ func (l *FixedLimiter) Allow(n int64) error {
return nil return nil
} }
// Remaining returns the remaining count until the limit is reached
func (l *FixedLimiter) Remaining() int64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.limit - l.value
}
// RateLimiter is a Limiter that wraps a rate.Limiter, allowing a floating time-based limit. // RateLimiter is a Limiter that wraps a rate.Limiter, allowing a floating time-based limit.
type RateLimiter struct { type RateLimiter struct {
limiter *rate.Limiter limiter *rate.Limiter
@ -74,6 +85,11 @@ func (l *RateLimiter) Allow(n int64) error {
return nil return nil
} }
// Remaining is not implemented for RateLimiter. It always returns -1.
func (l *RateLimiter) Remaining() int64 {
return -1
}
// LimitWriter implements an io.Writer that will pass through all Write calls to the underlying // LimitWriter implements an io.Writer that will pass through all Write calls to the underlying
// writer w until any of the limiter's limit is reached, at which point a Write will return ErrLimitReached. // writer w until any of the limiter's limit is reached, at which point a Write will return ErrLimitReached.
// Each limiter's value is increased with every write. // Each limiter's value is increased with every write.

View File

@ -1,61 +0,0 @@
package util
import (
"bytes"
"io"
"strings"
)
// PeakedReadCloser is a ReadCloser that allows peaking into a stream and buffering it in memory.
// It can be instantiated using the Peak function. After a stream has been peaked, it can still be fully
// read by reading the PeakedReadCloser. It first drained from the memory buffer, and then from the remaining
// underlying reader.
type PeakedReadCloser struct {
PeakedBytes []byte
LimitReached bool
peaked io.Reader
underlying io.ReadCloser
closed bool
}
// Peak reads the underlying ReadCloser into memory up until the limit and returns a PeakedReadCloser
func Peak(underlying io.ReadCloser, limit int) (*PeakedReadCloser, error) {
if underlying == nil {
underlying = io.NopCloser(strings.NewReader(""))
}
peaked := make([]byte, limit)
read, err := io.ReadFull(underlying, peaked)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
return &PeakedReadCloser{
PeakedBytes: peaked[:read],
LimitReached: read == limit,
underlying: underlying,
peaked: bytes.NewReader(peaked[:read]),
closed: false,
}, nil
}
// Read reads from the peaked bytes and then from the underlying stream
func (r *PeakedReadCloser) Read(p []byte) (n int, err error) {
if r.closed {
return 0, io.EOF
}
n, err = r.peaked.Read(p)
if err == io.EOF {
return r.underlying.Read(p)
} else if err != nil {
return 0, err
}
return
}
// Close closes the underlying stream
func (r *PeakedReadCloser) Close() error {
if r.closed {
return io.EOF
}
r.closed = true
return r.underlying.Close()
}

61
util/peek.go 100644
View File

@ -0,0 +1,61 @@
package util
import (
"bytes"
"io"
"strings"
)
// PeekedReadCloser is a ReadCloser that allows peeking into a stream and buffering it in memory.
// It can be instantiated using the Peek function. After a stream has been peeked, it can still be fully
// read by reading the PeekedReadCloser. It first drained from the memory buffer, and then from the remaining
// underlying reader.
type PeekedReadCloser struct {
PeekedBytes []byte
LimitReached bool
peeked io.Reader
underlying io.ReadCloser
closed bool
}
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser
func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
if underlying == nil {
underlying = io.NopCloser(strings.NewReader(""))
}
peeked := make([]byte, limit)
read, err := io.ReadFull(underlying, peeked)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
return &PeekedReadCloser{
PeekedBytes: peeked[:read],
LimitReached: read == limit,
underlying: underlying,
peeked: bytes.NewReader(peeked[:read]),
closed: false,
}, nil
}
// Read reads from the peeked bytes and then from the underlying stream
func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
if r.closed {
return 0, io.EOF
}
n, err = r.peeked.Read(p)
if err == io.EOF {
return r.underlying.Read(p)
} else if err != nil {
return 0, err
}
return
}
// Close closes the underlying stream
func (r *PeekedReadCloser) Close() error {
if r.closed {
return io.EOF
}
r.closed = true
return r.underlying.Close()
}

View File

@ -9,11 +9,11 @@ import (
func TestPeak_LimitReached(t *testing.T) { func TestPeak_LimitReached(t *testing.T) {
underlying := io.NopCloser(strings.NewReader("1234567890")) underlying := io.NopCloser(strings.NewReader("1234567890"))
peaked, err := Peak(underlying, 5) peaked, err := Peek(underlying, 5)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
require.Equal(t, []byte("12345"), peaked.PeakedBytes) require.Equal(t, []byte("12345"), peaked.PeekedBytes)
require.Equal(t, true, peaked.LimitReached) require.Equal(t, true, peaked.LimitReached)
all, err := io.ReadAll(peaked) all, err := io.ReadAll(peaked)
@ -21,13 +21,13 @@ func TestPeak_LimitReached(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
require.Equal(t, []byte("1234567890"), all) require.Equal(t, []byte("1234567890"), all)
require.Equal(t, []byte("12345"), peaked.PeakedBytes) require.Equal(t, []byte("12345"), peaked.PeekedBytes)
require.Equal(t, true, peaked.LimitReached) require.Equal(t, true, peaked.LimitReached)
} }
func TestPeak_LimitNotReached(t *testing.T) { func TestPeak_LimitNotReached(t *testing.T) {
underlying := io.NopCloser(strings.NewReader("1234567890")) underlying := io.NopCloser(strings.NewReader("1234567890"))
peaked, err := Peak(underlying, 15) peaked, err := Peek(underlying, 15)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -36,12 +36,12 @@ func TestPeak_LimitNotReached(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
require.Equal(t, []byte("1234567890"), all) require.Equal(t, []byte("1234567890"), all)
require.Equal(t, []byte("1234567890"), peaked.PeakedBytes) require.Equal(t, []byte("1234567890"), peaked.PeekedBytes)
require.Equal(t, false, peaked.LimitReached) require.Equal(t, false, peaked.LimitReached)
} }
func TestPeak_Nil(t *testing.T) { func TestPeak_Nil(t *testing.T) {
peaked, err := Peak(nil, 15) peaked, err := Peek(nil, 15)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -50,6 +50,6 @@ func TestPeak_Nil(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
require.Equal(t, []byte(""), all) require.Equal(t, []byte(""), all)
require.Equal(t, []byte(""), peaked.PeakedBytes) require.Equal(t, []byte(""), peaked.PeekedBytes)
require.Equal(t, false, peaked.LimitReached) require.Equal(t, false, peaked.LimitReached)
} }

View File

@ -7,7 +7,7 @@ import {
topicUrl, topicUrl,
topicUrlAuth, topicUrlAuth,
topicUrlJsonPoll, topicUrlJsonPoll,
topicUrlJsonPollWithSince topicUrlJsonPollWithSince, userStatsUrl
} from "./utils"; } from "./utils";
import userManager from "./UserManager"; import userManager from "./UserManager";
@ -93,6 +93,16 @@ class Api {
} }
throw new Error(`Unexpected server response ${response.status}`); throw new Error(`Unexpected server response ${response.status}`);
} }
async userStats(baseUrl) {
const url = userStatsUrl(baseUrl);
console.log(`[Api] Fetching user stats ${url}`);
const response = await fetch(url);
if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
return response.json();
}
} }
const api = new Api(); const api = new Api();

View File

@ -18,6 +18,7 @@ export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, top
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`; export const expandSecureUrl = (url) => `https://${url}`;

View File

@ -154,11 +154,6 @@ const Messaging = (props) => {
e.preventDefault(); e.preventDefault();
} }
}; };
const handleDrop = (e) => {
e.preventDefault();
setShowDropZone(false);
console.log(e.dataTransfer.files[0]);
};
return ( return (
<> <>
@ -173,6 +168,7 @@ const Messaging = (props) => {
open={showDialog} open={showDialog}
dropZone={showDropZone} dropZone={showDropZone}
onClose={handleSendDialogClose} onClose={handleSendDialogClose}
onDrop={() => setShowDropZone(false)}
topicUrl={selectedTopicUrl} topicUrl={selectedTopicUrl}
message={message} message={message}
/> />

View File

@ -26,7 +26,7 @@ import api from "../app/Api";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
const SendDialog = (props) => { const SendDialog = (props) => {
const [topicUrl, setTopicUrl] = useState(props.topicUrl); const [topicUrl, setTopicUrl] = useState("");
const [message, setMessage] = useState(props.message || ""); const [message, setMessage] = useState(props.message || "");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
@ -40,7 +40,7 @@ const SendDialog = (props) => {
const [delay, setDelay] = useState(""); const [delay, setDelay] = useState("");
const [publishAnother, setPublishAnother] = useState(false); const [publishAnother, setPublishAnother] = useState(false);
const [showTopicUrl, setShowTopicUrl] = useState(props.topicUrl === ""); // FIXME const [showTopicUrl, setShowTopicUrl] = useState("");
const [showClickUrl, setShowClickUrl] = useState(false); const [showClickUrl, setShowClickUrl] = useState(false);
const [showAttachUrl, setShowAttachUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false);
const [showEmail, setShowEmail] = useState(false); const [showEmail, setShowEmail] = useState(false);
@ -48,21 +48,25 @@ const SendDialog = (props) => {
const showAttachFile = !!attachFile && !showAttachUrl; const showAttachFile = !!attachFile && !showAttachUrl;
const attachFileInput = useRef(); const attachFileInput = useRef();
const [attachFileError, setAttachFileError] = useState("");
const [activeRequest, setActiveRequest] = useState(null); const [activeRequest, setActiveRequest] = useState(null);
const [statusText, setStatusText] = useState(""); const [statusText, setStatusText] = useState("");
const disabled = !!activeRequest; const disabled = !!activeRequest;
const dropZone = props.dropZone; const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
const dropZone = props.dropZone;
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const sendButtonEnabled = (() => { useEffect(() => {
if (!validTopicUrl(topicUrl)) { setTopicUrl(props.topicUrl);
return false; setShowTopicUrl(props.topicUrl === "")
} }, [props.topicUrl]);
return true;
})(); useEffect(() => {
setSendButtonEnabled(validTopicUrl(topicUrl) && !attachFileError);
}, [topicUrl, attachFileError]);
const handleSubmit = async () => { const handleSubmit = async () => {
const { baseUrl, topic } = splitTopicUrl(topicUrl); const { baseUrl, topic } = splitTopicUrl(topicUrl);
@ -124,23 +128,47 @@ const SendDialog = (props) => {
setActiveRequest(null); setActiveRequest(null);
}; };
const checkAttachmentLimits = async (file) => {
try {
const { baseUrl } = splitTopicUrl(topicUrl);
const stats = await api.userStats(baseUrl);
console.log(`[SendDialog] Visitor attachment limits`, stats);
const fileSizeLimit = stats.attachmentFileSizeLimit ?? 0;
if (fileSizeLimit > 0 && file.size > fileSizeLimit) {
return setAttachFileError(`exceeds ${formatBytes(fileSizeLimit)} limit`);
}
const remainingBytes = stats.visitorAttachmentBytesRemaining ?? 0;
if (remainingBytes > 0 && file.size > remainingBytes) {
return setAttachFileError(`quota reached, only ${formatBytes(remainingBytes)} remaining`);
}
setAttachFileError("");
} catch (e) {
console.log(`[SendDialog] Retrieving attachment limits failed`, e);
setAttachFileError(""); // Reset error (rely on server-side checking)
}
};
const handleAttachFileClick = () => { const handleAttachFileClick = () => {
attachFileInput.current.click(); attachFileInput.current.click();
}; };
const handleAttachFileChanged = (ev) => { const handleAttachFileChanged = async (ev) => {
const file = ev.target.files[0]; await updateAttachFile(ev.target.files[0]);
setAttachFile(file);
setFilename(file.name);
console.log(ev.target.files[0]);
console.log(URL.createObjectURL(ev.target.files[0]));
}; };
const handleDrop = (ev) => { const handleAttachFileDrop = async (ev) => {
ev.preventDefault(); ev.preventDefault();
const file = ev.dataTransfer.files[0]; props.onDrop();
await updateAttachFile(ev.dataTransfer.files[0]);
};
const updateAttachFile = async (file) => {
setAttachFile(file); setAttachFile(file);
setFilename(file.name); setFilename(file.name);
await checkAttachmentLimits(file);
}; };
const allowDrag = (ev) => { const allowDrag = (ev) => {
@ -178,7 +206,7 @@ const SendDialog = (props) => {
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}} }}
onDrop={handleDrop} onDrop={handleAttachFileDrop}
onDragEnter={allowDrag} onDragEnter={allowDrag}
onDragOver={allowDrag} onDragOver={allowDrag}
> >
@ -360,9 +388,11 @@ const SendDialog = (props) => {
file={attachFile} file={attachFile}
filename={filename} filename={filename}
disabled={disabled} disabled={disabled}
error={attachFileError}
onChangeFilename={(f) => setFilename(f)} onChangeFilename={(f) => setFilename(f)}
onClose={() => { onClose={() => {
setAttachFile(null); setAttachFile(null);
setAttachFileError("");
setFilename(""); setFilename("");
}} }}
/>} />}
@ -466,7 +496,7 @@ const AttachmentBox = (props) => {
borderRadius: '4px', borderRadius: '4px',
}}> }}>
<Icon type={file.type}/> <Icon type={file.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}> <Box sx={{ marginLeft: 1, textAlign: 'left' }}>
<ExpandingTextField <ExpandingTextField
minWidth={140} minWidth={140}
variant="body2" variant="body2"
@ -475,8 +505,15 @@ const AttachmentBox = (props) => {
disabled={props.disabled} disabled={props.disabled}
/> />
<br/> <br/>
{formatBytes(file.size)} <Typography variant="body2" sx={{ color: 'text.primary' }}>
</Typography> {formatBytes(file.size)}
{props.error &&
<Typography component="span" sx={{ color: 'error.main' }}>
{" "}({props.error})
</Typography>
}
</Typography>
</Box>
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton> <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>
</Box> </Box>
</> </>