summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSteve <nilslice@gmail.com>2016-11-16 02:51:26 -0800
committerGitHub <noreply@github.com>2016-11-16 02:51:26 -0800
commitf252472047f86d1bdf956dc59b89541ea0260d68 (patch)
tree6c0d5938c21505325ad50446f5ebcd1483d6b278
parent9a531ac701990bb932664039b11614227467e03a (diff)
parent6f5893828077eb0034dca01344f3742eb5cd5fa6 (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/LICENSE21
-rw-r--r--cmd/ponzu/vendor/github.com/nilslice/email/README.md51
-rw-r--r--cmd/ponzu/vendor/github.com/nilslice/email/email.go114
-rw-r--r--cmd/ponzu/vendor/github.com/nilslice/email/email_test.go19
-rw-r--r--content/item.go88
-rw-r--r--management/editor/editor.go42
-rw-r--r--system/admin/admin.go115
-rw-r--r--system/admin/filesystem.go36
-rw-r--r--system/admin/handlers.go418
-rw-r--r--system/admin/server.go8
-rw-r--r--system/admin/static/dashboard/css/admin.css2
-rw-r--r--system/api/external.go29
-rw-r--r--system/db/user.go55
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
+}