Attachment behavior fix for Firefox
This commit is contained in:
parent
f98743dd9b
commit
aba7e86cbc
13 changed files with 223 additions and 123 deletions
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
61
util/peak.go
61
util/peak.go
|
@ -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
Normal file
61
util/peek.go
Normal 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()
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
|
@ -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();
|
||||||
|
|
|
@ -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}`;
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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/>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.primary' }}>
|
||||||
{formatBytes(file.size)}
|
{formatBytes(file.size)}
|
||||||
|
{props.error &&
|
||||||
|
<Typography component="span" sx={{ color: 'error.main' }}>
|
||||||
|
{" "}({props.error})
|
||||||
</Typography>
|
</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>
|
||||||
</>
|
</>
|
||||||
|
|
Loading…
Reference in a new issue