Compare commits
24 Commits
Author | SHA1 | Date |
---|---|---|
Travis J Parker | 2ead35d8a4 | |
tjpcc | aa6bdb0649 | |
tjpcc | 46ad450327 | |
tjpcc | bc96af40db | |
tjpcc | fcf545c27c | |
tjpcc | 18d69173b4 | |
tjpcc | ac024567e8 | |
tjpcc | b7cb13b4e6 | |
tjpcc | a4ef387eeb | |
tjpcc | 4f6f3dcd4b | |
tjpcc | 9cbc5cdd46 | |
tjpcc | 04977e56b1 | |
tjpcc | 23fd67c25a | |
tjpcc | 84d8e515be | |
tjpcc | 66a1b1f39a | |
tjpcc | a27b879acc | |
tjpcc | 32e45e3557 | |
tjpcc | 997514292a | |
tjpcc | 23d705b93a | |
tjpcc | 0480e066a3 | |
tjpcc | df57a12539 | |
tjpcc | 8229f31f70 | |
tjpcc | a1c186878d | |
tjpcc | e1aa19f1e8 |
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
kind: pipeline
|
||||
name: verify
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: golang
|
||||
commands:
|
||||
- go test -v ./...
|
|
@ -1 +0,0 @@
|
|||
coverage.out
|
123
README.gmi
123
README.gmi
|
@ -1,123 +0,0 @@
|
|||
# Gus: The small web server toolkit
|
||||
|
||||
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 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: filtering, falling through a list of handlers
|
||||
|
||||
## gus/gemini
|
||||
|
||||
Gus is determined to be structured as composable building blocks, and the gemini package mainly just defines the structure that holds the blocks together.
|
||||
|
||||
The gemini package provides some gemini-specific concrete implementations.
|
||||
* I/O (parsing, formatting) request and responses for the gemini protocol
|
||||
* constructors for the various kinds of gemini protocol responses
|
||||
* a helper for building a gemini-suitable TLS config
|
||||
* a Client implementation
|
||||
* a Server which can run your Handlers.
|
||||
|
||||
## gus/gemini/gemtext
|
||||
|
||||
The gemtext package provides a parser and converters for gemtext documents. It exposes an AST representation for parsed gemtext, and functions to write that AST out as other formats (currently markdown and HTML are supported, but more are planned).
|
||||
|
||||
## 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.
|
||||
|
||||
So far there are at least 3 packages:
|
||||
* log contains a simple request-logging middleware
|
||||
* fs has handlers that make file servers possible: serve files, build directory listings, etc
|
||||
* cgi includes handlers which can execute CGI programs
|
||||
* ...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.
|
||||
|
||||
## 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, and 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;''''''''''',,,,
|
||||
```
|
131
README.md
131
README.md
|
@ -1,130 +1,5 @@
|
|||
# Gus: The small web toolkit now known as sliderule
|
||||
|
||||
# Gus: The small web server toolkit
|
||||
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 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: filtering, falling through a list of handlers
|
||||
|
||||
## gus/gemini
|
||||
|
||||
Gus is determined to be structured as composable building blocks, and the gemini package mainly just defines the structure that holds the blocks together.
|
||||
|
||||
The gemini package provides some gemini-specific concrete implementations.
|
||||
|
||||
* I/O (parsing, formatting) request and responses for the gemini protocol
|
||||
* constructors for the various kinds of gemini protocol responses
|
||||
* a helper for building a gemini-suitable TLS config
|
||||
* a Client implementation
|
||||
* a Server which can run your Handlers.
|
||||
|
||||
## gus/gemini/gemtext
|
||||
|
||||
The gemtext package provides a parser and converters for gemtext documents. It exposes an AST representation for parsed gemtext, and functions to write that AST out as other formats (currently markdown and HTML are supported, but more are planned).
|
||||
|
||||
## 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.
|
||||
|
||||
So far there are at least 3 packages:
|
||||
|
||||
* log contains a simple request-logging middleware
|
||||
* fs has handlers that make file servers possible: serve files, build directory listings, etc
|
||||
* cgi includes handlers which can execute CGI programs
|
||||
* ...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)
|
||||
|
||||
## 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, and 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,193 +0,0 @@
|
|||
package cgi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"tildegit.org/tjp/gus"
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
// CGIDirectory 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 CGIDirectory(pathRoot, fsRoot string) gus.Handler {
|
||||
fsRoot = strings.TrimRight(fsRoot, "/")
|
||||
|
||||
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||
if !strings.HasPrefix(req.Path, pathRoot) {
|
||||
return nil
|
||||
}
|
||||
|
||||
path := req.Path[len(pathRoot):]
|
||||
segments := strings.Split(strings.TrimLeft(path, "/"), "/")
|
||||
for i := range append(segments, "") {
|
||||
path := strings.Join(append([]string{fsRoot}, segments[:i]...), "/")
|
||||
path = strings.TrimRight(path, "/")
|
||||
isDir, isExecutable, err := executableFile(path)
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
if isExecutable {
|
||||
pathInfo := "/"
|
||||
if len(segments) > i+1 {
|
||||
pathInfo = strings.Join(segments[i:], "/")
|
||||
}
|
||||
return RunCGI(ctx, req, path, pathInfo)
|
||||
}
|
||||
|
||||
if !isDir {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func executableFile(path string) (bool, bool, error) {
|
||||
file, err := os.Open(path)
|
||||
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,
|
||||
req *gus.Request,
|
||||
executable string,
|
||||
pathInfo string,
|
||||
) *gus.Response {
|
||||
pathSegments := strings.Split(executable, "/")
|
||||
|
||||
dirPath := "."
|
||||
if len(pathSegments) > 1 {
|
||||
dirPath = strings.Join(pathSegments[:len(pathSegments)-1], "/")
|
||||
}
|
||||
basename := pathSegments[len(pathSegments)-1]
|
||||
|
||||
scriptName := req.Path[:len(req.Path)-len(pathInfo)]
|
||||
if strings.HasSuffix(scriptName, "/") {
|
||||
scriptName = scriptName[:len(scriptName)-1]
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "./"+basename)
|
||||
cmd.Env = prepareCGIEnv(ctx, req, scriptName, pathInfo)
|
||||
cmd.Dir = dirPath
|
||||
|
||||
responseBuffer := &bytes.Buffer{}
|
||||
cmd.Stdout = responseBuffer
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exErr *exec.ExitError
|
||||
if errors.As(err, &exErr) {
|
||||
errMsg := fmt.Sprintf("CGI returned exit code %d", exErr.ExitCode())
|
||||
return gemini.CGIError(errMsg)
|
||||
}
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
response, err := gemini.ParseResponse(responseBuffer)
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func prepareCGIEnv(
|
||||
ctx context.Context,
|
||||
req *gus.Request,
|
||||
scriptName string,
|
||||
pathInfo string,
|
||||
) []string {
|
||||
var authType string
|
||||
if len(req.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=" + req.RawQuery,
|
||||
}
|
||||
|
||||
host, _, _ := net.SplitHostPort(req.RemoteAddr.String())
|
||||
environ = append(environ, "REMOTE_ADDR="+host)
|
||||
|
||||
environ = append(
|
||||
environ,
|
||||
"REMOTE_HOST=",
|
||||
"REMOTE_IDENT=",
|
||||
"SCRIPT_NAME="+scriptName,
|
||||
"SERVER_NAME="+req.Server.Hostname(),
|
||||
"SERVER_PORT="+req.Server.Port(),
|
||||
"SERVER_PROTOCOL=GEMINI",
|
||||
"SERVER_SOFTWARE=GUS",
|
||||
)
|
||||
|
||||
if len(req.TLSState.PeerCertificates) > 0 {
|
||||
cert := req.TLSState.PeerCertificates[0]
|
||||
environ = append(
|
||||
environ,
|
||||
"TLS_CLIENT_HASH="+fingerprint(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,4 +0,0 @@
|
|||
/*
|
||||
Contrib contains sub-packages with specific functionality for small web servers.
|
||||
*/
|
||||
package contrib
|
|
@ -1,175 +0,0 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"tildegit.org/tjp/gus"
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
// DirectoryDefault handles directory path requests by looking for specific filenames.
|
||||
//
|
||||
// If any of the supported filenames are found, the contents of the file is returned
|
||||
// as the gemini response.
|
||||
//
|
||||
// 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 into the directory's contents to function.
|
||||
//
|
||||
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
|
||||
// it will also produce "51 Not Found" responses for directory paths.
|
||||
func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gus.Handler {
|
||||
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||
path, dirFile, resp := handleDir(req, fileSystem)
|
||||
if dirFile == nil {
|
||||
return resp
|
||||
}
|
||||
defer dirFile.Close()
|
||||
|
||||
entries, err := dirFile.ReadDir(0)
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
for _, fileName := range fileNames {
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == fileName {
|
||||
file, err := fileSystem.Open(path + "/" + fileName)
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
return gemini.Success(mediaType(fileName), file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// DirectoryListing produces a gemtext 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 into the directory's contents to function.
|
||||
//
|
||||
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
|
||||
// it will also produce "51 Not Found" responses for directory paths.
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// The template argument may be nil, in which case a simple default template is used.
|
||||
func DirectoryListing(fileSystem fs.FS, template *template.Template) gus.Handler {
|
||||
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||
path, dirFile, resp := handleDir(req, fileSystem)
|
||||
if dirFile == nil {
|
||||
return resp
|
||||
}
|
||||
defer dirFile.Close()
|
||||
|
||||
if template == nil {
|
||||
template = defaultDirListTemplate
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
environ, err := dirlistNamespace(path, dirFile)
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
if err := template.Execute(buf, environ); err != nil {
|
||||
gemini.Failure(err)
|
||||
}
|
||||
|
||||
return gemini.Success("text/gemini", buf)
|
||||
}
|
||||
}
|
||||
|
||||
var defaultDirListTemplate = template.Must(template.New("directory_listing").Parse(`
|
||||
# {{ .DirName }}
|
||||
{{ range .Entries }}
|
||||
=> {{ .Name }}{{ if .IsDir }}/{{ end -}}
|
||||
{{ end }}
|
||||
=> ../
|
||||
`[1:]))
|
||||
|
||||
func dirlistNamespace(path string, dirFile fs.ReadDirFile) (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:]
|
||||
}
|
||||
|
||||
m := map[string]any{
|
||||
"FullPath": path,
|
||||
"DirName": dirname,
|
||||
"Entries": entries,
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func handleDir(req *gus.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gus.Response) {
|
||||
path := strings.Trim(req.Path, "/")
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
|
||||
file, err := fileSystem.Open(path)
|
||||
if isNotFound(err) {
|
||||
return "", nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, gemini.Failure(err)
|
||||
}
|
||||
|
||||
isDir, err := fileIsDir(file)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return "", nil, gemini.Failure(err)
|
||||
}
|
||||
|
||||
if !isDir {
|
||||
file.Close()
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(req.Path, "/") {
|
||||
file.Close()
|
||||
url := *req.URL
|
||||
url.Path += "/"
|
||||
return "", nil, gemini.Redirect(url.String())
|
||||
}
|
||||
|
||||
dirFile, ok := file.(fs.ReadDirFile)
|
||||
if !ok {
|
||||
file.Close()
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
return path, dirFile, nil
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"strings"
|
||||
|
||||
"tildegit.org/tjp/gus"
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
// FileHandler builds a handler function which serves up a file system.
|
||||
func FileHandler(fileSystem fs.FS) gus.Handler {
|
||||
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||
file, err := fileSystem.Open(strings.TrimPrefix(req.Path, "/"))
|
||||
if isNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
isDir, err := fileIsDir(file)
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
if isDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gemini.Success(mediaType(req.Path), file)
|
||||
}
|
||||
}
|
||||
|
||||
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+dotIdx:]
|
||||
|
||||
mtype := mime.TypeByExtension(ext)
|
||||
if mtype == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return mtype
|
||||
}
|
|
@ -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,35 +0,0 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
kitlog "github.com/go-kit/log"
|
||||
|
||||
"tildegit.org/tjp/gus"
|
||||
)
|
||||
|
||||
func Requests(out io.Writer, logger kitlog.Logger) gus.Middleware {
|
||||
if logger == nil {
|
||||
logger = kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(out))
|
||||
}
|
||||
|
||||
return func(next gus.Handler) gus.Handler {
|
||||
return func(ctx context.Context, r *gus.Request) (resp *gus.Response) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
end := time.Now()
|
||||
logger.Log(
|
||||
"msg", "request",
|
||||
"ts", end,
|
||||
"dur", end.Sub(start),
|
||||
"url", r.URL,
|
||||
"status", resp.Status,
|
||||
)
|
||||
}()
|
||||
|
||||
return next(ctx, r)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,59 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"tildegit.org/tjp/gus/contrib/cgi"
|
||||
guslog "tildegit.org/tjp/gus/contrib/log"
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
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.CGIDirectory("/cgi-bin", "./cgi-bin")
|
||||
|
||||
// add stdout logging to the request handler
|
||||
handler := guslog.Requests(os.Stdout, nil)(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, tlsconf, "tcp4", ":1965", handler)
|
||||
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,97 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"tildegit.org/tjp/gus"
|
||||
guslog "tildegit.org/tjp/gus/contrib/log"
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// add request logging to the request handler
|
||||
handler := guslog.Requests(os.Stdout, nil)(cowsayHandler)
|
||||
|
||||
// run the server
|
||||
server, err := gemini.NewServer(context.Background(), tlsconf, "tcp4", ":1965", handler)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
server.Serve()
|
||||
}
|
||||
|
||||
func cowsayHandler(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,58 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"tildegit.org/tjp/gus"
|
||||
"tildegit.org/tjp/gus/contrib/fs"
|
||||
guslog "tildegit.org/tjp/gus/contrib/log"
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
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.DirectoryDefault(fileSystem, "index.gmi"),
|
||||
// next (still if they requested a directory) build a directory listing response
|
||||
fs.DirectoryListing(fileSystem, nil),
|
||||
// finally, try to find a file at the request path and respond with that
|
||||
fs.FileHandler(fileSystem),
|
||||
)
|
||||
// add request logging to stdout
|
||||
handler = guslog.Requests(os.Stdout, nil)(handler)
|
||||
|
||||
// run the server
|
||||
server, err := gemini.NewServer(context.Background(), tlsconf, "tcp4", ":1965", handler)
|
||||
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,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,93 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"tildegit.org/tjp/gus"
|
||||
guslog "tildegit.org/tjp/gus/contrib/log"
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// add stdout logging to the request handler
|
||||
handler := guslog.Requests(os.Stdout, nil)(inspectHandler)
|
||||
|
||||
// run the server
|
||||
server, err := gemini.NewServer(context.Background(), tlsconf, "tcp4", ":1965", handler)
|
||||
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
|
||||
}
|
||||
|
||||
func inspectHandler(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,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,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,36 +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.
|
||||
func ParseRequest(rdr io.Reader) (*gus.Request, error) {
|
||||
line, err := bufio.NewReader(rdr).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,334 +0,0 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
type ResponseReader interface {
|
||||
io.Reader
|
||||
io.WriterTo
|
||||
io.Closer
|
||||
}
|
||||
|
||||
func NewResponseReader(response *gus.Response) ResponseReader {
|
||||
return &responseReader{ Response: response }
|
||||
}
|
||||
|
||||
type responseReader struct {
|
||||
*gus.Response
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
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() {
|
||||
if rdr.reader != nil {
|
||||
return
|
||||
}
|
||||
|
||||
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,66 +0,0 @@
|
|||
package gemini_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"tildegit.org/tjp/gus"
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
tlsConf, err := gemini.FileTLS("./testdata/server.crt", "./testdata/server.key")
|
||||
if err != nil {
|
||||
t.Fatalf("FileTLS(): %s", err.Error())
|
||||
}
|
||||
|
||||
handler := 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(), tlsConf, "tcp", "127.0.0.1:0", handler)
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer(): %s", err.Error())
|
||||
}
|
||||
|
||||
go server.Serve()
|
||||
defer server.Close()
|
||||
|
||||
u, err := url.Parse(fmt.Sprintf("gemini://%s/test", server.Address()))
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse: %s", err.Error())
|
||||
}
|
||||
|
||||
cli := gemini.NewClient(testClientTLS())
|
||||
response, err := cli.RoundTrip(&gus.Request{URL: u})
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip(): %s", err.Error())
|
||||
}
|
||||
|
||||
if response.Status != gemini.StatusSuccess {
|
||||
t.Errorf("response status: expected %d, got %d", gemini.StatusSuccess, response.Status)
|
||||
}
|
||||
if response.Meta != "text/gemini" {
|
||||
t.Errorf("response meta: expected \"text/gemini\", got %q", response.Meta)
|
||||
}
|
||||
|
||||
if response.Body == nil {
|
||||
t.Fatal("succcess response has nil body")
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %s", err.Error())
|
||||
}
|
||||
if string(body) != "you've found my page" {
|
||||
t.Errorf("response body: expected \"you've found my page\", got %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func testClientTLS() *tls.Config {
|
||||
return &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
147
gemini/serve.go
147
gemini/serve.go
|
@ -1,147 +0,0 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"tildegit.org/tjp/gus"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
ctx context.Context
|
||||
network string
|
||||
address string
|
||||
cancel context.CancelFunc
|
||||
wg *sync.WaitGroup
|
||||
listener net.Listener
|
||||
handler gus.Handler
|
||||
}
|
||||
|
||||
// NewServer builds a gemini server.
|
||||
func NewServer(
|
||||
ctx context.Context,
|
||||
tlsConfig *tls.Config,
|
||||
network string,
|
||||
address string,
|
||||
handler gus.Handler,
|
||||
) (gus.Server, error) {
|
||||
listener, err := net.Listen(network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addr := listener.Addr()
|
||||
|
||||
s := &server{
|
||||
ctx: ctx,
|
||||
network: addr.Network(),
|
||||
address: addr.String(),
|
||||
wg: &sync.WaitGroup{},
|
||||
listener: tls.NewListener(listener, tlsConfig),
|
||||
handler: handler,
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Serve starts the server and blocks until it is closed.
|
||||
//
|
||||
// This function will allocate resources which are not cleaned up until
|
||||
// Close() is called.
|
||||
//
|
||||
// It will respect cancellation of the context the server was created with,
|
||||
// but be aware that Close() must still be called in that case to avoid
|
||||
// dangling goroutines.
|
||||
func (s *server) Serve() error {
|
||||
s.wg.Add(1)
|
||||
defer s.wg.Done()
|
||||
|
||||
s.ctx, s.cancel = context.WithCancel(s.ctx)
|
||||
|
||||
s.wg.Add(1)
|
||||
s.propagateCancel()
|
||||
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
if s.Closed() {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) Close() {
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *server) Network() string {
|
||||
return s.network
|
||||
}
|
||||
|
||||
func (s *server) Address() string {
|
||||
return s.address
|
||||
}
|
||||
|
||||
func (s *server) Hostname() string {
|
||||
host, _, _ := net.SplitHostPort(s.address)
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *server) Port() string {
|
||||
_, portStr, _ := net.SplitHostPort(s.address)
|
||||
return portStr
|
||||
}
|
||||
|
||||
func (s *server) handleConn(conn net.Conn) {
|
||||
defer s.wg.Done()
|
||||
defer conn.Close()
|
||||
|
||||
var response *gus.Response
|
||||
req, err := ParseRequest(conn)
|
||||
if err != nil {
|
||||
response = BadRequest(err.Error())
|
||||
return
|
||||
} else {
|
||||
req.Server = s
|
||||
req.RemoteAddr = conn.RemoteAddr()
|
||||
if tlsconn, ok := conn.(*tls.Conn); req != nil && ok {
|
||||
state := tlsconn.ConnectionState()
|
||||
req.TLSState = &state
|
||||
}
|
||||
|
||||
response = s.handler(s.ctx, req)
|
||||
if response == nil {
|
||||
response = NotFound("Resource does not exist.")
|
||||
}
|
||||
defer response.Close()
|
||||
}
|
||||
|
||||
_, _ = io.Copy(conn, NewResponseReader(response))
|
||||
}
|
||||
|
||||
func (s *server) propagateCancel() {
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
|
||||
<-s.ctx.Done()
|
||||
_ = s.listener.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *server) Closed() bool {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -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,18 +0,0 @@
|
|||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIIC0zCCAbsCAQAwPTESMBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJVUzEa
|
||||
MBgGA1UEBwwRU2FuIEZyYW5jaXNjbywgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
|
||||
DwAwggEKAoIBAQC5Wj2tQMQ0JzKNKkMWOfwX+zDX9cerTcY5KTJ0JzzPnB5ppZBI
|
||||
SNc8C5Py6y8cKu/jjsTEz6yYJawIgwKdbgmR4ynEzBZ8DvTOb3/C47WAY2XfPjkh
|
||||
8mFd+4McO4ff1rMZftWMONEGiSjPWro9sTsmg4+TrvfsqQn2xkqVVE56kXjlRYNk
|
||||
EjcBL4jmbtUrwKLj8jvfFiqF45+yuZsROeOXuJoBdRDIbYweZB+pF6mUlLM5RgNT
|
||||
JF9wJQ7GCSHGJw8HpTb8Ol0z6ZE9sDXocT8g9Ij+K7PluOYYFfMGKJKYKzN8cXtH
|
||||
OJqKeW866dh8s91sZxeysLSYB1v+mMYRu97JAgMBAAGgUTBPBgkqhkiG9w0BCQ4x
|
||||
QjBAMDEGA1UdJQQqMCgGCCsGAQUFBwMBBggrBgEFBQcDAgYIKwYBBQUHAwMGCCsG
|
||||
AQUFBwMEMAsGA1UdDwQEAwIFIDANBgkqhkiG9w0BAQsFAAOCAQEAOKb0Mnnm7oLT
|
||||
0fz7+CQ4KYva/dmr75k38PPRXGs/7Ls6nhu59yNhudHJtRyjaAzffwfg1NWxKlUV
|
||||
gDf+4K6S+cjz6bWVdU4XwH37V01GWWgzmwDGEsoZZpNstuq87BhI62BKQFKqJrw2
|
||||
pqNYoM+p4K7OnOUNT60LshzThguMb4h53YcTXyv7wAf9LABc4v0daVErunDZ5Elh
|
||||
QwlUZT/pngTLJiXDjrWB3PGnniTbC0OYhKKmFbX/dIR/TlUH7Fcc4mE9f514mU0n
|
||||
zys/mc57gBTdI11oIw1fkQJ6f3LDk3MsFfJntwhxjVeSXJUNOBwsxmxdyigjsifY
|
||||
J+SpNczO1Q==
|
||||
-----END CERTIFICATE REQUEST-----
|
|
@ -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=
|
52
handler.go
52
handler.go
|
@ -1,52 +0,0 @@
|
|||
package gus
|
||||
|
||||
import "context"
|
||||
|
||||
// Handler is a function which can turn a request into a response.
|
||||
//
|
||||
// A Handler can return a nil response, in which case the Server is expected
|
||||
// to build the protocol-appropriate "Not Found" response.
|
||||
type Handler func(context.Context, *Request) *Response
|
||||
|
||||
// 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 func(ctx context.Context, request *Request) *Response {
|
||||
for _, handler := range handlers {
|
||||
if response := handler(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 func(ctx context.Context, request *Request) *Response {
|
||||
if condition(ctx, request) {
|
||||
return success(ctx, request)
|
||||
}
|
||||
if failure == nil {
|
||||
return nil
|
||||
}
|
||||
return failure(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 := func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||
if req.Path == "/one" {
|
||||
return gemini.Success("text/gemini", bytes.NewBufferString("one"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
h2 := 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(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(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(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 := 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(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(context.Background(), &gus.Request{URL: u})
|
||||
if resp != nil {
|
||||
t.Errorf("expected nil, got %+v", resp)
|
||||
}
|
||||
}
|
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
28
response.go
28
response.go
|
@ -1,28 +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
|
||||
}
|
36
server.go
36
server.go
|
@ -1,36 +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
|
||||
|
||||
// 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
|
||||
}
|
Reference in New Issue