Skip to content

Commit 13ff345

Browse files
committed
add titan middleware
1 parent 42999c4 commit 13ff345

File tree

2 files changed

+323
-0
lines changed

2 files changed

+323
-0
lines changed

titan.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package gig
2+
3+
import (
4+
"errors"
5+
"io"
6+
"net/url"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
const (
12+
titanScheme = "titan"
13+
)
14+
15+
type titanParams struct {
16+
token string
17+
mime string
18+
size int
19+
}
20+
21+
func newTitanParams(url *url.URL) (p titanParams) {
22+
fragments := strings.Split(url.Path, ";")
23+
for i := range fragments {
24+
kv := strings.SplitN(fragments[i], "=", 2)
25+
if len(kv) != 2 {
26+
continue
27+
}
28+
29+
switch {
30+
case kv[0] == "token":
31+
p.token = kv[1]
32+
case kv[0] == "mime":
33+
p.mime = kv[1]
34+
case kv[0] == "size":
35+
if v, err := strconv.Atoi(kv[1]); err == nil {
36+
p.size = v
37+
}
38+
}
39+
}
40+
41+
return
42+
}
43+
44+
// Titan returns a middleware that implements Titan protocol request parsing
45+
// and validation. To limit size of uploaded files set sizeLimit to value
46+
// greater than 0.
47+
func Titan(sizeLimit int) MiddlewareFunc {
48+
return func(next HandlerFunc) HandlerFunc {
49+
return func(c Context) error {
50+
switch c.URL().Scheme {
51+
case titanScheme:
52+
c.Set("titan", true)
53+
54+
// Parameters
55+
params := newTitanParams(c.URL())
56+
57+
if params.size <= 0 {
58+
return c.NoContent(StatusBadRequest, "Size parameter is incorrect or not provided")
59+
}
60+
61+
if sizeLimit > 0 && sizeLimit < params.size {
62+
return c.NoContent(StatusBadRequest, "Request is bigger than allowed %d bytes", sizeLimit)
63+
}
64+
65+
c.Set("size", params.size)
66+
c.Set("token", params.token)
67+
c.Set("mime", params.mime)
68+
default:
69+
c.Set("titan", false)
70+
}
71+
72+
return next(c)
73+
}
74+
}
75+
}
76+
77+
// titanURLtoGemini strips Titan parameters and changes scheme to gemini.
78+
func titanURLtoGemini(url *url.URL) error {
79+
fragments := strings.Split(url.Path, ";")
80+
if len(fragments) == 0 {
81+
return errors.New("failed to create redirect URL")
82+
}
83+
84+
url.Scheme = "gemini"
85+
url.Path = fragments[0]
86+
87+
return nil
88+
}
89+
90+
// TitanRedirect is utility that redirects client to matching Gemini resource
91+
// after successful upload. It changes scheme to gemini and removes Titan
92+
// parameters from URL path.
93+
func TitanRedirect(c Context) error {
94+
url := c.URL()
95+
if err := titanURLtoGemini(url); err != nil {
96+
return c.NoContent(StatusPermanentFailure, err.Error())
97+
}
98+
99+
return c.NoContent(StatusRedirectTemporary, url.String())
100+
}
101+
102+
// TitanReadFull is utility wrapper that allocates new buffer and reads
103+
// Titan request's content into it.
104+
//
105+
// To store large file on disk directly methods like io.CopyN are preferable.
106+
func TitanReadFull(c Context) ([]byte, error) {
107+
size := c.Get("size").(int)
108+
buffer := make([]byte, size)
109+
110+
var err error
111+
if r := c.Reader(); r != nil {
112+
_, err = io.ReadFull(c.Reader(), buffer)
113+
} else {
114+
err = errors.New("context reader is nil")
115+
}
116+
117+
return buffer, err
118+
}

titan_test.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package gig
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"io"
7+
"net/url"
8+
"testing"
9+
10+
"github.com/matryer/is"
11+
)
12+
13+
func TestTitanURLParser(t *testing.T) {
14+
is := is.New(t)
15+
tests := []struct {
16+
url string
17+
token string
18+
mime string
19+
size int
20+
}{
21+
{"titan://f.org/raw/Test;token=hello;mime=plain/text;size=10", "hello", "plain/text", 10},
22+
{"titan://f.org/;mime=plain/text;size=10", "", "plain/text", 10},
23+
{"titan://f.org", "", "", 0},
24+
}
25+
26+
for _, tc := range tests {
27+
url, err := url.Parse(tc.url)
28+
is.NoErr(err)
29+
30+
got := newTitanParams(url)
31+
is.Equal(got, titanParams{
32+
token: tc.token,
33+
mime: tc.mime,
34+
size: tc.size,
35+
})
36+
}
37+
}
38+
39+
func TestTitanRequest(t *testing.T) {
40+
tests := []struct {
41+
name string
42+
uri string
43+
reader io.Reader
44+
sizeLimit int
45+
expect string
46+
handlerHook func(Context, *testing.T) error
47+
}{
48+
// Size param tests
49+
{
50+
name: "no size",
51+
uri: "titan://a.b",
52+
reader: nil,
53+
expect: "59 Size parameter is incorrect or not provided\r\n",
54+
},
55+
{
56+
name: "wrong size",
57+
uri: "titan://a.b;size=-1",
58+
reader: nil,
59+
expect: "59 Size parameter is incorrect or not provided\r\n",
60+
},
61+
{
62+
name: "size is not a number",
63+
uri: "titan://a.b;size=foo",
64+
reader: nil,
65+
expect: "59 Size parameter is incorrect or not provided\r\n",
66+
},
67+
{
68+
name: "zero size",
69+
uri: "titan://a.b;size=0",
70+
reader: nil,
71+
expect: "59 Size parameter is incorrect or not provided\r\n",
72+
},
73+
{
74+
name: "size provided",
75+
uri: "titan://a.b/;size=30",
76+
handlerHook: func(c Context, t *testing.T) error {
77+
is := is.New(t)
78+
is.Equal(c.Get("titan"), true)
79+
is.Equal(c.Get("size").(int), 30)
80+
return nil
81+
},
82+
},
83+
{
84+
name: "size bigger than size limit",
85+
uri: "titan://a.b/;size=10",
86+
expect: "59 Request is bigger than allowed 5 bytes\r\n",
87+
sizeLimit: 5,
88+
},
89+
{
90+
name: "gemini request on titan enabled endpoint",
91+
uri: "gemini://a.b/",
92+
handlerHook: func(c Context, t *testing.T) error {
93+
is := is.New(t)
94+
is.Equal(c.Get("titan"), false)
95+
return nil
96+
},
97+
},
98+
{
99+
name: "read correct ammout of data",
100+
uri: "titan://a.b/;size=10",
101+
reader: bytes.NewBuffer(make([]byte, 10)),
102+
handlerHook: func(c Context, t *testing.T) error {
103+
is := is.New(t)
104+
b, err := TitanReadFull(c)
105+
is.NoErr(err)
106+
is.True(b != nil)
107+
is.Equal(len(b), 10)
108+
return nil
109+
},
110+
},
111+
{
112+
name: "read underflow",
113+
uri: "titan://a.b/;size=5",
114+
reader: bytes.NewBuffer([]byte{1, 2, 3}),
115+
handlerHook: func(c Context, t *testing.T) error {
116+
is := is.New(t)
117+
b, err := TitanReadFull(c)
118+
is.True(errors.Is(err, io.ErrUnexpectedEOF))
119+
is.True(b != nil)
120+
is.Equal(len(b), 5)
121+
is.Equal(b, []byte{1, 2, 3, 0, 0})
122+
return nil
123+
},
124+
},
125+
{
126+
name: "stop reading at size",
127+
uri: "titan://a.b/;size=3",
128+
reader: bytes.NewBuffer([]byte{1, 2, 3, 4, 5}),
129+
handlerHook: func(c Context, t *testing.T) error {
130+
is := is.New(t)
131+
b, err := TitanReadFull(c)
132+
is.NoErr(err)
133+
is.True(b != nil)
134+
is.Equal(len(b), 3)
135+
is.Equal(b, []byte{1, 2, 3})
136+
return nil
137+
},
138+
},
139+
}
140+
141+
for _, tc := range tests {
142+
t.Run(tc.name, func(t *testing.T) {
143+
is := is.New(t)
144+
g := New()
145+
g.Handle("/*", func(c Context) error {
146+
if tc.handlerHook != nil {
147+
return tc.handlerHook(c, t)
148+
}
149+
return nil
150+
})
151+
g.Use(Titan(tc.sizeLimit))
152+
ctx, conn := g.NewFakeContext(
153+
tc.uri,
154+
nil,
155+
WithFakeReader(tc.reader),
156+
)
157+
g.ServeGemini(ctx)
158+
is.Equal(tc.expect, conn.Written)
159+
})
160+
}
161+
}
162+
163+
func TestTitanRedirect(t *testing.T) {
164+
tests := []struct {
165+
name string
166+
url string
167+
result string
168+
wantErr bool
169+
}{
170+
{
171+
name: "good url",
172+
url: "titan://f.org/raw/Test;token=hello;mime=plain/text;size=10",
173+
result: "gemini://f.org/raw/Test",
174+
},
175+
{
176+
name: "no fragments",
177+
url: "titan://f.org/foo",
178+
result: "gemini://f.org/foo",
179+
wantErr: false,
180+
},
181+
{
182+
name: "bad fragments",
183+
url: "titan://f.org/foo;a=1;b=2",
184+
result: "gemini://f.org/foo",
185+
wantErr: false,
186+
},
187+
}
188+
189+
for _, tc := range tests {
190+
tc := tc
191+
t.Run(tc.name, func(t *testing.T) {
192+
is := is.New(t)
193+
url, err := url.Parse(tc.url)
194+
is.NoErr(err)
195+
err = titanURLtoGemini(url)
196+
if !tc.wantErr {
197+
is.NoErr(err)
198+
is.Equal(tc.result, url.String())
199+
} else {
200+
t.Log(url)
201+
is.True(err != nil)
202+
}
203+
})
204+
}
205+
}

0 commit comments

Comments
 (0)