Compare commits

...

24 Commits
v0.5.0 ... main

Author SHA1 Message Date
Travis J Parker 2ead35d8a4 Remove the bulk of the project.
Leave a README with a pointer to sliderule, and the LICENSE file.

Git history is still accessible here.
2023-05-01 11:42:16 -06:00
tjpcc aa6bdb0649 include a CSS class in htmlconv output 2023-04-29 13:45:38 -06:00
tjpcc 46ad450327 Switch Handler to an interface.
continuous-integration/drone/push Build is passing Details
HandlerFunc is much better as a function returning a Handler, rather
than a newtype for the function type itself. This way there is no
confusion creating a type-inferenced variable with HandlerFunc(func(...
and then using a HandlerFunc where a Handler is expected. Much better to
only have one public type.
2023-02-15 16:44:29 -07:00
tjpcc bc96af40db no need to combine sub-router params any more
continuous-integration/drone/push Build is passing Details
2023-02-14 20:18:08 -07:00
tjpcc fcf545c27c Router improvements.
continuous-integration/drone/push Build is passing Details
- test coverage for Router, not just PathTree
- Router.Mount() now flattens routes into the parent router
- Router.Use() implemented to set middleware on a router itself
2023-02-14 20:13:25 -07:00
tjpcc 18d69173b4 Mount() adds a subrouter under a prefix pattern.
continuous-integration/drone/push Build is passing Details
...though I'm already eyeing a better approach.
2023-02-02 17:11:27 -07:00
tjpcc ac024567e8 Initial Router work.
continuous-integration/drone/push Build is passing Details
- Router type, supports: adding handlers, serving, fetching the matching
  handler for a route.
- Private PathTree type handles the modified radix trie.
2023-02-02 16:15:53 -07:00
tjpcc b7cb13b4e6 include gemini scheme in a URL
continuous-integration/drone/push Build is passing Details
2023-01-30 15:10:54 -07:00
tjpcc a4ef387eeb mention release signing and the key used
continuous-integration/drone/push Build is passing Details
2023-01-30 15:09:58 -07:00
tjpcc 4f6f3dcd4b finger protocol
continuous-integration/drone/push Build is passing Details
2023-01-30 11:36:48 -07:00
tjpcc 9cbc5cdd46 fix tests for #15 change
continuous-integration/drone/push Build is passing Details
2023-01-28 16:47:23 -07:00
tjpcc 04977e56b1 middleware to turn away non-gemini requests. fixes #3.
continuous-integration/drone/push Build is failing Details
2023-01-28 15:36:45 -07:00
tjpcc 23fd67c25a permanently redirect from /dir to /dir/ on gemini. fixes #15
continuous-integration/drone/push Build is failing Details
2023-01-28 15:30:09 -07:00
tjpcc 84d8e515be README updates
continuous-integration/drone/push Build is passing Details
2023-01-28 15:29:44 -07:00
tjpcc 66a1b1f39a gopher support.
continuous-integration/drone/push Build is passing Details
Some of the contrib packages were originally built gemini-specific and
had to be refactored into generic core functionality and thin
protocol-specific wrappers for each of gemini and gopher.
2023-01-28 15:01:41 -07:00
tjpcc a27b879acc test coverage and resulting bugfixes
continuous-integration/drone/push Build is passing Details
2023-01-26 16:22:58 -07:00
tjpcc 32e45e3557 test coverage for the cgi contrib
continuous-integration/drone/push Build is passing Details
2023-01-25 08:35:56 -07:00
tjpcc 997514292a testing and linting and linter fixes
continuous-integration/drone/push Build is passing Details
2023-01-24 19:59:47 -07:00
tjpcc 23d705b93a Add support for titan:// to the gemini server
continuous-integration/drone/push Build is passing Details
Titan is a gemini add-on protocol so it really didn't make sense to
build it out in a separate package. The most significant difference in
titan for the purposes of implementation here is that requests can have
bodies following the URL line.

Since gus.Request is a struct, the only way to smuggle in the new field
(a reader for the body) was to stash it in the context.
2023-01-24 07:36:28 -07:00
tjpcc 0480e066a3 logging library up to top level
continuous-integration/drone/push Build is passing Details
2023-01-23 22:15:16 -07:00
tjpcc df57a12539 update READMEs for contrib/tlsauth
continuous-integration/drone/push Build is passing Details
2023-01-20 11:26:23 -07:00
tjpcc 8229f31f70 "tlsauth" contrib package
continuous-integration/drone/push Build is passing Details
This package adds authentication middlewares via TLS client
certificates.
2023-01-20 10:58:35 -07:00
tjpcc a1c186878d minor marketing update
continuous-integration/drone/push Build is passing Details
2023-01-19 16:08:17 -07:00
tjpcc e1aa19f1e8 New sharedhost contrib package.
continuous-integration/drone/push Build is passing Details
ReplaceTilde simply replaces a leading ~ in the URL.

Fixes #9.
2023-01-19 16:03:52 -07:00
51 changed files with 3 additions and 3899 deletions

View File

@ -1,9 +0,0 @@
---
kind: pipeline
name: verify
steps:
- name: test
image: golang
commands:
- go test -v ./...

1
.gitignore vendored
View File

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

View File

@ -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
View File

@ -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.

View File

@ -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[:])
}

View File

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

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -1,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)
}
}
}

View File

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

View File

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

View File

@ -1,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
}

View File

@ -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
}

View File

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

View File

@ -1,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
}

View File

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

View File

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

View File

@ -1,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
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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 }}
`))

View File

@ -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())
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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
}

View File

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

View File

@ -1,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
}

View File

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

View File

@ -1,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}
}

View File

@ -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
}
}

View File

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

View File

@ -1,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-----

View File

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

View File

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

15
go.mod
View File

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

21
go.sum
View File

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

View File

@ -1,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)
}
}
}

View File

@ -1,118 +0,0 @@
package gus_test
import (
"bytes"
"context"
"io"
"net/url"
"strings"
"testing"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
)
func TestFallthrough(t *testing.T) {
h1 := 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)
}
}

View File

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

View File

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

View File

@ -1,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
}

View File

@ -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
}