diff --git a/.gitignore b/.gitignore index a5d8f72..c572307 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin/ dist/ +root/.private diff --git a/core/admin.go b/core/admin.go new file mode 100644 index 0000000..36fd626 --- /dev/null +++ b/core/admin.go @@ -0,0 +1,10 @@ +package core + +import ( + "net/http" +) + +// SetupHandler is the initial blog setup route. +func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { + b.RenderTemplate(w, r, "admin/setup", nil) +} diff --git a/core/app.go b/core/app.go index 321710f..eaa75d1 100644 --- a/core/app.go +++ b/core/app.go @@ -10,6 +10,8 @@ import ( // Blog is the root application object that maintains the app configuration // and helper objects. type Blog struct { + Debug bool + // DocumentRoot is the core static files root; UserRoot masks over it. DocumentRoot string UserRoot string @@ -27,6 +29,7 @@ func New(documentRoot, userRoot string) *Blog { } r := mux.NewRouter() blog.r = r + r.HandleFunc("/admin/setup", blog.SetupHandler) r.HandleFunc("/", blog.PageHandler) r.NotFoundHandler = http.HandlerFunc(blog.PageHandler) diff --git a/core/pages.go b/core/pages.go index ffd1b27..1afd8ab 100644 --- a/core/pages.go +++ b/core/pages.go @@ -1,6 +1,7 @@ package core import ( + "errors" "net/http" "os" "path/filepath" @@ -13,13 +14,64 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Remove trailing slashes by redirecting them away. if len(path) > 1 && path[len(path)-1] == '/' { - Redirect(w, strings.TrimRight(path, "/")) + b.Redirect(w, strings.TrimRight(path, "/")) + return + } + + // Restrict special paths. + if strings.HasPrefix(strings.ToLower(path), "/.") { + b.Forbidden(w, r) return } // Search for a file that matches their URL. + filepath, err := b.ResolvePath(path) + if err != nil { + b.NotFound(w, r, "The page you were looking for was not found.") + return + } + + // Is it a template file? + if strings.HasSuffix(filepath.URI, ".gohtml") || strings.HasSuffix(filepath.URI, ".html") { + b.RenderTemplate(w, r, filepath.URI, nil) + return + } + + http.ServeFile(w, r, filepath.Absolute) +} + +// Filepath represents a file discovered in the document roots, and maintains +// both its relative and absolute components. +type Filepath struct { + // Canonicalized URI version of the file resolved on disk, + // possible with a file extension injected. + // (i.e. "/about" -> "about.html") + URI string + Relative string // Relative path including document root (i.e. "root/about.html") + Absolute string // Absolute path on disk (i.e. "/opt/blog/root/about.html") +} + +func (f Filepath) String() string { + return f.Relative +} + +// ResolvePath matches a filesystem path to a relative request URI. +// +// This checks the UserRoot first and then the DocumentRoot. This way the user +// may override templates from the core app's document root. +func (b *Blog) ResolvePath(path string) (Filepath, error) { + // Strip leading slashes. + if path[0] == '/' { + path = strings.TrimPrefix(path, "/") + } + log.Debug("Resolving filepath for URI: %s", path) for _, root := range []string{b.DocumentRoot, b.UserRoot} { + if len(root) == 0 { + continue + } + + // Resolve the file path. relPath := filepath.Join(root, path) absPath, err := filepath.Abs(relPath) if err != nil { @@ -31,26 +83,26 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Found an exact hit? if stat, err := os.Stat(absPath); !os.IsNotExist(err) && !stat.IsDir() { log.Debug("Exact filepath found: %s", absPath) - http.ServeFile(w, r, absPath) - return + return Filepath{path, relPath, absPath}, nil } // Try some supported suffixes. suffixes := []string{ + ".gohtml", ".html", + "/index.gohtml", "/index.html", ".md", "/index.md", } for _, suffix := range suffixes { - if stat, err := os.Stat(absPath + suffix); !os.IsNotExist(err) && !stat.IsDir() { - log.Debug("Filepath found via suffix %s: %s", suffix, absPath+suffix) - http.ServeFile(w, r, absPath+suffix) - return + test := absPath + suffix + if stat, err := os.Stat(test); !os.IsNotExist(err) && !stat.IsDir() { + log.Debug("Filepath found via suffix %s: %s", suffix, test) + return Filepath{path + suffix, relPath + suffix, test}, nil } } } - // No file, must be a 404. - http.NotFound(w, r) + return Filepath{}, errors.New("not found") } diff --git a/core/responses.go b/core/responses.go index cec892c..3a5fda5 100644 --- a/core/responses.go +++ b/core/responses.go @@ -1,9 +1,36 @@ package core -import "net/http" +import ( + "net/http" +) // Redirect sends an HTTP redirect response. -func Redirect(w http.ResponseWriter, location string) { +func (b *Blog) Redirect(w http.ResponseWriter, location string) { w.Header().Set("Location", location) w.WriteHeader(http.StatusFound) } + +// NotFound sends a 404 response. +func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...string) { + if len(message) == 0 { + message = []string{"The page you were looking for was not found."} + } + + w.WriteHeader(http.StatusNotFound) + err := b.RenderTemplate(w, r, ".errors/404", map[string]interface{}{ + "message": message[0], + }) + if err != nil { + log.Error(err.Error()) + http.NotFound(w, r) + } +} + +// Forbidden sends an HTTP 400 Forbidden response. +func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) { + w.WriteHeader(http.StatusForbidden) + err := b.RenderTemplate(w, r, ".errors/403", nil) + if err != nil { + http.NotFound(w, r) + } +} diff --git a/core/templates.go b/core/templates.go new file mode 100644 index 0000000..9b00c4f --- /dev/null +++ b/core/templates.go @@ -0,0 +1,61 @@ +package core + +import ( + "html/template" + "net/http" +) + +// DefaultVars combines template variables with default, globally available vars. +func (b *Blog) DefaultVars(vars map[string]interface{}) map[string]interface{} { + defaults := map[string]interface{}{ + "title": "Untitled Blog", + } + if vars == nil { + return defaults + } + + for k, v := range defaults { + vars[k] = v + } + + return vars +} + +// RenderTemplate responds with an HTML template. +func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path string, vars map[string]interface{}) error { + // Get the layout template. + layout, err := b.ResolvePath(".layout") + if err != nil { + log.Error("RenderTemplate(%s): layout template not found", path) + return err + } + + // And the template in question. + filepath, err := b.ResolvePath(path) + if err != nil { + log.Error("RenderTemplate(%s): file not found", path) + return err + } + + // Parse the template files. The layout comes first because it's the wrapper + // and allows the filepath template to set the page title. + t, err := template.ParseFiles(layout.Absolute, filepath.Absolute) + if err != nil { + log.Error(err.Error()) + return err + } + + // Inject globally available variables. + vars = b.DefaultVars(vars) + + w.Header().Set("Content-Type", "text/html; encoding=UTF-8") + err = t.ExecuteTemplate(w, "layout", vars) + if err != nil { + log.Error("Template parsing error: %s", err) + return err + } + + log.Debug("Parsed template") + + return nil +} diff --git a/main.go b/main.go index ba33a78..dada670 100644 --- a/main.go +++ b/main.go @@ -34,5 +34,6 @@ func main() { flag.Parse() app := core.New(DocumentRoot, "") + app.Debug = fDebug app.ListenAndServe(fAddress) } diff --git a/root/.errors/403.gohtml b/root/.errors/403.gohtml new file mode 100644 index 0000000..fe7fac7 --- /dev/null +++ b/root/.errors/403.gohtml @@ -0,0 +1,3 @@ +{{ define "content" }} +

