Skip to content

Commit

Permalink
Add an API to Pixlet
Browse files Browse the repository at this point in the history
Currently, only one endpoint is implemented: `/api/render` renders an
applet accessible to the pixlet process and returns the webp or gif
image data.

Example request:

```
POST /api/render
{
    "path": "/workspaces/pixlet/examples/clock",
    "config": {
        "timezone": "America/New_York"
    }
}
```

The HTTP cache is bound to the process lifetime. That means, multiple
render requests use the same HTTP cache.
  • Loading branch information
IngmarStein committed Feb 16, 2025
1 parent 3f82366 commit 2fd17aa
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 7 deletions.
203 changes: 203 additions & 0 deletions cmd/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"image"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"github.com/spf13/cobra"
"tidbyt.dev/pixlet/encode"
"tidbyt.dev/pixlet/globals"
"tidbyt.dev/pixlet/runtime"
"tidbyt.dev/pixlet/tools"
)

func init() {
ApiCmd.Flags().StringVarP(&host, "host", "i", "127.0.0.1", "Host interface for serving rendered images")
ApiCmd.Flags().IntVarP(&port, "port", "p", 8080, "Port for serving rendered images")
ApiCmd.Flags().BoolVarP(&renderGif, "gif", "", false, "Generate GIF instead of WebP")
ApiCmd.Flags().BoolVarP(&silenceOutput, "silent", "", false, "Silence print statements when rendering app")
}

var ApiCmd = &cobra.Command{
Use: "api",
Short: "Run a Pixlet API server",
Args: cobra.MinimumNArgs(0),
RunE: api,
Long: `Start an HTTP server that runs a Pixlet app in response to API requests.
`,
}

type renderRequest struct {
Path string `json:"path"`
Config map[string]string `json:"config"`
Width int `json:"width"`
Height int `json:"height"`
Magnify int `json:"magnify"`
}

func renderApplet(path string, config map[string]string, width, height, magnify int) ([]byte, error) {
// check if path exists, and whether it is a directory or a file
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("failed to stat %s: %w", path, err)
}

var fs fs.FS
if info.IsDir() {
fs = os.DirFS(path)
} else {
if !strings.HasSuffix(path, ".star") {
return nil, fmt.Errorf("script file must have suffix .star: %s", path)
}

fs = tools.NewSingleFileFS(path)
}

if width > 0 {
globals.Width = width
}
if height > 0 {
globals.Height = height
}
if magnify == 0 {
magnify = 1
}

// Remove the print function from the starlark thread if the silent flag is
// passed.
var opts []runtime.AppletOption
if silenceOutput {
opts = append(opts, runtime.WithPrintDisabled())
}

ctx := context.Background()
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeoutCause(
ctx,
time.Duration(timeout)*time.Millisecond,
fmt.Errorf("timeout after %d ms", timeout),
)
defer cancel()
}

applet, err := runtime.NewAppletFromFS(filepath.Base(path), fs, opts...)
if err != nil {
return nil, fmt.Errorf("failed to load applet: %w", err)
}

roots, err := applet.RunWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("error running script: %w", err)
}
screens := encode.ScreensFromRoots(roots)

filter := func(input image.Image) (image.Image, error) {
if magnify <= 1 {
return input, nil
}
in, ok := input.(*image.RGBA)
if !ok {
return nil, fmt.Errorf("image not RGBA, very weird")
}

out := image.NewRGBA(
image.Rect(
0, 0,
in.Bounds().Dx()*magnify,
in.Bounds().Dy()*magnify),
)
for x := 0; x < in.Bounds().Dx(); x++ {
for y := 0; y < in.Bounds().Dy(); y++ {
for xx := 0; xx < magnify; xx++ {
for yy := 0; yy < magnify; yy++ {
out.SetRGBA(
x*magnify+xx,
y*magnify+yy,
in.RGBAAt(x, y),
)
}
}
}
}

return out, nil
}

var buf []byte

if screens.ShowFullAnimation {
maxDuration = 0
}

