Remove the bulk of the project.

Leave a README with a pointer to sliderule, and the LICENSE file.

Git history is still accessible here.
This commit is contained in:
Travis J Parker 2023-05-01 11:42:16 -06:00
parent aa6bdb0649
commit 2ead35d8a4
100 changed files with 3 additions and 7239 deletions

View File

@ -1,11 +0,0 @@
---
kind: pipeline
name: verify
steps:
- name: test
image: golang
commands:
- go test -race -v ./...
- name: lint
image: rancher/drone-golangci-lint:latest

1
.gitignore vendored
View File

@ -1 +0,0 @@
coverage.out

View File

@ -1,149 +0,0 @@
# Gus: The small web toolkit for Go
Gus is the toolkit for working with the small web in Go.
Think of it as a net/http for small web protocols. You still have to write your server, but you can focus on the logic you want to implement knowing the protocol is already dealt with. It's been said of gemini that you can write your server in a day. Now you can write it in well under an hour.
## The "gus" package
Gus is carefully structured as composable building blocks. The top-level package defines the framework in which servers and clients can be built.
* a request type
* a response type
* a "Server" interface type
* a "Handler" abstraction
* a "Middleware" abstraction
* some useful Handler wrappers: a router, request filtering, falling through a list of handlers
## Protocols
The packages gus/gemini, gus/gopher, and gus/finger provide concrete implementations of gus abstractions specific to those protocols.
* I/O (parsing, formatting) request and responses
* constructors for the various kinds of protocol responses
* helpers for building a protocol-suitable TLS config
* Client implementations
* Servers which can run your Handlers.
The primary text formats for those protocols have higher-level support provided in sub-packages:
* gus/gemini/gemtext supports parsing gemtext and getting direct programmatic access to its AST. Deeper sub-packages provide converters to other formats (markdown and HTML) with overridable templates.
* gus/gopher/gophermap similarly parses the gophermap format and provides access to its AST.
## Logging
Gus borrows the logging interface from go-kit.
=> https://pkg.go.dev/github.com/go-kit/log#Logger The logger interface from go-kit/log.
The gus/logging package provides everything you need to get a good basic start to producing helpful logs.
* A request-logging middleware with common diagnostics (time, duration, url, status codes, response body lengths)
* A simple constructor of useful default loggers at various levels. They output colorful logfmt lines to stdout.
## Routing
The router in the gus package supports slash-delimited path pattern strings. In the segments of these patterns:
* A "/:wildcard/" segment matches anything in that position, and captures the value as a route parameter. Or if the paramter name is omitted like "/:/", it matches anything in a single segment without capturing a paramter.
* A "/*remainder" segment is only allowed at the end and matches the rest of the path, capturing it into the paramter name. Or again, omitting a parameter name like "/*" simple matches any path suffix.
* Any other segment in the pattern must match the corresponding segment of a request exactly.
Router also supports maintaining a list of middlewares at the router level, mounting sub-routers under a pattern, looking up the matching handler for any request, and of course acting as a Handler itself.
## gus/contrib/*
This is where useful building blocks themselves start to come in. Sub-packages of contrib include Handler and Middleware implementations which accomplish the things your servers actually need to do.
The sub-packages include:
* fs has handlers that make file servers possible: serve files, build directory listings, etc
* cgi includes handlers which can execute CGI programs
* sharedhost which provides means of handling /~username URLs
* tlsauth contains middlewares and bool functions for authenticating against TLS client certificates
* ...with more to come
## Get it
### Using gus in your project
To add it to your own go project:
```shell command to add a dependency on gus in a go module
$ go get tildegit.org/tjp/gus
```
### Straight to the code please
=> https://tildegit.org/tjp/gus The code is hosted here on tildegit.
=> https://pkg.go.dev/tildegit.org/tjp/gus The generated documentation is on the go package index.
### Verify releases
Since v0.9.0, releases are signed with minisign. The signature file is included in the release downloads page, and the public key is RWSzQywJwHgjSMD0y0RXwXAGpapcMJplwbCVYQqabhAJ+NAnKAeh98Vb - this is also referenced on tjp's home page on gemini.
=> gemini://gemini.ctrl-c.club/~tjp TJP's home page, which also mentions the public key used for signing gus releases.
## Contribute
There's lots still to do, and contributions are very welcome!
=> https://tildegit.org/tjp/gus submit an issue or pull request on the tildegit repository,
=> mailto:tjp@ctrl-c.club send me an email directly,
or poke me on IRC: I'm @tjp on irc.tilde.chat where you'll find me in #gemini
------------------------
>Step 2: draw the rest of the owl
```An ASCII art owl
;;;;;:::::::::::::::::::::::;;;;;;;,,,,,,,''''''''''',,,,,,,,,,,;;;;;;;;;;,,,,,,,,,,,,,,,,,,;;;;:::::::::::::;;
;;;;;;:::::::::::::::;;;;;;;;;;;,,,,,,,,'''''''''''''',,,,,,,,;;;;;;;;;;;;;;;;;;;,,,,,,,,,,,,,;;;;::::::::::::;
;;;;;;;:::::::::::;;;;;;;;;;;,,,,,,,,,''''''''''''''',,,,,,,,;;;;;;;;::clooodddddddooooooodollc::;;:::::::::::;
;;;;;;;;;;;;;:::;;;;;;;;;;;;,,,,,,,,''''''''''''''''',,,,,,,,;;;;;:clodxkdodxdddooxxkxxxxkxxkxxxdlc::::::::::::
;;;;;;;;;;;;;;;;;;;;;;;;;;;,,,,,,,''''''''''''''''''',,,,,,,;;;;:lxkOkkkdolldl:c::loooolloclccolddolc:::::::::;
;;;;;;;;;;;;;;;;;;;;;;;;;;,,,,,,,''''''''''''''''''''',,,,,;;::oxOOOOkxxdoodkxlcc;;clcc:lccllddclloddol::::::;;
;;;;;;;;;;;;;;;;;;;;;;;;;,,,,,,,,,'''''''''''''''''''',,,,;;cdxxkkxdollllloodoodoc:::;:lddxxxOkdlldxkdolc;::;;;
;;;;;;,,,;;;;;;;;;;;;;;;;,,,,,,,,,,'''''''''''''''''''',,,;cdkkkxdollc::;,''',;lodd:;;cxkxdolllolc::ldool:;;;;;
,,,,,,,,,,;;;;;;;;;;;;;;;,,,,,,,,,,,,'''''''''''''''''',,,cdkkxdlc:cc:;........':odo::lolc;'...';:::::lool:;;;;
,,,,,,,,,,,,;;;;;;;;;;;;;,,,,,,,,,,,,,'''''''''''''''',,,:dkxxdolcccc::'':'.....,loollol,.........,;;::cll:;;;;
,,,,,,,,,,,,,,,,,;;;;;;;;;,,,,,,,,,,,,,,'''''''''',,,,,,;oxxxdllccccloo;'c:'';:,;codxdlc,'. ,,.,::::::lc:;;;;
,,,,,,,,,,,,,,,,,,;;;;;;;;;;;;;,,,,,,,,,,,,,,,,,,,,,,,,;lddddlc:;;:cldxdcclc::::cclddooc,';,',:,,clc::;:lc;;;;;
,,,,,,,,,,,,,,,,,,,,,;;;;;;;;;;;;;,,,,,,,,,,,,,,,,,,,,,:odddolc:;;::cloxxddocclllc::::ll:;;;:c::odoc;;;:lc;;;;;
;,,,,,,,,,,,,,,,,,,,,,,;;;;;;;;;;;;;;;;;;;;;,,,,,,,,,,,cdolloool:;;::cloddoooodoc;'''.:lllollddddl::;;;;::;;;;;
;;;,,,,,,,,,,,,,,,,,,,,,,,;;;;;;;;;;;;;;;;;;;;;;;;;;;,:odooxxooolc:::codxdlcllll:'....;ccccodddlc:;,,;;:c:,,,,,
;;;;,,,,,,,,,,,,,,,,,,,,,,,,;;;;;;;;;;::::::;;;;;;;;;:oxkO0KX0Oxolccccllodddddolc:'..;clcccclddl;,',,',::;,,,,,
;;;;;,,,,,,,,,,,,,,,,,,,,,,,;;;;;;;;;::::::::::::;:clodddooxOkxdol:coollodxkdl:,,'..';:looddool:;,'.';;::,,,,,;
;;;;;,,,,,,,,,,,,,,,,,,,,,,,;;;;;;;;::::::::::::ccloollloollc::::;;:lccodxkxlco:.......',cdxxdl;,,''';::;;;;;;;
;;;;;;,,,,,,,,,,,,,,,,,,,,,,,,;;;;;;;::::::::ccllccoolcclc:::::clc;;:;,;;cc,,cxd;,'......,:oxdol:;,;loc;;;;;;;;
;;;;;;,,,,,,,,,,,,,,,,,,,,,,,,,;;;;;;:::::cclllccccc::;;;;:ooc:oOxloxl'....,collddlc;'..';:lddoxdlldxdc;;;;::::
;;;;;;,,,,,,,,,,,,,,,,,,,,,,,,,,;;;;;:odooolcccccc::;:odlcoxdc;cldOKx;''''';:;,;loc;,'',,,,:clodkkkOkl:;:::::::
;;;;;;,,,,,,,,,,,,,,,,,,,,,,,,,,,;;:lxOxolllllccllc::x0xc;;c:''':kN0c,,;;;;;;,,',,,,''''''..,::lododdc:::::cccc
;;;;;,,,,,,,,,,,,,,,,,,,,,,,,,,,,;lxdolc:codoolcllc;:cc:,''''.'';lxo::::;;;;;;,''''.........:l;;::;:ol:::cccccc
;;;;;,,,,,,,,,,,,,,,,,,''',,,,,,:dxc'':loOKkdoccll:,,;;;,',,;:::;;;;::;;;;;;;;,,,...........,;,,',,;c::cccccccc
,,,,,,,,,,,,,,,,,,,,,,,'''''';:lol,':dxx0Xkolc::lc;;;;::;,;;:::;;;::::;;;;,,,',,,...........,;''''',:::ccccllll
,,,,,,,,,,,,,,,,,,,,,,,'''';ldl,..'cOKkdkdccccc:::::::::;;;:::;;:c:;;;,,,,'''''','........,;;'.',;;;:::ccllllll
,,,,,,,,,,,,,,,,,,,,,,,',;clc,.. .ckOkdlllccccc:c::::::::::;;;:::;;,,'''''...';,''.....';cl:'',;:ll:::ccccllccc
,,,,,,,,,,,,,,,,,,,,,,,;cl;......:loollcccccc::::::::c:;;,;;;;;;;,,,:l:'......'......,:llc;'',:codl::cccccccccc
,,,,,,,,,,,,,,,,,,,,;ccl:.. .':ldollc::::::::;;;;;;,;:,',;;;;;;;,',ox:..........',::::;,,,'';cllcccccccccccccc
',,,,,,,,,,,,,,,,,:ll:;,. .';cclllll:::::;;,,,,,,'..''',,,,;::;,'..........'..',;c:'.',;,''':ccccccccccccccccc
',,,,,,,,,,,,,;;;::'.. .,:cccc::cc:::;;,''',,'....,cc,'',;,'............'',;c:,,''',:c,,;,;ccccccclllllllcccc
',,,,,,,,,,;;;;,.. ..,:::::ccclolcc;;,,'''.',,''',::;,'..........',,;;:::::;'.,,,,cc,,;;:cccllllllllllllllll
'',,,,,,,,;;'.. .';::::clllolcc:;,;;;;,;;:;,'','................,:ccc::,''',;;,;::;,;::ccclllllllllllllllll
''',,,,,;:,.. .,;;;:clolc::;;;cc:;;;,;cc;,'..''..............',;;;;;;,',;::;;;::;;::::ccclllllllllllllllll
'''',,,,;'. ..',;::cc:;;,;;:ccc:;,,:lol;'''........''''..'',,;:ccc;,',:lc;,,,;;;;:::::ccclllllllllllllllll
'''''',;,.. ....,;:c::;:::::;:cc:,'''',,,'',,'....'';ccc::::::;;;:::,'':cc;,,,',;;;;;::::ccclllllllllllllllll
''''''':c,....',,:llllllc::;,:lo:,'',;;,,,,;;,,,'',;;cdxdllclllccccl:;,,::;,'''',,,,;;;;::::cccclllllcccccccccc
'''''',;lc'..,:ccllllcc:;,,;;clc::::cccccccc::::::clldxdoolloolcclc:;,,;,,;,,,,,,,,,,;;;;;:::cccccccccccccccccc
''''';lolc:ccllllc::::;;:cloollllllccclllooollooooooodolcclloc:;,,,;;,',,;,,,,,,,,,,,;;;;;;;::::ccccccccccc::::
'''';odl::lolc;,'...,codxxxxxddolcccllloooddddxxxxxdddlcccllc;,,;:::;,,;,,,,,,,,,,,,,,,,;;;;;;::::::::::::::::;
''',colc::;,......,coxxxdxxxol:;;;;:cloodddddddxxxxxddoooddlcccll:loo:;::,,,,,,,,,,,,,,,,,,,,;;;;;;:::::::;;;;;
'',co:,,,'...''..,:cccc:;::;,,,,,,;;:lodxddddooddddoooolcoxxdolc;,:ldxodoc:c:,,,,,,,,,,,,,,,,,,;;;;;;;;;;;;;;;,
''cOxcc;,,,;,'.''',,'',,,,,,,,,,,,,,;:clooddollooolllll;,cxkxol:,:,';llllodddo:,,,,,,,,,,,,,,,,,,,;;;;,,,,,,,,,
'';xkxoc,,,'..''',,,,,,,,,,,,,,,,,,,,,;;;:ccc::ccc:;::,';;;:cloodl;;,,:::lodOOl,',,,,,,,,,,,,,,,,,,,,,,,,,,,,,'
''',:c:,'';:;;;,,,,,,,,,,,,,,,,,,,,,,,,,,,;;,,;;;;,;;;'';;',oxddxocc:;clloxxk00kddc;,,,,,,,,,,,,,,,,,,,,,''''''
,''''''''';::,''''',,,,,,,,,,,,,,,,,,,,,,,;::cclccccc:,,,,.'loc;,:lllodoodkOO0KKKKOl;,;,,,,,,,,,,,,,,,,,,''''''
'''''''''''''''''''',,,,,,,,,,,,,,,,,,,,;clllcc::;,'...'...,lc:'.,c;:oook00OddO00KKd,,;;,;;;;,,,,,,,,,,,,,,,,''
'''''''''''''''''''',,,,,,,,,,,,,,,,;;::c:::;;,,''.....'''.:dl:'.,ll;',oKXXd;;:lod00dc::;;,;,,,,,,,,,,,,,,,,,,,
'''''''''''''''''',,,,,,,,;;;;,,,,;::ccccc::;;;,,,'..'''''';dc....:xxl;cxkkl:;;;::lddl:coc;,,,,,,,,,,,,,,,,,,,,
'''''''''''''''',,,,,;;;;;;;;;;;:clc:ccc::;;;;;,,'',,,,,,,,;lc'''',oOOxo;';cllccc:;:l:'':loc;,,,,,,,,,,,,,,,,,,
,''''''''''',,,,,,;;;;;;;::;;;::llc::::::;;:;;;:;;;;;;;;;;;;:cc;,,':k000x:;lddddoolodd:.'ldxo:,,,,,,,,,,,,,,'''
,'''''''''',,,,,;;;:::::::::cllllcccc::c:::::::cc::;;;;;;;;;;:cc:;,,lOKKK0dodxxxdollccl:,,codl;''''',,,,,''',''
,,''''''',,,,;;;;:::::::::cloooolcccc:::;;:::cc::;;,,;;;:;;;;;;:cc:,,oKKKKOddxxo:;:c::;:;',;:cc;''''''''''',,,,
```

158
README.md
View File

@ -1,157 +1,5 @@
# Gus: The small web toolkit now known as sliderule
# Gus: The small web toolkit for Go
gus is now known as [sliderule](https://tildegit.org/tjp/sliderule), please look for the latest on the project there.
Gus is the toolkit for working with the small web in Go.
Think of it as a net/http for small web protocols. You still have to write your server, but you can focus on the logic you want to implement knowing the protocol is already dealt with. It's been said of gemini that you can write your server in a day. Now you can write it in well under an hour.
## The "gus" package
Gus is carefully structured as composable building blocks. The top-level package defines the framework in which servers and clients can be built.
* a request type
* a response type
* a "Server" interface type
* a "Handler" abstraction
* a "Middleware" abstraction
* some useful Handler wrappers: a router, request filtering, falling through a list of handlers
## Protocols
The packages gus/gemini, gus/gopher, and gus/finger provide concrete implementations of gus abstractions specific to those protocols.
* I/O (parsing, formatting) request and responses
* constructors for the various kinds of protocol responses
* helpers for building a protocol-suitable TLS config
* Client implementations
* Servers which can run your Handlers.
The primary text formats for those protocols have higher-level support provided in sub-packages:
* gus/gemini/gemtext supports parsing gemtext and getting direct programmatic access to its AST. Deeper sub-packages provide converters to other formats (markdown and HTML) with overridable templates.
* gus/gopher/gophermap similarly parses the gophermap format and provides access to its AST.
## Logging
Gus borrows the logging interface from go-kit.
=> [The logger interface from go-kit/log.](https://pkg.go.dev/github.com/go-kit/log#Logger)
The gus/logging package provides everything you need to get a good basic start to producing helpful logs.
* A request-logging middleware with common diagnostics (time, duration, url, status codes, response body lengths)
* A simple constructor of useful default loggers at various levels. They output colorful logfmt lines to stdout.
## Routing
The router in the gus package supports slash-delimited path pattern strings. In the segments of these patterns:
* A "/:wildcard/" segment matches anything in that position, and captures the value as a route parameter. Or if the paramter name is omitted like "/:/", it matches anything in a single segment without capturing a paramter.
* A "/*remainder" segment is only allowed at the end and matches the rest of the path, capturing it into the paramter name. Or again, omitting a parameter name like "/*" simple matches any path suffix.
* Any other segment in the pattern must match the corresponding segment of a request exactly.
Router also supports maintaining a list of middlewares at the router level, mounting sub-routers under a pattern, looking up the matching handler for any request, and of course acting as a Handler itself.
## gus/contrib/*
This is where useful building blocks themselves start to come in. Sub-packages of contrib include Handler and Middleware implementations which accomplish the things your servers actually need to do.
The sub-packages include:
* fs has handlers that make file servers possible: serve files, build directory listings, etc
* cgi includes handlers which can execute CGI programs
* sharedhost which provides means of handling /~username URLs
* tlsauth contains middlewares and bool functions for authenticating against TLS client certificates
* ...with more to come
## Get it
### Using gus in your project
To add it to your own go project:
```
$ go get tildegit.org/tjp/gus
```
### Straight to the code please
=> [The code is hosted here on tildegit.](https://tildegit.org/tjp/gus)
=> [The generated documentation is on the go package index.](https://pkg.go.dev/tildegit.org/tjp/gus)
### Verify releases
Since v0.9.0, releases are signed with minisign. The signature file is included in the release downloads page, and the public key is RWSzQywJwHgjSMD0y0RXwXAGpapcMJplwbCVYQqabhAJ+NAnKAeh98Vb - this is also referenced on tjp's home page on gemini.
=> [TJP's home page, which also mentions the public key used for signing gus releases.](gemini://gemini.ctrl-c.club/~tjp)
## Contribute
There's lots still to do, and contributions are very welcome!
=> [submit an issue or pull request on the tildegit repository,](https://tildegit.org/tjp/gus)
=> [send me an email directly,](mailto:tjp@ctrl-c.club)
or poke me on IRC: I'm @tjp on irc.tilde.chat where you'll find me in #gemini
------------------------
> Step 2: draw the rest of the owl
```
;;;;;:::::::::::::::::::::::;;;;;;;,,,,,,,''''''''''',,,,,,,,,,,;;;;;;;;;;,,,,,,,,,,,,,,,,,,;;;;:::::::::::::;;
;;;;;;:::::::::::::::;;;;;;;;;;;,,,,,,,,'''''''''''''',,,,,,,,;;;;;;;;;;;;;;;;;;;,,,,,,,,,,,,,;;;;::::::::::::;
;;;;;;;:::::::::::;;;;;;;;;;;,,,,,,,,,''''''''''''''',,,,,,,,;;;;;;;;::clooodddddddooooooodollc::;;:::::::::::;
;;;;;;;;;;;;;:::;;;;;;;;;;;;,,,,,,,,''''''''''''''''',,,,,,,,;;;;;:clodxkdodxdddooxxkxxxxkxxkxxxdlc::::::::::::
;;;;;;;;;;;;;;;;;;;;;;;;;;;,,,,,,,''''''''''''''''''',,,,,,,;;;;:lxkOkkkdolldl:c::loooolloclccolddolc:::::::::;
;;;;;;;;;;;;;;;;;;;;;;;;;;,,,,,,,''''''''''''''''''''',,,,,;;::oxOOOOkxxdoodkxlcc;;clcc:lccllddclloddol::::::;;
;;;;;;;;;;;;;;;;;;;;;;;;;,,,,,,,,,'''''''''''''''''''',,,,;;cdxxkkxdollllloodoodoc:::;:lddxxxOkdlldxkdolc;::;;;
;;;;;;,,,;;;;;;;;;;;;;;;;,,,,,,,,,,'''''''''''''''''''',,,;cdkkkxdollc::;,''',;lodd:;;cxkxdolllolc::ldool:;;;;;
,,,,,,,,,,;;;;;;;;;;;;;;;,,,,,,,,,,,,'''''''''''''''''',,,cdkkxdlc:cc:;........':odo::lolc;'...';:::::lool:;;;;
,,,,,,,,,,,,;;;;;;;;;;;;;,,,,,,,,,,,,,'''''''''''''''',,,:dkxxdolcccc::'':'.....,loollol,.........,;;::cll:;;;;
,,,,,,,,,,,,,,,,,;;;;;;;;;,,,,,,,,,,,,,,'''''''''',,,,,,;oxxxdllccccloo;'c:'';:,;codxdlc,'. ,,.,::::::lc:;;;;
,,,,,,,,,,,,,,,,,,;;;;;;;;;;;;;,,,,,,,,,,,,,,,,,,,,,,,,;lddddlc:;;:cldxdcclc::::cclddooc,';,',:,,clc::;:lc;;;;;
,,,,,,,,,,,,,,,,,,,,,;;;;;;;;;;;;;,,,,,,,,,,,,,,,,,,,,,:odddolc:;;::cloxxddocclllc::::ll:;;;:c::odoc;;;:lc;;;;;
;,,,,,,,,,,,,,,,,,,,,,,;;;;;;;;;;;;;;;;;;;;;,,,,,,,,,,,cdolloool:;;::cloddoooodoc;'''.:lllollddddl::;;;;::;;;;;
;;;,,,,,,,,,,,,,,,,,,,,,,,;;;;;;;;;;;;;;;;;;;;;;;;;;;,:odooxxooolc:::codxdlcllll:'....;ccccodddlc:;,,;;:c:,,,,,
;;;;,,,,,,,,,,,,,,,,,,,,,,,,;;;;;;;;;;::::::;;;;;;;;;:oxkO0KX0Oxolccccllodddddolc:'..;clcccclddl;,',,',::;,,,,,
;;;;;,,,,,,,,,,,,,,,,,,,,,,,;;;;;;;;;::::::::::::;:clodddooxOkxdol:coollodxkdl:,,'..';:looddool:;,'.';;::,,,,,;
;;;;;,,,,,,,,,,,,,,,,,,,,,,,;;;;;;;;::::::::::::ccloollloollc::::;;:lccodxkxlco:.......',cdxxdl;,,''';::;;;;;;;
;;;;;;,,,,,,,,,,,,,,,,,,,,,,,,;;;;;;;::::::::ccllccoolcclc:::::clc;;:;,;;cc,,cxd;,'......,:oxdol:;,;loc;;;;;;;;
;;;;;;,,,,,,,,,,,,,,,,,,,,,,,,,;;;;;;:::::cclllccccc::;;;;:ooc:oOxloxl'....,collddlc;'..';:lddoxdlldxdc;;;;::::
;;;;;;,,,,,,,,,,,,,,,,,,,,,,,,,,;;;;;:odooolcccccc::;:odlcoxdc;cldOKx;''''';:;,;loc;,'',,,,:clodkkkOkl:;:::::::
;;;;;;,,,,,,,,,,,,,,,,,,,,,,,,,,,;;:lxOxolllllccllc::x0xc;;c:''':kN0c,,;;;;;;,,',,,,''''''..,::lododdc:::::cccc
;;;;;,,,,,,,,,,,,,,,,,,,,,,,,,,,,;lxdolc:codoolcllc;:cc:,''''.'';lxo::::;;;;;;,''''.........:l;;::;:ol:::cccccc
;;;;;,,,,,,,,,,,,,,,,,,''',,,,,,:dxc'':loOKkdoccll:,,;;;,',,;:::;;;;::;;;;;;;;,,,...........,;,,',,;c::cccccccc
,,,,,,,,,,,,,,,,,,,,,,,'''''';:lol,':dxx0Xkolc::lc;;;;::;,;;:::;;;::::;;;;,,,',,,...........,;''''',:::ccccllll
,,,,,,,,,,,,,,,,,,,,,,,'''';ldl,..'cOKkdkdccccc:::::::::;;;:::;;:c:;;;,,,,'''''','........,;;'.',;;;:::ccllllll
,,,,,,,,,,,,,,,,,,,,,,,',;clc,.. .ckOkdlllccccc:c::::::::::;;;:::;;,,'''''...';,''.....';cl:'',;:ll:::ccccllccc
,,,,,,,,,,,,,,,,,,,,,,,;cl;......:loollcccccc::::::::c:;;,;;;;;;;,,,:l:'......'......,:llc;'',:codl::cccccccccc
,,,,,,,,,,,,,,,,,,,,;ccl:.. .':ldollc::::::::;;;;;;,;:,',;;;;;;;,',ox:..........',::::;,,,'';cllcccccccccccccc
',,,,,,,,,,,,,,,,,:ll:;,. .';cclllll:::::;;,,,,,,'..''',,,,;::;,'..........'..',;c:'.',;,''':ccccccccccccccccc
',,,,,,,,,,,,,;;;::'.. .,:cccc::cc:::;;,''',,'....,cc,'',;,'............'',;c:,,''',:c,,;,;ccccccclllllllcccc
',,,,,,,,,,;;;;,.. ..,:::::ccclolcc;;,,'''.',,''',::;,'..........',,;;:::::;'.,,,,cc,,;;:cccllllllllllllllll
'',,,,,,,,;;'.. .';::::clllolcc:;,;;;;,;;:;,'','................,:ccc::,''',;;,;::;,;::ccclllllllllllllllll
''',,,,,;:,.. .,;;;:clolc::;;;cc:;;;,;cc;,'..''..............',;;;;;;,',;::;;;::;;::::ccclllllllllllllllll
'''',,,,;'. ..',;::cc:;;,;;:ccc:;,,:lol;'''........''''..'',,;:ccc;,',:lc;,,,;;;;:::::ccclllllllllllllllll
'''''',;,.. ....,;:c::;:::::;:cc:,'''',,,'',,'....'';ccc::::::;;;:::,'':cc;,,,',;;;;;::::ccclllllllllllllllll
''''''':c,....',,:llllllc::;,:lo:,'',;;,,,,;;,,,'',;;cdxdllclllccccl:;,,::;,'''',,,,;;;;::::cccclllllcccccccccc
'''''',;lc'..,:ccllllcc:;,,;;clc::::cccccccc::::::clldxdoolloolcclc:;,,;,,;,,,,,,,,,,;;;;;:::cccccccccccccccccc
''''';lolc:ccllllc::::;;:cloollllllccclllooollooooooodolcclloc:;,,,;;,',,;,,,,,,,,,,,;;;;;;;::::ccccccccccc::::
'''';odl::lolc;,'...,codxxxxxddolcccllloooddddxxxxxdddlcccllc;,,;:::;,,;,,,,,,,,,,,,,,,,;;;;;;::::::::::::::::;
''',colc::;,......,coxxxdxxxol:;;;;:cloodddddddxxxxxddoooddlcccll:loo:;::,,,,,,,,,,,,,,,,,,,,;;;;;;:::::::;;;;;
'',co:,,,'...''..,:cccc:;::;,,,,,,;;:lodxddddooddddoooolcoxxdolc;,:ldxodoc:c:,,,,,,,,,,,,,,,,,,;;;;;;;;;;;;;;;,
''cOxcc;,,,;,'.''',,'',,,,,,,,,,,,,,;:clooddollooolllll;,cxkxol:,:,';llllodddo:,,,,,,,,,,,,,,,,,,,;;;;,,,,,,,,,
'';xkxoc,,,'..''',,,,,,,,,,,,,,,,,,,,,;;;:ccc::ccc:;::,';;;:cloodl;;,,:::lodOOl,',,,,,,,,,,,,,,,,,,,,,,,,,,,,,'
''',:c:,'';:;;;,,,,,,,,,,,,,,,,,,,,,,,,,,,;;,,;;;;,;;;'';;',oxddxocc:;clloxxk00kddc;,,,,,,,,,,,,,,,,,,,,,''''''
,''''''''';::,''''',,,,,,,,,,,,,,,,,,,,,,,;::cclccccc:,,,,.'loc;,:lllodoodkOO0KKKKOl;,;,,,,,,,,,,,,,,,,,,''''''
'''''''''''''''''''',,,,,,,,,,,,,,,,,,,,;clllcc::;,'...'...,lc:'.,c;:oook00OddO00KKd,,;;,;;;;,,,,,,,,,,,,,,,,''
'''''''''''''''''''',,,,,,,,,,,,,,,,;;::c:::;;,,''.....'''.:dl:'.,ll;',oKXXd;;:lod00dc::;;,;,,,,,,,,,,,,,,,,,,,
'''''''''''''''''',,,,,,,,;;;;,,,,;::ccccc::;;;,,,'..'''''';dc....:xxl;cxkkl:;;;::lddl:coc;,,,,,,,,,,,,,,,,,,,,
'''''''''''''''',,,,,;;;;;;;;;;;:clc:ccc::;;;;;,,'',,,,,,,,;lc'''',oOOxo;';cllccc:;:l:'':loc;,,,,,,,,,,,,,,,,,,
,''''''''''',,,,,,;;;;;;;::;;;::llc::::::;;:;;;:;;;;;;;;;;;;:cc;,,':k000x:;lddddoolodd:.'ldxo:,,,,,,,,,,,,,,'''
,'''''''''',,,,,;;;:::::::::cllllcccc::c:::::::cc::;;;;;;;;;;:cc:;,,lOKKK0dodxxxdollccl:,,codl;''''',,,,,''',''
,,''''''',,,,;;;;:::::::::cloooolcccc:::;;:::cc::;;,,;;;:;;;;;;:cc:,,oKKKKOddxxo:;:c::;:;',;:cc;''''''''''',,,,
```
Old issues and the gus git history will be preserved here.

View File

@ -1,184 +0,0 @@
package cgi
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"io/fs"
"net"
"os"
"os/exec"
"strings"
"tildegit.org/tjp/gus"
)
// ResolveCGI finds a CGI program corresponding to a request path.
//
// It returns the path to the executable file and the PATH_INFO that should be passed,
// or an error.
//
// It will find executables which are just part way through the path, so for example
// a request for /foo/bar/baz can run an executable found at /foo or /foo/bar. In such
// a case the PATH_INFO would include the remaining portion of the URI path.
func ResolveCGI(requestPath, fsRoot string) (string, string, error) {
segments := strings.Split(strings.TrimLeft(requestPath, "/"), "/")
for i := range append(segments, "") {
filepath := strings.Join(append([]string{fsRoot}, segments[:i]...), "/")
filepath = strings.TrimRight(filepath, "/")
isDir, isExecutable, err := executableFile(filepath)
if err != nil {
return "", "", err
}
if isExecutable {
pathinfo := "/"
if len(segments) > i+1 {
pathinfo = strings.Join(segments[i:], "/")
}
return filepath, pathinfo, nil
}
if !isDir {
break
}
}
return "", "", nil
}
func executableFile(filepath string) (bool, bool, error) {
file, err := os.Open(filepath)
if isNotExistError(err) {
return false, false, nil
}
if err != nil {
return false, false, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return false, false, err
}
if info.IsDir() {
return true, false, nil
}
// readable + executable by anyone
return false, info.Mode()&0005 == 0005, nil
}
func isNotExistError(err error) bool {
if err != nil {
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
e := pathErr.Err
if errors.Is(e, fs.ErrInvalid) || errors.Is(e, fs.ErrNotExist) {
return true
}
}
}
return false
}
// RunCGI runs a specific program as a CGI script.
func RunCGI(
ctx context.Context,
request *gus.Request,
executable string,
pathInfo string,
) (io.Reader, int, error) {
pathSegments := strings.Split(executable, "/")
dirPath := "."
if len(pathSegments) > 1 {
dirPath = strings.Join(pathSegments[:len(pathSegments)-1], "/")
}
basename := pathSegments[len(pathSegments)-1]
infoLen := len(pathInfo)
if pathInfo == "/" {
infoLen -= 1
}
scriptName := request.Path[:len(request.Path)-infoLen]
scriptName = strings.TrimSuffix(scriptName, "/")
cmd := exec.CommandContext(ctx, "./"+basename)
cmd.Env = prepareCGIEnv(ctx, request, scriptName, pathInfo)
cmd.Dir = dirPath
responseBuffer := &bytes.Buffer{}
cmd.Stdout = responseBuffer
err := cmd.Run()
if err != nil {
var exErr *exec.ExitError
if errors.As(err, &exErr) {
return responseBuffer, exErr.ExitCode(), nil
}
}
return responseBuffer, cmd.ProcessState.ExitCode(), err
}
func prepareCGIEnv(
ctx context.Context,
request *gus.Request,
scriptName string,
pathInfo string,
) []string {
var authType string
if request.TLSState != nil && len(request.TLSState.PeerCertificates) > 0 {
authType = "Certificate"
}
environ := []string{
"AUTH_TYPE=" + authType,
"CONTENT_LENGTH=",
"CONTENT_TYPE=",
"GATEWAY_INTERFACE=CGI/1.1",
"PATH_INFO=" + pathInfo,
"PATH_TRANSLATED=",
"QUERY_STRING=" + request.RawQuery,
}
host, _, _ := net.SplitHostPort(request.RemoteAddr.String())
environ = append(environ, "REMOTE_ADDR="+host)
environ = append(
environ,
"REMOTE_HOST=",
"REMOTE_IDENT=",
"SCRIPT_NAME="+scriptName,
"SERVER_NAME="+request.Server.Hostname(),
"SERVER_PORT="+request.Server.Port(),
"SERVER_PROTOCOL="+request.Server.Protocol(),
"SERVER_SOFTWARE=GUS",
)
if request.TLSState != nil && len(request.TLSState.PeerCertificates) > 0 {
cert := request.TLSState.PeerCertificates[0]
environ = append(
environ,
"TLS_CLIENT_HASH="+fingerprint(cert.Raw),
"TLS_CLIENT_CERT="+hex.EncodeToString(cert.Raw),
"TLS_CLIENT_ISSUER="+cert.Issuer.String(),
"TLS_CLIENT_ISSUER_CN="+cert.Issuer.CommonName,
"TLS_CLIENT_SUBJECT="+cert.Subject.String(),
"TLS_CLIENT_SUBJECT_CN="+cert.Subject.CommonName,
)
}
return environ
}
func fingerprint(raw []byte) string {
hash := sha256.Sum256(raw)
return hex.EncodeToString(hash[:])
}

View File

@ -1,81 +0,0 @@
package cgi_test
import (
"context"
"crypto/tls"
"fmt"
"io"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/contrib/cgi"
"tildegit.org/tjp/gus/gemini"
)
func TestCGIDirectory(t *testing.T) {
tlsconf, err := gemini.FileTLS("testdata/server.crt", "testdata/server.key")
require.Nil(t, err)
handler := cgi.GeminiCGIDirectory("/cgi-bin", "./testdata")
server, err := gemini.NewServer(context.Background(), "localhost", "tcp", "127.0.0.1:0", handler, nil, tlsconf)
require.Nil(t, err)
go func() { assert.Nil(t, server.Serve()) }()
defer server.Close()
tests := []struct {
requestPath string
responseCode gus.Status
responseBody string
}{
{
requestPath: "/cgi-bin/hello_world",
responseCode: gemini.StatusSuccess,
responseBody: "hello, world!\n",
},
{
requestPath: "/cgi-bin/server.key",
responseCode: gemini.StatusNotFound,
},
{
requestPath: "/cgi-bin/non-existent",
responseCode: gemini.StatusNotFound,
},
{
requestPath: "/cgi-bin/fails",
responseCode: gemini.StatusCGIError,
},
}
for _, test := range tests {
t.Run(test.requestPath, func(t *testing.T) {
conn, err := tls.Dial(
server.Network(),
server.Address(),
&tls.Config{InsecureSkipVerify: true},
)
require.Nil(t, err)
_, err = fmt.Fprintf(conn, "gemini://%s%s\r\n", server.Address(), test.requestPath)
require.Nil(t, err)
response, err := io.ReadAll(conn)
require.Nil(t, err)
code, err := strconv.Atoi(string(response[:2]))
if assert.Nil(t, err) {
assert.Equal(t, test.responseCode, gus.Status(code))
}
_, body, found := strings.Cut(string(response), "\r\n")
if assert.True(t, found) && test.responseBody != "" {
assert.Equal(t, test.responseBody, body)
}
})
}
}

View File

@ -1,47 +0,0 @@
package cgi
import (
"context"
"fmt"
"strings"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
)
// GeminiCGIDirectory runs any executable files relative to a root directory on the file system.
//
// It will also find and run any executables _part way_ through the path, so for example
// a request for /foo/bar/baz can also run an executable found at /foo or /foo/bar. In
// such a case the PATH_INFO environment variable will include the remaining portion of
// the URI path.
func GeminiCGIDirectory(pathRoot, fsRoot string) gus.Handler {
fsRoot = strings.TrimRight(fsRoot, "/")
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
if !strings.HasPrefix(request.Path, pathRoot) {
return nil
}
filepath, pathinfo, err := ResolveCGI(request.Path[len(pathRoot):], fsRoot)
if err != nil {
return gemini.Failure(err)
}
if filepath == "" {
return nil
}
stdout, exitCode, err := RunCGI(ctx, request, filepath, pathinfo)
if err != nil {
return gemini.Failure(err)
}
if exitCode != 0 {
return gemini.CGIError(fmt.Sprintf("CGI process exited with status %d", exitCode))
}
response, err := gemini.ParseResponse(stdout)
if err != nil {
return gemini.Failure(err)
}
return response
})
}

View File

@ -1,45 +0,0 @@
package cgi
import (
"context"
"fmt"
"strings"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gopher"
)
// GopherCGIDirectory runs any executable files relative to a root directory on the file system.
//
// It will also find and run any executables part way through the path, so for example
// a request for /foo/bar/baz can also run an executable found at /foo or /foo/bar. In
// such a case the PATH_INFO environment variable will include the remaining portion of
// the URI path.
func GopherCGIDirectory(pathRoot, fsRoot string) gus.Handler {
fsRoot = strings.TrimRight(fsRoot, "/")
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
if !strings.HasPrefix(request.Path, pathRoot) {
return nil
}
filepath, pathinfo, err := ResolveCGI(request.Path[len(pathRoot):], fsRoot)
if err != nil {
return gopher.Error(err).Response()
}
if filepath == "" {
return nil
}
stdout, exitCode, err := RunCGI(ctx, request, filepath, pathinfo)
if err != nil {
return gopher.Error(err).Response()
}
if exitCode != 0 {
return gopher.Error(
fmt.Errorf("CGI process exited with status %d", exitCode),
).Response()
}
return gopher.File(0, stdout)
})
}

