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": "
"}, + {"name": "Variable", "id": "name"}, + {"name": "Text", "id": "
"} +] + +/* + +your name is cool
+<% else %> +ur not cool
+<% end %> + +*/ + +[ + {"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("/@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("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