diff --git a/README.md b/README.md index e9e0580..efea92b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ API is subject to change until v1.0 * [Custom port](#custom-port) * [Custom TLS config](#custom-tls-config) * [Testing](#testing) + * [Titan protocol](#titan-protocol) * [Who uses Gig](#who-uses-gig) * [Benchmarks](#benchmarks) * [Contribute](#contribute) @@ -530,6 +531,56 @@ func TestCertificate(t *testing.T) { } ``` +### Titan Protocol + +[Titan] protocol is supported as middleware. Example titan server can be found +in `examples/titan` directory. + +To use handle titan requests you must set `AllowProxying` property of gig to true. + +```go +g := gig.Default() +g.AllowProxying = true +``` + +It is recommenced to set request read timeout to non zero value: + +```go +g.ReadTimeout = time.Second * 10 +``` + +When creating middleware you must provide maximal request size in +bytes. Attempt to send bigger files than the limit will result in bad +request response. + +```go +g.Use(gig.Titan(1024)) +``` + +Titan middleware detects requests using `titan://` scheme and stores information about +it in request's context. + +| key | type | description | +|-------|--------|-----------------------------------------| +| titan | bool | true if request is titan request | +| size | int | size of received file | +| mime | string | mime type as described in [Titan] spec | +| token | string | auth token as described in [Titan] spec | + +The `titan` key in a context can be used to implement handlers that can +handle both titan and gemini requests. + +Please note that if client sends less data than indicated by size parameter +then request should result in error response. + +There are two utility function provided to make writing Titan servers easier: + +- `TitanReadFull`: reads whole request into a buffer. Takes care of case +when client sends less data than expeted. +- `TitanRedirect`: redirects client to the gemini resource uploaded in the request. + +[Titan]: https://communitywiki.org/wiki/Titan + ## Who uses Gig Gig is used by the following capsules: diff --git a/context.go b/context.go index 104c25f..85ffde5 100644 --- a/context.go +++ b/context.go @@ -49,6 +49,13 @@ type ( // to a server. Usually the URL() or Path() should be used instead. RequestURI() string + // Reader returns request connection reader that can be + // used to read data that client sent after Gemini request. + // This feature is necessary to implement Gemini extensions + // like Titan protocol. Spec compliant Gemini server should + // ignore all data after the request. + Reader() io.Reader + // Param returns path parameter by name. Param(name string) string @@ -98,6 +105,7 @@ type ( conn tlsconn TLS *tls.ConnectionState u *url.URL + reader io.Reader response *Response path string requestURI string @@ -168,6 +176,10 @@ func (c *context) QueryString() (string, error) { return url.QueryUnescape(c.u.RawQuery) } +func (c *context) Reader() io.Reader { + return c.reader +} + func (c *context) Get(key string) interface{} { c.lock.RLock() defer c.lock.RUnlock() @@ -355,12 +367,13 @@ func (c *context) Handler() HandlerFunc { return c.handler } -func (c *context) reset(conn tlsconn, u *url.URL, requestURI string, tls *tls.ConnectionState) { +func (c *context) reset(conn tlsconn, u *url.URL, requestURI string, reader io.Reader, tls *tls.ConnectionState) { c.conn = conn c.TLS = tls c.u = u c.requestURI = requestURI c.response.reset(conn) + c.reader = reader c.handler = NotFoundHandler c.store = nil c.path = "" diff --git a/context_test.go b/context_test.go index d46322f..fbcdaaa 100644 --- a/context_test.go +++ b/context_test.go @@ -100,7 +100,7 @@ func TestContext(t *testing.T) { // Reset c.Set("foe", "ban") - c.(*context).reset(nil, nil, "", nil) + c.(*context).reset(nil, nil, "", nil, nil) is.Equal(0, len(c.(*context).store)) is.Equal("", c.Path()) } @@ -146,7 +146,7 @@ func TestContextGetParam(t *testing.T) { is.Equal("", c.Param("bar")) // shouldn't explode during Reset() afterwards! - c.(*context).reset(nil, nil, "", nil) + c.(*context).reset(nil, nil, "", nil, nil) } func TestContextFile(t *testing.T) { diff --git a/examples/titan/file.txt b/examples/titan/file.txt new file mode 100644 index 0000000..4e1320e --- /dev/null +++ b/examples/titan/file.txt @@ -0,0 +1,7 @@ + ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄ ▄▄ ▄▄▄ ▄▄ + ▀▀▀██▀▀▀ ▀▀██▀▀ ▀▀▀██▀▀▀ ████ ███ ██ + ██ ██ ██ ████ ██▀█ ██ + ██ ██ ██ ██ ██ ██ ██ ██ + ██ ██ ██ ██████ ██ █▄██ + ██ ▄▄██▄▄ ██ ▄██ ██▄ ██ ███ + ▀▀ ▀▀▀▀▀▀ ▀▀ ▀▀ ▀▀ ▀▀ ▀▀▀ diff --git a/examples/titan/main.go b/examples/titan/main.go new file mode 100644 index 0000000..7e84183 --- /dev/null +++ b/examples/titan/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + + "github.com/pitr/gig" +) + +func main() { + g := gig.Default() + g.ReadTimeout = time.Second * 10 + g.AllowProxying = true + g.Use(gig.Titan(1024)) + + g.Handle("/file.txt*", func(c gig.Context) error { + if c.Get("titan").(bool) { + data, err := gig.TitanReadFull(c) + if err != nil { + return err + } + + err = os.WriteFile("file.txt", data, 0o644) + if err != nil { + return err + } + gig.TitanRedirect(c) + return nil + } + return c.File("file.txt") + }) + + g.Run("../astro.crt", "../astro.key") +} diff --git a/gig.go b/gig.go index 3cb827e..79440d9 100644 --- a/gig.go +++ b/gig.go @@ -77,6 +77,9 @@ type ( // TLSConfig is passed to tls.NewListener and needs to be modified // before Run is called. TLSConfig *tls.Config + + // AllowProxying disables error on using other schemas than gemini:// + AllowProxying bool } // Route contains a handler and information for matching against requests. @@ -374,7 +377,7 @@ func (g *Gig) ServeGemini(c Context) { ctx := g.ctxpool.Get().(*context) defer g.ctxpool.Put(ctx) - ctx.reset(orig.conn, orig.u, orig.requestURI, orig.TLS) + ctx.reset(orig.conn, orig.u, orig.requestURI, orig.reader, orig.TLS) c = ctx } @@ -582,7 +585,7 @@ func (g *Gig) handleRequest(conn tlsconn) { URL.Scheme = "gemini" } - if URL.Scheme != "gemini" { + if !g.AllowProxying && URL.Scheme != "gemini" { debugPrintf("gemini: non-gemini scheme: %s", header) _, _ = conn.Write(responseBadSchema) @@ -601,7 +604,7 @@ func (g *Gig) handleRequest(conn tlsconn) { // Acquire context c := g.ctxpool.Get().(*context) - c.reset(conn, URL, header, &tlsState) + c.reset(conn, URL, header, reader, &tlsState) g.ServeGemini(c) diff --git a/gigtest.go b/gigtest.go index 13e32d3..73a7d87 100644 --- a/gigtest.go +++ b/gigtest.go @@ -3,6 +3,7 @@ package gig import ( "crypto/tls" "errors" + "io" "net" "net/url" "time" @@ -16,6 +17,7 @@ type ( FakeConn struct { FailAfter int Written string + Reader io.Reader } ) @@ -25,8 +27,15 @@ func (a *FakeAddr) Network() string { return "tcp" } // String returns dummy data. func (a *FakeAddr) String() string { return "192.0.2.1:25" } -// Read always returns success. -func (c *FakeConn) Read(b []byte) (n int, err error) { return len(b), nil } +// Read always returns success if Reader is nil, otherwise Read is delegated +// to the FakeConn reader. +func (c *FakeConn) Read(b []byte) (n int, err error) { + if c.Reader != nil { + return c.Reader.Read(b) + } + + return len(b), nil +} // Write records bytes written and fails after FailAfter bytes. func (c *FakeConn) Write(b []byte) (n int, err error) { @@ -63,14 +72,29 @@ func (c *FakeConn) SetWriteDeadline(t time.Time) error { return nil } // ConnectionState always returns nil. func (c *FakeConn) ConnectionState() tls.ConnectionState { return tls.ConnectionState{} } +type FakeOpt func(Context, *FakeConn) + +// WithFakeReader adds reader to the context and connection. +func WithFakeReader(r io.Reader) FakeOpt { + return func(c Context, f *FakeConn) { + f.Reader = r + c.(*context).reader = r + } +} + // NewFakeContext returns Context that writes to FakeConn. -func (g *Gig) NewFakeContext(uri string, tlsState *tls.ConnectionState) (Context, *FakeConn) { +func (g *Gig) NewFakeContext(uri string, tlsState *tls.ConnectionState, opts ...FakeOpt) (Context, *FakeConn) { u, err := url.Parse(uri) if err != nil { panic(err) } conn := &FakeConn{} + ctx := g.newContext(conn, u, uri, tlsState) + + for _, o := range opts { + o(ctx, conn) + } - return g.newContext(conn, u, uri, tlsState), conn + return ctx, conn } diff --git a/router.go b/router.go index 1a021a5..cf7f022 100644 --- a/router.go +++ b/router.go @@ -227,6 +227,11 @@ func (n *node) findChildByKind(t kind) *node { // find lookup a handler registered for path. It also parses URL for path // parameters and load them into context. func (r *router) find(path string, c Context) { + i := strings.Index(path, ";") + if i != -1 { + path = path[0:i] + } + ctx := c.(*context) ctx.path = path cn := r.tree // Current node as root diff --git a/router_test.go b/router_test.go index 3478da8..f150a53 100644 --- a/router_test.go +++ b/router_test.go @@ -1210,6 +1210,14 @@ func TestRouterParam1466(t *testing.T) { is.Equal("sharewithme", c.Param("username")) is.Equal("self", c.Param("type")) + r.find("/users/sharewithme/uploads/self;mime=text/plain;size=24", c) + is.Equal("sharewithme", c.Param("username")) + is.Equal("self", c.Param("type")) + + r.find("/users/sharewithme/uploads/self?foo=1", c) + is.Equal("sharewithme", c.Param("username")) + is.Equal("self?foo=1", c.Param("type")) + c = g.newContext(nil, nil, "", nil).(*context) r.find("/users/ajitem/uploads/self", c) is.Equal("ajitem", c.Param("username")) diff --git a/serve_test.go b/serve_test.go index 61b03eb..0173ae3 100644 --- a/serve_test.go +++ b/serve_test.go @@ -1,3 +1,4 @@ +//go:build !race // +build !race package gig diff --git a/titan.go b/titan.go new file mode 100644 index 0000000..8077471 --- /dev/null +++ b/titan.go @@ -0,0 +1,121 @@ +package gig + +import ( + "errors" + "io" + "net/url" + "strconv" + "strings" +) + +const ( + titanScheme = "titan" +) + +type titanParams struct { + token string + mime string + size int +} + +func newTitanParams(url *url.URL) (p titanParams) { + fragments := strings.Split(url.Path, ";") + for i := range fragments { + kv := strings.SplitN(fragments[i], "=", 2) + if len(kv) != 2 { + continue + } + + switch { + case kv[0] == "token": + p.token = kv[1] + case kv[0] == "mime": + p.mime = kv[1] + case kv[0] == "size": + if v, err := strconv.Atoi(kv[1]); err == nil { + p.size = v + } + } + } + if p.mime == "" { + p.mime = "text/gemini" + } + + return +} + +// Titan returns a middleware that implements Titan protocol request parsing +// and validation. To limit size of uploaded files set sizeLimit to value +// greater than 0. +func Titan(sizeLimit int) MiddlewareFunc { + return func(next HandlerFunc) HandlerFunc { + return func(c Context) error { + switch c.URL().Scheme { + case titanScheme: + c.Set("titan", true) + + // Parameters + params := newTitanParams(c.URL()) + + if params.size <= 0 { + return c.NoContent(StatusBadRequest, "Size parameter is incorrect or not provided") + } + + if sizeLimit > 0 && sizeLimit < params.size { + return c.NoContent(StatusBadRequest, "Request is bigger than allowed %d bytes", sizeLimit) + } + + c.Set("size", params.size) + c.Set("token", params.token) + c.Set("mime", params.mime) + default: + c.Set("titan", false) + } + + return next(c) + } + } +} + +// titanURLtoGemini strips Titan parameters and changes scheme to gemini. +func titanURLtoGemini(url *url.URL) error { + fragments := strings.Split(url.Path, ";") + if len(fragments) == 0 { + return errors.New("failed to create redirect URL") + } + + url.Scheme = "gemini" + url.Path = fragments[0] + + return nil +} + +// TitanRedirect is utility that redirects client to matching Gemini resource +// after successful upload. It changes scheme to gemini and removes Titan +// parameters from URL path. +func TitanRedirect(c Context) error { + url := c.URL() + if err := titanURLtoGemini(url); err != nil { + return c.NoContent(StatusPermanentFailure, err.Error()) + } + + return c.NoContent(StatusRedirectTemporary, url.String()) +} + +// TitanReadFull is utility wrapper that allocates new buffer and reads +// Titan request's content into it. +// +// To store large file on disk directly methods like io.CopyN are preferable. +func TitanReadFull(c Context) ([]byte, error) { + size := c.Get("size").(int) + buffer := make([]byte, size) + + var err error + if r := c.Reader(); r != nil { + _, err = io.ReadFull(c.Reader(), buffer) + } else { + err = errors.New("context reader is nil") + } + + return buffer, err +} diff --git a/titan_test.go b/titan_test.go new file mode 100644 index 0000000..0cd9a81 --- /dev/null +++ b/titan_test.go @@ -0,0 +1,209 @@ +package gig + +import ( + "bytes" + "errors" + "io" + "net/url" + "testing" + + "github.com/matryer/is" +) + +func TestTitanURLParser(t *testing.T) { + tests := []struct { + url string + token string + mime string + size int + }{ + {"titan://f.org/raw/Test;token=hello;mime=plain/text;size=10", "hello", "plain/text", 10}, + {"titan://f.org/;mime=plain/text;size=10", "", "plain/text", 10}, + {"titan://f.org/", "", "text/gemini", 0}, + {"titan://f.org/;mime=a", "", "a", 0}, + } + + for _, tc := range tests { + tc := tc + t.Run("", func(t *testing.T) { + is := is.New(t) + url, err := url.Parse(tc.url) + is.NoErr(err) + + got := newTitanParams(url) + is.Equal(got, titanParams{ + token: tc.token, + mime: tc.mime, + size: tc.size, + }) + }) + } +} + +func TestTitanRequest(t *testing.T) { + tests := []struct { + name string + uri string + reader io.Reader + sizeLimit int + expect string + handlerHook func(Context, *testing.T) error + }{ + // Size param tests + { + name: "no size", + uri: "titan://a.b", + reader: nil, + expect: "59 Size parameter is incorrect or not provided\r\n", + }, + { + name: "wrong size", + uri: "titan://a.b;size=-1", + reader: nil, + expect: "59 Size parameter is incorrect or not provided\r\n", + }, + { + name: "size is not a number", + uri: "titan://a.b;size=foo", + reader: nil, + expect: "59 Size parameter is incorrect or not provided\r\n", + }, + { + name: "zero size", + uri: "titan://a.b;size=0", + reader: nil, + expect: "59 Size parameter is incorrect or not provided\r\n", + }, + { + name: "size provided", + uri: "titan://a.b/;size=30", + handlerHook: func(c Context, t *testing.T) error { + is := is.New(t) + is.Equal(c.Get("titan"), true) + is.Equal(c.Get("size").(int), 30) + return nil + }, + }, + { + name: "size bigger than size limit", + uri: "titan://a.b/;size=10", + expect: "59 Request is bigger than allowed 5 bytes\r\n", + sizeLimit: 5, + }, + { + name: "gemini request on titan enabled endpoint", + uri: "gemini://a.b/", + handlerHook: func(c Context, t *testing.T) error { + is := is.New(t) + is.Equal(c.Get("titan"), false) + return nil + }, + }, + { + name: "read correct ammout of data", + uri: "titan://a.b/;size=10", + reader: bytes.NewBuffer(make([]byte, 10)), + handlerHook: func(c Context, t *testing.T) error { + is := is.New(t) + b, err := TitanReadFull(c) + is.NoErr(err) + is.True(b != nil) + is.Equal(len(b), 10) + return nil + }, + }, + { + name: "read underflow", + uri: "titan://a.b/;size=5", + reader: bytes.NewBuffer([]byte{1, 2, 3}), + handlerHook: func(c Context, t *testing.T) error { + is := is.New(t) + b, err := TitanReadFull(c) + is.True(errors.Is(err, io.ErrUnexpectedEOF)) + is.True(b != nil) + is.Equal(len(b), 5) + is.Equal(b, []byte{1, 2, 3, 0, 0}) + return nil + }, + }, + { + name: "stop reading at size", + uri: "titan://a.b/;size=3", + reader: bytes.NewBuffer([]byte{1, 2, 3, 4, 5}), + handlerHook: func(c Context, t *testing.T) error { + is := is.New(t) + b, err := TitanReadFull(c) + is.NoErr(err) + is.True(b != nil) + is.Equal(len(b), 3) + is.Equal(b, []byte{1, 2, 3}) + return nil + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + is := is.New(t) + g := New() + g.Handle("/*", func(c Context) error { + if tc.handlerHook != nil { + return tc.handlerHook(c, t) + } + return nil + }) + g.Use(Titan(tc.sizeLimit)) + ctx, conn := g.NewFakeContext( + tc.uri, + nil, + WithFakeReader(tc.reader), + ) + g.ServeGemini(ctx) + is.Equal(tc.expect, conn.Written) + }) + } +} + +func TestTitanRedirect(t *testing.T) { + tests := []struct { + name string + url string + result string + wantErr bool + }{ + { + name: "good url", + url: "titan://f.org/raw/Test;token=hello;mime=plain/text;size=10", + result: "gemini://f.org/raw/Test", + }, + { + name: "no fragments", + url: "titan://f.org/foo", + result: "gemini://f.org/foo", + wantErr: false, + }, + { + name: "bad fragments", + url: "titan://f.org/foo;a=1;b=2", + result: "gemini://f.org/foo", + wantErr: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + is := is.New(t) + url, err := url.Parse(tc.url) + is.NoErr(err) + err = titanURLtoGemini(url) + if !tc.wantErr { + is.NoErr(err) + is.Equal(tc.result, url.String()) + } else { + t.Log(url) + is.True(err != nil) + } + }) + } +}