summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/ponzu/main.go6
-rw-r--r--cmd/ponzu/options.go41
-rw-r--r--content/post.go38
-rw-r--r--management/editor/editor.go25
-rw-r--r--system/admin/admin.go2
-rw-r--r--system/admin/handlers.go280
-rw-r--r--system/admin/server.go1
-rw-r--r--system/admin/static/common/js/util.js16
-rw-r--r--system/admin/static/dashboard/css/admin.css10
-rw-r--r--system/admin/upload/upload.go (renamed from system/admin/upload.go)5
-rw-r--r--system/api/analytics/init.go101
-rw-r--r--system/api/external.go109
-rw-r--r--system/api/handlers.go17
-rw-r--r--system/api/server.go2
-rw-r--r--system/db/content.go122
-rw-r--r--system/db/init.go18
16 files changed, 641 insertions, 152 deletions
diff --git a/cmd/ponzu/main.go b/cmd/ponzu/main.go
index 48aef9e..234d3b6 100644
--- a/cmd/ponzu/main.go
+++ b/cmd/ponzu/main.go
@@ -11,6 +11,7 @@ import (
"github.com/bosssauce/ponzu/system/admin"
"github.com/bosssauce/ponzu/system/api"
+ "github.com/bosssauce/ponzu/system/api/analytics"
"github.com/bosssauce/ponzu/system/db"
"github.com/bosssauce/ponzu/system/tls"
)
@@ -169,6 +170,11 @@ func main() {
case "serve", "s":
db.Init()
+ defer db.Close()
+
+ analytics.Init()
+ defer analytics.Close()
+
if len(args) > 1 {
services := strings.Split(args[1], ",")
diff --git a/cmd/ponzu/options.go b/cmd/ponzu/options.go
index 6f42f0c..b23ab2d 100644
--- a/cmd/ponzu/options.go
+++ b/cmd/ponzu/options.go
@@ -75,30 +75,11 @@ type {{ .name }} struct {
Title string ` + "`json:" + `"title"` + "`" + `
Content string ` + "`json:" + `"content"` + "`" + `
Author string ` + "`json:" + `"author"` + "`" + `
- Photo string ` + "`json:" + `"picture"` + "`" + `
+ Photo string ` + "`json:" + `"photo"` + "`" + `
Category []string ` + "`json:" + `"category"` + "`" + `
Theme string ` + "`json:" + `"theme"` + "`" + `
}
-func init() {
- Types["{{ .name }}"] = func() interface{} { return new({{ .name }}) }
-}
-
-// SetContentID partially implements editor.Editable
-func ({{ .initial }} *{{ .name }}) SetContentID(id int) { {{ .initial }}.ID = id }
-
-// ContentID partially implements editor.Editable
-func ({{ .initial }} *{{ .name }}) ContentID() int { return {{ .initial }}.ID }
-
-// ContentName partially implements editor.Editable
-func ({{ .initial }} *{{ .name }}) ContentName() string { return {{ .initial }}.Title }
-
-// SetSlug partially implements editor.Editable
-func ({{ .initial }} *{{ .name }}) SetSlug(slug string) { {{ .initial }}.Slug = slug }
-
-// Editor partially implements editor.Editable
-func ({{ .initial }} *{{ .name }}) Editor() *editor.Editor { return &{{ .initial }}.editor }
-
// MarshalEditor writes a buffer of html to edit a {{ .name }} and partially implements editor.Editable
func ({{ .initial }} *{{ .name }}) MarshalEditor() ([]byte, error) {
view, err := editor.Form({{ .initial }},
@@ -152,6 +133,26 @@ func ({{ .initial }} *{{ .name }}) MarshalEditor() ([]byte, error) {
return view, nil
}
+
+func init() {
+ Types["{{ .name }}"] = func() interface{} { return new({{ .name }}) }
+}
+
+// SetContentID partially implements editor.Editable
+func ({{ .initial }} *{{ .name }}) SetContentID(id int) { {{ .initial }}.ID = id }
+
+// ContentID partially implements editor.Editable
+func ({{ .initial }} *{{ .name }}) ContentID() int { return {{ .initial }}.ID }
+
+// ContentName partially implements editor.Editable
+func ({{ .initial }} *{{ .name }}) ContentName() string { return {{ .initial }}.Title }
+
+// SetSlug partially implements editor.Editable
+func ({{ .initial }} *{{ .name }}) SetSlug(slug string) { {{ .initial }}.Slug = slug }
+
+// Editor partially implements editor.Editable
+func ({{ .initial }} *{{ .name }}) Editor() *editor.Editor { return &{{ .initial }}.editor }
+
`
func newProjectInDir(path string) error {
diff --git a/content/post.go b/content/post.go
index d121ed7..dcdfeff 100644
--- a/content/post.go
+++ b/content/post.go
@@ -19,25 +19,6 @@ type Post struct {
Theme string `json:"theme"`
}
-func init() {
- Types["Post"] = func() interface{} { return new(Post) }
-}
-
-// SetContentID partially implements editor.Editable
-func (p *Post) SetContentID(id int) { p.ID = id }
-
-// ContentID partially implements editor.Editable
-func (p *Post) ContentID() int { return p.ID }
-
-// ContentName partially implements editor.Editable
-func (p *Post) ContentName() string { return p.Title }
-
-// SetSlug partially implements editor.Editable
-func (p *Post) SetSlug(slug string) { p.Slug = slug }
-
-// Editor partially implements editor.Editable
-func (p *Post) Editor() *editor.Editor { return &p.editor }
-
// MarshalEditor writes a buffer of html to edit a Post and partially implements editor.Editable
func (p *Post) MarshalEditor() ([]byte, error) {
view, err := editor.Form(p,
@@ -88,3 +69,22 @@ func (p *Post) MarshalEditor() ([]byte, error) {
return view, nil
}
+
+func init() {
+ Types["Post"] = func() interface{} { return new(Post) }
+}
+
+// SetContentID partially implements editor.Editable
+func (p *Post) SetContentID(id int) { p.ID = id }
+
+// ContentID partially implements editor.Editable
+func (p *Post) ContentID() int { return p.ID }
+
+// ContentName partially implements editor.Editable
+func (p *Post) ContentName() string { return p.Title }
+
+// SetSlug partially implements editor.Editable
+func (p *Post) SetSlug(slug string) { p.Slug = slug }
+
+// Editor partially implements editor.Editable
+func (p *Post) Editor() *editor.Editor { return &p.editor }
diff --git a/management/editor/editor.go b/management/editor/editor.go
index 3b26adb..68b787b 100644
--- a/management/editor/editor.go
+++ b/management/editor/editor.go
@@ -110,17 +110,31 @@ func Form(post Editable, fields ...Field) ([]byte, error) {
<button class="right waves-effect waves-light btn red delete-post" type="submit">Delete</button>
</div>
+<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>
+ </div>
+ <label class="approve-details right-align col s12">This content is pending approval. By clicking 'Approve', it will be immediately published.</label>
+</div>
+
<script>
$(function() {
var form = $('form'),
del = form.find('button.delete-post'),
+ approve = form.find('.post-controls.external'),
id = form.find('input[name=id]');
- // hide delete button if this is a new post, or a non-post editor page
+ // 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();
}
+ // hide approval if not on a pending content item
+ if (getParam("status") !== "pending") {
+ approve.hide();
+ }
+
del.on('click', function(e) {
e.preventDefault();
var action = form.attr('action');
@@ -131,6 +145,15 @@ func Form(post Editable, fields ...Field) ([]byte, error) {
form.submit();
}
});
+
+ approve.find('button').on('click', function(e) {
+ e.preventDefault();
+ var action = form.attr('action');
+ action = action + '/approve';
+ form.attr('action', action);
+
+ form.submit();
+ });
});
</script>
`
diff --git a/system/admin/admin.go b/system/admin/admin.go
index 824edc8..9ee86c3 100644
--- a/system/admin/admin.go
+++ b/system/admin/admin.go
@@ -414,7 +414,7 @@ var err405HTML = `
<div class="card">
<div class="card-content">
<div class="card-title"><b>405</b> Error: Method Not Allowed</div>
- <blockquote>Sorry, the page you requested could not be found.</blockquote>
+ <blockquote>Sorry, the method of your request is not allowed.</blockquote>
</div>
</div>
</div>
diff --git a/system/admin/handlers.go b/system/admin/handlers.go
index d508ef2..742b898 100644
--- a/system/admin/handlers.go
+++ b/system/admin/handlers.go
@@ -14,9 +14,12 @@ import (
"github.com/bosssauce/ponzu/management/editor"
"github.com/bosssauce/ponzu/management/manager"
"github.com/bosssauce/ponzu/system/admin/config"
+ "github.com/bosssauce/ponzu/system/admin/upload"
"github.com/bosssauce/ponzu/system/admin/user"
+ "github.com/bosssauce/ponzu/system/api"
"github.com/bosssauce/ponzu/system/db"
+ "github.com/gorilla/schema"
"github.com/nilslice/jwt"
)
@@ -532,10 +535,25 @@ func postsHandler(res http.ResponseWriter, req *http.Request) {
}
order := strings.ToLower(q.Get("order"))
+ status := q.Get("status")
posts := db.ContentAll(t + "_sorted")
b := &bytes.Buffer{}
- p, ok := content.Types[t]().(editor.Editable)
+
+ if _, ok := content.Types[t]; !ok {
+ res.WriteHeader(http.StatusBadRequest)
+ errView, err := Error405()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+
+ pt := content.Types[t]()
+
+ p, ok := pt.(editor.Editable)
if !ok {
res.WriteHeader(http.StatusInternalServerError)
errView, err := Error500()
@@ -547,6 +565,12 @@ func postsHandler(res http.ResponseWriter, req *http.Request) {
return
}
+ var hasExt bool
+ _, ok = pt.(api.Externalable)
+ if ok {
+ hasExt = true
+ }
+
html := `<div class="col s9 card">
<div class="card-content">
<div class="row">
@@ -562,21 +586,6 @@ func postsHandler(res http.ResponseWriter, req *http.Request) {
</div>
<script>
$(function() {
- var getParam = function(param) {
- var qs = window.location.search.substring(1);
- var qp = qs.split('&');
- var t = '';
-
- for (var i = 0; i < qp.length; i++) {
- var p = qp[i].split('=')
- if (p[0] === param) {
- t = p[1];
- }
- }
-
- return t;
- }
-
var sort = $('select.__ponzu.sort-order');
sort.on('change', function() {
@@ -604,39 +613,107 @@ func postsHandler(res http.ResponseWriter, req *http.Request) {
<input type="hidden" name="type" value="` + t + `" />
</div>
</form>
- </div>
- <ul class="posts row">`
+ </div>`
+ if hasExt {
+ if status == "" {
+ q.Add("status", "public")
+ }
- if order == "desc" || order == "" {
- // keep natural order of posts slice, as returned from sorted bucket
- for i := range posts {
- err := json.Unmarshal(posts[i], &p)
- if err != nil {
- log.Println("Error unmarshal json into", t, err, posts[i])
+ q.Set("status", "public")
+ publicURL := req.URL.Path + "?" + q.Encode()
+
+ q.Set("status", "pending")
+ pendingURL := req.URL.Path + "?" + q.Encode()
+
+ switch status {
+ case "public", "":
+ html += `<div class="row externalable">
+ <span class="description">Status:</span>
+ <span class="active">Public</span>
+ &nbsp;&vert;&nbsp;
+ <a href="` + pendingURL + `">Pending</a>
+ </div>`
+
+ case "pending":
+ // get _pending posts of type t from the db
+ posts = db.ContentAll(t + "_pending")
+
+ html += `<div class="row externalable">
+ <span class="description">Status:</span>
+ <a href="` + publicURL + `">Public</a>
+ &nbsp;&vert;&nbsp;
+ <span class="active">Pending</span>
+ </div>`
+ }
- post := `<li class="col s12">Error decoding data. Possible file corruption.</li>`
- b.Write([]byte(post))
- continue
+ }
+ html += `<ul class="posts row">`
+
+ switch order {
+ case "desc", "":
+ if hasExt {
+ // reverse the order of posts slice
+ for i := len(posts) - 1; i >= 0; i-- {
+ err := json.Unmarshal(posts[i], &p)
+ if err != nil {
+ log.Println("Error unmarshal json into", t, err, posts[i])
+
+ post := `<li class="col s12">Error decoding data. Possible file corruption.</li>`
+ b.Write([]byte(post))
+ continue
+ }
+
+ post := adminPostListItem(p, t, status)
+ b.Write(post)
}
+ } else {
+ // keep natural order of posts slice, as returned from sorted bucket
+ for i := range posts {
+ err := json.Unmarshal(posts[i], &p)
+ if err != nil {
+ log.Println("Error unmarshal json into", t, err, posts[i])
+
+ post := `<li class="col s12">Error decoding data. Possible file corruption.</li>`
+ b.Write([]byte(post))
+ continue
+ }
- post := adminPostListItem(p, t)
- b.Write(post)
+ post := adminPostListItem(p, t, status)
+ b.Write(post)
+ }
}
- } else if order == "asc" {
- // reverse the order of posts slice
- for i := len(posts) - 1; i >= 0; i-- {
- err := json.Unmarshal(posts[i], &p)
- if err != nil {
- log.Println("Error unmarshal json into", t, err, posts[i])
+ case "asc":
+ if hasExt {
+ // keep natural order of posts slice, as returned from sorted bucket
+ for i := range posts {
+ err := json.Unmarshal(posts[i], &p)
+ if err != nil {
+ log.Println("Error unmarshal json into", t, err, posts[i])
+
+ post := `<li class="col s12">Error decoding data. Possible file corruption.</li>`
+ b.Write([]byte(post))
+ continue
+ }
- post := `<li class="col s12">Error decoding data. Possible file corruption.</li>`
- b.Write([]byte(post))
- continue
+ post := adminPostListItem(p, t, status)
+ b.Write(post)
}
+ } else {
+ // reverse the order of posts slice
+ for i := len(posts) - 1; i >= 0; i-- {
+ err := json.Unmarshal(posts[i], &p)
+ if err != nil {
+ log.Println("Error unmarshal json into", t, err, posts[i])
- post := adminPostListItem(p, t)
- b.Write(post)
+ post := `<li class="col s12">Error decoding data. Possible file corruption.</li>`
+ b.Write([]byte(post))
+ continue
+ }
+
+ post := adminPostListItem(p, t, status)
+ b.Write(post)
+ }
}
}
@@ -671,7 +748,8 @@ func postsHandler(res http.ResponseWriter, req *http.Request) {
// adminPostListItem is a helper to create the li containing a post.
// p is the asserted post as an Editable, t is the Type of the post.
-func adminPostListItem(p editor.Editable, t string) []byte {
+// specifier is passed to append a name to a namespace like _pending
+func adminPostListItem(p editor.Editable, t, status string) []byte {
s, ok := p.(editor.Sortable)
if !ok {
log.Println("Content type", t, "doesn't implement editor.Sortable")
@@ -687,28 +765,127 @@ func adminPostListItem(p editor.Editable, t string) []byte {
cid := fmt.Sprintf("%d", p.ContentID())
+ if status == "public" {
+ status = ""
+ } else {
+ status = "_" + status
+ }
+
post := `
<li class="col s12">
- <a href="/admin/edit?type=` + t + `&id=` + cid + `">` + p.ContentName() + `</a>
+ <a href="/admin/edit?type=` + t + `&status=` + strings.TrimPrefix(status, "_") + `&id=` + cid + `">` + p.ContentName() + `</a>
<span class="post-detail">Updated: ` + updatedTime + `</span>
<span class="publish-date right">` + publishTime + `</span>
<form enctype="multipart/form-data" class="quick-delete-post __ponzu right" action="/admin/edit/delete" method="post">
<span>Delete</span>
<input type="hidden" name="id" value="` + cid + `" />
- <input type="hidden" name="type" value="` + t + `" />
+ <input type="hidden" name="type" value="` + t + status + `" />
</form>
</li>`
return []byte(post)
}
+func approvePostHandler(res http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodPost {
+ res.WriteHeader(http.StatusMethodNotAllowed)
+ errView, err := Error405()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+
+ err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB
+ if err != nil {
+ res.WriteHeader(http.StatusInternalServerError)
+ errView, err := Error500()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+
+ t := req.FormValue("type")
+ if strings.Contains(t, "_") {
+ t = strings.Split(t, "_")[0]
+ }
+
+ post := content.Types[t]()
+
+ // check if we have a Mergeable
+ m, ok := post.(api.Mergeable)
+ if !ok {
+ res.WriteHeader(http.StatusBadRequest)
+ errView, err := Error400()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+
+ dec := schema.NewDecoder()
+ dec.IgnoreUnknownKeys(true)
+ dec.SetAliasTag("json")
+ err = dec.Decode(post, req.Form)
+ if err != nil {
+ res.WriteHeader(http.StatusInternalServerError)
+ errView, err := Error500()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+
+ // call its Approve method
+ err = m.Approve(req)
+ if err != nil {
+ res.WriteHeader(http.StatusInternalServerError)
+ errView, err := Error500()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+
+ // Store the content in the bucket t
+ id, err := db.SetContent(t+":-1", req.Form)
+ if err != nil {
+ res.WriteHeader(http.StatusInternalServerError)
+ errView, err := Error500()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+
+ // redirect to the new approved content's editor
+ redir := req.URL.Scheme + req.URL.Host + strings.TrimSuffix(req.URL.Path, "/approve")
+ redir += fmt.Sprintf("?type=%s&id=%d", t, id)
+ http.Redirect(res, req, redir, http.StatusFound)
+}
+
func editHandler(res http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
q := req.URL.Query()
i := q.Get("id")
t := q.Get("type")
+ status := q.Get("status")
+
contentType, ok := content.Types[t]
if !ok {
fmt.Fprintf(res, content.ErrTypeNotRegistered, t)
@@ -717,6 +894,10 @@ func editHandler(res http.ResponseWriter, req *http.Request) {
post := contentType()
if i != "" {
+ if status == "pending" {
+ t = t + "_pending"
+ }
+
data, err := db.Content(t + ":" + i)
if err != nil {
log.Println(err)
@@ -731,6 +912,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) {
}
if len(data) < 1 || data == nil {
+ fmt.Println(string(data))
res.WriteHeader(http.StatusNotFound)
errView, err := Error404()
if err != nil {
@@ -809,7 +991,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) {
req.PostForm.Set("updated", ts)
}
- urlPaths, err := storeFileUploads(req)
+ urlPaths, err := upload.StoreFiles(req)
if err != nil {
log.Println(err)
res.WriteHeader(http.StatusInternalServerError)
@@ -900,6 +1082,12 @@ 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]
+ }
+
redir := strings.TrimSuffix(req.URL.Scheme+req.URL.Host+req.URL.Path, "/edit/delete")
redir = redir + "/posts?type=" + t
http.Redirect(res, req, redir, http.StatusFound)
@@ -911,7 +1099,7 @@ func editUploadHandler(res http.ResponseWriter, req *http.Request) {
return
}
- urlPaths, err := storeFileUploads(req)
+ urlPaths, err := upload.StoreFiles(req)
if err != nil {
log.Println("Couldn't store file uploads.", err)
res.WriteHeader(http.StatusInternalServerError)
@@ -967,7 +1155,7 @@ func searchHandler(res http.ResponseWriter, req *http.Request) {
continue
}
- post := adminPostListItem(p, t)
+ post := adminPostListItem(p, t, "")
b.Write([]byte(post))
}
diff --git a/system/admin/server.go b/system/admin/server.go
index c22c278..5d93d84 100644
--- a/system/admin/server.go
+++ b/system/admin/server.go
@@ -28,6 +28,7 @@ func Run() {
http.HandleFunc("/admin/edit", user.Auth(editHandler))
http.HandleFunc("/admin/edit/delete", user.Auth(deleteHandler))
+ http.HandleFunc("/admin/edit/approve", user.Auth(approvePostHandler))
http.HandleFunc("/admin/edit/upload", user.Auth(editUploadHandler))
pwd, err := os.Getwd()
diff --git a/system/admin/static/common/js/util.js b/system/admin/static/common/js/util.js
index 7f4c8ab..8d5e74b 100644
--- a/system/admin/static/common/js/util.js
+++ b/system/admin/static/common/js/util.js
@@ -67,4 +67,20 @@ function getPartialDate(unix) {
d.dd = day;
return d;
+}
+
+// Returns a part of the window URL 'search' string
+function getParam(param) {
+ var qs = window.location.search.substring(1);
+ var qp = qs.split('&');
+ var t = '';
+
+ for (var i = 0; i < qp.length; i++) {
+ var p = qp[i].split('=')
+ if (p[0] === param) {
+ t = p[1];
+ }
+ }
+
+ return t;
} \ No newline at end of file
diff --git a/system/admin/static/dashboard/css/admin.css b/system/admin/static/dashboard/css/admin.css
index 1de966d..3cffc5d 100644
--- a/system/admin/static/dashboard/css/admin.css
+++ b/system/admin/static/dashboard/css/admin.css
@@ -206,4 +206,14 @@ li:hover .quick-delete-post, li:hover .delete-user {
.chips {
margin-top: 10px;
+}
+
+.external.post-controls .col.input-field {
+ margin-top: 40px;
+ padding: 0;
+}
+
+.approve-details {
+ text-align: right;
+ padding: 10px 0 !important;
} \ No newline at end of file
diff --git a/system/admin/upload.go b/system/admin/upload/upload.go
index 7f2a4fa..169bffe 100644
--- a/system/admin/upload.go
+++ b/system/admin/upload/upload.go
@@ -1,4 +1,4 @@
-package admin
+package upload
import (
"fmt"
@@ -10,7 +10,8 @@ import (
"time"
)
-func storeFileUploads(req *http.Request) (map[string]string, error) {
+// StoreFiles stores file uploads at paths like /YYYY/MM/filename.ext
+func StoreFiles(req *http.Request) (map[string]string, error) {
err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB
if err != nil {
return nil, fmt.Errorf("%s", err)
diff --git a/system/api/analytics/init.go b/system/api/analytics/init.go
new file mode 100644
index 0000000..c351bed
--- /dev/null
+++ b/system/api/analytics/init.go
@@ -0,0 +1,101 @@
+// Package analytics provides the methods to run an analytics reporting system
+// for API requests which may be useful to users for measuring access and
+// possibly identifying bad actors abusing requests.
+package analytics
+
+import (
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/boltdb/bolt"
+)
+
+type apiRequest struct {
+ URL string `json:"url"`
+ Method string `json:"http_method"`
+ RemoteAddr string `json:"ip_address"`
+ Timestamp int64 `json:"timestamp"`
+ External bool `json:"external"`
+}
+
+var (
+ store *bolt.DB
+ recordChan chan apiRequest
+)
+
+// Record queues an apiRequest for metrics
+func Record(req *http.Request) {
+ external := strings.Contains(req.URL.Path, "/external/")
+
+ r := apiRequest{
+ URL: req.URL.String(),
+ Method: req.Method,
+ RemoteAddr: req.RemoteAddr,
+ Timestamp: time.Now().Unix() * 1000,
+ External: external,
+ }
+
+ // put r on buffered recordChan to take advantage of batch insertion in DB
+ recordChan <- r
+
+}
+
+// Close exports the abillity to close our db file. Should be called with defer
+// after call to Init() from the same place.
+func Close() {
+ err := store.Close()
+ if err != nil {
+ log.Println(err)
+ }
+}
+
+// Init creates a db connection, should run an initial prune of old data, and
+// sets up the queue/batching channel
+func Init() {
+ var err error
+ store, err = bolt.Open("analytics.db", 0666, nil)
+ if err != nil {
+ log.Fatalln(err)
+ }
+
+ recordChan = make(chan apiRequest, 1024*128)
+
+ go serve()
+
+ err = store.Update(func(tx *bolt.Tx) error {
+
+ return nil
+ })
+ if err != nil {
+ log.Fatalln(err)
+ }
+}
+
+func serve() {
+ // make timer to notify select to batch request insert from recordChan
+ // interval: 1 minute
+ apiRequestTimer := time.NewTicker(time.Minute * 1)
+
+ // make timer to notify select to remove old analytics
+ // interval: 2 weeks
+ // TODO: enable analytics backup service to cloud
+ pruneDBTimer := time.NewTicker(time.Hour * 24 * 14)
+
+ for {
+ select {
+ case <-apiRequestTimer.C:
+ var reqs []apiRequest
+ batchSize := len(recordChan)
+
+ for i := 0; i < batchSize; i++ {
+ reqs = append(reqs, <-recordChan)
+ }
+
+ case <-pruneDBTimer.C:
+
+ default:
+ }
+ }
+}
diff --git a/system/api/external.go b/system/api/external.go
new file mode 100644
index 0000000..4a42eb5
--- /dev/null
+++ b/system/api/external.go
@@ -0,0 +1,109 @@
+package api
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/bosssauce/ponzu/content"
+ "github.com/bosssauce/ponzu/system/admin/upload"
+ "github.com/bosssauce/ponzu/system/db"
+)
+
+// Externalable accepts or rejects external POST requests to endpoints such as:
+// /external/posts?type=Review
+type Externalable interface {
+ // Accepts determines whether a type will allow external submissions
+ Accepts() bool
+}
+
+// Mergeable allows external post content to be approved and published through
+// the public-facing API
+type Mergeable interface {
+ // Approve copies an external post to the internal collection and triggers
+ // a re-sort of its content type posts
+ Approve(req *http.Request) error
+}
+
+func externalPostsHandler(res http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodPost {
+ res.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+
+ err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB
+ if err != nil {
+ log.Println("[External] error:", err)
+ res.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ t := req.URL.Query().Get("type")
+ if t == "" {
+ res.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ p, found := content.Types[t]
+ if !found {
+ log.Println("[External] attempt to submit unknown type:", t, "from:", req.RemoteAddr)
+ res.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ post := p()
+
+ ext, ok := post.(Externalable)
+ if !ok {
+ log.Println("[External] rejected non-externalable type:", t, "from:", req.RemoteAddr)
+ res.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ if ext.Accepts() {
+ ts := fmt.Sprintf("%d", time.Now().Unix()*1000)
+ req.PostForm.Set("timestamp", ts)
+ req.PostForm.Set("updated", ts)
+
+ urlPaths, err := upload.StoreFiles(req)
+ if err != nil {
+ log.Println(err)
+ res.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ for name, urlPath := range urlPaths {
+ req.PostForm.Add(name, urlPath)
+ }
+
+ // check for any multi-value fields (ex. checkbox fields)
+ // and correctly format for db storage. Essentially, we need
+ // fieldX.0: value1, fieldX.1: value2 => fieldX: []string{value1, value2}
+ var discardKeys []string
+ for k, v := range req.PostForm {
+ if strings.Contains(k, ".") {
+ key := strings.Split(k, ".")[0]
+
+ if req.PostForm.Get(key) == "" {
+ req.PostForm.Set(key, v[0])
+ discardKeys = append(discardKeys, k)
+ } else {
+ req.PostForm.Add(key, v[0])
+ }
+ }
+ }
+
+ for _, discardKey := range discardKeys {
+ req.PostForm.Del(discardKey)
+ }
+
+ _, err = db.SetContent(t+"_pending:-1", req.PostForm)
+ if err != nil {
+ log.Println("[External] error:", err)
+ res.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ }
+}
diff --git a/system/api/handlers.go b/system/api/handlers.go
index 2ec82bb..8356683 100644
--- a/system/api/handlers.go
+++ b/system/api/handlers.go
@@ -3,13 +3,13 @@ package api
import (
"bytes"
"encoding/json"
- "fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/bosssauce/ponzu/content"
+ "github.com/bosssauce/ponzu/system/api/analytics"
"github.com/bosssauce/ponzu/system/db"
)
@@ -69,8 +69,6 @@ func postsHandler(res http.ResponseWriter, req *http.Request) {
all = append(all, post)
}
- fmt.Println(len(posts))
-
var start, end int
switch count {
case -1:
@@ -203,6 +201,7 @@ func SendJSON(res http.ResponseWriter, j map[string]interface{}) {
data, err = json.Marshal(j)
if err != nil {
+ log.Println(err)
data, _ = json.Marshal(map[string]interface{}{
"status": "fail",
"message": err.Error(),
@@ -212,9 +211,6 @@ func SendJSON(res http.ResponseWriter, j map[string]interface{}) {
sendData(res, data, 200)
}
-// ResponseFunc ...
-type ResponseFunc func(http.ResponseWriter, *http.Request)
-
// CORS wraps a HandleFunc to response to OPTIONS requests properly
func CORS(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
@@ -226,3 +222,12 @@ func CORS(next http.HandlerFunc) http.HandlerFunc {
next.ServeHTTP(res, req)
})
}
+
+// Record wraps a HandleFunc to record API requests for analytical purposes
+func Record(next http.HandlerFunc) http.HandlerFunc {
+ return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
+ go analytics.Record(req)
+
+ next.ServeHTTP(res, req)
+ })
+}
diff --git a/system/api/server.go b/system/api/server.go
index da73382..816bc21 100644
--- a/system/api/server.go
+++ b/system/api/server.go
@@ -9,4 +9,6 @@ func Run() {
http.HandleFunc("/api/posts", CORS(postsHandler))
http.HandleFunc("/api/post", CORS(postHandler))
+
+ http.HandleFunc("/api/external/posts", CORS(externalPostsHandler))
}
diff --git a/system/db/content.go b/system/db/content.go
index 9ab1f89..bd1ee4b 100644
--- a/system/db/content.go
+++ b/system/db/content.go
@@ -38,13 +38,20 @@ func SetContent(target string, data url.Values) (int, error) {
}
func update(ns, id string, data url.Values) (int, error) {
+ var specifier string // i.e. _pending, _sorted, etc.
+ if strings.Contains(ns, "_") {
+ spec := strings.Split(ns, "_")
+ ns = spec[0]
+ specifier = "_" + spec[1]
+ }
+
cid, err := strconv.Atoi(id)
if err != nil {
return 0, err
}
err = store.Update(func(tx *bolt.Tx) error {
- b, err := tx.CreateBucketIfNotExists([]byte(ns))
+ b, err := tx.CreateBucketIfNotExists([]byte(ns + specifier))
if err != nil {
return err
}
@@ -65,15 +72,24 @@ func update(ns, id string, data url.Values) (int, error) {
return 0, nil
}
- go SortContent(ns)
+ if specifier == "" {
+ go SortContent(ns)
+ }
return cid, nil
}
func insert(ns string, data url.Values) (int, error) {
var effectedID int
+ var specifier string // i.e. _pending, _sorted, etc.
+ if strings.Contains(ns, "_") {
+ spec := strings.Split(ns, "_")
+ ns = spec[0]
+ specifier = "_" + spec[1]
+ }
+
err := store.Update(func(tx *bolt.Tx) error {
- b, err := tx.CreateBucketIfNotExists([]byte(ns))
+ b, err := tx.CreateBucketIfNotExists([]byte(ns + specifier))
if err != nil {
return err
}
@@ -89,7 +105,7 @@ func insert(ns string, data url.Values) (int, error) {
if err != nil {
return err
}
- data.Add("id", cid)
+ data.Set("id", cid)
j, err := postToJSON(ns, data)
if err != nil {
@@ -107,40 +123,11 @@ func insert(ns string, data url.Values) (int, error) {
return 0, err
}
- go SortContent(ns)
-
- return effectedID, nil
-}
-
-func postToJSON(ns string, data url.Values) ([]byte, error) {
- // find the content type and decode values into it
- t, ok := content.Types[ns]
- if !ok {
- return nil, fmt.Errorf(content.ErrTypeNotRegistered, ns)
- }
- post := t()
-
- dec := schema.NewDecoder()
- dec.SetAliasTag("json") // allows simpler struct tagging when creating a content type
- dec.IgnoreUnknownKeys(true) // will skip over form values submitted, but not in struct
- err := dec.Decode(post, data)
- if err != nil {
- return nil, err
- }
-
- slug, err := manager.Slug(post.(editor.Editable))
- if err != nil {
- return nil, err
- }
- post.(editor.Editable).SetSlug(slug)
-
- // marshall content struct to json for db storage
- j, err := json.Marshal(post)
- if err != nil {
- return nil, err
+ if specifier == "" {
+ go SortContent(ns)
}
- return j, nil
+ return effectedID, nil
}
// DeleteContent removes an item from the database. Deleting a non-existent item
@@ -153,7 +140,6 @@ func DeleteContent(target string) error {
tx.Bucket([]byte(ns)).Delete([]byte(id))
return nil
})
-
if err != nil {
return err
}
@@ -197,8 +183,12 @@ func ContentAll(namespace string) [][]byte {
store.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(namespace))
- len := b.Stats().KeyN
- posts = make([][]byte, 0, len)
+ if b == nil {
+ return nil
+ }
+
+ numKeys := b.Stats().KeyN
+ posts = make([][]byte, 0, numKeys)
b.ForEach(func(k, v []byte) error {
posts = append(posts, v)
@@ -216,10 +206,15 @@ func ContentAll(namespace string) [][]byte {
// in descending order, from most recent to least recent
// Should be called from a goroutine after SetContent is successful
func SortContent(namespace string) {
+ // only sort main content types i.e. Post
+ if strings.Contains(namespace, "_") {
+ return
+ }
+
all := ContentAll(namespace)
var posts sortablePosts
- // decode each (json) into Editable
+ // decode each (json) into type to then sort
for i := range all {
j := all[i]
post := content.Types[namespace]()
@@ -238,18 +233,14 @@ func SortContent(namespace string) {
// store in <namespace>_sorted bucket, first delete existing
err := store.Update(func(tx *bolt.Tx) error {
- err := tx.DeleteBucket([]byte(namespace + "_sorted"))
+ bname := []byte(namespace + "_sorted")
+ err := tx.DeleteBucket(bname)
if err != nil {
return err
}
- b, err := tx.CreateBucket([]byte(namespace + "_sorted"))
+ b, err := tx.CreateBucketIfNotExists(bname)
if err != nil {
- err := tx.Rollback()
- if err != nil {
- return err
- }
-
return err
}
@@ -263,11 +254,6 @@ func SortContent(namespace string) {
cid := fmt.Sprintf("%d:%d", i, posts[i].Time())
err = b.Put([]byte(cid), j)
if err != nil {
- err := tx.Rollback()
- if err != nil {
- return err
- }
-
return err
}
}
@@ -293,3 +279,35 @@ func (s sortablePosts) Less(i, j int) bool {
func (s sortablePosts) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
+
+func postToJSON(ns string, data url.Values) ([]byte, error) {
+ // find the content type and decode values into it
+ ns = strings.TrimSuffix(ns, "_external")
+ t, ok := content.Types[ns]
+ if !ok {
+ return nil, fmt.Errorf(content.ErrTypeNotRegistered, ns)
+ }
+ post := t()
+
+ dec := schema.NewDecoder()
+ dec.SetAliasTag("json") // allows simpler struct tagging when creating a content type
+ dec.IgnoreUnknownKeys(true) // will skip over form values submitted, but not in struct
+ err := dec.Decode(post, data)
+ if err != nil {
+ return nil, err
+ }
+
+ slug, err := manager.Slug(post.(editor.Editable))
+ if err != nil {
+ return nil, err
+ }
+ post.(editor.Editable).SetSlug(slug)
+
+ // marshall content struct to json for db storage
+ j, err := json.Marshal(post)
+ if err != nil {
+ return nil, err
+ }
+
+ return j, nil
+}
diff --git a/system/db/init.go b/system/db/init.go
index 1a5ed25..63804e1 100644
--- a/system/db/init.go
+++ b/system/db/init.go
@@ -13,12 +13,21 @@ import (
var store *bolt.DB
+// Close exports the abillity to close our db file. Should be called with defer
+// after call to Init() from the same place.
+func Close() {
+ err := store.Close()
+ if err != nil {
+ log.Println(err)
+ }
+}
+
// Init creates a db connection, initializes db with required info, sets secrets
func Init() {
var err error
- store, err = bolt.Open("store.db", 0666, nil)
+ store, err = bolt.Open("system.db", 0666, nil)
if err != nil {
- log.Fatal(err)
+ log.Fatalln(err)
}
err = store.Update(func(tx *bolt.Tx) error {
@@ -67,10 +76,9 @@ func Init() {
return nil
})
if err != nil {
- log.Fatal("Coudn't initialize db with buckets.", err)
+ log.Fatalln("Coudn't initialize db with buckets.", err)
}
- // sort all content into type_sorted buckets
go func() {
for t := range content.Types {
SortContent(t)
@@ -99,7 +107,7 @@ func SystemInitComplete() bool {
})
if err != nil {
complete = false
- log.Fatal(err)
+ log.Fatalln(err)
}
return complete