Building a social photo app using Spin, KV & Nuxt.js

Fermyon is a fully remote company across 9 timezones, and this means that we primarily use Slack to communicate with each other. A LOT.

We also travel frequently for conferences and meetups. There’s a lot of work behind the scenes that goes into making this possible – from last-minute packing, and setting up the booths at events to taking naps between missed flights. We have a #behind-the-scenes channel on our Slack to share these moments with each other. We thought we could share some of these photos with you, so we built a social photo app using Fermyon Spin, Key-Value Store, and Nuxt.js. Here’s how we did it.

High-level architecture

behind-the-scenes.svg

At a high level, we wanted to have the following user stories for this app:

  • Users should be able to post pictures they want to share with the world easily
  • The user should be able to sign off the pictures explicitly.
  • User should be able to open a web app to view the posted pictures

Building Blocks

Let us now explore the different building blocks of this web app.

Slack Webhook

This component is responsible for receiving webhook from Slack whenever the bot @behind-the-scenes receives a mention or a reaction is added to such message.

As part of the handler, we first create Slack’s HTTP Client (backed by Spin’s HTTP Client). A simplified version of the code is as follows:

package webhook

import (
  spinhttp "github.com/fermyon/spin/sdk/go/http"
  "github.com/fermyon/spin/sdk/go/v2/variables"
  "github.com/slack-go/slack"
)

func NewClient() (*slack.Client, error) {
  token, err := variables.Get("slack_token")
  if err != nil {
    return nil, err
  }

  signingSecret, err := variables.Get("slack_signing_secret")
  if err != nil {
    return nil, err
  }
  
  httpclient := spinhttp.NewClient() // <--- http client provided by Spin's SDK
  return slack.New(token, slack.OptionHTTPClient(httpclient)), nil
}

Once we have the client, for this app, we need to handle two types of Slack webhook events:

  1. Verification of Webhook URL: For this, Slack sends a challenge to the configured URL and we need to reply with that challenge. You can read more about it in Slack’s documentation here.
  2. An App mention and Reaction added event webhook: This event gets triggered when someone sends a message in the Slack channel and tags the bot account in that message or adds an emoji reaction to the message.

A simplified implementation of the handler:

func (s *Handler) Handle(w http.ResponseWriter, r *http.Request) {
  ctx := r.Context()

  raw, err := io.ReadAll(r.Body)
  if err != nil {
    http.Error(w, "internal server error", http.StatusInternalServerError)
    return
  }

  err = s.slack.VerifySignature(r.Header, raw)
  if err != nil {
    http.Error(w, "forbidden", http.StatusForbidden)
    return
  }

  outerEvent, err := slack.ParseEvent(raw)
  if err != nil {
    http.Error(w, "internal server error", http.StatusInternalServerError)
    return
  }

  switch {
  case outerEvent.Type == slackevents.URLVerification:
    s.webhookVerificationHandler(w, raw)
    return
  case outerEvent.Type == slackevents.CallbackEvent:
    err = s.handleCallbackEvent(ctx, outerEvent)
    if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
    }

    return
  }

  logrus.Warnf("unknown event type %v", outerEvent.Type)
  fmt.Fprintln(w, "OK")
}

Now, let us take a closer look at both these handlers.

The webhookVerificationHandler is quite straightforward, and just needed us to send a challenge (received in the request) in the response body:

func (s *Handler) urlVerificationHandler(w http.ResponseWriter, raw []byte) {
  var r *slackevents.ChallengeResponse
  
  err := json.Unmarshal([]byte(raw), &r)
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }
  
  w.Header().Set("Content-Type", "text")
  w.Write([]byte(r.Challenge))
}

In the handleCallbackEvent function, we check what kind of event this is, and based on that call specific event handler functions:

func (s *Handler) handleCallbackEvent(ctx context.Context, outerEvent slackevents.EventsAPIEvent) error {
  logrus.Info("starting handleCallbackEvent")
  appMentionEvent, ok := outerEvent.InnerEvent.Data.(*slack.MessageEvent)
  if ok {
    return s.handleAppMentionEvent(ctx, appMentionEvent)
  }

  reactionAddedEvent, ok := outerEvent.InnerEvent.Data.(*slackevents.ReactionAddedEvent)
  if ok {
    return s.handleReactionAddedEvent(ctx, reactionAddedEvent)
  }

  return fmt.Errorf("unsupported event")
}

