├── .github ├── ISSUE_TEMPLATE.md ├── stale.yml └── workflows │ ├── golangci-lint.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── _fixture ├── certs │ ├── cert.pem │ └── key.pem ├── folder │ ├── .hidden │ ├── about.gmi │ └── another.blah ├── images │ └── walle.png └── index.gmi ├── cert_auth.go ├── cert_auth_test.go ├── context.go ├── context_test.go ├── debug.go ├── examples ├── README.md ├── astro.crt ├── astro.key ├── astrobotany.go └── astrobotany │ ├── index.gmi │ └── instructions.gmi ├── gig.go ├── gig_test.go ├── gigtest.go ├── gigtest_test.go ├── go.mod ├── go.sum ├── group.go ├── group_test.go ├── logger.go ├── logger_test.go ├── middleware.go ├── pass_auth.go ├── pass_auth_test.go ├── recover.go ├── recover_test.go ├── response.go ├── response_test.go ├── router.go ├── router_test.go ├── serve_test.go └── status.go /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Issue Description 2 | 3 | ### Checklist 4 | 5 | - [ ] Dependencies installed 6 | - [ ] No typos 7 | - [ ] Searched existing issues and docs 8 | 9 | ### Expected behaviour 10 | 11 | ### Actual behaviour 12 | 13 | ### Steps to reproduce 14 | 15 | ### Working code to debug 16 | 17 | ```go 18 | package main 19 | 20 | func main() { 21 | } 22 | ``` 23 | 24 | ### Version/commit 25 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - master 8 | pull_request: 9 | jobs: 10 | golangci: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Run golangci-lint 16 | uses: golangci/golangci-lint-action@v2.3.0 17 | with: 18 | version: v1.33 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Set up Go 1.x 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: ^1.13 20 | 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v2 23 | 24 | - name: Get dependencies 25 | run: | 26 | go get -v -t -d ./... 27 | 28 | - name: Install Dependencies 29 | run: go get -v golang.org/x/lint/golint 30 | 31 | - name: Build 32 | run: go build -v ./... 33 | 34 | - name: Test 35 | run: | 36 | go test --coverprofile=coverage.coverprofile --covermode=atomic . 37 | go test -race . 38 | golint -set_exit_status . 39 | 40 | - name: Upload coverage to Codecov 41 | uses: codecov/codecov-action@v1.1.1 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage.txt 3 | _test 4 | vendor 5 | .idea 6 | *.iml 7 | *.out 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Until June 2020 LabStack 4 | Copyright (c) 2020 Peter Vernigorov 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | coverage.txt: 2 | go test -coverprofile=coverage.txt -covermode=atomic -timeout 5s . 3 | 4 | test: coverage.txt 5 | golint -set_exit_status . 6 | go test -race . 7 | golangci-lint run -E asciicheck -E bodyclose -E depguard -E dogsled -E dupl -E gochecknoinits -E goconst -E gocritic -E godot -E godox -E gofmt -E goimports -E revive -E gomodguard -E goprintffuncname -E misspell -E nolintlint -E prealloc -E rowserrcheck -E stylecheck -E unconvert -E unparam -E whitespace -E wsl 8 | 9 | bench: 10 | go test -run=nothing -bench=. 11 | 12 | html: coverage.txt 13 | go tool cover -html=coverage.txt 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gig - Gemini framework 2 | 3 | [![Used By](https://img.shields.io/badge/used%20by-5%2B%20projects-brightgreen)](#who-uses-gig) 4 | [![godocs.io](https://godocs.io/github.com/pitr/gig?status.svg)](https://godocs.io/github.com/pitr/gig) 5 | [![GoDoc](http://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/github.com/pitr/gig) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/pitr/gig?style=flat-square)](https://goreportcard.com/report/github.com/pitr/gig) 7 | [![Codecov](https://img.shields.io/codecov/c/github/pitr/gig.svg?style=flat-square)](https://codecov.io/gh/pitr/gig) 8 | [![License](http://img.shields.io/badge/license-mit-blue.svg?style=flat-square)](https://raw.githubusercontent.com/pitr/gig/master/LICENSE) 9 | 10 | API is subject to change until v1.0 11 | 12 | ## Protocol compatibility 13 | 14 | | Version | Supported Gemini version | 15 | | ------- | ------------------------ | 16 | | 0.9.4 | v0.14.* | 17 | | < 0.9.4 | v0.13.* | 18 | 19 | ## Contents 20 | 21 | * [Feature Overview](#feature-overview) 22 | * [Guide](#guide) 23 | * [Quick Start](#quick-start) 24 | * [Parameters in path](#parameters-in-path) 25 | * [Query](#query) 26 | * [Client Certificate](#client-certificate) 27 | * [Grouping routes](#grouping-routes) 28 | * [Blank Gig without middleware by default](#blank-gig-without-middleware-by-default) 29 | * [Using middleware](#using-middleware) 30 | * [Writing logs to file](#writing-logs-to-file) 31 | * [Custom Log Format](#custom-log-format) 32 | * [Serving static files](#serving-static-files) 33 | * [Serving data from file](#serving-data-from-file) 34 | * [Serving data from reader](#serving-data-from-reader) 35 | * [Templates](#templates) 36 | * [Redirects](#redirects) 37 | * [Subdomains](#subdomains) 38 | * [Username/password authentication middleware](#usernamepassword-authentication-middleware) 39 | * [Custom middleware](#custom-middleware) 40 | * [Custom port](#custom-port) 41 | * [Custom TLS config](#custom-tls-config) 42 | * [Testing](#testing) 43 | * [Who uses Gig](#who-uses-gig) 44 | * [Benchmarks](#benchmarks) 45 | * [Contribute](#contribute) 46 | * [License](#license) 47 | 48 | ## Feature Overview 49 | 50 | - Client certificate suppport (access `x509.Certificate` directly from context) 51 | - Highly optimized router with zero dynamic memory allocation which smartly prioritizes routes 52 | - Group APIs 53 | - Extensible middleware framework 54 | - Define middleware at root, group or route level 55 | - Handy functions to send variety of Gemini responses 56 | - Centralized error handling 57 | - Template rendering with any template engine 58 | - Define your format for the logger 59 | - Highly customizable 60 | 61 | ## Guide 62 | 63 | ### Quick Start 64 | 65 | ```go 66 | package main 67 | 68 | import "github.com/pitr/gig" 69 | 70 | func main() { 71 | // Gig instance 72 | g := gig.Default() 73 | 74 | // Routes 75 | g.Handle("/", func(c gig.Context) error { 76 | return c.Gemini("# Hello, World!") 77 | }) 78 | 79 | // Start server on PORT or default port 80 | g.Run("my.crt", "my.key") 81 | } 82 | ``` 83 | ```bash 84 | $ go run main.go 85 | ``` 86 | 87 | ### Parameters in path 88 | 89 | ```go 90 | package main 91 | 92 | import "github.com/pitr/gig" 93 | 94 | func main() { 95 | g := gig.Default() 96 | 97 | g.Handle("/user/:name", func(c gig.Context) error { 98 | return c.Gemini("# Hello, %s!", c.Param("name")) 99 | }) 100 | 101 | g.Run("my.crt", "my.key") 102 | } 103 | ``` 104 | 105 | ### Query 106 | 107 | ```go 108 | package main 109 | 110 | import "github.com/pitr/gig" 111 | 112 | func main() { 113 | g := gig.Default() 114 | 115 | g.Handle("/user", func(c gig.Context) error { 116 | query, err := c.QueryString() 117 | if err != nil { 118 | return err 119 | } 120 | return c.Gemini("# Hello, %s!", query) 121 | }) 122 | 123 | g.Run("my.crt", "my.key") 124 | } 125 | ``` 126 | 127 | ### Client Certificate 128 | 129 | ```go 130 | package main 131 | 132 | import "github.com/pitr/gig" 133 | 134 | func main() { 135 | g := gig.Default() 136 | 137 | g.Handle("/user", func(c gig.Context) error { 138 | cert := c.Certificate() 139 | if cert == nil { 140 | return c.NoContent(gig.StatusClientCertificateRequired, "We need a certificate") 141 | } 142 | return c.Gemini("# Hello, %s!", cert.Subject.CommonName) 143 | }) 144 | 145 | // OR using middleware 146 | 147 | g.Handle("/user", func(c gig.Context) error { 148 | return c.Gemini("# Hello, %s!", c.Get("subject")) 149 | }, gig.CertAuth(gig.ValidateHasCertificate)) 150 | 151 | g.Run("my.crt", "my.key") 152 | } 153 | ``` 154 | 155 | ### Grouping routes 156 | ```go 157 | func main() { 158 | g := gig.Default() 159 | 160 | // Simple group: v1 161 | v1 := g.Group("/v1") 162 | { 163 | v1.Handle("/page1", page1Endpoint) 164 | v1.Handle("/page2", page2Endpoint) 165 | } 166 | 167 | // Simple group: v2 168 | v2 := g.Group("/v2") 169 | { 170 | v2.Handle("/page1", page1Endpoint) 171 | v2.Handle("/page2", page2Endpoint) 172 | } 173 | 174 | g.Run("my.crt", "my.key") 175 | } 176 | ``` 177 | 178 | ### Blank Gig without middleware by default 179 | Use 180 | ```go 181 | g := gig.New() 182 | ``` 183 | instead of 184 | ```go 185 | // Default With the Logger and Recovery middleware already attached 186 | g := gig.Default() 187 | ``` 188 | 189 | ### Using middleware 190 | ```go 191 | func main() { 192 | // Creates a router without any middleware by default 193 | g := gig.New() 194 | 195 | // Global middleware 196 | // Logger middleware will write the logs to gig.DefaultWriter. 197 | // By default gig.DefaultWriter = os.Stdout 198 | g.Use(gig.Logger()) 199 | 200 | // Recovery middleware recovers from any panics and return StatusPermanentFailure. 201 | g.Use(gig.Recovery()) 202 | 203 | // Private group 204 | // same as private := g.Group("/private", gig.CertAuth(gig.ValidateHasCertificate)) 205 | private := g.Group("/private") 206 | private.Use(gig.CertAuth(gig.ValidateHasCertificate)) 207 | { 208 | private.Handle("/user", userEndpoint) 209 | } 210 | 211 | g.Run("my.crt", "my.key") 212 | } 213 | ``` 214 | 215 | ### Writing logs to file 216 | ```go 217 | func main() { 218 | f, _ := os.Create("access.log") 219 | gig.DefaultWriter = io.MultiWriter(f) 220 | 221 | // Use the following code if you need to write the logs to file and console at the same time. 222 | // gig.DefaultWriter = io.MultiWriter(f, os.Stdout) 223 | 224 | g := gig.Default() 225 | 226 | g.Handle("/", func(c gig.Context) error { 227 | return c.Gemini("# Hello, World!") 228 | }) 229 | 230 | g.Run("my.crt", "my.key") 231 | } 232 | ``` 233 | 234 | ### Custom Log Format 235 | ```go 236 | func main() { 237 | g := gig.New() 238 | 239 | // See LoggerConfig documentation for format 240 | g.Use(gig.LoggerWithConfig(gig.LoggerConfig{Format: "${remote_ip} ${status}"})) 241 | 242 | g.Handle("/", func(c gig.Context) error { 243 | return c.Gemini("# Hello, World!") 244 | }) 245 | 246 | g.Run("my.crt", "my.key") 247 | } 248 | ``` 249 | 250 | ### Serving static files 251 | ```go 252 | func main() { 253 | g := gig.Default() 254 | 255 | // Register /images/* to serve files in my_images/ folder. 256 | // Requests to /images/ will show directory listing. 257 | g.Static("/images", "my_images") 258 | 259 | g.File("/robots.txt", "files/robots.txt") 260 | 261 | g.Run("my.crt", "my.key") 262 | } 263 | ``` 264 | 265 | ### Serving data from file 266 | ```go 267 | func main() { 268 | g := gig.Default() 269 | 270 | g.Handle("/robots.txt", func(c gig.Context) error { 271 | return c.File("robots.txt") 272 | }) 273 | 274 | g.Run("my.crt", "my.key") 275 | } 276 | ``` 277 | 278 | ### Serving data from reader 279 | ```go 280 | func main() { 281 | g := gig.Default() 282 | 283 | g.Handle("/data", func(c gig.Context) error { 284 | response, err := http.Get("https://google.com/") 285 | if err != nil || response.StatusCode != http.StatusOK { 286 | return c.NoContent(gig.StatusProxyError, "could not fetch google") 287 | } 288 | 289 | return c.Stream("text/html", response.Body) 290 | }) 291 | 292 | g.Run("my.crt", "my.key") 293 | } 294 | ``` 295 | 296 | ### Templates 297 | 298 | Set `Gig.Renderer` to something that responds to `Render(io.Writer, string, interface{}, gig.Context) error`. 299 | 300 | Use any templating library, such as `text/template`, [https://github.com/valyala/quicktemplate](https://github.com/valyala/quicktemplate), etc. The following example uses `text/template`: 301 | 302 | ```go 303 | import ( 304 | "text/template" 305 | 306 | "github.com/pitr/gig" 307 | ) 308 | 309 | type Template struct { 310 | templates *template.Template 311 | } 312 | 313 | func (t *Template) Render(w io.Writer, name string, data interface{}, c gig.Context) error { 314 | // Execute named template with data 315 | return t.templates.ExecuteTemplate(w, name, data) 316 | } 317 | 318 | func main() { 319 | g := gig.Default() 320 | 321 | // Register renderer 322 | g.Renderer = &Template{template.Must(template.ParseGlob("public/views/*.gmi"))} 323 | 324 | g.Handle("/user/:name", func(c gig.Context) error { 325 | // Render template "user" with username passed as data. 326 | return c.Render("user", c.Param("name")) 327 | }) 328 | 329 | g.Run("my.crt", "my.key") 330 | } 331 | ``` 332 | 333 | Consider bundling assets with the binary by using [go:ember](https://tip.golang.org/pkg/embed/), [go-assets](https://github.com/jessevdk/go-assets) or similar. 334 | 335 | ### Redirects 336 | ```go 337 | func main() { 338 | g := gig.Default() 339 | 340 | g.Handle("/old", func(c gig.Context) error { 341 | return c.NoContent(gig.StatusRedirectPermanent, "/new") 342 | }) 343 | 344 | g.Run("my.crt", "my.key") 345 | } 346 | ``` 347 | 348 | ### Subdomains 349 | 350 | ```go 351 | func main() { 352 | apps := map[string]*gig.Gig{} 353 | 354 | // App A 355 | a := gig.Default() 356 | apps["app-a.example.com"] = a 357 | 358 | a.Handle("/", func(c gig.Context) error { 359 | return c.Gemini("I am App A") 360 | }) 361 | 362 | // App B 363 | b := gig.Default() 364 | apps["app-b.example.com"] = b 365 | 366 | b.Handle("/", func(c gig.Context) error { 367 | return c.Gemini("I am App B") 368 | }) 369 | 370 | // Server (without default middleware to prevent double logging) 371 | g := gig.New() 372 | g.Handle("/*", func(c gig.Context) error { 373 | app := apps[c.URL().Host] 374 | 375 | if app == nil { 376 | return gig.ErrNotFound 377 | } 378 | 379 | app.ServeGemini(c) 380 | return nil 381 | }) 382 | 383 | g.Run("my.crt", "my.key") // must be wildcard SSL certificate for *.example.com 384 | } 385 | ``` 386 | 387 | ### Username/password authentication middleware 388 | 389 | Status: EXPERIMENTAL 390 | 391 | `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. 392 | 393 | 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. 394 | 395 | User registration is expected to be implemented by developer. 396 | 397 | The example assumes that there is a `db` module that does user management. 398 | 399 | ```go 400 | func main() { 401 | g := gig.Default() 402 | 403 | secret := g.Group("/secret", gig.PassAuth(func(sig string, c gig.Context) (string, error) { 404 | ok, err := db.CheckValid(sig) 405 | if err != nil { 406 | return "/login", err 407 | } 408 | if !ok { 409 | return "/login", nil 410 | } 411 | return "", nil 412 | })) 413 | // secret.Handle("/page", func(c gig.Context) {...}) 414 | 415 | g.PassAuthLoginHandle("/login", func(user, pass, sig string, c Context) (string, error) { 416 | // check user/pass combo, and activate cert signature if valid 417 | err := db.Login(user, pass, sig) 418 | if err != nil { 419 | return "", err 420 | } 421 | return "/secret/page", nil 422 | }) 423 | 424 | g.Run("my.crt", "my.key") 425 | } 426 | ``` 427 | 428 | ### Custom middleware 429 | ```go 430 | func MyMiddleware(next gig.HandlerFunc) gig.HandlerFunc { 431 | return func(c gig.Context) error { 432 | // Set example variable 433 | c.Set("example", "123") 434 | 435 | if err := next(c); err != nil { 436 | c.Error(err) 437 | } 438 | 439 | // Do something after request is done 440 | // ... 441 | 442 | return err 443 | } 444 | } 445 | 446 | func main() { 447 | g := gig.Default() 448 | g.Use(MyMiddleware) 449 | 450 | g.Handle("/", func(c gig.Context) error { 451 | return c.Gemini("# Example %s", c.Get("example")) 452 | }) 453 | 454 | g.Run("my.crt", "my.key") 455 | } 456 | ``` 457 | 458 | ### Custom port 459 | 460 | Use `PORT` environment variable: 461 | 462 | ``` 463 | PORT=12345 ./myapp 464 | ``` 465 | 466 | Alternatively, pass it to Run: 467 | 468 | ```go 469 | func main() { 470 | g := gig.Default() 471 | 472 | g.Handle("/", func(c gig.Context) error { 473 | return c.Gemini("# Hello world") 474 | }) 475 | 476 | g.Run(":12345", "my.crt", "my.key") 477 | } 478 | ``` 479 | 480 | ### Custom TLS config 481 | ```go 482 | func main() { 483 | g := gig.Default() 484 | g.TLSConfig.MinVersion = tls.VersionTLS13 485 | 486 | g.Handle("/", func(c gig.Context) error { 487 | return c.Gemini("# Hello world") 488 | }) 489 | 490 | g.Run("my.crt", "my.key") 491 | } 492 | ``` 493 | 494 | ### Testing 495 | ```go 496 | func setupServer() *gig.Gig { 497 | g := gig.Default() 498 | 499 | g.Handle("/private", func(c gig.Context) error { 500 | return c.Gemini("Hello %s", c.Get("subject")) 501 | }, gig.CertAuth(gig.ValidateHasCertificate)) 502 | 503 | return g 504 | } 505 | 506 | func TestServer(t *testing.T) { 507 | g := setupServer() 508 | c, res := g.NewFakeContext("/private", nil) 509 | 510 | g.ServeGemini(c) 511 | 512 | if res.Written != "60 Client Certificate Required\r\n" { 513 | t.Fail() 514 | } 515 | } 516 | 517 | func TestCertificate(t *testing.T) { 518 | g := setupServer() 519 | c, res := g.NewFakeContext("/", &tls.ConnectionState{ 520 | PeerCertificates: []*x509.Certificate{ 521 | {Subject: pkix.Name{CommonName: "john"}}, 522 | }, 523 | }) 524 | 525 | g.ServeGemini(c) 526 | 527 | if resp.Written != "20 text/gemini\r\nHello john" { 528 | t.Fail() 529 | } 530 | } 531 | ``` 532 | 533 | ## Who uses Gig 534 | 535 | Gig is used by the following capsules: 536 | 537 | - [gemif.fedi.farm](https://portal.mozz.us/gemini/gemif.fedi.farm) - GemIf, Interactive Fiction engine 538 | - [geddit.glv.one](https://portal.mozz.us/gemini/geddit.glv.one) - Link aggregator 539 | - [wp.glv.one](https://portal.mozz.us/gemini/wp.glv.one) - Wikipedia proxy 540 | - [egsam.glv.one](https://portal.mozz.us/gemini/egsam.glv.one) - Egsam, client torture test 541 | - [paste.gemigrep.com](https://portal.mozz.us/gemini/paste.gemigrep.com) - Paste service 542 | - [gemini.tunerapp.org](https://portal.mozz.us/gemini/gemini.tunerapp.org) - Internet Radio Stations Directory 543 | - [pon.ix.tc](https://portal.mozz.us/gemini/pon.ix.tc) - YouTube Proxy and other utiltiies 544 | - [gemfic.xyz](https://gemfic.xyz) - Fiction hub (Offprint proxy) 545 | 546 | If you use Gig, open a PR to add your capsule to this list. 547 | 548 | ## Benchmarks 549 | 550 | | Benchmark name | (1) | (2) | (3) | (4) | 551 | | ------------------------------ | --------:| -------------:| ----------:| --------------:| 552 | | BenchmarkRouterStaticRoutes | 104677 | 11105 ns/op | 0 B/op | 0 allocs/op | 553 | | BenchmarkRouterGitHubAPI | 50859 | 22973 ns/op | 0 B/op | 0 allocs/op | 554 | | BenchmarkRouterParseAPI | 302828 | 3717 ns/op | 0 B/op | 0 allocs/op | 555 | | BenchmarkRouterGooglePlusAPI | 185558 | 6136 ns/op | 0 B/op | 0 allocs/op | 556 | 557 | Generated using `make bench` in [router_test.go](https://github.com/pitr/gig/blob/master/router_test.go). APIs are based on [Go HTTP Router Benchmark repository](https://github.com/gin-gonic/go-http-routing-benchmark) and adapted to Gemini protocol, eg. verbs 558 | GET/POST/etc are ignored since Gemini does not support them. 559 | 560 | - (1): Total Repetitions achieved in constant time, higher means more confident result 561 | - (2): Single Repetition Duration (ns/op), lower is better 562 | - (3): Heap Memory (B/op), lower is better 563 | - (4): Average Allocations per Repetition (allocs/op), lower is better 564 | 565 | ## Contribute 566 | 567 | If something is missing, please open an issue. If possible, send a PR. 568 | 569 | ## License 570 | 571 | [MIT](https://github.com/pitr/gig/blob/master/LICENSE) 572 | -------------------------------------------------------------------------------- /_fixture/certs/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC+TCCAeGgAwIBAgIQe/dw9alKTWAPhsHoLdkn+TANBgkqhkiG9w0BAQsFADAS 3 | MRAwDgYDVQQKEwdBY21lIENvMB4XDTE2MDkyNTAwNDcxN1oXDTE3MDkyNTAwNDcx 4 | N1owEjEQMA4GA1UEChMHQWNtZSBDbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC 5 | AQoCggEBAL8WwhLGbK8HkiEDKV0JbjtWp3/EWKhKFW3YtKtPfPOgoZejdNn9VE0B 6 | IlQ4rwa1wmsM9NDKC0m60oiNOYeyugx9PoFI3RXzuKVX2x7E5LTW0sv0LC9PCggZ 7 | MZTih1AiYtwJIZl+aK6s4dTb/PUOLDdcRTZTF2egkdAicbUlQT4Kn+A3jHiE+ATC 8 | h3MlV2BHarhAhWb0FrOg2bEtFrMyFDaLbHI7xbj+vB9CkGB9L5tObP2M9lQCxH8d 9 | ElWkJjxg7vdkhJ5+sWNaY80utNipUdVO845tIERwRXRRviFYpOcuNfnJYC9kwRjv 10 | CRanh3epWhG0cFQVV5d45sHf6t5F+jsCAwEAAaNLMEkwDgYDVR0PAQH/BAQDAgWg 11 | MBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwFAYDVR0RBA0wC4IJ 12 | bG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4IBAQAdd3ZW6R4cImmxIzfoz7Ttq862 13 | oOiyzFnisCxgNdA78epit49zg0CgF7q9guTEArXJLI+/qnjPPObPOlTlsEyomb2F 14 | UOS+2hn/ZyU5/tUxhkeOBYqdEaryk6zF6vPLUJ5IphJgOg00uIQGL0UvupBLEyIG 15 | Rsa/lKEtW5Z9PbIi9GeVn51U+9VMCYft/T7SDziKl7OcE/qoVh1G0/tTRkAqOqpZ 16 | bzc8ssEhJVNZ/DO+uYHNYf/waB6NjfXQuTegU/SyxnawvQ4oBHIzyuWplGCcTlfT 17 | IXsOQdJo2xuu8807d+rO1FpN8yWi5OF/0sif0RrocSskLAIL/PI1qfWuuPck 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /_fixture/certs/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAvxbCEsZsrweSIQMpXQluO1anf8RYqEoVbdi0q09886Chl6N0 3 | 2f1UTQEiVDivBrXCawz00MoLSbrSiI05h7K6DH0+gUjdFfO4pVfbHsTktNbSy/Qs 4 | L08KCBkxlOKHUCJi3AkhmX5orqzh1Nv89Q4sN1xFNlMXZ6CR0CJxtSVBPgqf4DeM 5 | eIT4BMKHcyVXYEdquECFZvQWs6DZsS0WszIUNotscjvFuP68H0KQYH0vm05s/Yz2 6 | VALEfx0SVaQmPGDu92SEnn6xY1pjzS602KlR1U7zjm0gRHBFdFG+IVik5y41+clg 7 | L2TBGO8JFqeHd6laEbRwVBVXl3jmwd/q3kX6OwIDAQABAoIBAQCR69EcAUZxinh+ 8 | mSl3EIKK8atLGCcTrC8dCQU+ZJ7odFuxrnLHHHrJqvoKEpclqprioKw63G8uSGoJ 9 | OL8b7tHAQ8v9ciTSZKE2Mhb0MirsJbgnYzhykAr7EDIanbny6a9Qk/CChFNwQDjc 10 | EXnjsIT3aZC44U7YJXfz1rm6OM7Pjn6z8H4vYGRDOsYkhXvPfnPW8C2LFJVr9nvE 11 | 0gIAOVoGejEJrsJVK3Uj/nPcqSQYXmwEmtjtzOw7u6yp1b2VZEK7tR47HwJt6ltG 12 | Z9zhpwhpvdOuXNMqMOYRf9bLBWnSqIlTHOO0UlAnyRCY1HxluZB7ZSg9VnoJDrD7 13 | w+JqAGnBAoGBAO5qyIzjldwR004YjepmZfuX3PnGLZhzhmTTC7Pl9gqv1TvxfxvD 14 | 6yBFL2GrN1IcnrX9Qk2xncUAbpM989MF+EC7I4++1t1I6akUKFEDkfvQwQjCXfPS 15 | Jv2rkwIVSkt8F0X/tOb13OeIiHuFVI/Bb9VoJSP/k4DfPV+/HnwBxvzLAoGBAM0u 16 | b/rYfm5rb20/PKClUs154s0eKSokVogqiJkf+5qLsV+TD50JVZBVw8s4XM79iwQI 17 | PyGY9nI1AvqG7yIzxSy5/Qk1+ZVdVYpmWIO5PnJ8TVraDVhCQ3fVz1uWtcyaqPVr 18 | 3QzdyvsEgFUGFItmRdhSvA8RGrpVCHTBzrDj3jpRAoGBAKNaSLS3jkstb3D3w+yR 19 | YliisYX1cfIdXTyhmUgWTKD/3oLmsSdt8iC3JoKt1AaPk3Kv5ojjJG0BIcIC1ZeF 20 | ZJW9Yt0vbXpKZcYyCHmRj6lQW6JLwiG3oH133A62VaQojq2oSONiG4wL8S9oqAqj 21 | B6PZanEiwIaw7hU3FoTylstHAoGAFYvE0pCdZjb98njrgusZcN5VxLhgFj7On2no 22 | AjxrjWUR8TleMF1kkM2Qy+xVQp85U+kRyBNp/cA3WduFjQ/mqrW1LpxuYxL0Ap6Q 23 | uPRg7GDFNr8jG5uJvjHDnpiK6rtq9qqnAczgnc9xMnx699B7kSXO/b4MEnkPdENN 24 | 0yF6mqECgYA88UELxbhqMSdG24DX0zHXvkXLIml2JNVb54glFByIIem+acff9oG9 25 | X5GajlBroPoKk7FgA9ouqcQMH66UnFi6qh07l0J2xb0aXP8yzLAGauVGTTNIQCR4 26 | VpqyDpjlc1ZqfZWOrvwSrUH1mEkxbeVvQsOUja2Jvu+lc3Zo099ILw== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /_fixture/folder/.hidden: -------------------------------------------------------------------------------- 1 | .hidden -------------------------------------------------------------------------------- /_fixture/folder/about.gmi: -------------------------------------------------------------------------------- 1 | # About page 2 | 3 | => / 🏠 Home 4 | -------------------------------------------------------------------------------- /_fixture/folder/another.blah: -------------------------------------------------------------------------------- 1 | # Another page -------------------------------------------------------------------------------- /_fixture/images/walle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pitr/gig/8fc65eefe05103fb2a53f31f59cddca54d9bbd25/_fixture/images/walle.png -------------------------------------------------------------------------------- /_fixture/index.gmi: -------------------------------------------------------------------------------- 1 | # Hello from gig 2 | 3 | => / 🏠 Home 4 | -------------------------------------------------------------------------------- /cert_auth.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "crypto/x509" 5 | ) 6 | 7 | type ( 8 | // CertAuthConfig defines the config for CertAuth middleware. 9 | CertAuthConfig struct { 10 | // Skipper defines a function to skip middleware. 11 | Skipper Skipper 12 | 13 | // Validator is a function to validate client certificate. 14 | // Required. 15 | Validator CertAuthValidator 16 | } 17 | 18 | // CertAuthValidator defines a function to validate CertAuth credentials. 19 | CertAuthValidator func(*x509.Certificate, Context) *GeminiError 20 | ) 21 | 22 | var ( 23 | // DefaultCertAuthConfig is the default CertAuth middleware config. 24 | DefaultCertAuthConfig = CertAuthConfig{ 25 | Skipper: DefaultSkipper, 26 | Validator: ValidateHasCertificate, 27 | } 28 | ) 29 | 30 | // ValidateHasCertificate returns ErrClientCertificateRequired if no certificate is sent. 31 | // It also stores subject name in context under "subject". 32 | func ValidateHasCertificate(cert *x509.Certificate, c Context) *GeminiError { 33 | if cert == nil { 34 | return ErrClientCertificateRequired 35 | } 36 | 37 | c.Set("subject", cert.Subject.CommonName) 38 | 39 | return nil 40 | } 41 | 42 | // CertAuth returns an CertAuth middleware. 43 | // 44 | // For valid credentials it calls the next handler. 45 | func CertAuth(fn CertAuthValidator) MiddlewareFunc { 46 | c := DefaultCertAuthConfig 47 | c.Validator = fn 48 | 49 | return CertAuthWithConfig(c) 50 | } 51 | 52 | // CertAuthWithConfig returns an CertAuth middleware with config. 53 | // See `CertAuth()`. 54 | func CertAuthWithConfig(config CertAuthConfig) MiddlewareFunc { 55 | // Defaults 56 | if config.Validator == nil { 57 | config.Validator = DefaultCertAuthConfig.Validator 58 | } 59 | 60 | if config.Skipper == nil { 61 | config.Skipper = DefaultCertAuthConfig.Skipper 62 | } 63 | 64 | return func(next HandlerFunc) HandlerFunc { 65 | return func(c Context) error { 66 | if config.Skipper(c) { 67 | return next(c) 68 | } 69 | 70 | // Verify credentials 71 | err := config.Validator(c.Certificate(), c) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | return next(c) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /cert_auth_test.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "crypto/x509/pkix" 7 | "testing" 8 | 9 | "github.com/matryer/is" 10 | ) 11 | 12 | func TestCertAuth(t *testing.T) { 13 | is := is.New(t) 14 | g := New() 15 | 16 | testCases := []struct { 17 | mw MiddlewareFunc 18 | expectedErrNoCert error 19 | expectedErrBadCert error 20 | name string 21 | }{ 22 | { 23 | mw: CertAuth(ValidateHasCertificate), 24 | expectedErrNoCert: ErrClientCertificateRequired, 25 | expectedErrBadCert: nil, 26 | name: `ValidateHasCertificate`, 27 | }, 28 | { 29 | mw: CertAuth(func(cert *x509.Certificate, c Context) *GeminiError { 30 | if cert == nil { 31 | return ErrClientCertificateRequired 32 | } 33 | 34 | if cert.Subject.CommonName != "tester" { 35 | return ErrCertificateNotValid 36 | } 37 | 38 | c.Set("subject", cert.Subject.CommonName) 39 | 40 | return nil 41 | }), 42 | expectedErrNoCert: ErrClientCertificateRequired, 43 | expectedErrBadCert: ErrCertificateNotValid, 44 | name: `CustomValidator`, 45 | }, 46 | { 47 | mw: CertAuthWithConfig(CertAuthConfig{ 48 | Skipper: nil, 49 | Validator: nil, 50 | }), 51 | expectedErrNoCert: ErrClientCertificateRequired, 52 | expectedErrBadCert: nil, 53 | name: `NilConfig`, 54 | }, 55 | { 56 | mw: CertAuthWithConfig(CertAuthConfig{ 57 | Skipper: func(c Context) bool { 58 | c.Set("subject", "tester") 59 | 60 | return true 61 | }, 62 | }), 63 | expectedErrNoCert: nil, 64 | expectedErrBadCert: nil, 65 | name: `CustomSkipper`, 66 | }, 67 | } 68 | 69 | for _, test := range testCases { 70 | test := test 71 | t.Run(test.name, func(t *testing.T) { 72 | h := test.mw(func(c Context) error { 73 | return c.Gemini("test") 74 | }) 75 | 76 | // No certificate 77 | c, _ := g.NewFakeContext("/", nil) 78 | is.Equal(h(c), test.expectedErrNoCert) 79 | 80 | // Invalid certificate 81 | c, _ = g.NewFakeContext("/", &tls.ConnectionState{ 82 | PeerCertificates: []*x509.Certificate{ 83 | {Subject: pkix.Name{CommonName: "not-tester"}}, 84 | }, 85 | }) 86 | is.Equal(h(c), test.expectedErrBadCert) 87 | 88 | // Valid certificate 89 | c, _ = g.NewFakeContext("/", &tls.ConnectionState{ 90 | PeerCertificates: []*x509.Certificate{ 91 | {Subject: pkix.Name{CommonName: "tester"}}, 92 | }, 93 | }) 94 | is.NoErr(h(c)) 95 | is.Equal("tester", c.Get("subject")) 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "mime" 11 | "net" 12 | "net/url" 13 | "os" 14 | "path" 15 | "path/filepath" 16 | "sort" 17 | "strings" 18 | "sync" 19 | ) 20 | 21 | type ( 22 | // Context represents the context of the current request. It holds connection 23 | // reference, path, path parameters, data and registered handler. 24 | // DO NOT retain Context instance, as it will be reused by other connections. 25 | Context interface { 26 | // Response returns `*Response`. 27 | Response() *Response 28 | 29 | // IP returns the client's network address. 30 | IP() string 31 | 32 | // Certificate returns client's leaf certificate or nil if none provided 33 | Certificate() *x509.Certificate 34 | 35 | // CertHash returns a hash of client's leaf certificate or empty string is none 36 | CertHash() string 37 | 38 | // URL returns the URL for the context. 39 | URL() *url.URL 40 | 41 | // Path returns the registered path for the handler. 42 | Path() string 43 | 44 | // QueryString returns unescaped URL query string or error if the raw query 45 | // could not be unescaped. Use Context#URL().RawQuery to get raw query string. 46 | QueryString() (string, error) 47 | 48 | // RequestURI is the unmodified URL string as sent by the client 49 | // to a server. Usually the URL() or Path() should be used instead. 50 | RequestURI() string 51 | 52 | // Param returns path parameter by name. 53 | Param(name string) string 54 | 55 | // Get retrieves data from the context. 56 | Get(key string) interface{} 57 | 58 | // Set saves data in the context. 59 | Set(key string, val interface{}) 60 | 61 | // Render renders a template with data and sends a text/gemini response with status 62 | // code Success. Renderer must be registered using `Gig.Renderer`. 63 | Render(name string, data interface{}) error 64 | 65 | // Gemini sends a text/gemini response with status code Success. 66 | Gemini(text string, args ...interface{}) error 67 | 68 | // GeminiBlob sends a text/gemini blob response with status code Success. 69 | GeminiBlob(b []byte) error 70 | 71 | // Text sends a text/plain response with status code Success. 72 | Text(format string, values ...interface{}) error 73 | 74 | // Blob sends a blob response with status code Success and content type. 75 | Blob(contentType string, b []byte) error 76 | 77 | // Stream sends a streaming response with status code Success and content type. 78 | Stream(contentType string, r io.Reader) error 79 | 80 | // File sends a response with the content of the file. 81 | File(file string) error 82 | 83 | // NoContent sends a response with no body, and a status code and meta field. 84 | // Use for any non-2x status codes 85 | NoContent(code Status, meta string, values ...interface{}) error 86 | 87 | // Error invokes the registered error handler. Generally used by middleware. 88 | Error(err error) 89 | 90 | // Handler returns the matched handler by router. 91 | Handler() HandlerFunc 92 | 93 | // Gig returns the `Gig` instance. 94 | Gig() *Gig 95 | } 96 | 97 | context struct { 98 | conn tlsconn 99 | TLS *tls.ConnectionState 100 | u *url.URL 101 | response *Response 102 | path string 103 | requestURI string 104 | pnames []string 105 | pvalues []string 106 | handler HandlerFunc 107 | store storeMap 108 | gig *Gig 109 | lock sync.RWMutex 110 | } 111 | ) 112 | 113 | const ( 114 | indexPage = "index.gmi" 115 | ) 116 | 117 | func (c *context) Response() *Response { 118 | return c.response 119 | } 120 | 121 | func (c *context) IP() string { 122 | ra, _, _ := net.SplitHostPort(c.conn.RemoteAddr().String()) 123 | return ra 124 | } 125 | 126 | func (c *context) Certificate() *x509.Certificate { 127 | if c.TLS == nil || len(c.TLS.PeerCertificates) == 0 { 128 | return nil 129 | } 130 | 131 | return c.TLS.PeerCertificates[0] 132 | } 133 | 134 | func (c *context) CertHash() string { 135 | cert := c.Certificate() 136 | if cert == nil { 137 | return "" 138 | } 139 | 140 | return fmt.Sprintf("%x", md5.Sum(cert.Raw)) 141 | } 142 | 143 | func (c *context) URL() *url.URL { 144 | return c.u 145 | } 146 | 147 | func (c *context) Path() string { 148 | return c.path 149 | } 150 | 151 | func (c *context) RequestURI() string { 152 | return c.requestURI 153 | } 154 | 155 | func (c *context) Param(name string) string { 156 | for i, n := range c.pnames { 157 | if i < len(c.pvalues) { 158 | if n == name { 159 | return c.pvalues[i] 160 | } 161 | } 162 | } 163 | 164 | return "" 165 | } 166 | 167 | func (c *context) QueryString() (string, error) { 168 | return url.QueryUnescape(c.u.RawQuery) 169 | } 170 | 171 | func (c *context) Get(key string) interface{} { 172 | c.lock.RLock() 173 | defer c.lock.RUnlock() 174 | 175 | return c.store[key] 176 | } 177 | 178 | func (c *context) Set(key string, val interface{}) { 179 | c.lock.Lock() 180 | defer c.lock.Unlock() 181 | 182 | if c.store == nil { 183 | c.store = make(storeMap) 184 | } 185 | 186 | c.store[key] = val 187 | } 188 | 189 | func (c *context) Render(name string, data interface{}) (err error) { 190 | if c.gig.Renderer == nil { 191 | return ErrRendererNotRegistered 192 | } 193 | 194 | if err = c.response.WriteHeader(StatusSuccess, MIMETextGemini); err != nil { 195 | return 196 | } 197 | 198 | return c.gig.Renderer.Render(c.response, name, data, c) 199 | } 200 | 201 | func (c *context) Gemini(format string, values ...interface{}) error { 202 | return c.GeminiBlob([]byte(fmt.Sprintf(format, values...))) 203 | } 204 | 205 | func (c *context) GeminiBlob(b []byte) (err error) { 206 | return c.Blob(MIMETextGemini, b) 207 | } 208 | 209 | func (c *context) Text(format string, values ...interface{}) (err error) { 210 | return c.Blob(MIMETextPlain, []byte(fmt.Sprintf(format, values...))) 211 | } 212 | 213 | func (c *context) Blob(contentType string, b []byte) (err error) { 214 | err = c.response.WriteHeader(StatusSuccess, contentType) 215 | if err != nil { 216 | return 217 | } 218 | 219 | _, err = c.response.Write(b) 220 | 221 | return 222 | } 223 | 224 | func (c *context) Stream(contentType string, r io.Reader) (err error) { 225 | err = c.response.WriteHeader(StatusSuccess, contentType) 226 | if err != nil { 227 | return 228 | } 229 | 230 | _, err = io.Copy(c.response, r) 231 | 232 | return 233 | } 234 | 235 | func (c *context) File(file string) (err error) { 236 | if containsDotDot(file) { 237 | c.Error(ErrBadRequest) 238 | return 239 | } 240 | 241 | s, err := os.Stat(file) 242 | if err != nil { 243 | c.Error(ErrNotFound) 244 | return 245 | } 246 | 247 | if uint64(s.Mode().Perm())&0444 != 0444 { 248 | c.Error(ErrGone) 249 | return 250 | } 251 | 252 | if s.IsDir() { 253 | files, err := ioutil.ReadDir(file) 254 | if err != nil { 255 | c.Error(ErrTemporaryFailure) 256 | return err 257 | } 258 | 259 | for _, f := range files { 260 | if f.Name() == indexPage { 261 | return c.File(path.Join(file, indexPage)) 262 | } 263 | } 264 | 265 | err = c.response.WriteHeader(StatusSuccess, "text/gemini") 266 | 267 | if err != nil { 268 | return err 269 | } 270 | 271 | _, _ = c.response.Write([]byte(fmt.Sprintf("# Listing %s\n\n", c.u.Path))) 272 | 273 | sort.Slice(files, func(i, j int) bool { return files[i].Name() < files[j].Name() }) 274 | 275 | for _, file := range files { 276 | if strings.HasPrefix(file.Name(), ".") { 277 | continue 278 | } 279 | 280 | if uint64(file.Mode().Perm())&0444 != 0444 { 281 | continue 282 | } 283 | 284 | _, _ = c.response.Write([]byte(fmt.Sprintf("=> %s %s [ %v ]\n", filepath.Clean(path.Join(c.u.Path, file.Name())), file.Name(), bytefmt(file.Size())))) 285 | } 286 | 287 | return nil 288 | } 289 | 290 | ext := filepath.Ext(file) 291 | 292 | var mimeType string 293 | if ext == ".gmi" { 294 | mimeType = "text/gemini" 295 | } else { 296 | mimeType = mime.TypeByExtension(ext) 297 | if mimeType == "" { 298 | mimeType = "octet/stream" 299 | } 300 | } 301 | 302 | f, err := os.OpenFile(file, os.O_RDONLY, 0600) 303 | if err != nil { 304 | c.Error(ErrTemporaryFailure) 305 | return 306 | } 307 | defer f.Close() 308 | 309 | err = c.response.WriteHeader(StatusSuccess, mimeType) 310 | 311 | if err != nil { 312 | return 313 | } 314 | 315 | _, err = io.Copy(c.response, f) 316 | 317 | if err != nil { 318 | // .. remote closed the connection, nothing we can do besides log 319 | // or io error, but status is already sent, everything is broken! 320 | c.Error(ErrTemporaryFailure) 321 | } 322 | 323 | return 324 | } 325 | 326 | func containsDotDot(v string) bool { 327 | if !strings.Contains(v, "..") { 328 | return false 329 | } 330 | 331 | for _, ent := range strings.FieldsFunc(v, isSlashRune) { 332 | if ent == ".." { 333 | return true 334 | } 335 | } 336 | 337 | return false 338 | } 339 | 340 | func isSlashRune(r rune) bool { return r == '/' || r == '\\' } 341 | 342 | func (c *context) NoContent(code Status, meta string, values ...interface{}) error { 343 | return c.response.WriteHeader(code, fmt.Sprintf(meta, values...)) 344 | } 345 | 346 | func (c *context) Error(err error) { 347 | c.gig.GeminiErrorHandler(err, c) 348 | } 349 | 350 | func (c *context) Gig() *Gig { 351 | return c.gig 352 | } 353 | 354 | func (c *context) Handler() HandlerFunc { 355 | return c.handler 356 | } 357 | 358 | func (c *context) reset(conn tlsconn, u *url.URL, requestURI string, tls *tls.ConnectionState) { 359 | c.conn = conn 360 | c.TLS = tls 361 | c.u = u 362 | c.requestURI = requestURI 363 | c.response.reset(conn) 364 | c.handler = NotFoundHandler 365 | c.store = nil 366 | c.path = "" 367 | c.pnames = nil 368 | // NOTE: Don't reset because it has to have length c.gig.maxParam at all times 369 | for i := 0; i < *c.gig.maxParam; i++ { 370 | c.pvalues[i] = "" 371 | } 372 | } 373 | 374 | func bytefmt(b int64) string { 375 | const unit = 1000 376 | 377 | if b < unit { 378 | return fmt.Sprintf("%dB", b) 379 | } 380 | 381 | div, exp := int64(unit), 0 382 | 383 | for n := b / unit; n >= unit; n /= unit { 384 | div *= unit 385 | exp++ 386 | } 387 | 388 | return fmt.Sprintf("%.1f%cB", float64(b)/float64(div), "kMGTPE"[exp]) 389 | } 390 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "strings" 11 | "testing" 12 | "text/template" 13 | 14 | "github.com/matryer/is" 15 | ) 16 | 17 | type ( 18 | Template struct { 19 | templates *template.Template 20 | } 21 | TemplateFail struct{} 22 | ) 23 | 24 | func (t *Template) Render(w io.Writer, name string, data interface{}, c Context) error { 25 | return t.templates.ExecuteTemplate(w, name, data) 26 | } 27 | 28 | func (t *TemplateFail) Render(w io.Writer, name string, data interface{}, c Context) error { 29 | return errors.New("could not render") 30 | } 31 | 32 | func TestContext(t *testing.T) { 33 | g := New() 34 | c, conn := g.NewFakeContext("/", nil) 35 | 36 | is := is.New(t) 37 | 38 | // Gig 39 | is.True(c.Gig() != nil) 40 | 41 | // Conn 42 | if conn == nil { 43 | panic("staticcheck SA5011 false positive, conn cannot be nil here") 44 | } 45 | 46 | // Response 47 | is.True(c.Response() != nil) 48 | 49 | //-------- 50 | // Render 51 | //-------- 52 | 53 | g.Renderer = &Template{ 54 | templates: template.Must(template.New("hello").Parse("Hello, {{.}}!")), 55 | } 56 | err := c.Render("hello", "Jon Snow") 57 | is.NoErr(err) 58 | is.Equal("20 text/gemini\r\nHello, Jon Snow!", conn.Written) 59 | 60 | g.Renderer = &TemplateFail{} 61 | err = c.Render("hello", "Jon Snow") 62 | is.True(err != nil) 63 | 64 | g.Renderer = nil 65 | err = c.Render("hello", "Jon Snow") 66 | is.True(err != nil) 67 | 68 | // Text 69 | c, conn = g.NewFakeContext("/", nil) 70 | 71 | err = c.Text("Hello, %s!", "World") 72 | is.NoErr(err) 73 | is.Equal(fmt.Sprintf("%d %s\r\nHello, World!", StatusSuccess, MIMETextPlain), conn.Written) 74 | 75 | // Gemini 76 | c, conn = g.NewFakeContext("/", nil) 77 | 78 | err = c.Gemini("Hello, %s!", "World") 79 | is.NoErr(err) 80 | is.Equal(fmt.Sprintf("%d %s\r\nHello, World!", StatusSuccess, MIMETextGemini), conn.Written) 81 | 82 | // Stream 83 | c, conn = g.NewFakeContext("/", nil) 84 | 85 | r := strings.NewReader("response from a stream") 86 | err = c.Stream("application/octet-stream", r) 87 | is.NoErr(err) 88 | is.Equal(fmt.Sprintf("%d application/octet-stream\r\nresponse from a stream", StatusSuccess), conn.Written) 89 | 90 | c, conn = g.NewFakeContext("/", nil) 91 | conn.FailAfter = 1 92 | 93 | is.True(c.Stream("application/octet-stream", r) != nil) 94 | 95 | // Error 96 | c, conn = g.NewFakeContext("/", nil) 97 | 98 | c.Error(errors.New("error")) 99 | is.Equal(fmt.Sprintf("%d error\r\n", StatusPermanentFailure), conn.Written) 100 | 101 | // Reset 102 | c.Set("foe", "ban") 103 | c.(*context).reset(nil, nil, "", nil) 104 | is.Equal(0, len(c.(*context).store)) 105 | is.Equal("", c.Path()) 106 | } 107 | 108 | func TestContextPath(t *testing.T) { 109 | g := New() 110 | r := g.router 111 | 112 | r.add("/users/:id", nil) 113 | c := g.newContext(nil, nil, "", nil) 114 | r.find("/users/1", c) 115 | 116 | is := is.New(t) 117 | 118 | is.Equal("/users/:id", c.Path()) 119 | 120 | r.add("/users/:uid/files/:fid", nil) 121 | c = g.newContext(nil, nil, "", nil) 122 | r.find("/users/1/files/1", c) 123 | is.Equal("/users/:uid/files/:fid", c.Path()) 124 | } 125 | 126 | func TestContextRequestURI(t *testing.T) { 127 | g := New() 128 | 129 | c := g.newContext(nil, nil, "/my-uri", nil) 130 | 131 | is := is.New(t) 132 | 133 | is.Equal("/my-uri", c.RequestURI()) 134 | } 135 | 136 | func TestContextGetParam(t *testing.T) { 137 | g := New() 138 | is := is.New(t) 139 | r := g.router 140 | 141 | r.add("/:foo", func(Context) error { return nil }) 142 | 143 | c, _ := g.NewFakeContext("/bar", nil) 144 | 145 | // round-trip param values with modification 146 | is.Equal("", c.Param("bar")) 147 | 148 | // shouldn't explode during Reset() afterwards! 149 | c.(*context).reset(nil, nil, "", nil) 150 | } 151 | 152 | func TestContextFile(t *testing.T) { 153 | g := New() 154 | is := is.New(t) 155 | c, conn := g.NewFakeContext("/", nil) 156 | 157 | is.NoErr(c.File("_fixture/folder/about.gmi")) 158 | is.Equal("20 text/gemini\r\n# About page\n\n=> / 🏠 Home\n", conn.Written) 159 | 160 | c, conn = g.NewFakeContext("/", nil) 161 | 162 | is.NoErr(c.File("../../../../../../../../etc/profile")) 163 | is.Equal("59 Bad Request\r\n", conn.Written) 164 | } 165 | 166 | func TestContextNoContent(t *testing.T) { 167 | c, conn := New().NewFakeContext("/", nil) 168 | is := is.New(t) 169 | 170 | is.NoErr(c.NoContent(StatusRedirectPermanent, "gemini://gus.guru/")) 171 | is.Equal("31 gemini://gus.guru/\r\n", conn.Written) 172 | } 173 | 174 | func TestContextStore(t *testing.T) { 175 | c := new(context) 176 | c.Set("name", "Jon Snow") 177 | 178 | is := is.New(t) 179 | is.Equal("Jon Snow", c.Get("name")) 180 | } 181 | 182 | func BenchmarkContext_Store(b *testing.B) { 183 | b.ReportAllocs() 184 | 185 | g := &Gig{} 186 | 187 | c := &context{ 188 | gig: g, 189 | } 190 | 191 | for n := 0; n < b.N; n++ { 192 | c.Set("name", "Jon Snow") 193 | 194 | if c.Get("name") != "Jon Snow" { 195 | b.Fail() 196 | } 197 | } 198 | } 199 | 200 | func TestContextHandler(t *testing.T) { 201 | g := New() 202 | r := g.router 203 | b := new(bytes.Buffer) 204 | 205 | r.add("/handler", func(Context) error { 206 | _, err := b.Write([]byte("handler")) 207 | return err 208 | }) 209 | 210 | c := g.newContext(nil, nil, "", nil) 211 | r.find("/handler", c) 212 | err := c.Handler()(c) 213 | 214 | is := is.New(t) 215 | is.Equal("handler", b.String()) 216 | is.NoErr(err) 217 | } 218 | 219 | func TestContext_Path(t *testing.T) { 220 | path := "/pa/th" 221 | 222 | c := new(context) 223 | is := is.New(t) 224 | 225 | c.path = path 226 | is.Equal(path, c.Path()) 227 | } 228 | 229 | func TestContext_QueryString(t *testing.T) { 230 | queryString := "some+val" 231 | 232 | c, _ := New().NewFakeContext("/?"+queryString, nil) 233 | is := is.New(t) 234 | 235 | q, err := c.QueryString() 236 | is.NoErr(err) 237 | is.Equal("some val", q) 238 | } 239 | 240 | func TestContext_IP(t *testing.T) { 241 | c, _ := New().NewFakeContext("/", nil) 242 | 243 | is := is.New(t) 244 | is.Equal("192.0.2.1", c.IP()) 245 | } 246 | 247 | func TestContext_Certificate(t *testing.T) { 248 | c, _ := New().NewFakeContext("/", nil) 249 | is := is.New(t) 250 | 251 | is.Equal(c.Certificate(), nil) 252 | 253 | cert := &x509.Certificate{} 254 | c, _ = New().NewFakeContext("/", &tls.ConnectionState{ 255 | PeerCertificates: []*x509.Certificate{cert}, 256 | }) 257 | 258 | is.Equal(cert, c.Certificate()) 259 | } 260 | 261 | func TestContext_bytefmt(t *testing.T) { 262 | is := is.New(t) 263 | 264 | is.Equal(bytefmt(12345678), "12.3MB") 265 | } 266 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | // DefaultWriter is the default io.Writer used by gig for debug output and 12 | // middleware output like Logger() or Recovery(). 13 | // Note that both Logger and Recovery provides custom ways to configure their 14 | // output io.Writer. 15 | // To support coloring in Windows use: 16 | // import "github.com/mattn/go-colorable" 17 | // gig.DefaultWriter = colorable.NewColorableStdout() 18 | DefaultWriter io.Writer = os.Stdout 19 | 20 | // Debug enables gig to print its internal debug messages. 21 | Debug = true 22 | ) 23 | 24 | func debugPrintf(format string, values ...interface{}) { 25 | if Debug { 26 | if !strings.HasSuffix(format, "\n") { 27 | format += "\n" 28 | } 29 | 30 | fmt.Fprintf(DefaultWriter, "[gig-debug] "+format, values...) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Astrobotany 4 | 5 | Shows how to implement routing structure of [Astrobotany](gemini://astrobotany.mozz.us/) 6 | -------------------------------------------------------------------------------- /examples/astro.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC5TCCAc2gAwIBAgIJAMzEE/FFjOBFMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV 3 | BAMMCWxvY2FsaG9zdDAeFw0yMDA2MTIyMjMwMDlaFw0yMDA3MTIyMjMwMDlaMBQx 4 | EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 5 | ggEBAJ7F91P34E96GPud7MmXIz6DsCt6kZeCVnJP427rUCyogPzikNwArEWu7kxx 6 | HUUX7JPiuzlYvy+OVvw7hLuDtD6+c+11QR9ZQ8FygHO3IW6+NCcT6DXkA5HpEiFL 7 | c6gkr5pO8+qzrS2bDg5PvFpG9O9Qt3XtDTQT/XjhNFFrKgr/+W7ikDvXlfWrsNmy 8 | CZ7y9d+f6v6lvnrHODcFUvoh2fwFi2j6u5BDVe0W10vEPOXvRFjOeYUS6DCw1xkB 9 | z15jjwCjDDuthPrHx5v2Jq6aETYYuQa7DshXA7CONG61qYvBwNfAMv7hlikBTBW9 10 | jj6y9sqMNPG9USeDDc4CVt3qSGECAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo 11 | b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B 12 | AQsFAAOCAQEAAelMviRwWREU5sZHFNhBN6BPLloPvilPZ0KfBbg+0EwB4kZxQSAS 13 | okTk9SBLDW1Lbrj5TW7C4e/gl854KbaHGAz/Sr9phRymbbP9kW+NUeicjWZDrHfq 14 | de/L9Dpi8+DVo5y45O4WvImPvVT5ca/vySBLgM9ipdw4VQBk2K1WRIM2KVL7L/bk 15 | 3B180hEUFqbA1KmeKTAdrsT6j6MZ32P8OidmgQrgvwgbccdo8HVj9Cemnbv6lFKv 16 | gdZvG0+gJPsg013vMWF66ToL4b8/jwkIEvgrk0D3+Id4hVdlzfPXZlAvpsb4mWex 17 | 26X5Ad8pL6HKyvNgjENxsnEu5AQF8D5T8A== 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /examples/astro.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCexfdT9+BPehj7 3 | nezJlyM+g7ArepGXglZyT+Nu61AsqID84pDcAKxFru5McR1FF+yT4rs5WL8vjlb8 4 | O4S7g7Q+vnPtdUEfWUPBcoBztyFuvjQnE+g15AOR6RIhS3OoJK+aTvPqs60tmw4O 5 | T7xaRvTvULd17Q00E/144TRRayoK//lu4pA715X1q7DZsgme8vXfn+r+pb56xzg3 6 | BVL6Idn8BYto+ruQQ1XtFtdLxDzl70RYznmFEugwsNcZAc9eY48Aoww7rYT6x8eb 7 | 9iaumhE2GLkGuw7IVwOwjjRutamLwcDXwDL+4ZYpAUwVvY4+svbKjDTxvVEngw3O 8 | Albd6khhAgMBAAECggEAdKI6mO1bUyb/WT9e5YvoMREuBhKJB0KQ3HKBQUcNY3D5 9 | KEwLXAIGiz4BAhiKBuqXON/y5yKhd37ZuXrDe7g5XWos4QAksbGyS1YgtGCP57lD 10 | uH0wNP5l/Pa5AcpakOc6NGHRXtVU306rGapLVAoR/gdObguQinQw3G6bL0BI50CQ 11 | qz02L7olYliEDThrRJ/jAk6v01NH3vtWiQEmHM56/GCSj2Fw/ODyzEo63++Kr6uu 12 | 0QGK98dYjrhOOtRQB/1qutj7IOMcYjrptTKgAMrIpOp0FdpNzAfjecW68Q50twmG 13 | vm+VCjRTdyyX31cw+bH0T3dUwWA7lPUBWGv5K4NWPQKBgQDNWlWx7MdlG4A7cvbO 14 | rS/PzDFESWWPGupqgUefo9bStFaOowYjU6n6Qudju++6LW8IAeSUqzgPM+wKO8hi 15 | AD7908Sz6zDAwoUmAQsw8vUVHJwGqg3W69hMhtZ7qyhxxDriEH0OtkpiThO30YPn 16 | juf3++PKwag5C0dOkypAAreeRwKBgQDF7qtJmaZh/m4mAdnJixdik8mXGeI5U9E5 17 | gRl61WpKg9/kh/QXxmmu4zh6yZTIUfpLz0tf1Q0vv0nkWyqOEDqDjOojqUskZ/OH 18 | Ufh/RePG1fnHsxFepTM3DOlmNgDSRWwoBDGUskuMCwpXWuCbqKhBkkpQtplbO2Ks 19 | u70H3nRwFwKBgGLi+FdIxTAcESEPmGgoH9j55FOU4JIMDQwimyH13LH0Y6YmPQNv 20 | +29nHlP5oVRPIqOBfdhVpxYzE5xN8421vp+uhY96VyzLvyBw6jm1FW4IK95Nr4Jq 21 | aetYFxrQbhZyv0QzWnVmNOWn4XjoLJOqTmwtYSE3JlHp63mcBoFyjYdrAoGAcyov 22 | V5+jA5l6lXq2MWJQtPawcM6KpjhyoDbRkHrkYX7hoqLID51OmId0sVzgyL2KGNoA 23 | TT41cfanE8tHs2kV4rz27tDt+7zQIYg8QjF0GpkdwGgcTyln5zwIAYSibbYe/BmU 24 | j6Q9+LnjxngGAAvd+mkX/GaCTGb9PuvtTMrOjPkCgYADy7bRyrG8OgtuY6EJy9VQ 25 | fZhV2P2Pl/smfVK8chpWOyYGKBYx2bbPD21/tPXEpzNgGoRx102FDUaxTnUZrqKq 26 | RfN19u9AOERDZDqFfNPtqDVuNVtO8iO3QleDQgsVsLfQbrV3e+ko+rTB+Y4JbCtG 27 | 9GCFoKdEB1A9qYz+nWo2lA== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /examples/astrobotany.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/pitr/gig" 4 | 5 | func main() { 6 | g := gig.Default() 7 | 8 | g.Static("", "astrobotany/") 9 | 10 | plant := g.Group("/plant", gig.CertAuth(gig.ValidateHasCertificate)) 11 | { 12 | plant.Handle("", func(c gig.Context) error { 13 | return c.Gemini("Hello " + c.Get("subject").(string)) 14 | }) 15 | plant.Handle("/water", func(c gig.Context) error { 16 | return c.NoContent(gig.StatusRedirectTemporary, "/plant") 17 | }) 18 | plant.Handle("/name", func(c gig.Context) error { 19 | if name, err := c.QueryString(); err != nil { 20 | return c.NoContent(gig.StatusInput, "Bad input, try again") 21 | } else if name != "" { 22 | return c.NoContent(gig.StatusRedirectTemporary, "/plant") 23 | } 24 | return c.NoContent(gig.StatusInput, "Enter a new nickname for your plant") 25 | }) 26 | } 27 | 28 | panic(g.Run(":1965", "astro.crt", "astro.key")) 29 | } 30 | -------------------------------------------------------------------------------- /examples/astrobotany/index.gmi: -------------------------------------------------------------------------------- 1 | # Astrobotany 2 | 3 | => /plant Get to gardening! 4 | => /instructions.gmi Instructions 5 | -------------------------------------------------------------------------------- /examples/astrobotany/instructions.gmi: -------------------------------------------------------------------------------- 1 | # Gardening Basics 2 | 3 | * Water every day 4 | * That's it 5 | -------------------------------------------------------------------------------- /gig.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package gig implements high performance, minimalist Go framework for Gemini protocol. 3 | 4 | Example: 5 | 6 | package main 7 | 8 | import ( 9 | "github.com/pitr/gig" 10 | ) 11 | 12 | func main() { 13 | // Gig instance 14 | g := gig.Default() 15 | 16 | // Routes 17 | g.Handle("/user/:name", func(c gig.Context) error { 18 | return c.Gemini(gig.StatusSuccess, "# Hello, %s!", c.Param("name")) 19 | }) 20 | 21 | // Start server 22 | g.Run("my.crt", "my.key") 23 | } 24 | */ 25 | package gig 26 | 27 | import ( 28 | "bufio" 29 | "bytes" 30 | "crypto/tls" 31 | "errors" 32 | "fmt" 33 | "io" 34 | "io/ioutil" 35 | "net" 36 | "net/url" 37 | "os" 38 | "path" 39 | "path/filepath" 40 | "reflect" 41 | "runtime" 42 | "sync" 43 | "time" 44 | ) 45 | 46 | type ( 47 | // Gig is the top-level framework instance. 48 | Gig struct { 49 | common 50 | 51 | premiddleware []MiddlewareFunc 52 | middleware []MiddlewareFunc 53 | maxParam *int 54 | router *router 55 | listener net.Listener 56 | addr string 57 | ctxpool sync.Pool 58 | bufpool sync.Pool 59 | doneChan chan struct{} 60 | closeOnce sync.Once 61 | mu sync.Mutex 62 | 63 | // HideBanner disables banner on startup. 64 | HideBanner bool 65 | // HidePort disables startup message. 66 | HidePort bool 67 | // GeminiErrorHandler allows setting custom error handler 68 | GeminiErrorHandler GeminiErrorHandler 69 | // Renderer must be set for Context#Render to work 70 | Renderer Renderer 71 | // ReadTimeout set max read timeout on socket. 72 | // Default is none. 73 | ReadTimeout time.Duration 74 | // WriteTimeout set max write timeout on socket. 75 | // Default is none. 76 | WriteTimeout time.Duration 77 | // TLSConfig is passed to tls.NewListener and needs to be modified 78 | // before Run is called. 79 | TLSConfig *tls.Config 80 | } 81 | 82 | // Route contains a handler and information for matching against requests. 83 | Route struct { 84 | Path string 85 | Name string 86 | } 87 | 88 | // GeminiError represents an error that occurred while handling a request. 89 | GeminiError struct { 90 | Code Status 91 | Message string 92 | } 93 | 94 | // MiddlewareFunc defines a function to process middleware. 95 | MiddlewareFunc func(HandlerFunc) HandlerFunc 96 | 97 | // HandlerFunc defines a function to serve requests. 98 | HandlerFunc func(Context) error 99 | 100 | // GeminiErrorHandler is a centralized error handler. 101 | GeminiErrorHandler func(error, Context) 102 | 103 | // Renderer is the interface that wraps the Render function. 104 | Renderer interface { 105 | Render(io.Writer, string, interface{}, Context) error 106 | } 107 | 108 | storeMap map[string]interface{} 109 | 110 | // Common struct for Gig & Group. 111 | common struct{} 112 | ) 113 | 114 | // MIME types. 115 | const ( 116 | MIMETextGemini = "text/gemini" 117 | MIMETextGeminiCharsetUTF8 = "text/gemini; charset=UTF-8" 118 | MIMETextPlain = "text/plain" 119 | MIMETextPlainCharsetUTF8 = "text/plain; charset=UTF-8" 120 | ) 121 | 122 | const ( 123 | // Version of Gig. 124 | Version = "0.9.8" 125 | // http://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=gig 126 | banner = ` 127 | _ 128 | ___ _(_)__ _ 129 | / _ / / _ / 130 | \_, /_/\_, / 131 | /___/ /___/ %s 132 | 133 | ` 134 | ) 135 | 136 | // Errors that can be inherited from using NewErrorFrom. 137 | var ( 138 | ErrTemporaryFailure = NewError(StatusTemporaryFailure, "Temporary Failure") 139 | ErrServerUnavailable = NewError(StatusServerUnavailable, "Server Unavailable") 140 | ErrCGIError = NewError(StatusCGIError, "CGI Error") 141 | ErrProxyError = NewError(StatusProxyError, "Proxy Error") 142 | ErrSlowDown = NewError(StatusSlowDown, "Slow Down") 143 | ErrPermanentFailure = NewError(StatusPermanentFailure, "Permanent Failure") 144 | ErrNotFound = NewError(StatusNotFound, "Not Found") 145 | ErrGone = NewError(StatusGone, "Gone") 146 | ErrProxyRequestRefused = NewError(StatusProxyRequestRefused, "Proxy Request Refused") 147 | ErrBadRequest = NewError(StatusBadRequest, "Bad Request") 148 | ErrClientCertificateRequired = NewError(StatusClientCertificateRequired, "Client Certificate Required") 149 | ErrCertificateNotAuthorised = NewError(StatusCertificateNotAuthorised, "Certificate Not Authorised") 150 | ErrCertificateNotValid = NewError(StatusCertificateNotValid, "Certificate Not Valid") 151 | 152 | ErrRendererNotRegistered = errors.New("renderer not registered") 153 | ErrInvalidCertOrKeyType = errors.New("invalid cert or key type, must be string or []byte") 154 | 155 | ErrServerClosed = errors.New("gemini: Server closed") 156 | 157 | responseUnknownError = []byte(fmt.Sprintf("%d %s\r\n", StatusBadRequest, "Unknown error reading request!")) 158 | responseRequestTooLong = []byte(fmt.Sprintf("%d %s\r\n", StatusBadRequest, "Request too long!")) 159 | responseBadURL = []byte(fmt.Sprintf("%d %s\r\n", StatusBadRequest, "Error parsing URL!")) 160 | responseBadSchema = []byte(fmt.Sprintf("%d %s\r\n", StatusBadRequest, "No proxying to non-Gemini content!")) 161 | ) 162 | 163 | // Error handlers. 164 | var ( 165 | NotFoundHandler = func(c Context) error { 166 | return ErrNotFound 167 | } 168 | ) 169 | 170 | // DefaultGeminiErrorHandler is the default HTTP error handler. It sends a JSON response 171 | // with status code. 172 | func DefaultGeminiErrorHandler(err error, c Context) { 173 | he, ok := err.(*GeminiError) 174 | if !ok { 175 | he = &GeminiError{ 176 | Code: StatusPermanentFailure, 177 | Message: err.Error(), 178 | } 179 | } 180 | 181 | code := he.Code 182 | message := he.Message 183 | 184 | debugPrintf("gemini: handling error: %s", err) 185 | 186 | // Send response 187 | if !c.Response().Committed { 188 | err = c.NoContent(code, message) 189 | if err != nil { 190 | debugPrintf("gemini: could not handle error: %s", err) 191 | } 192 | } 193 | } 194 | 195 | // New creates an instance of Gig. 196 | func New() *Gig { 197 | g := &Gig{ 198 | TLSConfig: &tls.Config{ 199 | MinVersion: tls.VersionTLS12, 200 | ClientAuth: tls.RequestClientCert, 201 | }, 202 | maxParam: new(int), 203 | doneChan: make(chan struct{}), 204 | } 205 | g.GeminiErrorHandler = DefaultGeminiErrorHandler 206 | g.ctxpool.New = func() interface{} { return g.newContext(nil, nil, "", nil) } 207 | g.bufpool.New = func() interface{} { return bufio.NewReaderSize(nil, 1024) } 208 | g.router = newRouter(g) 209 | 210 | return g 211 | } 212 | 213 | // Default returns a Gig instance with Logger and Recover middleware enabled. 214 | func Default() *Gig { 215 | g := New() 216 | 217 | // Default middlewares 218 | g.Use(Logger(), Recover()) 219 | 220 | return g 221 | } 222 | 223 | func (g *Gig) newContext(c tlsconn, u *url.URL, requestURI string, tls *tls.ConnectionState) Context { 224 | return &context{ 225 | conn: c, 226 | TLS: tls, 227 | u: u, 228 | requestURI: requestURI, 229 | response: NewResponse(c), 230 | store: make(storeMap), 231 | gig: g, 232 | pvalues: make([]string, *g.maxParam), 233 | handler: NotFoundHandler, 234 | } 235 | } 236 | 237 | // Pre adds middleware to the chain which is run before router. 238 | func (g *Gig) Pre(middleware ...MiddlewareFunc) { 239 | g.premiddleware = append(g.premiddleware, middleware...) 240 | } 241 | 242 | // Use adds middleware to the chain which is run after router. 243 | func (g *Gig) Use(middleware ...MiddlewareFunc) { 244 | g.middleware = append(g.middleware, middleware...) 245 | } 246 | 247 | // Handle registers a new route for a path with matching handler in the router 248 | // with optional route-level middleware. 249 | func (g *Gig) Handle(path string, h HandlerFunc, m ...MiddlewareFunc) *Route { 250 | return g.add(path, h, m...) 251 | } 252 | 253 | // Static registers a new route with path prefix to serve static files from the 254 | // provided root directory. 255 | func (g *Gig) Static(prefix, root string) *Route { 256 | if root == "" { 257 | root = "." // For security we want to restrict to CWD. 258 | } 259 | 260 | return g.static(prefix, root, g.Handle) 261 | } 262 | 263 | func (common) static(prefix, root string, get func(string, HandlerFunc, ...MiddlewareFunc) *Route) *Route { 264 | h := func(c Context) error { 265 | p, err := url.PathUnescape(c.Param("*")) 266 | if err != nil { 267 | return err 268 | } 269 | 270 | name := filepath.Join(root, path.Clean("/"+p)) // "/"+ for security 271 | 272 | return c.File(name) 273 | } 274 | 275 | if prefix == "/" { 276 | return get(prefix+"*", h) 277 | } 278 | 279 | return get(prefix+"/*", h) 280 | } 281 | 282 | func (common) file(path, file string, get func(string, HandlerFunc, ...MiddlewareFunc) *Route, 283 | m ...MiddlewareFunc) *Route { 284 | return get(path, func(c Context) error { 285 | return c.File(file) 286 | }, m...) 287 | } 288 | 289 | // File registers a new route with path to serve a static file with optional route-level middleware. 290 | func (g *Gig) File(path, file string, m ...MiddlewareFunc) *Route { 291 | return g.file(path, file, g.Handle, m...) 292 | } 293 | 294 | func (g *Gig) add(path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route { 295 | name := handlerName(handler) 296 | 297 | g.router.add(path, func(c Context) error { 298 | h := handler 299 | // Chain middleware 300 | for i := len(middleware) - 1; i >= 0; i-- { 301 | h = middleware[i](h) 302 | } 303 | return h(c) 304 | }) 305 | 306 | r := &Route{ 307 | Path: path, 308 | Name: name, 309 | } 310 | 311 | g.router.routes[path] = r 312 | 313 | return r 314 | } 315 | 316 | // Group creates a new router group with prefix and optional group-level middleware. 317 | func (g *Gig) Group(prefix string, m ...MiddlewareFunc) (gg *Group) { 318 | gg = &Group{prefix: prefix, gig: g} 319 | gg.Use(m...) 320 | 321 | return 322 | } 323 | 324 | // URL generates a URL from handler. 325 | func (g *Gig) URL(handler HandlerFunc, params ...interface{}) string { 326 | name := handlerName(handler) 327 | return g.Reverse(name, params...) 328 | } 329 | 330 | // Reverse generates an URL from route name and provided parameters. 331 | func (g *Gig) Reverse(name string, params ...interface{}) string { 332 | uri := new(bytes.Buffer) 333 | ln := len(params) 334 | n := 0 335 | 336 | for _, r := range g.router.routes { 337 | if r.Name == name { 338 | for i, l := 0, len(r.Path); i < l; i++ { 339 | if r.Path[i] == ':' && n < ln { 340 | for ; i < l && r.Path[i] != '/'; i++ { 341 | } 342 | uri.WriteString(fmt.Sprintf("%v", params[n])) 343 | n++ 344 | } 345 | 346 | if i < l { 347 | uri.WriteByte(r.Path[i]) 348 | } 349 | } 350 | 351 | break 352 | } 353 | } 354 | 355 | return uri.String() 356 | } 357 | 358 | // Routes returns the registered routes. 359 | func (g *Gig) Routes() []*Route { 360 | routes := make([]*Route, 0, len(g.router.routes)) 361 | for _, v := range g.router.routes { 362 | routes = append(routes, v) 363 | } 364 | 365 | return routes 366 | } 367 | 368 | // ServeGemini serves Gemini request. 369 | func (g *Gig) ServeGemini(c Context) { 370 | if c.Gig() != g { 371 | // Acquire context from correct Gig and use it instead. 372 | orig := c.(*context) 373 | 374 | ctx := g.ctxpool.Get().(*context) 375 | defer g.ctxpool.Put(ctx) 376 | 377 | ctx.reset(orig.conn, orig.u, orig.requestURI, orig.TLS) 378 | 379 | c = ctx 380 | } 381 | 382 | var h HandlerFunc 383 | 384 | URL := c.URL() 385 | 386 | if g.premiddleware == nil { 387 | g.router.find(getPath(URL), c) 388 | h = c.Handler() 389 | h = applyMiddleware(h, g.middleware...) 390 | } else { 391 | h = func(c Context) error { 392 | g.router.find(getPath(URL), c) 393 | h := c.Handler() 394 | h = applyMiddleware(h, g.middleware...) 395 | return h(c) 396 | } 397 | h = applyMiddleware(h, g.premiddleware...) 398 | } 399 | 400 | // Execute chain 401 | if err := h(c); err != nil { 402 | g.GeminiErrorHandler(err, c) 403 | } 404 | } 405 | 406 | // Run starts a Gemini server. 407 | // If `certFile` or `keyFile` is `string` the values are treated as file paths. 408 | // If `certFile` or `keyFile` is `[]byte` the values are treated as the certificate or key as-is. 409 | func (g *Gig) Run(args ...interface{}) (err error) { 410 | var ( 411 | cert, key []byte 412 | certFile, keyFile interface{} 413 | addr string 414 | ) 415 | 416 | switch len(args) { 417 | case 2: 418 | addr, certFile, keyFile = os.Getenv("PORT"), args[0], args[1] 419 | if addr == "" { 420 | addr = ":1965" 421 | } else { 422 | addr = ":" + addr 423 | } 424 | case 3: 425 | addr, certFile, keyFile = args[0].(string), args[1], args[2] 426 | default: 427 | panic("must specify 2 or 3 arguments to Run") 428 | } 429 | 430 | if cert, err = filepathOrContent(certFile); err != nil { 431 | return 432 | } 433 | 434 | if key, err = filepathOrContent(keyFile); err != nil { 435 | return 436 | } 437 | 438 | g.TLSConfig.Certificates = make([]tls.Certificate, 1) 439 | 440 | if g.TLSConfig.Certificates[0], err = tls.X509KeyPair(cert, key); err != nil { 441 | return 442 | } 443 | 444 | return g.startTLS(addr) 445 | } 446 | 447 | func filepathOrContent(fileOrContent interface{}) (content []byte, err error) { 448 | switch v := fileOrContent.(type) { 449 | case string: 450 | return ioutil.ReadFile(v) 451 | case []byte: 452 | return v, nil 453 | default: 454 | return nil, ErrInvalidCertOrKeyType 455 | } 456 | } 457 | 458 | func (g *Gig) startTLS(address string) error { 459 | g.addr = address 460 | 461 | // Setup 462 | if !g.HideBanner { 463 | debugPrintf(banner, "v"+Version) 464 | } 465 | 466 | g.mu.Lock() 467 | if g.listener == nil { 468 | l, err := newListener(g.addr) 469 | if err != nil { 470 | return err 471 | } 472 | 473 | g.listener = tls.NewListener(l, g.TLSConfig) 474 | } 475 | g.mu.Unlock() 476 | 477 | defer g.listener.Close() 478 | 479 | if !g.HidePort { 480 | debugPrintf("⇨ gemini server started on %s\n", g.listener.Addr()) 481 | } 482 | 483 | return g.serve() 484 | } 485 | 486 | func (g *Gig) serve() error { 487 | var tempDelay time.Duration // how long to sleep on accept failure 488 | 489 | for { 490 | conn, err := g.listener.Accept() 491 | if err != nil { 492 | select { 493 | case <-g.doneChan: 494 | return ErrServerClosed 495 | default: 496 | } 497 | 498 | if ne, ok := err.(net.Error); ok && ne.Temporary() { 499 | if tempDelay == 0 { 500 | tempDelay = 5 * time.Millisecond 501 | } else { 502 | tempDelay *= 2 503 | } 504 | 505 | if max := 1 * time.Second; tempDelay > max { 506 | tempDelay = max 507 | } 508 | 509 | debugPrintf("gemini: Accept error: %v; retrying in %v", err, tempDelay) 510 | time.Sleep(tempDelay) 511 | 512 | continue 513 | } 514 | 515 | return err 516 | } 517 | 518 | tc, ok := conn.(*tls.Conn) 519 | if !ok { 520 | debugPrintf("gemini: non-tls connection") 521 | continue 522 | } 523 | 524 | go g.handleRequest(tc) 525 | } 526 | } 527 | 528 | // tlsconn wraps every necessary method from *tls.Conn, so it can be stubbed. 529 | type tlsconn interface { 530 | net.Conn 531 | ConnectionState() tls.ConnectionState 532 | } 533 | 534 | func (g *Gig) handleRequest(conn tlsconn) { 535 | defer conn.Close() 536 | 537 | if d := g.ReadTimeout; d != 0 { 538 | err := conn.SetReadDeadline(time.Now().Add(d)) 539 | if err != nil { 540 | debugPrintf("gemini: could not set socket read timeout: %s", err) 541 | } 542 | } 543 | 544 | // Acquire reader 545 | reader := g.bufpool.Get().(*bufio.Reader) 546 | defer g.bufpool.Put(reader) 547 | 548 | reader.Reset(conn) 549 | request, overflow, err := reader.ReadLine() 550 | 551 | if overflow { 552 | debugPrintf("gemini: request overflow") 553 | 554 | _, _ = conn.Write(responseRequestTooLong) 555 | 556 | return 557 | } else if err != nil { 558 | if err == io.EOF { 559 | debugPrintf("gemini: EOF reading from client, read %d bytes", len(request)) 560 | return 561 | } 562 | 563 | debugPrintf("gemini: unknown error reading request header: %s", err) 564 | 565 | _, _ = conn.Write(responseUnknownError) 566 | 567 | return 568 | } 569 | 570 | header := string(request) 571 | URL, err := url.Parse(header) 572 | 573 | if err != nil { 574 | debugPrintf("gemini: invalid request url: %s", err) 575 | 576 | _, _ = conn.Write(responseBadURL) 577 | 578 | return 579 | } 580 | 581 | if URL.Scheme == "" { 582 | URL.Scheme = "gemini" 583 | } 584 | 585 | if URL.Scheme != "gemini" { 586 | debugPrintf("gemini: non-gemini scheme: %s", header) 587 | 588 | _, _ = conn.Write(responseBadSchema) 589 | 590 | return 591 | } 592 | 593 | if d := g.WriteTimeout; d != 0 { 594 | err := conn.SetWriteDeadline(time.Now().Add(d)) 595 | if err != nil { 596 | debugPrintf("gemini: could not set socket write timeout: %s", err) 597 | } 598 | } 599 | 600 | tlsState := conn.ConnectionState() 601 | 602 | // Acquire context 603 | c := g.ctxpool.Get().(*context) 604 | c.reset(conn, URL, header, &tlsState) 605 | 606 | g.ServeGemini(c) 607 | 608 | // Release context 609 | g.ctxpool.Put(c) 610 | } 611 | 612 | // Close immediately stops the server. 613 | // It internally calls `net.Listener#Close()`. 614 | func (g *Gig) Close() error { 615 | g.closeOnce.Do(func() { 616 | close(g.doneChan) 617 | }) 618 | g.mu.Lock() 619 | defer g.mu.Unlock() 620 | 621 | if g.listener != nil { 622 | return g.listener.Close() 623 | } 624 | 625 | return nil 626 | } 627 | 628 | // NewError creates a new GeminiError instance. 629 | func NewError(code Status, message string) *GeminiError { 630 | return &GeminiError{Code: code, Message: message} 631 | } 632 | 633 | // NewErrorFrom creates a new GeminiError instance using Code from existing GeminiError. 634 | func NewErrorFrom(err *GeminiError, message string) *GeminiError { 635 | return &GeminiError{Code: err.Code, Message: message} 636 | } 637 | 638 | // Error makes it compatible with `error` interface. 639 | func (ge *GeminiError) Error() string { 640 | return fmt.Sprintf("error=%s", ge.Message) 641 | } 642 | 643 | // getPath returns RawPath, if it's empty returns Path from URL. 644 | func getPath(u *url.URL) string { 645 | path := u.RawPath 646 | if path == "" { 647 | path = u.Path 648 | } 649 | 650 | return path 651 | } 652 | 653 | func handlerName(h HandlerFunc) string { 654 | t := reflect.ValueOf(h).Type() 655 | if t.Kind() == reflect.Func { 656 | return runtime.FuncForPC(reflect.ValueOf(h).Pointer()).Name() 657 | } 658 | 659 | return t.String() 660 | } 661 | 662 | // // PathUnescape is wraps `url.PathUnescape` 663 | // func PathUnescape(s string) (string, error) { 664 | // return url.PathUnescape(s) 665 | // } 666 | 667 | // tcpKeepAliveListener sets TCP keep-alive timeouts on accepted 668 | // connections. It's used by Run so dead TCP connections (e.g. 669 | // closing laptop mid-download) eventually go away. 670 | type tcpKeepAliveListener struct { 671 | *net.TCPListener 672 | } 673 | 674 | func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) { 675 | if c, err = ln.AcceptTCP(); err != nil { 676 | return 677 | } else if err = c.(*net.TCPConn).SetKeepAlive(true); err != nil { 678 | return 679 | } 680 | // Ignore error from setting the KeepAlivePeriod as some systems, such as 681 | // OpenBSD, do not support setting TCP_USER_TIMEOUT on IPPROTO_TCP 682 | _ = c.(*net.TCPConn).SetKeepAlivePeriod(3 * time.Minute) 683 | 684 | return 685 | } 686 | 687 | func newListener(address string) (*tcpKeepAliveListener, error) { 688 | l, err := net.Listen("tcp", address) 689 | if err != nil { 690 | return nil, err 691 | } 692 | 693 | return &tcpKeepAliveListener{l.(*net.TCPListener)}, nil 694 | } 695 | 696 | func applyMiddleware(h HandlerFunc, middleware ...MiddlewareFunc) HandlerFunc { 697 | for i := len(middleware) - 1; i >= 0; i-- { 698 | h = middleware[i](h) 699 | } 700 | 701 | return h 702 | } 703 | -------------------------------------------------------------------------------- /gig_test.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "errors" 7 | "io/ioutil" 8 | "net" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/matryer/is" 14 | ) 15 | 16 | func TestGig(t *testing.T) { 17 | is := is.New(t) 18 | 19 | g := New() 20 | c, conn := g.NewFakeContext("/", nil) 21 | 22 | // Router 23 | is.True(g.router != nil) 24 | 25 | // DefaultGeminiErrorHandler 26 | DefaultGeminiErrorHandler(errors.New("error"), c) 27 | is.Equal("50 error\r\n", conn.Written) 28 | } 29 | 30 | func TestGigStatic(t *testing.T) { 31 | is := is.New(t) 32 | 33 | g := New() 34 | 35 | // OK 36 | g.Static("/images_ok", "_fixture/images") 37 | b := request("/images_ok/walle.png", g) 38 | is.True(strings.HasPrefix(b, "20 image/png\r\n")) 39 | 40 | // Empty root 41 | g.Static("/empty_root", "") 42 | b = request("/empty_root/_fixture/images/walle.png", g) 43 | is.True(strings.HasPrefix(b, "20 image/png\r\n")) 44 | 45 | // Missing file 46 | g.Static("/images_none", "_fixture/missing") 47 | b = request("/images_none/", g) 48 | is.Equal("51 Not Found\r\n", b) 49 | b = request("/images_none/walle.png", g) 50 | is.Equal("51 Not Found\r\n", b) 51 | 52 | // Directory Listing 53 | g.Static("/dir_no_index", "_fixture/folder") 54 | b = request("/dir_no_index/", g) 55 | is.Equal("20 text/gemini\r\n# Listing /dir_no_index/\n\n=> /dir_no_index/about.gmi about.gmi [ 29B ]\n=> /dir_no_index/another.blah another.blah [ 14B ]\n", b) 56 | 57 | // Directory Listing with index.gmi 58 | g.Static("/dir", "_fixture") 59 | b = request("/dir/", g) 60 | is.Equal("20 text/gemini\r\n# Hello from gig\n\n=> / 🏠 Home\n", b) 61 | b = request("/dir/folder", g) 62 | is.Equal("20 text/gemini\r\n# Listing /dir/folder\n\n=> /dir/folder/about.gmi about.gmi [ 29B ]\n=> /dir/folder/another.blah another.blah [ 14B ]\n", b) 63 | 64 | // File without known mime 65 | b = request("/dir/folder/another.blah", g) 66 | is.Equal("20 octet/stream\r\n# Another page", b) 67 | 68 | // Escape 69 | b = request("/dir/../../../../../../../../etc/profile", g) 70 | is.Equal(b, "51 Not Found\r\n") 71 | } 72 | 73 | func TestGigFile(t *testing.T) { 74 | is := is.New(t) 75 | 76 | g := New() 77 | g.File("/walle", "_fixture/images/walle.png") 78 | b := request("/walle", g) 79 | is.True(strings.HasPrefix(b, "20 ")) 80 | 81 | g.File("/missing", "_fixture/images/johnny.png") 82 | b = request("/missing", g) 83 | is.Equal(b, "51 Not Found\r\n") 84 | } 85 | 86 | func TestGigMiddleware(t *testing.T) { 87 | is := is.New(t) 88 | 89 | g := New() 90 | buf := new(bytes.Buffer) 91 | 92 | g.Pre(func(next HandlerFunc) HandlerFunc { 93 | return func(c Context) error { 94 | is.True(c.Path() == "") 95 | buf.WriteString("-1") 96 | return next(c) 97 | } 98 | }) 99 | 100 | g.Use(func(next HandlerFunc) HandlerFunc { 101 | return func(c Context) error { 102 | buf.WriteString("1") 103 | return next(c) 104 | } 105 | }) 106 | 107 | g.Use(func(next HandlerFunc) HandlerFunc { 108 | return func(c Context) error { 109 | buf.WriteString("2") 110 | return next(c) 111 | } 112 | }) 113 | 114 | g.Use(func(next HandlerFunc) HandlerFunc { 115 | return func(c Context) error { 116 | buf.WriteString("3") 117 | return next(c) 118 | } 119 | }) 120 | 121 | // Route 122 | g.Handle("/", func(c Context) error { 123 | return c.Text("OK") 124 | }) 125 | 126 | b := request("/", g) 127 | 128 | is.Equal("-1123", buf.String()) 129 | is.Equal("20 text/plain\r\nOK", b) 130 | } 131 | 132 | func TestGigMiddlewareError(t *testing.T) { 133 | is := is.New(t) 134 | 135 | g := New() 136 | g.Use(func(next HandlerFunc) HandlerFunc { 137 | return func(c Context) error { 138 | return NewErrorFrom(ErrPermanentFailure, "oops") 139 | } 140 | }) 141 | g.Handle("/", NotFoundHandler) 142 | b := request("/", g) 143 | is.Equal("50 oops\r\n", b) 144 | } 145 | 146 | func TestGigHandler(t *testing.T) { 147 | is := is.New(t) 148 | 149 | g := New() 150 | 151 | // HandlerFunc 152 | g.Handle("/ok", func(c Context) error { 153 | return c.Text("OK") 154 | }) 155 | 156 | b := request("/ok", g) 157 | is.Equal("20 text/plain\r\nOK", b) 158 | } 159 | 160 | func TestGigHandle(t *testing.T) { 161 | is := is.New(t) 162 | 163 | g := New() 164 | g.Handle("/", func(c Context) error { 165 | return c.Text("hello") 166 | }) 167 | 168 | b := request("/", g) 169 | is.Equal("20 text/plain\r\nhello", b) 170 | } 171 | 172 | func TestGigURL(t *testing.T) { 173 | is := is.New(t) 174 | 175 | g := New() 176 | static := func(Context) error { return nil } 177 | getUser := func(Context) error { return nil } 178 | getFile := func(Context) error { return nil } 179 | 180 | g.Handle("/static/file", static) 181 | g.Handle("/users/:id", getUser) 182 | gr := g.Group("/group") 183 | gr.Handle("/users/:uid/files/:fid", getFile) 184 | 185 | is.Equal("/static/file", g.URL(static)) 186 | is.Equal("/users/:id", g.URL(getUser)) 187 | is.Equal("/users/1", g.URL(getUser, "1")) 188 | is.Equal("/group/users/1/files/:fid", g.URL(getFile, "1")) 189 | is.Equal("/group/users/1/files/1", g.URL(getFile, "1", "1")) 190 | } 191 | 192 | func TestGigRoutes(t *testing.T) { 193 | is := is.New(t) 194 | 195 | g := New() 196 | routes := []*Route{ 197 | {"/users/:user/events", ""}, 198 | {"/users/:user/events/public", ""}, 199 | {"/repos/:owner/:repo/git/refs", ""}, 200 | {"/repos/:owner/:repo/git/tags", ""}, 201 | } 202 | 203 | for _, r := range routes { 204 | g.Handle(r.Path, func(c Context) error { 205 | return c.Text("OK") 206 | }) 207 | } 208 | 209 | is.Equal(len(routes), len(g.Routes())) 210 | 211 | for _, r := range g.Routes() { 212 | found := false 213 | 214 | for _, rr := range routes { 215 | if r.Path == rr.Path { 216 | found = true 217 | break 218 | } 219 | } 220 | 221 | if !found { 222 | t.Errorf("Route %s not found", r.Path) 223 | } 224 | } 225 | } 226 | 227 | func TestGigEncodedPath(t *testing.T) { 228 | is := is.New(t) 229 | 230 | g := New() 231 | g.Handle("/:id", func(c Context) error { 232 | return c.NoContent(StatusInput, "please enter name") 233 | }) 234 | 235 | c, conn := g.NewFakeContext("/with%2Fslash", nil) 236 | g.ServeGemini(c) 237 | is.Equal("10 please enter name\r\n", conn.Written) 238 | } 239 | 240 | func TestGigGroup(t *testing.T) { 241 | is := is.New(t) 242 | 243 | g := New() 244 | buf := new(bytes.Buffer) 245 | 246 | g.Use(MiddlewareFunc(func(next HandlerFunc) HandlerFunc { 247 | return func(c Context) error { 248 | buf.WriteString("0") 249 | return next(c) 250 | } 251 | })) 252 | 253 | h := func(c Context) error { 254 | return c.NoContent(StatusInput, "please enter name") 255 | } 256 | 257 | //-------- 258 | // Routes 259 | //-------- 260 | 261 | g.Handle("/users", h) 262 | 263 | // Group 264 | g1 := g.Group("/group1") 265 | g1.Use(func(next HandlerFunc) HandlerFunc { 266 | return func(c Context) error { 267 | buf.WriteString("1") 268 | return next(c) 269 | } 270 | }) 271 | g1.Handle("", h) 272 | 273 | // Nested groups with middleware 274 | g2 := g.Group("/group2") 275 | g2.Use(func(next HandlerFunc) HandlerFunc { 276 | return func(c Context) error { 277 | buf.WriteString("2") 278 | return next(c) 279 | } 280 | }) 281 | 282 | g3 := g2.Group("/group3") 283 | g3.Use(func(next HandlerFunc) HandlerFunc { 284 | return func(c Context) error { 285 | buf.WriteString("3") 286 | return next(c) 287 | } 288 | }) 289 | g3.Handle("", h) 290 | 291 | request("/users", g) 292 | is.Equal("0", buf.String()) 293 | 294 | buf.Reset() 295 | request("/group1", g) 296 | is.Equal("01", buf.String()) 297 | 298 | buf.Reset() 299 | request("/group2/group3", g) 300 | is.Equal("023", buf.String()) 301 | } 302 | 303 | func TestGigNotFound(t *testing.T) { 304 | is := is.New(t) 305 | 306 | g := New() 307 | c, conn := g.NewFakeContext("/files", nil) 308 | g.ServeGemini(c) 309 | is.Equal("51 Not Found\r\n", conn.Written) 310 | } 311 | 312 | func TestGigServeGemini(t *testing.T) { 313 | var ( 314 | is = is.New(t) 315 | g1 = New() 316 | g2 = New() 317 | ctx, conn = g1.NewFakeContext("/files", nil) 318 | ) 319 | 320 | g2.Handle("/", func(c Context) error { 321 | is.True(c.Gig() == g2) 322 | is.True(c != ctx) 323 | return c.NoContent(StatusSuccess, "ok") 324 | }) 325 | 326 | g2.ServeGemini(ctx) 327 | is.Equal("51 Not Found\r\n", conn.Written) 328 | } 329 | 330 | func TestGigRun(t *testing.T) { 331 | g := New() 332 | 333 | go func() { 334 | _ = g.Run("127.0.0.1:0", "_fixture/certs/cert.pem", "_fixture/certs/key.pem") 335 | }() 336 | time.Sleep(200 * time.Millisecond) 337 | 338 | g.Close() 339 | } 340 | 341 | func TestGigRun_BadAddress(t *testing.T) { 342 | is := is.New(t) 343 | 344 | g := New() 345 | err := g.Run("garbage address", "_fixture/certs/cert.pem", "_fixture/certs/key.pem") 346 | is.True(err != nil) 347 | is.True(strings.Contains(err.Error(), "address garbage address: missing port in address")) 348 | } 349 | 350 | func TestGigRunByteString(t *testing.T) { 351 | is := is.New(t) 352 | 353 | cert, err := ioutil.ReadFile("_fixture/certs/cert.pem") 354 | is.NoErr(err) 355 | key, err := ioutil.ReadFile("_fixture/certs/key.pem") 356 | is.NoErr(err) 357 | 358 | switchedCertError := errors.New("tls: failed to find certificate PEM data in certificate input, but did find a private key; PEM inputs may have been switched") 359 | 360 | testCases := []struct { 361 | cert interface{} 362 | key interface{} 363 | expectedErr error 364 | name string 365 | }{ 366 | { 367 | cert: "_fixture/certs/cert.pem", 368 | key: "_fixture/certs/key.pem", 369 | expectedErr: nil, 370 | name: `ValidCertAndKeyFilePath`, 371 | }, 372 | { 373 | cert: cert, 374 | key: key, 375 | expectedErr: nil, 376 | name: `ValidCertAndKeyByteString`, 377 | }, 378 | { 379 | cert: cert, 380 | key: 1, 381 | expectedErr: ErrInvalidCertOrKeyType, 382 | name: `InvalidKeyType`, 383 | }, 384 | { 385 | cert: 0, 386 | key: key, 387 | expectedErr: ErrInvalidCertOrKeyType, 388 | name: `InvalidCertType`, 389 | }, 390 | { 391 | cert: 0, 392 | key: 1, 393 | expectedErr: ErrInvalidCertOrKeyType, 394 | name: `InvalidCertAndKeyTypes`, 395 | }, 396 | { 397 | cert: "_fixture/certs/key.pem", 398 | key: "_fixture/certs/cert.pem", 399 | expectedErr: switchedCertError, 400 | name: `BadCertAndKey`, 401 | }, 402 | } 403 | 404 | for _, test := range testCases { 405 | test := test 406 | t.Run(test.name, func(t *testing.T) { 407 | is := is.New(t) 408 | 409 | g := New() 410 | g.HideBanner = true 411 | 412 | go func() { 413 | err := g.Run("127.0.0.1:0", test.cert, test.key) 414 | if test.expectedErr != nil { 415 | is.Equal(err.Error(), test.expectedErr.Error()) 416 | } else if err != ErrServerClosed { // Prevent the test to fail after closing the servers 417 | is.NoErr(err) 418 | } 419 | }() 420 | time.Sleep(200 * time.Millisecond) 421 | 422 | g.Close() 423 | }) 424 | } 425 | } 426 | 427 | func request(path string, g *Gig) string { 428 | c, conn := g.NewFakeContext(path, nil) 429 | g.ServeGemini(c) 430 | 431 | return conn.Written 432 | } 433 | 434 | func TestGeminiError(t *testing.T) { 435 | is := is.New(t) 436 | 437 | t.Run("manual", func(t *testing.T) { 438 | err := NewError(StatusSlowDown, "oops") 439 | is.Equal("error=oops", err.Error()) 440 | }) 441 | t.Run("existing", func(t *testing.T) { 442 | err := ErrSlowDown 443 | is.Equal("error=Slow Down", err.Error()) 444 | }) 445 | t.Run("inherited", func(t *testing.T) { 446 | err := NewErrorFrom(ErrSlowDown, "oops") 447 | is.Equal("error=oops", err.Error()) 448 | }) 449 | } 450 | 451 | func TestGigClose(t *testing.T) { 452 | is := is.New(t) 453 | 454 | g := New() 455 | errCh := make(chan error) 456 | 457 | go func() { 458 | errCh <- g.Run("127.0.0.1:0", "_fixture/certs/cert.pem", "_fixture/certs/key.pem") 459 | }() 460 | 461 | time.Sleep(200 * time.Millisecond) 462 | 463 | if err := g.Close(); err != nil { 464 | t.Fatal(err) 465 | } 466 | 467 | is.True(strings.Contains(g.Close().Error(), "use of closed network connection")) 468 | 469 | err := <-errCh 470 | is.Equal(err.Error(), "gemini: Server closed") 471 | } 472 | 473 | type ( 474 | fastFakeConn struct{} 475 | ) 476 | 477 | func (*fastFakeConn) Close() error { return nil } 478 | func (*fastFakeConn) Read(b []byte) (int, error) { return copy(b, "gemini://127.0.0.1/\r\n"), nil } 479 | func (*fastFakeConn) Write(b []byte) (n int, err error) { return len(b), nil } 480 | func (*fastFakeConn) RemoteAddr() net.Addr { return &FakeAddr{} } 481 | func (*fastFakeConn) LocalAddr() net.Addr { return &FakeAddr{} } 482 | func (*fastFakeConn) SetDeadline(t time.Time) error { return nil } 483 | func (*fastFakeConn) SetReadDeadline(t time.Time) error { return nil } 484 | func (*fastFakeConn) SetWriteDeadline(t time.Time) error { return nil } 485 | func (*fastFakeConn) ConnectionState() tls.ConnectionState { return tls.ConnectionState{} } 486 | 487 | func BenchmarkGig(b *testing.B) { 488 | var ( 489 | ok = []byte("ok") 490 | g = New() 491 | conn fastFakeConn 492 | ctx = g.ctxpool.New() 493 | buf = g.bufpool.New() 494 | ) 495 | 496 | // pre-alloc 1 context and buffer to avoid their allocation during benchmarking 497 | g.ctxpool.New = func() interface{} { return ctx } 498 | g.bufpool.New = func() interface{} { return buf } 499 | 500 | g.HidePort = true 501 | g.HideBanner = true 502 | 503 | g.Handle("/", func(c Context) error { 504 | return c.GeminiBlob(ok) 505 | }) 506 | 507 | go func() { 508 | _ = g.Run("127.0.0.1:1965", "_fixture/certs/cert.pem", "_fixture/certs/key.pem") 509 | }() 510 | time.Sleep(200 * time.Millisecond) 511 | 512 | defer g.Close() 513 | 514 | b.ReportAllocs() 515 | b.ResetTimer() 516 | 517 | for n := 0; n < b.N; n++ { 518 | g.handleRequest(&conn) 519 | } 520 | } 521 | -------------------------------------------------------------------------------- /gigtest.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "net" 7 | "net/url" 8 | "time" 9 | ) 10 | 11 | type ( 12 | // FakeAddr ia a fake net.Addr implementation. 13 | FakeAddr struct{} 14 | // FakeConn ia a fake net.Conn that can record what is written and can fail 15 | // after FailAfter bytes were written. 16 | FakeConn struct { 17 | FailAfter int 18 | Written string 19 | } 20 | ) 21 | 22 | // Network returns dummy data. 23 | func (a *FakeAddr) Network() string { return "tcp" } 24 | 25 | // String returns dummy data. 26 | func (a *FakeAddr) String() string { return "192.0.2.1:25" } 27 | 28 | // Read always returns success. 29 | func (c *FakeConn) Read(b []byte) (n int, err error) { return len(b), nil } 30 | 31 | // Write records bytes written and fails after FailAfter bytes. 32 | func (c *FakeConn) Write(b []byte) (n int, err error) { 33 | if c.FailAfter > 0 && len(c.Written)+len(b) > c.FailAfter { 34 | cut := c.FailAfter - len(c.Written) 35 | c.Written += string(b[:cut]) 36 | 37 | return cut, errors.New("cannot write") 38 | } 39 | 40 | c.Written += string(b) 41 | 42 | return len(b), nil 43 | } 44 | 45 | // Close always returns nil. 46 | func (c *FakeConn) Close() error { return nil } 47 | 48 | // LocalAddr returns fake address. 49 | func (c *FakeConn) LocalAddr() net.Addr { return &FakeAddr{} } 50 | 51 | // RemoteAddr returns fake address. 52 | func (c *FakeConn) RemoteAddr() net.Addr { return &FakeAddr{} } 53 | 54 | // SetDeadline always returns nil. 55 | func (c *FakeConn) SetDeadline(t time.Time) error { return nil } 56 | 57 | // SetReadDeadline always returns nil. 58 | func (c *FakeConn) SetReadDeadline(t time.Time) error { return nil } 59 | 60 | // SetWriteDeadline always returns nil. 61 | func (c *FakeConn) SetWriteDeadline(t time.Time) error { return nil } 62 | 63 | // ConnectionState always returns nil. 64 | func (c *FakeConn) ConnectionState() tls.ConnectionState { return tls.ConnectionState{} } 65 | 66 | // NewFakeContext returns Context that writes to FakeConn. 67 | func (g *Gig) NewFakeContext(uri string, tlsState *tls.ConnectionState) (Context, *FakeConn) { 68 | u, err := url.Parse(uri) 69 | if err != nil { 70 | panic(err) 71 | } 72 | 73 | conn := &FakeConn{} 74 | 75 | return g.newContext(conn, u, uri, tlsState), conn 76 | } 77 | -------------------------------------------------------------------------------- /gigtest_test.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/matryer/is" 8 | ) 9 | 10 | func TestNewFakeContext(t *testing.T) { 11 | is := is.New(t) 12 | g := New() 13 | 14 | c, conn := g.NewFakeContext("/login", nil) 15 | 16 | is.NoErr(c.Response().WriteHeader(StatusGone, "oops")) 17 | is.Equal("52 oops\r\n", conn.Written) 18 | 19 | n, err := conn.Read(make([]byte, 1)) 20 | is.Equal(1, n) 21 | is.NoErr(err) 22 | 23 | n, err = conn.Write([]byte("test")) 24 | is.Equal(4, n) 25 | is.NoErr(err) 26 | 27 | is.Equal(nil, conn.Close()) 28 | is.Equal(conn.LocalAddr().String(), "192.0.2.1:25") 29 | is.Equal(conn.RemoteAddr().String(), "192.0.2.1:25") 30 | is.Equal(nil, conn.SetDeadline(time.Now())) 31 | is.Equal(nil, conn.SetReadDeadline(time.Now())) 32 | is.Equal(nil, conn.SetWriteDeadline(time.Now())) 33 | } 34 | 35 | func TestNewFakeContext_panic(t *testing.T) { 36 | var ( 37 | is = is.New(t) 38 | g = New() 39 | ) 40 | 41 | defer func() { 42 | r := recover() 43 | is.True(r != nil) 44 | }() 45 | 46 | _, _ = g.NewFakeContext(":", nil) 47 | 48 | is.Fail() 49 | } 50 | 51 | func TestFakeAddr(t *testing.T) { 52 | is := is.New(t) 53 | addr := &FakeAddr{} 54 | 55 | is.Equal("tcp", addr.Network()) 56 | is.Equal("192.0.2.1:25", addr.String()) 57 | } 58 | 59 | func TestFakeConn(t *testing.T) { 60 | is := is.New(t) 61 | conn := &FakeConn{FailAfter: 5} 62 | 63 | n, err := conn.Write([]byte("test")) 64 | is.Equal(4, n) 65 | is.NoErr(err) 66 | 67 | n, err = conn.Write([]byte("more")) 68 | is.Equal(1, n) 69 | 70 | if err == nil { 71 | is.Fail() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pitr/gig 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/matryer/is v1.3.0 7 | github.com/valyala/fasttemplate v1.1.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/matryer/is v1.3.0 h1:9qiso3jaJrOe6qBRJRBt2Ldht05qDiFP9le0JOIhRSI= 2 | github.com/matryer/is v1.3.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= 3 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 4 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 5 | github.com/valyala/fasttemplate v1.1.0 h1:RZqt0yGBsps8NGvLSGW804QQqCUYYLsaOjTVHy1Ocw4= 6 | github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= 7 | -------------------------------------------------------------------------------- /group.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | type ( 4 | // Group is a set of sub-routes for a specified route. It can be used for inner 5 | // routes that share a common middleware or functionality that should be separate 6 | // from the parent gig instance while still inheriting from it. 7 | Group struct { 8 | common 9 | prefix string 10 | middleware []MiddlewareFunc 11 | gig *Gig 12 | } 13 | ) 14 | 15 | // Use implements `Gig#Use()` for sub-routes within the Group. 16 | func (g *Group) Use(middleware ...MiddlewareFunc) { 17 | g.middleware = append(g.middleware, middleware...) 18 | if len(g.middleware) == 0 { 19 | return 20 | } 21 | // Allow all requests to reach the group as they might get dropped if router 22 | // doesn't find a match, making none of the group middleware process. 23 | g.Handle("", NotFoundHandler) 24 | g.Handle("/*", NotFoundHandler) 25 | } 26 | 27 | // Handle implements `Gig#Handle()` for sub-routes within the Group. 28 | func (g *Group) Handle(path string, h HandlerFunc, m ...MiddlewareFunc) *Route { 29 | return g.add(path, h, m...) 30 | } 31 | 32 | // Group creates a new sub-group with prefix and optional sub-group-level middleware. 33 | func (g *Group) Group(prefix string, middleware ...MiddlewareFunc) *Group { 34 | m := make([]MiddlewareFunc, 0, len(g.middleware)+len(middleware)) 35 | m = append(m, g.middleware...) 36 | m = append(m, middleware...) 37 | 38 | return g.gig.Group(g.prefix+prefix, m...) 39 | } 40 | 41 | // Static implements `Gig#Static()` for sub-routes within the Group. 42 | func (g *Group) Static(prefix, root string) { 43 | g.static(prefix, root, g.Handle) 44 | } 45 | 46 | // File implements `Gig#File()` for sub-routes within the Group. 47 | func (g *Group) File(path, file string) { 48 | g.file(path, file, g.Handle) 49 | } 50 | 51 | func (g *Group) add(path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route { 52 | // Combine into a new slice to avoid accidentally passing the same slice for 53 | // multiple routes, which would lead to later add() calls overwriting the 54 | // middleware from earlier calls. 55 | m := make([]MiddlewareFunc, 0, len(g.middleware)+len(middleware)) 56 | m = append(m, g.middleware...) 57 | m = append(m, middleware...) 58 | 59 | return g.gig.add(g.prefix+path, handler, m...) 60 | } 61 | -------------------------------------------------------------------------------- /group_test.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "io/ioutil" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/matryer/is" 9 | ) 10 | 11 | func TestGroupFile(t *testing.T) { 12 | gig := New() 13 | g := gig.Group("/group") 14 | g.File("/walle", "_fixture/images/walle.png") 15 | 16 | expectedData, err := ioutil.ReadFile("_fixture/images/walle.png") 17 | 18 | is := is.New(t) 19 | 20 | is.NoErr(err) 21 | 22 | c, conn := gig.NewFakeContext("/group/walle", nil) 23 | gig.ServeGemini(c) 24 | is.Equal("20 image/png\r\n"+string(expectedData), conn.Written) 25 | } 26 | 27 | func TestGroupStatic(t *testing.T) { 28 | is := is.New(t) 29 | gig := New() 30 | g := gig.Group("/group") 31 | 32 | // OK 33 | g.Static("/images", "_fixture/images") 34 | 35 | b := request("/group/images/walle.png", gig) 36 | is.True(strings.HasPrefix(b, "20 image/png\r\n")) 37 | 38 | // No file 39 | g.Static("/images", "_fixture/scripts") 40 | 41 | b = request("/group/images/bolt.png", gig) 42 | is.Equal("51 Not Found\r\n", b) 43 | 44 | // Directory 45 | g.Static("/images", "_fixture/images") 46 | 47 | b = request("/group/images", gig) 48 | is.Equal("51 Not Found\r\n", b) 49 | 50 | b = request("/group/images/", gig) 51 | is.Equal("20 text/gemini\r\n# Listing /group/images/\n\n=> /group/images/walle.png walle.png [ 219.9kB ]\n", b) 52 | 53 | // Directory with index.gmi 54 | g.Static("/d", "_fixture") 55 | 56 | b = request("/group/d/", gig) 57 | is.Equal("20 text/gemini\r\n# Hello from gig\n\n=> / 🏠 Home\n", b) 58 | 59 | // Sub-directory with index.gmi 60 | b = request("/group/d/folder", gig) 61 | is.Equal("20 text/gemini\r\n# Listing /group/d/folder\n\n=> /group/d/folder/about.gmi about.gmi [ 29B ]\n=> /group/d/folder/another.blah another.blah [ 14B ]\n", b) 62 | 63 | // File without known mime 64 | b = request("/group/d/folder/another.blah", gig) 65 | is.Equal("20 octet/stream\r\n# Another page", b) 66 | 67 | // Escape 68 | b = request("/d/../../../../../../../../etc/profile", gig) 69 | is.Equal(b, "51 Not Found\r\n") 70 | } 71 | 72 | func TestGroupRouteMiddleware(t *testing.T) { 73 | // Ensure middleware slices are not re-used 74 | gig := New() 75 | g := gig.Group("/group") 76 | h := func(Context) error { return nil } 77 | m1 := func(next HandlerFunc) HandlerFunc { 78 | return func(c Context) error { 79 | return next(c) 80 | } 81 | } 82 | m2 := func(next HandlerFunc) HandlerFunc { 83 | return func(c Context) error { 84 | return next(c) 85 | } 86 | } 87 | m3 := func(next HandlerFunc) HandlerFunc { 88 | return func(c Context) error { 89 | return next(c) 90 | } 91 | } 92 | m4 := func(next HandlerFunc) HandlerFunc { 93 | return func(c Context) error { 94 | return c.NoContent(40, "oops") 95 | } 96 | } 97 | m5 := func(next HandlerFunc) HandlerFunc { 98 | return func(c Context) error { 99 | return c.NoContent(40, "another") 100 | } 101 | } 102 | 103 | g.Use(m1, m2, m3) 104 | g.Handle("/40_1", h, m4) 105 | g.Handle("/40_2", h, m5) 106 | 107 | is := is.New(t) 108 | 109 | b := request("/group/40_1", gig) 110 | is.Equal("40 oops\r\n", b) 111 | b = request("/group/40_2", gig) 112 | is.Equal("40 another\r\n", b) 113 | } 114 | 115 | func TestGroupRouteMiddlewareWithMatchAny(t *testing.T) { 116 | // Ensure middleware and match any routes do not conflict 117 | gig := New() 118 | g := gig.Group("/group") 119 | m1 := func(next HandlerFunc) HandlerFunc { 120 | return func(c Context) error { 121 | return next(c) 122 | } 123 | } 124 | m2 := func(next HandlerFunc) HandlerFunc { 125 | return func(c Context) error { 126 | return c.Text(c.Path()) 127 | } 128 | } 129 | h := func(c Context) error { 130 | return c.Text(c.Path()) 131 | } 132 | 133 | g.Use(m1) 134 | g.Handle("/help", h, m2) 135 | g.Handle("/*", h, m2) 136 | g.Handle("", h, m2) 137 | gig.Handle("unrelated", h, m2) 138 | gig.Handle("*", h, m2) 139 | 140 | is := is.New(t) 141 | 142 | b := request("/group/help", gig) 143 | is.Equal("20 text/plain\r\n/group/help", b) 144 | b = request("/group/help/other", gig) 145 | is.Equal("20 text/plain\r\n/group/*", b) 146 | b = request("/group/404", gig) 147 | is.Equal("20 text/plain\r\n/group/*", b) 148 | b = request("/group", gig) 149 | is.Equal("20 text/plain\r\n/group", b) 150 | b = request("/other", gig) 151 | is.Equal("20 text/plain\r\n/*", b) 152 | b = request("/", gig) 153 | is.Equal("20 text/plain\r\n", b) 154 | } 155 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "strconv" 8 | "sync" 9 | "time" 10 | 11 | "github.com/valyala/fasttemplate" 12 | ) 13 | 14 | type ( 15 | // LoggerConfig defines the config for Logger middleware. 16 | LoggerConfig struct { 17 | // Skipper defines a function to skip middleware. 18 | Skipper Skipper 19 | 20 | // Tags to construct the logger format. 21 | // 22 | // - time_unix 23 | // - time_unix_nano 24 | // - time_rfc3339 25 | // - time_rfc3339_nano 26 | // - time_custom 27 | // - remote_ip 28 | // - uri 29 | // - host 30 | // - path 31 | // - status 32 | // - error 33 | // - latency (In nanoseconds) 34 | // - latency_human (Human readable) 35 | // - bytes_in (Bytes received) 36 | // - bytes_out (Bytes sent) 37 | // - meta 38 | // - query 39 | // 40 | // Example "${remote_ip} ${status}" 41 | // 42 | // Optional. Default value DefaultLoggerConfig.Format. 43 | Format string 44 | 45 | // Optional. Default value DefaultLoggerConfig.CustomTimeFormat. 46 | CustomTimeFormat string 47 | 48 | template *fasttemplate.Template 49 | pool *sync.Pool 50 | } 51 | ) 52 | 53 | var ( 54 | // DefaultLoggerConfig is the default Logger middleware config. 55 | DefaultLoggerConfig = LoggerConfig{ 56 | Skipper: DefaultSkipper, 57 | Format: "time=\"${time_rfc3339}\" path=${path} status=${status} duration=${latency} ${error}\n", 58 | CustomTimeFormat: "2006-01-02 15:04:05.00000", 59 | } 60 | ) 61 | 62 | // Logger returns a middleware that logs Gemini requests. 63 | func Logger() MiddlewareFunc { 64 | return LoggerWithConfig(DefaultLoggerConfig) 65 | } 66 | 67 | // LoggerWithConfig returns a Logger middleware with config. 68 | // See: `Logger()`. 69 | func LoggerWithConfig(config LoggerConfig) MiddlewareFunc { 70 | // Defaults 71 | if config.Skipper == nil { 72 | config.Skipper = DefaultLoggerConfig.Skipper 73 | } 74 | 75 | if config.Format == "" { 76 | config.Format = DefaultLoggerConfig.Format 77 | } 78 | 79 | config.template = fasttemplate.New(config.Format, "${", "}") 80 | config.pool = &sync.Pool{ 81 | New: func() interface{} { 82 | return bytes.NewBuffer(make([]byte, 256)) 83 | }, 84 | } 85 | 86 | return func(next HandlerFunc) HandlerFunc { 87 | return func(c Context) (err error) { 88 | if config.Skipper(c) { 89 | return next(c) 90 | } 91 | 92 | start := time.Now() 93 | 94 | if err = next(c); err != nil { 95 | c.Error(err) 96 | } 97 | 98 | stop := time.Now() 99 | buf := config.pool.Get().(*bytes.Buffer) 100 | buf.Reset() 101 | 102 | defer config.pool.Put(buf) 103 | 104 | res := c.Response() 105 | 106 | if _, err = config.template.ExecuteFunc(buf, func(w io.Writer, tag string) (int, error) { 107 | switch tag { 108 | case "time_unix": 109 | return buf.WriteString(strconv.FormatInt(time.Now().Unix(), 10)) 110 | case "time_unix_nano": 111 | return buf.WriteString(strconv.FormatInt(time.Now().UnixNano(), 10)) 112 | case "time_rfc3339": 113 | return buf.WriteString(time.Now().Format(time.RFC3339)) 114 | case "time_rfc3339_nano": 115 | return buf.WriteString(time.Now().Format(time.RFC3339Nano)) 116 | case "time_custom": 117 | return buf.WriteString(time.Now().Format(config.CustomTimeFormat)) 118 | case "remote_ip": 119 | return buf.WriteString(c.IP()) 120 | case "host": 121 | return buf.WriteString(c.URL().Host) 122 | case "uri": 123 | return buf.WriteString(c.RequestURI()) 124 | case "path": 125 | p := c.URL().Path 126 | if p == "" { 127 | p = "/" 128 | } 129 | return buf.WriteString(p) 130 | case "status": 131 | return buf.WriteString(strconv.FormatInt(int64(res.Status), 10)) 132 | case "error": 133 | if err != nil { 134 | return buf.WriteString(err.Error()) 135 | } 136 | case "latency": 137 | ms := float64(stop.Sub(start)) / float64(time.Millisecond) 138 | return fmt.Fprintf(buf, "%.2f", ms) 139 | case "latency_human": 140 | return buf.WriteString(stop.Sub(start).String()) 141 | case "bytes_in": 142 | i := len(c.RequestURI()) 143 | return buf.WriteString(strconv.FormatInt(int64(i), 10)) 144 | case "bytes_out": 145 | return buf.WriteString(strconv.FormatInt(res.Size, 10)) 146 | case "meta": 147 | return buf.WriteString(res.Meta) 148 | case "query": 149 | query, err := c.QueryString() 150 | if err == nil { 151 | return buf.Write([]byte(query)) 152 | } 153 | } 154 | return 0, nil 155 | }); err != nil { 156 | return 157 | } 158 | 159 | fmt.Fprint(DefaultWriter, buf.String()) 160 | 161 | return 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /logger_test.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "strings" 8 | "testing" 9 | "time" 10 | "unsafe" 11 | 12 | "github.com/matryer/is" 13 | ) 14 | 15 | func TestLogger(t *testing.T) { 16 | is := is.New(t) 17 | 18 | // Note: Just for the test coverage, not a real test. 19 | g := New() 20 | c, _ := g.NewFakeContext("/", nil) 21 | 22 | h := Logger()(func(c Context) error { 23 | return c.Gemini("test") 24 | }) 25 | 26 | // Status 2x 27 | is.NoErr(h(c)) 28 | 29 | // Status 3x 30 | c, _ = g.NewFakeContext("/", nil) 31 | h = Logger()(func(c Context) error { 32 | return c.NoContent(StatusRedirectTemporary, "test") 33 | }) 34 | is.NoErr(h(c)) 35 | 36 | // Status 4x 37 | c, _ = g.NewFakeContext("/", nil) 38 | h = Logger()(func(c Context) error { 39 | return c.NoContent(StatusSlowDown, "test") 40 | }) 41 | is.NoErr(h(c)) 42 | 43 | // Status 5x with empty path 44 | c, _ = g.NewFakeContext("/", nil) 45 | h = Logger()(func(c Context) error { 46 | return errors.New("error") 47 | }) 48 | is.NoErr(h(c)) 49 | 50 | // Status 6x with empty path 51 | c, _ = g.NewFakeContext("/", nil) 52 | h = Logger()(func(c Context) error { 53 | return c.NoContent(StatusClientCertificateRequired, "test") 54 | }) 55 | is.NoErr(h(c)) 56 | } 57 | 58 | func TestLoggerTemplate(t *testing.T) { 59 | buf := new(bytes.Buffer) 60 | oldWriter := DefaultWriter 61 | DefaultWriter = buf 62 | 63 | defer func() { 64 | DefaultWriter = oldWriter 65 | }() 66 | 67 | g := New() 68 | g.Use(LoggerWithConfig(LoggerConfig{ 69 | Format: `{"time":"${time_rfc3339_nano}","time_unix":"${time_unix}",` + 70 | `"time_unix_nano":"${time_unix_nano}","time_rfc3339":"${time_rfc3339}",` + 71 | `"id":"${id}","remote_ip":"${remote_ip}","host":"${host}",` + 72 | `""uri":"${uri}","status":${status}, "latency":${latency},` + 73 | `"latency_human":"${latency_human}","bytes_in":${bytes_in}, "path":"${path}", ` + 74 | `"bytes_out":${bytes_out},"us":"${query}","meta":"${meta}"}` + "\n", 75 | })) 76 | 77 | g.Handle("/login", func(c Context) error { 78 | return c.Gemini("Header Logged") 79 | }) 80 | 81 | c, _ := g.NewFakeContext("/login?username=apagano-param&password=secret", nil) 82 | g.ServeGemini(c) 83 | 84 | cases := []string{ 85 | "apagano-param", 86 | "\"path\":\"/login\"", 87 | "\"uri\":\"/login?user", 88 | "\"remote_ip\":\"192.0.2.1\"", 89 | "\"status\":20", 90 | "\"bytes_in\":45,", 91 | "\"meta\":\"text/gemini", 92 | } 93 | 94 | for _, token := range cases { 95 | is := is.New(t) 96 | t.Run(token, func(t *testing.T) { 97 | is.True(strings.Contains(buf.String(), token)) 98 | }) 99 | } 100 | } 101 | 102 | func TestLoggerCustomTimestamp(t *testing.T) { 103 | is := is.New(t) 104 | buf := new(bytes.Buffer) 105 | oldWriter := DefaultWriter 106 | DefaultWriter = buf 107 | 108 | defer func() { 109 | DefaultWriter = oldWriter 110 | }() 111 | 112 | customTimeFormat := "2006-01-02 15:04:05.00000" 113 | g := New() 114 | g.Use(LoggerWithConfig(LoggerConfig{ 115 | Format: `{"time":"${time_custom}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","user_agent":"${user_agent}",` + 116 | `"method":"${method}","uri":"${uri}","status":${status}, "latency":${latency},` + 117 | `"latency_human":"${latency_human}","bytes_in":${bytes_in}, "path":"${path}", "referer":"${referer}",` + 118 | `"bytes_out":${bytes_out},"ch":"${header:X-Custom-Header}",` + 119 | `"us":"${query:username}", "cf":"${form:username}", "session":"${cookie:session}"}` + "\n", 120 | CustomTimeFormat: customTimeFormat, 121 | })) 122 | 123 | g.Handle("/", func(c Context) error { 124 | return c.Gemini("custom time stamp test") 125 | }) 126 | 127 | c, _ := g.NewFakeContext("/", nil) 128 | g.ServeGemini(c) 129 | 130 | var objs map[string]*json.RawMessage 131 | if err := json.Unmarshal(buf.Bytes(), &objs); err != nil { 132 | is.Fail() 133 | } 134 | 135 | loggedTime := *(*string)(unsafe.Pointer(objs["time"])) 136 | _, err := time.Parse(customTimeFormat, loggedTime) 137 | is.True(err != nil) 138 | } 139 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | type ( 4 | // Skipper defines a function to skip middleware. Returning true skips processing 5 | // the middleware. 6 | Skipper func(Context) bool 7 | ) 8 | 9 | // DefaultSkipper returns false which processes the middleware. 10 | func DefaultSkipper(Context) bool { 11 | return false 12 | } 13 | -------------------------------------------------------------------------------- /pass_auth.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | type ( 4 | // PassAuthCertCheck defines a function to validate certificate fingerprint. 5 | // Must return path on unsuccessful login. 6 | PassAuthCertCheck func(string, Context) (string, error) 7 | // PassAuthLogin defines a function to login user. 8 | // It may pin certificate to user if login is successful. 9 | // Must return path to redirect to after login. 10 | PassAuthLogin func(username, password, sig string, c Context) (string, error) 11 | ) 12 | 13 | // PassAuth is a middleware that implements username/password authentication 14 | // by first requiring a certificate, checking username/password using PassAuthValidator, 15 | // and then pinning certificate to it. 16 | // 17 | // For valid credentials it calls the next handler. 18 | func PassAuth(check PassAuthCertCheck) MiddlewareFunc { 19 | return func(next HandlerFunc) HandlerFunc { 20 | return func(c Context) error { 21 | // If no client certificate is sent, request it 22 | var sig = c.CertHash() 23 | if sig == "" { 24 | return c.NoContent(StatusClientCertificateRequired, "Please create a certificate") 25 | } 26 | 27 | to, err := check(sig, c) 28 | if err != nil { 29 | debugPrintf("gemini: could not check certificate: %s", err) 30 | return c.NoContent(StatusBadRequest, "Try again later") 31 | } 32 | 33 | if to != "" { 34 | return c.NoContent(StatusRedirectTemporary, to) 35 | } 36 | 37 | return next(c) 38 | } 39 | } 40 | } 41 | 42 | // PassAuthLoginHandle sets up handlers to check username/password using PassAuthLogin. 43 | func (g *Gig) PassAuthLoginHandle(path string, fn PassAuthLogin) { 44 | g.Handle(path, func(c Context) error { 45 | cert := c.Certificate() 46 | if cert == nil { 47 | return c.NoContent(StatusClientCertificateRequired, "Please create a certificate") 48 | } 49 | username, err := c.QueryString() 50 | if err != nil { 51 | debugPrintf("gemini: could not extract username from URL: %s", err) 52 | return c.NoContent(StatusBadRequest, "Invalid username received") 53 | } 54 | 55 | if username == "" { 56 | return c.NoContent(StatusInput, "Enter username") 57 | } 58 | 59 | return c.NoContent(StatusRedirectTemporary, "%s/%s", path, username) 60 | }) 61 | 62 | g.Handle(path+"/:username", func(c Context) error { 63 | var ( 64 | username = c.Param("username") 65 | sig = c.CertHash() 66 | ) 67 | 68 | if sig == "" { 69 | return c.NoContent(StatusClientCertificateRequired, "Please create a certificate") 70 | } 71 | 72 | if username == "" { 73 | return c.NoContent(StatusRedirectTemporary, path) 74 | } 75 | 76 | password, err := c.QueryString() 77 | 78 | if err != nil { 79 | debugPrintf("gemini: could not extract password from URL: %s", err) 80 | return c.NoContent(StatusBadRequest, "Invalid password received") 81 | } 82 | 83 | if password == "" { 84 | return c.NoContent(StatusSensitiveInput, "Enter password") 85 | } 86 | 87 | to, err := fn(username, password, sig, c) 88 | 89 | if err != nil { 90 | return err 91 | } 92 | 93 | return c.NoContent(StatusRedirectTemporary, to) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /pass_auth_test.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "testing" 10 | 11 | "github.com/matryer/is" 12 | ) 13 | 14 | func TestPassAuth(t *testing.T) { 15 | var ( 16 | is = is.New(t) 17 | g = New() 18 | invalidErr = errors.New("invalid credentials") 19 | goodCert = x509.Certificate{Raw: []byte{1}} 20 | newCert = x509.Certificate{Raw: []byte{2}} 21 | mw = PassAuth(func(sig string, c Context) (string, error) { 22 | if sig == fmt.Sprintf("%x", md5.Sum(goodCert.Raw)) { 23 | return "", nil 24 | } 25 | return "/login", nil 26 | }) 27 | ) 28 | 29 | g.Handle("/", func(c Context) error { 30 | return c.Gemini("ok") 31 | }) 32 | g.PassAuthLoginHandle("/login", func(u, p, sig string, c Context) (string, error) { 33 | if u == "valid-user" && p == "secret" { 34 | return "/private", nil 35 | } 36 | return "", invalidErr 37 | }) 38 | g.Handle("/private", func(c Context) error { 39 | return c.Gemini("private") 40 | }, mw) 41 | 42 | // Public endpoint 43 | c, res := g.NewFakeContext("/", nil) 44 | g.ServeGemini(c) 45 | is.Equal(res.Written, "20 text/gemini\r\nok") 46 | 47 | // No certificate 48 | c, res = g.NewFakeContext("/private", nil) 49 | g.ServeGemini(c) 50 | is.Equal(res.Written, "60 Please create a certificate\r\n") 51 | 52 | // New certificate 53 | c, res = g.NewFakeContext("/private", &tls.ConnectionState{ 54 | PeerCertificates: []*x509.Certificate{&newCert}, 55 | }) 56 | g.ServeGemini(c) 57 | is.Equal(res.Written, "30 /login\r\n") 58 | 59 | // Try login with new certificate 60 | c, res = g.NewFakeContext("/login", &tls.ConnectionState{ 61 | PeerCertificates: []*x509.Certificate{&newCert}, 62 | }) 63 | g.ServeGemini(c) 64 | is.Equal(res.Written, "10 Enter username\r\n") 65 | 66 | c, res = g.NewFakeContext("/login?valid-user", &tls.ConnectionState{ 67 | PeerCertificates: []*x509.Certificate{&newCert}, 68 | }) 69 | g.ServeGemini(c) 70 | is.Equal(res.Written, "30 /login/valid-user\r\n") 71 | 72 | c, res = g.NewFakeContext("/login/valid-user", &tls.ConnectionState{ 73 | PeerCertificates: []*x509.Certificate{&newCert}, 74 | }) 75 | g.ServeGemini(c) 76 | is.Equal(res.Written, "11 Enter password\r\n") 77 | 78 | c, res = g.NewFakeContext("/login/valid-user?bad-pass", &tls.ConnectionState{ 79 | PeerCertificates: []*x509.Certificate{&newCert}, 80 | }) 81 | g.ServeGemini(c) 82 | is.Equal(res.Written, "50 invalid credentials\r\n") 83 | 84 | c, res = g.NewFakeContext("/login/valid-user?secret", &tls.ConnectionState{ 85 | PeerCertificates: []*x509.Certificate{&newCert}, 86 | }) 87 | g.ServeGemini(c) 88 | is.Equal(res.Written, "30 /private\r\n") 89 | 90 | // Logged in certificate 91 | c, res = g.NewFakeContext("/private", &tls.ConnectionState{ 92 | PeerCertificates: []*x509.Certificate{&goodCert}, 93 | }) 94 | g.ServeGemini(c) 95 | is.Equal(res.Written, "20 text/gemini\r\nprivate") 96 | } 97 | 98 | func TestPassAuth_Errors(t *testing.T) { 99 | var ( 100 | is = is.New(t) 101 | g = New() 102 | newCert = x509.Certificate{Raw: []byte{2}} 103 | mw = PassAuth(func(sig string, c Context) (string, error) { 104 | return "/login", errors.New("oops") 105 | }) 106 | ) 107 | 108 | g.Handle("/", func(c Context) error { 109 | return c.Gemini("ok") 110 | }) 111 | g.PassAuthLoginHandle("/login", func(u, p, sig string, c Context) (string, error) { 112 | return "", errors.New("oops") 113 | }) 114 | g.Handle("/private", func(c Context) error { 115 | return c.Gemini("private") 116 | }, mw) 117 | 118 | // CertCheck fails 119 | c, res := g.NewFakeContext("/private", &tls.ConnectionState{ 120 | PeerCertificates: []*x509.Certificate{&newCert}, 121 | }) 122 | g.ServeGemini(c) 123 | is.Equal(res.Written, "59 Try again later\r\n") 124 | 125 | // Bad username 126 | c, res = g.NewFakeContext("/login?%%", &tls.ConnectionState{ 127 | PeerCertificates: []*x509.Certificate{&newCert}, 128 | }) 129 | g.ServeGemini(c) 130 | is.Equal(res.Written, "59 Invalid username received\r\n") 131 | 132 | // Bad password 133 | c, res = g.NewFakeContext("/login/valid-user?%%", &tls.ConnectionState{ 134 | PeerCertificates: []*x509.Certificate{&newCert}, 135 | }) 136 | g.ServeGemini(c) 137 | is.Equal(res.Written, "59 Invalid password received\r\n") 138 | 139 | // Login fails 140 | c, res = g.NewFakeContext("/login/valid-user?secret", &tls.ConnectionState{ 141 | PeerCertificates: []*x509.Certificate{&newCert}, 142 | }) 143 | g.ServeGemini(c) 144 | is.Equal(res.Written, "50 oops\r\n") 145 | } 146 | -------------------------------------------------------------------------------- /recover.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | type ( 9 | // RecoverConfig defines the config for Recover middleware. 10 | RecoverConfig struct { 11 | // Skipper defines a function to skip middleware. 12 | Skipper Skipper 13 | 14 | // Size of the stack to be printed. 15 | // Optional. Default value 4KB. 16 | StackSize int 17 | 18 | // DisableStackAll disables formatting stack traces of all other goroutines 19 | // into buffer after the trace for the current goroutine. 20 | // Optional. Default value false. 21 | DisableStackAll bool 22 | 23 | // DisablePrintStack disables printing stack trace. 24 | // Optional. Default value as false. 25 | DisablePrintStack bool 26 | } 27 | ) 28 | 29 | var ( 30 | // DefaultRecoverConfig is the default Recover middleware config. 31 | DefaultRecoverConfig = RecoverConfig{ 32 | Skipper: DefaultSkipper, 33 | StackSize: 4 << 10, // 4 KB 34 | DisableStackAll: false, 35 | DisablePrintStack: false, 36 | } 37 | ) 38 | 39 | // Recover returns a middleware which recovers from panics anywhere in the chain 40 | // and handles the control to the centralized GeminiErrorHandler. 41 | func Recover() MiddlewareFunc { 42 | return RecoverWithConfig(DefaultRecoverConfig) 43 | } 44 | 45 | // RecoverWithConfig returns a Recover middleware with config. 46 | // See: `Recover()`. 47 | func RecoverWithConfig(config RecoverConfig) MiddlewareFunc { 48 | // Defaults 49 | if config.Skipper == nil { 50 | config.Skipper = DefaultRecoverConfig.Skipper 51 | } 52 | 53 | if config.StackSize == 0 { 54 | config.StackSize = DefaultRecoverConfig.StackSize 55 | } 56 | 57 | return func(next HandlerFunc) HandlerFunc { 58 | return func(c Context) error { 59 | if config.Skipper(c) { 60 | return next(c) 61 | } 62 | 63 | defer func() { 64 | if r := recover(); r != nil { 65 | err, ok := r.(error) 66 | if !ok { 67 | err = fmt.Errorf("%v", r) 68 | } 69 | 70 | if !config.DisablePrintStack { 71 | stack := make([]byte, config.StackSize) 72 | length := runtime.Stack(stack, !config.DisableStackAll) 73 | fmt.Fprintf(DefaultWriter, "[PANIC RECOVER] %v %s\n", err, stack[:length]) 74 | } 75 | 76 | c.Error(err) 77 | } 78 | }() 79 | 80 | return next(c) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /recover_test.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/matryer/is" 9 | ) 10 | 11 | func TestRecover(t *testing.T) { 12 | g := New() 13 | buf := new(bytes.Buffer) 14 | oldWriter := DefaultWriter 15 | DefaultWriter = buf 16 | 17 | defer func() { 18 | DefaultWriter = oldWriter 19 | }() 20 | 21 | c, conn := g.NewFakeContext("/", nil) 22 | h := Recover()(HandlerFunc(func(c Context) error { 23 | panic("test") 24 | })) 25 | 26 | is := is.New(t) 27 | 28 | is.NoErr(h(c)) 29 | is.Equal("50 test\r\n", conn.Written) 30 | is.True(strings.Contains(buf.String(), "PANIC RECOVER")) 31 | } 32 | 33 | func TestRecover_Defaults(t *testing.T) { 34 | g := New() 35 | buf := new(bytes.Buffer) 36 | oldWriter := DefaultWriter 37 | DefaultWriter = buf 38 | 39 | defer func() { 40 | DefaultWriter = oldWriter 41 | }() 42 | 43 | c, conn := g.NewFakeContext("/", nil) 44 | h := RecoverWithConfig(RecoverConfig{})(HandlerFunc(func(c Context) error { 45 | panic("test") 46 | })) 47 | 48 | is := is.New(t) 49 | 50 | is.NoErr(h(c)) 51 | is.Equal("50 test\r\n", conn.Written) 52 | is.True(strings.Contains(buf.String(), "PANIC RECOVER")) 53 | } 54 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | type ( 9 | // Response wraps net.Conn, to be used by a context to construct a response. 10 | Response struct { 11 | Writer io.Writer 12 | Status Status 13 | Meta string 14 | Size int64 15 | Committed bool 16 | err error 17 | } 18 | ) 19 | 20 | // NewResponse creates a new instance of Response. Typically used for tests. 21 | func NewResponse(w io.Writer) (r *Response) { 22 | return &Response{Writer: w} 23 | } 24 | 25 | // WriteHeader sends a Gemini response header with status code. If WriteHeader is 26 | // not called explicitly, the first call to Write will trigger an implicit 27 | // WriteHeader(StatusSuccess, "text/gemini"). Thus explicit calls to WriteHeader 28 | // are mainly used to send error codes. 29 | func (r *Response) WriteHeader(code Status, meta string) error { 30 | if r.Committed { 31 | debugPrintf("gemini: response already committed") 32 | return nil 33 | } 34 | 35 | r.Status = code 36 | r.Meta = meta 37 | 38 | var n int 39 | 40 | n, r.err = r.Writer.Write([]byte(fmt.Sprintf("%d %s\r\n", code, meta))) 41 | r.Committed = true 42 | 43 | if r.err != nil { 44 | return r.err 45 | } 46 | 47 | r.Size += int64(n) 48 | 49 | return nil 50 | } 51 | 52 | // Write writes the data to the connection as part of a reply. 53 | func (r *Response) Write(b []byte) (int, error) { 54 | if r.err != nil { 55 | return 0, r.err 56 | } 57 | 58 | if !r.Committed { 59 | if r.Status == 0 { 60 | r.Status = StatusSuccess 61 | } 62 | 63 | r.err = r.WriteHeader(r.Status, "text/gemini") 64 | 65 | if r.err != nil { 66 | return 0, r.err 67 | } 68 | } 69 | 70 | var n int 71 | n, r.err = r.Writer.Write(b) 72 | 73 | if r.err != nil { 74 | return n, r.err 75 | } 76 | 77 | r.Size += int64(n) 78 | 79 | return n, nil 80 | } 81 | 82 | func (r *Response) reset(w io.Writer) { 83 | r.Writer = w 84 | r.Size = 0 85 | r.Meta = "" 86 | r.Status = StatusSuccess 87 | r.Committed = false 88 | r.err = nil 89 | } 90 | -------------------------------------------------------------------------------- /response_test.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matryer/is" 7 | ) 8 | 9 | func TestResponse(t *testing.T) { 10 | conn := &FakeConn{} 11 | res := &Response{Writer: conn} 12 | 13 | is := is.New(t) 14 | 15 | _, err := res.Write([]byte("test")) 16 | is.NoErr(err) 17 | is.Equal("20 text/gemini\r\ntest", conn.Written) 18 | } 19 | 20 | func TestResponse_Write_FallsBackToDefaultStatus(t *testing.T) { 21 | conn := &FakeConn{} 22 | res := &Response{Writer: conn} 23 | 24 | is := is.New(t) 25 | 26 | _, err := res.Write([]byte("test")) 27 | is.NoErr(err) 28 | is.Equal("20 text/gemini\r\ntest", conn.Written) 29 | } 30 | 31 | func TestResponse_Write_Fails(t *testing.T) { 32 | conn := &FakeConn{FailAfter: 1} 33 | res := &Response{Writer: conn} 34 | 35 | is := is.New(t) 36 | 37 | _, err := res.Write([]byte("test")) 38 | is.True(err != nil) 39 | is.Equal("2", conn.Written) 40 | } 41 | 42 | func TestResponse_Double_WriteHeader(t *testing.T) { 43 | conn := &FakeConn{} 44 | res := &Response{Writer: conn} 45 | 46 | is := is.New(t) 47 | 48 | is.NoErr(res.WriteHeader(StatusSuccess, "text/gemini")) 49 | is.NoErr(res.WriteHeader(StatusGone, "oops")) 50 | is.Equal("20 text/gemini\r\n", conn.Written) 51 | } 52 | 53 | func TestResponse_Write_UsesSetResponseCode(t *testing.T) { 54 | conn := &FakeConn{} 55 | res := &Response{Writer: conn} 56 | 57 | is := is.New(t) 58 | 59 | res.Status = StatusCGIError 60 | _, err := res.Write([]byte("test")) 61 | is.NoErr(err) 62 | is.Equal("42 text/gemini\r\ntest", conn.Written) 63 | } 64 | 65 | func TestResponse_Write_FailIfFailedBefore(t *testing.T) { 66 | conn := &FakeConn{ 67 | FailAfter: 4, 68 | } 69 | res := &Response{Writer: conn} 70 | 71 | is := is.New(t) 72 | 73 | n, err := res.Write([]byte("test")) 74 | is.True(err != nil) 75 | is.Equal(n, 0) 76 | is.Equal(res.err, err) 77 | is.Equal("20 t", conn.Written) 78 | 79 | _, err2 := res.Write([]byte("test")) 80 | is.True(err2 != nil) 81 | is.Equal(n, 0) 82 | is.Equal(res.err, err) 83 | is.Equal(err, err2) 84 | is.Equal("20 t", conn.Written) 85 | } 86 | -------------------------------------------------------------------------------- /router.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type ( 8 | router struct { 9 | tree *node 10 | routes map[string]*Route 11 | gig *Gig 12 | } 13 | node struct { 14 | kind kind 15 | label byte 16 | prefix string 17 | parent *node 18 | children children 19 | ppath string 20 | pnames []string 21 | handler HandlerFunc 22 | } 23 | kind uint8 24 | children []*node 25 | ) 26 | 27 | const ( 28 | skind kind = iota 29 | pkind 30 | akind 31 | ) 32 | 33 | func newRouter(g *Gig) *router { 34 | return &router{ 35 | tree: &node{}, 36 | routes: map[string]*Route{}, 37 | gig: g, 38 | } 39 | } 40 | 41 | func (r *router) add(path string, h HandlerFunc) { 42 | // Validate path 43 | if path == "" { 44 | path = "/" 45 | } 46 | 47 | if path[0] != '/' { 48 | path = "/" + path 49 | } 50 | 51 | pnames := []string{} // Param names 52 | ppath := path // Pristine path 53 | 54 | for i, l := 0, len(path); i < l; i++ { 55 | if path[i] == ':' { 56 | j := i + 1 57 | 58 | r.insert(path[:i], nil, skind, "", nil) 59 | 60 | for ; i < l && path[i] != '/'; i++ { 61 | } 62 | 63 | pnames = append(pnames, path[j:i]) 64 | path = path[:j] + path[i:] 65 | i, l = j, len(path) 66 | 67 | if i == l { 68 | r.insert(path[:i], h, pkind, ppath, pnames) 69 | } else { 70 | r.insert(path[:i], nil, pkind, "", nil) 71 | } 72 | } else if path[i] == '*' { 73 | r.insert(path[:i], nil, skind, "", nil) 74 | pnames = append(pnames, "*") 75 | r.insert(path[:i+1], h, akind, ppath, pnames) 76 | } 77 | } 78 | 79 | r.insert(path, h, skind, ppath, pnames) 80 | } 81 | 82 | func (r *router) insert(path string, h HandlerFunc, t kind, ppath string, pnames []string) { 83 | // Adjust max param 84 | l := len(pnames) 85 | if *r.gig.maxParam < l { 86 | *r.gig.maxParam = l 87 | } 88 | 89 | cn := r.tree // Current node as root 90 | if cn == nil { 91 | panic("gig: invalid tree") 92 | } 93 | 94 | search := path 95 | 96 | for { 97 | sl := len(search) 98 | pl := len(cn.prefix) 99 | l := 0 100 | 101 | // LCP 102 | max := pl 103 | if sl < max { 104 | max = sl 105 | } 106 | 107 | for ; l < max && search[l] == cn.prefix[l]; l++ { 108 | } 109 | 110 | switch { 111 | case l == 0: 112 | // At root node 113 | cn.label = search[0] 114 | cn.prefix = search 115 | 116 | if h != nil { 117 | cn.kind = t 118 | cn.handler = h 119 | cn.ppath = ppath 120 | cn.pnames = pnames 121 | } 122 | case l < pl: 123 | // Split node 124 | n := newNode(cn.kind, cn.prefix[l:], cn, cn.children, cn.handler, cn.ppath, cn.pnames) 125 | 126 | // Update parent path for all children to new node 127 | for _, child := range cn.children { 128 | child.parent = n 129 | } 130 | 131 | // Reset parent node 132 | cn.kind = skind 133 | cn.label = cn.prefix[0] 134 | cn.prefix = cn.prefix[:l] 135 | cn.children = nil 136 | cn.ppath = "" 137 | cn.pnames = nil 138 | 139 | cn.addChild(n) 140 | 141 | if l == sl { 142 | // At parent node 143 | cn.kind = t 144 | cn.handler = h 145 | cn.ppath = ppath 146 | cn.pnames = pnames 147 | } else { 148 | // Create child node 149 | n = newNode(t, search[l:], cn, nil, nil, ppath, pnames) 150 | n.handler = h 151 | cn.addChild(n) 152 | } 153 | case l < sl: 154 | search = search[l:] 155 | c := cn.findChildWithLabel(search[0]) 156 | 157 | if c != nil { 158 | // Go deeper 159 | cn = c 160 | continue 161 | } 162 | // Create child node 163 | n := newNode(t, search, cn, nil, nil, ppath, pnames) 164 | n.handler = h 165 | cn.addChild(n) 166 | case h != nil: 167 | // Node already exists 168 | cn.handler = h 169 | cn.ppath = ppath 170 | 171 | if len(cn.pnames) == 0 { 172 | cn.pnames = pnames 173 | } 174 | } 175 | 176 | return 177 | } 178 | } 179 | 180 | func newNode(t kind, pre string, p *node, c children, h HandlerFunc, ppath string, pnames []string) *node { 181 | return &node{ 182 | kind: t, 183 | label: pre[0], 184 | prefix: pre, 185 | parent: p, 186 | children: c, 187 | ppath: ppath, 188 | pnames: pnames, 189 | handler: h, 190 | } 191 | } 192 | 193 | func (n *node) addChild(c *node) { 194 | n.children = append(n.children, c) 195 | } 196 | 197 | func (n *node) findChild(l byte, t kind) *node { 198 | for _, c := range n.children { 199 | if c.label == l && c.kind == t { 200 | return c 201 | } 202 | } 203 | 204 | return nil 205 | } 206 | 207 | func (n *node) findChildWithLabel(l byte) *node { 208 | for _, c := range n.children { 209 | if c.label == l { 210 | return c 211 | } 212 | } 213 | 214 | return nil 215 | } 216 | 217 | func (n *node) findChildByKind(t kind) *node { 218 | for _, c := range n.children { 219 | if c.kind == t { 220 | return c 221 | } 222 | } 223 | 224 | return nil 225 | } 226 | 227 | // find lookup a handler registered for path. It also parses URL for path 228 | // parameters and load them into context. 229 | func (r *router) find(path string, c Context) { 230 | ctx := c.(*context) 231 | ctx.path = path 232 | cn := r.tree // Current node as root 233 | 234 | var ( 235 | search = path 236 | child *node // Child node 237 | n int // Param counter 238 | nk kind // Next kind 239 | nn *node // Next node 240 | ns string // Next search 241 | pvalues = ctx.pvalues // Use the internal slice so the interface can keep the illusion of a dynamic slice 242 | ) 243 | 244 | // Search order static > param > any 245 | for { 246 | if search == "" { 247 | break 248 | } 249 | 250 | pl := 0 // Prefix length 251 | l := 0 // LCP length 252 | 253 | if cn.label != ':' { 254 | sl := len(search) 255 | pl = len(cn.prefix) 256 | 257 | // LCP 258 | max := pl 259 | if sl < max { 260 | max = sl 261 | } 262 | 263 | for ; l < max && search[l] == cn.prefix[l]; l++ { 264 | } 265 | } 266 | 267 | if l == pl { 268 | // Continue search 269 | search = search[l:] 270 | // Finish routing if no remaining search and we are on an leaf node 271 | if search == "" && (nn == nil || cn.parent == nil || cn.ppath != "") { 272 | break 273 | } 274 | } 275 | 276 | // Attempt to go back up the tree on no matching prefix or no remaining search 277 | if l != pl || search == "" { 278 | // Handle special case of trailing slash route with existing any route (see #1526) 279 | if path[len(path)-1] == '/' && cn.findChildByKind(akind) != nil { 280 | goto Any 281 | } 282 | 283 | if nn == nil { 284 | return // Not found 285 | } 286 | 287 | cn = nn 288 | search = ns 289 | 290 | if nk == pkind { 291 | goto Param 292 | } else if nk == akind { 293 | goto Any 294 | } 295 | } 296 | 297 | // Static node 298 | if child = cn.findChild(search[0], skind); child != nil { 299 | // Save next 300 | if cn.prefix[len(cn.prefix)-1] == '/' { 301 | nk = pkind 302 | nn = cn 303 | ns = search 304 | } 305 | 306 | cn = child 307 | 308 | continue 309 | } 310 | 311 | Param: 312 | // Param node 313 | if child = cn.findChildByKind(pkind); child != nil { 314 | if len(pvalues) == n { 315 | continue 316 | } 317 | 318 | // Save next 319 | if cn.prefix[len(cn.prefix)-1] == '/' { 320 | nk = akind 321 | nn = cn 322 | ns = search 323 | } 324 | 325 | cn = child 326 | i, l := 0, len(search) 327 | for ; i < l && search[i] != '/'; i++ { 328 | } 329 | pvalues[n] = search[:i] 330 | n++ 331 | search = search[i:] 332 | continue 333 | } 334 | 335 | Any: 336 | // Any node 337 | if cn = cn.findChildByKind(akind); cn != nil { 338 | // If any node is found, use remaining path for pvalues 339 | pvalues[len(cn.pnames)-1] = search 340 | break 341 | } 342 | 343 | // No node found, continue at stored next node 344 | // or find nearest "any" route 345 | if nn != nil { 346 | // No next node to go down in routing (issue #954) 347 | // Find nearest "any" route going up the routing tree 348 | search = ns 349 | // Consider param route one level up only 350 | if cn = nn.findChildByKind(pkind); cn != nil { 351 | pos := strings.IndexByte(ns, '/') 352 | if pos == -1 { 353 | // If no slash is remaining in search string set param value 354 | pvalues[len(cn.pnames)-1] = search 355 | break 356 | } else if pos > 0 { 357 | // Otherwise continue route processing with restored next node 358 | cn = nn 359 | nn = nil 360 | ns = "" 361 | goto Param 362 | } 363 | } 364 | // No param route found, try to resolve nearest any route 365 | for { 366 | np := nn.parent 367 | 368 | if cn = nn.findChildByKind(akind); cn != nil { 369 | break 370 | } 371 | 372 | if np == nil { 373 | break // no further parent nodes in tree, abort 374 | } 375 | 376 | var str strings.Builder 377 | 378 | str.WriteString(nn.prefix) 379 | str.WriteString(search) 380 | search = str.String() 381 | nn = np 382 | } 383 | 384 | if cn != nil { // use the found "any" route and update path 385 | pvalues[len(cn.pnames)-1] = search 386 | break 387 | } 388 | } 389 | 390 | return // Not found 391 | } 392 | 393 | ctx.handler = cn.handler 394 | ctx.path = cn.ppath 395 | ctx.pnames = cn.pnames 396 | 397 | // NOTE: Slow zone... 398 | if ctx.handler == nil { 399 | ctx.handler = NotFoundHandler 400 | 401 | // Dig further for any 402 | if cn = cn.findChildByKind(akind); cn == nil { 403 | return 404 | } 405 | 406 | if cn.handler != nil { 407 | ctx.handler = cn.handler 408 | } else { 409 | ctx.handler = NotFoundHandler 410 | } 411 | 412 | ctx.path = cn.ppath 413 | ctx.pnames = cn.pnames 414 | pvalues[len(cn.pnames)-1] = "" 415 | } 416 | } 417 | -------------------------------------------------------------------------------- /router_test.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/matryer/is" 8 | ) 9 | 10 | var ( 11 | staticRoutes = []*Route{ 12 | {"/", ""}, 13 | {"/cmd.html", ""}, 14 | {"/code.html", ""}, 15 | {"/contrib.html", ""}, 16 | {"/contribute.html", ""}, 17 | {"/debugging_with_gdb.html", ""}, 18 | {"/docs.html", ""}, 19 | {"/effective_go.html", ""}, 20 | {"/files.log", ""}, 21 | {"/gccgo_contribute.html", ""}, 22 | {"/gccgo_install.html", ""}, 23 | {"/go-logo-black.png", ""}, 24 | {"/go-logo-blue.png", ""}, 25 | {"/go-logo-white.png", ""}, 26 | {"/go1.1.html", ""}, 27 | {"/go1.2.html", ""}, 28 | {"/go1.html", ""}, 29 | {"/go1compat.html", ""}, 30 | {"/go_faq.html", ""}, 31 | {"/go_mem.html", ""}, 32 | {"/go_spec.html", ""}, 33 | {"/help.html", ""}, 34 | {"/ie.css", ""}, 35 | {"/install-source.html", ""}, 36 | {"/install.html", ""}, 37 | {"/logo-153x55.png", ""}, 38 | {"/Makefile", ""}, 39 | {"/root.html", ""}, 40 | {"/share.png", ""}, 41 | {"/sieve.gif", ""}, 42 | {"/tos.html", ""}, 43 | {"/articles/", ""}, 44 | {"/articles/go_command.html", ""}, 45 | {"/articles/index.html", ""}, 46 | {"/articles/wiki/", ""}, 47 | {"/articles/wiki/edit.html", ""}, 48 | {"/articles/wiki/final-noclosure.go", ""}, 49 | {"/articles/wiki/final-noerror.go", ""}, 50 | {"/articles/wiki/final-parsetemplate.go", ""}, 51 | {"/articles/wiki/final-template.go", ""}, 52 | {"/articles/wiki/final.go", ""}, 53 | {"/articles/wiki/get.go", ""}, 54 | {"/articles/wiki/http-sample.go", ""}, 55 | {"/articles/wiki/index.html", ""}, 56 | {"/articles/wiki/Makefile", ""}, 57 | {"/articles/wiki/notemplate.go", ""}, 58 | {"/articles/wiki/part1-noerror.go", ""}, 59 | {"/articles/wiki/part1.go", ""}, 60 | {"/articles/wiki/part2.go", ""}, 61 | {"/articles/wiki/part3-errorhandling.go", ""}, 62 | {"/articles/wiki/part3.go", ""}, 63 | {"/articles/wiki/test.bash", ""}, 64 | {"/articles/wiki/test_edit.good", ""}, 65 | {"/articles/wiki/test_Test.txt.good", ""}, 66 | {"/articles/wiki/test_view.good", ""}, 67 | {"/articles/wiki/view.html", ""}, 68 | {"/codewalk/", ""}, 69 | {"/codewalk/codewalk.css", ""}, 70 | {"/codewalk/codewalk.js", ""}, 71 | {"/codewalk/codewalk.xml", ""}, 72 | {"/codewalk/functions.xml", ""}, 73 | {"/codewalk/markov.go", ""}, 74 | {"/codewalk/markov.xml", ""}, 75 | {"/codewalk/pig.go", ""}, 76 | {"/codewalk/popout.png", ""}, 77 | {"/codewalk/run", ""}, 78 | {"/codewalk/sharemem.xml", ""}, 79 | {"/codewalk/urlpoll.go", ""}, 80 | {"/devel/", ""}, 81 | {"/devel/release.html", ""}, 82 | {"/devel/weekly.html", ""}, 83 | {"/gopher/", ""}, 84 | {"/gopher/appenginegopher.jpg", ""}, 85 | {"/gopher/appenginegophercolor.jpg", ""}, 86 | {"/gopher/appenginelogo.gif", ""}, 87 | {"/gopher/bumper.png", ""}, 88 | {"/gopher/bumper192x108.png", ""}, 89 | {"/gopher/bumper320x180.png", ""}, 90 | {"/gopher/bumper480x270.png", ""}, 91 | {"/gopher/bumper640x360.png", ""}, 92 | {"/gopher/doc.png", ""}, 93 | {"/gopher/frontpage.png", ""}, 94 | {"/gopher/gopherbw.png", ""}, 95 | {"/gopher/gophercolor.png", ""}, 96 | {"/gopher/gophercolor16x16.png", ""}, 97 | {"/gopher/help.png", ""}, 98 | {"/gopher/pkg.png", ""}, 99 | {"/gopher/project.png", ""}, 100 | {"/gopher/ref.png", ""}, 101 | {"/gopher/run.png", ""}, 102 | {"/gopher/talks.png", ""}, 103 | {"/gopher/pencil/", ""}, 104 | {"/gopher/pencil/gopherhat.jpg", ""}, 105 | {"/gopher/pencil/gopherhelmet.jpg", ""}, 106 | {"/gopher/pencil/gophermega.jpg", ""}, 107 | {"/gopher/pencil/gopherrunning.jpg", ""}, 108 | {"/gopher/pencil/gopherswim.jpg", ""}, 109 | {"/gopher/pencil/gopherswrench.jpg", ""}, 110 | {"/play/", ""}, 111 | {"/play/fib.go", ""}, 112 | {"/play/hello.go", ""}, 113 | {"/play/life.go", ""}, 114 | {"/play/peano.go", ""}, 115 | {"/play/pi.go", ""}, 116 | {"/play/sieve.go", ""}, 117 | {"/play/solitaire.go", ""}, 118 | {"/play/tree.go", ""}, 119 | {"/progs/", ""}, 120 | {"/progs/cgo1.go", ""}, 121 | {"/progs/cgo2.go", ""}, 122 | {"/progs/cgo3.go", ""}, 123 | {"/progs/cgo4.go", ""}, 124 | {"/progs/defer.go", ""}, 125 | {"/progs/defer.out", ""}, 126 | {"/progs/defer2.go", ""}, 127 | {"/progs/defer2.out", ""}, 128 | {"/progs/eff_bytesize.go", ""}, 129 | {"/progs/eff_bytesize.out", ""}, 130 | {"/progs/eff_qr.go", ""}, 131 | {"/progs/eff_sequence.go", ""}, 132 | {"/progs/eff_sequence.out", ""}, 133 | {"/progs/eff_unused1.go", ""}, 134 | {"/progs/eff_unused2.go", ""}, 135 | {"/progs/error.go", ""}, 136 | {"/progs/error2.go", ""}, 137 | {"/progs/error3.go", ""}, 138 | {"/progs/error4.go", ""}, 139 | {"/progs/go1.go", ""}, 140 | {"/progs/gobs1.go", ""}, 141 | {"/progs/gobs2.go", ""}, 142 | {"/progs/image_draw.go", ""}, 143 | {"/progs/image_package1.go", ""}, 144 | {"/progs/image_package1.out", ""}, 145 | {"/progs/image_package2.go", ""}, 146 | {"/progs/image_package2.out", ""}, 147 | {"/progs/image_package3.go", ""}, 148 | {"/progs/image_package3.out", ""}, 149 | {"/progs/image_package4.go", ""}, 150 | {"/progs/image_package4.out", ""}, 151 | {"/progs/image_package5.go", ""}, 152 | {"/progs/image_package5.out", ""}, 153 | {"/progs/image_package6.go", ""}, 154 | {"/progs/image_package6.out", ""}, 155 | {"/progs/interface.go", ""}, 156 | {"/progs/interface2.go", ""}, 157 | {"/progs/interface2.out", ""}, 158 | {"/progs/json1.go", ""}, 159 | {"/progs/json2.go", ""}, 160 | {"/progs/json2.out", ""}, 161 | {"/progs/json3.go", ""}, 162 | {"/progs/json4.go", ""}, 163 | {"/progs/json5.go", ""}, 164 | {"/progs/run", ""}, 165 | {"/progs/slices.go", ""}, 166 | {"/progs/timeout1.go", ""}, 167 | {"/progs/timeout2.go", ""}, 168 | {"/progs/update.bash", ""}, 169 | } 170 | 171 | gitHubAPI = []*Route{ 172 | {"/applications/:client_id/tokens", ""}, 173 | {"/applications/:client_id/tokens/:access_token", ""}, 174 | {"/authorizations", ""}, 175 | {"/authorizations/:id", ""}, 176 | {"/authorizations/clients/:client_id", ""}, 177 | {"/emojis", ""}, 178 | {"/events", ""}, 179 | {"/feeds", ""}, 180 | {"/gists", ""}, 181 | {"/gists/:id", ""}, 182 | {"/gists/:id/forks", ""}, 183 | {"/gists/:id/star", ""}, 184 | {"/gists/public", ""}, 185 | {"/gists/starred", ""}, 186 | {"/gitignore/templates", ""}, 187 | {"/gitignore/templates/:name", ""}, 188 | {"/issues", ""}, 189 | {"/legacy/issues/search/:owner/:repository/:state/:keyword", ""}, 190 | {"/legacy/repos/search/:keyword", ""}, 191 | {"/legacy/user/email/:email", ""}, 192 | {"/legacy/user/search/:keyword", ""}, 193 | {"/markdown", ""}, 194 | {"/markdown/raw", ""}, 195 | {"/meta", ""}, 196 | {"/networks/:owner/:repo/events", ""}, 197 | {"/notifications", ""}, 198 | {"/notifications/threads/:id", ""}, 199 | {"/notifications/threads/:id/subscription", ""}, 200 | {"/orgs/:org", ""}, 201 | {"/orgs/:org/events", ""}, 202 | {"/orgs/:org/issues", ""}, 203 | {"/orgs/:org/members", ""}, 204 | {"/orgs/:org/members/:user", ""}, 205 | {"/orgs/:org/public_members", ""}, 206 | {"/orgs/:org/public_members/:user", ""}, 207 | {"/orgs/:org/repos", ""}, 208 | {"/orgs/:org/teams", ""}, 209 | {"/rate_limit", ""}, 210 | {"/repos/:owner/:repo", ""}, 211 | {"/repos/:owner/:repo/:archive_format/:ref", ""}, 212 | {"/repos/:owner/:repo/assignees", ""}, 213 | {"/repos/:owner/:repo/assignees/:assignee", ""}, 214 | {"/repos/:owner/:repo/branches", ""}, 215 | {"/repos/:owner/:repo/branches/:branch", ""}, 216 | {"/repos/:owner/:repo/collaborators", ""}, 217 | {"/repos/:owner/:repo/collaborators/:user", ""}, 218 | {"/repos/:owner/:repo/comments", ""}, 219 | {"/repos/:owner/:repo/comments/:id", ""}, 220 | {"/repos/:owner/:repo/commits", ""}, 221 | {"/repos/:owner/:repo/commits/:sha", ""}, 222 | {"/repos/:owner/:repo/commits/:sha/comments", ""}, 223 | {"/repos/:owner/:repo/contents/*path", ""}, 224 | {"/repos/:owner/:repo/contributors", ""}, 225 | {"/repos/:owner/:repo/downloads", ""}, 226 | {"/repos/:owner/:repo/downloads/:id", ""}, 227 | {"/repos/:owner/:repo/events", ""}, 228 | {"/repos/:owner/:repo/forks", ""}, 229 | {"/repos/:owner/:repo/git/blobs", ""}, 230 | {"/repos/:owner/:repo/git/blobs/:sha", ""}, 231 | {"/repos/:owner/:repo/git/commits", ""}, 232 | {"/repos/:owner/:repo/git/commits/:sha", ""}, 233 | {"/repos/:owner/:repo/git/refs", ""}, 234 | {"/repos/:owner/:repo/git/refs/*ref", ""}, 235 | {"/repos/:owner/:repo/git/tags", ""}, 236 | {"/repos/:owner/:repo/git/tags/:sha", ""}, 237 | {"/repos/:owner/:repo/git/trees", ""}, 238 | {"/repos/:owner/:repo/git/trees/:sha", ""}, 239 | {"/repos/:owner/:repo/hooks", ""}, 240 | {"/repos/:owner/:repo/hooks/:id", ""}, 241 | {"/repos/:owner/:repo/hooks/:id/tests", ""}, 242 | {"/repos/:owner/:repo/issues", ""}, 243 | {"/repos/:owner/:repo/issues/:number", ""}, 244 | {"/repos/:owner/:repo/issues/:number/comments", ""}, 245 | {"/repos/:owner/:repo/issues/:number/events", ""}, 246 | {"/repos/:owner/:repo/issues/:number/labels", ""}, 247 | {"/repos/:owner/:repo/issues/:number/labels/:name", ""}, 248 | {"/repos/:owner/:repo/issues/comments", ""}, 249 | {"/repos/:owner/:repo/issues/comments/:id", ""}, 250 | {"/repos/:owner/:repo/issues/events", ""}, 251 | {"/repos/:owner/:repo/issues/events/:id", ""}, 252 | {"/repos/:owner/:repo/keys", ""}, 253 | {"/repos/:owner/:repo/keys/:id", ""}, 254 | {"/repos/:owner/:repo/labels", ""}, 255 | {"/repos/:owner/:repo/labels/:name", ""}, 256 | {"/repos/:owner/:repo/languages", ""}, 257 | {"/repos/:owner/:repo/merges", ""}, 258 | {"/repos/:owner/:repo/milestones", ""}, 259 | {"/repos/:owner/:repo/milestones/:number", ""}, 260 | {"/repos/:owner/:repo/milestones/:number/labels", ""}, 261 | {"/repos/:owner/:repo/notifications", ""}, 262 | {"/repos/:owner/:repo/pulls", ""}, 263 | {"/repos/:owner/:repo/pulls/:number", ""}, 264 | {"/repos/:owner/:repo/pulls/:number/comments", ""}, 265 | {"/repos/:owner/:repo/pulls/:number/commits", ""}, 266 | {"/repos/:owner/:repo/pulls/:number/files", ""}, 267 | {"/repos/:owner/:repo/pulls/:number/merge", ""}, 268 | {"/repos/:owner/:repo/pulls/comments", ""}, 269 | {"/repos/:owner/:repo/pulls/comments/:number", ""}, 270 | {"/repos/:owner/:repo/readme", ""}, 271 | {"/repos/:owner/:repo/releases", ""}, 272 | {"/repos/:owner/:repo/releases/:id", ""}, 273 | {"/repos/:owner/:repo/releases/:id/assets", ""}, 274 | {"/repos/:owner/:repo/stargazers", ""}, 275 | {"/repos/:owner/:repo/stats/code_frequency", ""}, 276 | {"/repos/:owner/:repo/stats/commit_activity", ""}, 277 | {"/repos/:owner/:repo/stats/contributors", ""}, 278 | {"/repos/:owner/:repo/stats/participation", ""}, 279 | {"/repos/:owner/:repo/stats/punch_card", ""}, 280 | {"/repos/:owner/:repo/statuses/:ref", ""}, 281 | {"/repos/:owner/:repo/subscribers", ""}, 282 | {"/repos/:owner/:repo/subscription", ""}, 283 | {"/repos/:owner/:repo/tags", ""}, 284 | {"/repos/:owner/:repo/teams", ""}, 285 | {"/repositories", ""}, 286 | {"/search/code", ""}, 287 | {"/search/issues", ""}, 288 | {"/search/repositories", ""}, 289 | {"/search/users", ""}, 290 | {"/teams/:id", ""}, 291 | {"/teams/:id/members", ""}, 292 | {"/teams/:id/members/:user", ""}, 293 | {"/teams/:id/repos", ""}, 294 | {"/teams/:id/repos/:owner/:repo", ""}, 295 | {"/user", ""}, 296 | {"/user/emails", ""}, 297 | {"/user/followers", ""}, 298 | {"/user/following", ""}, 299 | {"/user/following/:user", ""}, 300 | {"/user/issues", ""}, 301 | {"/user/keys", ""}, 302 | {"/user/keys/:id", ""}, 303 | {"/user/orgs", ""}, 304 | {"/user/repos", ""}, 305 | {"/user/starred", ""}, 306 | {"/user/starred/:owner/:repo", ""}, 307 | {"/user/subscriptions", ""}, 308 | {"/user/subscriptions/:owner/:repo", ""}, 309 | {"/user/teams", ""}, 310 | {"/users", ""}, 311 | {"/users/:user", ""}, 312 | {"/users/:user/events", ""}, 313 | {"/users/:user/events/orgs/:org", ""}, 314 | {"/users/:user/events/public", ""}, 315 | {"/users/:user/followers", ""}, 316 | {"/users/:user/following", ""}, 317 | {"/users/:user/following/:target_user", ""}, 318 | {"/users/:user/gists", ""}, 319 | {"/users/:user/keys", ""}, 320 | {"/users/:user/orgs", ""}, 321 | {"/users/:user/received_events", ""}, 322 | {"/users/:user/received_events/public", ""}, 323 | {"/users/:user/repos", ""}, 324 | {"/users/:user/starred", ""}, 325 | {"/users/:user/subscriptions", ""}, 326 | } 327 | 328 | parseAPI = []*Route{ 329 | {"/1/classes/:className", ""}, 330 | {"/1/classes/:className/:objectId", ""}, 331 | {"/1/events/:eventName", ""}, 332 | {"/1/files/:fileName", ""}, 333 | {"/1/functions", ""}, 334 | {"/1/installations", ""}, 335 | {"/1/installations/:objectId", ""}, 336 | {"/1/login", ""}, 337 | {"/1/push", ""}, 338 | {"/1/requestPasswordReset", ""}, 339 | {"/1/roles", ""}, 340 | {"/1/roles/:objectId", ""}, 341 | {"/1/users", ""}, 342 | {"/1/users/:objectId", ""}, 343 | } 344 | 345 | googlePlusAPI = []*Route{ 346 | // People 347 | {"/people/:userId", ""}, 348 | {"/people", ""}, 349 | {"/activities/:activityId/people/:collection", ""}, 350 | {"/people/:userId/people/:collection", ""}, 351 | {"/people/:userId/openIdConnect", ""}, 352 | 353 | // Activities 354 | {"/people/:userId/activities/:collection", ""}, 355 | {"/activities/:activityId", ""}, 356 | {"/activities", ""}, 357 | 358 | // Comments 359 | {"/activities/:activityId/comments", ""}, 360 | {"/comments/:commentId", ""}, 361 | 362 | // Moments 363 | {"/people/:userId/moments/:collection", ""}, 364 | {"/people/:userId/moments/:collection", ""}, 365 | {"/moments/:id", ""}, 366 | } 367 | 368 | // handlerHelper created a function that will set a context key for assertion. 369 | handlerHelper = func(key string, value int) func(c Context) error { 370 | return func(c Context) error { 371 | c.Set(key, value) 372 | c.Set("path", c.Path()) 373 | return nil 374 | } 375 | } 376 | ) 377 | 378 | func TestRouterEmpty(t *testing.T) { 379 | is := is.New(t) 380 | 381 | g := New() 382 | r := g.router 383 | path := "" 384 | r.add(path, func(c Context) error { 385 | c.Set("path", path) 386 | return nil 387 | }) 388 | 389 | c := g.newContext(nil, nil, "", nil).(*context) 390 | r.find(path, c) 391 | 392 | is.NoErr(c.handler(c)) 393 | is.Equal(path, c.Get("path")) 394 | } 395 | 396 | func TestRouterStatic(t *testing.T) { 397 | g := New() 398 | r := g.router 399 | path := "/folders/a/files/gig.gif" 400 | r.add(path, func(c Context) error { 401 | c.Set("path", path) 402 | return nil 403 | }) 404 | 405 | c := g.newContext(nil, nil, "", nil).(*context) 406 | r.find(path, c) 407 | 408 | is := is.New(t) 409 | is.NoErr(c.handler(c)) 410 | is.Equal(path, c.Get("path")) 411 | } 412 | 413 | func TestRouterParam(t *testing.T) { 414 | g := New() 415 | r := g.router 416 | r.add("/users/:id", func(c Context) error { 417 | return nil 418 | }) 419 | 420 | c := g.newContext(nil, nil, "", nil).(*context) 421 | r.find("/users/1", c) 422 | 423 | is := is.New(t) 424 | is.Equal("1", c.Param("id")) 425 | } 426 | 427 | func TestRouterTwoParam(t *testing.T) { 428 | g := New() 429 | r := g.router 430 | r.add("/users/:uid/files/:fid", func(Context) error { 431 | return nil 432 | }) 433 | 434 | c := g.newContext(nil, nil, "", nil).(*context) 435 | 436 | r.find("/users/1/files/1", c) 437 | 438 | is := is.New(t) 439 | is.Equal("1", c.Param("uid")) 440 | is.Equal("1", c.Param("fid")) 441 | } 442 | 443 | func TestRouterParamWithSlash(t *testing.T) { 444 | g := New() 445 | r := g.router 446 | 447 | r.add("/a/:b/c/d/:g", func(c Context) error { 448 | return nil 449 | }) 450 | 451 | r.add("/a/:b/c/:d/:f", func(c Context) error { 452 | return nil 453 | }) 454 | 455 | c := g.newContext(nil, nil, "", nil).(*context) 456 | 457 | // No Panic 458 | r.find("/a/1/c/d/2/3", c) 459 | } 460 | 461 | func TestRouterParamStaticConflict(t *testing.T) { 462 | g := New() 463 | r := g.router 464 | handler := func(c Context) error { 465 | c.Set("path", c.Path()) 466 | return nil 467 | } 468 | 469 | gr := g.Group("/g") 470 | gr.Handle("/skills", handler) 471 | gr.Handle("/status", handler) 472 | gr.Handle("/:name", handler) 473 | 474 | is := is.New(t) 475 | 476 | c := g.newContext(nil, nil, "", nil).(*context) 477 | r.find("/g/s", c) 478 | 479 | is.NoErr(c.handler(c)) 480 | is.Equal("s", c.Param("name")) 481 | is.Equal("/g/:name", c.Get("path")) 482 | 483 | c = g.newContext(nil, nil, "", nil).(*context) 484 | r.find("/g/status", c) 485 | is.NoErr(c.handler(c)) 486 | is.Equal("/g/status", c.Get("path")) 487 | } 488 | 489 | func TestRouterMatchAny(t *testing.T) { 490 | g := New() 491 | r := g.router 492 | 493 | // Routes 494 | r.add("/", func(Context) error { 495 | return nil 496 | }) 497 | r.add("/*", func(Context) error { 498 | return nil 499 | }) 500 | r.add("/users/*", func(Context) error { 501 | return nil 502 | }) 503 | 504 | c := g.newContext(nil, nil, "", nil).(*context) 505 | 506 | is := is.New(t) 507 | 508 | r.find("/", c) 509 | is.Equal("", c.Param("*")) 510 | 511 | r.find("/download", c) 512 | is.Equal("download", c.Param("*")) 513 | 514 | r.find("/users/joe", c) 515 | is.Equal("joe", c.Param("*")) 516 | } 517 | 518 | // TestRouterMatchAnySlash shall verify finding the best route 519 | // for any routes with trailing slash requests. 520 | func TestRouterMatchAnySlash(t *testing.T) { 521 | g := New() 522 | r := g.router 523 | 524 | is := is.New(t) 525 | 526 | handler := func(c Context) error { 527 | c.Set("path", c.Path()) 528 | return nil 529 | } 530 | 531 | // Routes 532 | r.add("/users", handler) 533 | r.add("/users/*", handler) 534 | r.add("/img/*", handler) 535 | r.add("/img/load", handler) 536 | r.add("/img/load/*", handler) 537 | r.add("/assets/*", handler) 538 | 539 | c := g.newContext(nil, nil, "", nil).(*context) 540 | r.find("/", c) 541 | is.Equal("", c.Param("*")) 542 | 543 | // Test trailing slash request for simple any route (see #1526) 544 | c = g.newContext(nil, nil, "", nil).(*context) 545 | r.find("/users/", c) 546 | is.NoErr(c.handler(c)) 547 | is.Equal("/users/*", c.Get("path")) 548 | is.Equal("", c.Param("*")) 549 | 550 | c = g.newContext(nil, nil, "", nil).(*context) 551 | r.find("/users/joe", c) 552 | is.NoErr(c.handler(c)) 553 | is.Equal("/users/*", c.Get("path")) 554 | is.Equal("joe", c.Param("*")) 555 | 556 | // Test trailing slash request for nested any route (see #1526) 557 | c = g.newContext(nil, nil, "", nil).(*context) 558 | r.find("/img/load", c) 559 | is.NoErr(c.handler(c)) 560 | is.Equal("/img/load", c.Get("path")) 561 | is.Equal("", c.Param("*")) 562 | 563 | c = g.newContext(nil, nil, "", nil).(*context) 564 | r.find("/img/load/", c) 565 | is.NoErr(c.handler(c)) 566 | is.Equal("/img/load/*", c.Get("path")) 567 | is.Equal("", c.Param("*")) 568 | 569 | c = g.newContext(nil, nil, "", nil).(*context) 570 | r.find("/img/load/ben", c) 571 | is.NoErr(c.handler(c)) 572 | is.Equal("/img/load/*", c.Get("path")) 573 | is.Equal("ben", c.Param("*")) 574 | 575 | // Test /assets/* any route 576 | // ... without trailing slash must not match 577 | c = g.newContext(nil, nil, "", nil).(*context) 578 | r.find("/assets", c) 579 | is.True(c.handler(c) != nil) 580 | is.Equal(nil, c.Get("path")) 581 | is.Equal("", c.Param("*")) 582 | 583 | // ... with trailing slash must match 584 | c = g.newContext(nil, nil, "", nil).(*context) 585 | r.find("/assets/", c) 586 | is.NoErr(c.handler(c)) 587 | is.Equal("/assets/*", c.Get("path")) 588 | is.Equal("", c.Param("*")) 589 | } 590 | 591 | func TestRouterMatchAnyMultiLevel(t *testing.T) { 592 | is := is.New(t) 593 | 594 | g := New() 595 | r := g.router 596 | handler := func(c Context) error { 597 | c.Set("path", c.Path()) 598 | return nil 599 | } 600 | 601 | // Routes 602 | r.add("/api/users/jack", handler) 603 | r.add("/api/users/jill", handler) 604 | r.add("/api/users/*", handler) 605 | r.add("/api/*", handler) 606 | r.add("/other/*", handler) 607 | r.add("/*", handler) 608 | 609 | c := g.newContext(nil, nil, "", nil).(*context) 610 | r.find("/api/users/jack", c) 611 | is.NoErr(c.handler(c)) 612 | is.Equal("/api/users/jack", c.Get("path")) 613 | is.Equal("", c.Param("*")) 614 | 615 | r.find("/api/users/jill", c) 616 | is.NoErr(c.handler(c)) 617 | is.Equal("/api/users/jill", c.Get("path")) 618 | is.Equal("", c.Param("*")) 619 | 620 | r.find("/api/users/joe", c) 621 | is.NoErr(c.handler(c)) 622 | is.Equal("/api/users/*", c.Get("path")) 623 | is.Equal("joe", c.Param("*")) 624 | 625 | r.find("/api/nousers/joe", c) 626 | is.NoErr(c.handler(c)) 627 | is.Equal("/api/*", c.Get("path")) 628 | is.Equal("nousers/joe", c.Param("*")) 629 | 630 | r.find("/api/none", c) 631 | is.NoErr(c.handler(c)) 632 | is.Equal("/api/*", c.Get("path")) 633 | is.Equal("none", c.Param("*")) 634 | 635 | r.find("/noapi/users/jim", c) 636 | is.NoErr(c.handler(c)) 637 | is.Equal("/*", c.Get("path")) 638 | is.Equal("noapi/users/jim", c.Param("*")) 639 | } 640 | func TestRouterMatchAnyMultiLevelWithPost(t *testing.T) { 641 | is := is.New(t) 642 | 643 | g := New() 644 | r := g.router 645 | handler := func(c Context) error { 646 | c.Set("path", c.Path()) 647 | return nil 648 | } 649 | 650 | // Routes 651 | g.Handle("/api/auth/login", handler) 652 | g.Handle("/api/auth/forgotPassword", handler) 653 | g.Handle("/api/*", handler) 654 | g.Handle("/*", handler) 655 | 656 | // /api/auth/login shall choose login 657 | c := g.newContext(nil, nil, "", nil).(*context) 658 | r.find("/api/auth/login", c) 659 | is.NoErr(c.handler(c)) 660 | is.Equal("/api/auth/login", c.Get("path")) 661 | is.Equal("", c.Param("*")) 662 | 663 | // /api/auth/login shall choose any route 664 | // c = g.newContext(nil, nil,nil).(*context) 665 | // r.find( "/api/auth/login", c) 666 | // c.handler(c) 667 | // is.Equal("/api/*", c.Get("path")) 668 | // is.Equal("auth/login", c.Param("*")) 669 | 670 | // /api/auth/logout shall choose nearest any route 671 | c = g.newContext(nil, nil, "", nil).(*context) 672 | r.find("/api/auth/logout", c) 673 | is.NoErr(c.handler(c)) 674 | is.Equal("/api/*", c.Get("path")) 675 | is.Equal("auth/logout", c.Param("*")) 676 | 677 | // /api/other/test shall choose nearest any route 678 | c = g.newContext(nil, nil, "", nil).(*context) 679 | r.find("/api/other/test", c) 680 | is.NoErr(c.handler(c)) 681 | is.Equal("/api/*", c.Get("path")) 682 | is.Equal("other/test", c.Param("*")) 683 | 684 | // /api/other/test shall choose nearest any route 685 | c = g.newContext(nil, nil, "", nil).(*context) 686 | r.find("/api/other/test", c) 687 | is.NoErr(c.handler(c)) 688 | is.Equal("/api/*", c.Get("path")) 689 | is.Equal("other/test", c.Param("*")) 690 | } 691 | 692 | func TestRouterMicroParam(t *testing.T) { 693 | is := is.New(t) 694 | 695 | g := New() 696 | r := g.router 697 | r.add("/:a/:b/:c", func(c Context) error { 698 | return nil 699 | }) 700 | 701 | c := g.newContext(nil, nil, "", nil).(*context) 702 | r.find("/1/2/3", c) 703 | is.Equal("1", c.Param("a")) 704 | is.Equal("2", c.Param("b")) 705 | is.Equal("3", c.Param("c")) 706 | } 707 | 708 | func TestRouterMixParamMatchAny(t *testing.T) { 709 | is := is.New(t) 710 | 711 | g := New() 712 | r := g.router 713 | 714 | // Route 715 | r.add("/users/:id/*", func(c Context) error { 716 | return nil 717 | }) 718 | 719 | c := g.newContext(nil, nil, "", nil).(*context) 720 | 721 | r.find("/users/joe/comments", c) 722 | is.NoErr(c.handler(c)) 723 | is.Equal("joe", c.Param("id")) 724 | } 725 | 726 | func TestRouterMultiRoute(t *testing.T) { 727 | is := is.New(t) 728 | 729 | g := New() 730 | r := g.router 731 | 732 | // Routes 733 | r.add("/users", func(c Context) error { 734 | c.Set("path", "/users") 735 | return nil 736 | }) 737 | r.add("/users/:id", func(c Context) error { 738 | return nil 739 | }) 740 | 741 | c := g.newContext(nil, nil, "", nil).(*context) 742 | 743 | // Route > /users 744 | r.find("/users", c) 745 | is.NoErr(c.handler(c)) 746 | is.Equal("/users", c.Get("path")) 747 | 748 | // Route > /users/:id 749 | r.find("/users/1", c) 750 | is.Equal("1", c.Param("id")) 751 | 752 | // Route > /user 753 | c = g.newContext(nil, nil, "", nil).(*context) 754 | r.find("/user", c) 755 | he := c.handler(c).(*GeminiError) 756 | is.Equal(StatusNotFound, he.Code) 757 | } 758 | 759 | func TestRouterPriority(t *testing.T) { 760 | is := is.New(t) 761 | 762 | g := New() 763 | r := g.router 764 | 765 | // Routes 766 | r.add("/users", handlerHelper("a", 1)) 767 | r.add("/users/new", handlerHelper("b", 2)) 768 | r.add("/users/:id", handlerHelper("c", 3)) 769 | r.add("/users/dew", handlerHelper("d", 4)) 770 | r.add("/users/:id/files", handlerHelper("g", 5)) 771 | r.add("/users/newsee", handlerHelper("f", 6)) 772 | r.add("/users/*", handlerHelper("g", 7)) 773 | r.add("/users/new/*", handlerHelper("h", 8)) 774 | r.add("/*", handlerHelper("i", 9)) 775 | 776 | // Route > /users 777 | c := g.newContext(nil, nil, "", nil).(*context) 778 | r.find("/users", c) 779 | is.NoErr(c.handler(c)) 780 | is.Equal(1, c.Get("a")) 781 | is.Equal("/users", c.Get("path")) 782 | 783 | // Route > /users/new 784 | c = g.newContext(nil, nil, "", nil).(*context) 785 | r.find("/users/new", c) 786 | is.NoErr(c.handler(c)) 787 | is.Equal(2, c.Get("b")) 788 | is.Equal("/users/new", c.Get("path")) 789 | 790 | // Route > /users/:id 791 | c = g.newContext(nil, nil, "", nil).(*context) 792 | r.find("/users/1", c) 793 | is.NoErr(c.handler(c)) 794 | is.Equal(3, c.Get("c")) 795 | is.Equal("/users/:id", c.Get("path")) 796 | 797 | // Route > /users/dew 798 | c = g.newContext(nil, nil, "", nil).(*context) 799 | r.find("/users/dew", c) 800 | is.NoErr(c.handler(c)) 801 | is.Equal(4, c.Get("d")) 802 | is.Equal("/users/dew", c.Get("path")) 803 | 804 | // Route > /users/:id/files 805 | c = g.newContext(nil, nil, "", nil).(*context) 806 | r.find("/users/1/files", c) 807 | is.NoErr(c.handler(c)) 808 | is.Equal(5, c.Get("g")) 809 | is.Equal("/users/:id/files", c.Get("path")) 810 | 811 | // Route > /users/:id 812 | c = g.newContext(nil, nil, "", nil).(*context) 813 | r.find("/users/news", c) 814 | is.NoErr(c.handler(c)) 815 | is.Equal(3, c.Get("c")) 816 | is.Equal("/users/:id", c.Get("path")) 817 | 818 | // Route > /users/newsee 819 | c = g.newContext(nil, nil, "", nil).(*context) 820 | r.find("/users/newsee", c) 821 | is.NoErr(c.handler(c)) 822 | is.Equal(6, c.Get("f")) 823 | is.Equal("/users/newsee", c.Get("path")) 824 | 825 | // Route > /users/newsee 826 | r.find("/users/newsee", c) 827 | is.NoErr(c.handler(c)) 828 | is.Equal(6, c.Get("f")) 829 | 830 | // Route > /users/newsee 831 | r.find("/users/newsee", c) 832 | is.NoErr(c.handler(c)) 833 | is.Equal(6, c.Get("f")) 834 | 835 | // Route > /users/* 836 | c = g.newContext(nil, nil, "", nil).(*context) 837 | r.find("/users/joe/books", c) 838 | is.NoErr(c.handler(c)) 839 | is.Equal(7, c.Get("g")) 840 | is.Equal("/users/*", c.Get("path")) 841 | is.Equal("joe/books", c.Param("*")) 842 | 843 | // Route > /users/new/* should be matched 844 | c = g.newContext(nil, nil, "", nil).(*context) 845 | r.find("/users/new/someone", c) 846 | is.NoErr(c.handler(c)) 847 | is.Equal(8, c.Get("h")) 848 | is.Equal("/users/new/*", c.Get("path")) 849 | is.Equal("someone", c.Param("*")) 850 | 851 | // Route > /users/* should be matched although /users/dew exists 852 | c = g.newContext(nil, nil, "", nil).(*context) 853 | r.find("/users/dew/someone", c) 854 | is.NoErr(c.handler(c)) 855 | is.Equal(7, c.Get("g")) 856 | is.Equal("/users/*", c.Get("path")) 857 | 858 | is.Equal("dew/someone", c.Param("*")) 859 | 860 | // Route > /users/* should be matched although /users/dew exists 861 | c = g.newContext(nil, nil, "", nil).(*context) 862 | r.find("/users/notexists/someone", c) 863 | is.NoErr(c.handler(c)) 864 | is.Equal(7, c.Get("g")) 865 | is.Equal("/users/*", c.Get("path")) 866 | is.Equal("notexists/someone", c.Param("*")) 867 | 868 | // Route > * 869 | c = g.newContext(nil, nil, "", nil).(*context) 870 | r.find("/nousers", c) 871 | is.NoErr(c.handler(c)) 872 | is.Equal(9, c.Get("i")) 873 | is.Equal("/*", c.Get("path")) 874 | is.Equal("nousers", c.Param("*")) 875 | 876 | // Route > * 877 | c = g.newContext(nil, nil, "", nil).(*context) 878 | r.find("/nousers/new", c) 879 | is.NoErr(c.handler(c)) 880 | is.Equal(9, c.Get("i")) 881 | is.Equal("/*", c.Get("path")) 882 | is.Equal("nousers/new", c.Param("*")) 883 | } 884 | 885 | func TestRouterIssue1348(t *testing.T) { 886 | g := New() 887 | r := g.router 888 | 889 | r.add("/:lang/", func(c Context) error { 890 | return nil 891 | }) 892 | r.add("/:lang/dupa", func(c Context) error { 893 | return nil 894 | }) 895 | } 896 | 897 | func TestRouterPriorityNotFound(t *testing.T) { 898 | is := is.New(t) 899 | 900 | g := New() 901 | r := g.router 902 | c := g.newContext(nil, nil, "", nil).(*context) 903 | 904 | // Add 905 | r.add("/a/foo", func(c Context) error { 906 | c.Set("a", 1) 907 | return nil 908 | }) 909 | r.add("/a/bar", func(c Context) error { 910 | c.Set("b", 2) 911 | return nil 912 | }) 913 | 914 | // Find 915 | r.find("/a/foo", c) 916 | is.NoErr(c.handler(c)) 917 | is.Equal(1, c.Get("a")) 918 | 919 | r.find("/a/bar", c) 920 | is.NoErr(c.handler(c)) 921 | is.Equal(2, c.Get("b")) 922 | 923 | c = g.newContext(nil, nil, "", nil).(*context) 924 | r.find("/abc/def", c) 925 | he := c.handler(c).(*GeminiError) 926 | is.Equal(StatusNotFound, he.Code) 927 | } 928 | 929 | func TestRouterParamNames(t *testing.T) { 930 | is := is.New(t) 931 | 932 | g := New() 933 | r := g.router 934 | 935 | // Routes 936 | r.add("/users", func(c Context) error { 937 | c.Set("path", "/users") 938 | return nil 939 | }) 940 | r.add("/users/:id", func(c Context) error { 941 | return nil 942 | }) 943 | r.add("/users/:uid/files/:fid", func(c Context) error { 944 | return nil 945 | }) 946 | 947 | c := g.newContext(nil, nil, "", nil).(*context) 948 | 949 | // Route > /users 950 | r.find("/users", c) 951 | is.NoErr(c.handler(c)) 952 | is.Equal("/users", c.Get("path")) 953 | 954 | // Route > /users/:id 955 | r.find("/users/1", c) 956 | is.Equal("id", c.pnames[0]) 957 | is.Equal("1", c.Param("id")) 958 | 959 | // Route > /users/:uid/files/:fid 960 | r.find("/users/1/files/1", c) 961 | is.Equal("uid", c.pnames[0]) 962 | is.Equal("1", c.Param("uid")) 963 | is.Equal("fid", c.pnames[1]) 964 | is.Equal("1", c.Param("fid")) 965 | } 966 | 967 | func TestRouterStaticDynamicConflict(t *testing.T) { 968 | is := is.New(t) 969 | 970 | g := New() 971 | r := g.router 972 | 973 | r.add("/dictionary/skills", handlerHelper("a", 1)) 974 | r.add("/dictionary/:name", handlerHelper("b", 2)) 975 | r.add("/users/new", handlerHelper("d", 4)) 976 | r.add("/users/:name", handlerHelper("g", 5)) 977 | r.add("/server", handlerHelper("c", 3)) 978 | r.add("/", handlerHelper("f", 6)) 979 | 980 | c := g.newContext(nil, nil, "", nil) 981 | r.find("/dictionary/skills", c) 982 | is.NoErr(c.Handler()(c)) 983 | is.Equal(1, c.Get("a")) 984 | is.Equal("/dictionary/skills", c.Get("path")) 985 | 986 | c = g.newContext(nil, nil, "", nil) 987 | r.find("/dictionary/skillsnot", c) 988 | is.NoErr(c.Handler()(c)) 989 | is.Equal(2, c.Get("b")) 990 | is.Equal("/dictionary/:name", c.Get("path")) 991 | 992 | c = g.newContext(nil, nil, "", nil) 993 | r.find("/dictionary/type", c) 994 | is.NoErr(c.Handler()(c)) 995 | is.Equal(2, c.Get("b")) 996 | is.Equal("/dictionary/:name", c.Get("path")) 997 | 998 | c = g.newContext(nil, nil, "", nil) 999 | r.find("/server", c) 1000 | is.NoErr(c.Handler()(c)) 1001 | is.Equal(3, c.Get("c")) 1002 | is.Equal("/server", c.Get("path")) 1003 | 1004 | c = g.newContext(nil, nil, "", nil) 1005 | r.find("/users/new", c) 1006 | is.NoErr(c.Handler()(c)) 1007 | is.Equal(4, c.Get("d")) 1008 | is.Equal("/users/new", c.Get("path")) 1009 | 1010 | c = g.newContext(nil, nil, "", nil) 1011 | r.find("/users/new2", c) 1012 | is.NoErr(c.Handler()(c)) 1013 | is.Equal(5, c.Get("g")) 1014 | is.Equal("/users/:name", c.Get("path")) 1015 | 1016 | c = g.newContext(nil, nil, "", nil) 1017 | r.find("/", c) 1018 | is.NoErr(c.Handler()(c)) 1019 | is.Equal(6, c.Get("f")) 1020 | is.Equal("/", c.Get("path")) 1021 | } 1022 | 1023 | func TestRouterParamBacktraceNotFound(t *testing.T) { 1024 | is := is.New(t) 1025 | 1026 | g := New() 1027 | r := g.router 1028 | 1029 | // Add 1030 | r.add("/:param1", func(c Context) error { 1031 | return nil 1032 | }) 1033 | r.add("/:param1/foo", func(c Context) error { 1034 | return nil 1035 | }) 1036 | r.add("/:param1/bar", func(c Context) error { 1037 | return nil 1038 | }) 1039 | r.add("/:param1/bar/:param2", func(c Context) error { 1040 | return nil 1041 | }) 1042 | 1043 | c := g.newContext(nil, nil, "", nil).(*context) 1044 | 1045 | // Find 1046 | r.find("/a", c) 1047 | is.Equal("a", c.Param("param1")) 1048 | 1049 | c = g.newContext(nil, nil, "", nil).(*context) 1050 | r.find("/a/foo", c) 1051 | is.Equal("a", c.Param("param1")) 1052 | 1053 | c = g.newContext(nil, nil, "", nil).(*context) 1054 | r.find("/a/bar", c) 1055 | is.Equal("a", c.Param("param1")) 1056 | 1057 | c = g.newContext(nil, nil, "", nil).(*context) 1058 | r.find("/a/bar/b", c) 1059 | is.Equal("a", c.Param("param1")) 1060 | is.Equal("b", c.Param("param2")) 1061 | 1062 | c = g.newContext(nil, nil, "", nil).(*context) 1063 | r.find("/a/bbbbb", c) 1064 | he := c.handler(c).(*GeminiError) 1065 | is.Equal(StatusNotFound, he.Code) 1066 | } 1067 | 1068 | func testRouterAPI(t *testing.T, api []*Route) { 1069 | is := is.New(t) 1070 | 1071 | g := New() 1072 | r := g.router 1073 | 1074 | for _, route := range api { 1075 | r.add(route.Path, func(c Context) error { 1076 | return nil 1077 | }) 1078 | } 1079 | 1080 | c := g.newContext(nil, nil, "", nil).(*context) 1081 | 1082 | for _, route := range api { 1083 | r.find(route.Path, c) 1084 | tokens := strings.Split(route.Path[1:], "/") 1085 | 1086 | for _, token := range tokens { 1087 | if token[0] == ':' { 1088 | is.Equal(c.Param(token[1:]), token) 1089 | } 1090 | } 1091 | } 1092 | } 1093 | 1094 | func TestRouterGitHubAPI(t *testing.T) { 1095 | testRouterAPI(t, gitHubAPI) 1096 | } 1097 | 1098 | func TestRouterParamAlias(t *testing.T) { 1099 | api := []*Route{ 1100 | {"/users/:userID/following", ""}, 1101 | {"/users/:userID/followedBy", ""}, 1102 | {"/users/:userID/follow", ""}, 1103 | } 1104 | testRouterAPI(t, api) 1105 | } 1106 | 1107 | func TestRouterParamOrdering(t *testing.T) { 1108 | api := []*Route{ 1109 | {"/:a/:b/:c/:id", ""}, 1110 | {"/:a/:id", ""}, 1111 | {"/:a/:g/:id", ""}, 1112 | } 1113 | testRouterAPI(t, api) 1114 | 1115 | api2 := []*Route{ 1116 | {"/:a/:id", ""}, 1117 | {"/:a/:g/:id", ""}, 1118 | {"/:a/:b/:c/:id", ""}, 1119 | } 1120 | testRouterAPI(t, api2) 1121 | 1122 | api3 := []*Route{ 1123 | {"/:a/:b/:c/:id", ""}, 1124 | {"/:a/:g/:id", ""}, 1125 | {"/:a/:id", ""}, 1126 | } 1127 | testRouterAPI(t, api3) 1128 | } 1129 | 1130 | func TestRouterMixedParams(t *testing.T) { 1131 | api := []*Route{ 1132 | {"/teacher/:tid/room/suggestions", ""}, 1133 | {"/teacher/:id", ""}, 1134 | } 1135 | testRouterAPI(t, api) 1136 | 1137 | api2 := []*Route{ 1138 | {"/teacher/:id", ""}, 1139 | {"/teacher/:tid/room/suggestions", ""}, 1140 | } 1141 | testRouterAPI(t, api2) 1142 | } 1143 | 1144 | func TestRouterParam1466(t *testing.T) { 1145 | is := is.New(t) 1146 | 1147 | g := New() 1148 | r := g.router 1149 | 1150 | r.add("/users/signup", func(c Context) error { 1151 | return nil 1152 | }) 1153 | r.add("/users/signup/bulk", func(c Context) error { 1154 | return nil 1155 | }) 1156 | r.add("/users/survey", func(c Context) error { 1157 | return nil 1158 | }) 1159 | r.add("/users/:username", func(c Context) error { 1160 | return nil 1161 | }) 1162 | r.add("/interests/:name/users", func(c Context) error { 1163 | return nil 1164 | }) 1165 | r.add("/skills/:name/users", func(c Context) error { 1166 | return nil 1167 | }) 1168 | // Additional routes for Issue 1479 1169 | r.add("/users/:username/likes/projects/ids", func(c Context) error { 1170 | return nil 1171 | }) 1172 | r.add("/users/:username/profile", func(c Context) error { 1173 | return nil 1174 | }) 1175 | r.add("/users/:username/uploads/:type", func(c Context) error { 1176 | return nil 1177 | }) 1178 | 1179 | c := g.newContext(nil, nil, "", nil).(*context) 1180 | 1181 | r.find("/users/ajitem", c) 1182 | is.Equal("ajitem", c.Param("username")) 1183 | 1184 | c = g.newContext(nil, nil, "", nil).(*context) 1185 | r.find("/users/sharewithme", c) 1186 | is.Equal("sharewithme", c.Param("username")) 1187 | 1188 | c = g.newContext(nil, nil, "", nil).(*context) 1189 | r.find("/users/signup", c) 1190 | is.Equal("", c.Param("username")) 1191 | // Additional assertions for #1479 1192 | c = g.newContext(nil, nil, "", nil).(*context) 1193 | r.find("/users/sharewithme/likes/projects/ids", c) 1194 | is.Equal("sharewithme", c.Param("username")) 1195 | 1196 | c = g.newContext(nil, nil, "", nil).(*context) 1197 | r.find("/users/ajitem/likes/projects/ids", c) 1198 | is.Equal("ajitem", c.Param("username")) 1199 | 1200 | c = g.newContext(nil, nil, "", nil).(*context) 1201 | r.find("/users/sharewithme/profile", c) 1202 | is.Equal("sharewithme", c.Param("username")) 1203 | 1204 | c = g.newContext(nil, nil, "", nil).(*context) 1205 | r.find("/users/ajitem/profile", c) 1206 | is.Equal("ajitem", c.Param("username")) 1207 | 1208 | c = g.newContext(nil, nil, "", nil).(*context) 1209 | r.find("/users/sharewithme/uploads/self", c) 1210 | is.Equal("sharewithme", c.Param("username")) 1211 | is.Equal("self", c.Param("type")) 1212 | 1213 | c = g.newContext(nil, nil, "", nil).(*context) 1214 | r.find("/users/ajitem/uploads/self", c) 1215 | is.Equal("ajitem", c.Param("username")) 1216 | is.Equal("self", c.Param("type")) 1217 | 1218 | c = g.newContext(nil, nil, "", nil).(*context) 1219 | r.find("/users/tree/free", c) 1220 | is.Equal("", c.Param("id")) 1221 | is.Equal(Status(0), c.response.Status) 1222 | } 1223 | 1224 | func benchmarkRouterRoutes(b *testing.B, routes []*Route) { 1225 | g := New() 1226 | r := g.router 1227 | 1228 | b.ReportAllocs() 1229 | 1230 | // Add routes 1231 | for _, route := range routes { 1232 | r.add(route.Path, func(c Context) error { 1233 | return nil 1234 | }) 1235 | } 1236 | 1237 | // Find routes 1238 | for i := 0; i < b.N; i++ { 1239 | for _, route := range gitHubAPI { 1240 | c := g.ctxpool.Get().(*context) 1241 | r.find(route.Path, c) 1242 | g.ctxpool.Put(c) 1243 | } 1244 | } 1245 | } 1246 | 1247 | func BenchmarkRouterStaticRoutes(b *testing.B) { 1248 | benchmarkRouterRoutes(b, staticRoutes) 1249 | } 1250 | 1251 | func BenchmarkRouterGitHubAPI(b *testing.B) { 1252 | benchmarkRouterRoutes(b, gitHubAPI) 1253 | } 1254 | 1255 | func BenchmarkRouterParseAPI(b *testing.B) { 1256 | benchmarkRouterRoutes(b, parseAPI) 1257 | } 1258 | 1259 | func BenchmarkRouterGooglePlusAPI(b *testing.B) { 1260 | benchmarkRouterRoutes(b, googlePlusAPI) 1261 | } 1262 | -------------------------------------------------------------------------------- /serve_test.go: -------------------------------------------------------------------------------- 1 | // +build !race 2 | 3 | package gig 4 | 5 | import ( 6 | "crypto/tls" 7 | "io" 8 | "net" 9 | "syscall" 10 | "testing" 11 | "time" 12 | 13 | "github.com/matryer/is" 14 | ) 15 | 16 | type errorListener struct { 17 | errs []error 18 | } 19 | 20 | func (l *errorListener) Accept() (c net.Conn, err error) { 21 | if len(l.errs) == 0 { 22 | return nil, io.EOF 23 | } 24 | 25 | err = l.errs[0] 26 | l.errs = l.errs[1:] 27 | 28 | return 29 | } 30 | 31 | func (l *errorListener) Close() error { 32 | return nil 33 | } 34 | 35 | func (l *errorListener) Addr() net.Addr { 36 | return &FakeAddr{} 37 | } 38 | 39 | func TestServe_NetError(t *testing.T) { 40 | is := is.New(t) 41 | 42 | ln := &errorListener{[]error{ 43 | &net.OpError{ 44 | Op: "accept", 45 | Err: syscall.EMFILE, 46 | }}} 47 | g := New() 48 | g.listener = ln 49 | err := g.serve() 50 | is.Equal(io.EOF, err) 51 | } 52 | 53 | func TestServe(t *testing.T) { 54 | is := is.New(t) 55 | g := New() 56 | 57 | go func() { 58 | _ = g.Run("127.0.0.1:0", "_fixture/certs/cert.pem", "_fixture/certs/key.pem") 59 | }() 60 | time.Sleep(200 * time.Millisecond) 61 | 62 | addr := g.listener.Addr().String() 63 | conn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) 64 | is.NoErr(err) 65 | _, err = conn.Write([]byte("/test\r\n")) 66 | is.NoErr(err) 67 | 68 | buf := make([]byte, 15) 69 | n, err := conn.Read(buf) 70 | is.NoErr(err) 71 | 72 | is.Equal("51 Not Found\r\n\x00", string(buf)) 73 | is.Equal(14, n) 74 | 75 | g.Close() 76 | } 77 | 78 | func TestServe_SlowClient_Read(t *testing.T) { 79 | is := is.New(t) 80 | 81 | g := New() 82 | g.ReadTimeout = 1 * time.Millisecond 83 | 84 | go func() { 85 | _ = g.Run("127.0.0.1:0", "_fixture/certs/cert.pem", "_fixture/certs/key.pem") 86 | }() 87 | time.Sleep(200 * time.Millisecond) 88 | 89 | addr := g.listener.Addr().String() 90 | conn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) 91 | is.NoErr(err) 92 | 93 | time.Sleep(200 * time.Millisecond) // client sleeps before sending request 94 | 95 | _, err = conn.Write([]byte("/test\r\n")) 96 | 97 | is.True(err != nil) 98 | 99 | g.Close() 100 | } 101 | 102 | func TestServe_SlowClient_Write(t *testing.T) { 103 | is := is.New(t) 104 | 105 | g := New() 106 | g.WriteTimeout = 1 * time.Millisecond 107 | 108 | go func() { 109 | err := g.Run("127.0.0.1:0", "_fixture/certs/cert.pem", "_fixture/certs/key.pem") 110 | if err != ErrServerClosed { // Prevent the test to fail after closing the servers 111 | is.NoErr(err) 112 | } 113 | }() 114 | time.Sleep(200 * time.Millisecond) 115 | 116 | addr := g.listener.Addr().String() 117 | conn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) 118 | is.NoErr(err) 119 | _, err = conn.Write([]byte("/test\r\n")) 120 | is.NoErr(err) 121 | 122 | conn.Close() // client closes connection before reading response 123 | 124 | g.Close() 125 | } 126 | 127 | func TestServe_Overflow(t *testing.T) { 128 | is := is.New(t) 129 | 130 | g := New() 131 | 132 | go func() { 133 | _ = g.Run("127.0.0.1:0", "_fixture/certs/cert.pem", "_fixture/certs/key.pem") 134 | }() 135 | time.Sleep(200 * time.Millisecond) 136 | 137 | addr := g.listener.Addr().String() 138 | conn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) 139 | is.NoErr(err) 140 | 141 | request := make([]byte, 2000) 142 | _, _ = conn.Write(request) 143 | 144 | buf := make([]byte, 23) 145 | n, err := conn.Read(buf) 146 | is.NoErr(err) 147 | 148 | is.Equal("59 Request too long!\r\n\x00", string(buf)) 149 | is.Equal(22, n) 150 | 151 | g.Close() 152 | } 153 | 154 | func TestServe_NotGemini(t *testing.T) { 155 | is := is.New(t) 156 | g := New() 157 | 158 | go func() { 159 | _ = g.Run("127.0.0.1:0", "_fixture/certs/cert.pem", "_fixture/certs/key.pem") 160 | }() 161 | time.Sleep(200 * time.Millisecond) 162 | 163 | addr := g.listener.Addr().String() 164 | conn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) 165 | is.NoErr(err) 166 | 167 | _, err = conn.Write([]byte("http://google.com\r\n")) 168 | is.NoErr(err) 169 | 170 | buf := make([]byte, 40) 171 | n, err := conn.Read(buf) 172 | is.NoErr(err) 173 | 174 | is.Equal("59 No proxying to non-Gemini content!\r\n\x00", string(buf)) 175 | is.Equal(39, n) 176 | 177 | g.Close() 178 | } 179 | 180 | func TestServe_NotURL(t *testing.T) { 181 | is := is.New(t) 182 | g := New() 183 | 184 | go func() { 185 | _ = g.Run("127.0.0.1:0", "_fixture/certs/cert.pem", "_fixture/certs/key.pem") 186 | }() 187 | time.Sleep(200 * time.Millisecond) 188 | 189 | addr := g.listener.Addr().String() 190 | conn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) 191 | is.NoErr(err) 192 | 193 | _, err = conn.Write([]byte("::::::\r\n")) 194 | is.NoErr(err) 195 | 196 | buf := make([]byte, 24) 197 | n, err := conn.Read(buf) 198 | is.NoErr(err) 199 | 200 | is.Equal("59 Error parsing URL!\r\n\x00", string(buf)) 201 | is.Equal(23, n) 202 | 203 | g.Close() 204 | } 205 | -------------------------------------------------------------------------------- /status.go: -------------------------------------------------------------------------------- 1 | package gig 2 | 3 | // Status is a Gemini status code type. 4 | type Status int 5 | 6 | // Gemini status codes as documented by specification. 7 | // See: https://gemini.circumlunar.space/docs/spec-spec.txt 8 | const ( 9 | StatusInput Status = 10 10 | StatusSensitiveInput Status = 11 11 | StatusSuccess Status = 20 12 | StatusRedirectTemporary Status = 30 13 | StatusRedirectPermanent Status = 31 14 | StatusTemporaryFailure Status = 40 15 | StatusServerUnavailable Status = 41 16 | StatusCGIError Status = 42 17 | StatusProxyError Status = 43 18 | StatusSlowDown Status = 44 19 | StatusPermanentFailure Status = 50 20 | StatusNotFound Status = 51 21 | StatusGone Status = 52 22 | StatusProxyRequestRefused Status = 53 23 | StatusBadRequest Status = 59 24 | StatusClientCertificateRequired Status = 60 25 | StatusCertificateNotAuthorised Status = 61 26 | StatusCertificateNotValid Status = 62 27 | ) 28 | --------------------------------------------------------------------------------