diff --git a/README.md b/README.md index fc21643..ddf4d9c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,23 @@ # Compass -A Poorly Designed Go Web Framework + +A Go http wrapper for quick development. + +### Features + +- Kinda extensible +- Passplate templating engine +- Session management +- Resource management +- Questionable caching +- No dependencies + +Internally, there is also: +- Documentation on how things work and are implemented `/contributor-docs` +- Testing suite + +### Why? + +There are probably better wrappers out there. Heck, even better Go web server implementations. And faster, too. This +project was started to move from Python (Flask) to Go, but no suitable replacement was found (there probably already +is). Therefore, it was decided to make our own, because reinventing the wheel is always such a good and well-thought-out +decision™ diff --git a/contributor-docs/README.md b/contributor-docs/README.md new file mode 100644 index 0000000..680aa6a --- /dev/null +++ b/contributor-docs/README.md @@ -0,0 +1,6 @@ +# Compass Contributor Docs + +We care about maintaining our work and passing down information. Therefore, these docs exist. **THIS IS NOT THE REGULAR +DOCUMENTATION,** which you can find at [wiki.snackbag.net/compass](https://wiki.snackbag.net/compass). This is meant as +a guide to future contributors, so they don't have to take wild guesses, reimplement things themselves, and we avoid +knowledge discrimination. When writing code for Compass, also write documentation. diff --git a/contributor-docs/passplate/README.md b/contributor-docs/passplate/README.md new file mode 100644 index 0000000..787635a --- /dev/null +++ b/contributor-docs/passplate/README.md @@ -0,0 +1,23 @@ +# Passplate + +The Compass templating engine. It consists of the following parts: + +- lexer +- parser +- renderer +- interpreter + +It brings support for variables and other logic components in normal HTML files, to be evaluated on the server-side. Its +inner workings deviate from the typical programming language interpreter. When a new request for a template is made (and +we exclude all caching magic), it looks similar to the following: + +1. Load template file +2. Generate template AST + - parser goes before the lexer, because the lexer only runs within Passplate blocks +3. Render AST + - renderer and interpreter are separated, since the renderer mainly focuses around creating cohesive HTML, while the + interpreter executes the logic + +## Useful files + +You can find all Passplate files within the `passplate` directory. diff --git a/contributor-docs/passplate/ast.md b/contributor-docs/passplate/ast.md new file mode 100644 index 0000000..db05b0e --- /dev/null +++ b/contributor-docs/passplate/ast.md @@ -0,0 +1,37 @@ +# AST + +Passplate generates an AST to be interpreted/rendered. Pseudo: + +```json lines +[ + {"name": "Text", "value": "

hey

"}, + {"name": "Variable", "id": "name"}, + {"name": "Text", "id": "

"} +] + +/* + +

hey

+<% if name == "cool" %> +

your name is cool

+<% else %> +

ur not cool

