pull request, response, handlers out of the gemini package
This commit is contained in:
parent
30e21f8513
commit
2ef530daa4
|
@ -13,6 +13,7 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -22,12 +23,12 @@ import (
|
||||||
// a request for /foo/bar/baz can also run an executable found at /foo or /foo/bar. In
|
// 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
|
// such a case the PATH_INFO environment variable will include the remaining portion of
|
||||||
// the URI path.
|
// the URI path.
|
||||||
func CGIDirectory(pathRoot, fsRoot string) gemini.Handler {
|
func CGIDirectory(pathRoot, fsRoot string) gus.Handler {
|
||||||
fsRoot = strings.TrimRight(fsRoot, "/")
|
fsRoot = strings.TrimRight(fsRoot, "/")
|
||||||
|
|
||||||
return func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||||
if !strings.HasPrefix(req.Path, pathRoot) {
|
if !strings.HasPrefix(req.Path, pathRoot) {
|
||||||
return gemini.NotFound("Resource does not exist.")
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
path := req.Path[len(pathRoot):]
|
path := req.Path[len(pathRoot):]
|
||||||
|
@ -53,7 +54,7 @@ func CGIDirectory(pathRoot, fsRoot string) gemini.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return gemini.NotFound("Resource does not exist.")
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -97,10 +98,10 @@ func isNotExistError(err error) bool {
|
||||||
// RunCGI runs a specific program as a CGI script.
|
// RunCGI runs a specific program as a CGI script.
|
||||||
func RunCGI(
|
func RunCGI(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *gemini.Request,
|
req *gus.Request,
|
||||||
executable string,
|
executable string,
|
||||||
pathInfo string,
|
pathInfo string,
|
||||||
) *gemini.Response {
|
) *gus.Response {
|
||||||
pathSegments := strings.Split(executable, "/")
|
pathSegments := strings.Split(executable, "/")
|
||||||
|
|
||||||
dirPath := "."
|
dirPath := "."
|
||||||
|
@ -139,7 +140,7 @@ func RunCGI(
|
||||||
|
|
||||||
func prepareCGIEnv(
|
func prepareCGIEnv(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *gemini.Request,
|
req *gus.Request,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
pathInfo string,
|
pathInfo string,
|
||||||
) []string {
|
) []string {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -24,10 +25,10 @@ import (
|
||||||
//
|
//
|
||||||
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
|
// 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.
|
// it will also produce "51 Not Found" responses for directory paths.
|
||||||
func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gemini.Handler {
|
func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gus.Handler {
|
||||||
return func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||||
path, dirFile, resp := handleDir(req, fileSystem)
|
path, dirFile, resp := handleDir(req, fileSystem)
|
||||||
if resp != nil {
|
if dirFile == nil {
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
defer dirFile.Close()
|
defer dirFile.Close()
|
||||||
|
@ -50,7 +51,7 @@ func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gemini.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return gemini.NotFound("Resource does not exist.")
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,10 +70,10 @@ func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gemini.Handler {
|
||||||
// - .FullPath: the complete path to the listed directory
|
// - .FullPath: the complete path to the listed directory
|
||||||
// - .DirName: the name of the directory itself
|
// - .DirName: the name of the directory itself
|
||||||
// - .Entries: the []fs.DirEntry of the directory contents
|
// - .Entries: the []fs.DirEntry of the directory contents
|
||||||
//
|
//
|
||||||
// The template argument may be nil, in which case a simple default template is used.
|
// The template argument may be nil, in which case a simple default template is used.
|
||||||
func DirectoryListing(fileSystem fs.FS, template *template.Template) gemini.Handler {
|
func DirectoryListing(fileSystem fs.FS, template *template.Template) gus.Handler {
|
||||||
return func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||||
path, dirFile, resp := handleDir(req, fileSystem)
|
path, dirFile, resp := handleDir(req, fileSystem)
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
return resp
|
return resp
|
||||||
|
@ -132,7 +133,7 @@ func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, erro
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gemini.Response) {
|
func handleDir(req *gus.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gus.Response) {
|
||||||
path := strings.Trim(req.Path, "/")
|
path := strings.Trim(req.Path, "/")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = "."
|
path = "."
|
||||||
|
@ -140,7 +141,7 @@ func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *
|
||||||
|
|
||||||
file, err := fileSystem.Open(path)
|
file, err := fileSystem.Open(path)
|
||||||
if isNotFound(err) {
|
if isNotFound(err) {
|
||||||
return "", nil, gemini.NotFound("Resource does not exist.")
|
return "", nil, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, gemini.Failure(err)
|
return "", nil, gemini.Failure(err)
|
||||||
|
@ -154,7 +155,7 @@ func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *
|
||||||
|
|
||||||
if !isDir {
|
if !isDir {
|
||||||
file.Close()
|
file.Close()
|
||||||
return "", nil, gemini.NotFound("Resource does not exist.")
|
return "", nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasSuffix(req.Path, "/") {
|
if !strings.HasSuffix(req.Path, "/") {
|
||||||
|
@ -167,7 +168,7 @@ func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *
|
||||||
dirFile, ok := file.(fs.ReadDirFile)
|
dirFile, ok := file.(fs.ReadDirFile)
|
||||||
if !ok {
|
if !ok {
|
||||||
file.Close()
|
file.Close()
|
||||||
return "", nil, gemini.NotFound("Resource does not exist.")
|
return "", nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return path, dirFile, nil
|
return path, dirFile, nil
|
||||||
|
|
|
@ -6,15 +6,16 @@ import (
|
||||||
"mime"
|
"mime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileHandler builds a handler function which serves up a file system.
|
// FileHandler builds a handler function which serves up a file system.
|
||||||
func FileHandler(fileSystem fs.FS) gemini.Handler {
|
func FileHandler(fileSystem fs.FS) gus.Handler {
|
||||||
return func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||||
file, err := fileSystem.Open(strings.TrimPrefix(req.Path, "/"))
|
file, err := fileSystem.Open(strings.TrimPrefix(req.Path, "/"))
|
||||||
if isNotFound(err) {
|
if isNotFound(err) {
|
||||||
return gemini.NotFound("Resource does not exist.")
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return gemini.Failure(err)
|
return gemini.Failure(err)
|
||||||
|
@ -26,7 +27,7 @@ func FileHandler(fileSystem fs.FS) gemini.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if isDir {
|
if isDir {
|
||||||
return gemini.NotFound("Resource does not exist.")
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return gemini.Success(mediaType(req.Path), file)
|
return gemini.Success(mediaType(req.Path), file)
|
||||||
|
|
|
@ -7,16 +7,16 @@ import (
|
||||||
|
|
||||||
kitlog "github.com/go-kit/log"
|
kitlog "github.com/go-kit/log"
|
||||||
|
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Requests(out io.Writer, logger kitlog.Logger) gemini.Middleware {
|
func Requests(out io.Writer, logger kitlog.Logger) gus.Middleware {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(out))
|
logger = kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(next gemini.Handler) gemini.Handler {
|
return func(next gus.Handler) gus.Handler {
|
||||||
return func(ctx context.Context, r *gemini.Request) (resp *gemini.Response) {
|
return func(ctx context.Context, r *gus.Request) (resp *gus.Response) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
end := time.Now()
|
end := time.Now()
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
guslog "tildegit.org/tjp/gus/contrib/log"
|
guslog "tildegit.org/tjp/gus/contrib/log"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
)
|
)
|
||||||
|
@ -33,7 +34,7 @@ func main() {
|
||||||
server.Serve()
|
server.Serve()
|
||||||
}
|
}
|
||||||
|
|
||||||
func cowsayHandler(ctx context.Context, req *gemini.Request) *gemini.Response {
|
func cowsayHandler(ctx context.Context, req *gus.Request) *gus.Response {
|
||||||
// prompt for a query if there is none already
|
// prompt for a query if there is none already
|
||||||
if req.RawQuery == "" {
|
if req.RawQuery == "" {
|
||||||
return gemini.Input("enter a phrase")
|
return gemini.Input("enter a phrase")
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -29,20 +30,21 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse the URL and build the request
|
// parse the URL and build the request
|
||||||
request := &gemini.Request{URL: buildURL()}
|
request := &gus.Request{URL: buildURL()}
|
||||||
|
|
||||||
// fetch the response
|
// fetch the response
|
||||||
response, err := client.RoundTrip(request)
|
response, err := client.RoundTrip(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
defer response.Close()
|
||||||
|
|
||||||
if response.Status != gemini.StatusSuccess {
|
if response.Status != gemini.StatusSuccess {
|
||||||
log.Fatalf("%d %s\n", response.Status, response.Meta)
|
log.Fatalf("%d %s\n", response.Status, response.Meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
//io.Copy(os.Stdout, response)
|
//io.Copy(os.Stdout, response)
|
||||||
buf, err := io.ReadAll(response)
|
buf, err := io.ReadAll(gemini.NewResponseReader(response))
|
||||||
fmt.Printf("response: %s\n", buf)
|
fmt.Printf("response: %s\n", buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/contrib/fs"
|
"tildegit.org/tjp/gus/contrib/fs"
|
||||||
guslog "tildegit.org/tjp/gus/contrib/log"
|
guslog "tildegit.org/tjp/gus/contrib/log"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
|
@ -23,7 +24,7 @@ func main() {
|
||||||
// build the request handler
|
// build the request handler
|
||||||
fileSystem := os.DirFS(".")
|
fileSystem := os.DirFS(".")
|
||||||
// Fallthrough tries each handler in succession until it gets something other than "51 Not Found"
|
// Fallthrough tries each handler in succession until it gets something other than "51 Not Found"
|
||||||
handler := gemini.FallthroughHandler(
|
handler := gus.FallthroughHandler(
|
||||||
// first see if they're fetching a directory and we have <dir>/index.gmi
|
// first see if they're fetching a directory and we have <dir>/index.gmi
|
||||||
fs.DirectoryDefault(fileSystem, "index.gmi"),
|
fs.DirectoryDefault(fileSystem, "index.gmi"),
|
||||||
// next (still if they requested a directory) build a directory listing response
|
// next (still if they requested a directory) build a directory listing response
|
||||||
|
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
guslog "tildegit.org/tjp/gus/contrib/log"
|
guslog "tildegit.org/tjp/gus/contrib/log"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
)
|
)
|
||||||
|
@ -51,7 +52,7 @@ func envConfig() (string, string) {
|
||||||
return certfile, keyfile
|
return certfile, keyfile
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspectHandler(ctx context.Context, req *gemini.Request) *gemini.Response {
|
func inspectHandler(ctx context.Context, req *gus.Request) *gus.Response {
|
||||||
// build and return a ```-wrapped description of the connection TLS state
|
// build and return a ```-wrapped description of the connection TLS state
|
||||||
body := "```\n" + displayTLSState(req.TLSState) + "\n```"
|
body := "```\n" + displayTLSState(req.TLSState) + "\n```"
|
||||||
return gemini.Success("text/gemini", bytes.NewBufferString(body))
|
return gemini.Success("text/gemini", bytes.NewBufferString(body))
|
||||||
|
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is used for sending gemini requests and parsing gemini responses.
|
// Client is used for sending gemini requests and parsing gemini responses.
|
||||||
|
@ -31,7 +33,7 @@ func NewClient(tlsConf *tls.Config) Client {
|
||||||
//
|
//
|
||||||
// This method will not automatically follow redirects or cache permanent failures or
|
// This method will not automatically follow redirects or cache permanent failures or
|
||||||
// redirects.
|
// redirects.
|
||||||
func (client Client) RoundTrip(request *Request) (*Response, error) {
|
func (client Client) RoundTrip(request *gus.Request) (*gus.Response, error) {
|
||||||
if request.Scheme != "gemini" && request.Scheme != "" {
|
if request.Scheme != "gemini" && request.Scheme != "" {
|
||||||
return nil, errors.New("non-gemini protocols not supported")
|
return nil, errors.New("non-gemini protocols not supported")
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
package gemini
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// Handler is a function which can turn a gemini request into a gemini response.
|
|
||||||
//
|
|
||||||
// A Handler MUST NOT return a nil response. Errors should be returned in the form
|
|
||||||
// of error responses (4x, 5x, 6x response status). If the Handler should not be
|
|
||||||
// responsible for the requested resource it can return a 51 response.
|
|
||||||
type Handler func(context.Context, *Request) *Response
|
|
||||||
|
|
||||||
// Middleware is a handle 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 child handlers in order,
|
|
||||||
// stopping when it receives a response with status other than 51.
|
|
||||||
func FallthroughHandler(handlers ...Handler) Handler {
|
|
||||||
return func(ctx context.Context, req *Request) *Response {
|
|
||||||
for _, handler := range handlers {
|
|
||||||
response := handler(ctx, req)
|
|
||||||
if response.Status != StatusNotFound {
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NotFound("Resource does not exist.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter wraps a handler with a predicate which determines whether to run the handler.
|
|
||||||
//
|
|
||||||
// When the predicate function returns false, the Filter returns the provided failure
|
|
||||||
// response. The failure argument may be nil, in which case a "51 Resource does not exist."
|
|
||||||
// response will be used.
|
|
||||||
func Filter(
|
|
||||||
predicate func(context.Context, *Request) bool,
|
|
||||||
handler Handler,
|
|
||||||
failure *Response,
|
|
||||||
) Handler {
|
|
||||||
if failure == nil {
|
|
||||||
failure = NotFound("Resource does not exist.")
|
|
||||||
}
|
|
||||||
return func(ctx context.Context, req *Request) *Response {
|
|
||||||
if !predicate(ctx, req) {
|
|
||||||
return failure
|
|
||||||
}
|
|
||||||
return handler(ctx, req)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,43 +2,18 @@ package gemini
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"crypto/tls"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InvalidRequestLineEnding indicates that a gemini request didn't end with "\r\n".
|
// InvalidRequestLineEnding indicates that a gemini request didn't end with "\r\n".
|
||||||
var InvalidRequestLineEnding = errors.New("invalid request line ending")
|
var InvalidRequestLineEnding = errors.New("invalid request line ending")
|
||||||
|
|
||||||
// Request represents a request over the gemini protocol.
|
|
||||||
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 gemini 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseRequest parses a single gemini request from a reader.
|
// ParseRequest parses a single gemini request from a reader.
|
||||||
func ParseRequest(rdr io.Reader) (*Request, error) {
|
func ParseRequest(rdr io.Reader) (*gus.Request, error) {
|
||||||
line, err := bufio.NewReader(rdr).ReadString('\n')
|
line, err := bufio.NewReader(rdr).ReadString('\n')
|
||||||
if err != io.EOF && err != nil {
|
if err != io.EOF && err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -57,13 +32,5 @@ func ParseRequest(rdr io.Reader) (*Request, error) {
|
||||||
u.Scheme = "gemini"
|
u.Scheme = "gemini"
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Request{URL: u}, nil
|
return &gus.Request{URL: u}, nil
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ package gemini_test
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"testing"
|
"testing"
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
)
|
)
|
||||||
|
@ -85,19 +84,3 @@ func TestParseRequest(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 := gemini.Request{ URL: u }.UnescapedQuery()
|
|
||||||
if result != test {
|
|
||||||
t.Errorf("expected %q, got %q", test, result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,65 +6,68 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatusCategory represents the various types of responses.
|
// ResponseCategory represents the various types of gemini responses.
|
||||||
type StatusCategory int
|
type ResponseCategory int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// StatusCategoryInput is for responses which request additional input.
|
// ResponseCategoryInput is for responses which request additional input.
|
||||||
//
|
//
|
||||||
// The META line will be the prompt to display to the user.
|
// The META line will be the prompt to display to the user.
|
||||||
StatusCategoryInput StatusCategory = iota*10 + 10
|
ResponseCategoryInput ResponseCategory = iota*10 + 10
|
||||||
// StatusCategorySuccess is for successful responses.
|
// ResponseCategorySuccess is for successful responses.
|
||||||
//
|
//
|
||||||
// The META line will be the resource's mime type.
|
// The META line will be the resource's mime type.
|
||||||
// This is the only response status which indicates the presence of a response body,
|
// This is the only response status which indicates the presence of a response body,
|
||||||
// and it will contain the resource itself.
|
// and it will contain the resource itself.
|
||||||
StatusCategorySuccess
|
ResponseCategorySuccess
|
||||||
// StatusCategoryRedirect is for responses which direct the client to an alternative URL.
|
// ResponseCategoryRedirect is for responses which direct the client to an alternative URL.
|
||||||
//
|
//
|
||||||
// The META line will contain the new URL the client should try.
|
// The META line will contain the new URL the client should try.
|
||||||
StatusCategoryRedirect
|
ResponseCategoryRedirect
|
||||||
// StatusCategoryTemporaryFailure is for responses which indicate a transient server-side failure.
|
// ResponseCategoryTemporaryFailure is for responses which indicate a transient server-side failure.
|
||||||
//
|
//
|
||||||
// The META line may contain a line with more information about the error.
|
// The META line may contain a line with more information about the error.
|
||||||
StatusCategoryTemporaryFailure
|
ResponseCategoryTemporaryFailure
|
||||||
// StatusCategoryPermanentFailure is for permanent failure responses.
|
// ResponseCategoryPermanentFailure is for permanent failure responses.
|
||||||
//
|
//
|
||||||
// The META line may contain a line with more information about the error.
|
// The META line may contain a line with more information about the error.
|
||||||
StatusCategoryPermanentFailure
|
ResponseCategoryPermanentFailure
|
||||||
// StatusCategoryCertificateRequired indicates client certificate related issues.
|
// ResponseCategoryCertificateRequired indicates client certificate related issues.
|
||||||
//
|
//
|
||||||
// The META line may contain a line with more information about the error.
|
// The META line may contain a line with more information about the error.
|
||||||
StatusCategoryCertificateRequired
|
ResponseCategoryCertificateRequired
|
||||||
)
|
)
|
||||||
|
|
||||||
// Status is the integer status code of a gemini response.
|
func ResponseCategoryForStatus(status gus.Status) ResponseCategory {
|
||||||
type Status int
|
return ResponseCategory(status / 10)
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// StatusInput indicates a required query parameter at the requested URL.
|
// StatusInput indicates a required query parameter at the requested URL.
|
||||||
StatusInput Status = Status(StatusCategoryInput) + iota
|
StatusInput gus.Status = gus.Status(ResponseCategoryInput) + iota
|
||||||
// StatusSensitiveInput indicates a sensitive query parameter is required.
|
// StatusSensitiveInput indicates a sensitive query parameter is required.
|
||||||
StatusSensitiveInput
|
StatusSensitiveInput
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// StatusSuccess is a successful response.
|
// StatusSuccess is a successful response.
|
||||||
StatusSuccess = Status(StatusCategorySuccess) + iota
|
StatusSuccess = gus.Status(ResponseCategorySuccess) + iota
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// StatusTemporaryRedirect indicates a temporary redirect to another URL.
|
// StatusTemporaryRedirect indicates a temporary redirect to another URL.
|
||||||
StatusTemporaryRedirect = Status(StatusCategoryRedirect) + iota
|
StatusTemporaryRedirect = gus.Status(ResponseCategoryRedirect) + iota
|
||||||
// StatusPermanentRedirect indicates that the resource should always be requested at the new URL.
|
// StatusPermanentRedirect indicates that the resource should always be requested at the new URL.
|
||||||
StatusPermanentRedirect
|
StatusPermanentRedirect
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// StatusTemporaryFailure indicates that the request failed and there is no response body.
|
// StatusTemporaryFailure indicates that the request failed and there is no response body.
|
||||||
StatusTemporaryFailure = Status(StatusCategoryTemporaryFailure) + iota
|
StatusTemporaryFailure = gus.Status(ResponseCategoryTemporaryFailure) + iota
|
||||||
// StatusServerUnavailable occurs when the server is unavailable due to overload or maintenance.
|
// StatusServerUnavailable occurs when the server is unavailable due to overload or maintenance.
|
||||||
StatusServerUnavailable
|
StatusServerUnavailable
|
||||||
// StatusCGIError is the result of a failure of a CGI script.
|
// StatusCGIError is the result of a failure of a CGI script.
|
||||||
|
@ -80,7 +83,7 @@ const (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// StatusPermanentFailure is a server failure which should be expected to continue indefinitely.
|
// StatusPermanentFailure is a server failure which should be expected to continue indefinitely.
|
||||||
StatusPermanentFailure = Status(StatusCategoryPermanentFailure) + iota
|
StatusPermanentFailure = gus.Status(ResponseCategoryPermanentFailure) + iota
|
||||||
// StatusNotFound means the resource doesn't exist but it may in the future.
|
// StatusNotFound means the resource doesn't exist but it may in the future.
|
||||||
StatusNotFound
|
StatusNotFound
|
||||||
// StatusGone occurs when a resource will not be available any longer.
|
// StatusGone occurs when a resource will not be available any longer.
|
||||||
|
@ -88,58 +91,37 @@ const (
|
||||||
// StatusProxyRequestRefused means the server is unwilling to act as a proxy for the resource.
|
// StatusProxyRequestRefused means the server is unwilling to act as a proxy for the resource.
|
||||||
StatusProxyRequestRefused
|
StatusProxyRequestRefused
|
||||||
// StatusBadRequest indicates that the request was malformed somehow.
|
// StatusBadRequest indicates that the request was malformed somehow.
|
||||||
StatusBadRequest = Status(StatusCategoryPermanentFailure) + 9
|
StatusBadRequest = gus.Status(ResponseCategoryPermanentFailure) + 9
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// StatusClientCertificateRequired is returned when a certificate was required but not provided.
|
// StatusClientCertificateRequired is returned when a certificate was required but not provided.
|
||||||
StatusClientCertificateRequired = Status(StatusCategoryCertificateRequired) + iota
|
StatusClientCertificateRequired = gus.Status(ResponseCategoryCertificateRequired) + iota
|
||||||
// StatusCertificateNotAuthorized means the certificate doesn't grant access to the requested resource.
|
// StatusCertificateNotAuthorized means the certificate doesn't grant access to the requested resource.
|
||||||
StatusCertificateNotAuthorized
|
StatusCertificateNotAuthorized
|
||||||
// StatusCertificateNotValid means the provided client certificate is invalid.
|
// StatusCertificateNotValid means the provided client certificate is invalid.
|
||||||
StatusCertificateNotValid
|
StatusCertificateNotValid
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatusCategory returns the category a specific status belongs to.
|
|
||||||
func (s Status) Category() StatusCategory {
|
|
||||||
return StatusCategory(s / 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response contains everything in a gemini protocol response.
|
|
||||||
type Response struct {
|
|
||||||
// Status is the status code of the response.
|
|
||||||
Status Status
|
|
||||||
|
|
||||||
// Meta is the status-specific line of additional information.
|
|
||||||
Meta string
|
|
||||||
|
|
||||||
// Body is the response body, if any.
|
|
||||||
//
|
|
||||||
// It is not guaranteed to be readable more than once.
|
|
||||||
Body io.Reader
|
|
||||||
|
|
||||||
reader io.Reader
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input builds an input-prompting response.
|
// Input builds an input-prompting response.
|
||||||
func Input(prompt string) *Response {
|
func Input(prompt string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusInput,
|
Status: StatusInput,
|
||||||
Meta: prompt,
|
Meta: prompt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SensitiveInput builds a password-prompting response.
|
// SensitiveInput builds a password-prompting response.
|
||||||
func SensitiveInput(prompt string) *Response {
|
func SensitiveInput(prompt string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusSensitiveInput,
|
Status: StatusSensitiveInput,
|
||||||
Meta: prompt,
|
Meta: prompt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success builds a success response with resource body.
|
// Success builds a success response with resource body.
|
||||||
func Success(mediatype string, body io.Reader) *Response {
|
func Success(mediatype string, body io.Reader) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusSuccess,
|
Status: StatusSuccess,
|
||||||
Meta: mediatype,
|
Meta: mediatype,
|
||||||
Body: body,
|
Body: body,
|
||||||
|
@ -147,120 +129,120 @@ func Success(mediatype string, body io.Reader) *Response {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect builds a redirect response.
|
// Redirect builds a redirect response.
|
||||||
func Redirect(url string) *Response {
|
func Redirect(url string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusTemporaryRedirect,
|
Status: StatusTemporaryRedirect,
|
||||||
Meta: url,
|
Meta: url,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PermanentRedirect builds a response with a permanent redirect.
|
// PermanentRedirect builds a response with a permanent redirect.
|
||||||
func PermanentRedirect(url string) *Response {
|
func PermanentRedirect(url string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusPermanentRedirect,
|
Status: StatusPermanentRedirect,
|
||||||
Meta: url,
|
Meta: url,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Failure builds a temporary failure response from an error.
|
// Failure builds a temporary failure response from an error.
|
||||||
func Failure(err error) *Response {
|
func Failure(err error) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusTemporaryFailure,
|
Status: StatusTemporaryFailure,
|
||||||
Meta: err.Error(),
|
Meta: err.Error(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unavailable build a "server unavailable" response.
|
// Unavailable build a "server unavailable" response.
|
||||||
func Unavailable(msg string) *Response {
|
func Unavailable(msg string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusServerUnavailable,
|
Status: StatusServerUnavailable,
|
||||||
Meta: msg,
|
Meta: msg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CGIError builds a "cgi error" response.
|
// CGIError builds a "cgi error" response.
|
||||||
func CGIError(err string) *Response {
|
func CGIError(err string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusCGIError,
|
Status: StatusCGIError,
|
||||||
Meta: err,
|
Meta: err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyError builds a proxy error response.
|
// ProxyError builds a proxy error response.
|
||||||
func ProxyError(msg string) *Response {
|
func ProxyError(msg string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusProxyError,
|
Status: StatusProxyError,
|
||||||
Meta: msg,
|
Meta: msg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SlowDown builds a "slow down" response with the number of seconds until the resource is available.
|
// SlowDown builds a "slow down" response with the number of seconds until the resource is available.
|
||||||
func SlowDown(seconds int) *Response {
|
func SlowDown(seconds int) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusSlowDown,
|
Status: StatusSlowDown,
|
||||||
Meta: strconv.Itoa(seconds),
|
Meta: strconv.Itoa(seconds),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PermanentFailure builds a "permanent failure" from an error.
|
// PermanentFailure builds a "permanent failure" from an error.
|
||||||
func PermanentFailure(err error) *Response {
|
func PermanentFailure(err error) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusPermanentFailure,
|
Status: StatusPermanentFailure,
|
||||||
Meta: err.Error(),
|
Meta: err.Error(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotFound builds a "resource not found" response.
|
// NotFound builds a "resource not found" response.
|
||||||
func NotFound(msg string) *Response {
|
func NotFound(msg string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusNotFound,
|
Status: StatusNotFound,
|
||||||
Meta: msg,
|
Meta: msg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gone builds a "resource gone" response.
|
// Gone builds a "resource gone" response.
|
||||||
func Gone(msg string) *Response {
|
func Gone(msg string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusGone,
|
Status: StatusGone,
|
||||||
Meta: msg,
|
Meta: msg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefuseProxy builds a "proxy request refused" response.
|
// RefuseProxy builds a "proxy request refused" response.
|
||||||
func RefuseProxy(msg string) *Response {
|
func RefuseProxy(msg string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusProxyRequestRefused,
|
Status: StatusProxyRequestRefused,
|
||||||
Meta: msg,
|
Meta: msg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BadRequest builds a "bad request" response.
|
// BadRequest builds a "bad request" response.
|
||||||
func BadRequest(msg string) *Response {
|
func BadRequest(msg string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusBadRequest,
|
Status: StatusBadRequest,
|
||||||
Meta: msg,
|
Meta: msg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireCert builds a "client certificate required" response.
|
// RequireCert builds a "client certificate required" response.
|
||||||
func RequireCert(msg string) *Response {
|
func RequireCert(msg string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusClientCertificateRequired,
|
Status: StatusClientCertificateRequired,
|
||||||
Meta: msg,
|
Meta: msg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CertAuthFailure builds a "certificate not authorized" response.
|
// CertAuthFailure builds a "certificate not authorized" response.
|
||||||
func CertAuthFailure(msg string) *Response {
|
func CertAuthFailure(msg string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusCertificateNotAuthorized,
|
Status: StatusCertificateNotAuthorized,
|
||||||
Meta: msg,
|
Meta: msg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CertInvalid builds a "client certificate not valid" response.
|
// CertInvalid builds a "client certificate not valid" response.
|
||||||
func CertInvalid(msg string) *Response {
|
func CertInvalid(msg string) *gus.Response {
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: StatusCertificateNotValid,
|
Status: StatusCertificateNotValid,
|
||||||
Meta: msg,
|
Meta: msg,
|
||||||
}
|
}
|
||||||
|
@ -275,7 +257,7 @@ var InvalidResponseHeaderLine = errors.New("Invalid response header line.")
|
||||||
// ParseResponse parses a complete gemini response from a reader.
|
// ParseResponse parses a complete gemini response from a reader.
|
||||||
//
|
//
|
||||||
// The reader must contain only one gemini response.
|
// The reader must contain only one gemini response.
|
||||||
func ParseResponse(rdr io.Reader) (*Response, error) {
|
func ParseResponse(rdr io.Reader) (*gus.Response, error) {
|
||||||
bufrdr := bufio.NewReader(rdr)
|
bufrdr := bufio.NewReader(rdr)
|
||||||
|
|
||||||
hdrLine, err := bufrdr.ReadBytes('\n')
|
hdrLine, err := bufrdr.ReadBytes('\n')
|
||||||
|
@ -295,53 +277,57 @@ func ParseResponse(rdr io.Reader) (*Response, error) {
|
||||||
return nil, InvalidResponseHeaderLine
|
return nil, InvalidResponseHeaderLine
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Response{
|
return &gus.Response{
|
||||||
Status: Status(status),
|
Status: gus.Status(status),
|
||||||
Meta: string(hdrLine[3:]),
|
Meta: string(hdrLine[3:]),
|
||||||
Body: bufrdr,
|
Body: bufrdr,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read implements io.Reader for Response.
|
type ResponseReader interface {
|
||||||
func (r *Response) Read(b []byte) (int, error) {
|
io.Reader
|
||||||
r.ensureReader()
|
io.WriterTo
|
||||||
return r.reader.Read(b)
|
io.Closer
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteTo implements io.WriterTo for Response.
|
func NewResponseReader(response *gus.Response) ResponseReader {
|
||||||
func (r *Response) WriteTo(dst io.Writer) (int64, error) {
|
return &responseReader{ Response: response }
|
||||||
r.ensureReader()
|
|
||||||
return r.reader.(io.WriterTo).WriteTo(dst)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close implements io.Closer and ensures the body gets closed.
|
type responseReader struct {
|
||||||
func (r *Response) Close() error {
|
*gus.Response
|
||||||
if r != nil {
|
reader io.Reader
|
||||||
if cl, ok := r.Body.(io.Closer); ok {
|
|
||||||
return cl.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Response) ensureReader() {
|
func (rdr *responseReader) Read(b []byte) (int, error) {
|
||||||
if r.reader != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdr := bytes.NewBuffer(r.headerLine())
|
hdr := bytes.NewBuffer(rdr.headerLine())
|
||||||
if r.Body != nil {
|
if rdr.Body != nil {
|
||||||
r.reader = io.MultiReader(hdr, r.Body)
|
rdr.reader = io.MultiReader(hdr, rdr.Body)
|
||||||
} else {
|
} else {
|
||||||
r.reader = hdr
|
rdr.reader = hdr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Response) headerLine() []byte {
|
func (rdr responseReader) headerLine() []byte {
|
||||||
buf := make([]byte, len(r.Meta)+5)
|
meta := rdr.Meta.(string)
|
||||||
_ = strconv.AppendInt(buf[:0], int64(r.Status), 10)
|
buf := make([]byte, len(meta)+5)
|
||||||
|
_ = strconv.AppendInt(buf[:0], int64(rdr.Status), 10)
|
||||||
buf[2] = ' '
|
buf[2] = ' '
|
||||||
copy(buf[3:], r.Meta)
|
copy(buf[3:], meta)
|
||||||
buf[len(buf)-2] = '\r'
|
buf[len(buf)-2] = '\r'
|
||||||
buf[len(buf)-1] = '\n'
|
buf[len(buf)-1] = '\n'
|
||||||
return buf
|
return buf
|
||||||
|
|
|
@ -6,14 +6,15 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildResponses(t *testing.T) {
|
func TestBuildResponses(t *testing.T) {
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
response *gemini.Response
|
response *gus.Response
|
||||||
status gemini.Status
|
status gus.Status
|
||||||
meta string
|
meta string
|
||||||
body string
|
body string
|
||||||
}{
|
}{
|
||||||
|
@ -137,7 +138,7 @@ func TestBuildResponses(t *testing.T) {
|
||||||
t.Errorf("expected meta %q, got %q", test.meta, test.response.Meta)
|
t.Errorf("expected meta %q, got %q", test.meta, test.response.Meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
responseBytes, err := io.ReadAll(test.response)
|
responseBytes, err := io.ReadAll(gemini.NewResponseReader(test.response))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error reading response body: %q", err.Error())
|
t.Fatalf("error reading response body: %q", err.Error())
|
||||||
}
|
}
|
||||||
|
@ -153,7 +154,7 @@ func TestBuildResponses(t *testing.T) {
|
||||||
func TestParseResponses(t *testing.T) {
|
func TestParseResponses(t *testing.T) {
|
||||||
table := []struct {
|
table := []struct {
|
||||||
input string
|
input string
|
||||||
status gemini.Status
|
status gus.Status
|
||||||
meta string
|
meta string
|
||||||
body string
|
body string
|
||||||
err error
|
err error
|
||||||
|
@ -232,7 +233,7 @@ func TestParseResponses(t *testing.T) {
|
||||||
|
|
||||||
func TestResponseClose(t *testing.T) {
|
func TestResponseClose(t *testing.T) {
|
||||||
body := &rdCloser{Buffer: bytes.NewBufferString("the body here")}
|
body := &rdCloser{Buffer: bytes.NewBufferString("the body here")}
|
||||||
resp := &gemini.Response{
|
resp := &gus.Response{
|
||||||
Status: gemini.StatusSuccess,
|
Status: gemini.StatusSuccess,
|
||||||
Meta: "text/gemini",
|
Meta: "text/gemini",
|
||||||
Body: body,
|
Body: body,
|
||||||
|
@ -246,7 +247,7 @@ func TestResponseClose(t *testing.T) {
|
||||||
t.Error("response body was not closed by response.Close()")
|
t.Error("response body was not closed by response.Close()")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = &gemini.Response{
|
resp = &gus.Response{
|
||||||
Status: gemini.StatusInput,
|
Status: gemini.StatusInput,
|
||||||
Meta: "give me more",
|
Meta: "give me more",
|
||||||
}
|
}
|
||||||
|
@ -269,8 +270,8 @@ func (rc *rdCloser) Close() error {
|
||||||
func TestResponseWriteTo(t *testing.T) {
|
func TestResponseWriteTo(t *testing.T) {
|
||||||
// invariant under test: WriteTo() sends the same bytes as Read()
|
// invariant under test: WriteTo() sends the same bytes as Read()
|
||||||
|
|
||||||
clone := func(resp *gemini.Response) *gemini.Response {
|
clone := func(resp *gus.Response) *gus.Response {
|
||||||
other := &gemini.Response{
|
other := &gus.Response{
|
||||||
Status: resp.Status,
|
Status: resp.Status,
|
||||||
Meta: resp.Meta,
|
Meta: resp.Meta,
|
||||||
}
|
}
|
||||||
|
@ -296,7 +297,7 @@ func TestResponseWriteTo(t *testing.T) {
|
||||||
|
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
response *gemini.Response
|
response *gus.Response
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "simple success",
|
name: "simple success",
|
||||||
|
@ -316,13 +317,13 @@ func TestResponseWriteTo(t *testing.T) {
|
||||||
r1 := test.response
|
r1 := test.response
|
||||||
r2 := clone(test.response)
|
r2 := clone(test.response)
|
||||||
|
|
||||||
rdbuf, err := io.ReadAll(r1)
|
rdbuf, err := io.ReadAll(gemini.NewResponseReader(r1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("response.Read(): %s", err.Error())
|
t.Fatalf("response.Read(): %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
wtbuf := &bytes.Buffer{}
|
wtbuf := &bytes.Buffer{}
|
||||||
if _, err := r2.WriteTo(wtbuf); err != nil {
|
if _, err := gemini.NewResponseReader(r2).WriteTo(wtbuf); err != nil {
|
||||||
t.Fatalf("response.WriteTo(): %s", err.Error())
|
t.Fatalf("response.WriteTo(): %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -18,7 +19,7 @@ func TestRoundTrip(t *testing.T) {
|
||||||
t.Fatalf("FileTLS(): %s", err.Error())
|
t.Fatalf("FileTLS(): %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
handler := func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||||
return gemini.Success("text/gemini", bytes.NewBufferString("you've found my page"))
|
return gemini.Success("text/gemini", bytes.NewBufferString("you've found my page"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,7 +37,7 @@ func TestRoundTrip(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cli := gemini.NewClient(testClientTLS())
|
cli := gemini.NewClient(testClientTLS())
|
||||||
response, err := cli.RoundTrip(&gemini.Request{URL: u})
|
response, err := cli.RoundTrip(&gus.Request{URL: u})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RoundTrip(): %s", err.Error())
|
t.Fatalf("RoundTrip(): %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,27 +6,28 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server listens on a network and serves the gemini protocol.
|
type server struct {
|
||||||
type Server struct {
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
network string
|
network string
|
||||||
address string
|
address string
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
wg *sync.WaitGroup
|
wg *sync.WaitGroup
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
handler Handler
|
handler gus.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer builds a server.
|
// NewServer builds a gemini server.
|
||||||
func NewServer(
|
func NewServer(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tlsConfig *tls.Config,
|
tlsConfig *tls.Config,
|
||||||
network string,
|
network string,
|
||||||
address string,
|
address string,
|
||||||
handler Handler,
|
handler gus.Handler,
|
||||||
) (*Server, error) {
|
) (gus.Server, error) {
|
||||||
listener, err := net.Listen(network, address)
|
listener, err := net.Listen(network, address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -34,7 +35,7 @@ func NewServer(
|
||||||
|
|
||||||
addr := listener.Addr()
|
addr := listener.Addr()
|
||||||
|
|
||||||
s := &Server{
|
s := &server{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
network: addr.Network(),
|
network: addr.Network(),
|
||||||
address: addr.String(),
|
address: addr.String(),
|
||||||
|
@ -54,7 +55,7 @@ func NewServer(
|
||||||
// It will respect cancellation of the context the server was created with,
|
// 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
|
// but be aware that Close() must still be called in that case to avoid
|
||||||
// dangling goroutines.
|
// dangling goroutines.
|
||||||
func (s *Server) Serve() error {
|
func (s *server) Serve() error {
|
||||||
s.wg.Add(1)
|
s.wg.Add(1)
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
|
|
||||||
|
@ -66,7 +67,7 @@ func (s *Server) Serve() error {
|
||||||
for {
|
for {
|
||||||
conn, err := s.listener.Accept()
|
conn, err := s.listener.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s.closed() {
|
if s.Closed() {
|
||||||
err = nil
|
err = nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
@ -77,62 +78,57 @@ func (s *Server) Serve() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close begins a graceful shutdown of the server.
|
func (s *server) Close() {
|
||||||
//
|
|
||||||
// It cancels the server's context which interrupts all concurrently running
|
|
||||||
// request handlers, if they support it. It then blocks until all resources
|
|
||||||
// have been cleaned up and all request handlers have completed.
|
|
||||||
func (s *Server) Close() {
|
|
||||||
s.cancel()
|
s.cancel()
|
||||||
s.wg.Wait()
|
s.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network returns the network type on which the server is running.
|
func (s *server) Network() string {
|
||||||
func (s *Server) Network() string {
|
|
||||||
return s.network
|
return s.network
|
||||||
}
|
}
|
||||||
|
|
||||||
// Address returns the address on which the server is listening.
|
func (s *server) Address() string {
|
||||||
func (s *Server) Address() string {
|
|
||||||
return s.address
|
return s.address
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hostname returns just the hostname portion of the listen address.
|
func (s *server) Hostname() string {
|
||||||
func (s *Server) Hostname() string {
|
|
||||||
host, _, _ := net.SplitHostPort(s.address)
|
host, _, _ := net.SplitHostPort(s.address)
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port returns the port on which the server is listening.
|
func (s *server) Port() string {
|
||||||
func (s *Server) Port() string {
|
|
||||||
_, portStr, _ := net.SplitHostPort(s.address)
|
_, portStr, _ := net.SplitHostPort(s.address)
|
||||||
return portStr
|
return portStr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleConn(conn net.Conn) {
|
func (s *server) handleConn(conn net.Conn) {
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
var response *gus.Response
|
||||||
req, err := ParseRequest(conn)
|
req, err := ParseRequest(conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = io.Copy(conn, BadRequest(err.Error()))
|
response = BadRequest(err.Error())
|
||||||
return
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Server = s
|
_, _ = io.Copy(conn, NewResponseReader(response))
|
||||||
req.RemoteAddr = conn.RemoteAddr()
|
|
||||||
if tlsconn, ok := conn.(*tls.Conn); req != nil && ok {
|
|
||||||
state := tlsconn.ConnectionState()
|
|
||||||
req.TLSState = &state
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := s.handler(s.ctx, req)
|
|
||||||
defer resp.Close()
|
|
||||||
|
|
||||||
_, _ = io.Copy(conn, resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) propagateCancel() {
|
func (s *server) propagateCancel() {
|
||||||
go func() {
|
go func() {
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
|
|
||||||
|
@ -141,7 +137,7 @@ func (s *Server) propagateCancel() {
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) closed() bool {
|
func (s *server) Closed() bool {
|
||||||
select {
|
select {
|
||||||
case <-s.ctx.Done():
|
case <-s.ctx.Done():
|
||||||
return true
|
return true
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package gemini_test
|
package gus_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
@ -8,32 +8,33 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
"tildegit.org/tjp/gus/gemini"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFallthrough(t *testing.T) {
|
func TestFallthrough(t *testing.T) {
|
||||||
h1 := func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
h1 := func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||||
if req.Path == "/one" {
|
if req.Path == "/one" {
|
||||||
return gemini.Success("text/gemini", bytes.NewBufferString("one"))
|
return gemini.Success("text/gemini", bytes.NewBufferString("one"))
|
||||||
}
|
}
|
||||||
return gemini.NotFound("nope")
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 := func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
h2 := func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||||
if req.Path == "/two" {
|
if req.Path == "/two" {
|
||||||
return gemini.Success("text/gemini", bytes.NewBufferString("two"))
|
return gemini.Success("text/gemini", bytes.NewBufferString("two"))
|
||||||
}
|
}
|
||||||
return gemini.NotFound("no way")
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fth := gemini.FallthroughHandler(h1, h2)
|
fth := gus.FallthroughHandler(h1, h2)
|
||||||
|
|
||||||
u, err := url.Parse("gemini://test.local/one")
|
u, err := url.Parse("gemini://test.local/one")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("url.Parse: %s", err.Error())
|
t.Fatalf("url.Parse: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := fth(context.Background(), &gemini.Request{URL: u})
|
resp := fth(context.Background(), &gus.Request{URL: u})
|
||||||
|
|
||||||
if resp.Status != gemini.StatusSuccess {
|
if resp.Status != gemini.StatusSuccess {
|
||||||
t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status)
|
t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status)
|
||||||
|
@ -56,7 +57,7 @@ func TestFallthrough(t *testing.T) {
|
||||||
t.Fatalf("url.Parse: %s", err.Error())
|
t.Fatalf("url.Parse: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = fth(context.Background(), &gemini.Request{URL: u})
|
resp = fth(context.Background(), &gus.Request{URL: u})
|
||||||
|
|
||||||
if resp.Status != gemini.StatusSuccess {
|
if resp.Status != gemini.StatusSuccess {
|
||||||
t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status)
|
t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status)
|
||||||
|
@ -79,28 +80,28 @@ func TestFallthrough(t *testing.T) {
|
||||||
t.Fatalf("url.Parse: %s", err.Error())
|
t.Fatalf("url.Parse: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = fth(context.Background(), &gemini.Request{URL: u})
|
resp = fth(context.Background(), &gus.Request{URL: u})
|
||||||
|
|
||||||
if resp.Status != gemini.StatusNotFound {
|
if resp != nil {
|
||||||
t.Errorf("expected status %d, got %d", gemini.StatusNotFound, resp.Status)
|
t.Errorf("expected nil, got %+v", resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilter(t *testing.T) {
|
func TestFilter(t *testing.T) {
|
||||||
pred := func(ctx context.Context, req *gemini.Request) bool {
|
pred := func(ctx context.Context, req *gus.Request) bool {
|
||||||
return strings.HasPrefix(req.Path, "/allow")
|
return strings.HasPrefix(req.Path, "/allow")
|
||||||
}
|
}
|
||||||
base := func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
base := func(ctx context.Context, req *gus.Request) *gus.Response {
|
||||||
return gemini.Success("text/gemini", bytes.NewBufferString("allowed!"))
|
return gemini.Success("text/gemini", bytes.NewBufferString("allowed!"))
|
||||||
}
|
}
|
||||||
handler := gemini.Filter(pred, base, nil)
|
handler := gus.Filter(pred, nil)(base)
|
||||||
|
|
||||||
u, err := url.Parse("gemini://test.local/allow/please")
|
u, err := url.Parse("gemini://test.local/allow/please")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("url.Parse: %s", err.Error())
|
t.Fatalf("url.Parse: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := handler(context.Background(), &gemini.Request{URL: u})
|
resp := handler(context.Background(), &gus.Request{URL: u})
|
||||||
if resp.Status != gemini.StatusSuccess {
|
if resp.Status != gemini.StatusSuccess {
|
||||||
t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status)
|
t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status)
|
||||||
}
|
}
|
||||||
|
@ -110,8 +111,8 @@ func TestFilter(t *testing.T) {
|
||||||
t.Fatalf("url.Parse: %s", err.Error())
|
t.Fatalf("url.Parse: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = handler(context.Background(), &gemini.Request{URL: u})
|
resp = handler(context.Background(), &gus.Request{URL: u})
|
||||||
if resp.Status != gemini.StatusNotFound {
|
if resp != nil {
|
||||||
t.Errorf("expected status %d, got %d", gemini.StatusNotFound, resp.Status)
|
t.Errorf("expected nil, got %+v", resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package gus
|
||||||
|
|
||||||
|
// Server is a type which can serve a protocol.
|
||||||
|
type Server interface {
|
||||||
|
// Serve blocks listening for connections on an interface.
|
||||||
|
//
|
||||||
|
// It will only return after Close() has been called.
|
||||||
|
Serve() error
|
||||||
|
|
||||||
|
// Close initiates a graceful shutdown of the server.
|
||||||
|
//
|
||||||
|
// It blocks until all resources have been cleaned up and all
|
||||||
|
// outstanding requests have been handled and responses sent.
|
||||||
|
Close()
|
||||||
|
|
||||||
|
// Closed indicates whether Close has been called.
|
||||||
|
//
|
||||||
|
// It may be true even if the graceful shutdown procedure
|
||||||
|
// hasn't yet completed.
|
||||||
|
Closed() bool
|
||||||
|
|
||||||
|
// Network returns the network type on which the server is running.
|
||||||
|
Network() string
|
||||||
|
|
||||||
|
// Address returns the address on which the server is listening.
|
||||||
|
Address() string
|
||||||
|
|
||||||
|
// Hostname returns just the hostname portion of the listen address.
|
||||||
|
Hostname() string
|
||||||
|
|
||||||
|
// Port returns the port on which the server is listening.
|
||||||
|
//
|
||||||
|
// It will return the empty string if the network type does not
|
||||||
|
// have ports (unix sockets, for example).
|
||||||
|
Port() string
|
||||||
|
}
|
Reference in New Issue