View File

@ -1,3 +0,0 @@
#!/usr/bin/env sh
exit 4

View File

@ -1,3 +0,0 @@
#!/usr/bin/env sh
printf "20 text/gemini\r\nhello, world!\n"

View File

@ -1,18 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIC7jCCAdYCAQcwDQYJKoZIhvcNAQELBQAwPTESMBAGA1UEAwwJbG9jYWxob3N0
MQswCQYDVQQGEwJVUzEaMBgGA1UEBwwRU2FuIEZyYW5jaXNjbywgQ0EwHhcNMjMw
MTExMjAwMDU5WhcNMjUwNDE1MjAwMDU5WjA9MRIwEAYDVQQDDAlsb2NhbGhvc3Qx
CzAJBgNVBAYTAlVTMRowGAYDVQQHDBFTYW4gRnJhbmNpc2NvLCBDQTCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBALlaPa1AxDQnMo0qQxY5/Bf7MNf1x6tN
xjkpMnQnPM+cHmmlkEhI1zwLk/LrLxwq7+OOxMTPrJglrAiDAp1uCZHjKcTMFnwO
9M5vf8LjtYBjZd8+OSHyYV37gxw7h9/Wsxl+1Yw40QaJKM9auj2xOyaDj5Ou9+yp
CfbGSpVUTnqReOVFg2QSNwEviOZu1SvAouPyO98WKoXjn7K5mxE545e4mgF1EMht
jB5kH6kXqZSUszlGA1MkX3AlDsYJIcYnDwelNvw6XTPpkT2wNehxPyD0iP4rs+W4
5hgV8wYokpgrM3xxe0c4mop5bzrp2Hyz3WxnF7KwtJgHW/6YxhG73skCAwEAATAN
BgkqhkiG9w0BAQsFAAOCAQEAfI+UE/3d0Fb8BZ2gtv1kUh8yx75LUbpg1aOEsZdP
Rji+GkL5xiFDsm7BwqTKziAjDtjL2qtGcJJ835shsGiUSK6qJuf9C944utUvCoFm
b4aUZ8fTmN7PkwRS61nIcHaS1zkiFzUdvbquV3QWSnl9kC+yDLHT0Z535tcvCMVM
bO7JMj1sxml4Y9B/hfY7zAZJt1giSNH1iDeX2pTpmPPI40UsRn98cC8HZ0d8wFrv
yc3hKkz8E+WTgZUf7jFk/KX/T5uwu+Y85emwfbb82KIR3oqhkJIfOfpqop2duZXB
hMuO1QWEBkZ/hpfrAsN/foz8v46P9qgW8gfOfzhyBcqLvA==
-----END CERTIFICATE-----

View File

@ -1,27 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAuVo9rUDENCcyjSpDFjn8F/sw1/XHq03GOSkydCc8z5weaaWQ
SEjXPAuT8usvHCrv447ExM+smCWsCIMCnW4JkeMpxMwWfA70zm9/wuO1gGNl3z45
IfJhXfuDHDuH39azGX7VjDjRBokoz1q6PbE7JoOPk6737KkJ9sZKlVROepF45UWD
ZBI3AS+I5m7VK8Ci4/I73xYqheOfsrmbETnjl7iaAXUQyG2MHmQfqReplJSzOUYD
UyRfcCUOxgkhxicPB6U2/DpdM+mRPbA16HE/IPSI/iuz5bjmGBXzBiiSmCszfHF7
RziainlvOunYfLPdbGcXsrC0mAdb/pjGEbveyQIDAQABAoIBAQC36ylkLu4Bahup
I5RqC6NwEFpJEKLOAmB8+7oKs5yNzTYIUra2Y0DfXgWyd1fJtXlP7aymNgPm/QqV
b5o6qKNqVWRu2Kw+8YBNDypRMi45dWfyewWp/55J6XYRn6iVna8dz1MKzp3qxFLw
XfCLor802jqvqmBsPteaPOxo/LzatKhXp/mcO/hsxeMr1iSUVHTrQEIU/aIkmAqT
/eXp/zVZk7O9Tx8wwCijB3v7j3zTEkcKSwFlAp0w01XeqllmqA5P9rW3vVGXJVIM
t6t9C8XcJWPIOURz3JWZJpUBSZsyNe2N/wbCgkQV81A0s+4praKzgDbjE+njb0C/
1CClbHV5AoGBAO/mnOzHe7ZJyYfuiu6ZR2REBY61n2J6DkL1stkN5xd+Op25afHT
jLBjU98hM/AMtP1aHWFQpdEe0uyqRjV6PbpNE8j/m9AVfjZxzwR4ITW2xqUhXOSz
89o832RO54TTr19YGnIhdU8dDQmYOcKmCSuw6KwCfHwBzkFuDFZGk/4/AoGBAMXK
gzNyX3tN9Ug5AUo/Az4jQRSoyLjfnce0a0TF4jxEacUBx2COq3zaV/VADEFBla1t
5roOAUyJ3V6fXtZnoqwZPYh6iGP8p7Tj6vyXI4SDktV0uAV57qSdajqxTrA7yoXr
zrbxv3U/3vXr3JTsP42U5zp1m5n1VfVqCXBkynD3AoGBAOvs7JjDWXuctzASPNmH
LjmB18FQBk3vYQUi4l8pmAF3pyejx3gGJw70r+/4lD5YEMozjD8+88Njv+T1U5SW
Agysbm+2SMJr0LK0W/W2Olq7xEFzPQrBmmgeg0b/fhoXoBlw6JkjJF3IYSD1bqBp
bw1jrn4y979weynHkyRpxnM7AoGBALGSzRPlPR/gr7P1qdjUlb61u/omRn7kFC11
J1EJL8HX0fXTUQK5U/C1vn4q0FXN4elgX+LuK/BhXeNTxbtMM9m6l2nuSIEsFgzr
Cs9XicWwsqT9MzGHdN9JjFPBV9oU9BAj0uSgSbmkbDHxXYo+SBh+dNIhQF+KyW+Z
kXvcoXulAoGAA2hnEA17nJ7Vj1DZ4CoRblgjZFAMB64slcSesaorp3WWehvaXO8u
jbvWuvj58DgvTLiv8xPIn4Zsjd0a77ysifvUcmxSRa/k9UIle/lwjmXGjQ1GSMEI
FB5ZTqjLZwS9Y5BDxlPcYF7vqE9fNpcxmcfHGmSF5YAHvFOfGH6B63M=
-----END RSA PRIVATE KEY-----

View File

@ -1,4 +0,0 @@
/*
Contrib contains sub-packages with specific functionality for small web servers.
*/
package contrib

View File

@ -1,154 +0,0 @@
package fs
import (
"bytes"
"io"
"io/fs"
"sort"
"strings"
"text/template"
"tildegit.org/tjp/gus"
)
// ResolveDirectory opens the directory corresponding to a request path.
//
// The string is the full path to the directory. If the returned ReadDirFile
// is not nil, it will be open and must be closed by the caller.
func ResolveDirectory(
request *gus.Request,
fileSystem fs.FS,
) (string, fs.ReadDirFile, error) {
path := strings.Trim(request.Path, "/")
if path == "" {
path = "."
}
file, err := fileSystem.Open(path)
if isNotFound(err) {
return "", nil, nil
}
if err != nil {
return "", nil, err
}
isDir, err := fileIsDir(file)
if err != nil {
_ = file.Close()
return "", nil, err
}
if !isDir {
_ = file.Close()
return "", nil, nil
}
dirFile, ok := file.(fs.ReadDirFile)
if !ok {
_ = file.Close()
return "", nil, nil
}
return path, dirFile, nil
}
// ResolveDirectoryDefault finds any of the provided filenames within a directory.
//
// If it does not find any of the filenames it returns "", nil, nil.
func ResolveDirectoryDefault(
fileSystem fs.FS,
dirPath string,
dir fs.ReadDirFile,
filenames []string,
) (string, fs.File, error) {
entries, err := dir.ReadDir(0)
if err != nil {
return "", nil, err
}
sort.Slice(entries, func(a, b int) bool {
return entries[a].Name() < entries[b].Name()
})
for _, filename := range filenames {
idx := sort.Search(len(entries), func(i int) bool {
return entries[i].Name() <= filename
})
if idx < len(entries) && entries[idx].Name() == filename {
path := dirPath + "/" + filename
file, err := fileSystem.Open(path)
return path, file, err
}
}
return "", nil, nil
}
// RenderDirectoryListing provides an io.Reader with the output of a directory listing template.
//
// The template is provided the following namespace:
// - .FullPath: the complete path to the listed directory
// - .DirName: the name of the directory itself
// - .Entries: the []fs.DirEntry of the directory contents
// - .Hostname: the hostname of the server hosting the file
// - .Port: the port on which the server is listening
//
// Each entry in .Entries has the following fields:
// - .Name the string name of the item within the directory
// - .IsDir is a boolean
// - .Type is the FileMode bits
// - .Info is a method returning (fs.FileInfo, error)
func RenderDirectoryListing(
path string,
dir fs.ReadDirFile,
template *template.Template,
server gus.Server,
) (io.Reader, error) {
buf := &bytes.Buffer{}
environ, err := dirlistNamespace(path, dir, server)
if err != nil {
return nil, err
}
if err := template.Execute(buf, environ); err != nil {
return nil, err
}
return buf, nil
}
func dirlistNamespace(path string, dirFile fs.ReadDirFile, server gus.Server) (map[string]any, error) {
entries, err := dirFile.ReadDir(0)
if err != nil {
return nil, err
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
var dirname string
if path == "." {
dirname = "(root)"
} else {
dirname = path[strings.LastIndex(path, "/")+1:]
}
hostname := "none"
port := "0"
if server != nil {
hostname = server.Hostname()
port = server.Port()
}
m := map[string]any{
"FullPath": path,
"DirName": dirname,
"Entries": entries,
"Hostname": hostname,
"Port": port,
}
return m, nil
}

View File

@ -1,130 +0,0 @@
package fs_test
import (
"context"
"io"
"net/url"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/contrib/fs"
"tildegit.org/tjp/gus/gemini"
)
func TestDirectoryDefault(t *testing.T) {
handler := fs.GeminiDirectoryDefault(os.DirFS("testdata"), "index.gmi")
tests := []struct {
url string
status gus.Status
meta string
body string
}{
{
url: "gemini://localhost/d",
status: gemini.StatusPermanentRedirect,
meta: "gemini://localhost/d/",
},
{
url: "gemini://localhost/d/",
status: gemini.StatusSuccess,
meta: "text/gemini",
body: "# This is d\n",
},
{
url: "gemini://localhost/a/",
status: gemini.StatusNotFound,
},
}
for _, test := range tests {
t.Run(test.url, func(t *testing.T) {
u, err := url.Parse(test.url)
require.Nil(t, err)
request := &gus.Request{URL: u}
response := handler.Handle(context.Background(), request)
if response == nil {
assert.Equal(t, test.status, gemini.StatusNotFound)
return
} else {
assert.Equal(t, test.status, response.Status)
}
if test.meta != "" {
assert.Equal(t, test.meta, response.Meta)
}
if test.body != "" {
body, err := io.ReadAll(response.Body)
require.Nil(t, err)
assert.Equal(t, test.body, string(body))
}
})
}
}
func TestDirectoryListing(t *testing.T) {
handler := fs.GeminiDirectoryListing(os.DirFS("testdata"), nil)
tests := []struct {
url string
status gus.Status
meta string
body string
}{
{
url: "gemini://localhost/",
status: gemini.StatusSuccess,
meta: "text/gemini",
body: "# (root)\n\n=> a/\n=> d/\n=> ../\n",
},
{
url: "gemini://localhost/d",
status: gemini.StatusPermanentRedirect,
meta: "gemini://localhost/d/",
},
{
url: "gemini://localhost/d/",
status: gemini.StatusSuccess,
meta: "text/gemini",
body: "# d\n\n=> index.gmi\n=> ../\n",
},
{
url: "gemini://localhost/a/",
status: gemini.StatusSuccess,
meta: "text/gemini",
body: "# a\n\n=> b\n=> c.html\n=> ../\n",
},
}
for _, test := range tests {
t.Run(test.url, func(t *testing.T) {
u, err := url.Parse(test.url)
require.Nil(t, err)
request := &gus.Request{URL: u}
response := handler.Handle(context.Background(), request)
if response == nil {
assert.Equal(t, test.status, gemini.StatusNotFound)
return
} else {
assert.Equal(t, test.status, response.Status)
}
if test.meta != "" {
assert.Equal(t, test.meta, response.Meta)
}
if test.body != "" {
body, err := io.ReadAll(response.Body)
require.Nil(t, err)
assert.Equal(t, test.body, string(body))
}
})
}
}

View File

@ -1,59 +0,0 @@
package fs
import (
"io/fs"
"mime"
"strings"
"tildegit.org/tjp/gus"
)
// ResolveFile finds a file from a filesystem based on a request path.
//
// It only returns a non-nil file if a file is found - not a directory.
// If there is any other sort of filesystem access error, it will be
// returned.
func ResolveFile(request *gus.Request, fileSystem fs.FS) (string, fs.File, error) {
filepath := strings.TrimPrefix(request.Path, "/")
file, err := fileSystem.Open(filepath)
if isNotFound(err) {
return "", nil, nil
}
if err != nil {
return "", nil, err
}
isDir, err := fileIsDir(file)
if err != nil {
_ = file.Close()
return "", nil, err
}
if isDir {
_ = file.Close()
return "", nil, nil
}
return filepath, file, nil
}
func mediaType(filePath string) string {
if strings.HasSuffix(filePath, ".gmi") {
// This may not be present in the listings searched by mime.TypeByExtension,
// so provide a dedicated fast path for it here.
return "text/gemini"
}
slashIdx := strings.LastIndex(filePath, "/")
dotIdx := strings.LastIndex(filePath[slashIdx+1:], ".")
if dotIdx == -1 {
return "application/octet-stream"
}
ext := filePath[slashIdx+1+dotIdx:]
mtype := mime.TypeByExtension(ext)
if mtype == "" {
return "application/octet-stream"
}
return mtype
}

View File

@ -1,80 +0,0 @@
package fs_test
import (
"context"
"io"
"net/url"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/contrib/fs"
"tildegit.org/tjp/gus/gemini"
)
func TestFileHandler(t *testing.T) {
handler := fs.GeminiFileHandler(os.DirFS("testdata"))
tests := []struct {
url string
status gus.Status
meta string
body string
}{
{
url: "gemini://localhost/d",
status: gemini.StatusNotFound,
},
{
url: "gemini://localhost/d/",
status: gemini.StatusNotFound,
},
{
url: "gemini://localhost/d/index.gmi",
status: gemini.StatusSuccess,
meta: "text/gemini",
body: "# This is d\n",
},
{
url: "gemini://localhost/a/b",
status: gemini.StatusSuccess,
meta: "application/octet-stream",
body: "this is file b\n",
},
{
url: "gemini://localhost/a/c.html",
status: gemini.StatusSuccess,
meta: "text/html; charset=utf-8",
body: "",
},
}
for _, test := range tests {
t.Run(test.url, func(t *testing.T) {
u, err := url.Parse(test.url)
require.Nil(t, err)
request := &gus.Request{URL: u}
response := handler.Handle(context.Background(), request)
if response == nil {
assert.Equal(t, test.status, gemini.StatusNotFound)
return
} else {
assert.Equal(t, test.status, response.Status)
}
if test.meta != "" {
assert.Equal(t, test.meta, response.Meta)
}
if test.body != "" {
body, err := io.ReadAll(response.Body)
require.Nil(t, err)
assert.Equal(t, test.body, string(body))
}
})
}
}

View File

@ -1,130 +0,0 @@
package fs
import (
"context"
"io/fs"
"strings"
"text/template"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
)
// GeminiFileHandler builds a handler which serves up files from a file system.
//
// It only serves responses for paths which do not correspond to directories on disk.
func GeminiFileHandler(fileSystem fs.FS) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
filepath, file, err := ResolveFile(request, fileSystem)
if err != nil {
return gemini.Failure(err)
}
if file == nil {
return nil
}
return gemini.Success(mediaType(filepath), file)
})
}
// GeminiDirectoryDefault serves up default files for directory path requests.
//
// If any of the supported filenames are found, the contents of the file is returned
// as the gemini response.
//
// It returns nil for any paths which don't correspond to a directory.
//
// When it encounters a directory path which doesn't end in a trailing slash (/) it
// redirects to a URL with the trailing slash appended. This is necessary for relative
// links inot the directory's contents to function properly.
//
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they
// don't, it will produce nil responses for any directory paths.
func GeminiDirectoryDefault(fileSystem fs.FS, filenames ...string) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
dirpath, dir, response := handleDirGemini(request, fileSystem)
if response != nil {
return response
}
if dir == nil {
return nil
}
defer func() { _ = dir.Close() }()
filepath, file, err := ResolveDirectoryDefault(fileSystem, dirpath, dir, filenames)
if err != nil {
return gemini.Failure(err)
}
if file == nil {
return nil
}
return gemini.Success(mediaType(filepath), file)
})
}
// GeminiDirectoryListing produces a listing of the contents of any requested directories.
//
// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
//
// When it encounters a directory path which doesn't end in a trailing slash (/) it
// redirects to a URL with the trailing slash appended. This is necessary for relative
// links inot the directory's contents to function properly.
//
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they
// don't, it will produce "51 Not Found" responses for any directory paths.
//
// The template may be nil, in which case DefaultGeminiDirectoryList is used instead. The
// template is then processed with RenderDirectoryListing.
func GeminiDirectoryListing(fileSystem fs.FS, template *template.Template) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
dirpath, dir, response := handleDirGemini(request, fileSystem)
if response != nil {
return response
}
if dir == nil {
return nil
}
defer func() { _ = dir.Close() }()
if template == nil {
template = DefaultGeminiDirectoryList
}
body, err := RenderDirectoryListing(dirpath, dir, template, request.Server)
if err != nil {
return gemini.Failure(err)
}
return gemini.Success("text/gemini", body)
})
}
// DefaultGeminiDirectoryList is a template which renders a reasonable gemtext dir list.
var DefaultGeminiDirectoryList = template.Must(template.New("gemini_dirlist").Parse(`
# {{ .DirName }}
{{ range .Entries }}
=> {{ .Name }}{{ if .IsDir }}/{{ end -}}
{{ end }}
=> ../
`[1:]))
func handleDirGemini(request *gus.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gus.Response) {
path, dir, err := ResolveDirectory(request, fileSystem)
if err != nil {
return "", nil, gemini.Failure(err)
}
if dir == nil {
return "", nil, nil
}
if !strings.HasSuffix(request.Path, "/") {
_ = dir.Close()
url := *request.URL
url.Path += "/"
return "", nil, gemini.PermanentRedirect(url.String())
}
return path, dir, nil
}