+<% end %> + +*/ + +[ + {"name": "Text", "value": "

hey

\n"}, + {"name": "Statement", + "expr": { // name == "cool" + "a": {"type": "variable", "name": "name"}, + "b": {"type": "string", "value": "cool"}, + "operator": "==" + }, + + "pass": [{"name": "Text", "value": "

your name is cool

"}], + "else": [{"name": "Text", "value": "

ur not cool

"}] + } +] + +``` \ No newline at end of file diff --git a/contributor-docs/sessions.md b/contributor-docs/sessions.md new file mode 100644 index 0000000..5eb0ba4 --- /dev/null +++ b/contributor-docs/sessions.md @@ -0,0 +1,18 @@ +# Sessions + +Compass handles sessions for you. + +A session is built up like this: + +- true ID; an integer stored only on the server as primary key, autoincrement. +- UUID; the visual session ID. Stored in the server's database as `UNIQUE`, and on the client as the `_compassId` + cookie. It is what the server uses to distinguish sessions per request. +- Value; a JSON string stored only on the server. +- Expiry; the time a session takes until it expires. Stored on the server as unix timestamp, while the client's cookie + is set to never disappear. + +The server uses SQLite and has an index for the true ID and UUID. + +## Useful files +- [/session.go](/session.go) - session creation, lookup & checking +- [/db.go](/db.go) - database setup diff --git a/examples/basics/01_hello_world/main.go b/examples/basics/01_hello_world/main.go new file mode 100644 index 0000000..a3edd3f --- /dev/null +++ b/examples/basics/01_hello_world/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "github.com/snackbag/compass" +) + +func main() { + server := compass.NewServer(compass.NewStandardConfiguration()) + + server.AddRoute("/", func(request compass.Request) compass.Response { + return compass.Text("hi hello hey") + }) + + server.MustRun() +} diff --git a/examples/basics/02_logging/main.go b/examples/basics/02_logging/main.go new file mode 100644 index 0000000..eb51747 --- /dev/null +++ b/examples/basics/02_logging/main.go @@ -0,0 +1,11 @@ +package main + +import "github.com/snackbag/compass" + +func main() { + server := compass.NewServer(compass.NewStandardConfiguration()) + + server.Logger.Info("I'm simply logging some things") + server.Logger.Warn("I warn you about stuff") + server.Logger.Error("I am a big red scary text") +} diff --git a/examples/basics/03_static_pages/assets/static/hi.png b/examples/basics/03_static_pages/assets/static/hi.png new file mode 100644 index 0000000..4619080 Binary files /dev/null and b/examples/basics/03_static_pages/assets/static/hi.png differ diff --git a/examples/basics/03_static_pages/assets/static/test.txt b/examples/basics/03_static_pages/assets/static/test.txt new file mode 100644 index 0000000..05a682b --- /dev/null +++ b/examples/basics/03_static_pages/assets/static/test.txt @@ -0,0 +1 @@ +Hello! \ No newline at end of file diff --git a/examples/basics/03_static_pages/main.go b/examples/basics/03_static_pages/main.go new file mode 100644 index 0000000..28a209f --- /dev/null +++ b/examples/basics/03_static_pages/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "github.com/snackbag/compass" +) + +// see result on localhost:3000/static/test.txt or hi.png +func main() { + server := compass.NewServer(compass.NewStandardConfiguration()) + server.MustRun() +} diff --git a/examples/basics/04_url_parameters/main.go b/examples/basics/04_url_parameters/main.go new file mode 100644 index 0000000..0c1e4c4 --- /dev/null +++ b/examples/basics/04_url_parameters/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "github.com/snackbag/compass" +) + +func main() { + server := compass.NewServer(compass.NewStandardConfiguration()) + + server.AddRoute("/@", func(request compass.Request) compass.Response { + param := request.GetRouteParam("username") // returns empty if not found + return compass.Text(param) + }) + + server.MustRun() +} diff --git a/examples/basics/05_json/main.go b/examples/basics/05_json/main.go new file mode 100644 index 0000000..5855cbd --- /dev/null +++ b/examples/basics/05_json/main.go @@ -0,0 +1,17 @@ +package main + +import "github.com/snackbag/compass" + +func main() { + server := compass.NewServer(compass.NewStandardConfiguration()) + + server.AddRoute("/", func(request compass.Request) compass.Response { + return compass.JsonString(`{"test": 123, "wow": ["a", "b", "c"]}`) + }) + + server.AddRoute("/object", func(request compass.Request) compass.Response { + return compass.JsonMarshal(server.Config) + }) + + server.MustRun() +} diff --git a/examples/basics/06_download/example.png b/examples/basics/06_download/example.png new file mode 100644 index 0000000..4955167 Binary files /dev/null and b/examples/basics/06_download/example.png differ diff --git a/examples/basics/06_download/main.go b/examples/basics/06_download/main.go new file mode 100644 index 0000000..a0e258b --- /dev/null +++ b/examples/basics/06_download/main.go @@ -0,0 +1,17 @@ +package main + +import "github.com/snackbag/compass" + +func main() { + server := compass.NewServer(compass.NewStandardConfiguration()) + + server.AddRoute("/", func(request compass.Request) compass.Response { + return compass.DownloadBytes("example.txt", []byte("helo ereveryione!!")) + }) + + server.AddRoute("/file", func(request compass.Request) compass.Response { + return compass.DownloadFile("file.png", "example.png") + }) + + server.MustRun() +} diff --git a/examples/basics/07_redirect/main.go b/examples/basics/07_redirect/main.go new file mode 100644 index 0000000..6c86461 --- /dev/null +++ b/examples/basics/07_redirect/main.go @@ -0,0 +1,17 @@ +package main + +import "github.com/snackbag/compass" + +func main() { + server := compass.NewServer(compass.NewStandardConfiguration()) + + server.AddRoute("/target", func(request compass.Request) compass.Response { + return compass.Text("Woohooo! You've been redirected :D") + }) + + server.AddRoute("/", func(request compass.Request) compass.Response { + return compass.Redirect("/target", false) + }) + + server.MustRun() +} diff --git a/examples/basics/08_serve/example.mp3 b/examples/basics/08_serve/example.mp3 new file mode 100644 index 0000000..a8659c9 Binary files /dev/null and b/examples/basics/08_serve/example.mp3 differ diff --git a/examples/basics/08_serve/main.go b/examples/basics/08_serve/main.go new file mode 100644 index 0000000..08b4541 --- /dev/null +++ b/examples/basics/08_serve/main.go @@ -0,0 +1,13 @@ +package main + +import "github.com/snackbag/compass" + +func main() { + server := compass.NewServer(compass.NewStandardConfiguration()) + + server.AddRoute("/", func(request compass.Request) compass.Response { + return compass.ServeFile("example.mp3", "example.mp3") + }) + + server.MustRun() +} diff --git a/examples/passplate/01_basic_fill/main.go b/examples/passplate/01_basic_fill/main.go new file mode 100644 index 0000000..20aa63f --- /dev/null +++ b/examples/passplate/01_basic_fill/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "github.com/snackbag/compass/passplate" +) + +func main() { + text := `

Good morning, <$username/>.

You are<%if role == "admin"/>an admin<%elif role == "cool"/>pretty cool<%else/>just chill like that<%end/>

+` + node, err := passplate.Read(text) + if err != nil { + fmt.Println(err) + } + fmt.Println(passplate.Represent(node, 0)) +} diff --git a/logging.go b/logging.go new file mode 100644 index 0000000..79ed32c --- /dev/null +++ b/logging.go @@ -0,0 +1,65 @@ +package compass + +import ( + "fmt" + "net/http" + "time" +) + +type Logger interface { + Info(message string) + Warn(message string) + Error(message string) + + Request(r *http.Request, code int) +} + +type SimpleLogger struct { + PrefixMaxLength int +} + +func (s *SimpleLogger) log(color string, prefix string, message string) { + currentTime := time.Now().Format("[2006-01-02 15:04:05]") + + if len(prefix) > s.PrefixMaxLength { + prefix = prefix[:s.PrefixMaxLength] + } + prefix = fmt.Sprintf("%-*s", s.PrefixMaxLength, prefix) + + fmt.Printf("%s %s%s\033[0m %s\033[0m\n", currentTime, color, prefix, message) +} + +func (s *SimpleLogger) Info(message string) { + s.log("\x1b[38;2;40;177;249m", "INFO", message) +} + +func (s *SimpleLogger) Warn(message string) { + s.log("\033[1;33m", "WARN", message) +} + +func (s *SimpleLogger) Error(message string) { + s.log("\033[1;31m", "ERROR", message) +} + +func (s *SimpleLogger) Request(r *http.Request, code int) { + var colorCode string + switch { + case code >= 200 && code < 300: + colorCode = "\033[1;32m" + case code >= 300 && code < 400: + colorCode = "\033[1;33m" + case code >= 400 && code < 600: + colorCode = "\033[1;31m" + default: + colorCode = "\033[1;37m" + } + + fmt.Printf( + "\x1b[0;34m%s %s%d\033[0m - \033[0;35m%s %s\033[0m \033[0;37m\"%s\"\033[0m\n", + r.RemoteAddr, colorCode, code, r.Method, r.URL.Path, r.UserAgent(), + ) +} + +func NewSimpleLogger() *SimpleLogger { + return &SimpleLogger{PrefixMaxLength: 5} +} diff --git a/passplate/ast.go b/passplate/ast.go new file mode 100644 index 0000000..4594a13 --- /dev/null +++ b/passplate/ast.go @@ -0,0 +1,35 @@ +package passplate + +import ( + "fmt" + "strings" +) + +func Represent(_n Node, indent int) string { + switch n := _n.(type) { + case *RootNode: + { + builder := strings.Builder{} + + for _, child := range n.Children { + i := strings.Repeat(" ", indent) + + builder.WriteString(i + Represent(child, indent)) + builder.WriteString("\n") + } + + return builder.String() + } + + case *TextNode: + return fmt.Sprintf("", n.Content) + + case *ExprNode: + return fmt.Sprintf("", n.Expression.Repr()) + + case *IfNode: + return n.Repr(indent) + } + + return "" +} diff --git a/passplate/builder.go b/passplate/builder.go new file mode 100644 index 0000000..2147ade --- /dev/null +++ b/passplate/builder.go @@ -0,0 +1,168 @@ +package passplate + +import ( + "fmt" + "strings" +) + +type BuildState int + +const ( + StateIdle = iota + StateExpr + StateIf + StateElif + StateElse +) + +type stateMachine struct { + states []BuildState +} + +func (m *stateMachine) Pop() { + m.states = m.states[:len(m.states)-1] + + if len(m.states) == 0 { + m.states = append(m.states, StateIdle) + } +} + +func (m *stateMachine) Push(state BuildState) { + m.states = append(m.states, state) +} + +func (m *stateMachine) State() BuildState { + return m.states[len(m.states)-1] +} + +func Read(raw string) (*RootNode, error) { + buffer := "" + s := &stateMachine{make([]BuildState, 0)} + s.states = append(s.states, StateIdle) + + root := NewRootNode() + cursor := root + inString := false + + for _, r := range []rune(raw) { + char := string(r) + buffer += char + + switch s.State() { + case StateIdle: + if strings.HasSuffix(buffer, "<$") { + s.Push(StateExpr) + + cursor.Children = appendBuffer(cursor.Children, buffer, "<$") + buffer = "" + cursor.Children = append(cursor.Children) + } else if strings.HasSuffix(buffer, "<%if ") { + s.Push(StateIf) + + cursor.Children = appendBuffer(cursor.Children, buffer, "<%if ") + buffer = "" + } else if strings.HasSuffix(buffer, "<%elif ") { + s.Push(StateElif) + + cursor.Children = appendBuffer(cursor.Children, buffer, "<%elif ") + buffer = "" + } else if strings.HasSuffix(buffer, "<%else/>") { + s.Push(StateElse) + + cursor.Children = appendBuffer(cursor.Children, buffer, "<%else/>") + buffer = "" + } else if strings.HasSuffix(buffer, "<%end/>") { + cursor.Children = appendBuffer(cursor.Children, buffer, "<%end/>") + buffer = "" + cursor = cursor.Parent + } + + case StateExpr: + if strings.HasSuffix(buffer, "\"") { + inString = !inString + } + + if !inString && strings.HasSuffix(buffer, "/>") { + s.Pop() + + expr, err := createExpression(strings.TrimSuffix(buffer, "/>")) + if err != nil { + return root, fmt.Errorf("failed to create expression: %s", err) + } + node := NewExprNode() + node.Expression = expr + cursor.Children = append(cursor.Children, node) + + buffer = "" + } + + case StateIf: + if strings.HasSuffix(buffer, "\"") { + inString = !inString + } + + if !inString && strings.HasSuffix(buffer, "/>") { + s.Pop() + + clause := NewRootNode() + clause.Parent = cursor + + expr, err := createBooleanExpr(strings.TrimSuffix(buffer, "/>")) + if err != nil { + return root, fmt.Errorf("failed to create if: %s", err) + } + node := &IfNode{IfClause: clause, IfExpr: expr, ElseIfs: make(map[*BooleanExpr]*RootNode)} + cursor.Children = append(cursor.Children, node) + cursor = clause + + buffer = "" + } + + case StateElif: + if strings.HasSuffix(buffer, "\"") { + inString = !inString + } + + if !inString && strings.HasSuffix(buffer, "/>") { + s.Pop() + + clause := NewRootNode() + clause.Parent = cursor.Parent + node := cursor.Parent.LastChild().(*IfNode) + + expr, err := createBooleanExpr(strings.TrimSuffix(buffer, "/>")) + if err != nil { + return root, fmt.Errorf("failed to create elif: %s", err) + } + node.ElseIfs[expr] = clause + cursor = clause + + buffer = "" + } + + case StateElse: + s.Pop() + + rn := NewRootNode() + rn.Parent = cursor.Parent + node := cursor.Parent.LastChild().(*IfNode) + node.ElseClause = rn + cursor = rn + } + } + + cursor.Children = append(cursor.Children, NewTextNode(buffer)) + return root, nil +} + +func createExpression(buffer string) (Expression, error) { + return &VariableExpr{Name: buffer}, nil +} + +func createBooleanExpr(buffer string) (*BooleanExpr, error) { + return &BooleanExpr{Right: &TextExpression{Value: "test"}, Left: &VariableExpr{Name: "admin"}}, nil +} + +func appendBuffer(children []Node, buffer string, suffix string) []Node { + return append(children, NewTextNode(strings.TrimSuffix(buffer, suffix))) +} diff --git a/passplate/expr.go b/passplate/expr.go new file mode 100644 index 0000000..1dd7cf0 --- /dev/null +++ b/passplate/expr.go @@ -0,0 +1,67 @@ +package passplate + +import "fmt" + +type ExpressionKind int + +const ( + VariableKind ExpressionKind = iota + TextKind + BooleanKind +) + +type Expression interface { + Eval() string + Repr() string + + Kind() ExpressionKind +} + +type VariableExpr struct { + Name string +} + +func (e *VariableExpr) Kind() ExpressionKind { + return VariableKind +} + +func (e *VariableExpr) Eval() string { + return "" +} + +func (e *VariableExpr) Repr() string { + return fmt.Sprintf("{$%s}", e.Name) +} + +type TextExpression struct { + Value string +} + +func (e *TextExpression) Kind() ExpressionKind { + return TextKind +} + +func (e *TextExpression) Eval() string { + return e.Value +} + +func (e *TextExpression) Repr() string { + return fmt.Sprintf("{%q}", e.Value) +} + +type BooleanExpr struct { + Left Expression + Right Expression +} + +func (e *BooleanExpr) Kind() ExpressionKind { + return BooleanKind +} + +func (e *BooleanExpr) Eval() string { + return "" +} + +func (e *BooleanExpr) Repr() string { + return fmt.Sprintf("{%s == %s}", e.Left.Repr(), e.Right.Repr()) +} diff --git a/passplate/node.go b/passplate/node.go new file mode 100644 index 0000000..f6cba0f --- /dev/null +++ b/passplate/node.go @@ -0,0 +1,115 @@ +package passplate + +import ( + "fmt" + "strings" +) + +type NodeKind int + +const ( + NodeRoot NodeKind = iota + NodeText + NodeExpr + NodeIf + NodeFor +) + +type Node interface { + Kind() NodeKind +} + +// +// Root Node +// + +type RootNode struct { + Children []Node + Parent *RootNode +} + +func (n *RootNode) Kind() NodeKind { + return NodeRoot +} + +func NewRootNode() *RootNode { + return &RootNode{make([]Node, 0), nil} +} + +func (n *RootNode) LastChild() Node { + return n.Children[len(n.Children)-1] +} + +// +// Text Node +// + +type TextNode struct { + Content string +} + +func (n *TextNode) Kind() NodeKind { + return NodeText +} + +func NewTextNode(content string) *TextNode { + return &TextNode{Content: content} +} + +// +// Expression Node +// + +type ExprNode struct { + Expression Expression +} + +func (n *ExprNode) Kind() NodeKind { + return NodeExpr +} + +func NewExprNode() *ExprNode { + return &ExprNode{} +} + +// +// If Node +// + +type IfNode struct { + IfExpr *BooleanExpr + IfClause *RootNode + + ElseIfs map[*BooleanExpr]*RootNode + + ElseClause *RootNode +} + +func (n *IfNode) Kind() NodeKind { + return NodeIf +} + +func (n *IfNode) Repr(index int) string { + builder := strings.Builder{} + + builder.WriteString(fmt.Sprintf("\n", n.IfExpr.Repr())) + builder.WriteString(Represent(n.IfClause, index+1)) + + for e, r := range n.ElseIfs { + builder.WriteString(fmt.Sprintf("\n", e.Repr())) + builder.WriteString(Represent(r, index+1)) + } + + if n.ElseClause != nil { + builder.WriteString(fmt.Sprintf("\n")) + builder.WriteString(Represent(n.ElseClause, index+1)) + } + + builder.WriteString("") + + return builder.String() +} + +func (n *IfNode) Eval() string { + return "" +} diff --git a/request.go b/request.go new file mode 100644 index 0000000..77fce2a --- /dev/null +++ b/request.go @@ -0,0 +1,113 @@ +package compass + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +type Request struct { + Method string + URL *url.URL + Route *Route + + Http *http.Request +} + +func NewRequestFromHttp(r *http.Request) Request { + return Request{ + Method: strings.ToLower(r.Method), + URL: r.URL, + + Http: r, + } +} + +func (s *Server) handleRequest(w http.ResponseWriter, r Request) error { + if r.Route == nil { + return s.handleNotFound(w, r) + } + + resp := r.Route.handler(r) + if resp.internalError { + return errors.New(string(resp.Body)) + } + + for key, value := range resp.Headers { + if strings.HasPrefix(key, "--COMPASS") { + continue + } + + w.Header().Set(key, value) + } + + if resp.ContentType != nil { + switch *resp.ContentType { + case "--COMPASS-redirect": + { + http.Redirect(w, r.Http, string(resp.Body), resp.StatusCode) + s.Logger.Request(r.Http, resp.StatusCode) + return nil + } + case "--COMPASS-serve": + { + rs := bytes.NewReader(resp.Body) + http.ServeContent(w, r.Http, resp.Headers["-Compass-File-Name"], time.Now(), rs) + s.Logger.Request(r.Http, resp.StatusCode) + return nil + } + } + + if *resp.ContentType == "--COMPASS-redirect" { + http.Redirect(w, r.Http, string(resp.Body), resp.StatusCode) + return nil + } + + w.Header().Set("Content-Type", *resp.ContentType) + } else { + w.Header().Set("Content-Type", r.Http.Header.Get("Content-Type")) + } + w.WriteHeader(resp.StatusCode) + + _, err := w.Write(resp.Body) + if err != nil { + return err + } + + s.Logger.Request(r.Http, resp.StatusCode) + return nil +} + +// TODO add customizability +func (s *Server) handleNotFound(w http.ResponseWriter, r Request) error { + return s.write(w, r.Http, []byte(fmt.Sprintf("

Not Found

The requested route %s was not found on this server.

", r.URL.Path)), http.StatusNotFound) +} + +// GetRouteParam gets the requested content of the requested id. If nothing was found, it will return an empty string. +func (r *Request) GetRouteParam(id string) string { + if len(id) < 1 { + return "" + } + + index, ok := r.Route.partIdMap[id] + if !ok { + return "" + } + + split := splitUrlPath(r.URL.Path) + if index > len(split)-1 { + return "" + } + + part := r.Route.parts[index] + + value := split[index] + value = strings.TrimPrefix(value, part.prefix) + value = strings.TrimSuffix(value, part.suffix) + + return value +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..1253a8a --- /dev/null +++ b/response.go @@ -0,0 +1,143 @@ +package compass + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "regexp" + "strings" +) + +var asciiFallback = regexp.MustCompile(`[^A-Za-z0-9 ._-]+`) + +type Response struct { + internalError bool + + ContentType *string + Body []byte + StatusCode int + Headers map[string]string +} + +func Raw(contentType *string, body []byte, code int) Response { + return Response{ + internalError: false, + + ContentType: contentType, + Body: body, + StatusCode: code, + Headers: make(map[string]string), + } +} + +func InternalError(message string, code int) Response { + return Response{ + internalError: true, + + ContentType: nil, + Body: []byte(message), + StatusCode: code, + Headers: make(map[string]string), + } +} + +func Text(text string) Response { + return TextWithCode(text, 200) +} + +func TextWithCode(text string, code int) Response { + return Raw(nil, []byte(text), code) +} + +func JsonString(content string) Response { + return JsonStringWithCode(content, 200) +} + +func JsonStringWithCode(content string, code int) Response { + typ := "application/json" + return Raw(&typ, []byte(content), code) +} + +func JsonMarshal(obj any) Response { + return JsonMarshalWithCode(obj, 200) +} + +func JsonMarshalWithCode(obj any, code int) Response { + content, err := json.Marshal(obj) + if err != nil { + return InternalError(fmt.Sprintf("failed to marshal json object: %s", err), 500) + } + + return JsonStringWithCode(string(content), code) +} + +func DownloadBytes(filename string, data []byte) Response { + return DownloadBytesWithCode(filename, data, 200) +} + +func DownloadBytesWithCode(filename string, data []byte, code int) Response { + ascii := strings.TrimSpace(filename) + ascii = asciiFallback.ReplaceAllString(ascii, "_") + + if ascii == "" { + ascii = "download" + } + + resp := Raw(nil, data, code) + resp.Headers["Content-Disposition"] = `attachment; filename="` + ascii + `"; filename*=UTF-8''` + url.PathEscape(filename) + return resp +} + +func DownloadFile(filename string, path string) Response { + return DownloadFileWithCode(filename, path, 200) +} + +func DownloadFileWithCode(filename string, path string, code int) Response { + data, err := os.ReadFile(path) + if err != nil { + return InternalError(fmt.Sprintf("failed to prepare file data for download: %s", err), 500) + } + + return DownloadBytesWithCode(filename, data, code) +} + +func Redirect(target string, retainMethod bool) Response { + typ := "--COMPASS-redirect" + status := http.StatusSeeOther + if retainMethod { + status = http.StatusTemporaryRedirect + } + + return Raw(&typ, []byte(target), status) +} + +func PermaRedirect(target string) Response { + typ := "--COMPASS-redirect" + return Raw(&typ, []byte(target), http.StatusPermanentRedirect) +} + +func ServeBytes(data []byte, name string) Response { + return ServeBytesWithCode(data, name, 200) +} + +func ServeBytesWithCode(data []byte, name string, code int) Response { + typ := "--COMPASS-serve" + raw := Raw(&typ, data, code) + raw.Headers["-Compass-File-Name"] = name + return raw +} + +func ServeFile(path string, name string) Response { + return ServeFileWithCode(path, name, 200) +} + +func ServeFileWithCode(path string, name string, code int) Response { + data, err := os.ReadFile(path) + if err != nil { + return InternalError(fmt.Sprintf("failed to prepare file data for serve: %s", err), 500) + } + + return ServeBytesWithCode(data, name, code) +} diff --git a/route.go b/route.go new file mode 100644 index 0000000..8517da6 --- /dev/null +++ b/route.go @@ -0,0 +1,134 @@ +package compass + +import ( + "fmt" + "regexp" + "strings" +) + +var routeParamRegex = regexp.MustCompile("(<.+>)") + +type routePart struct { + id string + prefix string + suffix string +} + +type Route struct { + parts []routePart + partIdMap map[string]int + handler func(request Request) Response + + repr string +} + +func (r *Route) ToString() string { + return r.repr +} + +func (r *Route) matchesRawParts(split []string) bool { + for i, str := range split { + part := r.parts[i] + if !strings.HasPrefix(str, part.prefix) { + return false + } + + if !strings.HasSuffix(str, part.suffix) { + return false + } + + minLen := len(part.prefix) + len(part.suffix) + maxLen := minLen + + if part.id != "" { + maxLen = 2048 + } + + if len(str) < minLen || len(str) > maxLen { + return false + } + } + + return true +} + +func (s *Server) AddRoute(path string, handler func(request Request) Response) { + parts := createParts(path) + length := len(parts) + + if length < 1 { + s.Logger.Error(fmt.Sprintf("Skipped adding route %q, because it seems to be empty", path)) + return + } + + if _, ok := s.routes[length]; !ok { + s.routes[length] = make([]*Route, 0) + } + + partIdMap := make(map[string]int) + for i, part := range parts { + partIdMap[part.id] = i + } + + route := &Route{ + parts: parts, + partIdMap: partIdMap, + handler: handler, + + repr: path, + } + + s.routes[length] = append(s.routes[length], route) +} + +func createParts(path string) []routePart { + split := splitUrlPath(path) + parts := make([]routePart, 0) + + for _, raw := range split { + if raw == "" { + continue + } + + match := routeParamRegex.FindStringSubmatch(raw) + if match == nil { + parts = append(parts, routePart{id: "", prefix: raw, suffix: ""}) + continue + } + + id := strings.ToLower(match[1]) // name inside <> + id = id[1 : len(id)-1] + rawId := match[0] // full <...> + idx := strings.Index(raw, rawId) + + prefix := raw[:idx] + suffix := raw[idx+len(rawId):] + + parts = append(parts, routePart{id: id, prefix: prefix, suffix: suffix}) + } + + if len(parts) == 0 { + parts = append(parts, routePart{id: "", prefix: "", suffix: ""}) + } + + return parts +} + +func (s *Server) FindRoute(path string) *Route { + split := splitUrlPath(path) + + candidates, ok := s.routes[len(split)] + if !ok { + return nil + } + + for _, candidate := range candidates { + if !candidate.matchesRawParts(split) { + continue + } + + return candidate + } + + return nil +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..b4e47b0 --- /dev/null +++ b/server.go @@ -0,0 +1,166 @@ +package compass + +import ( + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" +) + +type ServerConfiguration struct { + Port uint16 `json:"port"` + AssetDir string `json:"asset_dir"` + StaticUrl string `json:"static_url"` + CompassDir string `json:"compass_dir"` +} + +type Server struct { + Config ServerConfiguration + Logger Logger + AlertHandler func(err error) + + routes map[int][]*Route // int = length +} + +func NewStandardConfiguration() ServerConfiguration { + return ServerConfiguration{ + Port: 3000, + AssetDir: "assets", + StaticUrl: "/static", + CompassDir: ".compass", + } +} + +// CheckValidity returns an empty string when valid, otherwise a list of human-readable reasons why the config is invalid +func (c ServerConfiguration) CheckValidity() string { + rv := "" + + if len(c.AssetDir) < 1 { + rv += "asset directory value is empty;" + } + + if len(c.StaticUrl) < 1 { + rv += "static url is empty;" + } else if !strings.HasPrefix(c.StaticUrl, "/") { + rv += "static url must start with /;" + } + + if len(c.CompassDir) < 1 { + rv += "compass directory value is empty;" + } + + return strings.TrimSuffix(rv, ";") +} + +func NewServer(config ServerConfiguration) *Server { + return &Server{ + Config: config, + Logger: NewSimpleLogger(), + AlertHandler: func(err error) {}, + + routes: make(map[int][]*Route), + } +} + +// Run starts the server, returns an error when the server crashes during startup. All other errors are handled by the Server.AlertHandler +func (s *Server) Run() error { + configValidity := s.Config.CheckValidity() + if configValidity != "" { + return fmt.Errorf("config invalid: %s", configValidity) + } + + s.Logger.Info(fmt.Sprintf("Server is listening on :%d", s.Config.Port)) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + request := NewRequestFromHttp(r) + request.Route = s.FindRoute(r.URL.Path) + + if strings.HasPrefix(request.URL.Path, s.Config.StaticUrl) { + err := s.writeStatic(w, request, s.Config.AssetDir, strings.TrimPrefix(filepath.Clean(request.URL.Path), s.Config.StaticUrl)) + if err != nil { + s.writeError(w, r, err) + } + + return + } + + err := s.handleRequest(w, request) + if err != nil { + s.writeError(w, r, err) + } + }) + + return http.ListenAndServe(fmt.Sprintf(":%d", s.Config.Port), nil) +} + +func (s *Server) MustRun() { + err := s.Run() + if err != nil { + log.Fatalf("failed to start: %s", err) + } +} + +func (s *Server) writeStatic(w http.ResponseWriter, request Request, assetDir string, target string) error { + path := filepath.Join(assetDir, "static", target) + + _, err := os.Stat(path) + if err != nil { + return s.handleNotFound(w, request) + } + + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return err + } + + s.Logger.Request(request.Http, http.StatusOK) + http.ServeContent(w, request.Http, target, stat.ModTime(), file) + return nil +} + +func (s *Server) write(w http.ResponseWriter, r *http.Request, data []byte, status int) error { + s.Logger.Request(r, status) + + w.WriteHeader(status) + _, err := w.Write(data) + if err != nil { + return fmt.Errorf("failed to write byte data for status %d: %v", status, err) + } + + return nil +} + +func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) { + s.Logger.Request(r, http.StatusInternalServerError) + s.Logger.Error(fmt.Sprintf("Soft capture: %v", err)) + s.AlertHandler(err) + + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("There was an internal server error. Try again later.")) +} + +func splitUrlPath(path string) []string { + raw := strings.Split(path, "/") + split := make([]string, 0) + + for _, part := range raw { + if part == "" { + continue + } + + split = append(split, part) + } + + if len(split) < 1 { + split = append(split, "") + } + + return split +} \ No newline at end of file diff --git a/v1/README.md b/v1/README.md new file mode 100644 index 0000000..fc21643 --- /dev/null +++ b/v1/README.md @@ -0,0 +1,2 @@ +# Compass +A Poorly Designed Go Web Framework diff --git a/compass/communication.go b/v1/compass/communication.go similarity index 100% rename from compass/communication.go rename to v1/compass/communication.go diff --git a/compass/component.go b/v1/compass/component.go similarity index 100% rename from compass/component.go rename to v1/compass/component.go diff --git a/compass/fill_html.go b/v1/compass/fill_html.go similarity index 100% rename from compass/fill_html.go rename to v1/compass/fill_html.go diff --git a/compass/main.go b/v1/compass/main.go similarity index 100% rename from compass/main.go rename to v1/compass/main.go diff --git a/compass/session.go b/v1/compass/session.go similarity index 100% rename from compass/session.go rename to v1/compass/session.go diff --git a/compass/simple_logger.go b/v1/compass/simple_logger.go similarity index 100% rename from compass/simple_logger.go rename to v1/compass/simple_logger.go diff --git a/components/test.html b/v1/components/test.html similarity index 100% rename from components/test.html rename to v1/components/test.html diff --git a/v1/go.mod b/v1/go.mod new file mode 100644 index 0000000..b55a58f --- /dev/null +++ b/v1/go.mod @@ -0,0 +1,3 @@ +module github.com/snackbag/compass + +go 1.22.7 diff --git a/main.go b/v1/main.go similarity index 100% rename from main.go rename to v1/main.go diff --git a/static/test.txt b/v1/static/test.txt similarity index 100% rename from static/test.txt rename to v1/static/test.txt diff --git a/templates/example.html b/v1/templates/example.html similarity index 100% rename from templates/example.html rename to v1/templates/example.html