Added dynamic error content via ResponseTransformer (#27)

Dynamic error content:

- [x] Error codes
- [x] Content per code
- [x] Customised templates for errors (introduces dynamic content via templates)

Handles:
- [X] Unrecognised selector
- [X] Error retrieving content (partial)
- [X] Error accessing resources (partial)

Closes #22

Co-authored-by: Nicolas Herry <beastieboy@beastieboy.net>
Reviewed-on: #27
pull/31/head
beastieboy 1 year ago
parent cfa34454d8
commit 751b614ceb
  1. 2
      .gitignore
  2. 7
      cmd/marmotte/main.go
  3. 6
      gopher/context.go
  4. 20
      gopher/errorcodes.go
  5. 4
      gopher/predicates.go
  6. 72
      gopher/request.go
  7. 2
      gopher/request_test.go
  8. 4
      gopher/response.go
  9. 5
      gopher/server.go
  10. 53
      gopher/transformers.go
  11. 6
      gopher/transformers_test.go
  12. 1
      testdata/errors/10.gt
  13. 1
      testdata/errors/20.gt
  14. 1
      testdata/errors/30.gt
  15. 1
      testdata/users_gopherspace/mcahill/about.txt

2
.gitignore vendored

@ -14,7 +14,7 @@
# Dependency directories (remove the comment below to include it)
# vendor/
testdate/tmp/
/testdata/tmp/
# logfile
logfile

@ -17,6 +17,7 @@ func main() {
viper.BindEnv("transport")
viper.BindEnv("logfile")
viper.BindEnv("users_gopherspace")
viper.BindEnv("errors")
viper.SetDefault("host", "localhost")
viper.SetDefault("port", 7070)
@ -24,6 +25,7 @@ func main() {
viper.SetDefault("transport", "tcp")
viper.SetDefault("logfile", "/var/log/marmotte.log")
viper.SetDefault("users_gopherspace", "/users")
viper.SetDefault("errors", "/errors")
pflag.String("host", "localhost", "the hostname the server sockets will bind to")
pflag.Int("port", 7070, "the port the server will listen on")
@ -31,6 +33,7 @@ func main() {
pflag.String("root", "/usr/local/marmotte", "the root directory where the documents can be found")
pflag.String("logfile", "/var/log/marmotte.log", "the logfile (not rotated)")
pflag.String("users_gopherspace", "/users", "the root directory for users gopherspace (~username)")
pflag.String("errors", "$ROOT/errors", "the directory where the error templates can be found")
pflag.Parse()
viper.BindPFlags(pflag.CommandLine)
@ -50,7 +53,9 @@ func main() {
Host: viper.GetString("host"),
Port: viper.GetInt("port"),
TransportProtocol: viper.GetString("transport"),
UsersGopherspace: viper.GetString("users_gopherspace"),}
UsersGopherspace: viper.GetString("users_gopherspace"),
ErrorRoot: viper.GetString("root") + "/" + viper.GetString("errors"),
}
ctx.DefaultRequestTransformers()
ctx.DefaultResponseTransformers()

@ -21,6 +21,7 @@ type Context struct {
Headers map[string][]string
Footers map[string][]string
UsersGopherspace string
ErrorRoot string
}
// AddRequestTransformer appends the RequestTransformer t at the end of the list of RequestTransformer instance found in the Context c. nil values are discarded and return an error.
@ -81,8 +82,11 @@ func (c *Context) DefaultResponseTransformers() []ResponseTransformer {
doc := ResponseTransformer {
Transformer: BinaryFileTransformer,
Predicate: FileTypeDocPredicate }
errors := ResponseTransformer {
Transformer: ErrorRedirectTransformer,
Predicate: GopherErrorPredicate }
c.ResponseTransformers = []ResponseTransformer{ executable, gophermap, textfile, binary, image, audio, pdf, doc }
c.ResponseTransformers = []ResponseTransformer{ executable, gophermap, textfile, binary, image, audio, pdf, doc, errors }
return c.ResponseTransformers
}

@ -0,0 +1,20 @@
package gopher
import (
"fmt"
)
type GopherErrorCode string
const GopherErrorNoError GopherErrorCode = "00"
const GopherErrorSelectorNotFound GopherErrorCode = "10"
const GopherErrorWrongPermissions GopherErrorCode = "20"
const GopherErrorDataRetrieval GopherErrorCode = "30"
func (e GopherErrorCode) String() string {
return fmt.Sprint(string(e))
}
type GopherErrorInfo struct {
Selector string
}

@ -71,3 +71,7 @@ func FileExecutable(c Context, rq Request, rs Response, e error) bool {
fi, err := os.Stat(rq.FullPath)
return err == nil && fi.Mode()&0110 != 0
}
func GopherErrorPredicate(c Context, rq Request, rs Response, e error) bool {
return rs.ErrorCode != GopherErrorNoError || (len(rs.ContentBinary) == 0 && len(rs.ContentText) == 0)
}

@ -3,7 +3,6 @@ package gopher
import (
"regexp"
"os"
"fmt"
"path/filepath"
"github.com/rs/zerolog/log"
@ -16,6 +15,7 @@ const (
Map RequestType = iota
File
NotImplemented
Unknown
)
// The Type Request describes a request.
@ -28,6 +28,22 @@ type Request struct {
Type RequestType
}
func GetRequestTypeFor(c Context, s string) RequestType {
fi, err := os.Stat(filepath.Clean(c.Root + "/" + s))
if err != nil {
log.Error().
Err(err).
Msg("Received an error when trying to stat a full path")
return Unknown
}
if fi.IsDir() {
return Map
} else {
return File
}
}
// NewRequest creates a new Request instance and returns a pointer to it. It relies on the selector s to determine the RequestType, and on the Context c to build the paths and verify the files.
func NewRequest(c Context, s string) (*Request, error) {
r := Request{}
@ -35,39 +51,45 @@ func NewRequest(c Context, s string) (*Request, error) {
Str("s", s).
Send()
ContentList := regexp.MustCompile("^$")
Selector := regexp.MustCompile("^(.+)$")
// Selector := regexp.MustCompile("^(.+)$")
if ContentList.MatchString(s) {
r.Path = "/"
r.FullPath = c.Root + "/"
r.Type = Map
} else if Selector.MatchString(s) {
fi, err := os.Stat(filepath.Clean(c.Root + "/" + s))
if err != nil {
log.Error().
Err(err).
Msg("Received an error when trying to stat a full path")
r.FullPath = c.Root + "/" + s
r.Path = s
return &r, nil
} else {
r.Path = filepath.Clean("/" + s)
r.FullPath = c.Root + r.Path
if fi.IsDir() {
r.Type = Map
} else {
r.Type = File
}
log.Debug().
Str("Fullpath", r.FullPath).
Send()
}
} else {
// by default, consider that what is not a directory or a path to a file is 'NotImplemented'
// later: this can be a function, etc.
r.Type = NotImplemented
return &r, fmt.Errorf("Unrecognised request: '%s'", s)
r.Type = GetRequestTypeFor(c, s)
}
// } else if Selector.MatchString(s) {
// fi, err := os.Stat(filepath.Clean(c.Root + "/" + s))
// if err != nil {
// log.Error().
// Err(err).
// Msg("Received an error when trying to stat a full path")
// r.FullPath = c.Root + "/" + s
// r.Path = s
// r.Type = Unknown
// return &r, nil
// } else {
// r.Path = filepath.Clean("/" + s)
// r.FullPath = c.Root + r.Path
// if fi.IsDir() {
// r.Type = Map
// } else {
// r.Type = File
// }
// log.Debug().
// Str("Fullpath", r.FullPath).
// Send()
// }
// } else {
// // by default, consider that what is not a directory or a path to a file is 'NotImplemented'
// // later: this can be a function, etc.
// r.Type = NotImplemented
// return &r, fmt.Errorf("Unrecognised request: '%s'", s)
// }
return &r, nil
}

@ -91,7 +91,7 @@ func TestNewRequest(t *testing.T) {
t.Errorf("Unexpected error returned by NewRequest for a well-formed user space: %s, %s", p, err)
}
expectedRq := Request{ Path: "~mcahill", FullPath: c.Root + "/~mcahill"}
expectedRq := Request{ Path: "/~mcahill", FullPath: c.Root + "/~mcahill", Type: Unknown}
if ! cmp.Equal(&expectedRq, rq) {
t.Errorf("Unexpected Request instance returned by NewRequest for a well-formed user space request %s: %s", p, cmp.Diff(&expectedRq, rq))

@ -37,6 +37,8 @@ type Response struct {
ContentBinary []byte
// ContentText contains the strings for a UTF-8 encoded text response.
ContentText []string
// ErrorCode contains the code for the error encountered
ErrorCode GopherErrorCode
}
// NewResponse creates a new Response instance and returns a pointer to it. It relies on the Request rq and the Context c to build the path to the resource and determine its type, based on MIME guessing. Note that a Request of type Map always yields a Response of type PlainText.
@ -44,6 +46,7 @@ func NewResponse(c Context, rq Request) *Response {
r:= Response { Type: Binary,
ContentText: []string{},
ContentBinary: []byte{},
ErrorCode: GopherErrorNoError,
}
if rq.Type == Map {
@ -55,7 +58,6 @@ func NewResponse(c Context, rq Request) *Response {
Err(err).
Str("FullPath", rq.FullPath).
Msg("Error detecting a file type")
return &r
}

@ -85,8 +85,9 @@ func TransformRequest(c *Context, rq *Request, rs Response, err error) []Request
// HandleConnection does all the work after a connection has been accepted in Serve:
// 1. Incoming request data is parsed from the Conn c, and corresponding Request and Response instances are created
// 2. Transformers are applied to the Response instance, to load and prepare the data
// 3. The data is written back to the client
// 4. The connection is closed
// 3. The last transformer runs error checking and redirects to an error page in case of error
// 4. The data is written back to the client
// 5. The connection is closed
func HandleConnection(ctx *Context, c net.Conn) error {
rq, err := ParseRequest(ctx, c)

@ -7,6 +7,9 @@ import (
"path/filepath"
"regexp"
"strings"
"text/template"
"fmt"
"io"
"github.com/rs/zerolog/log"
)
@ -78,9 +81,15 @@ func BinaryFileTransformer(c *Context, rq Request, rs *Response, e error) (*Cont
func HeaderTransformerFor(k string) func(*Context, Request, *Response, error) (*Context, Request, *Response, error) {
return func(c *Context, rq Request, rs *Response, e error) (*Context, Request, *Response, error) {
header := []string {}
prefix := ""
suffix := ""
if rq.Type == Map {
prefix = "i"
suffix = "\tfake.host\t1"
}
if h, ok := c.Headers[k]; ok {
for _, line := range h {
header = append(header, "i" + line + "\r\n")
header = append(header, prefix + line + suffix + "\r\n")
}
rs.ContentText = append(header, rs.ContentText...)
}
@ -92,9 +101,15 @@ func HeaderTransformerFor(k string) func(*Context, Request, *Response, error) (*
func FooterTransformerFor(k string) func(*Context, Request, *Response, error) (*Context, Request, *Response, error) {
return func(c *Context, rq Request, rs *Response, e error) (*Context, Request, *Response, error) {
footer := []string {}
prefix := ""
suffix := ""
if rq.Type == Map {
prefix = "i"
suffix = "\tfake.host\t1"
}
if h, ok := c.Footers[k]; ok {
for _, line := range h {
footer = append(footer, "i" + line + "\r\n")
footer = append(footer, prefix + line + suffix + "\r\n")
}
rs.ContentText = append(rs.ContentText, footer...)
}
@ -115,6 +130,7 @@ func SelectorRewriteTransformerFor(from string, to string) func(*Context, *Reque
rq.Path = re.ReplaceAllString(rq.Path, to)
rq.FullPath = filepath.Clean(c.Root + "/" + rq.Path)
rq.Type = GetRequestTypeFor(*c, rq.Path)
return c, rq, rs, e
}
@ -148,3 +164,36 @@ func ExecutableTransformer(c *Context, rq Request, rs *Response, err error) (*Co
// by default, return parameters unchanged
return c, rq, rs, err
}
func ErrorRedirectTransformer(c *Context, rq Request, rs *Response, err error) (*Context, Request, *Response, error) {
// If no content, no transformer could do anything: unknown selector
if len(rs.ContentBinary) == 0 && len(rs.ContentText) == 0 {
rs.ErrorCode = GopherErrorSelectorNotFound
}
// read the template found in c.ErrorRoot / rs.ErrorCode.txt
p := filepath.Clean(fmt.Sprint(c.ErrorRoot, "/", rs.ErrorCode,".gt"))
t := template.Must(template.ParseFiles(p))
if t != nil {
var buf bytes.Buffer
err = t.Execute(io.Writer(&buf), GopherErrorInfo { Selector: rq.Path })
log.Debug().
Str("Buffer", buf.String()).
Send()
rs.ContentText = []string{buf.String()}
rs.Type = PlainText
return c, rq, rs, err
}
log.Error().
// Err(err).
Str("Error template path", p).
Msg("Error reading an error template")
rs.ContentText = []string {fmt.Sprint("Error retrieving the error page for the request. The original error code was ", rs.ErrorCode, " for the selector ", rq.Path)}
rs.ErrorCode = GopherErrorDataRetrieval
rs.Type = PlainText
return c, rq, rs, err
}

@ -167,7 +167,7 @@ func TestHeaderTransformer(t *testing.T) {
HeaderTransformerFor(k)(&c, *rq, rs, err)
for i, h := range c.Headers[k] {
expected := "i" + h + "\r\n"
expected := "i" + h + "\tfake.host\t1" + "\r\n"
got := rs.ContentText[i]
if got != expected {
t.Errorf("Unexpected line in ContentText of a Response instance, expected %s, got %s (index %d)", expected, got, i)
@ -198,7 +198,7 @@ func TestFooterTransformer(t *testing.T) {
FooterTransformerFor(k)(&c, *rq, rs, err)
for i, h := range c.Footers[k] {
expected := "i" + h + "\r\n"
expected := "i" + h + "\tfake.host\t1" + "\r\n"
got := rs.ContentText[len(rs.ContentText) - len(c.Footers[k]) + i]
if got != expected {
t.Errorf("Unexpected line in ContentText of a Response instance, expected %s, got %s (index %d)", expected, got, i)
@ -216,7 +216,7 @@ func TestFooterTransformer(t *testing.T) {
func TestSelectorRewriteTransformer(t *testing.T) {
d, _ := os.Getwd()
c := Context { Root: d + "/../testdata", Footers: map[string][]string{"default": {"powered", "by", "marmotte"}} }
rt := RequestTransformer{Transformer: SelectorRewriteTransformerFor("^~(.+)", "/users_gopherspace/$1"),
rt := RequestTransformer{Transformer: SelectorRewriteTransformerFor("^/?~(.+)", "/users_gopherspace/$1"),
Predicate: FullPathMatchPredicateFor(".*"),
}

@ -0,0 +1 @@
The selector {{ .Selector }} could not be understood by the server. Please check the request or contact the administrator if the selector is legitimate and the problem persists.

@ -0,0 +1 @@
You have the wrong permissions to access the resources behind the selector {{ .Selector }}. If you believe you should have access to these resources, please contact the administrator.

@ -0,0 +1 @@
There was an error retrieving the resources behind the selector {{ .Selector }}. Please try again later or contact the administrator of the problem persists.

@ -0,0 +1 @@
Hello, I invented Gopher!
Loading…
Cancel
Save