summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSteve <nilslice@gmail.com>2016-12-06 15:24:36 -0800
committerGitHub <noreply@github.com>2016-12-06 15:24:36 -0800
commitf39c1519ab382a343c05163f00f38c83bff3583d (patch)
tree254f75834f2cb787179f7880b0063e667d8ad234
parent5527117e706114c1188afaa10188d96170874047 (diff)
parent64050ef8065bccdef0aab1748040995c637fe9ed (diff)
Merge pull request #19 from bosssauce/ponzu-dev
[core] Added account recovery process and content pagination in admin UI
-rw-r--r--cmd/ponzu/contentType.tmpl4
-rw-r--r--cmd/ponzu/vendor/github.com/nilslice/email/README.md2
-rw-r--r--cmd/ponzu/vendor/github.com/nilslice/email/email.go9
-rw-r--r--content/item.go8
-rw-r--r--content/types.go10
-rw-r--r--management/editor/editor.go1
-rw-r--r--management/editor/elements.go18
-rw-r--r--management/manager/process.go6
-rw-r--r--system/admin/admin.go9
-rw-r--r--system/admin/config/config.go4
-rw-r--r--system/admin/handlers.go269
-rw-r--r--system/admin/server.go6
-rw-r--r--system/api/analytics/init.go4
-rw-r--r--system/api/external.go134
-rw-r--r--system/api/handlers.go6
-rw-r--r--system/api/server.go6
-rw-r--r--system/db/config.go4
-rw-r--r--system/db/content.go73
-rw-r--r--system/db/init.go8
-rw-r--r--system/db/user.go25
-rw-r--r--system/tls/enable.go4
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,