diff options
-rw-r--r-- | cmd/ponzu/contentType.tmpl | 4 | ||||
-rw-r--r-- | cmd/ponzu/vendor/github.com/nilslice/email/README.md | 2 | ||||
-rw-r--r-- | cmd/ponzu/vendor/github.com/nilslice/email/email.go | 9 | ||||
-rw-r--r-- | content/item.go | 8 | ||||
-rw-r--r-- | content/types.go | 10 | ||||
-rw-r--r-- | management/editor/editor.go | 1 | ||||
-rw-r--r-- | management/editor/elements.go | 18 | ||||
-rw-r--r-- | management/manager/process.go | 6 | ||||
-rw-r--r-- | system/admin/admin.go | 9 | ||||
-rw-r--r-- | system/admin/config/config.go | 4 | ||||
-rw-r--r-- | system/admin/handlers.go | 269 | ||||
-rw-r--r-- | system/admin/server.go | 6 | ||||
-rw-r--r-- | system/api/analytics/init.go | 4 | ||||
-rw-r--r-- | system/api/external.go | 134 | ||||
-rw-r--r-- | system/api/handlers.go | 6 | ||||
-rw-r--r-- | system/api/server.go | 6 | ||||
-rw-r--r-- | system/db/config.go | 4 | ||||
-rw-r--r-- | system/db/content.go | 73 | ||||
-rw-r--r-- | system/db/init.go | 8 | ||||
-rw-r--r-- | system/db/user.go | 25 | ||||
-rw-r--r-- | system/tls/enable.go | 4 |
21 files changed, 400 insertions, 210 deletions
diff --git a/cmd/ponzu/contentType.tmpl b/cmd/ponzu/contentType.tmpl index df582a0..af60e57 100644 --- a/cmd/ponzu/contentType.tmpl +++ b/cmd/ponzu/contentType.tmpl @@ -41,10 +41,6 @@ func init() { Types["{{ .Name }}"] = func() interface{} { return new({{ .Name }}) } } -// ContentName is required to set the display name for a piece of content in the editor -// Partially implements editor.Editable -func ({{ .Initial }} *{{ .Name }}) ContentName() string { return fmt.Sprintf("{{ .Name }} - ID: %s", {{ .Initial }}.UniqueID()) } - // Editor is a buffer of bytes for the Form function to write input views // partially implements editor.Editable func ({{ .Initial }} *{{ .Name }}) Editor() *editor.Editor { return &{{ .Initial }}.editor } diff --git a/cmd/ponzu/vendor/github.com/nilslice/email/README.md b/cmd/ponzu/vendor/github.com/nilslice/email/README.md index 323004c..190c61f 100644 --- a/cmd/ponzu/vendor/github.com/nilslice/email/README.md +++ b/cmd/ponzu/vendor/github.com/nilslice/email/README.md @@ -19,7 +19,7 @@ import ( 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 + From: "me@server.name", // do not add < > or name in quotes Subject: "A simple email", Body: "Plain text email body. HTML not yet supported, but send a PR!", } diff --git a/cmd/ponzu/vendor/github.com/nilslice/email/email.go b/cmd/ponzu/vendor/github.com/nilslice/email/email.go index 9d613c8..15f0a49 100644 --- a/cmd/ponzu/vendor/github.com/nilslice/email/email.go +++ b/cmd/ponzu/vendor/github.com/nilslice/email/email.go @@ -87,14 +87,14 @@ func send(m Message, c *smtp.Client) error { } if m.From != "" { - _, err = msg.Write([]byte("From: " + m.From + "\r\n")) + _, 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")) + _, err = msg.Write([]byte("To: <" + m.To + ">\r\n")) if err != nil { return err } @@ -110,5 +110,10 @@ func send(m Message, c *smtp.Client) error { return err } + err = c.Quit() + if err != nil { + return err + } + return nil } diff --git a/content/item.go b/content/item.go index 9eb3c16..e6c8243 100644 --- a/content/item.go +++ b/content/item.go @@ -1,6 +1,7 @@ package content import ( + "fmt" "net/http" uuid "github.com/satori/go.uuid" @@ -22,6 +23,7 @@ type Identifiable interface { ItemID() int SetItemID(int) UniqueID() uuid.UUID + String() string } // Hookable provides our user with an easy way to intercept or add functionality @@ -83,6 +85,12 @@ func (i Item) UniqueID() uuid.UUID { return i.UUID } +// String formats an Item into a printable value +// partially implements the Identifiable interface +func (i Item) String() string { + return fmt.Sprintf("Item ID: %s", i.UniqueID()) +} + // BeforeSave is a no-op to ensure structs which embed Item implement Hookable func (i Item) BeforeSave(req *http.Request) error { return nil diff --git a/content/types.go b/content/types.go index ede2b58..696d589 100644 --- a/content/types.go +++ b/content/types.go @@ -6,13 +6,13 @@ const ( There is no type registered for %[1]s Add this to the file which defines %[1]s{} in the 'content' package: ---------------------------------------------------------------------------+ -func init() { - Types["%[1]s"] = func() interface{} { return new(%[1]s) } -} + + func init() { + Types["%[1]s"] = func() interface{} { return new(%[1]s) } + } ---------------------------------------------------------------------------+ + ` ) diff --git a/management/editor/editor.go b/management/editor/editor.go index 2a9183b..6b55a38 100644 --- a/management/editor/editor.go +++ b/management/editor/editor.go @@ -9,7 +9,6 @@ import ( // Editable ensures data is editable type Editable interface { - ContentName() string Editor() *Editor MarshalEditor() ([]byte, error) } diff --git a/management/editor/elements.go b/management/editor/elements.go index 4a95150..4a8ae55 100644 --- a/management/editor/elements.go +++ b/management/editor/elements.go @@ -341,7 +341,15 @@ func Tags(fieldName string, p interface{}, attrs map[string]string) []byte { // get the saved tags if this is already an existing post values := valueFromStructField(fieldName, p) - tags := strings.Split(values, "__ponzu") + var tags []string + if strings.Contains(values, "__ponzu") { + tags = strings.Split(values, "__ponzu") + } + + // case where there is only one tag stored, thus has no separator + if len(values) > 0 && !strings.Contains(values, "__ponzu") { + tags = append(tags, values) + } html := ` <div class="col s12 __ponzu-tags ` + name + `"> @@ -375,7 +383,7 @@ func Tags(fieldName string, p interface{}, attrs map[string]string) []byte { var input = $('<input>'); input.attr({ - class: 'tag '+chip.tag, + class: '__ponzu-tag '+chip.tag.split(' ').join('__'), name: '` + name + `.'+String(tags.find('input[type=hidden]').length), value: chip.tag, type: 'hidden' @@ -386,9 +394,7 @@ func Tags(fieldName string, p interface{}, attrs map[string]string) []byte { chips.on('chip.delete', function(e, chip) { // convert tag string to class-like selector "some tag" -> ".some.tag" - var sel = '.__ponzu-tag.'+chip.tag.split(' ').join('.'); - console.log(sel); - console.log(chips.parent().find(sel)); + var sel = '.__ponzu-tag.' + chip.tag.split(' ').join('__'); chips.parent().find(sel).remove(); // iterate through all hidden tag inputs to re-name them with the correct ` + name + `.index @@ -404,9 +410,9 @@ func Tags(fieldName string, p interface{}, attrs map[string]string) []byte { }); tags.append(input); - return; } + // re-name hidden storage elements in necessary format for (var i = 0; i < hidden.length; i++) { $(hidden[i]).attr('name', '` + name + `.'+String(i)); } diff --git a/management/manager/process.go b/management/manager/process.go index ec09e45..ad6da94 100644 --- a/management/manager/process.go +++ b/management/manager/process.go @@ -5,16 +5,16 @@ import ( "strings" "unicode" - "github.com/bosssauce/ponzu/management/editor" + "github.com/bosssauce/ponzu/content" "golang.org/x/text/transform" "golang.org/x/text/unicode/norm" ) // Slug returns a URL friendly string from the title of a post item -func Slug(e editor.Editable) (string, error) { +func Slug(i content.Identifiable) (string, error) { // get the name of the post item - name := strings.TrimSpace(e.ContentName()) + name := strings.TrimSpace(i.String()) // filter out non-alphanumeric character or non-whitespace slug, err := stringToSlug(name) diff --git a/system/admin/admin.go b/system/admin/admin.go index e0689b3..9ddff84 100644 --- a/system/admin/admin.go +++ b/system/admin/admin.go @@ -57,7 +57,7 @@ var mainAdminHTML = ` {{ range $t, $f := .Types }} <div class="row collection-item"> - <li><a class="col s12" href="/admin/posts?type={{ $t }}"><i class="tiny left material-icons">playlist_add</i>{{ $t }}</a></li> + <li><a class="col s12" href="/admin/contents?type={{ $t }}"><i class="tiny left material-icons">playlist_add</i>{{ $t }}</a></li> </div> {{ end }} @@ -205,7 +205,7 @@ var loginAdminHTML = ` </div> <div class="input-field col s12"> <input placeholder="Enter your password" class="validate required" type="password" id="password" name="password"/> - <a href="/admin/recover" class="right">Forgot password?</a> + <a href="/admin/recover">Forgot password?</a> <label for="password" class="active">Password</label> </div> <button class="btn waves-effect waves-light right">Log in</button> @@ -253,12 +253,13 @@ var forgotPasswordHTML = ` <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"> + <form method="post" action="/admin/recover" class="row" enctype="multipart/form-data"> <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> + <a href="/admin/recover/key">Already have a recovery key?</a> <button class="btn waves-effect waves-light right">Send Recovery Email</button> </form> </div> @@ -304,7 +305,7 @@ var recoveryKeyHTML = ` <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"> + <form method="post" action="/admin/recover/key" class="row" enctype="multipart/form-data"> <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> diff --git a/system/admin/config/config.go b/system/admin/config/config.go index 0a7103e..b898b49 100644 --- a/system/admin/config/config.go +++ b/system/admin/config/config.go @@ -18,8 +18,8 @@ type Config struct { CacheInvalidate []string `json:"cache"` } -// ContentName partially implements editor.Editable -func (c *Config) ContentName() string { return c.Name } +// String partially implements content.Identifiable and overrides Item's String() +func (c *Config) String() string { return c.Name } // Editor partially implements editor.Editable func (c *Config) Editor() *editor.Editor { return &c.editor } diff --git a/system/admin/handlers.go b/system/admin/handlers.go index 7b8bfae..c91db79 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -556,35 +556,20 @@ func forgotPasswordHandler(res http.ResponseWriter, req *http.Request) { email := strings.ToLower(req.FormValue("email")) if email == "" { res.WriteHeader(http.StatusBadRequest) - errView, err := Error400() - if err != nil { - return - } - - res.Write(errView) + log.Println("Failed account recovery. No email address submitted.") return } _, err = db.User(email) if err == db.ErrNoUserExists { res.WriteHeader(http.StatusBadRequest) - errView, err := Error400() - if err != nil { - return - } - - res.Write(errView) + log.Println("No user exists.", err) return } if err != db.ErrNoUserExists && err != nil { res.WriteHeader(http.StatusInternalServerError) - errView, err := Error500() - if err != nil { - return - } - - res.Write(errView) + log.Println("Error:", err) return } @@ -592,54 +577,48 @@ func forgotPasswordHandler(res http.ResponseWriter, req *http.Request) { key, err := db.SetRecoveryKey(email) if err != nil { res.WriteHeader(http.StatusInternalServerError) - errView, err := Error500() - if err != nil { - return - } + log.Println("Failed to set account recovery key.", err) + return + } - res.Write(errView) + domain, err := db.Config("domain") + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + log.Println("Failed to get domain from configuration.", err) return } - domain := db.ConfigCache("domain") body := fmt.Sprintf(` - There has been an account recovery request made for the user with email: - %s +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: - To recover your account, please go to http://%s/admin/recover/key and enter - this email address along with the following secret key: - - %s +%s - If you did not make the request, ignore this message and your password - will remain as-is. +If you did not make the request, ignore this message and your password +will remain as-is. - Thank you, - Ponzu CMS at %s +Thank you, +Ponzu CMS at %s + +`, email, domain, key, domain) - `, email, domain, key, domain) msg := emailer.Message{ To: email, - From: fmt.Sprintf("Ponzu CMS <ponzu-cms@%s", domain), + From: fmt.Sprintf("ponzu@%s", domain), Subject: fmt.Sprintf("Account Recovery [%s]", domain), Body: body, } - /* + go func() { err = msg.Send() if err != nil { - res.WriteHeader(http.StatusInternalServerError) - errView, err := Error500() - if err != nil { - return - } - - res.Write(errView) - return + log.Println("Failed to send message to:", msg.To, "about", msg.Subject, "Error:", err) } - */ - 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) @@ -662,33 +641,90 @@ func recoveryKeyHandler(res http.ResponseWriter, req *http.Request) { 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: + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + log.Println("Error parsing recovery key form:", err) + + res.WriteHeader(http.StatusInternalServerError) + res.Write([]byte("Error, please go back and try again.")) + return + } // check for email & key match + email := strings.ToLower(req.FormValue("email")) + key := req.FormValue("key") + + var actual string + if actual, err = db.RecoveryKey(email); err != nil || actual == "" { + log.Println("Error getting recovery key from database:", err) + + res.WriteHeader(http.StatusInternalServerError) + res.Write([]byte("Error, please go back and try again.")) + return + } + + if key != actual { + log.Println("Bad recovery key submitted:", key) + log.Println("Actual:", actual) + + res.WriteHeader(http.StatusBadRequest) + res.Write([]byte("Error, please go back and try again.")) + return + } // set user with new password + password := req.FormValue("password") + usr := &user.User{} + u, err := db.User(email) + if err != nil { + log.Println("Error finding user by email:", email, err) - // redirect to /admin/login + res.WriteHeader(http.StatusInternalServerError) + res.Write([]byte("Error, please go back and try again.")) + return + } - default: - res.WriteHeader(http.StatusMethodNotAllowed) - errView, err := Error405() + if u == nil { + log.Println("No user found with email:", email) + + res.WriteHeader(http.StatusBadRequest) + res.Write([]byte("Error, please go back and try again.")) + return + } + + err = json.Unmarshal(u, usr) if err != nil { + log.Println("Error decoding user from database:", err) + + res.WriteHeader(http.StatusInternalServerError) + res.Write([]byte("Error, please go back and try again.")) return } - res.Write(errView) + update := user.NewUser(email, password) + update.ID = usr.ID + + err = db.UpdateUser(usr, update) + if err != nil { + log.Println("Error updating user:", err) + + res.WriteHeader(http.StatusInternalServerError) + res.Write([]byte("Error, please go back and try again.")) + return + } + + // redirect to /admin/login + redir := req.URL.Scheme + req.URL.Host + "/admin/login" + http.Redirect(res, req, redir, http.StatusFound) + + default: + res.WriteHeader(http.StatusMethodNotAllowed) return } } @@ -697,7 +733,7 @@ func recoveryEditHandler(res http.ResponseWriter, req *http.Request) { } -func postsHandler(res http.ResponseWriter, req *http.Request) { +func contentsHandler(res http.ResponseWriter, req *http.Request) { q := req.URL.Query() t := q.Get("type") if t == "" { @@ -787,7 +823,7 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { Order: order, } - posts := db.Query(t+"_sorted", opts) + total, posts := db.Query(t+"__sorted", opts) b := &bytes.Buffer{} html := `<div class="col s9 card"> @@ -824,20 +860,25 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { </script> </div> </div> - <form class="col s4" action="/admin/posts/search" method="get"> + <form class="col s4" action="/admin/contents/search" method="get"> <div class="input-field post-search inline"> <label class="active">Search:</label> <i class="right material-icons search-icon">search</i> <input class="search" name="q" type="text" placeholder="Within all ` + t + ` fields" class="search"/> <input type="hidden" name="type" value="` + t + `" /> + <input type="hidden" name="status" value="` + status + `" /> </div> </form> </div>` if hasExt { if status == "" { - q.Add("status", "public") + q.Set("status", "public") } + // always start from top of results when changing public/pending + q.Del("count") + q.Del("offset") + q.Set("status", "public") publicURL := req.URL.Path + "?" + q.Encode() @@ -868,8 +909,8 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { } case "pending": - // get _pending posts of type t from the db - posts = db.Query(t+"_pending", opts) + // get __pending posts of type t from the db + _, posts = db.Query(t+"__pending", opts) html += `<div class="row externalable"> <span class="description">Status:</span> @@ -911,7 +952,56 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { html += `<ul class="posts row">` - b.Write([]byte(`</ul></div></div>`)) + b.Write([]byte(`</ul>`)) + + statusDisabled := "disabled" + prevStatus := "" + nextStatus := "" + // total may be less than 10 (default count), so reset count to match total + if total < count { + count = total + } + // nothing previous to current list + if offset == 0 { + prevStatus = statusDisabled + } + // nothing after current list + if (offset+1)*count >= total { + nextStatus = statusDisabled + } + + // set up pagination values + urlFmt := req.URL.Path + "?count=%d&offset=%d&&order=%s&status=%s&type=%s" + prevURL := fmt.Sprintf(urlFmt, count, offset-1, order, status, t) + nextURL := fmt.Sprintf(urlFmt, count, offset+1, order, status, t) + start := 1 + count*offset + end := start + count - 1 + + if total < end { + end = total + } + + pagination := fmt.Sprintf(` + <ul class="pagination row"> + <li class="col s2 waves-effect %s"><a href="%s"><i class="material-icons">chevron_left</i></a></li> + <li class="col s8">%d to %d of %d</li> + <li class="col s2 waves-effect %s"><a href="%s"><i class="material-icons">chevron_right</i></a></li> + </ul> + `, prevStatus, prevURL, start, end, total, nextStatus, nextURL) + + // show indicator that a collection of items will be listed implicitly, but + // that none are created yet + if total < 1 { + pagination = ` + <ul class="pagination row"> + <li class="col s2 waves-effect disabled"><a href="#"><i class="material-icons">chevron_left</i></a></li> + <li class="col s8">0 to 0 of 0</li> + <li class="col s2 waves-effect disabled"><a href="#"><i class="material-icons">chevron_right</i></a></li> + </ul> + ` + } + + b.Write([]byte(pagination + `</div></div>`)) script := ` <script> @@ -923,6 +1013,13 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { } }); }); + + // disable link from being clicked if parent is 'disabled' + $(function() { + $('ul.pagination li.disabled a').on('click', function(e) { + e.preventDefault(); + }); + }); </script> ` @@ -942,7 +1039,7 @@ 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. -// specifier is passed to append a name to a namespace like _pending +// specifier is passed to append a name to a namespace like __pending func adminPostListItem(e editor.Editable, typeName, status string) []byte { s, ok := e.(editor.Sortable) if !ok { @@ -970,12 +1067,12 @@ func adminPostListItem(e editor.Editable, typeName, status string) []byte { case "public", "": status = "" default: - status = "_" + status + status = "__" + status } post := ` <li class="col s12"> - <a href="/admin/edit?type=` + typeName + `&status=` + strings.TrimPrefix(status, "_") + `&id=` + cid + `">` + e.ContentName() + `</a> + <a href="/admin/edit?type=` + typeName + `&status=` + strings.TrimPrefix(status, "__") + `&id=` + cid + `">` + i.String() + `</a> <span class="post-detail">Updated: ` + updatedTime + `</span> <span class="publish-date right">` + publishTime + `</span> @@ -989,7 +1086,7 @@ func adminPostListItem(e editor.Editable, typeName, status string) []byte { return []byte(post) } -func approvePostHandler(res http.ResponseWriter, req *http.Request) { +func approveContentHandler(res http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { res.WriteHeader(http.StatusMethodNotAllowed) errView, err := Error405() @@ -1014,8 +1111,8 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { } t := req.FormValue("type") - if strings.Contains(t, "_") { - t = strings.Split(t, "_")[0] + if strings.Contains(t, "__") { + t = strings.Split(t, "__")[0] } post := content.Types[t]() @@ -1160,7 +1257,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { if i != "" { if status == "pending" { - t = t + "_pending" + t = t + "__pending" } data, err := db.Content(t + ":" + i) @@ -1275,7 +1372,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { } for name, urlPath := range urlPaths { - req.PostForm.Add(name, urlPath) + req.PostForm.Set(name, urlPath) } // check for any multi-value fields (ex. checkbox fields) @@ -1299,8 +1396,8 @@ func editHandler(res http.ResponseWriter, req *http.Request) { req.PostForm.Del(discardKey) } - if strings.Contains(t, "_") { - t = strings.Split(t, "_")[0] + if strings.Contains(t, "__") { + t = strings.Split(t, "__")[0] } p, ok := content.Types[t] @@ -1409,8 +1506,8 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { } // catch specifier suffix from delete form value - if strings.Contains(t, "_") { - spec := strings.Split(t, "_") + if strings.Contains(t, "__") { + spec := strings.Split(t, "__") ct = spec[0] } @@ -1506,7 +1603,7 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { } redir := strings.TrimSuffix(req.URL.Scheme+req.URL.Host+req.URL.Path, "/edit/delete") - redir = redir + "/posts?type=" + ct + redir = redir + "/contents?type=" + ct http.Redirect(res, req, redir, http.StatusFound) } @@ -1531,13 +1628,19 @@ func searchHandler(res http.ResponseWriter, req *http.Request) { q := req.URL.Query() t := q.Get("type") search := q.Get("q") + status := q.Get("status") + var specifier string if t == "" || search == "" { http.Redirect(res, req, req.URL.Scheme+req.URL.Host+"/admin", http.StatusFound) return } - posts := db.ContentAll(t) + if status == "pending" { + specifier = "__" + status + } + + posts := db.ContentAll(t + specifier) b := &bytes.Buffer{} p := content.Types[t]().(editor.Editable) @@ -1545,11 +1648,13 @@ func searchHandler(res http.ResponseWriter, req *http.Request) { <div class="card-content"> <div class="row"> <div class="card-title col s7">` + t + ` Results</div> - <form class="col s5" action="/admin/posts/search" method="get"> + <form class="col s4" action="/admin/contents/search" method="get"> <div class="input-field post-search inline"> + <label class="active">Search:</label> <i class="right material-icons search-icon">search</i> - <input class="search" name="q" type="text" placeholder="Search for ` + t + ` content" class="search"/> + <input class="search" name="q" type="text" placeholder="Within all ` + t + ` fields" class="search"/> <input type="hidden" name="type" value="` + t + `" /> + <input type="hidden" name="status" value="` + status + `" /> </div> </form> </div> @@ -1572,7 +1677,7 @@ func searchHandler(res http.ResponseWriter, req *http.Request) { continue } - post := adminPostListItem(p, t, "") + post := adminPostListItem(p, t, status) b.Write([]byte(post)) } diff --git a/system/admin/server.go b/system/admin/server.go index 75b48f6..f80a750 100644 --- a/system/admin/server.go +++ b/system/admin/server.go @@ -27,12 +27,12 @@ func Run() { 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/contents", user.Auth(contentsHandler)) + http.HandleFunc("/admin/contents/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/approve", user.Auth(approveContentHandler)) http.HandleFunc("/admin/edit/upload", user.Auth(editUploadHandler)) pwd, err := os.Getwd() diff --git a/system/api/analytics/init.go b/system/api/analytics/init.go index 41c24c7..6558adf 100644 --- a/system/api/analytics/init.go +++ b/system/api/analytics/init.go @@ -66,7 +66,7 @@ func Init() { } err = store.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte("requests")) + _, err := tx.CreateBucketIfNotExists([]byte("__requests")) if err != nil { return err } @@ -138,7 +138,7 @@ func ChartData() (map[string]interface{}, error) { // get api request analytics from db var requests = []apiRequest{} err := store.View(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte("requests")) + b := tx.Bucket([]byte("__requests")) err := b.ForEach(func(k, v []byte) error { var r apiRequest diff --git a/system/api/external.go b/system/api/external.go index 4e008af..0d1ea03 100644 --- a/system/api/external.go +++ b/system/api/external.go @@ -15,11 +15,17 @@ import ( // Externalable accepts or rejects external POST requests to endpoints such as: // /external/content?type=Review type Externalable interface { - // Accepts determines whether a type will allow external submissions - Accepts() bool + // Accept allows external content submissions of a specific type + Accept(req *http.Request) error } -func externalPostHandler(res http.ResponseWriter, req *http.Request) { +// Trustable allows external content to be auto-approved, meaning content sent +// as an Externalable will be stored in the public content bucket +type Trustable interface { + AutoApprove(req *http.Request) error +} + +func externalContentHandler(res http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { res.WriteHeader(http.StatusMethodNotAllowed) return @@ -54,69 +60,93 @@ func externalPostHandler(res http.ResponseWriter, req *http.Request) { return } - if ext.Accepts() { - ts := fmt.Sprintf("%d", time.Now().Unix()*1000) - req.PostForm.Set("timestamp", ts) - req.PostForm.Set("updated", ts) + 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 - } + 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) - } + for name, urlPath := range urlPaths { + req.PostForm.Set(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]) - } + // 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) - } + for _, discardKey := range discardKeys { + 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 - } + // call Accept with the request, enabling developer to add or chack data + // before saving to DB + err = ext.Accept(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + return + } - err = hook.BeforeSave(req) - if err != nil { - log.Println("[External] error:", err) - res.WriteHeader(http.StatusInternalServerError) - return - } + 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 = db.SetContent(t+"_pending:-1", req.PostForm) - if err != nil { - log.Println("[External] error:", err) - res.WriteHeader(http.StatusInternalServerError) - return - } + err = hook.BeforeSave(req) + if err != nil { + log.Println("[External] error:", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + // set specifier for db bucket in case content is/isn't Trustable + var spec string - err = hook.AfterSave(req) + // check if the content is Trustable should be auto-approved + trusted, ok := post.(Trustable) + if ok { + err := trusted.AutoApprove(req) if err != nil { log.Println("[External] error:", err) res.WriteHeader(http.StatusInternalServerError) return } + } else { + spec = "__pending" } + + _, err = db.SetContent(t+spec+":-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/api/handlers.go b/system/api/handlers.go index afe5819..8a1517b 100644 --- a/system/api/handlers.go +++ b/system/api/handlers.go @@ -28,7 +28,7 @@ func typesHandler(res http.ResponseWriter, req *http.Request) { sendData(res, j, http.StatusOK) } -func postsHandler(res http.ResponseWriter, req *http.Request) { +func contentsHandler(res http.ResponseWriter, req *http.Request) { q := req.URL.Query() t := q.Get("type") if t == "" { @@ -72,7 +72,7 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { Order: order, } - bb := db.Query(t+"_sorted", opts) + _, bb := db.Query(t+"__sorted", opts) var result = []json.RawMessage{} for i := range bb { result = append(result, bb[i]) @@ -87,7 +87,7 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { sendData(res, j, http.StatusOK) } -func postHandler(res http.ResponseWriter, req *http.Request) { +func contentHandler(res http.ResponseWriter, req *http.Request) { q := req.URL.Query() id := q.Get("id") t := q.Get("type") diff --git a/system/api/server.go b/system/api/server.go index 70add10..823ec16 100644 --- a/system/api/server.go +++ b/system/api/server.go @@ -8,9 +8,9 @@ import ( func Run() { http.HandleFunc("/api/types", CORS(Record(typesHandler))) - http.HandleFunc("/api/contents", CORS(Record(postsHandler))) + http.HandleFunc("/api/contents", CORS(Record(contentsHandler))) - http.HandleFunc("/api/content", CORS(Record(postHandler))) + http.HandleFunc("/api/content", CORS(Record(contentHandler))) - http.HandleFunc("/api/content/external", CORS(Record(externalPostHandler))) + http.HandleFunc("/api/content/external", CORS(Record(externalContentHandler))) } diff --git a/system/db/config.go b/system/db/config.go index 6855081..ab1c720 100644 --- a/system/db/config.go +++ b/system/db/config.go @@ -24,7 +24,7 @@ func init() { // SetConfig sets key:value pairs in the db for configuration settings func SetConfig(data url.Values) error { err := store.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte("_config")) + b := tx.Bucket([]byte("__config")) // check for any multi-value fields (ex. checkbox fields) // and correctly format for db storage. Essentially, we need @@ -108,7 +108,7 @@ func Config(key string) ([]byte, error) { func ConfigAll() ([]byte, error) { val := &bytes.Buffer{} err := store.View(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte("_config")) + b := tx.Bucket([]byte("__config")) val.Write(b.Get([]byte("settings"))) return nil diff --git a/system/db/content.go b/system/db/content.go index 0cfadf4..74a77ec 100644 --- a/system/db/content.go +++ b/system/db/content.go @@ -39,11 +39,11 @@ 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, "_") + var specifier string // i.e. __pending, __sorted, etc. + if strings.Contains(ns, "__") { + spec := strings.Split(ns, "__") ns = spec[0] - specifier = "_" + spec[1] + specifier = "__" + spec[1] } cid, err := strconv.Atoi(id) @@ -82,11 +82,11 @@ func update(ns, id string, data url.Values) (int, error) { 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, "_") + var specifier string // i.e. __pending, __sorted, etc. + if strings.Contains(ns, "__") { + spec := strings.Split(ns, "__") ns = spec[0] - specifier = "_" + spec[1] + specifier = "__" + spec[1] } err := store.Update(func(tx *bolt.Tx) error { @@ -215,8 +215,21 @@ type QueryOptions struct { } // Query retrieves a set of content from the db based on options -func Query(namespace string, opts QueryOptions) [][]byte { +// and returns the total number of content in the namespace and the content +func Query(namespace string, opts QueryOptions) (int, [][]byte) { var posts [][]byte + var total int + + // correct bad input rather than return nil or error + // similar to default case for opts.Order switch below + if opts.Count < 0 { + opts.Count = 0 + } + + if opts.Offset < 0 { + opts.Offset = 0 + } + store.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte(namespace)) if b == nil { @@ -225,6 +238,12 @@ func Query(namespace string, opts QueryOptions) [][]byte { c := b.Cursor() n := b.Stats().KeyN + total = n + + // return nil if no content + if n == 0 { + return nil + } var start, end int switch opts.Count { @@ -279,12 +298,29 @@ func Query(namespace string, opts QueryOptions) [][]byte { i++ cur++ } + + default: + // results for DESC order + for k, v := c.First(); k != nil; k, v = c.Next() { + if cur < start { + cur++ + continue + } + + if cur >= end { + break + } + + posts = append(posts, v) + i++ + cur++ + } } return nil }) - return posts + return total, posts } // SortContent sorts all content of the type supplied as the namespace by time, @@ -292,13 +328,13 @@ func Query(namespace string, opts QueryOptions) [][]byte { // 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, "_") { + if strings.Contains(namespace, "__") { return } all := ContentAll(namespace) - var posts sortablePosts + var posts sortableContent // decode each (json) into type to then sort for i := range all { j := all[i] @@ -318,7 +354,7 @@ func SortContent(namespace string) { // store in <namespace>_sorted bucket, first delete existing err := store.Update(func(tx *bolt.Tx) error { - bname := []byte(namespace + "_sorted") + bname := []byte(namespace + "__sorted") err := tx.DeleteBucket(bname) if err != nil { return err @@ -351,23 +387,22 @@ func SortContent(namespace string) { } -type sortablePosts []editor.Sortable +type sortableContent []editor.Sortable -func (s sortablePosts) Len() int { +func (s sortableContent) Len() int { return len(s) } -func (s sortablePosts) Less(i, j int) bool { +func (s sortableContent) Less(i, j int) bool { return s[i].Time() > s[j].Time() } -func (s sortablePosts) Swap(i, j int) { +func (s sortableContent) 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) @@ -382,7 +417,7 @@ func postToJSON(ns string, data url.Values) ([]byte, error) { return nil, err } - slug, err := manager.Slug(post.(editor.Editable)) + slug, err := manager.Slug(post.(content.Identifiable)) if err != nil { return nil, err } diff --git a/system/db/init.go b/system/db/init.go index 63804e1..967eed1 100644 --- a/system/db/init.go +++ b/system/db/init.go @@ -38,14 +38,14 @@ func Init() { return err } - _, err = tx.CreateBucketIfNotExists([]byte(t + "_sorted")) + _, err = tx.CreateBucketIfNotExists([]byte(t + "__sorted")) if err != nil { return err } } // init db with other buckets as needed - buckets := []string{"_config", "_users"} + buckets := []string{"__config", "__users"} for _, name := range buckets { _, err := tx.CreateBucketIfNotExists([]byte(name)) if err != nil { @@ -54,7 +54,7 @@ func Init() { } // seed db with configs structure if not present - b := tx.Bucket([]byte("_config")) + b := tx.Bucket([]byte("__config")) if b.Get([]byte("settings")) == nil { j, err := json.Marshal(&config.Config{}) if err != nil { @@ -93,7 +93,7 @@ func SystemInitComplete() bool { complete := false err := store.View(func(tx *bolt.Tx) error { - users := tx.Bucket([]byte("_users")) + users := tx.Bucket([]byte("__users")) err := users.ForEach(func(k, v []byte) error { complete = true diff --git a/system/db/user.go b/system/db/user.go index 3386dc2..b92a62a 100644 --- a/system/db/user.go +++ b/system/db/user.go @@ -24,7 +24,7 @@ var ErrNoUserExists = errors.New("Error. No user exists.") func SetUser(usr *user.User) (int, error) { err := store.Update(func(tx *bolt.Tx) error { email := []byte(usr.Email) - users := tx.Bucket([]byte("_users")) + users := tx.Bucket([]byte("__users")) // check if user is found by email, fail if nil exists := users.Get(email) @@ -61,8 +61,13 @@ func SetUser(usr *user.User) (int, error) { // UpdateUser sets key:value pairs in the db for existing user settings func UpdateUser(usr, updatedUsr *user.User) error { + // ensure user ID remains the same + if updatedUsr.ID != usr.ID { + updatedUsr.ID = usr.ID + } + err := store.Update(func(tx *bolt.Tx) error { - users := tx.Bucket([]byte("_users")) + users := tx.Bucket([]byte("__users")) // check if user is found by email, fail if nil exists := users.Get([]byte(usr.Email)) @@ -103,7 +108,7 @@ func UpdateUser(usr, updatedUsr *user.User) error { // 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")) + b := tx.Bucket([]byte("__users")) err := b.Delete([]byte(email)) if err != nil { return err @@ -122,7 +127,7 @@ func DeleteUser(email string) error { func User(email string) ([]byte, error) { val := &bytes.Buffer{} err := store.View(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte("_users")) + b := tx.Bucket([]byte("__users")) usr := b.Get([]byte(email)) _, err := val.Write(usr) @@ -147,7 +152,7 @@ func User(email string) ([]byte, error) { func UserAll() ([][]byte, error) { var users [][]byte err := store.View(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte("_users")) + b := tx.Bucket([]byte("__users")) err := b.ForEach(func(k, v []byte) error { users = append(users, v) return nil @@ -196,7 +201,7 @@ 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")) + b, err := tx.CreateBucketIfNotExists([]byte("__recoveryKeys")) if err != nil { return err } @@ -215,18 +220,18 @@ func SetRecoveryKey(email string) (string, error) { 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 +// RecoveryKey gets a previously set recovery 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")) + b := tx.Bucket([]byte("__recoveryKeys")) if b == nil { return errors.New("No database found for checking keys.") } - _, err := key.Write(b.Get([]byte("email"))) + _, err := key.Write(b.Get([]byte(email))) if err != nil { return err } diff --git a/system/tls/enable.go b/system/tls/enable.go index c53fac6..4ade194 100644 --- a/system/tls/enable.go +++ b/system/tls/enable.go @@ -42,7 +42,7 @@ func setup() { 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("Using", string(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") @@ -53,7 +53,7 @@ func setup() { 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...") + fmt.Println("Using", string(email), "as contact email for certificate...") m = autocert.Manager{ Prompt: autocert.AcceptTOS, |