diff options
Diffstat (limited to 'system')
-rw-r--r-- | system/admin/admin.go | 128 | ||||
-rw-r--r-- | system/admin/config/config.go | 8 | ||||
-rw-r--r-- | system/admin/handlers.go | 591 | ||||
-rw-r--r-- | system/admin/server.go | 3 | ||||
-rw-r--r-- | system/admin/static/common/js/util.js | 16 | ||||
-rw-r--r-- | system/admin/static/dashboard/css/admin.css | 26 | ||||
-rw-r--r-- | system/admin/upload/upload.go (renamed from system/admin/upload.go) | 5 | ||||
-rw-r--r-- | system/api/analytics/init.go | 101 | ||||
-rw-r--r-- | system/api/external.go | 109 | ||||
-rw-r--r-- | system/api/handlers.go | 80 | ||||
-rw-r--r-- | system/api/server.go | 2 | ||||
-rw-r--r-- | system/db/content.go | 124 | ||||
-rw-r--r-- | system/db/init.go | 18 | ||||
-rw-r--r-- | system/db/user.go | 114 | ||||
-rw-r--r-- | system/tls/enable.go | 79 |
15 files changed, 1245 insertions, 159 deletions
diff --git a/system/admin/admin.go b/system/admin/admin.go index b375eb6..9ee86c3 100644 --- a/system/admin/admin.go +++ b/system/admin/admin.go @@ -4,9 +4,12 @@ package admin import ( "bytes" + "encoding/json" "html/template" + "net/http" "github.com/bosssauce/ponzu/content" + "github.com/bosssauce/ponzu/system/admin/user" "github.com/bosssauce/ponzu/system/db" ) @@ -241,6 +244,129 @@ func Login() ([]byte, error) { return buf.Bytes(), nil } +// UsersList ... +func UsersList(req *http.Request) ([]byte, error) { + html := ` + <div class="card user-management"> + <div class="card-title">Edit your account:</div> + <form class="row" enctype="multipart/form-data" action="/admin/configure/users/edit" method="post"> + <div class="input-feild col s9"> + <label class="active">Email Address</label> + <input type="email" name="email" value="{{ .User.Email }}"/> + </div> + + <div class="input-feild col s9"> + <div>To approve changes, enter your password:</div> + + <label class="active">Current Password</label> + <input type="password" name="password"/> + </div> + + <div class="input-feild col s9"> + <label class="active">New Password: (leave blank if no password change needed)</label> + <input name="new_password" type="password"/> + </div> + + <div class="input-feild col s9"> + <button class="btn waves-effect waves-light green right" type="submit">Save</button> + </div> + </form> + + <div class="card-title">Add a new user:</div> + <form class="row" enctype="multipart/form-data" action="/admin/configure/users" method="post"> + <div class="input-feild col s9"> + <label class="active">Email Address</label> + <input type="email" name="email" value=""/> + </div> + + <div class="input-feild col s9"> + <label class="active">Password</label> + <input type="password" name="password"/> + </div> + + <div class="input-feild col s9"> + <button class="btn waves-effect waves-light green right" type="submit">Add User</button> + </div> + </form> + + <div class="card-title">Remove Admin Users</div> + <ul class="users row"> + {{ range .Users }} + <li class="col s9"> + {{ .Email }} + <form enctype="multipart/form-data" class="delete-user __ponzu right" action="/admin/configure/users/delete" method="post"> + <span>Delete</span> + <input type="hidden" name="email" value="{{ .Email }}"/> + <input type="hidden" name="id" value="{{ .ID }}"/> + </form> + </li> + {{ end }} + </ul> + </div> + ` + script := ` + <script> + $(function() { + var del = $('.delete-user.__ponzu span'); + del.on('click', function(e) { + if (confirm("[Ponzu] Please confirm:\n\nAre you sure you want to delete this user?\nThis cannot be undone.")) { + $(e.target).parent().submit(); + } + }); + }); + </script> + ` + // get current user out to pass as data to execute template + j, err := db.CurrentUser(req) + if err != nil { + return nil, err + } + + var usr user.User + err = json.Unmarshal(j, &usr) + if err != nil { + return nil, err + } + + // get all users to list + jj, err := db.UserAll() + if err != nil { + return nil, err + } + + var usrs []user.User + for i := range jj { + var u user.User + err = json.Unmarshal(jj[i], &u) + if err != nil { + return nil, err + } + if u.Email != usr.Email { + usrs = append(usrs, u) + } + } + + // make buffer to execute html into then pass buffer's bytes to Admin + buf := &bytes.Buffer{} + tmpl := template.Must(template.New("users").Parse(html + script)) + data := map[string]interface{}{ + "User": usr, + "Users": usrs, + } + + err = tmpl.Execute(buf, data) + if err != nil { + return nil, err + } + + view, err := Admin(buf.Bytes()) + if err != nil { + return nil, err + } + + return view, nil +} + var err400HTML = ` <div class="error-page e400 col s6"> <div class="card"> @@ -288,7 +414,7 @@ var err405HTML = ` <div class="card"> <div class="card-content"> <div class="card-title"><b>405</b> Error: Method Not Allowed</div> - <blockquote>Sorry, the page you requested could not be found.</blockquote> + <blockquote>Sorry, the method of your request is not allowed.</blockquote> </div> </div> </div> diff --git a/system/admin/config/config.go b/system/admin/config/config.go index c83c311..66f767d 100644 --- a/system/admin/config/config.go +++ b/system/admin/config/config.go @@ -12,6 +12,7 @@ type Config struct { Name string `json:"name"` Domain string `json:"domain"` + AdminEmail string `json:"admin_email"` ClientSecret string `json:"client_secret"` Etag string `json:"etag"` CacheInvalidate []string `json:"-"` @@ -48,8 +49,13 @@ func (c *Config) MarshalEditor() ([]byte, error) { }), }, editor.Field{ + View: editor.Input("AdminEmail", c, map[string]string{ + "label": "Adminstrator Email (will be notified of internal system information)", + }), + }, + editor.Field{ View: editor.Input("ClientSecret", c, map[string]string{ - "label": "Client Secret (used to validate requests)", + "label": "Client Secret (used to validate requests, DO NOT SHARE)", "disabled": "true", }), }, diff --git a/system/admin/handlers.go b/system/admin/handlers.go index de340ae..742b898 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -14,16 +14,19 @@ import ( "github.com/bosssauce/ponzu/management/editor" "github.com/bosssauce/ponzu/management/manager" "github.com/bosssauce/ponzu/system/admin/config" + "github.com/bosssauce/ponzu/system/admin/upload" "github.com/bosssauce/ponzu/system/admin/user" + "github.com/bosssauce/ponzu/system/api" "github.com/bosssauce/ponzu/system/db" + "github.com/gorilla/schema" "github.com/nilslice/jwt" ) func adminHandler(res http.ResponseWriter, req *http.Request) { view, err := Admin(nil) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -42,7 +45,7 @@ func initHandler(res http.ResponseWriter, req *http.Request) { case http.MethodGet: view, err := Init() if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -52,7 +55,7 @@ func initHandler(res http.ResponseWriter, req *http.Request) { case http.MethodPost: err := req.ParseForm() if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -66,20 +69,22 @@ func initHandler(res http.ResponseWriter, req *http.Request) { etag := db.NewEtag() req.Form.Set("etag", etag) - err = db.SetConfig(req.Form) - if err != nil { - fmt.Println(err) - res.WriteHeader(http.StatusInternalServerError) - return - } - email := strings.ToLower(req.FormValue("email")) password := req.FormValue("password") usr := user.NewUser(email, password) _, err = db.SetUser(usr) if err != nil { - fmt.Println(err) + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + // set initial user email as admin_email and make config + req.Form.Set("admin_email", email) + err = db.SetConfig(req.Form) + if err != nil { + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -98,6 +103,7 @@ func initHandler(res http.ResponseWriter, req *http.Request) { Name: "_token", Value: token, Expires: week, + Path: "/", }) redir := strings.TrimSuffix(req.URL.String(), "/init") @@ -113,7 +119,7 @@ func configHandler(res http.ResponseWriter, req *http.Request) { case http.MethodGet: data, err := db.ConfigAll() if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -122,21 +128,21 @@ func configHandler(res http.ResponseWriter, req *http.Request) { err = json.Unmarshal(data, c) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } cfg, err := c.MarshalEditor() if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } adminView, err := Admin(cfg) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -147,14 +153,14 @@ func configHandler(res http.ResponseWriter, req *http.Request) { case http.MethodPost: err := req.ParseForm() if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } err = db.SetConfig(req.Form) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -170,10 +176,246 @@ func configHandler(res http.ResponseWriter, req *http.Request) { func configUsersHandler(res http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: - // list all users and delete buttons + view, err := UsersList(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + res.Write(view) case http.MethodPost: // create new user + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + email := strings.ToLower(req.FormValue("email")) + password := req.PostFormValue("password") + + if email == "" || password == "" { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + usr := user.NewUser(email, password) + + _, err = db.SetUser(usr) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + http.Redirect(res, req, req.URL.String(), http.StatusFound) + + default: + res.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func configUsersEditHandler(res http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodPost: + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + + // check if user to be edited is current user + j, err := db.CurrentUser(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + usr := &user.User{} + err = json.Unmarshal(j, usr) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + // check if password matches + password := req.PostFormValue("password") + + if !user.IsUser(usr, password) { + log.Println("Unexpected user/password combination for", usr.Email) + res.WriteHeader(http.StatusBadRequest) + errView, err := Error405() + if err != nil { + return + } + + res.Write(errView) + return + } + + email := strings.ToLower(req.PostFormValue("email")) + newPassword := req.PostFormValue("new_password") + var updatedUser *user.User + if newPassword != "" { + updatedUser = user.NewUser(email, newPassword) + } else { + updatedUser = user.NewUser(email, password) + } + + // set the ID to the same ID as current user + updatedUser.ID = usr.ID + + // set user in db + err = db.UpdateUser(usr, updatedUser) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + // create new token + week := time.Now().Add(time.Hour * 24 * 7) + claims := map[string]interface{}{ + "exp": week, + "user": updatedUser.Email, + } + token, err := jwt.New(claims) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + // add token to cookie +1 week expiration + cookie := &http.Cookie{ + Name: "_token", + Value: token, + Expires: week, + Path: "/", + } + http.SetCookie(res, cookie) + + // add new token cookie to the request + req.AddCookie(cookie) + + http.Redirect(res, req, strings.TrimSuffix(req.URL.String(), "/edit"), http.StatusFound) + + default: + res.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func configUsersDeleteHandler(res http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodPost: + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + + // do not allow current user to delete themselves + j, err := db.CurrentUser(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + usr := &user.User{} + err = json.Unmarshal(j, &usr) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + email := strings.ToLower(req.PostFormValue("email")) + + if usr.Email == email { + log.Println(err) + res.WriteHeader(http.StatusBadRequest) + errView, err := Error405() + if err != nil { + return + } + + res.Write(errView) + return + } + + // delete existing user + err = db.DeleteUser(email) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + http.Redirect(res, req, strings.TrimSuffix(req.URL.String(), "/delete"), http.StatusFound) default: res.WriteHeader(http.StatusMethodNotAllowed) @@ -196,7 +438,7 @@ func loginHandler(res http.ResponseWriter, req *http.Request) { view, err := Login() if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -212,7 +454,7 @@ func loginHandler(res http.ResponseWriter, req *http.Request) { err := req.ParseForm() if err != nil { - fmt.Println(err) + log.Println(err) http.Redirect(res, req, req.URL.String(), http.StatusFound) return } @@ -220,7 +462,7 @@ func loginHandler(res http.ResponseWriter, req *http.Request) { // check email & password j, err := db.User(strings.ToLower(req.FormValue("email"))) if err != nil { - fmt.Println(err) + log.Println(err) http.Redirect(res, req, req.URL.String(), http.StatusFound) return } @@ -233,7 +475,7 @@ func loginHandler(res http.ResponseWriter, req *http.Request) { usr := &user.User{} err = json.Unmarshal(j, usr) if err != nil { - fmt.Println(err) + log.Println(err) http.Redirect(res, req, req.URL.String(), http.StatusFound) return } @@ -250,7 +492,7 @@ func loginHandler(res http.ResponseWriter, req *http.Request) { } token, err := jwt.New(claims) if err != nil { - fmt.Println(err) + log.Println(err) http.Redirect(res, req, req.URL.String(), http.StatusFound) return } @@ -260,6 +502,7 @@ func loginHandler(res http.ResponseWriter, req *http.Request) { Name: "_token", Value: token, Expires: week, + Path: "/", }) http.Redirect(res, req, strings.TrimSuffix(req.URL.String(), "/login"), http.StatusFound) @@ -271,6 +514,7 @@ func logoutHandler(res http.ResponseWriter, req *http.Request) { Name: "_token", Expires: time.Unix(0, 0), Value: "", + Path: "/", }) http.Redirect(res, req, req.URL.Scheme+req.URL.Host+"/admin/login", http.StatusFound) @@ -291,10 +535,25 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { } order := strings.ToLower(q.Get("order")) + status := q.Get("status") posts := db.ContentAll(t + "_sorted") b := &bytes.Buffer{} - p, ok := content.Types[t]().(editor.Editable) + + if _, ok := content.Types[t]; !ok { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error405() + if err != nil { + return + } + + res.Write(errView) + return + } + + pt := content.Types[t]() + + p, ok := pt.(editor.Editable) if !ok { res.WriteHeader(http.StatusInternalServerError) errView, err := Error500() @@ -306,6 +565,12 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { return } + var hasExt bool + _, ok = pt.(api.Externalable) + if ok { + hasExt = true + } + html := `<div class="col s9 card"> <div class="card-content"> <div class="row"> @@ -321,21 +586,6 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { </div> <script> $(function() { - var getParam = function(param) { - var qs = window.location.search.substring(1); - var qp = qs.split('&'); - var t = ''; - - for (var i = 0; i < qp.length; i++) { - var p = qp[i].split('=') - if (p[0] === param) { - t = p[1]; - } - } - - return t; - } - var sort = $('select.__ponzu.sort-order'); sort.on('change', function() { @@ -363,39 +613,107 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { <input type="hidden" name="type" value="` + t + `" /> </div> </form> - </div> - <ul class="posts row">` + </div>` + if hasExt { + if status == "" { + q.Add("status", "public") + } - if order == "desc" || order == "" { - // keep natural order of posts slice, as returned from sorted bucket - for i := range posts { - err := json.Unmarshal(posts[i], &p) - if err != nil { - log.Println("Error unmarshal json into", t, err, posts[i]) + q.Set("status", "public") + publicURL := req.URL.Path + "?" + q.Encode() + + q.Set("status", "pending") + pendingURL := req.URL.Path + "?" + q.Encode() + + switch status { + case "public", "": + html += `<div class="row externalable"> + <span class="description">Status:</span> + <span class="active">Public</span> + | + <a href="` + pendingURL + `">Pending</a> + </div>` + + case "pending": + // get _pending posts of type t from the db + posts = db.ContentAll(t + "_pending") + + html += `<div class="row externalable"> + <span class="description">Status:</span> + <a href="` + publicURL + `">Public</a> + | + <span class="active">Pending</span> + </div>` + } + + } + html += `<ul class="posts row">` + + switch order { + case "desc", "": + if hasExt { + // reverse the order of posts slice + for i := len(posts) - 1; i >= 0; i-- { + err := json.Unmarshal(posts[i], &p) + if err != nil { + log.Println("Error unmarshal json into", t, err, posts[i]) + + post := `<li class="col s12">Error decoding data. Possible file corruption.</li>` + b.Write([]byte(post)) + continue + } - post := `<li class="col s12">Error decoding data. Possible file corruption.</li>` - b.Write([]byte(post)) - continue + post := adminPostListItem(p, t, status) + b.Write(post) } + } else { + // keep natural order of posts slice, as returned from sorted bucket + for i := range posts { + err := json.Unmarshal(posts[i], &p) + if err != nil { + log.Println("Error unmarshal json into", t, err, posts[i]) + + post := `<li class="col s12">Error decoding data. Possible file corruption.</li>` + b.Write([]byte(post)) + continue + } - post := adminPostListItem(p, t) - b.Write(post) + post := adminPostListItem(p, t, status) + b.Write(post) + } } - } else if order == "asc" { - // reverse the order of posts slice - for i := len(posts) - 1; i >= 0; i-- { - err := json.Unmarshal(posts[i], &p) - if err != nil { - log.Println("Error unmarshal json into", t, err, posts[i]) + case "asc": + if hasExt { + // keep natural order of posts slice, as returned from sorted bucket + for i := range posts { + err := json.Unmarshal(posts[i], &p) + if err != nil { + log.Println("Error unmarshal json into", t, err, posts[i]) + + post := `<li class="col s12">Error decoding data. Possible file corruption.</li>` + b.Write([]byte(post)) + continue + } - post := `<li class="col s12">Error decoding data. Possible file corruption.</li>` - b.Write([]byte(post)) - continue + post := adminPostListItem(p, t, status) + b.Write(post) } + } else { + // reverse the order of posts slice + for i := len(posts) - 1; i >= 0; i-- { + err := json.Unmarshal(posts[i], &p) + if err != nil { + log.Println("Error unmarshal json into", t, err, posts[i]) - post := adminPostListItem(p, t) - b.Write(post) + post := `<li class="col s12">Error decoding data. Possible file corruption.</li>` + b.Write([]byte(post)) + continue + } + + post := adminPostListItem(p, t, status) + b.Write(post) + } } } @@ -419,7 +737,7 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { adminView, err := Admin([]byte(html)) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -430,7 +748,8 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { // adminPostListItem is a helper to create the li containing a post. // p is the asserted post as an Editable, t is the Type of the post. -func adminPostListItem(p editor.Editable, t string) []byte { +// specifier is passed to append a name to a namespace like _pending +func adminPostListItem(p editor.Editable, t, status string) []byte { s, ok := p.(editor.Sortable) if !ok { log.Println("Content type", t, "doesn't implement editor.Sortable") @@ -446,28 +765,127 @@ func adminPostListItem(p editor.Editable, t string) []byte { cid := fmt.Sprintf("%d", p.ContentID()) + if status == "public" { + status = "" + } else { + status = "_" + status + } + post := ` <li class="col s12"> - <a href="/admin/edit?type=` + t + `&id=` + cid + `">` + p.ContentName() + `</a> + <a href="/admin/edit?type=` + t + `&status=` + strings.TrimPrefix(status, "_") + `&id=` + cid + `">` + p.ContentName() + `</a> <span class="post-detail">Updated: ` + updatedTime + `</span> <span class="publish-date right">` + publishTime + `</span> <form enctype="multipart/form-data" class="quick-delete-post __ponzu right" action="/admin/edit/delete" method="post"> <span>Delete</span> <input type="hidden" name="id" value="` + cid + `" /> - <input type="hidden" name="type" value="` + t + `" /> + <input type="hidden" name="type" value="` + t + status + `" /> </form> </li>` return []byte(post) } +func approvePostHandler(res http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + res.WriteHeader(http.StatusMethodNotAllowed) + errView, err := Error405() + if err != nil { + return + } + + res.Write(errView) + return + } + + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + t := req.FormValue("type") + if strings.Contains(t, "_") { + t = strings.Split(t, "_")[0] + } + + post := content.Types[t]() + + // check if we have a Mergeable + m, ok := post.(api.Mergeable) + if !ok { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + dec := schema.NewDecoder() + dec.IgnoreUnknownKeys(true) + dec.SetAliasTag("json") + err = dec.Decode(post, req.Form) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + // call its Approve method + err = m.Approve(req) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + // Store the content in the bucket t + id, err := db.SetContent(t+":-1", req.Form) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + // redirect to the new approved content's editor + redir := req.URL.Scheme + req.URL.Host + strings.TrimSuffix(req.URL.Path, "/approve") + redir += fmt.Sprintf("?type=%s&id=%d", t, id) + http.Redirect(res, req, redir, http.StatusFound) +} + func editHandler(res http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: q := req.URL.Query() i := q.Get("id") t := q.Get("type") + status := q.Get("status") + contentType, ok := content.Types[t] if !ok { fmt.Fprintf(res, content.ErrTypeNotRegistered, t) @@ -476,9 +894,13 @@ func editHandler(res http.ResponseWriter, req *http.Request) { post := contentType() if i != "" { + if status == "pending" { + t = t + "_pending" + } + data, err := db.Content(t + ":" + i) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) errView, err := Error500() if err != nil { @@ -490,6 +912,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { } if len(data) < 1 || data == nil { + fmt.Println(string(data)) res.WriteHeader(http.StatusNotFound) errView, err := Error404() if err != nil { @@ -502,7 +925,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { err = json.Unmarshal(data, post) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) errView, err := Error500() if err != nil { @@ -518,7 +941,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { m, err := manager.Manage(post.(editor.Editable), t) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) errView, err := Error500() if err != nil { @@ -531,7 +954,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { adminView, err := Admin(m) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -542,7 +965,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { case http.MethodPost: err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusBadRequest) errView, err := Error405() if err != nil { @@ -568,9 +991,9 @@ func editHandler(res http.ResponseWriter, req *http.Request) { req.PostForm.Set("updated", ts) } - urlPaths, err := storeFileUploads(req) + urlPaths, err := upload.StoreFiles(req) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) errView, err := Error500() if err != nil { @@ -608,7 +1031,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { id, err := db.SetContent(t+":"+cid, req.PostForm) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) errView, err := Error500() if err != nil { @@ -639,7 +1062,7 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB if err != nil { - fmt.Println("req.ParseMPF") + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } @@ -654,11 +1077,17 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { err = db.DeleteContent(t + ":" + id) if err != nil { - fmt.Println("db.DeleteContent") + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } + // catch specifier suffix from delete form value + if strings.Contains(t, "_") { + spec := strings.Split(t, "_") + t = spec[0] + } + redir := strings.TrimSuffix(req.URL.Scheme+req.URL.Host+req.URL.Path, "/edit/delete") redir = redir + "/posts?type=" + t http.Redirect(res, req, redir, http.StatusFound) @@ -670,9 +1099,9 @@ func editUploadHandler(res http.ResponseWriter, req *http.Request) { return } - urlPaths, err := storeFileUploads(req) + urlPaths, err := upload.StoreFiles(req) if err != nil { - fmt.Println("Couldn't store file uploads.", err) + log.Println("Couldn't store file uploads.", err) res.WriteHeader(http.StatusInternalServerError) return } @@ -726,7 +1155,7 @@ func searchHandler(res http.ResponseWriter, req *http.Request) { continue } - post := adminPostListItem(p, t) + post := adminPostListItem(p, t, "") b.Write([]byte(post)) } @@ -737,7 +1166,7 @@ func searchHandler(res http.ResponseWriter, req *http.Request) { adminView, err := Admin([]byte(html)) if err != nil { - fmt.Println(err) + log.Println(err) res.WriteHeader(http.StatusInternalServerError) return } diff --git a/system/admin/server.go b/system/admin/server.go index b3b128d..5d93d84 100644 --- a/system/admin/server.go +++ b/system/admin/server.go @@ -20,12 +20,15 @@ func Run() { http.HandleFunc("/admin/configure", user.Auth(configHandler)) http.HandleFunc("/admin/configure/users", user.Auth(configUsersHandler)) + http.HandleFunc("/admin/configure/users/edit", user.Auth(configUsersEditHandler)) + http.HandleFunc("/admin/configure/users/delete", user.Auth(configUsersDeleteHandler)) http.HandleFunc("/admin/posts", user.Auth(postsHandler)) http.HandleFunc("/admin/posts/search", user.Auth(searchHandler)) http.HandleFunc("/admin/edit", user.Auth(editHandler)) http.HandleFunc("/admin/edit/delete", user.Auth(deleteHandler)) + http.HandleFunc("/admin/edit/approve", user.Auth(approvePostHandler)) http.HandleFunc("/admin/edit/upload", user.Auth(editUploadHandler)) pwd, err := os.Getwd() diff --git a/system/admin/static/common/js/util.js b/system/admin/static/common/js/util.js index 7f4c8ab..8d5e74b 100644 --- a/system/admin/static/common/js/util.js +++ b/system/admin/static/common/js/util.js @@ -67,4 +67,20 @@ function getPartialDate(unix) { d.dd = day; return d; +} + +// Returns a part of the window URL 'search' string +function getParam(param) { + var qs = window.location.search.substring(1); + var qp = qs.split('&'); + var t = ''; + + for (var i = 0; i < qp.length; i++) { + var p = qp[i].split('=') + if (p[0] === param) { + t = p[1]; + } + } + + return t; }
\ No newline at end of file diff --git a/system/admin/static/dashboard/css/admin.css b/system/admin/static/dashboard/css/admin.css index e23658e..3cffc5d 100644 --- a/system/admin/static/dashboard/css/admin.css +++ b/system/admin/static/dashboard/css/admin.css @@ -44,7 +44,7 @@ padding: 0px !important; } -ul.posts li { +ul.posts li, ul.users li { display: block; margin: 0 0 20px 0; padding: 0 0 20px 0 !important; @@ -164,15 +164,15 @@ span.post-detail { font-style: italic; } -.quick-delete-post { +.quick-delete-post, .delete-user { display: none; } -li:hover .quick-delete-post { +li:hover .quick-delete-post, li:hover .delete-user { display: inline-block; } -.quick-delete-post span { +.quick-delete-post span, .delete-user span { cursor: pointer; color: #F44336; text-transform: uppercase; @@ -181,6 +181,10 @@ li:hover .quick-delete-post { margin-right: 20px; } +.user-management { + padding: 20px; +} + /* OVERRIDE Bootstrap + Materialize conflicts */ .iso-texteditor.input-field label { @@ -199,3 +203,17 @@ li:hover .quick-delete-post { -o-transform: translateY(-140%); transform: translateY(-140%); } + +.chips { + margin-top: 10px; +} + +.external.post-controls .col.input-field { + margin-top: 40px; + padding: 0; +} + +.approve-details { + text-align: right; + padding: 10px 0 !important; +}
\ No newline at end of file diff --git a/system/admin/upload.go b/system/admin/upload/upload.go index 7f2a4fa..169bffe 100644 --- a/system/admin/upload.go +++ b/system/admin/upload/upload.go @@ -1,4 +1,4 @@ -package admin +package upload import ( "fmt" @@ -10,7 +10,8 @@ import ( "time" ) -func storeFileUploads(req *http.Request) (map[string]string, error) { +// StoreFiles stores file uploads at paths like /YYYY/MM/filename.ext +func StoreFiles(req *http.Request) (map[string]string, error) { err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB if err != nil { return nil, fmt.Errorf("%s", err) diff --git a/system/api/analytics/init.go b/system/api/analytics/init.go new file mode 100644 index 0000000..c351bed --- /dev/null +++ b/system/api/analytics/init.go @@ -0,0 +1,101 @@ +// Package analytics provides the methods to run an analytics reporting system +// for API requests which may be useful to users for measuring access and +// possibly identifying bad actors abusing requests. +package analytics + +import ( + "log" + "net/http" + "strings" + "time" + + "github.com/boltdb/bolt" +) + +type apiRequest struct { + URL string `json:"url"` + Method string `json:"http_method"` + RemoteAddr string `json:"ip_address"` + Timestamp int64 `json:"timestamp"` + External bool `json:"external"` +} + +var ( + store *bolt.DB + recordChan chan apiRequest +) + +// Record queues an apiRequest for metrics +func Record(req *http.Request) { + external := strings.Contains(req.URL.Path, "/external/") + + r := apiRequest{ + URL: req.URL.String(), + Method: req.Method, + RemoteAddr: req.RemoteAddr, + Timestamp: time.Now().Unix() * 1000, + External: external, + } + + // put r on buffered recordChan to take advantage of batch insertion in DB + recordChan <- r + +} + +// Close exports the abillity to close our db file. Should be called with defer +// after call to Init() from the same place. +func Close() { + err := store.Close() + if err != nil { + log.Println(err) + } +} + +// Init creates a db connection, should run an initial prune of old data, and +// sets up the queue/batching channel +func Init() { + var err error + store, err = bolt.Open("analytics.db", 0666, nil) + if err != nil { + log.Fatalln(err) + } + + recordChan = make(chan apiRequest, 1024*128) + + go serve() + + err = store.Update(func(tx *bolt.Tx) error { + + return nil + }) + if err != nil { + log.Fatalln(err) + } +} + +func serve() { + // make timer to notify select to batch request insert from recordChan + // interval: 1 minute + apiRequestTimer := time.NewTicker(time.Minute * 1) + + // make timer to notify select to remove old analytics + // interval: 2 weeks + // TODO: enable analytics backup service to cloud + pruneDBTimer := time.NewTicker(time.Hour * 24 * 14) + + for { + select { + case <-apiRequestTimer.C: + var reqs []apiRequest + batchSize := len(recordChan) + + for i := 0; i < batchSize; i++ { + reqs = append(reqs, <-recordChan) + } + + case <-pruneDBTimer.C: + + default: + } + } +} diff --git a/system/api/external.go b/system/api/external.go new file mode 100644 index 0000000..4a42eb5 --- /dev/null +++ b/system/api/external.go @@ -0,0 +1,109 @@ +package api + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/bosssauce/ponzu/content" + "github.com/bosssauce/ponzu/system/admin/upload" + "github.com/bosssauce/ponzu/system/db" +) + +// Externalable accepts or rejects external POST requests to endpoints such as: +// /external/posts?type=Review +type Externalable interface { + // Accepts determines whether a type will allow external submissions + Accepts() bool +} + +// Mergeable allows external post content to be approved and published through +// the public-facing API +type Mergeable interface { + // Approve copies an external post to the internal collection and triggers + // a re-sort of its content type posts + Approve(req *http.Request) error +} + +func externalPostsHandler(res http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + res.WriteHeader(http.StatusMethodNotAllowed) + return + } + + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + log.Println("[External] error:", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + t := req.URL.Query().Get("type") + if t == "" { + res.WriteHeader(http.StatusBadRequest) + return + } + + p, found := content.Types[t] + if !found { + log.Println("[External] attempt to submit unknown type:", t, "from:", req.RemoteAddr) + res.WriteHeader(http.StatusNotFound) + return + } + + post := p() + + ext, ok := post.(Externalable) + if !ok { + log.Println("[External] rejected non-externalable type:", t, "from:", req.RemoteAddr) + res.WriteHeader(http.StatusBadRequest) + return + } + + if ext.Accepts() { + ts := fmt.Sprintf("%d", time.Now().Unix()*1000) + req.PostForm.Set("timestamp", ts) + req.PostForm.Set("updated", ts) + + urlPaths, err := upload.StoreFiles(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + for name, urlPath := range urlPaths { + req.PostForm.Add(name, urlPath) + } + + // check for any multi-value fields (ex. checkbox fields) + // and correctly format for db storage. Essentially, we need + // fieldX.0: value1, fieldX.1: value2 => fieldX: []string{value1, value2} + var discardKeys []string + for k, v := range req.PostForm { + if strings.Contains(k, ".") { + key := strings.Split(k, ".")[0] + + if req.PostForm.Get(key) == "" { + req.PostForm.Set(key, v[0]) + discardKeys = append(discardKeys, k) + } else { + req.PostForm.Add(key, v[0]) + } + } + } + + for _, discardKey := range discardKeys { + req.PostForm.Del(discardKey) + } + + _, err = db.SetContent(t+"_pending:-1", req.PostForm) + if err != nil { + log.Println("[External] error:", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + } +} diff --git a/system/api/handlers.go b/system/api/handlers.go index 0c9139f..8356683 100644 --- a/system/api/handlers.go +++ b/system/api/handlers.go @@ -5,8 +5,11 @@ import ( "encoding/json" "log" "net/http" + "strconv" + "strings" "github.com/bosssauce/ponzu/content" + "github.com/bosssauce/ponzu/system/api/analytics" "github.com/bosssauce/ponzu/system/db" ) @@ -28,24 +31,72 @@ func typesHandler(res http.ResponseWriter, req *http.Request) { func postsHandler(res http.ResponseWriter, req *http.Request) { q := req.URL.Query() t := q.Get("type") - // TODO: implement pagination - // num := q.Get("num") - // page := q.Get("page") - - // TODO: inplement time-based ?after=time.Time, ?before=time.Time between=time.Time|time.Time - if t == "" { res.WriteHeader(http.StatusBadRequest) return } - posts := db.ContentAll(t) + count, err := strconv.Atoi(q.Get("count")) // int: determines number of posts to return (10 default, -1 is all) + if err != nil { + if q.Get("count") == "" { + count = 10 + } else { + res.WriteHeader(http.StatusInternalServerError) + return + } + } + + offset, err := strconv.Atoi(q.Get("offset")) // int: multiplier of count for pagination (0 default) + if err != nil { + if q.Get("offset") == "" { + offset = 0 + } else { + res.WriteHeader(http.StatusInternalServerError) + return + } + } + + order := strings.ToLower(q.Get("order")) // string: sort order of posts by timestamp ASC / DESC (DESC default) + if order != "asc" || order == "" { + order = "desc" + } + + // TODO: time-based ?after=time.Time, ?before=time.Time between=time.Time|time.Time + + posts := db.ContentAll(t + "_sorted") var all = []json.RawMessage{} for _, post := range posts { all = append(all, post) } - j, err := fmtJSON(all...) + var start, end int + switch count { + case -1: + start = 0 + end = len(posts) + + default: + start = count * offset + end = start + count + } + + // bounds check on posts given the start & end count + if start > len(posts) { + start = len(posts) - count + } + if end > len(posts) { + end = len(posts) + } + + // reverse the sorted order if ASC + if order == "asc" { + all = []json.RawMessage{} + for i := len(posts) - 1; i >= 0; i-- { + all = append(all, posts[i]) + } + } + + j, err := fmtJSON(all[start:end]...) if err != nil { res.WriteHeader(http.StatusInternalServerError) return @@ -150,6 +201,7 @@ func SendJSON(res http.ResponseWriter, j map[string]interface{}) { data, err = json.Marshal(j) if err != nil { + log.Println(err) data, _ = json.Marshal(map[string]interface{}{ "status": "fail", "message": err.Error(), @@ -159,9 +211,6 @@ func SendJSON(res http.ResponseWriter, j map[string]interface{}) { sendData(res, data, 200) } -// ResponseFunc ... -type ResponseFunc func(http.ResponseWriter, *http.Request) - // CORS wraps a HandleFunc to response to OPTIONS requests properly func CORS(next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { @@ -173,3 +222,12 @@ func CORS(next http.HandlerFunc) http.HandlerFunc { next.ServeHTTP(res, req) }) } + +// Record wraps a HandleFunc to record API requests for analytical purposes +func Record(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + go analytics.Record(req) + + next.ServeHTTP(res, req) + }) +} diff --git a/system/api/server.go b/system/api/server.go index da73382..816bc21 100644 --- a/system/api/server.go +++ b/system/api/server.go @@ -9,4 +9,6 @@ func Run() { http.HandleFunc("/api/posts", CORS(postsHandler)) http.HandleFunc("/api/post", CORS(postHandler)) + + http.HandleFunc("/api/external/posts", CORS(externalPostsHandler)) } diff --git a/system/db/content.go b/system/db/content.go index 2d6e8ea..bd1ee4b 100644 --- a/system/db/content.go +++ b/system/db/content.go @@ -38,13 +38,20 @@ func SetContent(target string, data url.Values) (int, error) { } func update(ns, id string, data url.Values) (int, error) { + var specifier string // i.e. _pending, _sorted, etc. + if strings.Contains(ns, "_") { + spec := strings.Split(ns, "_") + ns = spec[0] + specifier = "_" + spec[1] + } + cid, err := strconv.Atoi(id) if err != nil { return 0, err } err = store.Update(func(tx *bolt.Tx) error { - b, err := tx.CreateBucketIfNotExists([]byte(ns)) + b, err := tx.CreateBucketIfNotExists([]byte(ns + specifier)) if err != nil { return err } @@ -65,15 +72,24 @@ func update(ns, id string, data url.Values) (int, error) { return 0, nil } - go SortContent(ns) + if specifier == "" { + go SortContent(ns) + } return cid, nil } func insert(ns string, data url.Values) (int, error) { var effectedID int + var specifier string // i.e. _pending, _sorted, etc. + if strings.Contains(ns, "_") { + spec := strings.Split(ns, "_") + ns = spec[0] + specifier = "_" + spec[1] + } + err := store.Update(func(tx *bolt.Tx) error { - b, err := tx.CreateBucketIfNotExists([]byte(ns)) + b, err := tx.CreateBucketIfNotExists([]byte(ns + specifier)) if err != nil { return err } @@ -89,7 +105,7 @@ func insert(ns string, data url.Values) (int, error) { if err != nil { return err } - data.Add("id", cid) + data.Set("id", cid) j, err := postToJSON(ns, data) if err != nil { @@ -107,40 +123,11 @@ func insert(ns string, data url.Values) (int, error) { return 0, err } - go SortContent(ns) - - return effectedID, nil -} - -func postToJSON(ns string, data url.Values) ([]byte, error) { - // find the content type and decode values into it - t, ok := content.Types[ns] - if !ok { - return nil, fmt.Errorf(content.ErrTypeNotRegistered, ns) - } - post := t() - - dec := schema.NewDecoder() - dec.SetAliasTag("json") // allows simpler struct tagging when creating a content type - dec.IgnoreUnknownKeys(true) // will skip over form values submitted, but not in struct - err := dec.Decode(post, data) - if err != nil { - return nil, err - } - - slug, err := manager.Slug(post.(editor.Editable)) - if err != nil { - return nil, err - } - post.(editor.Editable).SetSlug(slug) - - // marshall content struct to json for db storage - j, err := json.Marshal(post) - if err != nil { - return nil, err + if specifier == "" { + go SortContent(ns) } - return j, nil + return effectedID, nil } // DeleteContent removes an item from the database. Deleting a non-existent item @@ -153,7 +140,6 @@ func DeleteContent(target string) error { tx.Bucket([]byte(ns)).Delete([]byte(id)) return nil }) - if err != nil { return err } @@ -178,7 +164,7 @@ func Content(target string) ([]byte, error) { b := tx.Bucket([]byte(ns)) _, err := val.Write(b.Get([]byte(id))) if err != nil { - fmt.Println(err) + log.Println(err) return err } @@ -197,8 +183,12 @@ func ContentAll(namespace string) [][]byte { store.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte(namespace)) - len := b.Stats().KeyN - posts = make([][]byte, 0, len) + if b == nil { + return nil + } + + numKeys := b.Stats().KeyN + posts = make([][]byte, 0, numKeys) b.ForEach(func(k, v []byte) error { posts = append(posts, v) @@ -216,10 +206,15 @@ func ContentAll(namespace string) [][]byte { // in descending order, from most recent to least recent // Should be called from a goroutine after SetContent is successful func SortContent(namespace string) { + // only sort main content types i.e. Post + if strings.Contains(namespace, "_") { + return + } + all := ContentAll(namespace) var posts sortablePosts - // decode each (json) into Editable + // decode each (json) into type to then sort for i := range all { j := all[i] post := content.Types[namespace]() @@ -238,18 +233,14 @@ func SortContent(namespace string) { // store in <namespace>_sorted bucket, first delete existing err := store.Update(func(tx *bolt.Tx) error { - err := tx.DeleteBucket([]byte(namespace + "_sorted")) + bname := []byte(namespace + "_sorted") + err := tx.DeleteBucket(bname) if err != nil { return err } - b, err := tx.CreateBucket([]byte(namespace + "_sorted")) + b, err := tx.CreateBucketIfNotExists(bname) if err != nil { - err := tx.Rollback() - if err != nil { - return err - } - return err } @@ -263,11 +254,6 @@ func SortContent(namespace string) { cid := fmt.Sprintf("%d:%d", i, posts[i].Time()) err = b.Put([]byte(cid), j) if err != nil { - err := tx.Rollback() - if err != nil { - return err - } - return err } } @@ -293,3 +279,35 @@ func (s sortablePosts) Less(i, j int) bool { func (s sortablePosts) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +func postToJSON(ns string, data url.Values) ([]byte, error) { + // find the content type and decode values into it + ns = strings.TrimSuffix(ns, "_external") + t, ok := content.Types[ns] + if !ok { + return nil, fmt.Errorf(content.ErrTypeNotRegistered, ns) + } + post := t() + + dec := schema.NewDecoder() + dec.SetAliasTag("json") // allows simpler struct tagging when creating a content type + dec.IgnoreUnknownKeys(true) // will skip over form values submitted, but not in struct + err := dec.Decode(post, data) + if err != nil { + return nil, err + } + + slug, err := manager.Slug(post.(editor.Editable)) + if err != nil { + return nil, err + } + post.(editor.Editable).SetSlug(slug) + + // marshall content struct to json for db storage + j, err := json.Marshal(post) + if err != nil { + return nil, err + } + + return j, nil +} diff --git a/system/db/init.go b/system/db/init.go index 1a5ed25..63804e1 100644 --- a/system/db/init.go +++ b/system/db/init.go @@ -13,12 +13,21 @@ import ( var store *bolt.DB +// Close exports the abillity to close our db file. Should be called with defer +// after call to Init() from the same place. +func Close() { + err := store.Close() + if err != nil { + log.Println(err) + } +} + // Init creates a db connection, initializes db with required info, sets secrets func Init() { var err error - store, err = bolt.Open("store.db", 0666, nil) + store, err = bolt.Open("system.db", 0666, nil) if err != nil { - log.Fatal(err) + log.Fatalln(err) } err = store.Update(func(tx *bolt.Tx) error { @@ -67,10 +76,9 @@ func Init() { return nil }) if err != nil { - log.Fatal("Coudn't initialize db with buckets.", err) + log.Fatalln("Coudn't initialize db with buckets.", err) } - // sort all content into type_sorted buckets go func() { for t := range content.Types { SortContent(t) @@ -99,7 +107,7 @@ func SystemInitComplete() bool { }) if err != nil { complete = false - log.Fatal(err) + log.Fatalln(err) } return complete diff --git a/system/db/user.go b/system/db/user.go index a3a0be3..d2dc3a9 100644 --- a/system/db/user.go +++ b/system/db/user.go @@ -4,15 +4,21 @@ import ( "bytes" "encoding/json" "errors" + "fmt" + "net/http" "github.com/bosssauce/ponzu/system/admin/user" "github.com/boltdb/bolt" + "github.com/nilslice/jwt" ) // ErrUserExists is used for the db to report to admin user of existing user var ErrUserExists = errors.New("Error. User exists.") +// ErrNoUserExists is used for the db to report to admin user of non-existing user +var ErrNoUserExists = errors.New("Error. No user exists.") + // SetUser sets key:value pairs in the db for user settings func SetUser(usr *user.User) (int, error) { err := store.Update(func(tx *bolt.Tx) error { @@ -38,7 +44,7 @@ func SetUser(usr *user.User) (int, error) { return err } - err = users.Put([]byte(usr.Email), j) + err = users.Put(email, j) if err != nil { return err } @@ -52,6 +58,65 @@ func SetUser(usr *user.User) (int, error) { return usr.ID, nil } +// UpdateUser sets key:value pairs in the db for existing user settings +func UpdateUser(usr, updatedUsr *user.User) error { + err := store.Update(func(tx *bolt.Tx) error { + users := tx.Bucket([]byte("_users")) + + // check if user is found by email, fail if nil + exists := users.Get([]byte(usr.Email)) + if exists == nil { + return ErrNoUserExists + } + + // marshal User to json and put into bucket + j, err := json.Marshal(updatedUsr) + if err != nil { + return err + } + + err = users.Put([]byte(updatedUsr.Email), j) + if err != nil { + return err + } + + // if email address was changed, delete the old record of former + // user with original email address + if usr.Email != updatedUsr.Email { + err = users.Delete([]byte(usr.Email)) + if err != nil { + return err + } + + } + + return nil + }) + if err != nil { + return err + } + + return nil +} + +// DeleteUser deletes a user from the db by email +func DeleteUser(email string) error { + err := store.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("_users")) + err := b.Delete([]byte(email)) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return err + } + + return nil +} + // User gets the user by email from the db func User(email string) ([]byte, error) { val := &bytes.Buffer{} @@ -72,3 +137,50 @@ func User(email string) ([]byte, error) { return val.Bytes(), nil } + +// UserAll returns all users from the db +func UserAll() ([][]byte, error) { + var users [][]byte + err := store.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("_users")) + err := b.ForEach(func(k, v []byte) error { + users = append(users, v) + return nil + }) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + return users, nil +} + +// CurrentUser extracts the user from the request data and returns the current user from the db +func CurrentUser(req *http.Request) ([]byte, error) { + if !user.IsValid(req) { + return nil, fmt.Errorf("Error. Invalid User.") + } + + token, err := req.Cookie("_token") + if err != nil { + return nil, err + } + + claims := jwt.GetClaims(token.Value) + email, ok := claims["user"] + if !ok { + return nil, fmt.Errorf("Error. No user data found in request token.") + } + + usr, err := User(email.(string)) + if err != nil { + return nil, err + } + + return usr, nil +} diff --git a/system/tls/enable.go b/system/tls/enable.go new file mode 100644 index 0000000..c53fac6 --- /dev/null +++ b/system/tls/enable.go @@ -0,0 +1,79 @@ +package tls + +import ( + "crypto/tls" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/bosssauce/ponzu/system/db" + + "golang.org/x/crypto/acme/autocert" +) + +var m autocert.Manager + +// setup attempts to locate or create the cert cache directory and the certs for TLS encryption +func setup() { + pwd, err := os.Getwd() + if err != nil { + log.Fatalln("Couldn't find working directory to locate or save certificates.") + } + + cache := autocert.DirCache(filepath.Join(pwd, "system", "tls", "certs")) + if _, err := os.Stat(string(cache)); os.IsNotExist(err) { + err := os.MkdirAll(string(cache), os.ModePerm|os.ModeDir) + if err != nil { + log.Fatalln("Couldn't create cert directory at", cache) + } + } + + // get host/domain and email from Config to use for TLS request to Let's encryption. + // we will fail fatally if either are not found since Let's Encrypt will rate-limit + // and sending incomplete requests is wasteful and guarenteed to fail its check + host, err := db.Config("domain") + if err != nil { + log.Fatalln("Error identifying host/domain during TLS set-up.", err) + } + + if host == nil { + log.Fatalln("No 'domain' field set in Configuration. Please add a domain before attempting to make certificates.") + } + fmt.Println("Using", host, "as host/domain for certificate...") + fmt.Println("NOTE: if the host/domain is not configured properly or is unreachable, HTTPS set-up will fail.") + + email, err := db.Config("admin_email") + if err != nil { + log.Fatalln("Error identifying admin email during TLS set-up.", err) + } + + if email == nil { + log.Fatalln("No 'admin_email' field set in Configuration. Please add an admin email before attempting to make certificates.") + } + fmt.Println("Using", email, "as contact email for certificate...") + + m = autocert.Manager{ + Prompt: autocert.AcceptTOS, + Cache: cache, + HostPolicy: autocert.HostWhitelist(string(host)), + RenewBefore: time.Hour * 24 * 30, + Email: string(email), + } + +} + +// Enable runs the setup for creating or locating certificates and starts the TLS server +func Enable() { + setup() + + server := &http.Server{ + Addr: ":443", + TLSConfig: &tls.Config{GetCertificate: m.GetCertificate}, + } + + go log.Fatalln(server.ListenAndServeTLS("", "")) + fmt.Println("Server listening for HTTPS requests...") +} |