View File

@ -1,168 +0,0 @@
package fs
import (
"context"
"io/fs"
"mime"
"path"
"strings"
"text/template"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gopher"
)
// GopherFileHandler builds a handler which serves up files from a file system.
//
// It only serves responses for paths which correspond to files, not directories.
func GopherFileHandler(fileSystem fs.FS) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
filepath, file, err := ResolveFile(request, fileSystem)
if err != nil {
return gopher.Error(err).Response()
}
if file == nil {
return nil
}
return gopher.File(GuessGopherItemType(filepath), file)
})
}
// GopherDirectoryDefault serves up default files for directory path requests.
//
// If any of the supported filenames are found in the requested directory, the
// contents of that file is returned as the gopher response.
//
// It returns nil for any paths which don't correspond to a directory.
//
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If
// they don't, it will produce nil responses for all directory paths.
func GopherDirectoryDefault(fileSystem fs.FS, filenames ...string) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
dirpath, dir, err := ResolveDirectory(request, fileSystem)
if err != nil {
return gopher.Error(err).Response()
}
if dir == nil {
return nil
}
defer func() { _ = dir.Close() }()
_, file, err := ResolveDirectoryDefault(fileSystem, dirpath, dir, filenames)
if err != nil {
return gopher.Error(err).Response()
}
if file == nil {
return nil
}
return gopher.File(gopher.MenuType, file)
})
}
// GopherDirectoryListing produces a listing of the contents of any requested directories.
//
// It returns nil for any paths which don't correspond to a filesystem directory.
//
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they
// don't, it will produce nil responses for any directory paths.
//
// A template may be nil, in which case DefaultGopherDirectoryList is used instead. The
// template is then processed with RenderDirectoryListing.
func GopherDirectoryListing(fileSystem fs.FS, tpl *template.Template) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
dirpath, dir, err := ResolveDirectory(request, fileSystem)
if err != nil {
return gopher.Error(err).Response()
}
if dir == nil {
return nil
}
defer func() { _ = dir.Close() }()
if tpl == nil {
tpl = DefaultGopherDirectoryList
}
body, err := RenderDirectoryListing(dirpath, dir, tpl, request.Server)
if err != nil {
return gopher.Error(err).Response()
}
return gopher.File(gopher.MenuType, body)
})
}
// GopherTemplateFunctions is a map for templates providing useful functions for gophermaps.
//
// - GuessItemType: return a gopher item type for a file based on its path/name.
var GopherTemplateFunctions = template.FuncMap{
"GuessItemType": func(filepath string) string {
return string([]byte{byte(GuessGopherItemType(filepath))})
},
}
// DefaultGopherDirectoryList is a template which renders a directory listing as gophermap.
var DefaultGopherDirectoryList = template.Must(
template.New("gopher_dirlist").Funcs(GopherTemplateFunctions).Parse(
strings.ReplaceAll(
`
{{ $root := .FullPath -}}
{{ if eq .FullPath "." }}{{ $root = "" }}{{ end -}}
{{ $hostname := .Hostname -}}
{{ $port := .Port -}}
i{{ .DirName }} {{ $hostname }} {{ $port }}
i {{ $hostname }} {{ $port }}
{{ range .Entries -}}
{{ if .IsDir -}}
1{{ .Name }} {{ $root }}/{{ .Name }} {{ $hostname }} {{ $port }}
{{- else -}}
{{ GuessItemType .Name }}{{ .Name }} {{ $root }}/{{ .Name }} {{ $hostname }} {{ $port }}
{{- end }}
{{ end -}}
.
`[1:],
"\n",
"\r\n",
),
),
)
// GuessGopherItemType attempts to find the best gopher item type for a file based on its name.
func GuessGopherItemType(filepath string) gus.Status {
ext := path.Ext(filepath)
switch ext {
case "txt", "gmi":
return gopher.TextFileType
case "gif", "png", "jpg", "jpeg":
return gopher.ImageFileType
case "mp4", "mov":
return gopher.MovieFileType
case "mp3", "aiff", "aif", "aac", "ogg", "flac", "alac", "wma":
return gopher.SoundFileType
case "bmp":
return gopher.BitmapType
case "doc", "docx", "odt":
return gopher.DocumentType
case "html", "htm":
return gopher.HTMLType
case "rtf":
return gopher.RtfDocumentType
case "wav":
return gopher.WavSoundFileType
case "pdf":
return gopher.PdfDocumentType
case "xml":
return gopher.XmlDocumentType
case "":
return gopher.BinaryFileType
}
mtype := mime.TypeByExtension(ext)
if strings.HasPrefix(mtype, "text/") {
return gopher.TextFileType
}
return gopher.BinaryFileType
}

View File

@ -1,28 +0,0 @@
package fs
import (
"errors"
"io/fs"
)
func isNotFound(err error) bool {
if err == nil {
return false
}
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
e := pathErr.Err
return errors.Is(e, fs.ErrInvalid) || errors.Is(e, fs.ErrNotExist)
}
return false
}
func fileIsDir(file fs.File) (bool, error) {
info, err := file.Stat()
if err != nil {
return false, err
}
return info.IsDir(), nil
}

View File

@ -1 +0,0 @@
this is file b

View File

View File

@ -1 +0,0 @@
# This is d

View File

@ -1,46 +0,0 @@
package sharedhost
import (
"context"
"crypto/tls"
"net/url"
"tildegit.org/tjp/gus"
)
// ReplaceTilde builds a middleware which substitutes a leading '~' in the request path.
//
// It makes the alteration to a copy of the request which is then passed into the
// wrapped Handler. This way middlewares outside of this one inspecting the request
// afterwards will see the original URL.
//
// Typically the replacement should end with a "/", so that the ~ ends up mapping to a
// particular directory on the filesystem. For instance with a replacement string of
// "users/", "domain.com/~jim/index.gmi" maps to "domain.com/users/jim/index.gmi".
func ReplaceTilde(replacement string) gus.Middleware {
return func(inner gus.Handler) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
if len(request.Path) > 1 && request.Path[0] == '/' && request.Path[1] == '~' {
request = cloneRequest(request)
request.Path = "/" + replacement + request.Path[2:]
}
return inner.Handle(ctx, request)
})
}
}
func cloneRequest(start *gus.Request) *gus.Request {
next := &gus.Request{}
*next = *start
next.URL = &url.URL{}
*next.URL = *start.URL
if start.TLSState != nil {
next.TLSState = &tls.ConnectionState{}
*next.TLSState = *start.TLSState
}
return next
}

View File

@ -1,57 +0,0 @@
package sharedhost_test
import (
"context"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/contrib/sharedhost"
)
func TestReplaceTilde(t *testing.T) {
tests := []struct {
replacement string
inputURL string
replacedPath string
}{
{
replacement: "users/",
inputURL: "gemini://domain.com/~username/foo/bar",
replacedPath: "/users/username/foo/bar",
},
{
replacement: "people-",
inputURL: "gemini://domain.com/non/match",
replacedPath: "/non/match",
},
{
replacement: "people-",
inputURL: "gemini://domain.com/~someone/dir/file",
replacedPath: "/people-someone/dir/file",
},
}
for _, test := range tests {
t.Run(test.inputURL, func(t *testing.T) {
u, err := url.Parse(test.inputURL)
assert.Nil(t, err)
originalPath := u.Path
replacer := sharedhost.ReplaceTilde(test.replacement)
request := &gus.Request{URL: u}
handler := replacer(gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
assert.Equal(t, test.replacedPath, request.Path)
return &gus.Response{}
}))
handler.Handle(context.Background(), request)
// original request was unmodified
assert.Equal(t, originalPath, request.Path)
})
}
}

View File

@ -1,17 +0,0 @@
package tlsauth
import "crypto/x509"
// Approver is a function that validates a certificate.
//
// It should not be have to handle a nil argument.
type Approver func(*x509.Certificate) bool
// RequireSpecificIdentity builds an approver that demands one specific client certificate.
func RequireSpecificIdentity(identity *x509.Certificate) Approver { return identity.Equal }
// Allow is an approver which permits anything.
func Allow(_ *x509.Certificate) bool { return true }
// Reject is an approver which denies everything.
func Reject(_ *x509.Certificate) bool { return false }

View File

@ -1,47 +0,0 @@
package tlsauth_test
import (
"crypto/tls"
"crypto/x509"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"tildegit.org/tjp/gus/contrib/tlsauth"
)
func TestRequireSpecificIdentity(t *testing.T) {
cert1, err := leafCert("testdata/client1.crt", "testdata/client1.key")
assert.Nil(t, err)
cert2, err := leafCert("testdata/client2.crt", "testdata/client2.key")
assert.Nil(t, err)
assert.True(t, cert1.Equal(cert1))
assert.False(t, cert1.Equal(cert2))
assert.False(t, cert2.Equal(cert1))
assert.True(t, cert2.Equal(cert2))
assert.True(t, tlsauth.RequireSpecificIdentity(cert1)(cert1))
assert.False(t, tlsauth.RequireSpecificIdentity(cert1)(cert2))
assert.False(t, tlsauth.RequireSpecificIdentity(cert2)(cert1))
assert.True(t, tlsauth.RequireSpecificIdentity(cert2)(cert2))
}
func leafCert(certfile, keyfile string) (*x509.Certificate, error) {
cert, err := tls.LoadX509KeyPair(certfile, keyfile)
if err != nil {
return nil, err
}
if cert.Leaf != nil {
return cert.Leaf, nil
}
if len(cert.Certificate) == 0 {
return nil, errors.New("no certificate blocks found")
}
return x509.ParseCertificate(cert.Certificate[0])
}

View File

@ -1,46 +0,0 @@
package tlsauth
import (
"context"
"crypto/x509"
"tildegit.org/tjp/gus"
)
// Identity returns the client certificate for the request or nil if there is none.
func Identity(request *gus.Request) *x509.Certificate {
if request.TLSState == nil || len(request.TLSState.PeerCertificates) == 0 {
return nil
}
return request.TLSState.PeerCertificates[0]
}
// RequiredAuth produces an auth predicate.
//
// The check requires both that there is a client certificate associated with the
// request and that it passes the provided approver.
func RequiredAuth(approve Approver) func(context.Context, *gus.Request) bool {
return func(_ context.Context, request *gus.Request) bool {
identity := Identity(request)
if identity == nil {
return false
}
return approve(identity)
}
}
// OptionalAuth produces an auth predicate.
//
// The check allows through any request with no client certificate, but if
// there is one present then it requires that it pass the provided approver.
func OptionalAuth(approve Approver) func(context.Context, *gus.Request) bool {
return func(_ context.Context, request *gus.Request) bool {
identity := Identity(request)
if identity == nil {
return true
}
return approve(identity)
}
}

View File

@ -1,190 +0,0 @@
package tlsauth_test
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/contrib/tlsauth"
"tildegit.org/tjp/gus/gemini"
)
func TestIdentify(t *testing.T) {
invoked := false
var leafCert *x509.Certificate
server, client, clientCert := setup(t,
"testdata/server.crt", "testdata/server.key",
"testdata/client1.crt", "testdata/client1.key",
gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
invoked = true
ident := tlsauth.Identity(request)
if assert.NotNil(t, ident) {
assert.True(t, ident.Equal(leafCert))
}
return nil
}),
)
leafCert, err := x509.ParseCertificate(clientCert.Certificate[0])
require.Nil(t, err)
go func() {
_ = server.Serve()
}()
defer server.Close()
requestPath(t, client, server, "/")
assert.True(t, invoked)
}
func TestRequiredAuth(t *testing.T) {
invoked1 := false
invoked2 := false
handler1 := gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
invoked1 = true
return gemini.Success("", &bytes.Buffer{})
})
handler2 := gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
invoked2 = true
return gemini.Success("", &bytes.Buffer{})
})
authMiddleware := gus.Filter(tlsauth.RequiredAuth(tlsauth.Allow), nil)
handler1 = gus.Filter(
func(_ context.Context, req *gus.Request) bool {
return strings.HasPrefix(req.Path, "/one")
},
nil,
)(authMiddleware(handler1))
handler2 = authMiddleware(handler2)
server, client, _ := setup(t,
"testdata/server.crt", "testdata/server.key",
"testdata/client1.crt", "testdata/client1.key",
gus.FallthroughHandler(handler1, handler2),
)
go func() {
_ = server.Serve()
}()
defer server.Close()
requestPath(t, client, server, "/one")
assert.True(t, invoked1)
client, _ = clientFor(t, server, "", "") // no client cert this time
requestPath(t, client, server, "/two")
assert.False(t, invoked2)
}
func TestOptionalAuth(t *testing.T) {
invoked1 := false
invoked2 := false
handler1 := gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
if !strings.HasPrefix(request.Path, "/one") {
return nil
}
invoked1 = true
return gemini.Success("", &bytes.Buffer{})
})
handler2 := gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
invoked2 = true
return gemini.Success("", &bytes.Buffer{})
})
mw := gus.Filter(tlsauth.OptionalAuth(tlsauth.Reject), nil)
handler := gus.FallthroughHandler(mw(handler1), mw(handler2))
server, client, _ := setup(t,
"testdata/server.crt", "testdata/server.key",
"testdata/client1.crt", "testdata/client1.key",
handler,
)
go func() {
_ = server.Serve()
}()
defer server.Close()
requestPath(t, client, server, "/one")
assert.False(t, invoked1)
client, _ = clientFor(t, server, "", "")
requestPath(t, client, server, "/two")
assert.True(t, invoked2)
}
func setup(
t *testing.T,
serverCertPath string,
serverKeyPath string,
clientCertPath string,
clientKeyPath string,
handler gus.Handler,
) (gus.Server, gemini.Client, tls.Certificate) {
serverTLS, err := gemini.FileTLS(serverCertPath, serverKeyPath)
require.Nil(t, err)
server, err := gemini.NewServer(
context.Background(),
"localhost",
"tcp",
"127.0.0.1:0",
handler,
nil,
serverTLS,
)
require.Nil(t, err)
client, clientCert := clientFor(t, server, clientCertPath, clientKeyPath)
return server, client, clientCert
}
func clientFor(
t *testing.T,
server gus.Server,
certPath string,
keyPath string,
) (gemini.Client, tls.Certificate) {
var clientCert tls.Certificate
var certs []tls.Certificate
if certPath != "" {
c, err := tls.LoadX509KeyPair(certPath, keyPath)
require.Nil(t, err)
clientCert = c
certs = []tls.Certificate{c}
}
return gemini.NewClient(&tls.Config{
Certificates: certs,
InsecureSkipVerify: true,
}), clientCert
}
func requestPath(t *testing.T, client gemini.Client, server gus.Server, path string) *gus.Response {
u, err := url.Parse("gemini://" + server.Address() + path)
require.Nil(t, err)
response, err := client.RoundTrip(&gus.Request{URL: u})
require.Nil(t, err)
return response
}

View File

@ -1,58 +0,0 @@
package tlsauth
import (
"context"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
)
// GeminiAuth builds an authentication middleware from approval criteria.
//
// If a request does not contain a client certificate it will be rejected
// with a "60 certificate required" response. If the client identity does
// not pass the approver it will be rejected with "62 certificate invalid".
func GeminiAuth(approver Approver) gus.Middleware {
return func(inner gus.Handler) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
identity := Identity(request)
if identity == nil {
return geminiMissingCert(ctx, request)
}
if !approver(identity) {
return geminiCertNotAuthorized(ctx, request)
}
return inner.Handle(ctx, request)
})
}
}
// GeminiOptionalAuth builds auth middleware which doesn't require an identity.
//
// If there is no client certificate the request will pass through the middleware.
// It will only be rejected with "62 certificate invalid" if there *is* a client
// certificate, but it fails the approval.
func GeminiOptionalAuth(approver Approver) gus.Middleware {
return func(inner gus.Handler) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
identity := Identity(request)
if identity != nil && !approver(identity) {
return geminiCertNotAuthorized(ctx, request)
}
return inner.Handle(ctx, request)
})
}
}
// GeminiRequireCertificate is a middleware that only requires a client certificate.
var GeminiRequireCertificate = GeminiAuth(Allow)
func geminiMissingCert(_ context.Context, _ *gus.Request) *gus.Response {
return gemini.RequireCert("A client certificate is required.")
}
func geminiCertNotAuthorized(_ context.Context, _ *gus.Request) *gus.Response {
return gemini.CertAuthFailure("Client certificate not authorized.")
}

View File

@ -1,115 +0,0 @@
package tlsauth_test
import (
"bytes"
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/contrib/tlsauth"
"tildegit.org/tjp/gus/gemini"
)
func TestGeminiAuth(t *testing.T) {
handler1 := gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
if !strings.HasPrefix(request.Path, "/one") {
return nil
}
return gemini.Success("", &bytes.Buffer{})
})
handler2 := gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
if !strings.HasPrefix(request.Path, "/two") {
return nil
}
return gemini.Success("", &bytes.Buffer{})
})
handler3 := gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
if !strings.HasPrefix(request.Path, "/three") {
return nil
}
return gemini.Success("", &bytes.Buffer{})
})
handler4 := gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
return gemini.Success("", &bytes.Buffer{})
})
handler := gus.FallthroughHandler(
tlsauth.GeminiAuth(tlsauth.Allow)(handler1),
tlsauth.GeminiAuth(tlsauth.Allow)(handler2),
tlsauth.GeminiAuth(tlsauth.Reject)(handler3),
tlsauth.GeminiAuth(tlsauth.Reject)(handler4),
)
server, authClient, _ := setup(t,
"testdata/server.crt", "testdata/server.key",
"testdata/client1.crt", "testdata/client1.key",
handler,
)
authlessClient, _ := clientFor(t, server, "", "")
go func() {
_ = server.Serve()
}()
defer server.Close()
resp := requestPath(t, authClient, server, "/one")
assert.Equal(t, gemini.StatusSuccess, resp.Status)
resp = requestPath(t, authlessClient, server, "/two")
assert.Equal(t, gemini.StatusClientCertificateRequired, resp.Status)
resp = requestPath(t, authClient, server, "/three")
assert.Equal(t, gemini.StatusCertificateNotAuthorized, resp.Status)
resp = requestPath(t, authlessClient, server, "/four")
assert.Equal(t, gemini.StatusClientCertificateRequired, resp.Status)
}
func TestGeminiOptionalAuth(t *testing.T) {
pathHandler := func(path string) gus.Handler {
return gus.HandlerFunc(func(_ context.Context, request *gus.Request) *gus.Response {
if !strings.HasPrefix(request.Path, path) {
return nil
}
return gemini.Success("", &bytes.Buffer{})
})
}
handler := gus.FallthroughHandler(
tlsauth.GeminiOptionalAuth(tlsauth.Allow)(pathHandler("/one")),
tlsauth.GeminiOptionalAuth(tlsauth.Allow)(pathHandler("/two")),
tlsauth.GeminiOptionalAuth(tlsauth.Reject)(pathHandler("/three")),
tlsauth.GeminiOptionalAuth(tlsauth.Reject)(pathHandler("/four")),
)
server, authClient, _ := setup(t,
"testdata/server.crt", "testdata/server.key",
"testdata/client1.crt", "testdata/client1.key",
handler,
)
authlessClient, _ := clientFor(t, server, "", "")
go func() {
_ = server.Serve()
}()
defer server.Close()
resp := requestPath(t, authClient, server, "/one")
assert.Equal(t, gemini.StatusSuccess, resp.Status)
resp = requestPath(t, authlessClient, server, "/two")
assert.Equal(t, gemini.StatusSuccess, resp.Status)
resp = requestPath(t, authClient, server, "/three")
assert.Equal(t, gemini.StatusCertificateNotAuthorized, resp.Status)
resp = requestPath(t, authlessClient, server, "/four")
assert.Equal(t, gemini.StatusSuccess, resp.Status)
}

View File

@ -1,16 +0,0 @@
-----BEGIN CERTIFICATE-----
MIICnDCCAYQCAQAwDQYJKoZIhvcNAQELBQAwFDESMBAGA1UEAwwJdGVzdCB1c2Vy
MB4XDTIzMDEyMDAyMDkwNloXDTMzMDExOTA3MDAwMFowFDESMBAGA1UEAwwJdGVz
dCB1c2VyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3Ar3h421bfzG
DNvnZBWx63frF1ym93FqWaXA0qVTJYjnLcraTvNCzVt3zi8A9xgIQMy57Yy/QkQf
04TNj6oYPD7HumH1oblLsFIHk6ODtM5hv5RrGrWpKKS/SSb2HaKI+bjXaZCfYHNc
5ZKtH91c0wlRUYW8A4Cov6qydqv8jzWPYb3gIFsbkOLfdiZUdTFNg3hPF9mRNWv2
OlAR7bkyrKCIcsYdOxDxmnZOZqJD9m92n/k/teb4yQnK1BY08HLzIUyb1jWw71ak
5keHvmGmxqvu8W6B5PJBrQwnfAqFnxGLbNZMr6lGHCJvjQiI2P6T39jb2LqOmOND
0zBFGJsLAwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAvyDck+fSTvALbf9AGpn9n
dIaAcKB/o+a9IHAex7ZNywoh1i41gnahuFSnjFbkruchtzG0blpt5/jcIU0Eqg2u
hR8ILLavVxdYSitpE/FTpmUQ70rieOy6kSp60P6fi7pRC47pnlPb9WOBtMvjCR5M
ATMClARo9m2tQVEh1k6ZFkgsPfoYCWxfPgBpCGMzFnXQNWiWN6DuB0xUVOugNncH
OiIDDOpFQuq9ZP/w9TVjgVHKJdet50F4moXdqXfeAitkKOIAJozCNwq0ayMfQDS5
rRMUmlNUJA3qFZdmuyXSC/N+Rj/aQVcVFY1Y2KNlKfcArFgpDwUTPDfsoC9cBZBA
-----END CERTIFICATE-----

View File

@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDcCveHjbVt/MYM
2+dkFbHrd+sXXKb3cWpZpcDSpVMliOctytpO80LNW3fOLwD3GAhAzLntjL9CRB/T
hM2Pqhg8Pse6YfWhuUuwUgeTo4O0zmG/lGsatakopL9JJvYdooj5uNdpkJ9gc1zl
kq0f3VzTCVFRhbwDgKi/qrJ2q/yPNY9hveAgWxuQ4t92JlR1MU2DeE8X2ZE1a/Y6
UBHtuTKsoIhyxh07EPGadk5mokP2b3af+T+15vjJCcrUFjTwcvMhTJvWNbDvVqTm
R4e+YabGq+7xboHk8kGtDCd8CoWfEYts1kyvqUYcIm+NCIjY/pPf2NvYuo6Y40PT
MEUYmwsDAgMBAAECggEABUqeOTxHKKXzfUusfNOou6jelmk7+qdXj2BVCru/DCAG
rys5pLxk1ttkPikTNN33FNfXgMbpsoZA3a1L5DCK9Kft1aWVapYyI8NVO0+rUyXD
ZAAFs1a7AqczkmbFdGD8OkUfqQI5UvBzQ3ILh8CjAtAujG9S3iKx7CoGsKPiJu9v
XHXTa0ccKiJpqKvLDaUYUcfz+5DLVaFxH+AsDb8ew8Ec8HMOmpyAEHfYyRAdcbeu
bh4XtG4ccTpqFtZS+1YEqUowq8lwIYM6gC9m4Mqj+/ZfRl9Ul5CSLpk9GpaWqyAt
HI5JHoCDWI9RPuQ55yajpzPvcbCidV/nQxiocs60gQKBgQD67u9cMau9TZzu/aML
A2nk3DoYzPIoZ7cD5LdxV6EWZJ507+MIcbqdBAuarrkcfrToiN4sq/502ExHCclO
lorMj8cO+AetLMzRfIVTsVrBHjvoVTNOYffF+d8qZ7sYIR2MsuT4XZbJbVb3fD+5
52PTNOJF07lup7rr7dudVZXJqwKBgQDgfFwt/JGSClOeGhBTm1avAinIkEzyzjr/
HYHlzChFZeqSm5P3RuI9sGvMQs9oJQcze7tEAndRXhVXZ3jJfiFUKb+1exNjcWyV
yFAACi5n1Yl9ZJRieQsXxhzLE7XiKvknipvwyA+Wf5SY2AR0uvAQSZFyBP3aF8I6
0dntXxjcCQKBgBQmwgQmXQNby0GKDuDgik19vhClzMCf65udb3njrqUMuYjshs/z
yie33nKym10Fc+PEsgrmWQ3rHN0LlSYBgu4AkdzK4Frw4RXlqRNah85AblEvHmqq
BWrZsSlHoUfDyQq2hxrG8UgFxtkjGj8ErQiWE6HF6ftP7vvpYxyUde33AoGBAKCW
9k6DhxPFRWot1Q4qawmJb6Cl8hYkiDnmvv1IXmq+7N7yYxibYc+lvIgEJ2GmU491
7VJKvVa9CmFbiSIDlA4kS5ulLwqNopNIGEre/bGUJeZJJImJc/EO7ZwtPolMGq85
zjKiu9v66q+ooQ9sh73vExhRD9SL+Iuhgdf4ls0ZAoGBAMwcYLCPVI01EDpKR6RA
tVcHQ2SDeG0i5C93DtNLFoe/pWLtB9VKqHpNVduP4u9m6g5tGIQUd5tb167Fv37c
JMXg5tH3fzPSNyrwxVYgPghByCnU+2EuSoW8Le907LqEzSK8bGWWAIhEEdGPpCXd
9U3aIgpCmgCxfMwT/pGQNcIC
-----END PRIVATE KEY-----

