Implementing Anonymous Messages
I thought it would be fun if I implemented the ability for users to send me anonymous messages, like Whisper or YikYak back in the old days.
Below is a deep dive on my implementation of this in Go.
Tech Stack
As is a trend with my website, I wanted this feature to be extremely lightweight and, ideally, free.
To accomplish this, I used a tool called ntfy. If you haven't heard of it, it's a free API that allows users to create URLs which we can then send messages to and receive notifications from.
Because it's a free and simple API, we're not introducing any dependencies to our Go app or spending any money. We can use the standard library to make a POST request to ntfy with the user's message.
The Router
First, we need to expose an API for our Go app which my website will use to forward user messages anonymously.
To do this, we will add a route to the router
1appMux.HandleFunc("POST /api/messages", h.HandleMessage)
Every route we add needs a handler so we attach the HandleMessage handler to it.
The Handler
This is what HandleMessage looks like:
1type ContactRequest struct {
2 Message string `json:"message"`
3 BotTrap string `json:"website"`
4}
5
6func (h *Handler) HandleMessage(w http.ResponseWriter, r *http.Request) {
7 var req ContactRequest
8 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
9 HttpErrorResponse(w, "bad request", http.StatusBadRequest)
10 return
11 }
12
13 if req.BotTrap != "" {
14 w.WriteHeader(http.StatusOK)
15 return
16 }
17
18 if len(req.Message) < 5 || len(req.Message) > 1500 {
19 HttpErrorResponse(w, "message too short or too long", http.StatusBadRequest)
20 return
21 }
22
23 go h.msgQueue.Queue(req.Message)
24
25 w.WriteHeader(http.StatusOK)
26 w.Write([]byte(`{"status":"sent"}`))
27}
ContactRequest.BotTrap is a simple form field that we include on the frontend, completely hidden from real users. Automated bots will likely see this field and fill it out automatically, so we know if this field comes filled in, that we should ignore the request.
Notice that we make the following call before returning a success to the user:
1go h.msgQueue.Queue(req.Message)
This function is what actually causes the message to be sent to me. The go keyword means we're running it in a goroutine so that the handler can continue executing and return a response to the user immediately, rather than waiting on h.messenger.Queue to finish first. This makes the feedback for the user incredibly fast.
Now is a good time to go over the msgQueue implementation and how the Queue function works.
MessageQueue
MessageQueue is responsible for 3 things:
- Telling
messengerto send me the message viantfy. - Rate limiting how many requests per second my Go app sends to
ntfy. - Protecting me from getting spammed by messages (also solved by rate limiting.)
This is what queue.go looks like:
1package notify
2
3import (
4 "context"
5 "log"
6 "time"
7)
8
9const bufferSize = 10
10
11func NewQueue(ctx context.Context, messenger *Notifier) *MessageQueue {
12 messages := make(chan string, bufferSize)
13 m := &MessageQueue{messages: messages, messenger: messenger}
14
15 go m.Process(ctx)
16
17 return m
18}
19
20type MessageQueue struct {
21 messages chan string
22 messenger *Notifier
23}
24
25func (q *MessageQueue) Queue(message string) {
26 select {
27 case q.messages <- message:
28 return
29 default:
30 log.Printf("WARN: message queue full.")
31 return
32 }
33}
34
35func (q *MessageQueue) Process(ctx context.Context) {
36 ticker := time.NewTicker(time.Second * 5)
37
38 for {
39 select {
40 case <-ticker.C:
41 select {
42 case message, ok := <-q.messages:
43 if !ok {
44 return
45 }
46 go q.messenger.Send(message)
47 case <-ctx.Done():
48 return
49 }
50 case <-ctx.Done():
51 return
52 }
53 }
54}
First, I'll explain the NewQueue function. NewQueue creates a MessageQueue struct and, crucially, it starts the MessageQueue's Process loop in a separate goroutine.
It's also worth noting that it creates a channel called messages with a buffer of size bufferSize. bufferSize is crucial for rate limiting. I'll come back to this later.
1func NewQueue(ctx context.Context, messenger *Notifier) *MessageQueue {
2 messages := make(chan string, bufferSize)
3 m := &MessageQueue{messages: messages, messenger: messenger}
4
5 go m.Process(ctx)
6
7 return m
8}
Now for the Queue function logic:
1func (q *MessageQueue) Queue(message string) {
2 select {
3 case q.messages <- message:
4 return
5 default:
6 log.Printf("WARN: message queue full.")
7 return
8 }
9}
Queue takes the message and attempts to write it to the messages channel. This is how bufferSize comes in. If the messages channel is full (equal to bufferSize), it simply does nothing (the default case.) This behavior is essentially for rate limiting. If a bot or a malicious user tries to spam messages, those messages will be dropped instantly.
Now let's go over the Process loop.
1func (q *MessageQueue) Process(ctx context.Context) {
2 ticker := time.NewTicker(time.Second * 5)
3
4 for {
5 select {
6 case <-ticker.C:
7 select {
8 case message, ok := <-q.messages:
9 if !ok {
10 return
11 }
12 go q.messenger.Send(message)
13 case <-ctx.Done():
14 return
15 }
16 case <-ctx.Done():
17 return
18 }
19 }
20}
Process is a little bit complicated so I will explain it step by step:
1for {
2 // outer select
3 select {
4 case <-ticker.C:
5 // ... inner select ...
6 case <-ctx.Done():
7 return
8 }
9}
The outer select statement is inside an infinite loop (the for clause.) case <-ticker.C is saying "wait until 5 seconds has passed before executing the inner select."
Crucially, the case <-ctx.Done() is what allows a graceful shutdown of our app. We will only reach that case if the app shuts down.
The inner select:
1case <-ticker.C:
2 // Select waits until one of the cases receives a value.
3 select {
4 case message, ok := <-q.messages:
5 if !ok {
6 return
7 }
8 go q.messenger.Send(message)
9 case <-ctx.Done():
10 return
11 }
after 5 seconds, it waits until a message is received from the messages channel. If a message is received, it sends the message via the messenger's Send function. This behavior is important so that we don't spam the ntfy API and get blocked or rate limited. By waiting to send once every 5 seconds, we guarantee that we only send a message at most once every 5 seconds.
Again, this select must also account for <-ctx.Done() in case the app is shut down.
Now, let's go over the messenger implementation.
Messenger
The messenger is responsible for actually sending the HTTP request to ntfy. It looks like this:
1package notify
2
3import (
4 "fmt"
5 "log"
6 "net/http"
7 "strings"
8 "time"
9)
10
11type Notifier struct {
12 TopicURL string
13 Client *http.Client
14}
15
16type Option func(*http.Request)
17
18func New(topic string) *Notifier {
19 if topic == "" {
20 log.Println("WARN: no topic for ntfy set.")
21 }
22 return &Notifier{
23 TopicURL: "https://ntfy.sh/" + topic,
24 Client: &http.Client{Timeout: 5 * time.Second},
25 }
26}
27
28func (n *Notifier) Send(message string, opts ...Option) error {
29 if n.TopicURL == "" {
30 log.Println("WARN: topic for ntfy not set")
31 return nil
32 }
33
34 req, err := http.NewRequest("POST", n.TopicURL, strings.NewReader(message))
35 if err != nil {
36 return err
37 }
38
39 resp, err := n.Client.Do(req)
40 if err != nil {
41 return err
42 }
43 defer resp.Body.Close()
44 return nil
45}
The implementation is self-explanatory. It exposes a Send function which the MessageQueue invokes. Send prepares the HTTP POST request to ntfy containing the message.
The topic must be passed into the messenger, which is basically a password. It's set via environment variables.
Tradeoffs
It's worth noting the main tradeoff I made with this implementation.
- The queue is in my app's RAM, meaning if the queue is full and my app goes down, all of the queue's messages will be lost. For my simple blog and something low-stakes like anonymous messages, this is an acceptable tradeoff to me. Plus, it's highly unlikely that scenario ever happens.
Conclusion
The web page itself has a stylized form that the user types their message into. The form, when submitted, will send a POST request to my /api/messages handler, which then calls the Queue function and instantly returns a success to the user. The MessageQueue will then handle sending the message to ntfy in a rate-limited and spam-protected manner. Now users can leave me anonymous messages and I didn't have to add any dependencies to my app.