if renderGif {
buf, err = screens.EncodeGIF(maxDuration, filter)
} else {
buf, err = screens.EncodeWebP(maxDuration, filter)
}
if err != nil {
return nil, fmt.Errorf("error rendering: %w", err)
}

return buf, nil
}

func validatePath(path string) bool {
return !strings.Contains(path, "..")
}

// Example request
//
// {
// "path": "/workspaces/pixlet/examples/clock",
// "config": {
// "timezone": "America/New_York"
// }
// }
func renderHandler(w http.ResponseWriter, req *http.Request) {
var r renderRequest

if err := json.NewDecoder(req.Body).Decode(&r); err != nil {
http.Error(w, fmt.Sprintf("failed to decode render request: %v", err), http.StatusBadRequest)
return
}

Check warning

Code scanning / CodeQL

Information exposure through a stack trace Medium

HTTP response depends on
stack trace information
and may be exposed to an external user.
HTTP response depends on
stack trace information
and may be exposed to an external user.
if !validatePath(r.Path) {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}

buf, err := renderApplet(r.Path, r.Config, r.Width, r.Height, r.Magnify)
if err != nil {
http.Error(w, fmt.Sprintf("error rendering: %v", err), http.StatusInternalServerError)
return
}

if renderGif {
w.Header().Set("Content-Type", "image/gif")
} else {
w.Header().Set("Content-Type", "image/webp")
}
w.Write(buf)
}

func api(cmd *cobra.Command, args []string) error {
cache := runtime.NewInMemoryCache()
runtime.InitHTTP(cache)
runtime.InitCache(cache)

addr := fmt.Sprintf("%s:%d", host, port)
log.Printf("listening at http://%s\n", addr)
mux := http.NewServeMux()
mux.HandleFunc("POST /api/render", renderHandler)
return http.ListenAndServe(addr, mux)
}
2 changes: 1 addition & 1 deletion cmd/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func render(cmd *cobra.Command, args []string) error {
if len(split) < 2 {
return fmt.Errorf("parameters must be on form <key>=<value>, found %s", param)
}
config[split[0]] = strings.Join(split[1:len(split)], "=")
config[split[0]] = strings.Join(split[1:], "=")
}

}
Expand Down
12 changes: 6 additions & 6 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import (
)

var (
host string
port int
watch bool
serveGif bool
host string
port int
watch bool
serveGif bool
configOutFile string
)

func init() {
ServeCmd.Flags().StringVarP(&configOutFile,"saveconfig","o","", "Output file for config changes")
ServeCmd.Flags().StringVarP(&configOutFile, "saveconfig", "o", "", "Output file for config changes")
ServeCmd.Flags().StringVarP(&host, "host", "i", "127.0.0.1", "Host interface for serving rendered images")
ServeCmd.Flags().IntVarP(&port, "port", "p", 8080, "Port for serving rendered images")
ServeCmd.Flags().BoolVarP(&watch, "watch", "w", true, "Reload scripts on change. Does not recurse sub-directories.")
Expand All @@ -36,7 +36,7 @@ containing multiple Starlark files and resources.`,
}

func serve(cmd *cobra.Command, args []string) error {
s, err := server.NewServer(host, port, watch, args[0], maxDuration, timeout, serveGif,configOutFile)
s, err := server.NewServer(host, port, watch, args[0], maxDuration, timeout, serveGif, configOutFile)
if err != nil {
return err
}
Expand Down
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"os"
_ "time/tzdata"

"github.com/spf13/cobra"
"tidbyt.dev/pixlet/cmd"
Expand All @@ -18,6 +19,7 @@ var (
)

func init() {
rootCmd.AddCommand(cmd.ApiCmd)
rootCmd.AddCommand(cmd.RenderCmd)
rootCmd.AddCommand(cmd.PushCmd)
rootCmd.AddCommand(cmd.EncryptCmd)
Expand Down

0 comments on commit 2fd17aa

Please sign in to comment.