Digging one level down further, in handleAppMentionEvent, we first check if we have processed this event already. If not, we then check if it meets our validation criteria (message from an allowed channel, all tagged users’ approval received or not, etc.). If it does meet our validation criteria, we retrieve the images from the event msg and store the post in our KV store as follows:

imageIdsMap := map[string]string{}
imageIds := []string{}
for _, file := range event.Files {
  if file.Filetype == "mp4" {
    continue
  }

  imageId := uuid.New().String()

  imageIdsMap[imageId] = file.URLPrivateDownload
  imageIds = append(imageIds, imageId)
}

post := &posts.Post{
  Msg:       event.Text,
  ImageIds:  imageIds,
  ImageMap:  imageIdsMap,
  Timestamp: event.Timestamp,
  Approved:  verifySignoffFromEvent(event),
}

store, err := kv.OpenStore("default")
if err != nil {
  return err
}
defer store.Close()

raw, err := json.Marshal(post)
if err != nil {
  return err
}

err = store.Set(skey, raw)
if err != nil {
  logrus.Infof("error when adding key %s into store %v", skey, err)
}

The handleReactionAddedEvent looks quite similar to handleAppMentionEvent. The only difference is that we use Slack’s SDK to retrieve the message details. As a reminder, when we initialized Slack’s client, we configured it with the HTTP Client provided by Spin’s runtime:

resp, err := s.slack.GetConversationHistory(&slack.GetConversationHistoryParameters{
    ChannelID:          event.Item.Channel,
    Latest:             event.Item.Timestamp,
    Limit:              1,
    Inclusive:          true,
    IncludeAllMetadata: true,
  })

Frontend

This component is the front end of the BTS website. We use Nuxt for the app, static generation mode to generate the static website, and the static file server WebAssembly component to serve it using spin.

To generate and serve the statically built frontend, we configure the component in spin.toml as follows:

### configure the component for UI
[[trigger.http]]
route = "/..."
component = "fileserver-static"

[component.fileserver-static]
source = { url = "https://github.com/fermyon/spin-fileserver/releases/download/v0.2.0/spin_static_fs.wasm", digest = "sha256:1342e1b51f00ba3f9f5c96821f4ee8af75a8f49ca024a8bc892a3b74bbf53df2" }
files = [ { source = "ui/.output/public/", destination = "/" } ]

[component.fileserver-static.build]
command = "cd ui && yarn install && yarn generate && cd -"

Backend API

This component provides the API for the frontend and exposes the following endpoints:

  • GET /api/posts - this returns the id for all the posts available in our storage. Because we are using Spin’s KV store, we call the function GetKeys to get all the keys and return them to the front end.
func GetAllPostsKeys() ([]string, error) {
  store, err := kv.OpenStore("default")
  if err != nil {
    return nil, err
  }

  allKeys, err := store.GetKeys()
  if err != nil {
    return nil, err
  }

  keys := []string{}
  for _, key := range allKeys {
    if !strings.HasPrefix(key, "post:") {
      continue
    }

    keys = append(keys, strings.TrimPrefix(key, "post:"))
  }

  return keys, nil
}
  • GET /api/posts/:postId - using the id returned above, we now fetch details of the specific post from the API. For this, we make use of the Get function call to retrieve the value stored in the KV store.
func GetPost(id string) (*Post, error) {
  store, err := kv.OpenStore("default")
  if err != nil {
    return nil, err
  }
  defer store.Close()

  raw, err := store.Get(fmt.Sprintf("post:%s", id))
  if err != nil {
    return nil, err
  }

  var post Post
  err = json.Unmarshal(raw, &post)
  if err != nil {
    return nil, err
  }

  return &post, nil
}
  • GET /api/posts/:postId/image/:imageId - using the details of the post, we now have access to the list of imageId. imageId is the internal ID that we use to store the corresponding Slack download URL for the image.