View File

@ -1,17 +0,0 @@
-----BEGIN CERTIFICATE-----
MIICrDCCAZQCAQAwDQYJKoZIhvcNAQELBQAwHDEaMBgGA1UEAwwRYW5vdGhlciB0
ZXN0IHVzZXIwHhcNMjMwMTIwMDIwOTE3WhcNMzMwMTE5MDcwMDAwWjAcMRowGAYD
VQQDDBFhbm90aGVyIHRlc3QgdXNlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
AQoCggEBALoYWzKowSNs4sI94rbqHQQB03vU6i0Yv6A4FMS8qtiOKIUjoEvPKD3V
QyTWv8caYJlQX8C2BcOpCQ6ZXdycTGAA2Mo0ssRnGCmhIHmzKOvpFYAcLgTTiTK/
nuOU+pOR29V2gwQtcTU0zxs1C9YOn946TErwkYIUf7fIDUWjSCuLgFa4H2qtJ5D6
dklEWe73Fdd1BSywrcI7Y5jCL+quPLpVxYQv3OS6/yk3XV91O+CXCeTskE8pFR+d
m8Qhf2ZVov/cI1CZ9+DerYlX/9vlCGyb6EdvV+2DjPId+INh/ac8RSOK8RwL3U8j
qPVcsK/sKRapBECT++C5KJYJZ/rqkMkCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEA
DyUuK7A+jxgHHezZ+7RzYwWkBUL/tRtXfwax114MYVJPN25rJ0T0lDncgXjE6qg1
cSK4ERJ+qi6Bj+ACXh/iaLNGnidWcGg6HHCCJCiTjco2SIxq0ytEe8znN6uhVj+d
pSdt8ICDhimr+yMZUYs0q7rMzPFUJSUPGi50mo/BubSuPzImaBLbS5TJ/sF/EAbC
Q+zRo5qjlehZatlXUYp+Zh5pHTvLDtmkHU5dQibbf8cYncuCCtjpbsjLXGNsi29c
OWqk+BKOEjGfCgUDjxzt5TXREA5MsCIVnrnU2lkhUUgfDVIoLK32p21NMKlXiIH0
yGVD41bZ6d+ZDO5LU0LjzA==
-----END CERTIFICATE-----

View File

@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC6GFsyqMEjbOLC
PeK26h0EAdN71OotGL+gOBTEvKrYjiiFI6BLzyg91UMk1r/HGmCZUF/AtgXDqQkO
mV3cnExgANjKNLLEZxgpoSB5syjr6RWAHC4E04kyv57jlPqTkdvVdoMELXE1NM8b
NQvWDp/eOkxK8JGCFH+3yA1Fo0gri4BWuB9qrSeQ+nZJRFnu9xXXdQUssK3CO2OY
wi/qrjy6VcWEL9zkuv8pN11fdTvglwnk7JBPKRUfnZvEIX9mVaL/3CNQmffg3q2J
V//b5Qhsm+hHb1ftg4zyHfiDYf2nPEUjivEcC91PI6j1XLCv7CkWqQRAk/vguSiW
CWf66pDJAgMBAAECggEARzlpNjNmcGONSmCbM/zYjB8SzTNJSWdOeEjekgnPrcCC
+6oOANXRhhDoeOIEVnTfAe7EJyLDhAZfJApI5VWg2aGZV5Lh1M/MbKpxnoKWp+v2
waiHaGt5+EVkz5/GY9KQe9u2+1NVH9MNbVFZLV09jLVtW8VFO68SzskvYzbCOX+s
0xzaKIvgZGUxgYIR8+W422hugKxSi31RTG/Os7MViKH0eEavFZDzc4qbesOxFWKA
ayfSIfeJVPZnDG5SQTT63QxNLHz9b8oZlmXagfxgxvHVCJc9wgOayQ3fM01a/QLP
XMLmDOhUn+9SB2c09Ky6tGasQpCLdzXjlmqWcemobQKBgQDb4DjBqT2KhhFkuX7G
3c+9ak4XhGkvDXZ90McUrZ5gH8T6t49tXokrzvWZhf36MsLhbCK+x4IEkPgGhTOD
ZkdIDRU/Fn/mnRaLZuoO4Y39lXm/x1evHI8mP5ker9pJwlKeUNp8qH+UVeG23UCO
j0fMaVi+3R1ILZugOXOyL9xBUwKBgQDYq1kcZts6NC2QFwHToZgN1rifHsYpK6v5
9g93V/JyAPgBbihGfjkp4pwpD0qI7/VZsw14zNKkSSAeFLaekWtc5b31ejmlW5ZQ
PZqVdhcftWVgO0zmtBkqZzDsY7Itdzgsd1y/CiVCU6daoMcPyc/GY//KAR6aOkqv
w6zMOzhV8wKBgBmm+Tgu5I0qwxC7S30sF7aDloTL3/GrYm2fU/qnnticHEEb9VHP
O7WuuZyls1HjZjUihpM3d4XM3AL2u2HTJvHTBO2NVHK1VRICecAutIAnVkL3oNU/
Qbw2o0ifP5pnX3g0+qich/XoZyMMgSGgucGxcLcj2Oy41XVF/qeFSe8dAoGAB9az
I57pAYMPvu3GKCTpfl6tUTxoyOaGk0V+q7+nys4UKuEUXfCFGunS4n1mIewkgTlE
HIG0gTMQEWaIcNYr/zFknPPuD/hvSLnh1NRv47rJTyD2GEadvnX7RCIbOR/eDWXI
GjVAwdSK8nFsojqX5MKLZ4CA3e2L9C3tG3ptAFcCgYEAgcAXUyCy8bs6pqwRsVbk
g/vpHrOpCyWbpKTu9ErXsc6Lf6wGP/yzAHuUSznPSEs7JPCqCk9SGumS20OTKrmp
yvFdaOrdVh51NY+ag8I5FpcWifc6YD14qenxfRapqjxi2r9WnptVfQvTNotsa44S
V7ueLsLSkYsIfAq9bYQsg5U=
-----END PRIVATE KEY-----

View File

@ -1,18 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIC7jCCAdYCAQcwDQYJKoZIhvcNAQELBQAwPTESMBAGA1UEAwwJbG9jYWxob3N0
MQswCQYDVQQGEwJVUzEaMBgGA1UEBwwRU2FuIEZyYW5jaXNjbywgQ0EwHhcNMjMw
MTExMjAwMDU5WhcNMjUwNDE1MjAwMDU5WjA9MRIwEAYDVQQDDAlsb2NhbGhvc3Qx
CzAJBgNVBAYTAlVTMRowGAYDVQQHDBFTYW4gRnJhbmNpc2NvLCBDQTCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBALlaPa1AxDQnMo0qQxY5/Bf7MNf1x6tN
xjkpMnQnPM+cHmmlkEhI1zwLk/LrLxwq7+OOxMTPrJglrAiDAp1uCZHjKcTMFnwO
9M5vf8LjtYBjZd8+OSHyYV37gxw7h9/Wsxl+1Yw40QaJKM9auj2xOyaDj5Ou9+yp
CfbGSpVUTnqReOVFg2QSNwEviOZu1SvAouPyO98WKoXjn7K5mxE545e4mgF1EMht
jB5kH6kXqZSUszlGA1MkX3AlDsYJIcYnDwelNvw6XTPpkT2wNehxPyD0iP4rs+W4
5hgV8wYokpgrM3xxe0c4mop5bzrp2Hyz3WxnF7KwtJgHW/6YxhG73skCAwEAATAN
BgkqhkiG9w0BAQsFAAOCAQEAfI+UE/3d0Fb8BZ2gtv1kUh8yx75LUbpg1aOEsZdP
Rji+GkL5xiFDsm7BwqTKziAjDtjL2qtGcJJ835shsGiUSK6qJuf9C944utUvCoFm
b4aUZ8fTmN7PkwRS61nIcHaS1zkiFzUdvbquV3QWSnl9kC+yDLHT0Z535tcvCMVM
bO7JMj1sxml4Y9B/hfY7zAZJt1giSNH1iDeX2pTpmPPI40UsRn98cC8HZ0d8wFrv
yc3hKkz8E+WTgZUf7jFk/KX/T5uwu+Y85emwfbb82KIR3oqhkJIfOfpqop2duZXB
hMuO1QWEBkZ/hpfrAsN/foz8v46P9qgW8gfOfzhyBcqLvA==
-----END CERTIFICATE-----

View File

@ -1,27 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAuVo9rUDENCcyjSpDFjn8F/sw1/XHq03GOSkydCc8z5weaaWQ
SEjXPAuT8usvHCrv447ExM+smCWsCIMCnW4JkeMpxMwWfA70zm9/wuO1gGNl3z45
IfJhXfuDHDuH39azGX7VjDjRBokoz1q6PbE7JoOPk6737KkJ9sZKlVROepF45UWD
ZBI3AS+I5m7VK8Ci4/I73xYqheOfsrmbETnjl7iaAXUQyG2MHmQfqReplJSzOUYD
UyRfcCUOxgkhxicPB6U2/DpdM+mRPbA16HE/IPSI/iuz5bjmGBXzBiiSmCszfHF7
RziainlvOunYfLPdbGcXsrC0mAdb/pjGEbveyQIDAQABAoIBAQC36ylkLu4Bahup
I5RqC6NwEFpJEKLOAmB8+7oKs5yNzTYIUra2Y0DfXgWyd1fJtXlP7aymNgPm/QqV
b5o6qKNqVWRu2Kw+8YBNDypRMi45dWfyewWp/55J6XYRn6iVna8dz1MKzp3qxFLw
XfCLor802jqvqmBsPteaPOxo/LzatKhXp/mcO/hsxeMr1iSUVHTrQEIU/aIkmAqT
/eXp/zVZk7O9Tx8wwCijB3v7j3zTEkcKSwFlAp0w01XeqllmqA5P9rW3vVGXJVIM
t6t9C8XcJWPIOURz3JWZJpUBSZsyNe2N/wbCgkQV81A0s+4praKzgDbjE+njb0C/
1CClbHV5AoGBAO/mnOzHe7ZJyYfuiu6ZR2REBY61n2J6DkL1stkN5xd+Op25afHT
jLBjU98hM/AMtP1aHWFQpdEe0uyqRjV6PbpNE8j/m9AVfjZxzwR4ITW2xqUhXOSz
89o832RO54TTr19YGnIhdU8dDQmYOcKmCSuw6KwCfHwBzkFuDFZGk/4/AoGBAMXK
gzNyX3tN9Ug5AUo/Az4jQRSoyLjfnce0a0TF4jxEacUBx2COq3zaV/VADEFBla1t
5roOAUyJ3V6fXtZnoqwZPYh6iGP8p7Tj6vyXI4SDktV0uAV57qSdajqxTrA7yoXr
zrbxv3U/3vXr3JTsP42U5zp1m5n1VfVqCXBkynD3AoGBAOvs7JjDWXuctzASPNmH
LjmB18FQBk3vYQUi4l8pmAF3pyejx3gGJw70r+/4lD5YEMozjD8+88Njv+T1U5SW
Agysbm+2SMJr0LK0W/W2Olq7xEFzPQrBmmgeg0b/fhoXoBlw6JkjJF3IYSD1bqBp
bw1jrn4y979weynHkyRpxnM7AoGBALGSzRPlPR/gr7P1qdjUlb61u/omRn7kFC11
J1EJL8HX0fXTUQK5U/C1vn4q0FXN4elgX+LuK/BhXeNTxbtMM9m6l2nuSIEsFgzr
Cs9XicWwsqT9MzGHdN9JjFPBV9oU9BAj0uSgSbmkbDHxXYo+SBh+dNIhQF+KyW+Z
kXvcoXulAoGAA2hnEA17nJ7Vj1DZ4CoRblgjZFAMB64slcSesaorp3WWehvaXO8u
jbvWuvj58DgvTLiv8xPIn4Zsjd0a77ysifvUcmxSRa/k9UIle/lwjmXGjQ1GSMEI
FB5ZTqjLZwS9Y5BDxlPcYF7vqE9fNpcxmcfHGmSF5YAHvFOfGH6B63M=
-----END RSA PRIVATE KEY-----

View File

@ -1,15 +0,0 @@
#!/usr/bin/env sh
set -euo pipefail
if [[ -z "$QUERY_STRING" ]]; then
printf "10 Enter a phrase.\r\n"
exit 0
fi
decodeURL() { printf "%b\n" "$(sed 's/+/ /g; s/%\([0-9a-fA-F][0-9a-fA-F]\)/\\x\1/g;')"; }
printf "20 text/gemini\r\n\`\`\`\n"
echo $QUERY_STRING | decodeURL | cowsay
echo "\`\`\`"
echo "\n=> $SCRIPT_NAME again"

View File

@ -1,8 +0,0 @@
#!/usr/bin/env sh
set -euo pipefail
printf "20 text/gemini\r\n"
echo "\`\`\`env(1) output"
env
echo "\`\`\`"

View File

@ -1,61 +0,0 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"tildegit.org/tjp/gus/contrib/cgi"
"tildegit.org/tjp/gus/gemini"
"tildegit.org/tjp/gus/logging"
)
func main() {
// GET TLS files from the environment
certfile, keyfile := envConfig()
// build a TLS configuration suitable for gemini
tlsconf, err := gemini.FileTLS(certfile, keyfile)
if err != nil {
log.Fatal(err)
}
// make use of a CGI request handler
cgiHandler := cgi.GeminiCGIDirectory("/cgi-bin", "./cgi-bin")
_, infoLog, _, errLog := logging.DefaultLoggers()
// add stdout logging to the request handler
handler := logging.LogRequests(infoLog)(cgiHandler)
// set up signals to trigger graceful shutdown
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGHUP)
defer stop()
// run the server
server, err := gemini.NewServer(ctx, "localhost", "tcp4", ":1965", handler, errLog, tlsconf)
if err != nil {
log.Fatal(err)
}
defer server.Close()
if err := server.Serve(); err != nil {
log.Fatal(err)
}
}
func envConfig() (string, string) {
certfile, ok := os.LookupEnv("SERVER_CERTIFICATE")
if !ok {
log.Fatal("missing SERVER_CERTIFICATE environment variable")
}
keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY")
if !ok {
log.Fatal("missing SERVER_PRIVATEKEY environment variable")
}
return certfile, keyfile
}

View File

@ -1,99 +0,0 @@
package main
import (
"bytes"
"context"
"io"
"log"
"os"
"os/exec"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
"tildegit.org/tjp/gus/logging"
)
func main() {
// Get TLS files from the environment
certfile, keyfile := envConfig()
// build a TLS configuration suitable for gemini
tlsconf, err := gemini.FileTLS(certfile, keyfile)
if err != nil {
log.Fatal(err)
}
_, infoLog, _, errLog := logging.DefaultLoggers()
// add request logging to the request handler
handler := logging.LogRequests(infoLog)(cowsayHandler)
// run the server
server, err := gemini.NewServer(context.Background(), "localhost", "tcp4", ":1965", handler, errLog, tlsconf)
if err != nil {
log.Fatal(err)
}
server.Serve()
}
var cowsayHandler = gus.HandlerFunc(func(ctx context.Context, req *gus.Request) *gus.Response {
// prompt for a query if there is none already
if req.RawQuery == "" {
return gemini.Input("enter a phrase")
}
// find the "cowsay" executable
binpath, err := exec.LookPath("cowsay")
if err != nil {
return gemini.Failure(err)
}
// build the command and set the query to be passed to its stdin
cmd := exec.CommandContext(ctx, binpath)
cmd.Stdin = bytes.NewBufferString(req.UnescapedQuery())
// set up a pipe so we can read the command's stdout
rd, err := cmd.StdoutPipe()
if err != nil {
return gemini.Failure(err)
}
// start the command
if err := cmd.Start(); err != nil {
return gemini.Failure(err)
}
// read the complete stdout contents, clean up the process on error
buf, err := io.ReadAll(rd)
if err != nil {
cmd.Process.Kill()
cmd.Wait()
return gemini.Failure(err)
}
// wait for the process to close
cmd.Wait()
// pass the buffer to the response wrapped in ``` toggles,
// and include a link to start over
out := io.MultiReader(
bytes.NewBufferString("```\n"),
bytes.NewBuffer(buf),
bytes.NewBufferString("\n```\n=> . again"),
)
return gemini.Success("text/gemini", out)
})
func envConfig() (string, string) {
certfile, ok := os.LookupEnv("SERVER_CERTIFICATE")
if !ok {
log.Fatal("missing SERVER_CERTIFICATE environment variable")
}
keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY")
if !ok {
log.Fatal("missing SERVER_PRIVATEKEY environment variable")
}
return certfile, keyfile
}

View File

@ -1,67 +0,0 @@
package main
import (
"fmt"
"io"
"log"
"net/url"
"os"
"strings"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "usage: %s <gemini url>\n", os.Args[0])
}
certfile, keyfile := envConfig()
// build a client
var client gemini.Client
if certfile != "" && keyfile != "" {
tlsConf, err := gemini.FileTLS(certfile, keyfile)
if err != nil {
log.Fatal(err)
}
client = gemini.NewClient(tlsConf)
}
// parse the URL and build the request
request := &gus.Request{URL: buildURL()}
// fetch the response
response, err := client.RoundTrip(request)
if err != nil {
log.Fatal(err)
}
defer response.Close()
if response.Status != gemini.StatusSuccess {
log.Fatalf("%d %s\n", response.Status, response.Meta)
}
//io.Copy(os.Stdout, response)
buf, err := io.ReadAll(gemini.NewResponseReader(response))
fmt.Printf("response: %s\n", buf)
}
func envConfig() (string, string) {
return os.Getenv("SERVER_CERTIFICATE"), os.Getenv("SERVER_PRIVATEKEY")
}
func buildURL() *url.URL {
raw := os.Args[1]
if strings.HasPrefix(raw, "//") {
raw = "gemini:" + raw
}
u, err := url.Parse(raw)
if err != nil {
log.Fatal(err)
}
return u
}

View File

@ -1,61 +0,0 @@
package main
import (
"context"
"log"
"os"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/contrib/fs"
"tildegit.org/tjp/gus/gemini"
"tildegit.org/tjp/gus/logging"
)
func main() {
// Get TLS files from the environment
certfile, keyfile := envConfig()
// build a TLS configuration suitable for gemini
tlsconf, err := gemini.FileTLS(certfile, keyfile)
if err != nil {
log.Fatal(err)
}
// build the request handler
fileSystem := os.DirFS(".")
// Fallthrough tries each handler in succession until it gets something other than "51 Not Found"
handler := gus.FallthroughHandler(
// first see if they're fetching a directory and we have <dir>/index.gmi
fs.GeminiDirectoryDefault(fileSystem, "index.gmi"),
// next (still if they requested a directory) build a directory listing response
fs.GeminiDirectoryListing(fileSystem, nil),
// finally, try to find a file at the request path and respond with that
fs.GeminiFileHandler(fileSystem),
)
_, infoLog, _, errLog := logging.DefaultLoggers()
// add request logging to stdout
handler = logging.LogRequests(infoLog)(handler)
// run the server
server, err := gemini.NewServer(context.Background(), "localhost", "tcp4", ":1965", handler, errLog, tlsconf)
if err != nil {
log.Fatal(err)
}
server.Serve()
}
func envConfig() (string, string) {
certfile, ok := os.LookupEnv("SERVER_CERTIFICATE")
if !ok {
log.Fatal("missing SERVER_CERTIFICATE environment variable")
}
keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY")
if !ok {
log.Fatal("missing SERVER_PRIVATEKEY environment variable")
}
return certfile, keyfile
}

View File

@ -1,27 +0,0 @@
package main
import (
"context"
"log"
"tildegit.org/tjp/gus/finger"
"tildegit.org/tjp/gus/logging"
)
func main() {
_, infoLog, _, errLog := logging.DefaultLoggers()
fs, err := finger.NewServer(
context.Background(),
"localhost",
"tcp",
":79",
logging.LogRequests(infoLog)(finger.SystemFinger(false)),
errLog,
)
if err != nil {
log.Fatal(err)
}
fs.Serve()
}

View File

@ -1,20 +0,0 @@
package main
import (
"log"
"os"
"tildegit.org/tjp/gus/gemini/gemtext"
"tildegit.org/tjp/gus/gemini/gemtext/htmlconv"
)
func main() {
gmiDoc, err := gemtext.Parse(os.Stdin)
if err != nil {
log.Fatal(err)
}
if err := htmlconv.Convert(os.Stdout, gmiDoc, nil); err != nil {
log.Fatal(err)
}
}

View File

@ -1,20 +0,0 @@
package main
import (
"log"
"os"
"tildegit.org/tjp/gus/gemini/gemtext"
"tildegit.org/tjp/gus/gemini/gemtext/mdconv"
)
func main() {
gmiDoc, err := gemtext.Parse(os.Stdin)
if err != nil {
log.Fatal(err)
}
if err := mdconv.Convert(os.Stdout, gmiDoc, nil); err != nil {
log.Fatal(err)
}
}

View File

@ -1,33 +0,0 @@
package main
import (
"context"
"log"
"os"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/contrib/cgi"
"tildegit.org/tjp/gus/contrib/fs"
"tildegit.org/tjp/gus/gopher"
"tildegit.org/tjp/gus/logging"
)
func main() {
fileSystem := os.DirFS(".")
handler := gus.FallthroughHandler(
fs.GopherDirectoryDefault(fileSystem, "index.gophermap"),
fs.GopherDirectoryListing(fileSystem, nil),
cgi.GopherCGIDirectory("/cgi-bin", "./cgi-bin"),
fs.GopherFileHandler(fileSystem),
)
_, infoLog, _, errLog := logging.DefaultLoggers()
handler = logging.LogRequests(infoLog)(handler)
server, err := gopher.NewServer(context.Background(), "localhost", "tcp4", ":70", handler, errLog)
if err != nil {
log.Fatal(err)
}
server.Serve()
}

View File

@ -1,95 +0,0 @@
package main
import (
"bytes"
"context"
"crypto/md5"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"log"
"os"
"strings"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
"tildegit.org/tjp/gus/logging"
)
func main() {
// Get TLS files from the environment
certfile, keyfile := envConfig()
// build a TLS configuration suitable for gemini
tlsconf, err := gemini.FileTLS(certfile, keyfile)
if err != nil {
log.Fatal(err)
}
_, infoLog, _, errLog := logging.DefaultLoggers()
// add stdout logging to the request handler
handler := logging.LogRequests(infoLog)(inspectHandler)
// run the server
server, err := gemini.NewServer(context.Background(), "localhost", "tcp4", ":1965", handler, errLog, tlsconf)
if err != nil {
log.Fatal(err)
}
server.Serve()
}
func envConfig() (string, string) {
certfile, ok := os.LookupEnv("SERVER_CERTIFICATE")
if !ok {
log.Fatal("missing SERVER_CERTIFICATE environment variable")
}
keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY")
if !ok {
log.Fatal("missing SERVER_PRIVATEKEY environment variable")
}
return certfile, keyfile
}
var inspectHandler = gus.HandlerFunc(func(ctx context.Context, req *gus.Request) *gus.Response {
// build and return a ```-wrapped description of the connection TLS state
body := "```\n" + displayTLSState(req.TLSState) + "\n```"
return gemini.Success("text/gemini", bytes.NewBufferString(body))
})
func displayTLSState(state *tls.ConnectionState) string {
builder := &strings.Builder{}
builder.WriteString("Version: ")
builder.WriteString(map[uint16]string{
tls.VersionTLS10: "TLSv1.0",
tls.VersionTLS11: "TLSv1.1",
tls.VersionTLS12: "TLSv1.2",
tls.VersionTLS13: "TLSv1.3",
tls.VersionSSL30: "SSLv3",
}[state.Version])
builder.WriteString("\n")
builder.WriteString(fmt.Sprintf("Handshake complete: %t\n", state.HandshakeComplete))
builder.WriteString(fmt.Sprintf("Did resume: %t\n", state.DidResume))
builder.WriteString(fmt.Sprintf("Cipher suite: %x\n", state.CipherSuite))
builder.WriteString(fmt.Sprintf("Negotiated protocol: %q\n", state.NegotiatedProtocol))
builder.WriteString(fmt.Sprintf("Server name: %s\n", state.ServerName))
builder.WriteString(fmt.Sprintf("Certificates (%d)\n", len(state.PeerCertificates)))
for i, cert := range state.PeerCertificates {
builder.WriteString(fmt.Sprintf(" #%d: %s\n", i+1, fingerprint(cert)))
}
return builder.String()
}
func fingerprint(cert *x509.Certificate) []byte {
raw := md5.Sum(cert.Raw)
dst := make([]byte, hex.EncodedLen(len(raw)))
hex.Encode(dst, raw[:])
return dst
}

View File

@ -1,76 +0,0 @@
package finger
import (
"bufio"
"errors"
"io"
"net/url"
"strings"
"tildegit.org/tjp/gus"
)
// ForwardingDenied is returned in response to requests for forwarding service.
var ForwardingDenied = errors.New("Finger forwarding service denied.")
// InvalidFingerQuery is sent when a client doesn't properly format the query.
var InvalidFingerQuery = errors.New("Invalid finger query .")
// ParseRequest builds a gus.Request by reading a finger protocol request.
//
// At the time of writing, there is no firm standard on how to represent finger
// queries as URLs (the finger protocol itself predates URLs entirely), but there
// are a few helpful resources to go from.
// - The lynx browser supports finger URLs and documents the forms they may take:
// https://lynx.invisible-island.net/lynx_help/lynx_url_support.html#finger_url
// - There is an IETF draft:
// https://datatracker.ietf.org/doc/html/draft-ietf-uri-url-finger
//
// As this function builds a *gus.Request (which is mostly a wrapper around a URL)
// from nothing but an io.Reader, it doesn't have the context of the hostname which
// the receiving server was hosting. So it only has the host component if it
// arrived in the body of the query in the form username@hostname. Bear in mind that
// in gus handlers, request objects will also carry a reference to the server so
// that hostname is always available as request.Server.Hostname().
//
// The primary deviation from the IETF draft is that a query-specified host becomes
// the Host section of the URL, rather than remaining in the Path. Where the IETF draft
// would consider a query of "tjp@ctrl-c.club\r\n" to be "finger:/tjp@ctrl-c.club", this
// function will parse it into "finger://ctrl-c.club/tjp". This decision to separate the
// query-specified host from the username is intended to make it easier to avoid
// inadvertently acting as a jump host for example with:
// `exec.Command("/usr/bin/finger", request.Path[1:])`.
//
// Consistent with the IETF draft, the /W whois switch is dropped and not represented
// in the URL at all.
//
// In accordance with the recommendation of RFC 1288 section 3.2.1
// (https://datatracker.ietf.org/doc/html/rfc1288#section-3.2.1), any queries which
// include a jump-host (user@host1@host2) are rejected with the ForwardingDenied error.
func ParseRequest(rdr io.Reader) (*gus.Request, error) {
line, err := bufio.NewReader(rdr).ReadString('\n')
if err != nil {
return nil, err
}
if line[len(line)-2] != '\r' {
return nil, InvalidFingerQuery
}
line = strings.TrimSuffix(line, "\r\n")
line = strings.TrimPrefix(line, "/W")
line = strings.TrimLeft(line, " ")
username, hostname, _ := strings.Cut(line, "@")
if strings.Contains(hostname, "@") {
return nil, ForwardingDenied
}
return &gus.Request{URL: &url.URL{
Scheme: "finger",
Host: hostname,
Path: "/" + username,
OmitHost: true, //nolint:typecheck
// (for some reason typecheck on drone-ci doesn't realize OmitHost is a field in url.URL)
}}, nil
}

View File

