pitr / gig

Gemini framework
MIT License
61 stars 5 forks source link

Gig - Gemini framework

Used By godocs.io GoDoc Go Report Card Codecov License

API is subject to change until v1.0

Protocol compatibility

Version Supported Gemini version
0.9.4 v0.14.*
< 0.9.4 v0.13.*

Contents

Feature Overview

Guide

Quick Start

package main

import "github.com/pitr/gig"

func main() {
  // Gig instance
  g := gig.Default()

  // Routes
  g.Handle("/", func(c gig.Context) error {
    return c.Gemini("# Hello, World!")
  })

  // Start server on PORT or default port
  g.Run("my.crt", "my.key")
}
$ go run main.go

Parameters in path

package main

import "github.com/pitr/gig"

func main() {
  g := gig.Default()

  g.Handle("/user/:name", func(c gig.Context) error {
    return c.Gemini("# Hello, %s!", c.Param("name"))
  })

  g.Run("my.crt", "my.key")
}

Query

package main

import "github.com/pitr/gig"

func main() {
  g := gig.Default()

  g.Handle("/user", func(c gig.Context) error {
    query, err := c.QueryString()
    if err != nil {
      return err
    }
    return c.Gemini("# Hello, %s!", query)
  })

  g.Run("my.crt", "my.key")
}

Client Certificate

package main

import "github.com/pitr/gig"

func main() {
  g := gig.Default()

  g.Handle("/user", func(c gig.Context) error {
    cert := c.Certificate()
    if cert == nil {
      return c.NoContent(gig.StatusClientCertificateRequired, "We need a certificate")
    }
    return c.Gemini("# Hello, %s!", cert.Subject.CommonName)
  })

  // OR using middleware

  g.Handle("/user", func(c gig.Context) error {
    return c.Gemini("# Hello, %s!", c.Get("subject"))
  }, gig.CertAuth(gig.ValidateHasCertificate))

  g.Run("my.crt", "my.key")
}

Grouping routes

func main() {
  g := gig.Default()

  // Simple group: v1
  v1 := g.Group("/v1")
  {
    v1.Handle("/page1", page1Endpoint)
    v1.Handle("/page2", page2Endpoint)
  }

  // Simple group: v2
  v2 := g.Group("/v2")
  {
    v2.Handle("/page1", page1Endpoint)
    v2.Handle("/page2", page2Endpoint)
  }

  g.Run("my.crt", "my.key")
}

Blank Gig without middleware by default

Use

g := gig.New()

instead of

// Default With the Logger and Recovery middleware already attached
g := gig.Default()

Using middleware

func main() {
  // Creates a router without any middleware by default
  g := gig.New()

  // Global middleware
  // Logger middleware will write the logs to gig.DefaultWriter.
  // By default gig.DefaultWriter = os.Stdout
  g.Use(gig.Logger())

  // Recovery middleware recovers from any panics and return StatusPermanentFailure.
  g.Use(gig.Recovery())

  // Private group
  // same as private := g.Group("/private", gig.CertAuth(gig.ValidateHasCertificate))
  private := g.Group("/private")
  private.Use(gig.CertAuth(gig.ValidateHasCertificate))
  {
    private.Handle("/user", userEndpoint)
  }

  g.Run("my.crt", "my.key")
}

Writing logs to file

func main() {
  f, _ := os.Create("access.log")
  gig.DefaultWriter = io.MultiWriter(f)

  // Use the following code if you need to write the logs to file and console at the same time.
  // gig.DefaultWriter = io.MultiWriter(f, os.Stdout)

  g := gig.Default()

  g.Handle("/", func(c gig.Context) error {
      return c.Gemini("# Hello, World!")
  })

  g.Run("my.crt", "my.key")
}

Custom Log Format

func main() {
  g := gig.New()

  // See LoggerConfig documentation for format
  g.Use(gig.LoggerWithConfig(gig.LoggerConfig{Format: "${remote_ip} ${status}"}))

  g.Handle("/", func(c gig.Context) error {
      return c.Gemini("# Hello, World!")
  })

  g.Run("my.crt", "my.key")
}

Serving static files

func main() {
  g := gig.Default()

  // Register /images/* to serve files in my_images/ folder.
  // Requests to /images/ will show directory listing.
  g.Static("/images", "my_images")

  g.File("/robots.txt", "files/robots.txt")

  g.Run("my.crt", "my.key")
}

Serving data from file

func main() {
  g := gig.Default()

  g.Handle("/robots.txt", func(c gig.Context) error {
      return c.File("robots.txt")
  })

  g.Run("my.crt", "my.key")
}

Serving data from reader

func main() {
  g := gig.Default()

  g.Handle("/data", func(c gig.Context) error {
    response, err := http.Get("https://google.com/")
    if err != nil || response.StatusCode != http.StatusOK {
      return c.NoContent(gig.StatusProxyError, "could not fetch google")
    }

    return c.Stream("text/html", response.Body)
  })

  g.Run("my.crt", "my.key")
}

Templates

Set Gig.Renderer to something that responds to Render(io.Writer, string, interface{}, gig.Context) error.

Use any templating library, such as text/template, https://github.com/valyala/quicktemplate, etc. The following example uses text/template:

import (
  "text/template"

  "github.com/pitr/gig"
)

type Template struct {
  templates *template.Template
}

func (t *Template) Render(w io.Writer, name string, data interface{}, c gig.Context) error {
  // Execute named template with data
  return t.templates.ExecuteTemplate(w, name, data)
}