This handler is slightly more interesting. Because these images are private and need a Slack auth token to retrieve, we cannot fetch them directly on the client side. Instead, we need to proxy the request. One of the previous implementations did that, and as one could imagine, that was not very efficient.

But thanks to recently added streaming support to Spin, we switched this api implementation to Rust + Streaming, which means this api was suddenly much more responsive and efficient:

use spin_sdk::{
    key_value::Store,
    variables,
    http::{self, Headers, IncomingRequest, OutgoingResponse, ResponseOutparam, OutgoingRequest, Method, Scheme, IncomingResponse}
};

#[http_component]
async fn send_outbound(req: IncomingRequest, res: ResponseOutparam) {
    get_and_stream_imagefile(req, res).await.unwrap();
}

async fn get_and_stream_imagefile(req: IncomingRequest, res: ResponseOutparam) -> Result<()> {
  let token = variables::get("slack_token").unwrap();
  let url = Url::parse("https://files.slack.com/path/to/image/file.png").unwrap();

  let outgoing_request = OutgoingRequest::new(
    &Method::Get,
    Some(url.path()),
    Some(&match url.scheme() {
        "http" => Scheme::Http,
        "https" => Scheme::Https,
        scheme => Scheme::Other(scheme.into()),
    }),
    Some(url.authority()),
    &Headers::new(&[(
        "authorization".to_string(),
        format!("Bearer {token}").as_bytes().to_vec(),
    )]),
  );

  let response = http::send::<_, IncomingResponse>(outgoing_request).await?;

  let status = response.status();

  let mut stream = response.take_body_stream();

  let out_response = OutgoingResponse::new(
    status,
    &Headers::new(&[(
        "content-type".to_string(),
        b"application/octet-stream".to_vec(),
    )]),
  );

  let mut body = out_response.take_body();
  res.set(out_response);

  while let Some(chunk) = stream.next().await {
    body.send(chunk?).await?;
  }

  Ok(())
}

CI/CD

An important step in the lifecycle of any app is how we securely and continually deploy that. Here, we are using Fermyon Cloud’s GitHub actions. This set of GitHub actions is provided by Fermyon and facilitates the following functionalities:

  • set-up Spin
  • package and push the Spin app to the OCI registry
  • package and deploy the Spin app to Fermyon Cloud

Deploying your Spin app to Fermyon Cloud is as simple as the following:

- name: build and deploy
  uses: fermyon/actions/spin/deploy@v1
  id: deploy
  with:
    fermyon_token: ${{ secrets.FERMYON_CLOUD_TOKEN }}
    manifest_file: spin.toml
    variables: |-
      allowed_channel=${{ secrets.ALLOWED_CHANNEL }}
      trigger_on_emoji_code=slats
      slack_token=${{ secrets.SLACK_TOKEN }}
      slack_signing_secret=${{ secrets.SLACK_SIGNING_SECRET }}
      bts_admin_api_key=${{ secrets.BTS_ADMIN_API_KEY }}

Refer to the complete GitHub action for this app or this tutorial for details.

Custom Domain

Last but not least, we wanted to host the app on a URL that tells a story in itself. We wanted it to convey that this is a “behind the scenes @ fermyon” but also that it is built using our open source project Spin. Therefore we now host this app at https://fermyon-bts.usingspin.com. The domain is configured using the Custom Domain feature in Fermyon Cloud. Using this, you can bring your domains (and thus branding) for providing access to your app to your users.

Conclusion

In this blog post, we covered how we implemented the Slack webhook and RESTful API using Spin, made use of Fermyon’s KV store as persistent storage, and used GitHub actions to implement a secure CI/CD pipeline for the app.

Building the “behind the scenes” app with Fermyon Spin was fun and exercising the abilities of Fermyon Cloud to deploy this made the journey easy.

We also have a #pets channel on our Slack to share cute and adorable photos of our pets. We feature one of these pet photos in our weekly newsletter, which you can subscribe to in the footer below.

Interested in learning more?

Talk to us