From 07fe1b15899fa6439e587984d6183371f9a6877c Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 15 Mar 2017 11:01:28 -0700 Subject: [PATCH 1/8] changing API for external client interaction. Externalable -> Createable, +Deleteable, changing Hookable interface methods to conform to pattern: BeforeAPI$ACTION, etc. --- system/api/external.go | 230 ----------------------------------------- system/api/handlers.go | 4 + system/api/update.go | 29 +++--- system/item/item.go | 37 ++++--- 4 files changed, 42 insertions(+), 258 deletions(-) delete mode 100644 system/api/external.go diff --git a/system/api/external.go b/system/api/external.go deleted file mode 100644 index 7f13917f..00000000 --- a/system/api/external.go +++ /dev/null @@ -1,230 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "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" -) - -// Externalable accepts or rejects external POST requests to endpoints such as: -// /api/content/external?type=Review -type Externalable interface { - // Accept allows external content submissions of a specific type - Accept(http.ResponseWriter, *http.Request) error -} - -// 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(http.ResponseWriter, *http.Request) error -} - -func externalContentHandler(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 := item.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 - } - - 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("[External] error: Type", t, "does not implement item.Hookable or embed item.Item.") - res.WriteHeader(http.StatusBadRequest) - return - } - - err = hook.BeforeAccept(res, req) - if err != nil { - log.Println("[External] error calling BeforeAccept:", err) - return - } - - err = ext.Accept(res, req) - if err != nil { - log.Println("[External] error calling Accept:", err) - return - } - - err = hook.BeforeSave(res, req) - if err != nil { - log.Println("[External] error calling BeforeSave:", err) - return - } - - // set specifier for db bucket in case content is/isn't Trustable - var spec string - - // check if the content is Trustable should be auto-approved, if so the - // content is immediately added to the public content API. If not, then it - // is added to a "pending" list, only visible to Admins in the CMS and only - // if the type implements editor.Mergable - trusted, ok := post.(Trustable) - if ok { - err := trusted.AutoApprove(res, req) - if err != nil { - log.Println("[External] error calling AutoApprove:", err) - return - } - } else { - spec = "__pending" - } - - id, err := db.SetContent(t+spec+":-1", req.PostForm) - if err != nil { - log.Println("[External] 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("[External] error calling AfterSave:", err) - return - } - - err = hook.AfterAccept(res, req) - if err != nil { - log.Println("[External] error calling AfterAccept:", 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("[External] 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("[External] error writing response:", err) - return - } - -} diff --git a/system/api/handlers.go b/system/api/handlers.go index 4a9eaff9..83bbe430 100644 --- a/system/api/handlers.go +++ b/system/api/handlers.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "log" "net/http" "strconv" @@ -11,6 +12,9 @@ import ( "github.com/ponzu-cms/ponzu/system/item" ) +// ErrNoAuth should be used to report failed auth requests +var ErrNoAuth = errors.New("Auth failed for request") + // deprecating from API, but going to provide code here in case someone wants it func typesHandler(res http.ResponseWriter, req *http.Request) { var types = []string{} diff --git a/system/api/update.go b/system/api/update.go index 3a92a842..f7f73464 100644 --- a/system/api/update.go +++ b/system/api/update.go @@ -3,7 +3,6 @@ package api import ( "context" "encoding/json" - "errors" "fmt" "log" "net/http" @@ -15,13 +14,11 @@ import ( "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 - AcceptUpdate(http.ResponseWriter, *http.Request) error + // Update enabled external clients to update content of a specific type + Update(http.ResponseWriter, *http.Request) error } func updateContentHandler(res http.ResponseWriter, req *http.Request) { @@ -45,14 +42,14 @@ func updateContentHandler(res http.ResponseWriter, req *http.Request) { p, found := item.Types[t] if !found { - log.Println("[Update] attempt to submit unknown type:", t, "from:", req.RemoteAddr) + log.Println("[Update] attempt to update content 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) + log.Println("[Update] attempt to update content with missing or invalid id from:", req.RemoteAddr) res.WriteHeader(http.StatusBadRequest) return } @@ -135,21 +132,21 @@ func updateContentHandler(res http.ResponseWriter, req *http.Request) { return } - err = hook.BeforeAcceptUpdate(res, req) + err = hook.BeforeAPIUpdate(res, req) if err != nil { - log.Println("[Update] error calling BeforeAcceptUpdate:", err) + log.Println("[Update] error calling BeforeAPIUpdate:", err) if err == ErrNoAuth { - // BeforeAcceptUpdate can check user.IsValid(req) for auth + // BeforeAPIUpdate can check user.IsValid(req) for auth res.WriteHeader(http.StatusUnauthorized) } return } - err = ext.AcceptUpdate(res, req) + err = ext.Update(res, req) if err != nil { - log.Println("[Update] error calling AcceptUpdate:", err) + log.Println("[Update] error calling Update:", err) if err == ErrNoAuth { - // AcceptUpdate can check user.IsValid(req) for auth + // Update can check user.IsValid(req) or other forms of validation for auth res.WriteHeader(http.StatusUnauthorized) } return @@ -172,7 +169,7 @@ func updateContentHandler(res http.ResponseWriter, req *http.Request) { } // 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)) + ctx := context.WithValue(req.Context(), "target", fmt.Sprintf("%s:%s", t, id)) req = req.WithContext(ctx) err = hook.AfterSave(res, req) @@ -181,9 +178,9 @@ func updateContentHandler(res http.ResponseWriter, req *http.Request) { return } - err = hook.AfterAcceptUpdate(res, req) + err = hook.AfterAPIUpdate(res, req) if err != nil { - log.Println("[Update] error calling AfterAcceptUpdate:", err) + log.Println("[Update] error calling AfterAPIUpdate:", err) return } diff --git a/system/item/item.go b/system/item/item.go index 286842b5..f6e8f99e 100644 --- a/system/item/item.go +++ b/system/item/item.go @@ -42,11 +42,14 @@ 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 + BeforeAPICreate(http.ResponseWriter, *http.Request) error + AfterAPICreate(http.ResponseWriter, *http.Request) error - BeforeAccept(http.ResponseWriter, *http.Request) error - AfterAccept(http.ResponseWriter, *http.Request) error + BeforeAPIUpdate(http.ResponseWriter, *http.Request) error + AfterAPIUpdate(http.ResponseWriter, *http.Request) error + + BeforeAPIDelete(http.ResponseWriter, *http.Request) error + AfterAPIDelete(http.ResponseWriter, *http.Request) error BeforeSave(http.ResponseWriter, *http.Request) error AfterSave(http.ResponseWriter, *http.Request) error @@ -135,23 +138,33 @@ 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 { +// BeforeAPICreate is a no-op to ensure structs which embed Item implement Hookable +func (i Item) BeforeAPICreate(res http.ResponseWriter, req *http.Request) error { + return nil +} + +// AfterAPICreate is a no-op to ensure structs which embed Item implement Hookable +func (i Item) AfterAPICreate(res http.ResponseWriter, req *http.Request) error { + return nil +} + +// BeforeAPIUpdate is a no-op to ensure structs which embed Item implement Hookable +func (i Item) BeforeAPIUpdate(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 { +// AfterAPIUpdate is a no-op to ensure structs which embed Item implement Hookable +func (i Item) AfterAPIUpdate(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 { +// BeforeAPIDelete is a no-op to ensure structs which embed Item implement Hookable +func (i Item) BeforeAPIDelete(res http.ResponseWriter, req *http.Request) error { return nil } -// AfterAccept is a no-op to ensure structs which embed Item implement Hookable -func (i Item) AfterAccept(res http.ResponseWriter, req *http.Request) error { +// AfterAPIDelete is a no-op to ensure structs which embed Item implement Hookable +func (i Item) AfterAPIDelete(res http.ResponseWriter, req *http.Request) error { return nil } From 2f225325d7674a9a7594fc8cd787514e52ce0d77 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 15 Mar 2017 11:01:37 -0700 Subject: [PATCH 2/8] changing API for external client interaction. Externalable -> Createable, +Deleteable, changing Hookable interface methods to conform to pattern: BeforeAPI$ACTION, etc. --- system/api/create.go | 230 +++++++++++++++++++++++++++++++++++++++++++ system/api/delete.go | 140 ++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 system/api/create.go create mode 100644 system/api/delete.go diff --git a/system/api/create.go b/system/api/create.go new file mode 100644 index 00000000..fbd00dc7 --- /dev/null +++ b/system/api/create.go @@ -0,0 +1,230 @@ +package api + +import ( + "context" + "encoding/json" + "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" +) + +// Createable accepts or rejects external POST requests to endpoints such as: +// /api/content/create?type=Review +type Createable interface { + // Create enables external clients to submit content of a specific type + Create(http.ResponseWriter, *http.Request) error +} + +// Trustable allows external content to be auto-approved, meaning content sent +// as an Createable will be stored in the public content bucket +type Trustable interface { + AutoApprove(http.ResponseWriter, *http.Request) error +} + +func externalContentHandler(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("[Create] 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("[Create] attempt to submit unknown type:", t, "from:", req.RemoteAddr) + res.WriteHeader(http.StatusNotFound) + return + } + + post := p() + + ext, ok := post.(Createable) + if !ok { + log.Println("[Create] rejected non-createable 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("[Create] error: Type", t, "does not implement item.Hookable or embed item.Item.") + res.WriteHeader(http.StatusBadRequest) + return + } + + err = hook.BeforeAPICreate(res, req) + if err != nil { + log.Println("[Create] error calling BeforeAccept:", err) + return + } + + err = ext.Create(res, req) + if err != nil { + log.Println("[Create] error calling Accept:", err) + return + } + + err = hook.BeforeSave(res, req) + if err != nil { + log.Println("[Create] error calling BeforeSave:", err) + return + } + + // set specifier for db bucket in case content is/isn't Trustable + var spec string + + // check if the content is Trustable should be auto-approved, if so the + // content is immediately added to the public content API. If not, then it + // is added to a "pending" list, only visible to Admins in the CMS and only + // if the type implements editor.Mergable + trusted, ok := post.(Trustable) + if ok { + err := trusted.AutoApprove(res, req) + if err != nil { + log.Println("[Create] error calling AutoApprove:", err) + return + } + } else { + spec = "__pending" + } + + id, err := db.SetContent(t+spec+":-1", req.PostForm) + if err != nil { + log.Println("[Create] 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("[Create] error calling AfterSave:", err) + return + } + + err = hook.AfterAPICreate(res, req) + if err != nil { + log.Println("[Create] error calling AfterAccept:", 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("[Create] 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("[Create] error writing response:", err) + return + } + +} diff --git a/system/api/delete.go b/system/api/delete.go new file mode 100644 index 00000000..68b5f357 --- /dev/null +++ b/system/api/delete.go @@ -0,0 +1,140 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/ponzu-cms/ponzu/system/db" + "github.com/ponzu-cms/ponzu/system/item" +) + +// Deleteable accepts or rejects update POST requests to endpoints such as: +// /api/content/delete?type=Review&id=1 +type Deleteable interface { + // Delete enables external clients to delete content of a specific type + Delete(http.ResponseWriter, *http.Request) error +} + +func deleteContentHandler(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("[Delete] 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("[Delete] attempt to delete content of unknown type:", t, "from:", req.RemoteAddr) + res.WriteHeader(http.StatusNotFound) + return + } + + id := req.URL.Query().Get("id") + if !db.IsValidID(id) { + log.Println("[Delete] attempt to delete content with missing or invalid id from:", req.RemoteAddr) + res.WriteHeader(http.StatusBadRequest) + return + } + + post := p() + + ext, ok := post.(Deleteable) + if !ok { + log.Println("[Delete] rejected non-deleteable type:", t, "from:", req.RemoteAddr) + res.WriteHeader(http.StatusBadRequest) + return + } + + hook, ok := post.(item.Hookable) + if !ok { + log.Println("[Delete] error: Type", t, "does not implement item.Hookable or embed item.Item.") + res.WriteHeader(http.StatusBadRequest) + return + } + + err = hook.BeforeAPIDelete(res, req) + if err != nil { + log.Println("[Delete] error calling BeforeAPIDelete:", err) + if err == ErrNoAuth { + // BeforeAPIDelete can check user.IsValid(req) for auth + res.WriteHeader(http.StatusUnauthorized) + } + return + } + + err = ext.Delete(res, req) + if err != nil { + log.Println("[Delete] error calling Delete:", err) + if err == ErrNoAuth { + // Delete can check user.IsValid(req) or other forms of validation for auth + res.WriteHeader(http.StatusUnauthorized) + } + return + } + + err = hook.BeforeDelete(res, req) + if err != nil { + log.Println("[Delete] error calling BeforeSave:", err) + return + } + + err = db.DeleteContent(t+":"+id, req.PostForm) + if err != nil { + log.Println("[Delete] error calling DeleteContent:", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + err = hook.AfterDelete(res, req) + if err != nil { + log.Println("[Delete] error calling AfterDelete:", err) + return + } + + err = hook.AfterAPIDelete(res, req) + if err != nil { + log.Println("[Delete] error calling AfterAPIDelete:", err) + return + } + + // create JSON response to send data back to client + var data = map[string]interface{}{ + "id": id, + "status": "deleted", + "type": t, + } + + resp := map[string]interface{}{ + "data": []map[string]interface{}{ + data, + }, + } + + j, err := json.Marshal(resp) + if err != nil { + log.Println("[Delete] 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("[Delete] error writing response:", err) + return + } + +} From 37774dda33abe98d4d85dc62b2b8a1933ba786a0 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 15 Mar 2017 11:02:21 -0700 Subject: [PATCH 3/8] changing API for external client interaction. Externalable -> Createable, +Deleteable, changing Hookable interface methods to conform to pattern: BeforeAPI$ACTION, etc. --- system/api/create.go | 2 +- system/api/server.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/system/api/create.go b/system/api/create.go index fbd00dc7..3328bd64 100644 --- a/system/api/create.go +++ b/system/api/create.go @@ -27,7 +27,7 @@ type Trustable interface { AutoApprove(http.ResponseWriter, *http.Request) error } -func externalContentHandler(res http.ResponseWriter, req *http.Request) { +func createContentHandler(res http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { res.WriteHeader(http.StatusMethodNotAllowed) return diff --git a/system/api/server.go b/system/api/server.go index 6a848dd5..c5688772 100644 --- a/system/api/server.go +++ b/system/api/server.go @@ -8,7 +8,9 @@ func Run() { http.HandleFunc("/api/content", Record(CORS(Gzip(contentHandler)))) - http.HandleFunc("/api/content/external", Record(CORS(externalContentHandler))) + http.HandleFunc("/api/content/create", Record(CORS(createContentHandler))) http.HandleFunc("/api/content/update", Record(CORS(updateContentHandler))) + + http.HandleFunc("/api/content/delete", Record(CORS(deleteContentHandler))) } From 4f90b228e1f145340ae4c9a6fc83b2a2cf0a86fa Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 15 Mar 2017 11:03:48 -0700 Subject: [PATCH 4/8] changing API for external client interaction. Externalable -> Createable, +Deleteable, changing Hookable interface methods to conform to pattern: BeforeAPI$ACTION, etc. --- system/admin/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/admin/handlers.go b/system/admin/handlers.go index 4bb55217..56efc0c5 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -866,7 +866,7 @@ func contentsHandler(res http.ResponseWriter, req *http.Request) { } var hasExt bool - _, ok = pt.(api.Externalable) + _, ok = pt.(api.Createable) if ok { hasExt = true } From 8684ca00eedc5799ae954f8149d4beeeb8e236bf Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 15 Mar 2017 12:49:03 -0700 Subject: [PATCH 5/8] updating DeleteContent signature to remove requirement of url.Values, will instead to a lookup in content before the delete to get slug, etc --- system/admin/handlers.go | 4 ++-- system/db/content.go | 23 ++++++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/system/admin/handlers.go b/system/admin/handlers.go index 56efc0c5..a585faab 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -1409,7 +1409,7 @@ func approveContentHandler(res http.ResponseWriter, req *http.Request) { } if pendingID != "" { - err = db.DeleteContent(req.FormValue("type")+":"+pendingID, req.Form) + err = db.DeleteContent(req.FormValue("type") + ":" + pendingID) if err != nil { log.Println("Failed to remove content after approval:", err) } @@ -1757,7 +1757,7 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { return } - err = db.DeleteContent(t+":"+id, req.Form) + err = db.DeleteContent(t + ":" + id) if err != nil { log.Println(err) res.WriteHeader(http.StatusInternalServerError) diff --git a/system/db/content.go b/system/db/content.go index dd93c606..8a91828b 100644 --- a/system/db/content.go +++ b/system/db/content.go @@ -228,11 +228,25 @@ func insert(ns string, data url.Values) (int, error) { // DeleteContent removes an item from the database. Deleting a non-existent item // will return a nil error. -func DeleteContent(target string, data url.Values) error { +func DeleteContent(target string) error { t := strings.Split(target, ":") ns, id := t[0], t[1] - err := store.Update(func(tx *bolt.Tx) error { + b, err := Content(target) + if err != nil { + return err + } + + // get content slug to delete from __contentIndex if it exists + // this way content added later can use slugs even if previously + // deleted content had used one + var itm item.Item + err = json.Unmarshal(b, &itm) + if err != nil { + return err + } + + err = store.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte(ns)) if b == nil { return bolt.ErrBucketNotFound @@ -244,14 +258,13 @@ func DeleteContent(target string, data url.Values) error { } // if content has a slug, also delete it from __contentIndex - slug := data.Get("slug") - if slug != "" { + if itm.Slug != "" { ci := tx.Bucket([]byte("__contentIndex")) if ci == nil { return bolt.ErrBucketNotFound } - err := ci.Delete([]byte(slug)) + err := ci.Delete([]byte(itm.Slug)) if err != nil { return err } From 40a7be1cb8218e705d77965f61c2c948e23971b9 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 15 Mar 2017 12:49:39 -0700 Subject: [PATCH 6/8] updating DeleteContent signature to remove requirement of url.Values, will instead to a lookup in content before the delete to get slug, etc --- system/api/delete.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/api/delete.go b/system/api/delete.go index 68b5f357..36f2b1be 100644 --- a/system/api/delete.go +++ b/system/api/delete.go @@ -91,7 +91,7 @@ func deleteContentHandler(res http.ResponseWriter, req *http.Request) { return } - err = db.DeleteContent(t+":"+id, req.PostForm) + err = db.DeleteContent(t + ":" + id) if err != nil { log.Println("[Delete] error calling DeleteContent:", err) res.WriteHeader(http.StatusInternalServerError) From 95c7e73e2b8acf048ba61b3feab76dd5f46ac955 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 15 Mar 2017 13:24:25 -0700 Subject: [PATCH 7/8] adding deleteable example, rename and modify externalable -> createable --- examples/README.md | 2 +- .../{externalable => createable}/README.md | 6 +- .../content/song.go | 18 +-- examples/deleteable/README.md | 31 +++++ examples/deleteable/content/song.go | 117 ++++++++++++++++++ examples/updateable/README.md | 4 +- examples/updateable/content/song.go | 16 +-- 7 files changed, 171 insertions(+), 23 deletions(-) rename examples/{externalable => createable}/README.md (85%) rename examples/{externalable => createable}/content/song.go (87%) create mode 100644 examples/deleteable/README.md create mode 100644 examples/deleteable/content/song.go diff --git a/examples/README.md b/examples/README.md index f575db75..cd070b1d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,6 +10,6 @@ feature, it would be very helpful to include an example with a concrete use-case for this directory. ### Table of Contents -1. [Add content via HTTP API using the `api.Externalable` interface](https://github.com/ponzu-cms/ponzu/tree/master/examples/externalable) +1. [Add content via HTTP API using the `api.Createable` interface](https://github.com/ponzu-cms/ponzu/tree/master/examples/createable) 2. [Update content via HTTP API using the `api.Updateable` interface](https://github.com/ponzu-cms/ponzu/tree/master/examples/updateable) diff --git a/examples/externalable/README.md b/examples/createable/README.md similarity index 85% rename from examples/externalable/README.md rename to examples/createable/README.md index 025e7fbc..44ba03cc 100644 --- a/examples/externalable/README.md +++ b/examples/createable/README.md @@ -1,8 +1,8 @@ -# Externalable +# Createable This example shows how to enable outside clients to submit content to your CMS. All content submitted must be done through a POST request encoded as `multipart/form-data` -to the API endpoint `/api/content/external?type=` +to the API endpoint `/api/content/create?type=` ## Song example Imagine an app that lets users add Spotify music to a global playlist, and you need them @@ -23,7 +23,7 @@ See the file `content/song.go` and read the comments to understand the various methods needed to satisfy required interfaces for this kind of activity. ### Overview -1. Implement `api.Externalable` with the `Accept(http.ResponseWriter, *http.Request)` method to allow outside POST requests +1. Implement `api.Createable` with the `Create(http.ResponseWriter, *http.Request)` method to allow outside POST requests 2. Implement `editor.Mergeable` with the `Approve(http.ResponseWriter, *http.Request)` method so you can control the Approval / Rejection of submitted content OR 3. Implement `api.Trustable` with the `AutoApprove(http.ResponseWriter, *http.Request)` method to bypass `Approve` and auto-approve and publish submitted content diff --git a/examples/externalable/content/song.go b/examples/createable/content/song.go similarity index 87% rename from examples/externalable/content/song.go rename to examples/createable/content/song.go index f2b1b732..74a6d4a0 100644 --- a/examples/externalable/content/song.go +++ b/examples/createable/content/song.go @@ -78,10 +78,10 @@ func init() { // String defines the display name of a Song in the CMS list-view func (s *Song) String() string { return s.Title } -// Accept implements api.Externalable, and allows external POST requests from clients +// Create implements api.Createable, and allows external POST requests from clients // to add content as long as the request contains the json tag names of the Song // struct fields, and is multipart encoded -func (s *Song) Accept(res http.ResponseWriter, req *http.Request) error { +func (s *Song) Create(res http.ResponseWriter, req *http.Request) error { // do form data validation for required fields required := []string{ "title", @@ -101,10 +101,10 @@ func (s *Song) Accept(res http.ResponseWriter, req *http.Request) error { return nil } -// BeforeAccept is only called if the Song type implements api.Externalable -// It is called before Accept, and returning an error will cancel the request +// BeforeAPICreate is only called if the Song type implements api.Createable +// It is called before Create, and returning an error will cancel the request // causing the system to reject the data sent in the POST -func (s *Song) BeforeAccept(res http.ResponseWriter, req *http.Request) error { +func (s *Song) BeforeAPICreate(res http.ResponseWriter, req *http.Request) error { // do initial user authentication here on the request, checking for a // token or cookie, or that certain form fields are set and valid @@ -116,16 +116,16 @@ func (s *Song) BeforeAccept(res http.ResponseWriter, req *http.Request) error { } // you could then to data validation on the request post form, or do it in - // the Accept method, which is called after BeforeAccept + // the Create method, which is called after BeforeAPICreate return nil } -// AfterAccept is called after Accept, and is useful for logging or triggering +// AfterAPICreate is called after Create, and is useful for logging or triggering // notifications, etc. after the data is saved to the database, etc. // The request has a context containing the databse 'target' affected by the // request. Ex. Song__pending:3 or Song:8 depending if Song implements api.Trustable -func (s *Song) AfterAccept(res http.ResponseWriter, req *http.Request) error { +func (s *Song) AfterAPICreate(res http.ResponseWriter, req *http.Request) error { addr := req.RemoteAddr log.Println("Song sent by:", addr, "titled:", req.PostFormValue("title")) @@ -149,7 +149,7 @@ func (s *Song) Approve(res http.ResponseWriter, req *http.Request) error { */ // AutoApprove implements api.Trustable, and will automatically approve content -// that has been submitted by an external client via api.Externalable. Be careful +// that has been submitted by an external client via api.Createable. Be careful // when using AutoApprove, because content will immediately be available through // your public content API. If the Trustable interface is satisfied, the AfterApprove // method is bypassed. The diff --git a/examples/deleteable/README.md b/examples/deleteable/README.md new file mode 100644 index 00000000..06f41885 --- /dev/null +++ b/examples/deleteable/README.md @@ -0,0 +1,31 @@ +# Updateable + +This example shows how to enable outside clients to update content to your CMS. +All content submitted must be done through a POST request encoded as `multipart/form-data` +to the API endpoint `/api/content/update?type=&id=` + +## Song example +Imagine an app that lets users add Spotify music to a global playlist, and you need them +to supply songs in the format: +```go +type Song struct { + item.Item + + Title string `json:"title"` + Artist string `json:"artist"` + Rating int `json:"rating"` + Opinion string `json:"opinion"` + SpotifyURL string `json:"spotify_url"` +} +``` + +See the file `content/song.go` and read the comments to understand the various +methods needed to satisfy required interfaces for this kind of activity. + +### Overview +1. Implement `api.Updateable` with the `Update(http.ResponseWriter, *http.Request)` method to allow outside POST requests. +2. Consistent with the createable example, authentication can be validated in `BeforeAPIUpdate(http.ResponseWriter, *http.Request)` + +There are various validation and request checks shown in this example as well. +Please feel free to modify and submit a PR for updates or bug fixes! + diff --git a/examples/deleteable/content/song.go b/examples/deleteable/content/song.go new file mode 100644 index 00000000..2198e334 --- /dev/null +++ b/examples/deleteable/content/song.go @@ -0,0 +1,117 @@ +package content + +import ( + "fmt" + "log" + + "net/http" + + "github.com/ponzu-cms/ponzu/management/editor" + "github.com/ponzu-cms/ponzu/system/admin/user" + "github.com/ponzu-cms/ponzu/system/item" +) + +type Song struct { + item.Item + + Title string `json:"title"` + Artist string `json:"artist"` + Rating int `json:"rating"` + Opinion string `json:"opinion"` + SpotifyURL string `json:"spotify_url"` +} + +// MarshalEditor writes a buffer of html to edit a Song within the CMS +// and implements editor.Editable +func (s *Song) MarshalEditor() ([]byte, error) { + view, err := editor.Form(s, + // Take note that the first argument to these Input-like functions + // is the string version of each Song field, and must follow + // this pattern for auto-decoding and auto-encoding reasons: + editor.Field{ + View: editor.Input("Title", s, map[string]string{ + "label": "Title", + "type": "text", + "placeholder": "Enter the Title here", + }), + }, + editor.Field{ + View: editor.Input("Artist", s, map[string]string{ + "label": "Artist", + "type": "text", + "placeholder": "Enter the Artist here", + }), + }, + editor.Field{ + View: editor.Input("Rating", s, map[string]string{ + "label": "Rating", + "type": "text", + "placeholder": "Enter the Rating here", + }), + }, + editor.Field{ + View: editor.Richtext("Opinion", s, map[string]string{ + "label": "Opinion", + "placeholder": "Enter the Opinion here", + }), + }, + editor.Field{ + View: editor.Input("SpotifyURL", s, map[string]string{ + "label": "SpotifyURL", + "type": "text", + "placeholder": "Enter the SpotifyURL here", + }), + }, + ) + + if err != nil { + return nil, fmt.Errorf("Failed to render Song editor view: %s", err.Error()) + } + + return view, nil +} + +func init() { + item.Types["Song"] = func() interface{} { return new(Song) } +} + +// String defines the display name of a Song in the CMS list-view +func (s *Song) String() string { return s.Title } + +// BeforeAPIDelete is only called if the Song type implements api.Deleteable +// It is called before Delete, and returning an error will cancel the request +// causing the system to reject the data sent in the POST +func (s *Song) BeforeAPIDelete(res http.ResponseWriter, req *http.Request) error { + // do initial user authentication here on the request, checking for a + // token or cookie, or that certain form fields are set and valid + + // for example, this will check if the request was made by a CMS admin user: + if !user.IsValid(req) { + addr := req.RemoteAddr + err := fmt.Errorf("request rejected, invalid user. IP: %s", addr) + return err + } + + // you could then to data validation on the request post form, or do it in + // the Delete method, which is called after BeforeAPIDelete + + return nil +} + +// Delete is called after BeforeAPIDelete and implements api.Deleteable. All +// other delete-based hooks are only called if this is implemented. +func (s *Song) Delete(res http.ResponseWriter, req *http.Request) error { + // See BeforeAPIDelete above, how we have checked the request for some + // form of auth. This could be done here instead, but if it is done once + // above, it means the request is valid here too. + return nil +} + +// AfterAPIDelete is called after Delete, and is useful for logging or triggering +// notifications, etc. after the data is deleted frm the database, etc. +func (s *Song) AfterAPIDelete(res http.ResponseWriter, req *http.Request) error { + addr := req.RemoteAddr + log.Println("Song deleted by:", addr, "id:", req.URL.Query().Get("id")) + + return nil +} diff --git a/examples/updateable/README.md b/examples/updateable/README.md index 1cc50f86..06f41885 100644 --- a/examples/updateable/README.md +++ b/examples/updateable/README.md @@ -23,8 +23,8 @@ See the file `content/song.go` and read the comments to understand the various methods needed to satisfy required interfaces for this kind of activity. ### Overview -1. Implement `api.Updateable` with the `AcceptUpdate(http.ResponseWriter, *http.Request)` method to allow outside POST requests. -2. Consistent with the externalable example, authentication can be validated in `BeforeAcceptUdate(http.ResponseWriter, *http.Request)` +1. Implement `api.Updateable` with the `Update(http.ResponseWriter, *http.Request)` method to allow outside POST requests. +2. Consistent with the createable example, authentication can be validated in `BeforeAPIUpdate(http.ResponseWriter, *http.Request)` There are various validation and request checks shown in this example as well. Please feel free to modify and submit a PR for updates or bug fixes! diff --git a/examples/updateable/content/song.go b/examples/updateable/content/song.go index 1ebe232a..947821cf 100644 --- a/examples/updateable/content/song.go +++ b/examples/updateable/content/song.go @@ -79,10 +79,10 @@ func init() { // String defines the display name of a Song in the CMS list-view func (s *Song) String() string { return s.Title } -// BeforeAcceptUpdate is only called if the Song type implements api.Updateable -// It is called before AcceptUpdate, and returning an error will cancel the request +// BeforeAPIUpdate is only called if the Song type implements api.Updateable +// It is called before Update, and returning an error will cancel the request // causing the system to reject the data sent in the POST -func (s *Song) BeforeAcceptUpdate(res http.ResponseWriter, req *http.Request) error { +func (s *Song) BeforeAPIUpdate(res http.ResponseWriter, req *http.Request) error { // do initial user authentication here on the request, checking for a // token or cookie, or that certain form fields are set and valid @@ -94,16 +94,16 @@ func (s *Song) BeforeAcceptUpdate(res http.ResponseWriter, req *http.Request) er } // you could then to data validation on the request post form, or do it in - // the Accept method, which is called after BeforeAccept + // the Update method, which is called after BeforeAPIUpdate return nil } -// AcceptUpdate is called after BeforeAccept and is where you may influence the +// Update is called after BeforeAPIUpdate and is where you may influence the // merge process. For example, maybe you don't want an empty string for the Title // or Artist field to be accepted by the update request. Updates will always merge // with existing values, but by default will accept zero value as an update if sent. -func (s *Song) AcceptUpdate(res http.ResponseWriter, req *http.Request) error { +func (s *Song) Update(res http.ResponseWriter, req *http.Request) error { addr := req.RemoteAddr log.Println("Song update sent by:", addr, "id:", req.URL.Query().Get("id")) @@ -129,11 +129,11 @@ func (s *Song) AcceptUpdate(res http.ResponseWriter, req *http.Request) error { return nil } -// AfterAcceptUpdate is called after AcceptUpdate, and is useful for logging or triggering +// AfterAPIUpdate is called after Update, and is useful for logging or triggering // notifications, etc. after the data is saved to the database, etc. // The request has a context containing the databse 'target' affected by the // request. -func (s *Song) AfterAcceptUpdate(res http.ResponseWriter, req *http.Request) error { +func (s *Song) AfterAPIUpdate(res http.ResponseWriter, req *http.Request) error { addr := req.RemoteAddr log.Println("Song updated by:", addr, "id:", req.URL.Query().Get("id")) From 5ec811b5d1899cfe538ed6d19c8a5ee01a553f03 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 15 Mar 2017 13:28:53 -0700 Subject: [PATCH 8/8] using api.ErrNoAuth return from auth check in examples --- examples/createable/content/song.go | 5 ++--- examples/deleteable/content/song.go | 5 ++--- examples/updateable/content/song.go | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/examples/createable/content/song.go b/examples/createable/content/song.go index 74a6d4a0..7473e91f 100644 --- a/examples/createable/content/song.go +++ b/examples/createable/content/song.go @@ -8,6 +8,7 @@ import ( "github.com/ponzu-cms/ponzu/management/editor" "github.com/ponzu-cms/ponzu/system/admin/user" + "github.com/ponzu-cms/ponzu/system/api" "github.com/ponzu-cms/ponzu/system/item" ) @@ -110,9 +111,7 @@ func (s *Song) BeforeAPICreate(res http.ResponseWriter, req *http.Request) error // for example, this will check if the request was made by a CMS admin user: if !user.IsValid(req) { - addr := req.RemoteAddr - err := fmt.Errorf("request rejected, invalid user. IP: %s", addr) - return err + return api.ErrNoAuth } // you could then to data validation on the request post form, or do it in diff --git a/examples/deleteable/content/song.go b/examples/deleteable/content/song.go index 2198e334..b33bad0e 100644 --- a/examples/deleteable/content/song.go +++ b/examples/deleteable/content/song.go @@ -8,6 +8,7 @@ import ( "github.com/ponzu-cms/ponzu/management/editor" "github.com/ponzu-cms/ponzu/system/admin/user" + "github.com/ponzu-cms/ponzu/system/api" "github.com/ponzu-cms/ponzu/system/item" ) @@ -87,9 +88,7 @@ func (s *Song) BeforeAPIDelete(res http.ResponseWriter, req *http.Request) error // for example, this will check if the request was made by a CMS admin user: if !user.IsValid(req) { - addr := req.RemoteAddr - err := fmt.Errorf("request rejected, invalid user. IP: %s", addr) - return err + return api.ErrNoAuth } // you could then to data validation on the request post form, or do it in diff --git a/examples/updateable/content/song.go b/examples/updateable/content/song.go index 947821cf..a2dc7f88 100644 --- a/examples/updateable/content/song.go +++ b/examples/updateable/content/song.go @@ -9,6 +9,7 @@ import ( "github.com/ponzu-cms/ponzu/management/editor" "github.com/ponzu-cms/ponzu/system/admin/user" + "github.com/ponzu-cms/ponzu/system/api" "github.com/ponzu-cms/ponzu/system/item" ) @@ -88,9 +89,7 @@ func (s *Song) BeforeAPIUpdate(res http.ResponseWriter, req *http.Request) error // for example, this will check if the request was made by a CMS admin user: if !user.IsValid(req) { - addr := req.RemoteAddr - err := fmt.Errorf("request rejected, invalid user. IP: %s", addr) - return err + return api.ErrNoAuth } // you could then to data validation on the request post form, or do it in