summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--system/api/server.go2
-rw-r--r--system/api/update.go223
-rw-r--r--system/db/content.go71
-rw-r--r--system/item/item.go13
4 files changed, 304 insertions, 5 deletions
diff --git a/system/api/server.go b/system/api/server.go
index c5c1a23..6a848dd 100644
--- a/system/api/server.go
+++ b/system/api/server.go
@@ -9,4 +9,6 @@ func Run() {
http.HandleFunc("/api/content", Record(CORS(Gzip(contentHandler))))
http.HandleFunc("/api/content/external", Record(CORS(externalContentHandler)))
+
+ http.HandleFunc("/api/content/update", Record(CORS(updateContentHandler)))
}
diff --git a/system/api/update.go b/system/api/update.go
new file mode 100644
index 0000000..da79330
--- /dev/null
+++ b/system/api/update.go
@@ -0,0 +1,223 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/ponzu-cms/ponzu/system/admin/upload"
+ "github.com/ponzu-cms/ponzu/system/db"
+ "github.com/ponzu-cms/ponzu/system/item"
+)
+
+var ErrNoAuth = errors.New("Auth failed for update request.")
+
+// Updateable accepts or rejects update POST requests to endpoints such as:
+// /api/content/update?type=Review&id=1
+type Updateable interface {
+ // AcceptUpdate allows external content update submissions of a specific type
+ // user.IsValid(req) may be checked in AcceptUpdate to validate the request
+ AcceptUpdate(http.ResponseWriter, *http.Request) error
+}
+
+func updateContentHandler(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("[Update] error:", err)
+ res.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ t := req.URL.Query().Get("type")
+ if t == "" {
+ res.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ p, found := item.Types[t]
+ if !found {
+ log.Println("[Update] attempt to submit unknown type:", t, "from:", req.RemoteAddr)
+ res.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ id := req.URL.Query().Get("id")
+ if !db.IsValidID(id) {
+ log.Println("[Update] attempt to submit update with missing or invalid id from:", req.RemoteAddr)
+ res.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ post := p()
+
+ ext, ok := post.(Updateable)
+ if !ok {
+ log.Println("[Update] rejected non-updateable type:", t, "from:", req.RemoteAddr)
+ res.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ ts := fmt.Sprintf("%d", int64(time.Nanosecond)*time.Now().UnixNano()/int64(time.Millisecond))
+ 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.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}
+ fieldOrderValue := make(map[string]map[string][]string)
+ ordVal := make(map[string][]string)
+ for k, v := range req.PostForm {
+ if strings.Contains(k, ".") {
+ fo := strings.Split(k, ".")
+
+ // put the order and the field value into map
+ field := string(fo[0])
+ order := string(fo[1])
+ fieldOrderValue[field] = ordVal
+
+ // orderValue is 0:[?type=Thing&id=1]
+ orderValue := fieldOrderValue[field]
+ orderValue[order] = v
+ fieldOrderValue[field] = orderValue
+
+ // discard the post form value with name.N
+ req.PostForm.Del(k)
+ }
+
+ }
+
+ // add/set the key & value to the post form in order
+ for f, ov := range fieldOrderValue {
+ for i := 0; i < len(ov); i++ {
+ position := fmt.Sprintf("%d", i)
+ fieldValue := ov[position]
+
+ if req.PostForm.Get(f) == "" {
+ for i, fv := range fieldValue {
+ if i == 0 {
+ req.PostForm.Set(f, fv)
+ } else {
+ req.PostForm.Add(f, fv)
+ }
+ }
+ } else {
+ for _, fv := range fieldValue {
+ req.PostForm.Add(f, fv)
+ }
+ }
+ }
+ }
+
+ hook, ok := post.(item.Hookable)
+ if !ok {
+ log.Println("[Update] error: Type", t, "does not implement item.Hookable or embed item.Item.")
+ res.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ err = hook.BeforeAcceptUpdate(res, req)
+ if err != nil {
+ log.Println("[Update] error calling BeforeAcceptUpdate:", err)
+ return
+ }
+
+ err = ext.AcceptUpdate(res, req)
+ if err != nil {
+ log.Println("[Update] error calling AcceptUpdate:", err)
+ if err == ErrNoAuth {
+ res.WriteHeader(http.StatusUnauthorized)
+ }
+ return
+ }
+
+ err = hook.BeforeSave(res, req)
+ if err != nil {
+ log.Println("[Update] error calling BeforeSave:", err)
+ return
+ }
+
+ // set specifier for db bucket in case content is/isn't Trustable
+ var spec string
+
+ _, err = db.SetContent(t+spec+":"+id, req.PostForm)
+ if err != nil {
+ log.Println("[Update] error calling SetContent:", err)
+ res.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ // set the target in the context so user can get saved value from db in hook
+ ctx := context.WithValue(req.Context(), "target", fmt.Sprintf("%s:%d", t, id))
+ req = req.WithContext(ctx)
+
+ err = hook.AfterSave(res, req)
+ if err != nil {
+ log.Println("[Update] error calling AfterSave:", err)
+ return
+ }
+
+ err = hook.AfterAcceptUpdate(res, req)
+ if err != nil {
+ log.Println("[Update] error calling AfterAcceptUpdate:", err)
+ return
+ }
+
+ // create JSON response to send data back to client
+ var data map[string]interface{}
+ if spec != "" {
+ spec = strings.TrimPrefix(spec, "__")
+ data = map[string]interface{}{
+ "status": spec,
+ "type": t,
+ }
+ } else {
+ spec = "public"
+ data = map[string]interface{}{
+ "id": id,
+ "status": spec,
+ "type": t,
+ }
+ }
+
+ resp := map[string]interface{}{
+ "data": []map[string]interface{}{
+ data,
+ },
+ }
+
+ j, err := json.Marshal(resp)
+ if err != nil {
+ log.Println("[Update] error marshalling response to JSON:", err)
+ res.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ res.Header().Set("Content-Type", "application/json")
+ _, err = res.Write(j)
+ if err != nil {
+ log.Println("[Update] error writing response:", err)
+ return
+ }
+
+}
diff --git a/system/db/content.go b/system/db/content.go
index d9096ae..dd93c60 100644
--- a/system/db/content.go
+++ b/system/db/content.go
@@ -3,6 +3,7 @@ package db
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"log"
"net/url"
@@ -17,6 +18,15 @@ import (
uuid "github.com/satori/go.uuid"
)
+func IsValidID(id string) bool {
+ // ID should be a non-negative integer.
+ // ID of -1 is special for new posts, not updates.
+ if i, err := strconv.Atoi(id); err != nil || i < 0 {
+ return false
+ }
+ return true
+}
+
// SetContent inserts or updates values in the database.
// The `target` argument is a string made up of namespace:id (string:int)
func SetContent(target string, data url.Values) (int, error) {
@@ -33,10 +43,16 @@ func SetContent(target string, data url.Values) (int, error) {
return insert(ns, data)
}
- return update(ns, id, data)
+ // retrieve existing content from the database
+ existingContent, err := Content(target)
+ if err != nil {
+ return 0, err
+ }
+ return update(ns, id, data, &existingContent)
}
-func update(ns, id string, data url.Values) (int, error) {
+// update can support merge or replace behavior
+func update(ns, id string, data url.Values, existingContent *[]byte) (int, error) {
var specifier string // i.e. __pending, __sorted, etc.
if strings.Contains(ns, "__") {
spec := strings.Split(ns, "__")
@@ -49,9 +65,17 @@ func update(ns, id string, data url.Values) (int, error) {
return 0, err
}
- j, err := postToJSON(ns, data)
- if err != nil {
- return 0, err
+ var j []byte
+ if existingContent == nil {
+ j, err = postToJSON(ns, data)
+ if err != nil {
+ return 0, err
+ }
+ } else {
+ j, err = mergeData(ns, data, *existingContent)
+ if err != nil {
+ return 0, err
+ }
}
err = store.Update(func(tx *bolt.Tx) error {
@@ -84,6 +108,43 @@ func update(ns, id string, data url.Values) (int, error) {
return cid, nil
}
+func mergeData(ns string, data url.Values, existingContent []byte) ([]byte, error) {
+ var j []byte
+ t, ok := item.Types[ns]
+ if !ok {
+ log.Println("Type not found from namespace:", ns)
+ return j, errors.New("Invalid type.")
+ }
+
+ // Unmarsal the existing values
+ s := t()
+ err := json.Unmarshal(existingContent, &s)
+ if err != nil {
+ log.Println("Error decoding json while updating", ns, ":", err)
+ return j, err
+ }
+
+ // Don't allow the Item fields to be updated from form values
+ data.Del("id")
+ data.Del("uuid")
+ data.Del("slug")
+
+ 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(s, data)
+ if err != nil {
+ return j, err
+ }
+
+ j, err = json.Marshal(s)
+ if err != nil {
+ return j, err
+ }
+
+ return j, nil
+}
+
func insert(ns string, data url.Values) (int, error) {
var effectedID int
var specifier string // i.e. __pending, __sorted, etc.
diff --git a/system/item/item.go b/system/item/item.go
index 5b41693..286842b 100644
--- a/system/item/item.go
+++ b/system/item/item.go
@@ -42,6 +42,9 @@ type Sortable interface {
// to the different lifecycles/events a struct may encounter. Item implements
// Hookable with no-ops so our user can override only whichever ones necessary.
type Hookable interface {
+ BeforeAcceptUpdate(http.ResponseWriter, *http.Request) error
+ AfterAcceptUpdate(http.ResponseWriter, *http.Request) error
+
BeforeAccept(http.ResponseWriter, *http.Request) error
AfterAccept(http.ResponseWriter, *http.Request) error
@@ -132,6 +135,16 @@ func (i Item) String() string {
return fmt.Sprintf("Item ID: %s", i.UniqueID())
}
+// BeforeAcceptUpdate is a no-op to ensure structs which embed Item implement Hookable
+func (i Item) BeforeAcceptUpdate(res http.ResponseWriter, req *http.Request) error {
+ return nil
+}
+
+// AfterAcceptUpdate is a no-op to ensure structs which embed Item implement Hookable
+func (i Item) AfterAcceptUpdate(res http.ResponseWriter, req *http.Request) error {
+ return nil
+}
+
// BeforeAccept is a no-op to ensure structs which embed Item implement Hookable
func (i Item) BeforeAccept(res http.ResponseWriter, req *http.Request) error {
return nil