diff options
author | Steve <nilslice@gmail.com> | 2016-11-16 02:51:26 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2016-11-16 02:51:26 -0800 |
commit | f252472047f86d1bdf956dc59b89541ea0260d68 (patch) | |
tree | 6c0d5938c21505325ad50446f5ebcd1483d6b278 | |
parent | 9a531ac701990bb932664039b11614227467e03a (diff) | |
parent | 6f5893828077eb0034dca01344f3742eb5cd5fa6 (diff) |
Merge pull request #17 from bosssauce/ponzu-dev
[core] Adding lifecycle hooks for Save, Delete, Approve, Reject
-rw-r--r-- | cmd/ponzu/vendor/github.com/nilslice/email/LICENSE | 21 | ||||
-rw-r--r-- | cmd/ponzu/vendor/github.com/nilslice/email/README.md | 51 | ||||
-rw-r--r-- | cmd/ponzu/vendor/github.com/nilslice/email/email.go | 114 | ||||
-rw-r--r-- | cmd/ponzu/vendor/github.com/nilslice/email/email_test.go | 19 | ||||
-rw-r--r-- | content/item.go | 88 | ||||
-rw-r--r-- | management/editor/editor.go | 42 | ||||
-rw-r--r-- | system/admin/admin.go | 115 | ||||
-rw-r--r-- | system/admin/filesystem.go | 36 | ||||
-rw-r--r-- | system/admin/handlers.go | 418 | ||||
-rw-r--r-- | system/admin/server.go | 8 | ||||
-rw-r--r-- | system/admin/static/dashboard/css/admin.css | 2 | ||||
-rw-r--r-- | system/api/external.go | 29 | ||||
-rw-r--r-- | system/db/user.go | 55 |
13 files changed, 952 insertions, 46 deletions
diff --git a/cmd/ponzu/vendor/github.com/nilslice/email/LICENSE b/cmd/ponzu/vendor/github.com/nilslice/email/LICENSE new file mode 100644 index 0000000..6ac9da6 --- /dev/null +++ b/cmd/ponzu/vendor/github.com/nilslice/email/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Steve Manuel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cmd/ponzu/vendor/github.com/nilslice/email/README.md b/cmd/ponzu/vendor/github.com/nilslice/email/README.md new file mode 100644 index 0000000..323004c --- /dev/null +++ b/cmd/ponzu/vendor/github.com/nilslice/email/README.md @@ -0,0 +1,51 @@ +## Email + +I needed a way to send email from a [Ponzu](https://ponzu-cms.org) installation +running on all kinds of systems without shelling out. `sendmail` or `postfix` et +al are not standard on all systems, and I didn't want to force users to add API +keys from a third-party just to send something like an account recovery email. + +### Usage: +`$ go get github.com/nilslice/email` + +```go +package main + +import ( + "fmt" + "github.com/nilslice/email" +) + +func main() { + msg := email.Message{ + To: "you@server.name", // do not add < > or name in quotes + From: "Name <me@server.name>", // ok to format in From field + Subject: "A simple email", + Body: "Plain text email body. HTML not yet supported, but send a PR!", + } + + err := msg.Send() + if err != nil { + fmt.Println(err) + } +} + +``` + +### Under the hood +`email` looks at a `Message`'s `To` field, splits the string on the @ symbol and +issues an MX lookup to find the mail exchange server(s). Then it iterates over +all the possibilities in combination with commonly used SMTP ports for non-SSL +clients: `25, 2525, & 587` + +It stops once it has an active client connected to a mail server and sends the +initial information, the message, and then closes the connection. + +Currently, this doesn't support any additional headers or `To` field formatting +(the recipient's email must be the only string `To` takes). Although these would +be fairly strightforward to implement, I don't need them yet.. so feel free to +contribute anything you find useful. + +#### Warning +Be cautious of how often you run this locally or in testing, as it's quite +likely your IP will be blocked/blacklisted if it is not already.
\ No newline at end of file diff --git a/cmd/ponzu/vendor/github.com/nilslice/email/email.go b/cmd/ponzu/vendor/github.com/nilslice/email/email.go new file mode 100644 index 0000000..9d613c8 --- /dev/null +++ b/cmd/ponzu/vendor/github.com/nilslice/email/email.go @@ -0,0 +1,114 @@ +package email + +import ( + "fmt" + "net" + "net/smtp" + "strings" +) + +// Message creates a email to be sent +type Message struct { + To string + From string + Subject string + Body string +} + +var ( + ports = []int{25, 2525, 587} +) + +// Send sends a message to recipient(s) listed in the 'To' field of a Message +func (m Message) Send() error { + if !strings.Contains(m.To, "@") { + return fmt.Errorf("Invalid recipient address: <%s>", m.To) + } + + host := strings.Split(m.To, "@")[1] + addrs, err := net.LookupMX(host) + if err != nil { + return err + } + + c, err := newClient(addrs, ports) + if err != nil { + return err + } + + err = send(m, c) + if err != nil { + return err + } + + return nil +} + +func newClient(mx []*net.MX, ports []int) (*smtp.Client, error) { + for i := range mx { + for j := range ports { + server := strings.TrimSuffix(mx[i].Host, ".") + hostPort := fmt.Sprintf("%s:%d", server, ports[j]) + client, err := smtp.Dial(hostPort) + if err != nil { + if j == len(ports)-1 { + return nil, err + } + + continue + } + + return client, nil + } + } + + return nil, fmt.Errorf("Couldn't connect to servers %v on any common port.", mx) +} + +func send(m Message, c *smtp.Client) error { + if err := c.Mail(m.From); err != nil { + return err + } + + if err := c.Rcpt(m.To); err != nil { + return err + } + + msg, err := c.Data() + if err != nil { + return err + } + + if m.Subject != "" { + _, err = msg.Write([]byte("Subject: " + m.Subject + "\r\n")) + if err != nil { + return err + } + } + + if m.From != "" { + _, err = msg.Write([]byte("From: " + m.From + "\r\n")) + if err != nil { + return err + } + } + + if m.To != "" { + _, err = msg.Write([]byte("To: " + m.To + "\r\n")) + if err != nil { + return err + } + } + + _, err = fmt.Fprint(msg, m.Body) + if err != nil { + return err + } + + err = msg.Close() + if err != nil { + return err + } + + return nil +} diff --git a/cmd/ponzu/vendor/github.com/nilslice/email/email_test.go b/cmd/ponzu/vendor/github.com/nilslice/email/email_test.go new file mode 100644 index 0000000..3576a79 --- /dev/null +++ b/cmd/ponzu/vendor/github.com/nilslice/email/email_test.go @@ -0,0 +1,19 @@ +package email + +import ( + "testing" +) + +func TestSend(t *testing.T) { + m := Message{ + To: "", + From: "", + Subject: "", + Body: "", + } + + err := m.Send() + if err != nil { + t.Fatal("Send returned error:", err) + } +} diff --git a/content/item.go b/content/item.go index 81593b6..c847ed7 100644 --- a/content/item.go +++ b/content/item.go @@ -1,5 +1,41 @@ package content +import "net/http" + +// Sluggable makes a struct locatable by URL with it's own path +// As an Item implementing Sluggable, slugs may overlap. If this is an issue, +// make your content struct (or one which imbeds Item) implement Sluggable +// and it will override the slug created by Item's SetSlug with your struct's +type Sluggable interface { + SetSlug(string) +} + +// Identifiable enables a struct to have its ID set. Typically this is done +// to set an ID to -1 indicating it is new for DB inserts, since by default +// a newly initialized struct would have an ID of 0, the int zero-value, and +// BoltDB's starting key per bucket is 0, thus overwriting the first record. +type Identifiable interface { + SetItemID(int) +} + +// Hookable provides our user with an easy way to intercept or add functionality +// to the different lifecycles/events a struct may encounter. Item implements +// Hookable with no-ops so our user can override only whichever ones necessary. +type Hookable interface { + BeforeSave(req *http.Request) error + AfterSave(req *http.Request) error + + BeforeDelete(req *http.Request) error + AfterDelete(req *http.Request) error + + BeforeApprove(req *http.Request) error + AfterApprove(req *http.Request) error + + BeforeReject(req *http.Request) error + AfterReject(req *http.Request) error +} + + // Item should only be embedded into content type structs. type Item struct { ID int `json:"id"` @@ -28,23 +64,47 @@ func (i *Item) SetSlug(slug string) { i.Slug = slug } -// SetContentID sets the Item's ID field -func (i *Item) SetContentID(id int) { +// SetItemID sets the Item's ID field +func (i *Item) SetItemID(id int) { i.ID = id } -// Sluggable makes a struct locatable by URL with it's own path -// As an Item implementing Sluggable, slugs may overlap. If this is an issue, -// make your content struct (or one which imbeds Item) implement Sluggable -// and it will override the slug created by Item's SetSlug with your struct's -type Sluggable interface { - SetSlug(string) +// BeforeSave is a no-op to ensure structs which embed Item implement Hookable +func (i Item) BeforeSave(req *http.Request) error { + return nil } -// Identifiable enables a struct to have its ID set. Typically this is done -// to set an ID to -1 indicating it is new for DB inserts, since by default -// a newly initialized struct would have an ID of 0, the int zero-value, and -// BoltDB's starting key per bucket is 0, thus overwriting the first record. -type Identifiable interface { - SetContentID(int) +// AfterSave is a no-op to ensure structs which embed Item implement Hookable +func (i Item) AfterSave(req *http.Request) error { + return nil } + +// BeforeDelete is a no-op to ensure structs which embed Item implement Hookable +func (i Item) BeforeDelete(req *http.Request) error { + return nil +} + +// AfterDelete is a no-op to ensure structs which embed Item implement Hookable +func (i Item) AfterDelete(req *http.Request) error { + return nil +} + +// BeforeApprove is a no-op to ensure structs which embed Item implement Hookable +func (i Item) BeforeApprove(req *http.Request) error { + return nil +} + +// AfterApprove is a no-op to ensure structs which embed Item implement Hookable +func (i Item) AfterApprove(req *http.Request) error { + return nil +} + +// BeforeReject is a no-op to ensure structs which embed Item implement Hookable +func (i Item) BeforeReject(req *http.Request) error { + return nil +} + +// AfterReject is a no-op to ensure structs which embed Item implement Hookable +func (i Item) AfterReject(req *http.Request) error { + return nil +}
\ No newline at end of file diff --git a/management/editor/editor.go b/management/editor/editor.go index f76197a..3843592 100644 --- a/management/editor/editor.go +++ b/management/editor/editor.go @@ -4,6 +4,7 @@ package editor import ( "bytes" + "net/http" ) // Editable ensures data is editable @@ -20,6 +21,14 @@ type Sortable interface { ItemID() int } +// 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 +} + // Editor is a view containing fields to manage content type Editor struct { ViewBuf *bytes.Buffer @@ -106,31 +115,39 @@ func Form(post Editable, fields ...Field) ([]byte, error) { <button class="right waves-effect waves-light btn green save-post" type="submit">Save</button> <button class="right waves-effect waves-light btn red delete-post" type="submit">Delete</button> </div> - +` + _, ok := post.(Mergeable) + if ok { + submit += + ` <div class="row external post-controls"> <div class="col s12 input-field"> <button class="right waves-effect waves-light btn blue approve-post" type="submit">Approve</button> + <button class="right waves-effect waves-light btn grey darken-2 reject-post" type="submit">Reject</button> </div> - <label class="approve-details right-align col s12">This content is pending approval. By clicking 'Approve', it will be immediately published.</label> + <label class="approve-details right-align col s12">This content is pending approval. By clicking 'Approve', it will be immediately published. By clicking 'Reject', it will be deleted.</label> </div> +` + } + script := ` <script> $(function() { var form = $('form'), save = form.find('button.save-post'), del = form.find('button.delete-post'), - approve = form.find('.post-controls.external'), + external = form.find('.post-controls.external'), id = form.find('input[name=id]'); // hide if this is a new post, or a non-post editor page if (id.val() === '-1' || form.attr('action') !== '/admin/edit') { del.hide(); - approve.hide(); + external.hide(); } // hide approval if not on a pending content item if (getParam('status') !== 'pending') { - approve.hide(); + external.hide(); } save.on('click', function(e) { @@ -155,7 +172,7 @@ func Form(post Editable, fields ...Field) ([]byte, error) { } }); - approve.find('button').on('click', function(e) { + external.find('button.approve-post').on('click', function(e) { e.preventDefault(); var action = form.attr('action'); action = action + '/approve'; @@ -163,10 +180,21 @@ func Form(post Editable, fields ...Field) ([]byte, error) { form.submit(); }); + + external.find('button.reject-post').on('click', function(e) { + e.preventDefault(); + var action = form.attr('action'); + action = action + '/delete?reject=true'; + form.attr('action', action); + + if (confirm("[Ponzu] Please confirm:\n\nAre you sure you want to reject this post?\nDoing so will delete it, and cannot be undone.")) { + form.submit(); + } + }); }); </script> ` - editor.ViewBuf.Write([]byte(submit + `</td></tr></tbody></table>`)) + editor.ViewBuf.Write([]byte(submit + script + `</td></tr></tbody></table>`)) return editor.ViewBuf.Bytes(), nil } diff --git a/system/admin/admin.go b/system/admin/admin.go index 8c13582..e0689b3 100644 --- a/system/admin/admin.go +++ b/system/admin/admin.go @@ -205,7 +205,8 @@ var loginAdminHTML = ` </div> <div class="input-field col s12"> <input placeholder="Enter your password" class="validate required" type="password" id="password" name="password"/> - <label for="password" class="active">Password</label> + <a href="/admin/recover" class="right">Forgot password?</a> + <label for="password" class="active">Password</label> </div> <button class="btn waves-effect waves-light right">Log in</button> </form> @@ -246,6 +247,118 @@ func Login() ([]byte, error) { return buf.Bytes(), nil } +var forgotPasswordHTML = ` +<div class="init col s5"> +<div class="card"> +<div class="card-content"> + <div class="card-title">Account Recovery</div> + <blockquote>Please enter the email for your account and a recovery message will be sent to you at this address. Check your spam folder in case the message was flagged.</blockquote> + <form method="post" action="/admin/recover" class="row"> + <div class="input-field col s12"> + <input placeholder="Enter your email address e.g. you@example.com" class="validate required" type="email" id="email" name="email"/> + <label for="email" class="active">Email</label> + </div> + + <button class="btn waves-effect waves-light right">Send Recovery Email</button> + </form> +</div> +</div> +</div> +<script> + $(function() { + $('.nav-wrapper ul.right').hide(); + }); +</script> +` + +// ForgotPassword ... +func ForgotPassword() ([]byte, error) { + html := startAdminHTML + forgotPasswordHTML + endAdminHTML + + cfg, err := db.Config("name") + if err != nil { + return nil, err + } + + if cfg == nil { + cfg = []byte("") + } + + a := admin{ + Logo: string(cfg), + } + + buf := &bytes.Buffer{} + tmpl := template.Must(template.New("forgotPassword").Parse(html)) + err = tmpl.Execute(buf, a) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +var recoveryKeyHTML = ` +<div class="init col s5"> +<div class="card"> +<div class="card-content"> + <div class="card-title">Account Recovery</div> + <blockquote>Please check for your recovery key inside an email sent to the address you provided. Check your spam folder in case the message was flagged.</blockquote> + <form method="post" action="/admin/recover/key" class="row"> + <div class="input-field col s12"> + <input placeholder="Enter your recovery key" class="validate required" type="text" id="key" name="key"/> + <label for="key" class="active">Recovery Key</label> + </div> + + <div class="input-field col s12"> + <input placeholder="Enter your email address e.g. you@example.com" class="validate required" type="email" id="email" name="email"/> + <label for="email" class="active">Email</label> + </div> + + <div class="input-field col s12"> + <input placeholder="Enter your password" class="validate required" type="password" id="password" name="password"/> + <label for="password" class="active">New Password</label> + </div> + + <button class="btn waves-effect waves-light right">Update Account</button> + </form> +</div> +</div> +</div> +<script> + $(function() { + $('.nav-wrapper ul.right').hide(); + }); +</script> +` + +// RecoveryKey ... +func RecoveryKey() ([]byte, error) { + html := startAdminHTML + recoveryKeyHTML + endAdminHTML + + cfg, err := db.Config("name") + if err != nil { + return nil, err + } + + if cfg == nil { + cfg = []byte("") + } + + a := admin{ + Logo: string(cfg), + } + + buf := &bytes.Buffer{} + tmpl := template.Must(template.New("recoveryKey").Parse(html)) + err = tmpl.Execute(buf, a) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + // UsersList ... func UsersList(req *http.Request) ([]byte, error) { html := ` diff --git a/system/admin/filesystem.go b/system/admin/filesystem.go new file mode 100644 index 0000000..4e64a26 --- /dev/null +++ b/system/admin/filesystem.go @@ -0,0 +1,36 @@ +package admin + +import ( + "net/http" + "os" +) + + +func restrict(dir http.Dir) justFilesFilesystem { + return justFilesFilesystem{dir} +} + +// the code below removes the open directory listing when accessing a URL which +// normally would point to a directory. code from golang-nuts mailing list: +// https://groups.google.com/d/msg/golang-nuts/bStLPdIVM6w/hidTJgDZpHcJ +// credit: Brad Fitzpatrick (c) 2012 + +type justFilesFilesystem struct { + fs http.FileSystem +} + +func (fs justFilesFilesystem) Open(name string) (http.File, error) { + f, err := fs.fs.Open(name) + if err != nil { + return nil, err + } + return neuteredReaddirFile{f}, nil +} + +type neuteredReaddirFile struct { + http.File +} + +func (f neuteredReaddirFile) Readdir(count int) ([]os.FileInfo, error) { + return nil, nil +} diff --git a/system/admin/handlers.go b/system/admin/handlers.go index 1f277e0..38ce41d 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -21,6 +21,7 @@ import ( "github.com/bosssauce/ponzu/system/db" "github.com/gorilla/schema" + emailer "github.com/nilslice/email" "github.com/nilslice/jwt" ) @@ -521,12 +522,187 @@ func logoutHandler(res http.ResponseWriter, req *http.Request) { http.Redirect(res, req, req.URL.Scheme+req.URL.Host+"/admin/login", http.StatusFound) } +func forgotPasswordHandler(res http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + view, err := ForgotPassword() + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + res.Write(view) + + case http.MethodPost: + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + // check email for user, if no user return Error + email := strings.ToLower(req.FormValue("email")) + if email == "" { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + _, err = db.User(email) + if err == db.ErrNoUserExists { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + if err != db.ErrNoUserExists && err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + // create temporary key to verify user + key, err := db.SetRecoveryKey(email) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + domain := db.ConfigCache("domain") + body := fmt.Sprintf(` + There has been an account recovery request made for the user with email: + %s + + To recover your account, please go to http://%s/admin/recover/key and enter + this email address along with the following secret key: + + %s + + If you did not make the request, ignore this message and your password + will remain as-is. + + + Thank you, + Ponzu CMS at %s + + `, email, domain, key, domain) + msg := emailer.Message{ + To: email, + From: fmt.Sprintf("Ponzu CMS <ponzu-cms@%s", domain), + Subject: fmt.Sprintf("Account Recovery [%s]", domain), + Body: body, + } + + /* + err = msg.Send() + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + */ + fmt.Println(msg) + + // redirect to /admin/recover/key and send email with key and URL + http.Redirect(res, req, req.URL.Scheme+req.URL.Host+"/admin/recover/key", http.StatusFound) + + default: + res.WriteHeader(http.StatusMethodNotAllowed) + errView, err := Error405() + if err != nil { + return + } + + res.Write(errView) + return + } +} + +func recoveryKeyHandler(res http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + view, err := RecoveryKey() + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + res.Write(view) + + case http.MethodPost: + + // check for email & key match + + // set user with new password + + // redirect to /admin/login + + default: + res.WriteHeader(http.StatusMethodNotAllowed) + errView, err := Error405() + if err != nil { + return + } + + res.Write(errView) + return + } +} + +func recoveryEditHandler(res http.ResponseWriter, req *http.Request) { + +} + func postsHandler(res http.ResponseWriter, req *http.Request) { q := req.URL.Query() t := q.Get("type") if t == "" { res.WriteHeader(http.StatusBadRequest) - errView, err := Error405() + errView, err := Error400() if err != nil { return } @@ -544,7 +720,7 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { if _, ok := content.Types[t]; !ok { res.WriteHeader(http.StatusBadRequest) - errView, err := Error405() + errView, err := Error400() if err != nil { return } @@ -717,7 +893,22 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { } } + } else { + 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, status) + b.Write(post) + } } + html += `<ul class="posts row">` b.Write([]byte(`</ul></div></div>`)) @@ -822,10 +1013,24 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { post := content.Types[t]() + // run hooks + hook, ok := post.(content.Hookable) + if !ok { + log.Println("Type", t, "does not implement content.Hookable or embed content.Item.") + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + // check if we have a Mergeable - m, ok := post.(api.Mergeable) + m, ok := post.(editor.Mergeable) if !ok { - log.Println("Content type", t, "must implement api.Mergable before it can bee approved.") + log.Println("Content type", t, "must implement editor.Mergable before it can bee approved.") res.WriteHeader(http.StatusBadRequest) errView, err := Error400() if err != nil { @@ -851,6 +1056,18 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { return } + err = hook.BeforeApprove(req) + 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 { @@ -864,6 +1081,30 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { return } + err = hook.AfterApprove(req) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + err = hook.BeforeSave(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 { @@ -877,6 +1118,18 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { return } + err = hook.AfterSave(req) + 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) @@ -946,7 +1199,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { log.Println("Content type", t, "doesn't implement editor.Identifiable") return } - s.SetContentID(-1) + s.SetItemID(-1) } @@ -1040,6 +1293,50 @@ func editHandler(res http.ResponseWriter, req *http.Request) { req.PostForm.Del(discardKey) } + if strings.Contains(t, "_") { + t = strings.Split(t, "_")[0] + } + + p, ok := content.Types[t] + if !ok { + log.Println("Type", t, "is not a content type. Cannot edit or save.") + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + post := p() + hook, ok := post.(content.Hookable) + if !ok { + log.Println("Type", t, "does not implement content.Hookable or embed content.Item.") + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + err = hook.BeforeSave(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + id, err := db.SetContent(t+":"+cid, req.PostForm) if err != nil { log.Println(err) @@ -1053,13 +1350,23 @@ func editHandler(res http.ResponseWriter, req *http.Request) { return } + err = hook.AfterSave(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + scheme := req.URL.Scheme host := req.URL.Host path := req.URL.Path sid := fmt.Sprintf("%d", id) - if strings.Contains(t, "_") { - t = strings.Split(t, "_")[0] - } redir := scheme + host + path + "?type=" + t + "&id=" + sid if req.URL.Query().Get("status") == "pending" { @@ -1088,12 +1395,75 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { id := req.FormValue("id") t := req.FormValue("type") + ct := t if id == "" || t == "" { res.WriteHeader(http.StatusBadRequest) return } + // catch specifier suffix from delete form value + if strings.Contains(t, "_") { + spec := strings.Split(t, "_") + ct = spec[0] + } + + p, ok := content.Types[ct] + if !ok { + log.Println("Type", t, "does not implement content.Hookable or embed content.Item.") + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + post := p() + hook, ok := post.(content.Hookable) + if !ok { + log.Println("Type", t, "does not implement content.Hookable or embed content.Item.") + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + reject := req.URL.Query().Get("reject") + if reject == "true" { + err = hook.BeforeReject(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + } + + err = hook.BeforeDelete(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + err = db.DeleteContent(t + ":" + id) if err != nil { log.Println(err) @@ -1101,14 +1471,36 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { return } - // catch specifier suffix from delete form value - if strings.Contains(t, "_") { - spec := strings.Split(t, "_") - t = spec[0] + err = hook.AfterDelete(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + if reject == "true" { + err = hook.AfterReject(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } } redir := strings.TrimSuffix(req.URL.Scheme+req.URL.Host+req.URL.Path, "/edit/delete") - redir = redir + "/posts?type=" + t + redir = redir + "/posts?type=" + ct http.Redirect(res, req, redir, http.StatusFound) } diff --git a/system/admin/server.go b/system/admin/server.go index 5d93d84..75b48f6 100644 --- a/system/admin/server.go +++ b/system/admin/server.go @@ -18,6 +18,10 @@ func Run() { http.HandleFunc("/admin/login", loginHandler) http.HandleFunc("/admin/logout", logoutHandler) + http.HandleFunc("/admin/recover", forgotPasswordHandler) + http.HandleFunc("/admin/recover/key", recoveryKeyHandler) + http.HandleFunc("/admin/recover/edit", recoveryEditHandler) + http.HandleFunc("/admin/configure", user.Auth(configHandler)) http.HandleFunc("/admin/configure/users", user.Auth(configUsersHandler)) http.HandleFunc("/admin/configure/users/edit", user.Auth(configUsersEditHandler)) @@ -37,11 +41,11 @@ func Run() { } staticDir := filepath.Join(pwd, "cmd", "ponzu", "vendor", "github.com", "bosssauce", "ponzu", "system") - http.Handle("/admin/static/", CacheControl(http.FileServer(http.Dir(staticDir)))) + http.Handle("/admin/static/", CacheControl(http.FileServer(restrict(http.Dir(staticDir))))) // API path needs to be registered within server package so that it is handled // even if the API server is not running. Otherwise, images/files uploaded // through the editor will not load within the admin system. uploadsDir := filepath.Join(pwd, "uploads") - http.Handle("/api/uploads/", CacheControl(http.StripPrefix("/api/uploads/", http.FileServer(http.Dir(uploadsDir))))) + http.Handle("/api/uploads/", CacheControl(http.StripPrefix("/api/uploads/", http.FileServer(restrict(http.Dir(uploadsDir)))))) } diff --git a/system/admin/static/dashboard/css/admin.css b/system/admin/static/dashboard/css/admin.css index 3cffc5d..8d2fb89 100644 --- a/system/admin/static/dashboard/css/admin.css +++ b/system/admin/static/dashboard/css/admin.css @@ -154,7 +154,7 @@ footer p { color: #9e9e9e; } -.post-controls .save-post { +.post-controls .save-post, .post-controls .approve-post { margin-left: 10px; } diff --git a/system/api/external.go b/system/api/external.go index c9fb2a7..5c50172 100644 --- a/system/api/external.go +++ b/system/api/external.go @@ -19,14 +19,6 @@ type Externalable interface { 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 externalPostHandler(res http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { res.WriteHeader(http.StatusMethodNotAllowed) @@ -99,11 +91,32 @@ func externalPostHandler(res http.ResponseWriter, req *http.Request) { req.PostForm.Del(discardKey) } + hook, ok := post.(content.Hookable) + if !ok { + log.Println("[External] error: Type", t, "does not implement content.Hookable or embed content.Item.") + res.WriteHeader(http.StatusBadRequest) + return + } + + err = hook.BeforeSave(req) + if err != nil { + log.Println("[External] error:", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + _, err = db.SetContent(t+"_pending:-1", req.PostForm) if err != nil { log.Println("[External] error:", err) res.WriteHeader(http.StatusInternalServerError) return } + + err = hook.AfterSave(req) + if err != nil { + log.Println("[External] error:", err) + res.WriteHeader(http.StatusInternalServerError) + return + } } } diff --git a/system/db/user.go b/system/db/user.go index d2dc3a9..3386dc2 100644 --- a/system/db/user.go +++ b/system/db/user.go @@ -11,6 +11,7 @@ import ( "github.com/boltdb/bolt" "github.com/nilslice/jwt" + "github.com/nilslice/rand" ) // ErrUserExists is used for the db to report to admin user of existing user @@ -135,6 +136,10 @@ func User(email string) ([]byte, error) { return nil, err } + if val.Bytes() == nil { + return nil, ErrNoUserExists + } + return val.Bytes(), nil } @@ -184,3 +189,53 @@ func CurrentUser(req *http.Request) ([]byte, error) { return usr, nil } + +// SetRecoveryKey generates and saves a random secret key to verify an email +// address submitted in order to recover/reset an account password +func SetRecoveryKey(email string) (string, error) { + key := fmt.Sprintf("%d", rand.Int63()) + + err := store.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte("_recoveryKeys")) + if err != nil { + return err + } + + err = b.Put([]byte(email), []byte(key)) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return "", err + } + + return key, nil +} + +// RecoveryKey generates and saves a random secret key to verify an email +// address submitted in order to recover/reset an account password +func RecoveryKey(email string) (string, error) { + key := &bytes.Buffer{} + + err := store.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("_recoveryKeys")) + if b == nil { + return errors.New("No database found for checking keys.") + } + + _, err := key.Write(b.Get([]byte("email"))) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return "", err + } + + return key.String(), nil +} |