00%
blog.info()
← Back to Home
SEQUENCE // Building The Blog

Implementing Anonymous Messages

Author Thorn Hall
0

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 messenger to send me the message via ntfy.
  • 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.

View Abstract Syntax Tree (Build-Time Generated)
Document
Paragraph
Text "I thought it would be fun i..."
Text " days."
Paragraph
Text "Below is a deep dive on my ..."
Text " Go."
Heading
Text "Tech"
Text " Stack"
Paragraph
Text "As is a trend with my websi..."
Text " free."
Paragraph
Text "To accomplish this, I used ..."
CodeSpan
Text "ntfy"
Text ". If you haven't heard of i..."
Text " from."
Paragraph
Text "Because it's a free and sim..."
CodeSpan
Text "POST"
Text " request to "
CodeSpan
Text "ntfy"
Text " with the user's"
Text " message."
Heading
Text "The"
Text " Router"
Paragraph
Text "First, we need to expose an..."
Text " anonymously."
Paragraph
Text "To do this, we will add a r..."
CodeSpan
Text "router"
FencedCodeBlock code: "appMux.HandleFunc..."
Paragraph
Text "Every route we add needs a ..."
CodeSpan
Text "HandleMessage"
Text " handler to"
Text " it."
Heading
Text "The"
Text " Handler"
Paragraph
Text "This is what "
CodeSpan
Text "HandleMessage"
Text " looks"
Text " like:"
FencedCodeBlock code: "type ContactReque..."
Paragraph
CodeSpan
Text "ContactRequest.BotTrap"
Text " is a simple form field tha..."
Text " request."
Paragraph
Text "Notice that we make the fol..."
Text " user:"
FencedCodeBlock code: "go h.msgQueue.Que..."
Paragraph
Text "This function is what actua..."
CodeSpan
Text "go"
Text " keyword means we're runnin..."
CodeSpan
Text "h.messenger.Queue"
Text " to finish first. This make..."
Text " fast."
Paragraph
Text "Now is a good time to go ov..."
CodeSpan
Text "msgQueue"
Text " implementation and how the "
CodeSpan
Text "Queue"
Text " function"
Text " works."
Heading
Text "MessageQueue"
Paragraph
CodeSpan
Text "MessageQueue"
Text " is responsible for 3"
Text " things:"
List
ListItem
TextBlock
Text "Telling "
CodeSpan
Text "messenger"
Text " to send me the message via "
CodeSpan
Text "ntfy"
Text "."
ListItem
TextBlock
Text "Rate limiting how many requ..."
CodeSpan
Text "ntfy"
Text "."
ListItem
TextBlock
Text "Protecting me from getting ..."
Text " limiting.)"
Paragraph
Text "This is what "
CodeSpan
Text "queue.go"
Text " looks"
Text " like:"
FencedCodeBlock code: "package notify "
Paragraph
Text "First, I'll explain the "
CodeSpan
Text "NewQueue"
Text " function. "
CodeSpan
Text "NewQueue"
Text " creates a "
CodeSpan
Text "MessageQueue"
Text " struct and, crucially, it ..."
CodeSpan
Text "Process"
Text " loop in a separate"
Text " goroutine."
Paragraph
Text "It's also worth noting that..."
CodeSpan
Text "messages"
Text " with a buffer of size "
CodeSpan
Text "bufferSize"
Text ". "
CodeSpan
Text "bufferSize"
Text " is crucial for rate limiti..."
Text " later."
FencedCodeBlock code: "func NewQueue(ctx..."
Paragraph
Text "Now for the "
CodeSpan
Text "Queue"
Text " function"
Text " logic:"
FencedCodeBlock code: "func (q *MessageQ..."
Paragraph
CodeSpan
Text "Queue"
Text " takes the message and atte..."
CodeSpan
Text "messages"
Text " channel. This is how "
CodeSpan
Text "bufferSize"
Text " comes in. If the "
CodeSpan
Text "messages"
Text " channel is full (equal to "
CodeSpan
Text "bufferSize"
Text "), it simply does nothing (..."
CodeSpan
Text "default"
Text " case.) This behavior is es..."
Text " instantly."
Paragraph
Text "Now let's go over the "
CodeSpan
Text "Process"
Text " loop."
FencedCodeBlock code: "func (q *MessageQ..."
Paragraph
CodeSpan
Text "Process"
Text " is a little bit complicate..."
Text " step:"
FencedCodeBlock code: "for { "
Paragraph
Text "The outer "
CodeSpan
Text "select"
Text " statement is inside an inf..."
CodeSpan
Text "for"
Text " clause.) "
CodeSpan
Text "case <-ticker.C"
Text " is saying "wait until 5 se..."
CodeSpan
Text "select"
Text ".""
Paragraph
Text "Crucially, the "
CodeSpan
Text "case <-ctx.Done()"
Text " is what allows a graceful ..."
Text " down."
Paragraph
Text "The inner "
CodeSpan
Text "select"
Text ":"
FencedCodeBlock code: "case <-ticker.C: "
Paragraph
Text "after 5 seconds, it waits u..."
CodeSpan
Text "messages"
Text " channel. If a message is r..."
CodeSpan
Text "messenger"
Text "'s "
CodeSpan
Text "Send"
Text " function. This behavior is..."
CodeSpan
Text "ntfy"
Text " API and get blocked or rat..."
Text " seconds."
Paragraph
Text "Again, this "
CodeSpan
Text "select"
Text " must also account for "
CodeSpan
Text "<-ctx.Done()"
Text " in case the app is shut"
Text " down."
Paragraph
Text "Now, let's go over the "
CodeSpan
Text "messenger"
Text " implementation."
Heading
Text "Messenger"
Paragraph
Text "The "
CodeSpan
Text "messenger"
Text " is responsible for actuall..."
CodeSpan
Text "ntfy"
Text ". It looks like"
Text " this:"
FencedCodeBlock code: "package notify "
Paragraph
Text "The implementation is self-..."
CodeSpan
Text "Send"
Text " function which the "
CodeSpan
Text "MessageQueue"
Text " invokes. "
CodeSpan
Text "Send"
Text " prepares the HTTP "
CodeSpan
Text "POST"
Text " request to "
CodeSpan
Text "ntfy"
Text " containing the"
Text " message."
Paragraph
Text "The topic must be passed in..."
CodeSpan
Text "messenger"
Text ", which is basically a pass..."
Text " variables."
Heading
Text "Tradeoffs"
Paragraph
Text "It's worth noting the main ..."
Text " implementation."
List
ListItem
TextBlock
Text "The queue is in my app's RA..."
Text " lost."
Text "For my simple blog and some..."
Text " happens."
Heading
Text "Conclusion"
Paragraph
Text "The web page itself has a s..."
CodeSpan
Text "POST"
Text " request to my "
CodeSpan
Text "/api/messages"
Text " handler, which then calls ..."
CodeSpan
Text "Queue"
Text " function and instantly ret..."
CodeSpan
Text "MessageQueue"
Text " will then handle sending t..."
CodeSpan
Text "ntfy"
Text " in a rate-limited and spam..."
Text " app."