403 Forbidden

+{{ end }} diff --git a/root/.errors/404.gohtml b/root/.errors/404.gohtml new file mode 100644 index 0000000..d40ec2d --- /dev/null +++ b/root/.errors/404.gohtml @@ -0,0 +1,6 @@ +{{ define "title" }}Not Found{{ end }} +{{ define "content" }} +

404 Not Found

+ +{{ .message }} +{{ end }} diff --git a/root/.layout.gohtml b/root/.layout.gohtml new file mode 100644 index 0000000..ffe9055 --- /dev/null +++ b/root/.layout.gohtml @@ -0,0 +1,132 @@ +{{ define "title" }}Untitled{{ end }} +{{ define "layout" }} + + + + + + + {{ template "title" or "Untitled" }} - {{ .title }} + + + + + + + + + +
+
+

{{ .title }}

+

Just another web blog.

+
+
+ +
+
+
+ {{ template "content" . }} +
+
+ +
+
+

About

+ +

Hello, world!

+
+
+ +
+
+

Archives

+ + +
+
+ +
+
+

Elsewhere

+ + +
+
+ +
+
+
+ + + + + +{{ end }} diff --git a/root/admin/setup.gohtml b/root/admin/setup.gohtml new file mode 100644 index 0000000..ee2f456 --- /dev/null +++ b/root/admin/setup.gohtml @@ -0,0 +1,39 @@ +{{ define "title" }}Initial Setup{{ end }} +{{ define "content" }} +