func main() {
  g := gig.Default()

  // Register renderer
  g.Renderer = &Template{template.Must(template.ParseGlob("public/views/*.gmi"))}

  g.Handle("/user/:name", func(c gig.Context) error {
    // Render template "user" with username passed as data.
    return c.Render("user", c.Param("name"))
  })

  g.Run("my.crt", "my.key")
}

Consider bundling assets with the binary by using go:ember, go-assets or similar.

Redirects

func main() {
  g := gig.Default()

  g.Handle("/old", func(c gig.Context) error {
    return c.NoContent(gig.StatusRedirectPermanent, "/new")
  })

  g.Run("my.crt", "my.key")
}

Subdomains

func main() {
  apps := map[string]*gig.Gig{}

  // App A
  a := gig.Default()
  apps["app-a.example.com"] = a

  a.Handle("/", func(c gig.Context) error {
      return c.Gemini("I am App A")
  })

  // App B
  b := gig.Default()
  apps["app-b.example.com"] = b

  b.Handle("/", func(c gig.Context) error {
      return c.Gemini("I am App B")
  })

  // Server (without default middleware to prevent double logging)
  g := gig.New()
  g.Handle("/*", func(c gig.Context) error {
      app := apps[c.URL().Host]

      if app == nil {
          return gig.ErrNotFound
      }

      app.ServeGemini(c)
      return nil
  })

  g.Run("my.crt", "my.key") // must be wildcard SSL certificate for *.example.com
}

Username/password authentication middleware

Status: EXPERIMENTAL

PassAuth middleware ensures that request has a client certificate, validates its fingerprint using function passed to middleware. If authentication is required, this function should return a path where user should be redirect to.

Login handlers are setup using PassAuthLoginHandle function, which collects username and password, and passes them to the provided function. That function should return an error if login failed, or absolute path to redirect user to.

User registration is expected to be implemented by developer.

The example assumes that there is a db module that does user management.

func main() {
  g := gig.Default()

  secret := g.Group("/secret", gig.PassAuth(func(sig string, c gig.Context) (string, error) {
    ok, err := db.CheckValid(sig)
    if err != nil {
      return "/login", err
    }
    if !ok {
      return "/login", nil
    }
    return "", nil
  }))
  // secret.Handle("/page", func(c gig.Context) {...})

  g.PassAuthLoginHandle("/login", func(user, pass, sig string, c Context) (string, error) {
    // check user/pass combo, and activate cert signature if valid
    err := db.Login(user, pass, sig)
    if err != nil {
      return "", err
    }
    return "/secret/page", nil
  })

  g.Run("my.crt", "my.key")
}

Custom middleware

func MyMiddleware(next gig.HandlerFunc) gig.HandlerFunc {
  return func(c gig.Context) error {
    // Set example variable
    c.Set("example", "123")

    if err := next(c); err != nil {
      c.Error(err)
    }

    // Do something after request is done
    // ...

    return err
  }
}

func main() {
  g := gig.Default()
  g.Use(MyMiddleware)

  g.Handle("/", func(c gig.Context) error {
    return c.Gemini("# Example %s", c.Get("example"))
  })

  g.Run("my.crt", "my.key")
}

Custom port

Use PORT environment variable:

PORT=12345 ./myapp

Alternatively, pass it to Run:

func main() {
  g := gig.Default()

  g.Handle("/", func(c gig.Context) error {
    return c.Gemini("# Hello world")
  })

  g.Run(":12345", "my.crt", "my.key")
}

Custom TLS config

func main() {
  g := gig.Default()
  g.TLSConfig.MinVersion = tls.VersionTLS13

  g.Handle("/", func(c gig.Context) error {
    return c.Gemini("# Hello world")
  })

  g.Run("my.crt", "my.key")
}

Testing

func setupServer() *gig.Gig {
  g := gig.Default()

  g.Handle("/private", func(c gig.Context) error {
    return c.Gemini("Hello %s", c.Get("subject"))
  }, gig.CertAuth(gig.ValidateHasCertificate))

  return g
}

func TestServer(t *testing.T) {
  g := setupServer()
  c, res := g.NewFakeContext("/private", nil)

  g.ServeGemini(c)

  if res.Written != "60 Client Certificate Required\r\n" {
    t.Fail()
  }
}

func TestCertificate(t *testing.T) {
  g := setupServer()
  c, res := g.NewFakeContext("/", &tls.ConnectionState{
    PeerCertificates: []*x509.Certificate{
      {Subject: pkix.Name{CommonName: "john"}},
    },
  })

  g.ServeGemini(c)

  if resp.Written != "20 text/gemini\r\nHello john" {
    t.Fail()
  }
}

Who uses Gig

Gig is used by the following capsules:

If you use Gig, open a PR to add your capsule to this list.

Benchmarks

Benchmark name (1) (2) (3) (4)
BenchmarkRouterStaticRoutes 104677 11105 ns/op 0 B/op 0 allocs/op
BenchmarkRouterGitHubAPI 50859 22973 ns/op 0 B/op 0 allocs/op
BenchmarkRouterParseAPI 302828 3717 ns/op 0 B/op 0 allocs/op
BenchmarkRouterGooglePlusAPI 185558 6136 ns/op 0 B/op 0 allocs/op

Generated using make bench in router_test.go. APIs are based on Go HTTP Router Benchmark repository and adapted to Gemini protocol, eg. verbs GET/POST/etc are ignored since Gemini does not support them.

Contribute

If something is missing, please open an issue. If possible, send a PR.

License

MIT