@ -1,68 +0,0 @@
package finger_test
import (
"bytes"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus/finger"
)
func TestParseRequest(t *testing.T) {
tests := []struct {
source string
host string
path string
err error
}{
{
source: "/W tjp\r\n",
host: "",
path: "/tjp",
},
{
source: "tjp@host.com\r\n",
host: "host.com",
path: "/tjp",
},
{
source: "tjp@forwarder.com@host.com\r\n",
err: finger.ForwardingDenied,
},
{
source: "tjp\r\n",
host: "",
path: "/tjp",
},
{
source: "\r\n",
host: "",
path: "/",
},
{
source: "/W\r\n",
host: "",
path: "/",
},
{
source: "tjp",
err: io.EOF,
},
}
for _, test := range tests {
t.Run(test.source, func(t *testing.T) {
request, err := finger.ParseRequest(bytes.NewBufferString(test.source))
require.Equal(t, test.err, err)
if err == nil {
assert.Equal(t, "finger", request.Scheme)
assert.Equal(t, test.host, request.Host)
assert.Equal(t, test.path, request.Path)
}
})
}
}

View File

@ -1,22 +0,0 @@
package finger
import (
"bytes"
"io"
"strings"
"tildegit.org/tjp/gus"
)
// Error produces a finger Response containing the error message and Status 1.
func Error(msg string) *gus.Response {
if !strings.HasSuffix(msg, "\r\n") {
msg += "\r\n"
}
return &gus.Response{Body: bytes.NewBufferString(msg), Status: 1}
}
// Success produces a finger response with a Status of 0.
func Success(body io.Reader) *gus.Response {
return &gus.Response{Body: body}
}

View File

@ -1,68 +0,0 @@
package finger
import (
"context"
"fmt"
"io"
"net"
"strings"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/internal"
"tildegit.org/tjp/gus/logging"
)
type fingerServer struct {
internal.Server
handler gus.Handler
}
func (fs fingerServer) Protocol() string { return "FINGER" }
// NewServer builds a finger server.
func NewServer(
ctx context.Context,
hostname string,
network string,
address string,
handler gus.Handler,
errLog logging.Logger,
) (gus.Server, error) {
fs := &fingerServer{handler: handler}
if strings.IndexByte(hostname, ':') < 0 {
hostname = net.JoinHostPort(hostname, "79")
}
var err error
fs.Server, err = internal.NewServer(ctx, hostname, network, address, errLog, fs.handleConn)
if err != nil {
return nil, err
}
return fs, nil
}
func (fs *fingerServer) handleConn(conn net.Conn) {
request, err := ParseRequest(conn)
if err != nil {
_, _ = fmt.Fprint(conn, err.Error()+"\r\n")
}
request.Server = fs
request.RemoteAddr = conn.RemoteAddr()
defer func() {
if r := recover(); r != nil {
_ = fs.LogError("msg", "panic in handler", "err", r)
_, _ = fmt.Fprint(conn, "Error handling request.\r\n")
}
}()
response := fs.handler.Handle(fs.Ctx, request)
if response == nil {
response = Error("No result found.")
}
defer response.Close()
_, _ = io.Copy(conn, response.Body)
}

View File

@ -1,48 +0,0 @@
package finger
import (
"bytes"
"context"
"errors"
"os/exec"
"tildegit.org/tjp/gus"
)
// ListingDenied is returned to reject online user listing requests.
var ListingDenied = errors.New("Finger online user list denied.")
// SystemFinger handles finger requests by invoking the finger(1) command-line utility.
func SystemFinger(allowListings bool) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
fingerPath, err := exec.LookPath("finger")
if err != nil {
_ = request.Server.LogError(
"msg", "handler failure",
"ctx", "exec.LookPath(\"finger\")",
"err", err,
)
return Error("Could not resolve request.")
}
path := request.Path[1:]
if len(path) == 0 && !allowListings {
return Error(ListingDenied.Error())
}
args := make([]string, 0, 1)
if len(path) > 0 {
args = append(args, path)
}
cmd := exec.CommandContext(ctx, fingerPath, args...)
outbuf := &bytes.Buffer{}
cmd.Stdout = outbuf
if err := cmd.Run(); err != nil {
return Error(err.Error())
}
return Success(outbuf)
})
}

View File

@ -1,74 +0,0 @@
package gemini
import (
"bytes"
"crypto/tls"
"errors"
"io"
"net"
"tildegit.org/tjp/gus"
)
// Client is used for sending gemini requests and parsing gemini responses.
//
// It carries no state and is usable and reusable simultaneously by multiple goroutines.
// The only reason you might create more than one Client is to support separate TLS-cert
// driven identities.
//
// The zero value is a usable Client with no client TLS certificate.
type Client struct {
tlsConf *tls.Config
}
// Create a gemini Client with the given TLS configuration.
func NewClient(tlsConf *tls.Config) Client {
return Client{tlsConf: tlsConf}
}
// RoundTrip sends a single gemini request to the correct server and returns its response.
//
// It also populates the TLSState and RemoteAddr fields on the request - the only field
// it needs populated beforehand is the URL.
//
// This method will not automatically follow redirects or cache permanent failures or
// redirects.
func (client Client) RoundTrip(request *gus.Request) (*gus.Response, error) {
if request.Scheme != "gemini" && request.Scheme != "" {
return nil, errors.New("non-gemini protocols not supported")
}
host := request.Host
if _, port, _ := net.SplitHostPort(host); port == "" {
host = net.JoinHostPort(host, "1965")
}
conn, err := tls.Dial("tcp", host, client.tlsConf)
if err != nil {
return nil, err
}
defer conn.Close()
request.RemoteAddr = conn.RemoteAddr()
st := conn.ConnectionState()
request.TLSState = &st
if _, err := conn.Write([]byte(request.URL.String() + "\r\n")); err != nil {
return nil, err
}
response, err := ParseResponse(conn)
if err != nil {
return nil, err
}
// read and store the request body in full or we may miss doing so before
// closing the connection
bodybuf, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
response.Body = bytes.NewBuffer(bodybuf)
return response, nil
}

View File

@ -1,10 +0,0 @@
/*
The gemini package contains everything needed for building clients and servers on the gemini protocol.
There are server and client implementations, parsers, formatters, and constructors for gemini requests
and responses, and a utility for building a gemini-ready TLS configuration.
The gemtext subpackage is a library usefor for parsing and otherwise using gemtext documents, including
transforming them into a few other languages with overridable templates.
*/
package gemini

View File

@ -1,7 +0,0 @@
/*
The gemtext package contains a gemtext AST and parser.
Conversion sub-packages can convert this AST into other document types, and support
overridable templates.
*/
package gemtext

View File

@ -1,16 +0,0 @@
package gemtext_test
import (
"bytes"
"testing"
"tildegit.org/tjp/gus/gemini/gemtext"
)
func FuzzParse(f *testing.F) {
f.Fuzz(func(t *testing.T, input []byte) {
if _, err := gemtext.Parse(bytes.NewBuffer(input)); err != nil {
t.Errorf("Parse error: %s", err.Error())
}
})
}

View File

@ -1,95 +0,0 @@
package htmlconv
import (
"html/template"
"io"
"tildegit.org/tjp/gus/gemini/gemtext"
"tildegit.org/tjp/gus/gemini/gemtext/internal"
)
// Convert writes markdown to a writer from the provided gemtext document.
//
// Templates can be provided to override the output for different line types.
// The templates supported are:
// - "header" is called before any lines and is passed the full Document. It should,
// at a minimum, produce opening <html> and <body> tags.
// - "footer" is called after the lines and is passed the full Document. It should,
// at a minimum, provide closing </body> and </html> tags.
// - "textline" is called once per line of text and is passed a gemtext.TextLine.
// - "linkline" is called once per link line and is passed an object which wraps
// a gemtext.LinkLine but also supports a ValidatedURL() method returning a
// string which html/template will always allow as href attributes.
// - "preformattedtextlines" is called once for a block of preformatted text and is
// passed a slice of gemtext.PreformattedTextLines.
// - "heading1line" is called once per h1 line and is passed a gemtext.Heading1Line.
// - "heading2line" is called once per h2 line and is passed a gemtext.Heading2Line.
// - "heading3line" is called once per h3 line and is passed a gemtext.Heading3Line.
// - "listitemlines" is called once for a block of contiguous list item lines and
// is passed a slice of gemtext.ListItemLines.
// - "quoteline" is passed once per blockquote line and is passed a gemtext.QuoteLine.
//
// There exist default implementations of each of these templates, so the "overrides"
// argument can be nil.
func Convert(wr io.Writer, doc gemtext.Document, overrides *template.Template) error {
if err := internal.ValidateLinks(doc); err != nil {
return err
}
tmpl, err := baseTmpl.Clone()
if err != nil {
return err
}
tmpl, err = internal.AddHTMLTemplates(tmpl, overrides)
if err != nil {
return err
}
for _, item := range internal.RenderItems(doc) {
if err := tmpl.ExecuteTemplate(wr, item.Template, item.Object); err != nil {
return err
}
}
return nil
}
var baseTmpl = template.Must(template.New("htmlconv").Parse(`
{{ define "header" -}}
<!DOCTYPE html>
<html><body class="gemtext">{{ end }}
{{ define "textline" -}}
{{ if ne .String "\n" -}}
<p class="gemtext">{{ . }}</p>
{{- end }}
{{- end }}
{{ define "linkline" -}}
<p class="gemtext">=> <a class="gemtext" href="{{ .ValidatedURL }}">
{{- if eq .Label "" -}}
{{ .URL }}
{{- else -}}
{{ .Label }}
{{- end -}}
</a></p>
{{- end }}
{{ define "preformattedtextlines" -}}
<pre class="gemtext">
{{- range . -}}
{{ . }}
{{- end -}}
</pre>
{{- end }}
{{ define "heading1line" }}<h1 class="gemtext">{{ .Body }}</h1>{{ end }}
{{ define "heading2line" }}<h2 class="gemtext">{{ .Body }}</h2>{{ end }}
{{ define "heading3line" }}<h3 class="gemtext">{{ .Body }}</h3>{{ end }}
{{ define "listitemlines" -}}
<ul class="gemtext">
{{- range . -}}
<li class="gemtext">{{ .Body }}</li>
{{- end -}}
</ul>
{{- end }}
{{ define "quoteline" }}<blockquote class="gemtext">{{ .Body }}</blockquote>{{ end }}
{{ define "footer" }}</body></html>{{ end }}
`))

View File

@ -1,47 +0,0 @@
package htmlconv_test
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus/gemini/gemtext"
"tildegit.org/tjp/gus/gemini/gemtext/htmlconv"
)
var gmiDoc = `
# top-level header line
## subtitle
This is some non-blank regular text.
* an
* unordered
* list
=> gemini://google.com/ as if
=> https://google.com/
> this is a quote
> -tjp
`[1:] + "```pre-formatted code\ndoc := gemtext.Parse(req.Body)\n```ignored closing alt-text\n"
func TestConvert(t *testing.T) {
htmlDoc := `
<!DOCTYPE html>
<html><body class="gemtext"><h1 class="gemtext">top-level header line</h1><h2 class="gemtext">subtitle</h2><p class="gemtext">This is some non-blank regular text.
</p><ul class="gemtext"><li class="gemtext">an</li><li class="gemtext">unordered</li><li class="gemtext">list</li></ul><p class="gemtext">=> <a class="gemtext" href="gemini://google.com/">as if</a></p><p class="gemtext">=> <a class="gemtext" href="https://google.com/">https://google.com/</a></p><blockquote class="gemtext"> this is a quote</blockquote><blockquote class="gemtext"> -tjp</blockquote><pre class="gemtext">doc := gemtext.Parse(req.Body)
</pre></body></html>`[1:]
doc, err := gemtext.Parse(bytes.NewBufferString(gmiDoc))
require.Nil(t, err)
buf := &bytes.Buffer{}
require.Nil(t, htmlconv.Convert(buf, doc, nil))
assert.Equal(t, htmlDoc, buf.String())
}

View File

@ -1,150 +0,0 @@
package internal
import (
htemplate "html/template"
"net/url"
"text/template"
"tildegit.org/tjp/gus/gemini/gemtext"
)
var Renderers = map[gemtext.LineType]string{
gemtext.LineTypeText: "textline",
gemtext.LineTypeLink: "linkline",
gemtext.LineTypeHeading1: "heading1line",
gemtext.LineTypeHeading2: "heading2line",
gemtext.LineTypeHeading3: "heading3line",
gemtext.LineTypeQuote: "quoteline",
}
func AddAllTemplates(base *template.Template, additions *template.Template) (*template.Template, error) {
if additions == nil {
return base, nil
}
tmpl := base
var err error
for _, addition := range additions.Templates() {
tmpl, err = tmpl.AddParseTree(addition.Name(), addition.Tree)
if err != nil {
return nil, err
}
}
return tmpl, nil
}
func AddHTMLTemplates(base *htemplate.Template, additions *htemplate.Template) (*htemplate.Template, error) {
if additions == nil {
return base, nil
}
tmpl := base
var err error
for _, addition := range additions.Templates() {
tmpl, err = tmpl.AddParseTree(addition.Name(), addition.Tree)
if err != nil {
return nil, err
}
}
return tmpl, nil
}
func ValidateLinks(doc gemtext.Document) error {
for _, line := range doc {
if linkLine, ok := line.(gemtext.LinkLine); ok {
_, err := url.Parse(linkLine.URL())
if err != nil {
return err
}
}
}
return nil
}
type RenderItem struct {
Template string
Object any
}
func RenderItems(doc gemtext.Document) []RenderItem {
out := make([]RenderItem, 0, len(doc))
out = append(out, RenderItem{
Template: "header",
Object: doc,
})
inUL := false
ulStart := 0
inPF := false
pfStart := 0
for i, line := range doc {
switch line.Type() {
case gemtext.LineTypeListItem:
if !inUL {
inUL = true
ulStart = i
}
case gemtext.LineTypePreformatToggle:
if inUL {
inUL = false
out = append(out, RenderItem{
Template: "listitemlines",
Object: doc[ulStart:i],
})
}
if !inPF {
inPF = true
pfStart = i
} else {
inPF = false
out = append(out, RenderItem{
Template: "preformattedtextlines",
Object: doc[pfStart+1 : i],
})
}
case gemtext.LineTypePreformattedText:
default:
if inUL {
inUL = false
out = append(out, RenderItem{
Template: "listitemlines",
Object: doc[ulStart:i],
})
}
if linkLine, ok := line.(gemtext.LinkLine); ok {
line = validatedLinkLine{linkLine}
}
out = append(out, RenderItem{
Template: Renderers[line.Type()],
Object: line,
})
}
}
if inUL {
out = append(out, RenderItem{
Template: "listitemlines",
Object: doc[ulStart:],
})
}
out = append(out, RenderItem{
Template: "footer",
Object: doc,
})
return out
}
type validatedLinkLine struct {
gemtext.LinkLine
}
func (vll validatedLinkLine) ValidatedURL() htemplate.URL {
return htemplate.URL(vll.URL())
}

View File

@ -1,15 +0,0 @@
package internal_test
import "testing"
func TestAddAllTemplates(t *testing.T) {
}
func TestAddHTMLTemplates(t *testing.T) {
}
func TestValidateLinks(t *testing.T) {
}
func TestRenderItems(t *testing.T) {
}

View File

@ -1,78 +0,0 @@
package mdconv
import (
"io"
"text/template"
"tildegit.org/tjp/gus/gemini/gemtext"
"tildegit.org/tjp/gus/gemini/gemtext/internal"
)
// Convert writes markdown to a writer from the provided gemtext document.
//
// Templates can be provided to override the output for different line types.
// The templates supported are:
// - "header" is called before any lines and is passed the full Document.
// - "footer" is called after the lines and is passed the full Document.
// - "textline" is called once per line of text and is passed a gemtext.TextLine.
// - "linkline" is called once per link line and is passed a gemtext.LinkLine.
// - "preformattedtextlines" is called once for a block of preformatted text and is
// passed a slice of gemtext.PreformattedTextLines.
// - "heading1line" is called once per h1 line and is passed a gemtext.Heading1Line.
// - "heading2line" is called once per h2 line and is passed a gemtext.Heading2Line.
// - "heading3line" is called once per h3 line and is passed a gemtext.Heading3Line.
// - "listitemlines" is called once for a block of contiguous list item lines and
// is passed a slice of gemtext.ListItemLines.
// - "quoteline" is passed once per blockquote line and is passed a gemtext.QuoteLine.
//
// There exist default implementations of each of these templates, so the "overrides"
// argument can be nil.
func Convert(wr io.Writer, doc gemtext.Document, overrides *template.Template) error {
if err := internal.ValidateLinks(doc); err != nil {
return err
}
tmpl, err := baseTmpl.Clone()
if err != nil {
return err
}
tmpl, err = internal.AddAllTemplates(tmpl, overrides)
if err != nil {
return err
}
for _, item := range internal.RenderItems(doc) {
if err := tmpl.ExecuteTemplate(wr, item.Template, item.Object); err != nil {
return err
}
}
return nil
}
var baseTmpl = template.Must(template.New("mdconv").Parse(`
{{ define "header" }}{{ end }}
{{ define "textline" }}{{ if ne .String "\n" }}
{{ . }}{{ end }}{{ end }}
{{ define "linkline" }}
=> [{{ if eq .Label "" }}{{ .URL }}{{ else }}{{ .Label }}{{ end }}]({{ .URL }})
{{ end }}
{{ define "preformattedtextlines" }}` + "\n```\n" + `{{ range . }}{{ . }}{{ end }}` + "```\n" + `{{ end }}
{{ define "heading1line" }}
# {{ .Body }}
{{ end }}
{{ define "heading2line" }}
## {{ .Body }}
{{ end }}
{{ define "heading3line" }}
### {{ .Body }}
{{ end }}
{{ define "listitemlines" }}
{{ range . }}* {{ .Body }}
{{ end }}{{ end }}
{{ define "quoteline" }}
> {{ .Body }}
{{ end }}
{{ define "footer" }}{{ end }}
`))

View File

@ -1,103 +0,0 @@
package mdconv_test
import (
"bytes"
"testing"
"text/template"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus/gemini/gemtext"
"tildegit.org/tjp/gus/gemini/gemtext/mdconv"
)
var gmiDoc = `
# top-level header line
## subtitle
This is some non-blank regular text.
* an
* unordered
* list
=> gemini://google.com/ as if
=> https://google.com/
> this is a quote
> -tjp
`[1:] + "```pre-formatted code\ndoc := gemtext.Parse(req.Body)\n```ignored closing alt-text\n"
func TestConvert(t *testing.T) {
mdDoc := `
# top-level header line
## subtitle
This is some non-blank regular text.
* an
* unordered
* list
=> [as if](gemini://google.com/)
=> [https://google.com/](https://google.com/)
> this is a quote
> -tjp
` + "```\ndoc := gemtext.Parse(req.Body)\n```\n"
doc, err := gemtext.Parse(bytes.NewBufferString(gmiDoc))
require.Nil(t, err)
buf := &bytes.Buffer{}
require.Nil(t, mdconv.Convert(buf, doc, nil))
assert.Equal(t, mdDoc, buf.String())
}
func TestConvertWithOverrides(t *testing.T) {
mdDoc := `
# h1: top-level header line
text:
## h2: subtitle
text:
text: This is some non-blank regular text.
text:
* li: an
* li: unordered
* li: list
text:
=> link: [as if](gemini://google.com/)
=> link: [https://google.com/](https://google.com/)
text:
> quote: this is a quote
> quote: -tjp
text:
`[1:] + "```\npf: doc := gemtext.Parse(req.Body)\n```\n"
overrides := template.Must(template.New("overrides").Parse((`
{{define "textline"}}text: {{.}}{{end}}
{{define "linkline"}}=> link: [{{if eq .Label ""}}{{.URL}}{{else}}{{.Label}}{{end}}]({{.URL}})` + "\n" + `{{end}}
{{define "preformattedtextlines"}}` + "```\n" + `{{range . }}pf: {{.}}{{end}}` + "```\n" + `{{end}}
{{define "heading1line"}}# h1: {{.Body}}` + "\n" + `{{end}}
{{define "heading2line"}}## h2: {{.Body}}` + "\n" + `{{end}}
{{define "heading3line"}}### h3: {{.Body}}` + "\n" + `{{end}}
{{define "listitemlines"}}{{range .}}* li: {{.Body}}` + "\n" + `{{end}}{{end}}
{{define "quoteline"}}> quote: {{.Body}}` + "\n" + `{{end}}
`)[1:]))
doc, err := gemtext.Parse(bytes.NewBufferString(gmiDoc))
require.Nil(t, err)
buf := &bytes.Buffer{}
require.Nil(t, mdconv.Convert(buf, doc, overrides))
assert.Equal(t, mdDoc, buf.String())
}

View File

@ -1,49 +0,0 @@
package gemtext
import (
"bufio"
"io"
)
// Parse parses the full contents of an io.Reader into a gemtext.Document.
func Parse(input io.Reader) (Document, error) {
rdr := bufio.NewReader(input)
var lines []Line
inPFT := false
for {
raw, err := rdr.ReadBytes('\n')
if err != io.EOF && err != nil {
return nil, err
}
var line Line
if inPFT && (len(raw) < 3 || raw[0] != '`' || raw[1] != '`' || raw[2] != '`') {
line = PreformattedTextLine{raw: raw}
} else {
line = ParseLine(raw)
}
if line != nil && line.Type() == LineTypePreformatToggle {
if inPFT {
toggle := line.(PreformatToggleLine)
(&toggle).clearAlt()
line = toggle
}
inPFT = !inPFT
}
if line != nil {
lines = append(lines, line)
}
if err == io.EOF {
break
}
}
return Document(lines), nil
}

View File

@ -1,107 +0,0 @@
package gemtext
import "bytes"
// ParseLine parses a single line (including the trailing \n) into a gemtext.Line.
func ParseLine(line []byte) Line {
if len(line) == 0 {
return nil
}
switch line[0] {
case '=':
if len(line) == 1 || line[1] != '>' {
break
}
return parseLinkLine(line)
case '`':
if len(line) < 3 || line[1] != '`' || line[2] != '`' {
break
}
return parsePreformatToggleLine(line)
case '#':
level := 1
if len(line) > 1 && line[1] == '#' {
level += 1
if len(line) > 2 && line[2] == '#' {
level += 1
}
}
return parseHeadingLine(level, line)
case '*':
if len(line) == 1 || line[1] != ' ' {
break
}
return parseListItemLine(line)
case '>':
return parseQuoteLine(line)
}
return TextLine{raw: line}
}
func parseLinkLine(raw []byte) LinkLine {
line := LinkLine{raw: raw}
// move past =>[<whitespace>]
raw = bytes.TrimLeft(raw[2:], " \t")
// find the next space or tab
spIdx := bytes.IndexByte(raw, ' ')
tbIdx := bytes.IndexByte(raw, '\t')
idx := spIdx
if idx == -1 {
idx = tbIdx
}
if tbIdx >= 0 && tbIdx < idx {
idx = tbIdx
}
if idx < 0 {
line.url = bytes.TrimRight(raw, "\r\n")
return line
}
line.url = raw[:idx]
raw = raw[idx+1:]
label := bytes.TrimRight(bytes.TrimLeft(raw, " \t"), "\r\n")
if len(label) > 0 {
line.label = label
}
return line
}
func parsePreformatToggleLine(raw []byte) PreformatToggleLine {
line := PreformatToggleLine{raw: raw}
raw = bytes.TrimRight(raw[3:], "\r\n")
if len(raw) > 0 {
line.altText = raw
}
return line
}
func parseHeadingLine(level int, raw []byte) HeadingLine {
return HeadingLine{
raw: raw,
lineType: LineTypeHeading1 - 1 + LineType(level),
body: bytes.TrimRight(bytes.TrimLeft(raw[level:], " \t"), "\r\n"),
}
}
func parseListItemLine(raw []byte) ListItemLine {
return ListItemLine{
raw: raw,
body: bytes.TrimRight(raw[2:], "\r\n"),
}
}
func parseQuoteLine(raw []byte) QuoteLine {
return QuoteLine{
raw: raw,
body: bytes.TrimRight(raw[1:], "\r\n"),
}
}

View File

@ -1,271 +0,0 @@
package gemtext_test
import (
"testing"
"tildegit.org/tjp/gus/gemini/gemtext"
)
func TestParseLinkLine(t *testing.T) {
tests := []struct {
input string
url string
label string
}{
{
input: "=> gemini.ctrl-c.club/~tjp/ home page\r\n",
url: "gemini.ctrl-c.club/~tjp/",
label: "home page",
},
{
input: "=> gemi.dev/\n",
url: "gemi.dev/",
},
{
input: "=> /gemlog/foobar 2023-01-13 - Foo Bar\n",
url: "/gemlog/foobar",
label: "2023-01-13 - Foo Bar",
},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
line := gemtext.ParseLine([]byte(test.input))
if line == nil {
t.Fatal("ParseLine() returned nil line")
}
if string(line.Raw()) != string(test.input) {
t.Error("Raw() does not match input")
}
if line.Type() != gemtext.LineTypeLink {
t.Errorf("expected LineTypeLink, got %d", line.Type())
}
link, ok := line.(gemtext.LinkLine)
if !ok {
t.Fatalf("expected a LinkLine, got %T", line)
}
if link.URL() != test.url {
t.Errorf("expected url %q, got %q", test.url, link.URL())
}
if link.Label() != test.label {
t.Errorf("expected label %q, got %q", test.label, link.Label())
}
})
}
}
func TestParsePreformatToggleLine(t *testing.T) {
tests := []struct {
input string
altText string
}{
{
input: "```\n",
},
{
input: "```some alt-text\r\n",
altText: "some alt-text",
},
{
input: "``` leading space preserved\n",
altText: " leading space preserved",
},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
line := gemtext.ParseLine([]byte(test.input))
if line == nil {
t.Fatal("ParseLine() returned nil line")
}
if string(line.Raw()) != string(test.input) {
t.Error("Raw() does not match input")
}
if line.Type() != gemtext.LineTypePreformatToggle {
t.Errorf("expected LineTypePreformatToggle, got %d", line.Type())
}
toggle, ok := line.(gemtext.PreformatToggleLine)
if !ok {
t.Fatalf("expected a PreformatToggleLine, got %T", line)
}
if toggle.AltText() != test.altText {
t.Errorf("expected alt-text %q, got %q", test.altText, toggle.AltText())
}
})
}
}
func TestParseHeadingLine(t *testing.T) {
tests := []struct {
input string
lineType gemtext.LineType
body string
}{
{
input: "# this is an H1\n",
lineType: gemtext.LineTypeHeading1,
body: "this is an H1",
},
{
input: "## extra leading spaces\r\n",
lineType: gemtext.LineTypeHeading2,
body: "extra leading spaces",
},
{
input: "##no leading space\n",
lineType: gemtext.LineTypeHeading2,
body: "no leading space",
},
{
input: "#### there is no h4\n",
lineType: gemtext.LineTypeHeading3,
body: "# there is no h4",
},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
line := gemtext.ParseLine([]byte(test.input))
if line == nil {
t.Fatal("ParseLine() returned nil")
}
if line.Type() != test.lineType {
t.Errorf("expected line type %d, got %d", test.lineType, line.Type())
}
if string(line.Raw()) != test.input {
t.Error("line.Raw() does not match input")
}
hdg, ok := line.(gemtext.HeadingLine)
if !ok {
t.Fatalf("expected HeadingLine, got a %T", line)
}
if hdg.Body() != test.body {
t.Errorf("expected body %q, got %q", test.body, hdg.Body())
}
})
}
}
func TestParseListItemLine(t *testing.T) {
tests := []struct {
input string
body string
}{
{
input: "* this is a list item\r\n",
body: "this is a list item",
},
{
input: "* more leading spaces\n",
body: " more leading spaces",
},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
line := gemtext.ParseLine([]byte(test.input))
if line == nil {
t.Fatal("ParseLine() returned nil")
}
if line.Type() != gemtext.LineTypeListItem {
t.Errorf("expected LineTypeListItem, got %d", line.Type())
}
if string(line.Raw()) != test.input {
t.Error("line.Raw() does not match input")
}
li, ok := line.(gemtext.ListItemLine)
if !ok {
t.Fatalf("expected ListItemLine, got a %T", line)
}
if li.Body() != test.body {
t.Errorf("expected body %q, got %q", test.body, li.Body())
}
})
}
}
func TestParseQuoteLine(t *testing.T) {
tests := []struct {
input string
body string
}{
{
input: ">a quote line\r\n",
body: "a quote line",
},
{
input: "> with a leading space\n",
body: " with a leading space",
},
{
input: "> more leading spaces\n",
body: " more leading spaces",
},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
line := gemtext.ParseLine([]byte(test.input))
if line == nil {
t.Fatal("ParseLine() returned nil")
}
if line.Type() != gemtext.LineTypeQuote {
t.Errorf("expected LineTypeQuote, got %d", line.Type())
}
if string(line.Raw()) != test.input {
t.Error("line.Raw() does not match input")
}
qu, ok := line.(gemtext.QuoteLine)
if !ok {
t.Fatalf("expected QuoteLine , got a %T", line)
}
if qu.Body() != test.body {
t.Errorf("expected body %q, got %q", test.body, qu.Body())
}
})
}
}
func TestParseTextLine(t *testing.T) {
tests := []string {
"\n",
"simple text line\r\n",
" * an invalid list item\n",
"*another invalid list item\r\n",
}
for _, test := range tests {
t.Run(test, func(t *testing.T) {
line := gemtext.ParseLine([]byte(test))
if line == nil {
t.Fatal("ParseLine() returned nil")
}
if line.Type() != gemtext.LineTypeText {
t.Errorf("expected LineTypeText, got %d", line.Type())
}
if string(line.Raw()) != test {
t.Error("line.Raw() does not match input")
}
_, ok := line.(gemtext.TextLine)
if !ok {
t.Fatalf("expected TextLine , got a %T", line)
}
})
}
}