Initial Setup

+ +

+ Welcome to your new web blog! To get started, you'll need to create a username + and password to be your admin user. You can create additional + users for your blog in a later step. +

+ +

+ It is not recommended to name this user "admin" because that would be very + predictable for an attacker to guess. +

+ +
+
+ + +
+
+ + + + Choose an appropriately strong password. + +
+
+ + +
+ + +
+{{ end }} diff --git a/root/bluez/theme.css b/root/bluez/theme.css new file mode 100644 index 0000000..f6fb809 --- /dev/null +++ b/root/bluez/theme.css @@ -0,0 +1,76 @@ +/* + * Globals + */ + +* { + transition-duration: 0.5s; +} + +body { + font-family: Verdana, Helvetica, Arial, sans-serif; + color: #555; + margin-top: 80px; + background-color: #DEF; +} + +h1, .h1, +h2, .h2, +h3, .h3, +h4, .h4, +h5, .h5, +h6, .h6 { + font-family: "Trebuchet MS", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: normal; + color: #333; +} + +/* + * Bootstrap tweaks and overrides + */ +.form-group label { + font-weight: bold; +} + +/* + * Top nav + */ +.bluez-navbar { + background-color: rgba(0, 75, 153, 0.8); +} +.bluez-navbar a { + color: #DDD; +} +.bluez-navbar a:hover, .bluez-navbar a:active { + color: #FFF !important; +} +.bluez-navbar .active a { + color: #FFF; +} + +/* Blog title and description */ +.bluez-header { + padding-bottom: 1.25rem; + margin-bottom: 2rem; + border-bottom: .05rem solid #CCC; +} +.bluez-title { + margin-bottom: 0; + font-size: 2rem; + font-weight: 400; +} +.bluez-description { + font-size: 1.1rem; + color: #999; +} + +/* Page footer */ +.bluez-footer { + padding: 2.5rem 0; + color: #999; + text-align: center; + background-color: #EEF; + border-top: .05rem solid #CCC; +} +.bluez-footer p:last-child { + margin-bottom: 0; +} diff --git a/root/index.gohtml b/root/index.gohtml new file mode 100644 index 0000000..d0448c0 --- /dev/null +++ b/root/index.gohtml @@ -0,0 +1,4 @@ +{{ define "title" }}Welcome{{ end }} +{{ define "content" }} +

Index

+{{ end }} diff --git a/root/index.html b/root/index.html deleted file mode 100644 index 85fc7b1..0000000 --- a/root/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - Blog - - - -

Index

- - -