Compare commits
2 Commits
Author | SHA1 | Date |
---|---|---|
Travis J Parker | 2ead35d8a4 | |
tjpcc | aa6bdb0649 |
11
.drone.yml
11
.drone.yml
|
@ -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 +0,0 @@
|
|||
coverage.out
|
149
README.gmi
149
README.gmi
|
@ -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
158
README.md
|
@ -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.
|
||||
|
|
|
@ -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[:])
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
exit 4
|
|
@ -1,3 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
printf "20 text/gemini\r\nhello, world!\n"
|
|
@ -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-----
|
|
@ -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-----
|
|
@ -1,4 +0,0 @@
|
|||
/*
|
||||
Contrib contains sub-packages with specific functionality for small web servers.
|
||||
*/
|
||||
package contrib
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
this is file b
|
|
@ -1 +0,0 @@
|
|||
# This is d
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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 }
|
|
@ -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])
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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.")
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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-----
|
|
@ -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-----
|
|
@ -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-----
|
|
@ -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-----
|
|
@ -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-----
|
|
@ -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-----
|
|
@ -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"
|
|
@ -1,8 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
printf "20 text/gemini\r\n"
|
||||
echo "\`\`\`env(1) output"
|
||||
env
|
||||
echo "\`\`\`"
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,88 +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" }}<html><body>{{ end }}
|
||||
{{ define "textline" }}{{ if ne .String "\n" }}<p>{{ . }}</p>{{ end }}{{ end }}
|
||||
{{ define "linkline" -}}
|
||||
<p>=> <a href="{{ .ValidatedURL }}">{{ if eq .Label "" -}}
|
||||
{{ .URL }}
|
||||
{{- else -}}
|
||||
{{ .Label }}
|
||||
{{- end -}}
|
||||
</a></p>
|
||||
{{- end }}
|
||||
{{ define "preformattedtextlines" -}}
|
||||
<pre>
|
||||
{{- range . -}}
|
||||
{{ . }}
|
||||
{{- end -}}
|
||||
</pre>
|
||||
{{- end }}
|
||||
{{ define "heading1line" }}<h1>{{ .Body }}</h1>{{ end }}
|
||||
{{ define "heading2line" }}<h2>{{ .Body }}</h2>{{ end }}
|
||||
{{ define "heading3line" }}<h3>{{ .Body }}</h3>{{ end }}
|
||||
{{ define "listitemlines" -}}
|
||||
<ul>
|
||||
{{- range . -}}
|
||||
<li>{{ .Body }}</li>
|
||||
{{- end -}}
|
||||
</ul>
|
||||
{{- end }}
|
||||
{{ define "quoteline" }}<blockquote>{{ .Body }}</blockquote>{{ end }}
|
||||
{{ define "footer" }}</body></html>{{ end }}
|
||||
`))
|
|
@ -1,46 +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 := `
|
||||
<html><body><h1>top-level header line</h1><h2>subtitle</h2><p>This is some non-blank regular text.
|
||||
</p><ul><li>an</li><li>unordered</li><li>list</li></ul><p>=> <a href="gemini://google.com/">as if</a></p><p>=> <a href="https://google.com/">https://google.com/</a></p><blockquote> this is a quote</blockquote><blockquote> -tjp</blockquote><pre>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())
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -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) {
|
||||
}
|
|
@ -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 }}
|
||||
`))
|
|
@ -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())
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"),
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -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) }
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
}
|
138
gemini/serve.go
138
gemini/serve.go
|
@ -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.")
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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-----
|
|
@ -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-----
|
|
@ -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
15
go.mod
|
@ -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
21
go.sum
|
@ -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=
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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))
|
||||
}
|
66
handler.go
66
handler.go
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
118
handler_test.go
118
handler_test.go
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}()
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
43
request.go
43
request.go
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
35
response.go
35
response.go
|
@ -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
109
router.go
|
@ -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{}
|
|
@ -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)
|
||||
}
|
42
server.go
42
server.go
|
@ -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
|
||||
}
|
Reference in New Issue