View File

@ -1,104 +0,0 @@
package gemtext_test
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus/gemini/gemtext"
)
func TestParse(t *testing.T) {
docBytes := []byte(`
# top-level header line
## subtitle
This is some non-blank regular text.
* an
* unordered
* list
=> gemini://google.com/ as if
> this is a quote
> -tjp
`[1:] + "```pre-formatted code\ndoc := gemtext.Parse(req.Body)\n```ignored closing alt-text\n")
assertEmptyLine := func(t *testing.T, line gemtext.Line) {
assert.Equal(t, gemtext.LineTypeText, line.Type())
assert.Equal(t, "\n", string(line.Raw()))
}
doc, err := gemtext.Parse(bytes.NewBuffer(docBytes))
require.Nil(t, err)
require.Equal(t, 18, len(doc))
assert.Equal(t, gemtext.LineTypeHeading1, doc[0].Type())
assert.Equal(t, "# top-level header line\n", string(doc[0].Raw()))
assert.Equal(t, "top-level header line", doc[0].(gemtext.HeadingLine).Body())
assertEmptyLine(t, doc[1])
assert.Equal(t, gemtext.LineTypeHeading2, doc[2].Type())
assert.Equal(t, "## subtitle\n", string(doc[2].Raw()))
assert.Equal(t, "subtitle", doc[2].(gemtext.HeadingLine).Body())
assertEmptyLine(t, doc[3])
assert.Equal(t, gemtext.LineTypeText, doc[4].Type())
assert.Equal(t, "This is some non-blank regular text.\n", string(doc[4].Raw()))
assertEmptyLine(t, doc[5])
assert.Equal(t, gemtext.LineTypeListItem, doc[6].Type())
assert.Equal(t, "an", doc[6].(gemtext.ListItemLine).Body())
assert.Equal(t, gemtext.LineTypeListItem, doc[7].Type())
assert.Equal(t, "unordered", doc[7].(gemtext.ListItemLine).Body())
assert.Equal(t, gemtext.LineTypeListItem, doc[8].Type())
assert.Equal(t, "list", doc[8].(gemtext.ListItemLine).Body())
assertEmptyLine(t, doc[9])
assert.Equal(t, gemtext.LineTypeLink, doc[10].Type())
assert.Equal(t, "=> gemini://google.com/ as if\n", string(doc[10].Raw()))
assert.Equal(t, "gemini://google.com/", doc[10].(gemtext.LinkLine).URL())
assert.Equal(t, "as if", doc[10].(gemtext.LinkLine).Label())
assertEmptyLine(t, doc[11])
assert.Equal(t, gemtext.LineTypeQuote, doc[12].Type())
assert.Equal(t, "> this is a quote\n", string(doc[12].Raw()))
assert.Equal(t, " this is a quote", doc[12].(gemtext.QuoteLine).Body())
assert.Equal(t, gemtext.LineTypeQuote, doc[13].Type())
assert.Equal(t, "> -tjp\n", string(doc[13].Raw()))
assert.Equal(t, " -tjp", doc[13].(gemtext.QuoteLine).Body())
assertEmptyLine(t, doc[14])
assert.Equal(t, gemtext.LineTypePreformatToggle, doc[15].Type())
assert.Equal(t, "```pre-formatted code\n", string(doc[15].Raw()))
assert.Equal(t, "pre-formatted code", doc[15].(gemtext.PreformatToggleLine).AltText())
assert.Equal(t, gemtext.LineTypePreformattedText, doc[16].Type())
assert.Equal(t, "doc := gemtext.Parse(req.Body)\n", string(doc[16].Raw()))
assert.Equal(t, gemtext.LineTypePreformatToggle, doc[17].Type())
assert.Equal(t, "```ignored closing alt-text\n", string(doc[17].Raw()))
assert.Equal(t, "", doc[17].(gemtext.PreformatToggleLine).AltText())
// ensure we can rebuild the original doc from all the line.Raw()s
buf := &bytes.Buffer{}
for _, line := range doc {
_, _ = buf.Write(line.Raw())
}
assert.Equal(t, string(docBytes), buf.String())
}

View File

@ -1,184 +0,0 @@
package gemtext
// LineType represents the different types of lines in a gemtext document.
type LineType int
const (
// LineTypeText is the default case when nothing else matches.
//
// It indicates that the line object is a TextLine.
LineTypeText LineType = iota + 1
// LineTypeLink is a link line.
//
// =>[<ws>]<url>[<ws><label>][\r]\n
//
// The line is a LinkLine.
LineTypeLink
// LineTypePreformatToggle switches the document between pre-formatted text or not.
//
// ```[<alt-text>][\r]\n
//
// The line object is a PreformatToggleLine.
LineTypePreformatToggle
// LineTypePreformattedText is any line between two PreformatToggles.
//
// The line is a PreformattedTextLine.
LineTypePreformattedText
// LineTypeHeading1 is a top-level heading.
//
// #[<ws>]body[\r]\n
//
// The line is a HeadingLine.
LineTypeHeading1
// LineTypeHeading2 is a second-level heading.
//
// ##[<ws>]body[\r]\n
//
// The line is a HeadingLine.
LineTypeHeading2
// LineTypeHeading3 is a third-level heading.
//
// ###[<ws>]<body>[\r]\n
//
// The line is a HeadingLine.
LineTypeHeading3
// LineTypeListItem is an unordered list item.
//
// * <body>[\r]\n
//
// The line object is a ListItemLine.
LineTypeListItem
// LineTypeQuote is a quote line.
//
// ><body>[\r]\n
//
// The line object is a QuoteLine.
LineTypeQuote
)
// Line is the interface implemented by all specific line types.
//
// Many of those concrete implementation types have additional useful fields,
// so it can be a good idea to cast these to their concrete types based on the
// return value of the Type() method.
type Line interface {
// Type returns the specific type of the gemtext line.
Type() LineType
// Raw reproduces the original bytes from the source reader.
Raw() []byte
// String represents the original bytes from the source reader as a string.
String() string
}
// Document is the list of lines that make up a full text/gemini resource.
type Document []Line
// TextLine is a line of LineTypeText.
type TextLine struct {
raw []byte
}
func (tl TextLine) Type() LineType { return LineTypeText }
func (tl TextLine) Raw() []byte { return tl.raw }
func (tl TextLine) String() string { return string(tl.raw) }
// LinkLine is a line of LineTypeLink.
type LinkLine struct {
raw []byte
url []byte
label []byte
}
func (ll LinkLine) Type() LineType { return LineTypeLink }
func (ll LinkLine) Raw() []byte { return ll.raw }
func (ll LinkLine) String() string { return string(ll.raw) }
// URL returns the original url portion of the line.
//
// It is not guaranteed to be a valid URL.
func (ll LinkLine) URL() string { return string(ll.url) }
// Label returns the label portion of the line.
func (ll LinkLine) Label() string { return string(ll.label) }
// PreformatToggleLine is a preformatted text toggle line.
type PreformatToggleLine struct {
raw []byte
altText []byte
}
func (tl PreformatToggleLine) Type() LineType { return LineTypePreformatToggle }
func (tl PreformatToggleLine) Raw() []byte { return tl.raw }
func (tl PreformatToggleLine) String() string { return string(tl.raw) }
// AltText returns the alt-text portion of the line.
//
// If the line was parsed as part of a full document by Parse(),
// and this is a *closing* toggle, any alt-text present will be
// stripped and this will be empty. If the line was parsed by
// ParseLine() no such correction is performed.
func (tl PreformatToggleLine) AltText() string { return string(tl.altText) }
func (tl *PreformatToggleLine) clearAlt() { tl.altText = nil }
// PreformattedTextLine represents a line between two toggles.
//
// It is never returned by ParseLine but can be part of a
// document parsed by Parse().
type PreformattedTextLine struct {
raw []byte
}
func (tl PreformattedTextLine) Type() LineType { return LineTypePreformattedText }
func (tl PreformattedTextLine) Raw() []byte { return tl.raw }
func (tl PreformattedTextLine) String() string { return string(tl.raw) }
// HeadingLine is a line of LineTypeHeading[1,2,3].
type HeadingLine struct {
raw []byte
lineType LineType
body []byte
}
func (hl HeadingLine) Type() LineType { return hl.lineType }
func (hl HeadingLine) Raw() []byte { return hl.raw }
func (hl HeadingLine) String() string { return string(hl.raw) }
// Body returns the portion of the line with the header text.
func (hl HeadingLine) Body() string { return string(hl.body) }
// ListItemLine is a line of LineTypeListItem.
type ListItemLine struct {
raw []byte
body []byte
}
func (li ListItemLine) Type() LineType { return LineTypeListItem }
func (li ListItemLine) Raw() []byte { return li.raw }
func (li ListItemLine) String() string { return string(li.raw) }
// Body returns the text of the list item.
func (li ListItemLine) Body() string { return string(li.body) }
// QuoteLine is a line of LineTypeQuote.
type QuoteLine struct {
raw []byte
body []byte
}
func (ql QuoteLine) Type() LineType { return LineTypeQuote }
func (ql QuoteLine) Raw() []byte { return ql.raw }
func (ql QuoteLine) String() string { return string(ql.raw) }
// Body returns the text of the quote.
func (ql QuoteLine) Body() string { return string(ql.body) }

View File

@ -1,43 +0,0 @@
package gemini
import (
"bufio"
"errors"
"io"
"net/url"
"tildegit.org/tjp/gus"
)
// InvalidRequestLineEnding indicates that a gemini request didn't end with "\r\n".
var InvalidRequestLineEnding = errors.New("invalid request line ending")
// ParseRequest parses a single gemini request from a reader.
//
// If the reader argument is a *bufio.Reader, it will only read a single line from it.
func ParseRequest(rdr io.Reader) (*gus.Request, error) {
bufrdr, ok := rdr.(*bufio.Reader)
if !ok {
bufrdr = bufio.NewReader(rdr)
}
line, err := bufrdr.ReadString('\n')
if err != io.EOF && err != nil {
return nil, err
}
if len(line) < 2 || line[len(line)-2:] != "\r\n" {
return nil, InvalidRequestLineEnding
}
u, err := url.Parse(line[:len(line)-2])
if err != nil {
return nil, err
}
if u.Scheme == "" {
u.Scheme = "gemini"
}
return &gus.Request{URL: u}, nil
}

View File

@ -1,86 +0,0 @@
package gemini_test
import (
"bytes"
"testing"
"tildegit.org/tjp/gus/gemini"
)
func TestParseRequest(t *testing.T) {
table := []struct {
input string
scheme string
host string
path string
query string
fragment string
err error
}{
{
input: "gemini://foo.com/bar?baz#qux\r\n",
scheme: "gemini",
host: "foo.com",
path: "/bar",
query: "baz",
fragment: "qux",
err: nil,
},
{
input: "//foo.com/path\r\n",
scheme: "gemini",
host: "foo.com",
path: "/path",
query: "",
fragment: "",
err: nil,
},
{
input: "/path\r\n",
scheme: "gemini",
host: "",
path: "/path",
query: "",
fragment: "",
err: nil,
},
{
input: "gemini://invalid.com/line/ending",
scheme: "",
host: "",
path: "",
query: "",
fragment: "",
err: gemini.InvalidRequestLineEnding,
},
}
for _, test := range table {
t.Run(test.input, func(t *testing.T) {
req, err := gemini.ParseRequest(bytes.NewBufferString(test.input))
if err != test.err {
t.Fatalf("expected error %q, got %q", test.err, err)
}
if err != nil {
return
}
if req.Scheme != test.scheme {
t.Errorf("expected scheme %q, got %q", test.scheme, req.Scheme)
}
if req.Host != test.host {
t.Errorf("expected host %q, got %q", test.host, req.Host)
}
if req.Path != test.path {
t.Errorf("expected path %q, got %q", test.path, req.Path)
}
if req.RawQuery != test.query {
t.Errorf("expected query %q, got %q", test.query, req.RawQuery)
}
if req.Fragment != test.fragment {
t.Errorf("expected fragment %q, got %q", test.fragment, req.Fragment)
}
})
}
}

View File

@ -1,331 +0,0 @@
package gemini
import (
"bufio"
"bytes"
"errors"
"io"
"strconv"
"sync"
"tildegit.org/tjp/gus"
)
// ResponseCategory represents the various types of gemini responses.
type ResponseCategory int
const (
// ResponseCategoryInput is for responses which request additional input.
//
// The META line will be the prompt to display to the user.
ResponseCategoryInput ResponseCategory = iota*10 + 10
// ResponseCategorySuccess is for successful responses.
//
// The META line will be the resource's mime type.
// This is the only response status which indicates the presence of a response body,
// and it will contain the resource itself.
ResponseCategorySuccess
// ResponseCategoryRedirect is for responses which direct the client to an alternative URL.
//
// The META line will contain the new URL the client should try.
ResponseCategoryRedirect
// ResponseCategoryTemporaryFailure is for responses which indicate a transient server-side failure.
//
// The META line may contain a line with more information about the error.
ResponseCategoryTemporaryFailure
// ResponseCategoryPermanentFailure is for permanent failure responses.
//
// The META line may contain a line with more information about the error.
ResponseCategoryPermanentFailure
// ResponseCategoryCertificateRequired indicates client certificate related issues.
//
// The META line may contain a line with more information about the error.
ResponseCategoryCertificateRequired
)
func ResponseCategoryForStatus(status gus.Status) ResponseCategory {
return ResponseCategory(status / 10)
}
const (
// StatusInput indicates a required query parameter at the requested URL.
StatusInput gus.Status = gus.Status(ResponseCategoryInput) + iota
// StatusSensitiveInput indicates a sensitive query parameter is required.
StatusSensitiveInput
)
const (
// StatusSuccess is a successful response.
StatusSuccess = gus.Status(ResponseCategorySuccess) + iota
)
const (
// StatusTemporaryRedirect indicates a temporary redirect to another URL.
StatusTemporaryRedirect = gus.Status(ResponseCategoryRedirect) + iota
// StatusPermanentRedirect indicates that the resource should always be requested at the new URL.
StatusPermanentRedirect
)
const (
// StatusTemporaryFailure indicates that the request failed and there is no response body.
StatusTemporaryFailure = gus.Status(ResponseCategoryTemporaryFailure) + iota
// StatusServerUnavailable occurs when the server is unavailable due to overload or maintenance.
StatusServerUnavailable
// StatusCGIError is the result of a failure of a CGI script.
StatusCGIError
// StatusProxyError indicates that the server is acting as a proxy and the outbound request failed.
StatusProxyError
// StatusSlowDown tells the client that rate limiting is in effect.
//
// Unlike other statuses in this category, the META line is an integer indicating how
// many more seconds the client must wait before sending another request.
StatusSlowDown
)
const (
// StatusPermanentFailure is a server failure which should be expected to continue indefinitely.
StatusPermanentFailure = gus.Status(ResponseCategoryPermanentFailure) + iota
// StatusNotFound means the resource doesn't exist but it may in the future.
StatusNotFound
// StatusGone occurs when a resource will not be available any longer.
StatusGone
// StatusProxyRequestRefused means the server is unwilling to act as a proxy for the resource.
StatusProxyRequestRefused
// StatusBadRequest indicates that the request was malformed somehow.
StatusBadRequest = gus.Status(ResponseCategoryPermanentFailure) + 9
)
const (
// StatusClientCertificateRequired is returned when a certificate was required but not provided.
StatusClientCertificateRequired = gus.Status(ResponseCategoryCertificateRequired) + iota
// StatusCertificateNotAuthorized means the certificate doesn't grant access to the requested resource.
StatusCertificateNotAuthorized
// StatusCertificateNotValid means the provided client certificate is invalid.
StatusCertificateNotValid
)
// Input builds an input-prompting response.
func Input(prompt string) *gus.Response {
return &gus.Response{
Status: StatusInput,
Meta: prompt,
}
}
// SensitiveInput builds a password-prompting response.
func SensitiveInput(prompt string) *gus.Response {
return &gus.Response{
Status: StatusSensitiveInput,
Meta: prompt,
}
}
// Success builds a success response with resource body.
func Success(mediatype string, body io.Reader) *gus.Response {
return &gus.Response{
Status: StatusSuccess,
Meta: mediatype,
Body: body,
}
}
// Redirect builds a redirect response.
func Redirect(url string) *gus.Response {
return &gus.Response{
Status: StatusTemporaryRedirect,
Meta: url,
}
}
// PermanentRedirect builds a response with a permanent redirect.
func PermanentRedirect(url string) *gus.Response {
return &gus.Response{
Status: StatusPermanentRedirect,
Meta: url,
}
}
// Failure builds a temporary failure response from an error.
func Failure(err error) *gus.Response {
return &gus.Response{
Status: StatusTemporaryFailure,
Meta: err.Error(),
}
}
// Unavailable build a "server unavailable" response.
func Unavailable(msg string) *gus.Response {
return &gus.Response{
Status: StatusServerUnavailable,
Meta: msg,
}
}
// CGIError builds a "cgi error" response.
func CGIError(err string) *gus.Response {
return &gus.Response{
Status: StatusCGIError,
Meta: err,
}
}
// ProxyError builds a proxy error response.
func ProxyError(msg string) *gus.Response {
return &gus.Response{
Status: StatusProxyError,
Meta: msg,
}
}
// SlowDown builds a "slow down" response with the number of seconds until the resource is available.
func SlowDown(seconds int) *gus.Response {
return &gus.Response{
Status: StatusSlowDown,
Meta: strconv.Itoa(seconds),
}
}
// PermanentFailure builds a "permanent failure" from an error.
func PermanentFailure(err error) *gus.Response {
return &gus.Response{
Status: StatusPermanentFailure,
Meta: err.Error(),
}
}
// NotFound builds a "resource not found" response.
func NotFound(msg string) *gus.Response {
return &gus.Response{
Status: StatusNotFound,
Meta: msg,
}
}
// Gone builds a "resource gone" response.
func Gone(msg string) *gus.Response {
return &gus.Response{
Status: StatusGone,
Meta: msg,
}
}
// RefuseProxy builds a "proxy request refused" response.
func RefuseProxy(msg string) *gus.Response {
return &gus.Response{
Status: StatusProxyRequestRefused,
Meta: msg,
}
}
// BadRequest builds a "bad request" response.
func BadRequest(msg string) *gus.Response {
return &gus.Response{
Status: StatusBadRequest,
Meta: msg,
}
}
// RequireCert builds a "client certificate required" response.
func RequireCert(msg string) *gus.Response {
return &gus.Response{
Status: StatusClientCertificateRequired,
Meta: msg,
}
}
// CertAuthFailure builds a "certificate not authorized" response.
func CertAuthFailure(msg string) *gus.Response {
return &gus.Response{
Status: StatusCertificateNotAuthorized,
Meta: msg,
}
}
// CertInvalid builds a "client certificate not valid" response.
func CertInvalid(msg string) *gus.Response {
return &gus.Response{
Status: StatusCertificateNotValid,
Meta: msg,
}
}
// InvalidResponseLineEnding indicates that a gemini response header didn't end with "\r\n".
var InvalidResponseLineEnding = errors.New("Invalid response line ending.")
// InvalidResponseHeaderLine indicates a malformed gemini response header line.
var InvalidResponseHeaderLine = errors.New("Invalid response header line.")
// ParseResponse parses a complete gemini response from a reader.
//
// The reader must contain only one gemini response.
func ParseResponse(rdr io.Reader) (*gus.Response, error) {
bufrdr := bufio.NewReader(rdr)
hdrLine, err := bufrdr.ReadBytes('\n')
if err != nil {
return nil, InvalidResponseLineEnding
}
if hdrLine[len(hdrLine)-2] != '\r' {
return nil, InvalidResponseLineEnding
}
if hdrLine[2] != ' ' {
return nil, InvalidResponseHeaderLine
}
hdrLine = hdrLine[:len(hdrLine)-2]
status, err := strconv.Atoi(string(hdrLine[:2]))
if err != nil {
return nil, InvalidResponseHeaderLine
}
return &gus.Response{
Status: gus.Status(status),
Meta: string(hdrLine[3:]),
Body: bufrdr,
}, nil
}
func NewResponseReader(response *gus.Response) gus.ResponseReader {
return &responseReader{
Response: response,
once: &sync.Once{},
}
}
type responseReader struct {
*gus.Response
reader io.Reader
once *sync.Once
}
func (rdr *responseReader) Read(b []byte) (int, error) {
rdr.ensureReader()
return rdr.reader.Read(b)
}
func (rdr *responseReader) WriteTo(dst io.Writer) (int64, error) {
rdr.ensureReader()
return rdr.reader.(io.WriterTo).WriteTo(dst)
}
func (rdr *responseReader) ensureReader() {
rdr.once.Do(func() {
hdr := bytes.NewBuffer(rdr.headerLine())
if rdr.Body != nil {
rdr.reader = io.MultiReader(hdr, rdr.Body)
} else {
rdr.reader = hdr
}
})
}
func (rdr responseReader) headerLine() []byte {
meta := rdr.Meta.(string)
buf := make([]byte, len(meta)+5)
_ = strconv.AppendInt(buf[:0], int64(rdr.Status), 10)
buf[2] = ' '
copy(buf[3:], meta)
buf[len(buf)-2] = '\r'
buf[len(buf)-1] = '\n'
return buf
}

View File

@ -1,335 +0,0 @@
package gemini_test
import (
"bytes"
"errors"
"io"
"testing"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
)
func TestBuildResponses(t *testing.T) {
table := []struct {
name string
response *gus.Response
status gus.Status
meta string
body string
}{
{
name: "input response",
response: gemini.Input("prompt here"),
status: gemini.StatusInput,
meta: "prompt here",
},
{
name: "sensitive input response",
response: gemini.SensitiveInput("password please"),
status: gemini.StatusSensitiveInput,
meta: "password please",
},
{
name: "success response",
response: gemini.Success("text/gemini", bytes.NewBufferString("body text here")),
status: gemini.StatusSuccess,
meta: "text/gemini",
body: "body text here",
},
{
name: "temporary redirect",
response: gemini.Redirect("/foo/bar"),
status: gemini.StatusTemporaryRedirect,
meta: "/foo/bar",
},
{
name: "permanent redirect",
response: gemini.PermanentRedirect("/baz/qux"),
status: gemini.StatusPermanentRedirect,
meta: "/baz/qux",
},
{
name: "fail response",
response: gemini.Failure(errors.New("a failure")),
status: gemini.StatusTemporaryFailure,
meta: "a failure",
},
{
name: "server unavailable",
response: gemini.Unavailable("server unavailable"),
status: gemini.StatusServerUnavailable,
meta: "server unavailable",
},
{
name: "cgi error",
response: gemini.CGIError("some cgi error msg"),
status: gemini.StatusCGIError,
meta: "some cgi error msg",
},
{
name: "proxy error",
response: gemini.ProxyError("upstream's full"),
status: gemini.StatusProxyError,
meta: "upstream's full",
},
{
name: "rate limiting",
response: gemini.SlowDown(15),
status: gemini.StatusSlowDown,
meta: "15",
},
{
name: "permanent failure",
response: gemini.PermanentFailure(errors.New("wut r u doin")),
status: gemini.StatusPermanentFailure,
meta: "wut r u doin",
},
{
name: "not found",
response: gemini.NotFound("nope"),
status: gemini.StatusNotFound,
meta: "nope",
},
{
name: "gone",
response: gemini.Gone("all out of that"),
status: gemini.StatusGone,
meta: "all out of that",
},
{
name: "refuse proxy",
response: gemini.RefuseProxy("no I don't think I will"),
status: gemini.StatusProxyRequestRefused,
meta: "no I don't think I will",
},
{
name: "bad request",
response: gemini.BadRequest("that don't make no sense"),
status: gemini.StatusBadRequest,
meta: "that don't make no sense",
},
{
name: "require cert",
response: gemini.RequireCert("cert required"),
status: gemini.StatusClientCertificateRequired,
meta: "cert required",
},
{
name: "cert auth failure",
response: gemini.CertAuthFailure("you can't see that"),
status: gemini.StatusCertificateNotAuthorized,
meta: "you can't see that",
},
{
name: "invalid cert",
response: gemini.CertInvalid("bad cert dude"),
status: gemini.StatusCertificateNotValid,
meta: "bad cert dude",
},
}
for _, test := range table {
t.Run(test.name, func(t *testing.T) {
if test.response.Status != test.status {
t.Errorf("expected status %d, got %d", test.status, test.response.Status)
}
if test.response.Meta != test.meta {
t.Errorf("expected meta %q, got %q", test.meta, test.response.Meta)
}
responseBytes, err := io.ReadAll(gemini.NewResponseReader(test.response))
if err != nil {
t.Fatalf("error reading response body: %q", err.Error())
}
body := string(bytes.SplitN(responseBytes, []byte("\r\n"), 2)[1])
if body != test.body {
t.Errorf("expected body %q, got %q", test.body, body)
}
})
}
}
func TestParseResponses(t *testing.T) {
table := []struct {
input string
status gus.Status
meta string
body string
err error
}{
{
input: "20 text/gemini\r\n# you got me!\n",
status: gemini.StatusSuccess,
meta: "text/gemini",
body: "# you got me!\n",
},
{
input: "30 gemini://some.where/else\r\n",
status: gemini.StatusTemporaryRedirect,
meta: "gemini://some.where/else",
},
{
input: "10 forgot the line ending",
err: gemini.InvalidResponseLineEnding,
},
{
input: "10 wrong line ending\n",
err: gemini.InvalidResponseLineEnding,
},
{
input: "10no space\r\n",
err: gemini.InvalidResponseHeaderLine,
},
{
input: "no status code\r\n",
err: gemini.InvalidResponseHeaderLine,
},
{
input: "31 gemini://domain.com/my/new/home\r\n",
status: gemini.StatusPermanentRedirect,
meta: "gemini://domain.com/my/new/home",
},
}
for _, test := range table {
t.Run(test.input, func(t *testing.T) {
response, err := gemini.ParseResponse(bytes.NewBufferString(test.input))
if !errors.Is(err, test.err) {
t.Fatalf("expected error %s, got %s", test.err, err)
}
if err != nil {
return
}
if response.Status != test.status {
t.Errorf("expected status %d, got %d", test.status, response.Status)
}
if response.Meta != test.meta {
t.Errorf("expected meta %q, got %q", test.meta, response.Meta)
}
if response.Body == nil {
if test.body != "" {
t.Errorf("expected body %q, got nil", test.body)
}
} else {
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("error reading response body: %s", err.Error())
}
if test.body != string(body) {
t.Errorf("expected body %q, got %q", test.body, string(body))
}
}
})
}
}
func TestResponseClose(t *testing.T) {
body := &rdCloser{Buffer: bytes.NewBufferString("the body here")}
resp := &gus.Response{
Status: gemini.StatusSuccess,
Meta: "text/gemini",
Body: body,
}
if err := resp.Close(); err != nil {
t.Fatalf("response close error: %s", err.Error())
}
if !body.closed {
t.Error("response body was not closed by response.Close()")
}
resp = &gus.Response{
Status: gemini.StatusInput,
Meta: "give me more",
}
if err := resp.Close(); err != nil {
t.Fatalf("response close error: %s", err.Error())
}
}
type rdCloser struct {
*bytes.Buffer
closed bool
}
func (rc *rdCloser) Close() error {
rc.closed = true
return nil
}
func TestResponseWriteTo(t *testing.T) {
// invariant under test: WriteTo() sends the same bytes as Read()
clone := func(resp *gus.Response) *gus.Response {
other := &gus.Response{
Status: resp.Status,
Meta: resp.Meta,
}
if resp.Body != nil {
// the body could be one-time readable, so replace it with a buffer
buf, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("error reading response body: %s", err.Error())
}
resp.Body = bytes.NewBuffer(buf)
buf2 := make([]byte, len(buf))
if copy(buf2, buf) != len(buf) {
t.Fatalf("short copy on a []byte")
}
other.Body = bytes.NewBuffer(buf2)
}
return other
}
table := []struct {
name string
response *gus.Response
}{
{
name: "simple success",
response: gemini.Success(
"text/gemini",
bytes.NewBufferString("the body goes here"),
),
},
{
name: "no body",
response: gemini.Input("need more pls"),
},
}
for _, test := range table {
t.Run(test.name, func(t *testing.T) {
r1 := test.response
r2 := clone(test.response)
rdbuf, err := io.ReadAll(gemini.NewResponseReader(r1))
if err != nil {
t.Fatalf("response.Read(): %s", err.Error())
}
wtbuf := &bytes.Buffer{}
if _, err := gemini.NewResponseReader(r2).WriteTo(wtbuf); err != nil {
t.Fatalf("response.WriteTo(): %s", err.Error())
}
if wtbuf.String() != string(rdbuf) {
t.Fatalf("Read produced %q but WriteTo produced %q", string(rdbuf), wtbuf.String())
}
})
}
}

View File

@ -1,98 +0,0 @@
package gemini_test
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
)
func TestRoundTrip(t *testing.T) {
tlsConf, err := gemini.FileTLS("./testdata/server.crt", "./testdata/server.key")
require.Nil(t, err)
handler := gus.HandlerFunc(func(ctx context.Context, req *gus.Request) *gus.Response {
return gemini.Success("text/gemini", bytes.NewBufferString("you've found my page"))
})
server, err := gemini.NewServer(context.Background(), "localhost", "tcp", "127.0.0.1:0", handler, nil, tlsConf)
require.Nil(t, err)
go func() {
_ = server.Serve()
}()
defer server.Close()
u, err := url.Parse(fmt.Sprintf("gemini://%s/test", server.Address()))
require.Nil(t, err)
cli := gemini.NewClient(testClientTLS())
response, err := cli.RoundTrip(&gus.Request{URL: u})
require.Nil(t, err)
assert.Equal(t, gemini.StatusSuccess, response.Status)
assert.Equal(t, "text/gemini", response.Meta)
require.NotNil(t, response.Body)
body, err := io.ReadAll(response.Body)
require.Nil(t, err)
assert.Equal(t, "you've found my page", string(body))
}
func TestTitanRequest(t *testing.T) {
tlsConf, err := gemini.FileTLS("./testdata/server.crt", "./testdata/server.key")
require.Nil(t, err)
invoked := false
handler := gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
invoked = true
body := ctx.Value(gemini.TitanRequestBody)
if !assert.NotNil(t, body) {
return gemini.Success("", nil)
}
bodyBytes, err := io.ReadAll(body.(io.Reader))
require.Nil(t, err)
assert.Equal(t, "the request body\n", string(bodyBytes))
return gemini.Success("", nil)
})
server, err := gemini.NewServer(context.Background(), "localhost", "tcp", "127.0.0.1:0", handler, nil, tlsConf)
require.Nil(t, err)
go func() {
_ = server.Serve()
}()
defer server.Close()
conn, err := tls.Dial(server.Network(), server.Address(), testClientTLS())
require.Nil(t, err)
_, err = fmt.Fprintf(
conn,
"titan://%s/foobar;size=17;mime=text/plain\r\nthe request body\n",
server.Address(),
)
require.Nil(t, err)
_, err = io.ReadAll(conn)
require.Nil(t, err)
assert.True(t, invoked)
}
func testClientTLS() *tls.Config {
return &tls.Config{InsecureSkipVerify: true}
}

View File

@ -1,138 +0,0 @@
package gemini
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/internal"
"tildegit.org/tjp/gus/logging"
)
type titanRequestBodyKey struct{}
// TitanRequestBody is the key set in a handler's context for titan requests.
//
// When this key is present in the context (request.URL.Scheme will be "titan"), the
// corresponding value is a *bufio.Reader from which the request body can be read.
var TitanRequestBody = titanRequestBodyKey{}
type server struct {
internal.Server
handler gus.Handler
}
func (s server) Protocol() string { return "GEMINI" }
// NewServer builds a gemini server.
func NewServer(
ctx context.Context,
hostname string,
network string,
address string,
handler gus.Handler,
errorLog logging.Logger,
tlsConfig *tls.Config,
) (gus.Server, error) {
s := &server{handler: handler}
if strings.IndexByte(hostname, ':') < 0 {
hostname = net.JoinHostPort(hostname, "1965")
}
internalServer, err := internal.NewServer(ctx, hostname, network, address, errorLog, s.handleConn)
if err != nil {
return nil, err
}
s.Server = internalServer
s.Listener = tls.NewListener(s.Listener, tlsConfig)
return s, nil
}
func (s *server) handleConn(conn net.Conn) {
buf := bufio.NewReader(conn)
var response *gus.Response
request, err := ParseRequest(buf)
if err != nil {
response = BadRequest(err.Error())
} else {
request.Server = s
request.RemoteAddr = conn.RemoteAddr()
if tlsconn, ok := conn.(*tls.Conn); ok {
state := tlsconn.ConnectionState()
request.TLSState = &state
}
ctx := s.Ctx
if request.Scheme == "titan" {
len, err := sizeParam(request.Path)
if err == nil {
ctx = context.WithValue(
ctx,
TitanRequestBody,
io.LimitReader(buf, int64(len)),
)
}
}
defer func() {
if r := recover(); r != nil {
err := fmt.Errorf("%s", r)
_ = s.LogError("msg", "panic in handler", "err", err)
_, _ = io.Copy(conn, NewResponseReader(Failure(err)))
}
}()
response = s.handler.Handle(ctx, request)
if response == nil {
response = NotFound("Resource does not exist.")
}
}
defer response.Close()
_, _ = io.Copy(conn, NewResponseReader(response))
}
func sizeParam(path string) (int, error) {
_, rest, found := strings.Cut(path, ";")
if !found {
return 0, errors.New("no params in path")
}
for _, piece := range strings.Split(rest, ";") {
key, val, _ := strings.Cut(piece, "=")
if key == "size" {
return strconv.Atoi(val)
}
}
return 0, errors.New("no size param found")
}
// GeminiOnly filters requests down to just those on the gemini:// protocol.
//
// Optionally, it will also allow through titan:// requests.
//
// Filtered requests will be turned away with a 53 response "proxy request refused".
func GeminiOnly(allowTitan bool) gus.Middleware {
return func(inner gus.Handler) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
if request.Scheme == "gemini" || (allowTitan && request.Scheme == "titan") {
return inner.Handle(ctx, request)
}
return RefuseProxy("Non-gemini protocol requests are not supported.")
})
}
}

View File

@ -1,18 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIC7jCCAdYCAQcwDQYJKoZIhvcNAQELBQAwPTESMBAGA1UEAwwJbG9jYWxob3N0
MQswCQYDVQQGEwJVUzEaMBgGA1UEBwwRU2FuIEZyYW5jaXNjbywgQ0EwHhcNMjMw
MTExMjAwMDU5WhcNMjUwNDE1MjAwMDU5WjA9MRIwEAYDVQQDDAlsb2NhbGhvc3Qx
CzAJBgNVBAYTAlVTMRowGAYDVQQHDBFTYW4gRnJhbmNpc2NvLCBDQTCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBALlaPa1AxDQnMo0qQxY5/Bf7MNf1x6tN
xjkpMnQnPM+cHmmlkEhI1zwLk/LrLxwq7+OOxMTPrJglrAiDAp1uCZHjKcTMFnwO
9M5vf8LjtYBjZd8+OSHyYV37gxw7h9/Wsxl+1Yw40QaJKM9auj2xOyaDj5Ou9+yp
CfbGSpVUTnqReOVFg2QSNwEviOZu1SvAouPyO98WKoXjn7K5mxE545e4mgF1EMht
jB5kH6kXqZSUszlGA1MkX3AlDsYJIcYnDwelNvw6XTPpkT2wNehxPyD0iP4rs+W4
5hgV8wYokpgrM3xxe0c4mop5bzrp2Hyz3WxnF7KwtJgHW/6YxhG73skCAwEAATAN
BgkqhkiG9w0BAQsFAAOCAQEAfI+UE/3d0Fb8BZ2gtv1kUh8yx75LUbpg1aOEsZdP
Rji+GkL5xiFDsm7BwqTKziAjDtjL2qtGcJJ835shsGiUSK6qJuf9C944utUvCoFm
b4aUZ8fTmN7PkwRS61nIcHaS1zkiFzUdvbquV3QWSnl9kC+yDLHT0Z535tcvCMVM
bO7JMj1sxml4Y9B/hfY7zAZJt1giSNH1iDeX2pTpmPPI40UsRn98cC8HZ0d8wFrv
yc3hKkz8E+WTgZUf7jFk/KX/T5uwu+Y85emwfbb82KIR3oqhkJIfOfpqop2duZXB
hMuO1QWEBkZ/hpfrAsN/foz8v46P9qgW8gfOfzhyBcqLvA==
-----END CERTIFICATE-----

View File

@ -1,27 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAuVo9rUDENCcyjSpDFjn8F/sw1/XHq03GOSkydCc8z5weaaWQ
SEjXPAuT8usvHCrv447ExM+smCWsCIMCnW4JkeMpxMwWfA70zm9/wuO1gGNl3z45
IfJhXfuDHDuH39azGX7VjDjRBokoz1q6PbE7JoOPk6737KkJ9sZKlVROepF45UWD
ZBI3AS+I5m7VK8Ci4/I73xYqheOfsrmbETnjl7iaAXUQyG2MHmQfqReplJSzOUYD
UyRfcCUOxgkhxicPB6U2/DpdM+mRPbA16HE/IPSI/iuz5bjmGBXzBiiSmCszfHF7
RziainlvOunYfLPdbGcXsrC0mAdb/pjGEbveyQIDAQABAoIBAQC36ylkLu4Bahup
I5RqC6NwEFpJEKLOAmB8+7oKs5yNzTYIUra2Y0DfXgWyd1fJtXlP7aymNgPm/QqV
b5o6qKNqVWRu2Kw+8YBNDypRMi45dWfyewWp/55J6XYRn6iVna8dz1MKzp3qxFLw
XfCLor802jqvqmBsPteaPOxo/LzatKhXp/mcO/hsxeMr1iSUVHTrQEIU/aIkmAqT
/eXp/zVZk7O9Tx8wwCijB3v7j3zTEkcKSwFlAp0w01XeqllmqA5P9rW3vVGXJVIM
t6t9C8XcJWPIOURz3JWZJpUBSZsyNe2N/wbCgkQV81A0s+4praKzgDbjE+njb0C/
1CClbHV5AoGBAO/mnOzHe7ZJyYfuiu6ZR2REBY61n2J6DkL1stkN5xd+Op25afHT
jLBjU98hM/AMtP1aHWFQpdEe0uyqRjV6PbpNE8j/m9AVfjZxzwR4ITW2xqUhXOSz
89o832RO54TTr19YGnIhdU8dDQmYOcKmCSuw6KwCfHwBzkFuDFZGk/4/AoGBAMXK
gzNyX3tN9Ug5AUo/Az4jQRSoyLjfnce0a0TF4jxEacUBx2COq3zaV/VADEFBla1t
5roOAUyJ3V6fXtZnoqwZPYh6iGP8p7Tj6vyXI4SDktV0uAV57qSdajqxTrA7yoXr
zrbxv3U/3vXr3JTsP42U5zp1m5n1VfVqCXBkynD3AoGBAOvs7JjDWXuctzASPNmH
LjmB18FQBk3vYQUi4l8pmAF3pyejx3gGJw70r+/4lD5YEMozjD8+88Njv+T1U5SW
Agysbm+2SMJr0LK0W/W2Olq7xEFzPQrBmmgeg0b/fhoXoBlw6JkjJF3IYSD1bqBp
bw1jrn4y979weynHkyRpxnM7AoGBALGSzRPlPR/gr7P1qdjUlb61u/omRn7kFC11
J1EJL8HX0fXTUQK5U/C1vn4q0FXN4elgX+LuK/BhXeNTxbtMM9m6l2nuSIEsFgzr
Cs9XicWwsqT9MzGHdN9JjFPBV9oU9BAj0uSgSbmkbDHxXYo+SBh+dNIhQF+KyW+Z
kXvcoXulAoGAA2hnEA17nJ7Vj1DZ4CoRblgjZFAMB64slcSesaorp3WWehvaXO8u
jbvWuvj58DgvTLiv8xPIn4Zsjd0a77ysifvUcmxSRa/k9UIle/lwjmXGjQ1GSMEI
FB5ZTqjLZwS9Y5BDxlPcYF7vqE9fNpcxmcfHGmSF5YAHvFOfGH6B63M=
-----END RSA PRIVATE KEY-----

View File

@ -1,19 +0,0 @@
package gemini
import "crypto/tls"
// FileTLS builds a TLS configuration from paths to a certificate and key file.
//
// It sets parameters on the configuration to make it suitable for use with gemini.
func FileTLS(certfile string, keyfile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certfile, keyfile)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
ClientAuth: tls.RequestClientCert,
}, nil
}

15
go.mod
View File

@ -1,15 +0,0 @@
module tildegit.org/tjp/gus
go 1.19
require (
github.com/go-kit/log v0.2.1
github.com/stretchr/testify v1.8.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-logfmt/logfmt v0.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

21
go.sum
View File

@ -1,21 +0,0 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,55 +0,0 @@
package gopher
import (
"bytes"
"errors"
"io"
"net"
"tildegit.org/tjp/gus"
)
// Client is used for sending gopher requests and producing the responses.
//
// It carries no state and is reusable simultaneously by multiple goroutines.
//
// The zero value is immediately usable.
type Client struct{}
// RoundTrip sends a single gopher request and returns its response.
func (c Client) RoundTrip(request *gus.Request) (*gus.Response, error) {
if request.Scheme != "gopher" && request.Scheme != "" {
return nil, errors.New("non-gopher protocols not supported")
}
host := request.Host
if _, port, _ := net.SplitHostPort(host); port == "" {
host = net.JoinHostPort(host, "70")
}
conn, err := net.Dial("tcp", host)
if err != nil {
return nil, err
}
defer conn.Close()
request.RemoteAddr = conn.RemoteAddr()
request.TLSState = nil
requestBody := request.Path
if request.RawQuery != "" {
requestBody += "\t" + request.UnescapedQuery()
}
requestBody += "\r\n"
if _, err := conn.Write([]byte(requestBody)); err != nil {
return nil, err
}
response, err := io.ReadAll(conn)
if err != nil {
return nil, err
}
return &gus.Response{Body: bytes.NewBuffer(response)}, nil
}

View File

@ -1,61 +0,0 @@
package gophermap
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gopher"
)
// Parse reads a gophermap document from a reader.
func Parse(input io.Reader) (gopher.MapDocument, error) {
rdr := bufio.NewReader(input)
doc := gopher.MapDocument{}
num := 0
for {
num += 1
line, err := rdr.ReadBytes('\n')
isEOF := errors.Is(err, io.EOF)
if err != nil && !isEOF {
return nil, err
}
if len(line) > 2 && !bytes.Equal(line, []byte(".\r\n")) {
if line[len(line)-2] != '\r' || line[len(line)-1] != '\n' {
return nil, InvalidLine(num)
}
item := gopher.MapItem{Type: gus.Status(line[0])}
spl := bytes.Split(line[1:len(line)-2], []byte{'\t'})
if len(spl) != 4 {
return nil, InvalidLine(num)
}
item.Display = string(spl[0])
item.Selector = string(spl[1])
item.Hostname = string(spl[2])
item.Port = string(spl[3])
doc = append(doc, item)
}
if isEOF {
break
}
}
return doc, nil
}
// InvalidLine is returned from Parse when the reader contains a line which is invalid gophermap.
type InvalidLine int
// Error implements the error interface.
func (il InvalidLine) Error() string {
return fmt.Sprintf("Invalid gophermap on line %d.", il)
}

View File

@ -1,96 +0,0 @@
package gophermap_test
import (
"bytes"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus/gopher"
"tildegit.org/tjp/gus/gopher/gophermap"
)
func TestParse(t *testing.T) {
tests := []struct {
doc string
lines gopher.MapDocument
}{
{
doc: `
iI am informational text localhost 70
icontinued on this line localhost 70
i localhost 70
0this is my text file /file.txt localhost 70
i localhost 70
1here's a sub-menu /sub/ localhost 70
.
`[1:],
lines: gopher.MapDocument{
gopher.MapItem{
Type: gopher.InfoMessageType,
Display: "I am informational text",
Selector: "",
Hostname: "localhost",
Port: "70",
},
gopher.MapItem{
Type: gopher.InfoMessageType,
Display: "continued on this line",
Selector: "",
Hostname: "localhost",
Port: "70",
},
gopher.MapItem{
Type: gopher.InfoMessageType,
Display: "",
Selector: "",
Hostname: "localhost",
Port: "70",
},
gopher.MapItem{
Type: gopher.TextFileType,
Display: "this is my text file",
Selector: "/file.txt",
Hostname: "localhost",
Port: "70",
},
gopher.MapItem{
Type: gopher.InfoMessageType,
Display: "",
Selector: "",
Hostname: "localhost",
Port: "70",
},
gopher.MapItem{
Type: gopher.MenuType,
Display: "here's a sub-menu",
Selector: "/sub/",
Hostname: "localhost",
Port: "70",
},
},
},
}
for _, test := range tests {
t.Run(test.lines[0].Display, func(t *testing.T) {
text := strings.ReplaceAll(test.doc, "\n", "\r\n")
doc, err := gophermap.Parse(bytes.NewBufferString(text))
require.Nil(t, err)
if assert.Equal(t, len(test.lines), len(doc)) {
for i, line := range doc {
expect := test.lines[i]
assert.Equal(t, expect.Type, line.Type)
assert.Equal(t, expect.Display, line.Display)
assert.Equal(t, expect.Selector, line.Selector)
assert.Equal(t, expect.Hostname, line.Hostname)
assert.Equal(t, expect.Port, line.Port)
}
}
})
}
}

View File

@ -1,72 +0,0 @@
package gopher
import (
"bytes"
"errors"
"io"
"net/url"
"path"
"strings"
"tildegit.org/tjp/gus"
)
// ParseRequest parses a gopher protocol request into a gus.Request object.
func ParseRequest(rdr io.Reader) (*gus.Request, error) {
selector, search, err := readFullRequest(rdr)
if err != nil {
return nil, err
}
if !strings.HasPrefix(selector, "/") {
selector = "/" + selector
}
return &gus.Request{
URL: &url.URL{
Scheme: "gopher",
Path: path.Clean(strings.TrimSuffix(selector, "\r\n")),
OmitHost: true, //nolint:typecheck
// (for some reason typecheck on drone-ci doesn't realize OmitHost is a field in url.URL)
RawQuery: url.QueryEscape(strings.TrimSuffix(search, "\r\n")),
},
}, nil
}
func readFullRequest(rdr io.Reader) (string, string, error) {
// The vast majority of requests will fit in this size:
// the specified 255 byte max for selector, then CRLF.
buf := make([]byte, 257)
n, err := rdr.Read(buf)
if err != nil && !errors.Is(err, io.EOF) {
return "", "", err
}
buf = buf[:n]
// Full-text search transactions are the exception, they
// may be longer because there is an additional search string
if n == 257 && buf[256] != '\n' {
intake := buf[n:cap(buf)]
total := n
for {
intake = append(intake, 0)
intake = intake[:cap(intake)]
n, err = rdr.Read(intake)
if err != nil && err != io.EOF {
return "", "", err
}
total += n
if n < cap(intake) || intake[cap(intake)-1] == '\n' {
break
}
intake = intake[n:]
}
buf = buf[:total]
}
selector, search, _ := bytes.Cut(buf, []byte{'\t'})
return string(selector), string(search), nil
}

View File

@ -1,43 +0,0 @@
package gopher_test
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus/gopher"
)
func TestParseRequest(t *testing.T) {
tests := []struct {
requestLine string
path string
query string
}{
{
requestLine: "\r\n",
path: "/",
},
{
requestLine: "foo/bar\r\n",
path: "/foo/bar",
},
{
requestLine: "search\tthis AND that\r\n",
path: "/search",
query: "this+AND+that",
},
}
for _, test := range tests {
t.Run(test.requestLine, func(t *testing.T) {
request, err := gopher.ParseRequest(bytes.NewBufferString(test.requestLine))
require.Nil(t, err)
assert.Equal(t, test.path, request.Path)
assert.Equal(t, test.query, request.RawQuery)
})
}
}

View File

@ -1,162 +0,0 @@
package gopher
import (
"bytes"
"fmt"
"io"
"sync"
"tildegit.org/tjp/gus"
)
// The Canonical gopher item types.
const (
TextFileType gus.Status = '0'
MenuType gus.Status = '1'
CSOPhoneBookType gus.Status = '2'
ErrorType gus.Status = '3'
MacBinHexType gus.Status = '4'
DosBinType gus.Status = '5'
UuencodedType gus.Status = '6'
SearchType gus.Status = '7'
TelnetSessionType gus.Status = '8'
BinaryFileType gus.Status = '9'
MirrorServerType gus.Status = '+'
GifFileType gus.Status = 'g'
ImageFileType gus.Status = 'I'
Telnet3270Type gus.Status = 'T'
)
// The gopher+ types.
const (
BitmapType gus.Status = ':'
MovieFileType gus.Status = ';'
SoundFileType gus.Status = '<'
)
// The various non-canonical gopher types.
const (
DocumentType gus.Status = 'd'
HTMLType gus.Status = 'h'
InfoMessageType gus.Status = 'i'
PngImageFileType gus.Status = 'p'
RtfDocumentType gus.Status = 'r'
WavSoundFileType gus.Status = 's'
PdfDocumentType gus.Status = 'P'
XmlDocumentType gus.Status = 'X'
)
// MapItem is a single item in a gophermap.
type MapItem struct {
Type gus.Status
Display string
Selector string
Hostname string
Port string
}
// String serializes the item into a gophermap CRLF-terminated text line.
func (mi MapItem) String() string {
return fmt.Sprintf(
"%s%s\t%s\t%s\t%s\r\n",
[]byte{byte(mi.Type)},
mi.Display,
mi.Selector,
mi.Hostname,
mi.Port,
)
}
// Response builds a response which contains just this single MapItem.
//
// Meta in the response will be a pointer to the MapItem.
func (mi *MapItem) Response() *gus.Response {
return &gus.Response{
Status: mi.Type,
Meta: &mi,
Body: bytes.NewBufferString(mi.String() + ".\r\n"),
}
}
// MapDocument is a list of map items which can print out a full gophermap document.
type MapDocument []MapItem
// String serializes the document into gophermap format.
func (md MapDocument) String() string {
return md.serialize().String()
}
// Response builds a gopher response containing the gophermap.
//
// Meta will be the MapDocument itself.
func (md MapDocument) Response() *gus.Response {
return &gus.Response{
Status: DocumentType,
Meta: md,
Body: md.serialize(),
}
}
func (md MapDocument) serialize() *bytes.Buffer {
buf := &bytes.Buffer{}
for _, mi := range md {
_, _ = buf.WriteString(mi.String())
}
_, _ = buf.WriteString(".\r\n")
return buf
}
// Error builds an error message MapItem.
func Error(err error) *MapItem {
return &MapItem{
Type: ErrorType,
Display: err.Error(),
Hostname: "none",
Port: "0",
}
}
// File builds a minimal response delivering a file's contents.
//
// Meta is nil and Status is 0 in this response.
func File(status gus.Status, contents io.Reader) *gus.Response {
return &gus.Response{Status: status, Body: contents}
}
// NewResponseReader produces a reader which supports reading gopher protocol responses.
func NewResponseReader(response *gus.Response) gus.ResponseReader {
return &responseReader{
Response: response,
once: &sync.Once{},
}
}
type responseReader struct {
*gus.Response
reader io.Reader
once *sync.Once
}
func (rdr *responseReader) Read(b []byte) (int, error) {
rdr.ensureReader()
return rdr.reader.Read(b)
}
func (rdr *responseReader) WriteTo(dst io.Writer) (int64, error) {
rdr.ensureReader()
return rdr.reader.(io.WriterTo).WriteTo(dst)
}
func (rdr *responseReader) ensureReader() {
rdr.once.Do(func() {
if _, ok := rdr.Body.(io.WriterTo); ok {
rdr.reader = rdr.Body
return
}
// rdr.reader needs to implement WriterTo, so in this case
// we borrow an implementation in terms of io.Reader from
// io.MultiReader.
rdr.reader = io.MultiReader(rdr.Body)
})
}

View File

@ -1,72 +0,0 @@
package gopher
import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/internal"
"tildegit.org/tjp/gus/logging"
)
type gopherServer struct {
internal.Server
handler gus.Handler
}
func (gs gopherServer) Protocol() string { return "GOPHER" }
// NewServer builds a gopher server.
func NewServer(
ctx context.Context,
hostname string,
network string,
address string,
handler gus.Handler,
errLog logging.Logger,
) (gus.Server, error) {
gs := &gopherServer{handler: handler}
if strings.IndexByte(hostname, ':') < 0 {
hostname = net.JoinHostPort(hostname, "70")
}
var err error
gs.Server, err = internal.NewServer(ctx, hostname, network, address, errLog, gs.handleConn)
if err != nil {
return nil, err
}
return gs, nil
}
func (gs *gopherServer) handleConn(conn net.Conn) {
var response *gus.Response
request, err := ParseRequest(conn)
if err != nil {
response = Error(errors.New("Malformed request.")).Response()
} else {
request.Server = gs
request.RemoteAddr = conn.RemoteAddr()
defer func() {
if r := recover(); r != nil {
err := fmt.Errorf("%s", r)
_ = gs.LogError("msg", "panic in handler", "err", err)
rdr := NewResponseReader(Error(errors.New("Server error.")).Response())
_, _ = io.Copy(conn, rdr)
}
}()
response = gs.handler.Handle(gs.Ctx, request)
if response == nil {
response = Error(errors.New("Resource does not exist.")).Response()
}
}
defer response.Close()
_, _ = io.Copy(conn, NewResponseReader(response))
}

View File

@ -1,66 +0,0 @@
package gus
import "context"
// Handler is a type which can turn a request into a response.
//
// Handle may return a nil response, in which case the Server is expected
// to build the protocol-appropriate "Not Found" response.
type Handler interface {
Handle(context.Context, *Request) *Response
}
type handlerFunc func(context.Context, *Request) *Response
// HandlerFunc is a wrapper to allow using a function as a Handler.
func HandlerFunc(f func(context.Context, *Request) *Response) Handler {
return handlerFunc(f)
}
// Handle implements Handler.
func (f handlerFunc) Handle(ctx context.Context, request *Request) *Response {
return f(ctx, request)
}
// Middleware is a handler decorator.
//
// It returns a handler which may call the passed-in handler or not, or may
// transform the request or response in some way.
type Middleware func(Handler) Handler
// FallthroughHandler builds a handler which tries multiple child handlers.
//
// The returned handler will invoke each of the passed-in handlers in order,
// stopping when it receives a non-nil response.
func FallthroughHandler(handlers ...Handler) Handler {
return HandlerFunc(func(ctx context.Context, request *Request) *Response {
for _, handler := range handlers {
if response := handler.Handle(ctx, request); response != nil {
return response
}
}
return nil
})
}
// Filter builds a middleware which only calls the wrapped Handler under a condition.
//
// When the condition function returns false it instead invokes the test-failure
// handler. The failure handler may also be nil, in which case the final handler will
// return a nil response whenever the condition fails.
func Filter(
condition func(context.Context, *Request) bool,
failure Handler,
) Middleware {
return func(success Handler) Handler {
return HandlerFunc(func(ctx context.Context, request *Request) *Response {
if condition(ctx, request) {
return success.Handle(ctx, request)
}
if failure == nil {
return nil
}
return failure.Handle(ctx, request)
})
}
}

View File

@ -1,118 +0,0 @@
package gus_test
import (
"bytes"
"context"
"io"
"net/url"
"strings"
"testing"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
)
func TestFallthrough(t *testing.T) {
h1 := gus.HandlerFunc(func(ctx context.Context, req *gus.Request) *gus.Response {
if req.Path == "/one" {
return gemini.Success("text/gemini", bytes.NewBufferString("one"))
}
return nil
})
h2 := gus.HandlerFunc(func(ctx context.Context, req *gus.Request) *gus.Response {
if req.Path == "/two" {
return gemini.Success("text/gemini", bytes.NewBufferString("two"))
}
return nil
})
fth := gus.FallthroughHandler(h1, h2)
u, err := url.Parse("gemini://test.local/one")
if err != nil {
t.Fatalf("url.Parse: %s", err.Error())
}
resp := fth.Handle(context.Background(), &gus.Request{URL: u})
if resp.Status != gemini.StatusSuccess {
t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status)
}
if resp.Meta != "text/gemini" {
t.Errorf(`expected meta "text/gemini", got %q`, resp.Meta)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Errorf("Read: %s", err.Error())
}
if string(body) != "one" {
t.Errorf(`expected body "one", got %q`, string(body))
}
u, err = url.Parse("gemini://test.local/two")
if err != nil {
t.Fatalf("url.Parse: %s", err.Error())
}
resp = fth.Handle(context.Background(), &gus.Request{URL: u})
if resp.Status != gemini.StatusSuccess {
t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status)
}
if resp.Meta != "text/gemini" {
t.Errorf(`expected meta "text/gemini", got %q`, resp.Meta)
}
body, err = io.ReadAll(resp.Body)
if err != nil {
t.Errorf("Read: %s", err.Error())
}
if string(body) != "two" {
t.Errorf(`expected body "two", got %q`, string(body))
}
u, err = url.Parse("gemini://test.local/three")
if err != nil {
t.Fatalf("url.Parse: %s", err.Error())
}
resp = fth.Handle(context.Background(), &gus.Request{URL: u})
if resp != nil {
t.Errorf("expected nil, got %+v", resp)
}
}
func TestFilter(t *testing.T) {
pred := func(ctx context.Context, req *gus.Request) bool {
return strings.HasPrefix(req.Path, "/allow")
}
base := gus.HandlerFunc(func(ctx context.Context, req *gus.Request) *gus.Response {
return gemini.Success("text/gemini", bytes.NewBufferString("allowed!"))
})
handler := gus.Filter(pred, nil)(base)
u, err := url.Parse("gemini://test.local/allow/please")
if err != nil {
t.Fatalf("url.Parse: %s", err.Error())
}
resp := handler.Handle(context.Background(), &gus.Request{URL: u})
if resp.Status != gemini.StatusSuccess {
t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status)
}
u, err = url.Parse("gemini://test.local/disallow/please")
if err != nil {
t.Fatalf("url.Parse: %s", err.Error())
}
resp = handler.Handle(context.Background(), &gus.Request{URL: u})
if resp != nil {
t.Errorf("expected nil, got %+v", resp)
}
}

View File

@ -1,323 +0,0 @@
package internal
import (
"sort"
"strings"
)
type PathTree[V any] struct {
tree subtree[V]
root *V
}
func (pt PathTree[V]) Match(path string) (*V, map[string]string) {
path = strings.TrimPrefix(path, "/")
if path == "" {
return pt.root, map[string]string{}
}
m := pt.tree.Match(strings.Split(path, "/"), nil)
if m == nil {
return nil, nil
}
return m.value, m.params()
}
func (pt *PathTree[V]) Add(pattern string, value V) {
pattern = strings.TrimPrefix(pattern, "/")
if pattern == "" {
pt.root = &value
} else {
pt.tree.Add(strings.Split(pattern, "/"), value)
}
}
type Route[V any] struct {
Pattern string
Value V
}
func (pt PathTree[V]) Routes() []Route[V] {
return pt.tree.routes()
}
// pattern segment which must be a specific string ("/users/").
type segmentNode[V any] struct {
label string
value *V
subtree subtree[V]
}
func makeSegment[V any](pattern []string, value V) segmentNode[V] {
node := segmentNode[V]{label: pattern[0]}
if len(pattern) == 1 {
node.value = &value
} else {
node.subtree.Add(pattern[1:], value)
}
return node
}
func (sn segmentNode[V]) Match(segments []string, m *match[V]) *match[V] {
var l int
if m != nil {
l = m.length
}
m = &match[V]{
value: sn.value,
length: l + len(sn.label),
prev: m,
}
if len(segments) == 1 {
return m
}
return sn.subtree.Match(segments[1:], m)
}
func (sn *segmentNode[V]) Add(pattern []string, value V) {
if len(pattern) == 0 {
if sn.value != nil {
panic("pattern already exists")
}
sn.value = &value
return
}
sn.subtree.Add(pattern, value)
}
// single-segment param-capturing wildcard ("/:username/")
type wildcardNode[V any] struct {
param string
value *V
subtree subtree[V]
}
func makeWildcard[V any](pattern []string, value V) wildcardNode[V] {
node := wildcardNode[V]{param: pattern[0][1:]}
if len(pattern) == 1 {
node.value = &value
} else {
node.subtree.Add(pattern[1:], value)
}
return node
}
func (wn wildcardNode[V]) Match(segments []string, m *match[V]) *match[V] {
var l int
if m != nil {
l = m.length
}
m = &match[V]{
value: wn.value,
length: l + len(segments[0]),
prev: m,
}
if wn.param != "" {
m.paramkey = wn.param
m.paramval = segments[0]
}
if len(segments) == 1 {
return m
}
return wn.subtree.Match(segments[1:], m)
}
func (wn *wildcardNode[V]) Add(pattern []string, value V) {
if len(pattern) == 0 {
if wn.value != nil {
panic("pattern already exists")
}
wn.value = &value
return
}
wn.subtree.Add(pattern, value)
}
// "rest of the path" capturing node ("/*path_info") - always a final node
type remainderNode[V any] struct {
param string
value V
}
func (rn remainderNode[V]) Match(segments []string, m *match[V]) *match[V] {
m = &match[V]{
value: &rn.value,
length: m.length,
prev: m,
}
if rn.param != "" {
m.paramkey = rn.param
m.paramval = strings.Join(segments, "/")
}
return m
}
// all the children under a tree node
type subtree[V any] struct {
segments childSegments[V]
wildcards childWildcards[V]
remainder *remainderNode[V]
}
func (st subtree[V]) Match(segments []string, m *match[V]) *match[V] {
var best *match[V]
if st.remainder != nil {
best = st.remainder.Match(segments, m)
}
for _, wc := range st.wildcards {
candidate := wc.Match(segments, m)
if best == nil || candidate.length > best.length {
best = candidate
}
}
childSeg := st.segments.Find(segments[0])
if childSeg == nil {
return best
}
candidate := childSeg.Match(segments, m)
if best == nil || (candidate != nil && candidate.length >= best.length) {
best = candidate
}
return best
}
func (st *subtree[V]) Add(pattern []string, value V) {
if len(pattern[0]) == 0 {
panic("invalid pattern")
}
switch pattern[0][0] {
case '*':
if len(pattern) > 1 {
panic("invalid pattern: segments after *remainder")
}
if st.remainder != nil {
panic("pattern already exists")
}
st.remainder = &remainderNode[V]{
param: pattern[0][1:],
value: value,
}
case ':':
child := st.wildcards.Find(pattern[0][1:])
if child != nil {
child.Add(pattern[1:], value)
} else {
st.wildcards = append(st.wildcards, makeWildcard(pattern, value))
sort.Sort(st.wildcards)
}
default:
child := st.segments.Find(pattern[0])
if child != nil {
child.Add(pattern[1:], value)
} else {
st.segments = append(st.segments, makeSegment(pattern, value))
sort.Sort(st.segments)
}
}
}
func (st subtree[V]) routes() []Route[V] {
routes := []Route[V]{}
for _, seg := range st.segments {
if seg.value != nil {
routes = append(routes, Route[V]{
Pattern: seg.label,
Value: *seg.value,
})
}
for _, r := range seg.subtree.routes() {
r.Pattern = seg.label + "/" + r.Pattern
routes = append(routes, r)
}
}
for _, wc := range st.wildcards {
if wc.value != nil {
routes = append(routes, Route[V]{
Pattern: ":" + wc.param,
Value: *wc.value,
})
}
for _, r := range wc.subtree.routes() {
r.Pattern = ":" + wc.param + "/" + r.Pattern
routes = append(routes, r)
}
}
if st.remainder != nil {
rn := *st.remainder
routes = append(routes, Route[V]{
Pattern: "*" + rn.param,
Value: rn.value,
})
}
return routes
}
type childSegments[V any] []segmentNode[V]
func (cs childSegments[V]) Len() int { return len(cs) }
func (cs childSegments[V]) Less(i, j int) bool { return cs[i].label < cs[j].label }
func (cs childSegments[V]) Swap(i, j int) { cs[i], cs[j] = cs[j], cs[i] }
func (cs childSegments[V]) Find(label string) *segmentNode[V] {
idx := sort.Search(len(cs), func(i int) bool {
return label <= cs[i].label
})
if idx < len(cs) && cs[idx].label == label {
return &cs[idx]
}
return nil
}
type childWildcards[V any] []wildcardNode[V]
func (cw childWildcards[V]) Len() int { return len(cw) }
func (cw childWildcards[V]) Less(i, j int) bool { return cw[i].param < cw[j].param }
func (cw childWildcards[V]) Swap(i, j int) { cw[i], cw[j] = cw[j], cw[i] }
func (cw childWildcards[V]) Find(param string) *wildcardNode[V] {
i := sort.Search(len(cw), func(i int) bool {
return param <= cw[i].param
})
if i < len(cw) && cw[i].param == param {
return &cw[i]
}
return nil
}
// linked list we build up as we match our way through the path segments
// - if there is a parameter captured it goes in paramkey/paramval
// - if there was a value it goes in value
// - also store cumulative length to choose the longest match
type match[V any] struct {
paramkey string
paramval string
value *V
length int
prev *match[V]
}
func (m match[_]) params() map[string]string {
mch := &m
out := make(map[string]string)
for mch != nil {
if mch.paramkey != "" {
out[mch.paramkey] = mch.paramval
}
mch = mch.prev
}
return out
}

View File

@ -1,103 +0,0 @@
package internal_test
import (
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus/internal"
)
func TestPathTree(t *testing.T) {
type pattern struct {
string
int
}
type matchresult struct {
value int
params map[string]string
failed bool
}
tests := []struct {
// path-matching pattern and the integers the tree stores for each
patterns []pattern
// paths to match against, and the int we get and captured params
paths map[string]matchresult
}{
{
patterns: []pattern{
{"/a", 1},
{"/a/*rest", 2},
{"/a/b", 3},
{"/c", 4},
{"/x/:y/z/*rest", 5},
},
paths: map[string]matchresult{
"/a": {
value: 1,
params: map[string]string{},
},
"/a/other": {
value: 2,
params: map[string]string{"rest": "other"},
},
"/a/b": {
value: 3,
params: map[string]string{},
},
"/a/b/c": {
value: 2,
params: map[string]string{"rest": "b/c"},
},
"/c": {
value: 4,
params: map[string]string{},
},
"/c/d": {
failed: true,
},
"/x/foo/z/bar/baz": {
value: 5,
params: map[string]string{"y": "foo", "rest": "bar/baz"},
},
"/": {
failed: true,
},
},
},
{
patterns: []pattern{
{"/", 10},
},
paths: map[string]matchresult{
"/": {value: 10, params: map[string]string{}},
"/foo": {failed: true},
},
},
}
for i, test := range tests {
t.Run(strconv.Itoa(i+1), func(t *testing.T) {
tree := &internal.PathTree[int]{}
for _, pattern := range test.patterns {
tree.Add(pattern.string, pattern.int)
}
for path, result := range test.paths {
t.Run(path, func(t *testing.T) {
n, params := tree.Match(path)
if result.failed {
require.Nil(t, n)
} else {
require.NotNil(t, n)
assert.Equal(t, result.value, *n)
assert.Equal(t, result.params, params)
}
})
}
})
}
}

View File

@ -1,128 +0,0 @@
package internal
import (
"context"
"net"
"sync"
)
type logger interface {
Log(keyvals ...any) error
}
type Server struct {
Ctx context.Context
Cancel context.CancelFunc
Wg *sync.WaitGroup
Listener net.Listener
HandleConn connHandler
ErrorLog logger
Host string
NetworkAddr net.Addr
}
type connHandler func(net.Conn)
func NewServer(
ctx context.Context,
hostname string,
network string,
address string,
errorLog logger,
handleConn connHandler,
) (Server, error) {
listener, err := net.Listen(network, address)
if err != nil {
return Server{}, err
}
networkAddr := listener.Addr()
ctx, cancel := context.WithCancel(ctx)
return Server{
Ctx: ctx,
Cancel: cancel,
Wg: &sync.WaitGroup{},
Listener: listener,
HandleConn: handleConn,
ErrorLog: errorLog,
Host: hostname,
NetworkAddr: networkAddr,
}, nil
}
func (s *Server) Serve() error {
s.Wg.Add(1)
defer s.Wg.Done()
s.propagateClose()
for {
conn, err := s.Listener.Accept()
if err != nil {
if s.Closed() {
err = nil
} else {
_ = s.ErrorLog.Log("msg", "accept error", "error", err)
}
return err
}
s.Wg.Add(1)
go func() {
defer s.Wg.Done()
defer func() {
_ = conn.Close()
}()
s.HandleConn(conn)
}()
}
}
func (s *Server) Hostname() string {
host, _, _ := net.SplitHostPort(s.Host)
return host
}
func (s *Server) Port() string {
_, port, _ := net.SplitHostPort(s.Host)
return port
}
func (s *Server) Network() string {
return s.NetworkAddr.Network()
}
func (s *Server) Address() string {
return s.NetworkAddr.String()
}
func (s *Server) Close() {
s.Cancel()
s.Wg.Wait()
}
func (s *Server) LogError(keyvals ...any) error {
return s.ErrorLog.Log(keyvals...)
}
func (s *Server) Closed() bool {
select {
case <-s.Ctx.Done():
return true
default:
return false
}
}
func (s *Server) propagateClose() {
s.Wg.Add(1)
go func() {
defer s.Wg.Done()
<-s.Ctx.Done()
_ = s.Listener.Close()
}()
}

View File

@ -1,43 +0,0 @@
package logging
import (
"os"
"github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/go-kit/log/term"
)
// Logger records log lines to an output.
type Logger interface {
Log(keyvals ...any) error
}
// DefaultLoggers produces helpful base loggers for each level.
//
// They write logfmt to standard out, annotated with ANSI colors depending on the level.
func DefaultLoggers() (debug, info, warn, error Logger) {
base := term.NewLogger(os.Stdout, log.NewLogfmtLogger, func(keyvals ...any) term.FgBgColor {
for i := 0; i < len(keyvals)-1; i += 2 {
if keyvals[i] != "level" {
continue
}
switch keyvals[i+1] {
case level.DebugValue():
return term.FgBgColor{Fg: term.DarkGray}
case level.InfoValue():
return term.FgBgColor{Fg: term.Green}
case level.WarnValue():
return term.FgBgColor{Fg: term.Yellow}
case level.ErrorValue():
return term.FgBgColor{Fg: term.Red}
}
}
return term.FgBgColor{}
})
base = log.NewSyncLogger(base)
return level.Debug(base), level.Info(base), level.Warn(base), level.Error(base)
}

View File

@ -1,99 +0,0 @@
package logging
import (
"context"
"errors"
"io"
"time"
"tildegit.org/tjp/gus"
)
func LogRequests(logger Logger) gus.Middleware {
return func(inner gus.Handler) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, request *gus.Request) *gus.Response {
response := inner.Handle(ctx, request)
if response != nil {
response.Body = loggingBody(logger, request, response)
}
return response
})
}
}
type loggedResponseBody struct {
request *gus.Request
response *gus.Response
body io.Reader
start time.Time
written int
logger Logger
}
func (lr *loggedResponseBody) log() {
end := time.Now()
_ = lr.logger.Log(
"msg", "request",
"ts", end.UTC(),
"dur", end.Sub(lr.start),
"url", lr.request.URL,
"status", lr.response.Status,
"bodylen", lr.written,
)
}
func (lr *loggedResponseBody) Read(b []byte) (int, error) {
if lr.body == nil {
lr.log()
return 0, io.EOF
}
wr, err := lr.body.Read(b)
lr.written += wr
if errors.Is(err, io.EOF) {
lr.log()
}
return wr, err
}
func (lr *loggedResponseBody) Close() error {
if cl, ok := lr.body.(io.Closer); ok {
return cl.Close()
}
return nil
}
type loggedWriteToResponseBody struct {
*loggedResponseBody
}
func (lwtr loggedWriteToResponseBody) WriteTo(dst io.Writer) (int64, error) {
n, err := lwtr.body.(io.WriterTo).WriteTo(dst)
if err == nil {
lwtr.written += int(n)
lwtr.log()
}
return n, err
}
func loggingBody(logger Logger, request *gus.Request, response *gus.Response) io.Reader {
body := &loggedResponseBody{
request: request,
response: response,
body: response.Body,
start: time.Now(),
written: 0,
logger: logger,
}
if _, ok := response.Body.(io.WriterTo); ok {
return loggedWriteToResponseBody{body}
}
return body
}

View File

@ -1,47 +0,0 @@
package logging_test
import (
"context"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/logging"
)
func TestLogRequests(t *testing.T) {
logger := logRecorder{}
handler := logging.LogRequests(&logger)(gus.HandlerFunc(func(_ context.Context, _ *gus.Request) *gus.Response {
return &gus.Response{}
}))
response := handler.Handle(context.Background(), &gus.Request{})
_, err := io.ReadAll(response.Body)
assert.Nil(t, err)
require.Equal(t, 1, len(logger))
keyvals := map[string]any{}
for i := 0; i < len(logger[0])-1; i += 2 {
keyvals[logger[0][i].(string)] = logger[0][i+1]
}
if assert.Contains(t, keyvals, "msg") {
assert.Equal(t, keyvals["msg"], "request")
}
assert.Contains(t, keyvals, "ts")
assert.Contains(t, keyvals, "dur")
assert.Contains(t, keyvals, "url")
assert.Contains(t, keyvals, "status")
assert.Contains(t, keyvals, "bodylen")
}
type logRecorder [][]any
func (lr *logRecorder) Log(keyvals ...any) error {
*lr = append(*lr, keyvals)
return nil
}

View File

@ -1,43 +0,0 @@
package gus
import (
"crypto/tls"
"net"
"net/url"
)
// Request represents a request over any small web protocol.
//
// Because protocols have so many differences, this type represents a
// greatest common denominator of request/response-oriented protocols.
type Request struct {
// URL is the specific URL being fetched by the request.
*url.URL
// Server is the server which received the request.
//
// This is only populated in servers.
// It is unused on the client end.
Server Server
// RemoteAddr is the address of the other side of the connection.
//
// This will be the server address for clients, or the connecting
// client's address in servers.
//
// Be aware though that proxies (and reverse proxies) can confuse this.
RemoteAddr net.Addr
// TLSState contains information about the TLS encryption over the connection.
//
// This includes peer certificates and version information.
TLSState *tls.ConnectionState
}
// UnescapedQuery performs %XX unescaping on the URL query segment.
//
// Like URL.Query(), it silently drops malformed %-encoded sequences.
func (req Request) UnescapedQuery() string {
unescaped, _ := url.QueryUnescape(req.RawQuery)
return unescaped
}

View File

@ -1,24 +0,0 @@
package gus_test
import (
"net/url"
"testing"
"tildegit.org/tjp/gus"
)
func TestUnescapedQuery(t *testing.T) {
table := []string{
"foo bar",
}
for _, test := range table {
t.Run(test, func(t *testing.T) {
u, _ := url.Parse("gemini://domain.com/path?" + url.QueryEscape(test))
result := gus.Request{URL: u}.UnescapedQuery()
if result != test {
t.Errorf("expected %q, got %q", test, result)
}
})
}
}

View File

@ -1,35 +0,0 @@
package gus
import "io"
// Status is the integer status code of a response.
type Status int
// Response contains the data in a response over the small web.
//
// Because protocols have so many differences, this type represents a
// greatest common denominator of request/response-oriented protocols.
type Response struct {
// Status is the status code of the response.
Status Status
// Meta contains status-specific additional information.
Meta any
// Body is the response body, if any.
Body io.Reader
}
func (response *Response) Close() error {
if cl, ok := response.Body.(io.Closer); ok {
return cl.Close()
}
return nil
}
// ResponseReader is an object which can serialize a response to a protocol.
type ResponseReader interface {
io.Reader
io.WriterTo
io.Closer
}

109
router.go
View File

@ -1,109 +0,0 @@
package gus
import (
"context"
"strings"
"tildegit.org/tjp/gus/internal"
)
// Router stores a mapping of request path patterns to handlers.
//
// Pattern may begin with "/" and then contain slash-delimited segments.
// - Segments beginning with colon (:) are wildcards and will match any path
// segment at that location. It may optionally have a word after the colon,
// which will be the parameter name the path segment is captured into.
// - Segments beginning with asterisk (*) are remainder wildcards. This must
// come last and will capture any remainder of the path. It may have a name
// after the asterisk which will be the parameter name.
// - Any other segment in the pattern must match a path segment exactly.
//
// These patterns do not match any path which shares a prefix, rather then
// full path must match a pattern. If you want to only match a prefix of the
// path you can end the pattern with a *remainder segment.
//
// The zero value is a usable Router which will fail to match any requst path.
type Router struct {
tree internal.PathTree[Handler]
middleware []Middleware
routeAdded bool
}
// Route adds a handler to the router under a path pattern.
func (r *Router) Route(pattern string, handler Handler) {
for i := len(r.middleware) - 1; i >= 0; i-- {
handler = r.middleware[i](handler)
}
r.tree.Add(pattern, handler)
r.routeAdded = true
}
// Handler matches against the request path and dipatches to a route handler.
//
// If no route matches, it returns a nil response.
// Captured path parameters will be stored in the context passed into the handler
// and can be retrieved with RouteParams().
func (r Router) Handler(ctx context.Context, request *Request) *Response {
handler, params := r.Match(request)
if handler == nil {
return nil
}
return handler.Handle(context.WithValue(ctx, routeParamsKey, params), request)
}
// Match returns the matched handler and captured path parameters, or (nil, nil).
//
// The returned handlers will be wrapped with any middleware attached to the router.
func (r Router) Match(request *Request) (Handler, map[string]string) {
handler, params := r.tree.Match(request.Path)
if handler == nil {
return nil, nil
}
return *handler, params
}
// Mount attaches a sub-router to handle path suffixes after an initial prefix pattern.
//
// The prefix pattern may include segment :wildcards, but no *remainder segment. The
// mounted sub-router should have patterns which only include the portion of the path
// after whatever was matched by the prefix pattern.
func (r *Router) Mount(prefix string, subrouter *Router) {
prefix = strings.TrimSuffix(prefix, "/")
for _, subroute := range subrouter.tree.Routes() {
r.Route(prefix+"/"+subroute.Pattern, subroute.Value)
}
}
// Use attaches a middleware to the router.
//
// Any routes set on the router will have their handlers decorated by the attached
// middlewares in reverse order (the first middleware attached will be the outer-most:
// first to see requests and the last to see responses).
//
// Use will panic if Route or Mount have already been called on the router -
// middlewares must be set before any routes.
func (r *Router) Use(mw Middleware) {
if r.routeAdded {
panic("all middlewares must be added prior to adding routes")
}
r.middleware = append(r.middleware, mw)
}
// RouteParams gathers captured path parameters from the request context.
//
// If the context doesn't contain a parameter map, it returns nil.
// If Router was used but no parameters were captured in the pattern, it
// returns a non-nil empty map.
func RouteParams(ctx context.Context) map[string]string {
if m, ok := ctx.Value(routeParamsKey).(map[string]string); ok {
return m
}
return nil
}
type routeParamsKeyType struct{}
var routeParamsKey = routeParamsKeyType{}

View File

@ -1,80 +0,0 @@
package gus_test
import (
"bytes"
"context"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
)
var h1 = gus.HandlerFunc(func(_ context.Context, _ *gus.Request) *gus.Response {
return gemini.Success("", &bytes.Buffer{})
})
func mw1(h gus.Handler) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, req *gus.Request) *gus.Response {
resp := h.Handle(ctx, req)
resp.Body = io.MultiReader(resp.Body, bytes.NewBufferString("\nmiddleware 1"))
return resp
})
}
func mw2(h gus.Handler) gus.Handler {
return gus.HandlerFunc(func(ctx context.Context, req *gus.Request) *gus.Response {
resp := h.Handle(ctx, req)
resp.Body = io.MultiReader(resp.Body, bytes.NewBufferString("\nmiddleware 2"))
return resp
})
}
func TestRouterUse(t *testing.T) {
r := &gus.Router{}
r.Use(mw1)
r.Use(mw2)
r.Route("/", h1)
request, err := gemini.ParseRequest(bytes.NewBufferString("/\r\n"))
require.Nil(t, err)
response := r.Handler(context.Background(), request)
require.NotNil(t, response)
body, err := io.ReadAll(response.Body)
require.Nil(t, err)
assert.Equal(t, "\nmiddleware 2\nmiddleware 1", string(body))
}
func TestRouterMount(t *testing.T) {
outer := &gus.Router{}
outer.Use(mw2)
inner := &gus.Router{}
inner.Use(mw1)
inner.Route("/bar", h1)
outer.Mount("/foo", inner)
request, err := gemini.ParseRequest(bytes.NewBufferString("/foo/bar\r\n"))
require.Nil(t, err)
response := outer.Handler(context.Background(), request)
require.NotNil(t, response)
body, err := io.ReadAll(response.Body)
require.Nil(t, err)
assert.Equal(t, "\nmiddleware 1\nmiddleware 2", string(body))
request, err = gemini.ParseRequest(bytes.NewBufferString("/foo\r\n"))
require.Nil(t, err)
response = outer.Handler(context.Background(), request)
require.Nil(t, response)
}

View File

@ -1,42 +0,0 @@
package gus
// Server is a type which can serve a protocol.
type Server interface {
// Serve blocks listening for connections on an interface.
//
// It will only return after Close() has been called.
Serve() error
// Close initiates a graceful shutdown of the server.
//
// It blocks until all resources have been cleaned up and all
// outstanding requests have been handled and responses sent.
Close()
// Closed indicates whether Close has been called.
//
// It may be true even if the graceful shutdown procedure
// hasn't yet completed.
Closed() bool
// Protocol returns the protocol being served by the server.
Protocol() string
// Network returns the network type on which the server is running.
Network() string
// Address returns the address on which the server is listening.
Address() string
// Hostname returns just the hostname portion of the listen address.
Hostname() string
// Port returns the port on which the server is listening.
//
// It will return the empty string if the network type does not
// have ports (unix sockets, for example).
Port() string
// LogError sends a log message to the server's error log.
LogError(keyvals ...any) error
}