diff options
Diffstat (limited to 'system')
-rw-r--r-- | system/addon/addon.go | 234 | ||||
-rw-r--r-- | system/addon/manager.go | 117 | ||||
-rw-r--r-- | system/admin/admin.go | 3 | ||||
-rw-r--r-- | system/admin/config/config.go | 12 | ||||
-rw-r--r-- | system/admin/handlers.go | 584 | ||||
-rw-r--r-- | system/admin/server.go | 3 | ||||
-rw-r--r-- | system/admin/static/dashboard/css/admin.css | 17 | ||||
-rw-r--r-- | system/admin/upload/upload.go | 2 | ||||
-rw-r--r-- | system/api/external.go | 7 | ||||
-rw-r--r-- | system/api/handlers.go | 2 | ||||
-rw-r--r-- | system/db/addon.go | 151 | ||||
-rw-r--r-- | system/db/content.go | 29 | ||||
-rw-r--r-- | system/db/init.go | 7 | ||||
-rw-r--r-- | system/item/types.go | 24 |
14 files changed, 1145 insertions, 47 deletions
diff --git a/system/addon/addon.go b/system/addon/addon.go new file mode 100644 index 0000000..51be9dc --- /dev/null +++ b/system/addon/addon.go @@ -0,0 +1,234 @@ +package addon + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/ponzu-cms/ponzu/system/db" + "github.com/ponzu-cms/ponzu/system/item" + + "github.com/tidwall/sjson" +) + +var ( + // Types is a record of addons, like content types, of addon_reverse_dns:interface{} + Types = make(map[string]func() interface{}) +) + +const ( + // StatusEnabled defines string status for Addon enabled state + StatusEnabled = "enabled" + // StatusDisabled defines string status for Addon disabled state + StatusDisabled = "disabled" +) + +// Meta contains the basic information about the addon +type Meta struct { + PonzuAddonName string `json:"addon_name"` + PonzuAddonAuthor string `json:"addon_author"` + PonzuAddonAuthorURL string `json:"addon_author_url"` + PonzuAddonVersion string `json:"addon_version"` + PonzuAddonReverseDNS string `json:"addon_reverse_dns"` + PonzuAddonStatus string `json:"addon_status"` +} + +// Addon contains information about a provided addon to the system +type Addon struct { + item.Item + Meta +} + +// Register constructs a new addon and registers it with the system. Meta is a +// addon.Meta and fn is a closure returning a pointer to your own addon type +func Register(m Meta, fn func() interface{}) Addon { + // get or create the reverse DNS identifier + if m.PonzuAddonReverseDNS == "" { + revDNS, err := reverseDNS(m) + if err != nil { + panic(err) + } + + m.PonzuAddonReverseDNS = revDNS + } + + Types[m.PonzuAddonReverseDNS] = fn + + a := Addon{Meta: m} + + err := register(a) + if err != nil { + panic(err) + } + + return a +} + +// register sets up the system to use the Addon by: +// 1. Validating the Addon struct +// 2. Saving it to the __addons bucket in DB with id/key = addon_reverse_dns +func register(a Addon) error { + if a.PonzuAddonName == "" { + return fmt.Errorf(`Addon must have valid Meta struct embedded: missing %s field.`, "PonzuAddonName") + } + if a.PonzuAddonAuthor == "" { + return fmt.Errorf(`Addon must have valid Meta struct embedded: missing %s field.`, "PonzuAddonAuthor") + } + if a.PonzuAddonAuthorURL == "" { + return fmt.Errorf(`Addon must have valid Meta struct embedded: missing %s field.`, "PonzuAddonAuthorURL") + } + if a.PonzuAddonVersion == "" { + return fmt.Errorf(`Addon must have valid Meta struct embedded: missing %s field.`, "PonzuAddonVersion") + } + + if _, ok := Types[a.PonzuAddonReverseDNS]; !ok { + return fmt.Errorf(`Addon "%s" has no record in the addons.Types map`, a.PonzuAddonName) + } + + // check if addon is already registered in db as addon_reverse_dns + if db.AddonExists(a.PonzuAddonReverseDNS) { + return nil + } + + // convert a.Item into usable data, Item{} => []byte(json) => map[string]interface{} + kv := make(map[string]interface{}) + + data, err := json.Marshal(a.Item) + if err != nil { + return err + } + + err = json.Unmarshal(data, &kv) + if err != nil { + return err + } + + // save new addon to db + vals := make(url.Values) + for k, v := range kv { + vals.Set(k, fmt.Sprintf("%v", v)) + } + + vals.Set("addon_name", a.PonzuAddonName) + vals.Set("addon_author", a.PonzuAddonAuthor) + vals.Set("addon_author_url", a.PonzuAddonAuthorURL) + vals.Set("addon_version", a.PonzuAddonVersion) + vals.Set("addon_reverse_dns", a.PonzuAddonReverseDNS) + vals.Set("addon_status", StatusDisabled) + + // db.SetAddon is like SetContent, but rather than the key being an int64 ID, + // we need it to be a string based on the addon_reverse_dns + kind, ok := Types[a.PonzuAddonReverseDNS] + if !ok { + return fmt.Errorf("Error: no addon to set with id: %s", a.PonzuAddonReverseDNS) + } + + err = db.SetAddon(vals, kind()) + if err != nil { + return err + } + + return nil +} + +// Deregister removes an addon from the system. `key` is the addon_reverse_dns +func Deregister(key string) error { + err := db.DeleteAddon(key) + if err != nil { + return err + } + + delete(Types, key) + return nil +} + +// Enable sets the addon status to `enabled`. `key` is the addon_reverse_dns +func Enable(key string) error { + err := setStatus(key, StatusEnabled) + if err != nil { + return err + } + + return nil +} + +// Disable sets the addon status to `disabled`. `key` is the addon_reverse_dns +func Disable(key string) error { + err := setStatus(key, StatusDisabled) + if err != nil { + return err + } + + return nil +} + +func setStatus(key, status string) error { + a, err := db.Addon(key) + if err != nil { + return err + } + + a, err = sjson.SetBytes(a, "addon_status", status) + if err != nil { + return err + } + + kind, ok := Types[key] + if !ok { + return fmt.Errorf("Error: no addon to set with id: %s", key) + } + + // convert json => map[string]interface{} => url.Values + var kv map[string]interface{} + err = json.Unmarshal(a, &kv) + if err != nil { + return err + } + + vals := make(url.Values) + for k, v := range kv { + switch v.(type) { + case []string: + s := v.([]string) + for i := range s { + if i == 0 { + vals.Set(k, s[i]) + } + + vals.Add(k, s[i]) + } + default: + vals.Set(k, fmt.Sprintf("%v", v)) + } + } + + err = db.SetAddon(vals, kind()) + if err != nil { + return err + } + + return nil +} + +func reverseDNS(meta Meta) (string, error) { + u, err := url.Parse(meta.PonzuAddonAuthorURL) + if err != nil { + return "", nil + } + + if u.Host == "" { + return "", fmt.Errorf(`Error parsing Addon Author URL: %s. Ensure URL is formatted as "scheme://hostname/path?query" (path & query optional)`, meta.PonzuAddonAuthorURL) + } + + name := strings.Replace(meta.PonzuAddonName, " ", "", -1) + + // reverse the host name parts, split on '.', ex. bosssauce.it => it.bosssauce + parts := strings.Split(u.Host, ".") + strap := make([]string, 0, len(parts)) + for i := len(parts) - 1; i >= 0; i-- { + strap = append(strap, parts[i]) + } + + return strings.Join(append(strap, name), "."), nil +} diff --git a/system/addon/manager.go b/system/addon/manager.go new file mode 100644 index 0000000..d3c9673 --- /dev/null +++ b/system/addon/manager.go @@ -0,0 +1,117 @@ +package addon + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "net/url" + + "github.com/ponzu-cms/ponzu/management/editor" + + "github.com/gorilla/schema" + "github.com/tidwall/gjson" +) + +const defaultInput = `<input type="hidden" name="%s" value="%s"/>` + +const managerHTML = ` +<div class="card editor"> + <form method="post" action="/admin/addon" enctype="multipart/form-data"> + <div class="card-content"> + <div class="card-title">{{ .AddonName }}</div> + </div> + {{ .DefaultInputs }} + {{ .Editor }} + </form> +</div> +` + +type manager struct { + DefaultInputs template.HTML + Editor template.HTML + AddonName string +} + +// Manage ... +func Manage(data []byte, reverseDNS string) ([]byte, error) { + a, ok := Types[reverseDNS] + if !ok { + return nil, fmt.Errorf("Addon has not been added to addon.Types map") + } + + // convert json => map[string]interface{} => url.Values + var kv map[string]interface{} + err := json.Unmarshal(data, &kv) + if err != nil { + return nil, err + } + + vals := make(url.Values) + for k, v := range kv { + switch v.(type) { + case []string: + s := v.([]string) + for i := range s { + if i == 0 { + vals.Set(k, s[i]) + } + + vals.Add(k, s[i]) + } + default: + vals.Set(k, fmt.Sprintf("%v", v)) + } + } + + at := a() + + dec := schema.NewDecoder() + dec.IgnoreUnknownKeys(true) + dec.SetAliasTag("json") + err = dec.Decode(at, vals) + if err != nil { + return nil, err + } + + e, ok := at.(editor.Editable) + if !ok { + return nil, fmt.Errorf("Addon is not editable - must implement editor.Editable: %T", at) + } + + v, err := e.MarshalEditor() + if err != nil { + return nil, fmt.Errorf("Couldn't marshal editor for addon: %s", err.Error()) + } + + inputs := &bytes.Buffer{} + fields := []string{ + "addon_name", + "addon_author", + "addon_author_url", + "addon_version", + "addon_reverse_dns", + "addon_status", + } + + for _, f := range fields { + input := fmt.Sprintf(defaultInput, f, gjson.GetBytes(data, f).String()) + _, err := inputs.WriteString(input) + if err != nil { + return nil, fmt.Errorf("Failed to write input for addon view: %s", f) + } + } + + m := manager{ + DefaultInputs: template.HTML(inputs.Bytes()), + Editor: template.HTML(v), + AddonName: gjson.GetBytes(data, "addon_name").String(), + } + + // execute html template into buffer for func return val + buf := &bytes.Buffer{} + tmpl := template.Must(template.New("manager").Parse(managerHTML)) + tmpl.Execute(buf, m) + + return buf.Bytes(), nil +} diff --git a/system/admin/admin.go b/system/admin/admin.go index e3ae2d6..9c4cfdd 100644 --- a/system/admin/admin.go +++ b/system/admin/admin.go @@ -64,7 +64,8 @@ var mainAdminHTML = ` <div class="card-title">System</div> <div class="row collection-item"> <li><a class="col s12" href="/admin/configure"><i class="tiny left material-icons">settings</i>Configuration</a></li> - <li><a class="col s12" href="/admin/configure/users"><i class="tiny left material-icons">supervisor_account</i>Users</a></li> + <li><a class="col s12" href="/admin/configure/users"><i class="tiny left material-icons">supervisor_account</i>Admin Users</a></li> + <li><a class="col s12" href="/admin/addons"><i class="tiny left material-icons">settings_input_svideo</i>Addons</a></li> </div> </ul> </div> diff --git a/system/admin/config/config.go b/system/admin/config/config.go index 2bc80c6..7b57dc0 100644 --- a/system/admin/config/config.go +++ b/system/admin/config/config.go @@ -8,7 +8,6 @@ import ( // Config represents the confirgurable options of the system type Config struct { item.Item - editor editor.Editor Name string `json:"name"` Domain string `json:"domain"` @@ -23,9 +22,6 @@ type Config struct { // String partially implements item.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 } - // MarshalEditor writes a buffer of html to edit a Post and partially implements editor.Editable func (c *Config) MarshalEditor() ([]byte, error) { view, err := editor.Form(c, @@ -90,7 +86,13 @@ func (c *Config) MarshalEditor() ([]byte, error) { return nil, err } - open := []byte(`<div class="card"><form action="/admin/configure" method="post">`) + open := []byte(` + <div class="card"> + <div class="card-content"> + <div class="card-title">System Configuration</div> + </div> + <form action="/admin/configure" method="post"> + `) close := []byte(`</form></div>`) script := []byte(` <script> diff --git a/system/admin/handlers.go b/system/admin/handlers.go index e7dabfe..c39fee4 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -2,6 +2,7 @@ package admin import ( "bytes" + "context" "encoding/base64" "encoding/json" "fmt" @@ -13,12 +14,14 @@ import ( "github.com/ponzu-cms/ponzu/management/editor" "github.com/ponzu-cms/ponzu/management/manager" + "github.com/ponzu-cms/ponzu/system/addon" "github.com/ponzu-cms/ponzu/system/admin/config" "github.com/ponzu-cms/ponzu/system/admin/upload" "github.com/ponzu-cms/ponzu/system/admin/user" "github.com/ponzu-cms/ponzu/system/api" "github.com/ponzu-cms/ponzu/system/db" "github.com/ponzu-cms/ponzu/system/item" + "github.com/tidwall/gjson" "github.com/gorilla/schema" emailer "github.com/nilslice/email" @@ -263,6 +266,17 @@ func configUsersEditHandler(res http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodPost: err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } // check if user to be edited is current user j, err := db.CurrentUser(req) @@ -385,6 +399,17 @@ func configUsersDeleteHandler(res http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodPost: err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } // do not allow current user to delete themselves j, err := db.CurrentUser(req) @@ -567,8 +592,9 @@ func forgotPasswordHandler(res http.ResponseWriter, req *http.Request) { case http.MethodPost: err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB if err != nil { - res.WriteHeader(http.StatusBadRequest) - errView, err := Error400() + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() if err != nil { return } @@ -939,12 +965,37 @@ func contentsHandler(res http.ResponseWriter, req *http.Request) { log.Println("Error unmarshal json into", t, err, string(posts[i])) post := `<li class="col s12">Error decoding data. Possible file corruption.</li>` - b.Write([]byte(post)) + _, err := b.Write([]byte(post)) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } + continue } post := adminPostListItem(p, t, status) - b.Write(post) + _, err = b.Write(post) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } } case "pending": @@ -964,12 +1015,36 @@ func contentsHandler(res http.ResponseWriter, req *http.Request) { log.Println("Error unmarshal json into", t, err, string(posts[i])) post := `<li class="col s12">Error decoding data. Possible file corruption.</li>` - b.Write([]byte(post)) + _, err := b.Write([]byte(post)) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } continue } post := adminPostListItem(p, t, status) - b.Write(post) + _, err = b.Write(post) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } } } @@ -982,18 +1057,54 @@ func contentsHandler(res http.ResponseWriter, req *http.Request) { log.Println("Error unmarshal json into", t, err, string(posts[i])) post := `<li class="col s12">Error decoding data. Possible file corruption.</li>` - b.Write([]byte(post)) + _, err := b.Write([]byte(post)) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } continue } post := adminPostListItem(p, t, status) - b.Write(post) + _, err = b.Write(post) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } } } html += `<ul class="posts row">` - b.Write([]byte(`</ul>`)) + _, err = b.Write([]byte(`</ul>`)) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } statusDisabled := "disabled" prevStatus := "" @@ -1042,7 +1153,19 @@ func contentsHandler(res http.ResponseWriter, req *http.Request) { ` } - b.Write([]byte(pagination + `</div></div>`)) + _, err = b.Write([]byte(pagination + `</div></div>`)) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } script := ` <script> @@ -1269,6 +1392,10 @@ func approveContentHandler(res http.ResponseWriter, req *http.Request) { 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(req) if err != nil { log.Println("Error running AfterSave hook in approveContentHandler for:", t, err) @@ -1306,7 +1433,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { contentType, ok := item.Types[t] if !ok { - fmt.Fprintf(res, item.ErrTypeNotRegistered, t) + fmt.Fprintf(res, item.ErrTypeNotRegistered.Error(), t) return } post := contentType() @@ -1358,6 +1485,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { log.Println("Content type", t, "doesn't implement item.Identifiable") return } + item.SetItemID(-1) } @@ -1388,8 +1516,8 @@ func editHandler(res http.ResponseWriter, req *http.Request) { err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB if err != nil { log.Println(err) - res.WriteHeader(http.StatusBadRequest) - errView, err := Error405() + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() if err != nil { return } @@ -1452,11 +1580,12 @@ func editHandler(res http.ResponseWriter, req *http.Request) { req.PostForm.Del(discardKey) } + pt := t if strings.Contains(t, "__") { - t = strings.Split(t, "__")[0] + pt = strings.Split(t, "__")[0] } - p, ok := item.Types[t] + p, ok := item.Types[pt] if !ok { log.Println("Type", t, "is not a content type. Cannot edit or save.") res.WriteHeader(http.StatusBadRequest) @@ -1472,7 +1601,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { post := p() hook, ok := post.(item.Hookable) if !ok { - log.Println("Type", t, "does not implement item.Hookable or embed item.Item.") + log.Println("Type", pt, "does not implement item.Hookable or embed item.Item.") res.WriteHeader(http.StatusBadRequest) errView, err := Error400() if err != nil { @@ -1509,6 +1638,10 @@ func editHandler(res http.ResponseWriter, req *http.Request) { 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(req) if err != nil { log.Println(err) @@ -1526,7 +1659,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { host := req.URL.Host path := req.URL.Path sid := fmt.Sprintf("%d", id) - redir := scheme + host + path + "?type=" + t + "&id=" + sid + redir := scheme + host + path + "?type=" + pt + "&id=" + sid if req.URL.Query().Get("status") == "pending" { redir += "&status=pending" @@ -1549,6 +1682,12 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { if err != nil { log.Println(err) res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) return } @@ -1729,15 +1868,51 @@ func searchHandler(res http.ResponseWriter, req *http.Request) { log.Println("Error unmarshal search result json into", t, err, posts[i]) post := `<li class="col s12">Error decoding data. Possible file corruption.</li>` - b.Write([]byte(post)) + _, err = b.Write([]byte(post)) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } continue } post := adminPostListItem(p, t, status) - b.Write([]byte(post)) + _, err = b.Write([]byte(post)) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } } - b.Write([]byte(`</ul></div></div>`)) + _, err := b.WriteString(`</ul></div></div>`) + if err != nil { + log.Println(err) + + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + } + + res.Write(errView) + return + } btn := `<div class="col s3"><a href="/admin/edit?type=` + t + `" class="btn new-post waves-effect waves-light">New ` + t + `</a></div></div>` html = html + b.String() + btn @@ -1752,3 +1927,372 @@ func searchHandler(res http.ResponseWriter, req *http.Request) { res.Header().Set("Content-Type", "text/html") res.Write(adminView) } + +func addonsHandler(res http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + all := db.AddonAll() + list := &bytes.Buffer{} + + for i := range all { + v := adminAddonListItem(all[i]) + _, err := list.Write(v) + if err != nil { + log.Println("Error writing bytes to addon list view:", err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + return + } + + res.Write(errView) + return + } + } + + html := &bytes.Buffer{} + open := `<div class="col s9 card"> + <div class="card-content"> + <div class="row"> + <div class="card-title col s7">Addons</div> + </div> + <ul class="posts row">` + + _, err := html.WriteString(open) + if err != nil { + log.Println("Error writing open html to addon html view:", err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + return + } + + res.Write(errView) + return + } + + _, err = html.Write(list.Bytes()) + if err != nil { + log.Println("Error writing list bytes to addon html view:", err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + return + } + + res.Write(errView) + return + } + + _, err = html.WriteString(`</ul></div></div>`) + if err != nil { + log.Println("Error writing close html to addon html view:", err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + return + } + + res.Write(errView) + return + } + + if html.Len() == 0 { + _, err := html.WriteString(`<p>No addons available.</p>`) + if err != nil { + log.Println("Error writing default addon html to admin view:", err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + return + } + + res.Write(errView) + return + } + } + + view, err := Admin(html.Bytes()) + if err != nil { + log.Println("Error writing addon html to admin view:", err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + log.Println(err) + return + } + + res.Write(errView) + return + } + + res.Write(view) + + case http.MethodPost: + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + id := req.PostFormValue("id") + action := strings.ToLower(req.PostFormValue("action")) + + _, err = db.Addon(id) + if err == db.ErrNoAddonExists { + log.Println(err) + res.WriteHeader(http.StatusNotFound) + errView, err := Error404() + if err != nil { + return + } + + res.Write(errView) + return + } + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + switch action { + case "enable": + err := addon.Enable(id) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + case "disable": + err := addon.Disable(id) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + default: + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + http.Redirect(res, req, req.URL.String(), http.StatusFound) + + default: + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + log.Println(err) + return + } + + res.Write(errView) + return + } +} + +func addonHandler(res http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + id := req.FormValue("id") + + data, err := db.Addon(id) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + _, ok := addon.Types[id] + if !ok { + log.Println("Addon: ", id, "is not found in addon.Types map") + res.WriteHeader(http.StatusNotFound) + errView, err := Error404() + if err != nil { + return + } + + res.Write(errView) + return + } + + m, err := addon.Manage(data, id) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + addonView, err := Admin(m) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + res.Header().Set("Content-Type", "text/html") + res.Write(addonView) + + case http.MethodPost: + // save req.Form + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + name := req.FormValue("addon_name") + id := req.FormValue("addon_reverse_dns") + + at, ok := addon.Types[id] + if !ok { + log.Println("Error: addon", name, "has no record in addon.Types map at", id) + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + // if Hookable, call BeforeSave prior to saving + h, ok := at().(item.Hookable) + if ok { + err := h.BeforeSave(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + } + + err = db.SetAddon(req.Form, at()) + if err != nil { + log.Println("Error saving addon:", name, err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + http.Redirect(res, req, "/admin/addon?id="+id, http.StatusFound) + + default: + res.WriteHeader(http.StatusBadRequest) + errView, err := Error405() + if err != nil { + log.Println(err) + return + } + + res.Write(errView) + return + } +} + +func adminAddonListItem(data []byte) []byte { + id := gjson.GetBytes(data, "addon_reverse_dns").String() + status := gjson.GetBytes(data, "addon_status").String() + name := gjson.GetBytes(data, "addon_name").String() + author := gjson.GetBytes(data, "addon_author").String() + authorURL := gjson.GetBytes(data, "addon_author_url").String() + version := gjson.GetBytes(data, "addon_version").String() + + var action string + var buttonClass string + if status != addon.StatusEnabled { + action = "Enable" + buttonClass = "green" + } else { + action = "Disable" + buttonClass = "red" + } + + a := ` + <li class="col s12"> + <div class="row"> + <div class="col s9"> + <a class="addon-name" href="/admin/addon?id=` + id + `" alt="Configure '` + name + `'">` + name + `</a> + <span class="addon-meta addon-author">by: <a href="` + authorURL + `">` + author + `</a></span> + <span class="addon-meta addon-version">version: ` + version + `</span> + </div> + + <div class="col s3"> + <form enctype="multipart/form-data" class="quick-` + strings.ToLower(action) + `-addon __ponzu right" action="/admin/addons" method="post"> + <button class="btn waves-effect waves-effect-light ` + buttonClass + `">` + action + `</button> + <input type="hidden" name="id" value="` + id + `" /> + <input type="hidden" name="action" value="` + action + `" /> + </form> + </div> + </div> + </li>` + + return []byte(a) +} diff --git a/system/admin/server.go b/system/admin/server.go index 155892a..f2bf244 100644 --- a/system/admin/server.go +++ b/system/admin/server.go @@ -23,6 +23,9 @@ func Run() { http.HandleFunc("/admin/recover", forgotPasswordHandler) http.HandleFunc("/admin/recover/key", recoveryKeyHandler) + http.HandleFunc("/admin/addons", user.Auth(addonsHandler)) + http.HandleFunc("/admin/addon", user.Auth(addonHandler)) + http.HandleFunc("/admin/configure", user.Auth(configHandler)) http.HandleFunc("/admin/configure/users", user.Auth(configUsersHandler)) http.HandleFunc("/admin/configure/users/edit", user.Auth(configUsersEditHandler)) diff --git a/system/admin/static/dashboard/css/admin.css b/system/admin/static/dashboard/css/admin.css index ce533a4..a977afb 100644 --- a/system/admin/static/dashboard/css/admin.css +++ b/system/admin/static/dashboard/css/admin.css @@ -191,6 +191,23 @@ li:hover .quick-delete-post, li:hover .delete-user { top: -10px; } +tr.default-fields, tr.editor-fields { + margin-top: 20px; + margin-bottom: 20px; +} + +.addon-meta a { + color: #7e7e7e; + text-decoration: underline; + font-style: italic; +} + +.addon-meta { + display: block; + color: #9e9e9e; + font-size: 12px; +} + /* OVERRIDE Bootstrap + Materialize conflicts */ .iso-texteditor.input-field label { color: #9e9e9e; diff --git a/system/admin/upload/upload.go b/system/admin/upload/upload.go index 323f371..486f55c 100644 --- a/system/admin/upload/upload.go +++ b/system/admin/upload/upload.go @@ -14,7 +14,7 @@ import ( 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) + return nil, err } ts := req.FormValue("timestamp") // timestamp in milliseconds since unix epoch diff --git a/system/api/external.go b/system/api/external.go index 302b7c9..662fc07 100644 --- a/system/api/external.go +++ b/system/api/external.go @@ -1,6 +1,7 @@ package api import ( + "context" "fmt" "log" "net/http" @@ -136,13 +137,17 @@ func externalContentHandler(res http.ResponseWriter, req *http.Request) { spec = "__pending" } - _, err = db.SetContent(t+spec+":-1", req.PostForm) + id, err := db.SetContent(t+spec+":-1", req.PostForm) if err != nil { log.Println("[External] error:", 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(req) if err != nil { log.Println("[External] error:", err) diff --git a/system/api/handlers.go b/system/api/handlers.go index 8b4a387..1bc4fbb 100644 --- a/system/api/handlers.go +++ b/system/api/handlers.go @@ -178,7 +178,7 @@ func hide(it interface{}, res http.ResponseWriter, req *http.Request) bool { // check if should be hidden if h, ok := it.(item.Hideable); ok { err := h.Hide(req) - if err != nil && err.Error() == item.AllowHiddenItem { + if err == item.ErrAllowHiddenItem { return false } diff --git a/system/db/addon.go b/system/db/addon.go new file mode 100644 index 0000000..f4621fa --- /dev/null +++ b/system/db/addon.go @@ -0,0 +1,151 @@ +package db + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "net/url" + + "github.com/boltdb/bolt" + "github.com/gorilla/schema" +) + +var ( + // ErrNoAddonExists indicates that there was not addon found in the db + ErrNoAddonExists = errors.New("No addon exists.") +) + +// Addon looks for an addon by its addon_reverse_dns as the key and returns +// the []byte as json representation of an addon +func Addon(key string) ([]byte, error) { + buf := &bytes.Buffer{} + + err := store.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("__addons")) + + val := b.Get([]byte(key)) + + if val == nil { + return ErrNoAddonExists + } + + _, err := buf.Write(val) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// SetAddon stores the values of an addon into the __addons bucket with a the +// `addon_reverse_dns` field used as the key. `kind` is the interface{} type for +// the provided addon (as in the result of calling addon.Types[id]) +func SetAddon(data url.Values, kind interface{}) error { + dec := schema.NewDecoder() + dec.IgnoreUnknownKeys(true) + dec.SetAliasTag("json") + err := dec.Decode(kind, data) + + v, err := json.Marshal(kind) + + err = store.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("__addons")) + k := data.Get("addon_reverse_dns") + if k == "" { + name := data.Get("addon_name") + return fmt.Errorf(`Addon "%s" has no identifier to use as key.`, name) + } + + err := b.Put([]byte(k), v) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return err + } + + return nil +} + +// AddonAll returns all registered addons as a [][]byte +func AddonAll() [][]byte { + var all [][]byte + + err := store.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("__addons")) + err := b.ForEach(func(k, v []byte) error { + all = append(all, v) + + return nil + }) + if err != nil { + return err + } + + return nil + }) + if err != nil { + log.Println("Error finding addons in db with db.AddonAll:", err) + return nil + } + + return all +} + +// DeleteAddon removes an addon from the db by its key, the addon_reverse_dns +func DeleteAddon(key string) error { + err := store.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("__addons")) + + if err := b.Delete([]byte(key)); err != nil { + return err + } + + return nil + }) + if err != nil { + return err + } + + return nil +} + +// AddonExists checks if there is an existing addon stored. The key is an the +// value at addon_reverse_dns +func AddonExists(key string) bool { + var exists bool + + if store == nil { + Init() + } + + err := store.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte("__addons")) + if err != nil { + return err + } + if b.Get([]byte(key)) == nil { + return nil + } + + exists = true + return nil + }) + if err != nil { + log.Println("Error checking existence of addon with key:", key, "-", err) + return false + } + + return exists +} diff --git a/system/db/content.go b/system/db/content.go index dc4477f..b8d9cb8 100644 --- a/system/db/content.go +++ b/system/db/content.go @@ -432,29 +432,40 @@ func SortContent(namespace string) { // sort posts sort.Sort(posts) + // marshal posts to json + var bb [][]byte + for i := range posts { + j, err := json.Marshal(posts[i]) + if err != nil { + // log error and kill sort so __sorted is not in invalid state + log.Println("Error marshal post to json in SortContent:", err) + return + } + + bb = append(bb, j) + } + // store in <namespace>_sorted bucket, first delete existing err := store.Update(func(tx *bolt.Tx) error { bname := []byte(namespace + "__sorted") err := tx.DeleteBucket(bname) - if err != nil { + if err != nil && err != bolt.ErrBucketNotFound { + fmt.Println("Error in DeleteBucket") return err } b, err := tx.CreateBucketIfNotExists(bname) if err != nil { + fmt.Println("Error in CreateBucketIfNotExists") return err } // encode to json and store as 'i:post.Time()':post - for i := range posts { - j, err := json.Marshal(posts[i]) - if err != nil { - return err - } - + for i := range bb { cid := fmt.Sprintf("%d:%d", i, posts[i].Time()) - err = b.Put([]byte(cid), j) + err = b.Put([]byte(cid), bb[i]) if err != nil { + fmt.Println("Error in Put") return err } } @@ -485,7 +496,7 @@ func postToJSON(ns string, data url.Values) ([]byte, error) { // find the content type and decode values into it t, ok := item.Types[ns] if !ok { - return nil, fmt.Errorf(item.ErrTypeNotRegistered, ns) + return nil, fmt.Errorf(item.ErrTypeNotRegistered.Error(), ns) } post := t() diff --git a/system/db/init.go b/system/db/init.go index bf93bc6..eaf6d76 100644 --- a/system/db/init.go +++ b/system/db/init.go @@ -24,6 +24,10 @@ func Close() { // Init creates a db connection, initializes db with required info, sets secrets func Init() { + if store != nil { + return + } + var err error store, err = bolt.Open("system.db", 0666, nil) if err != nil { @@ -45,7 +49,7 @@ func Init() { } // init db with other buckets as needed - buckets := []string{"__config", "__users", "__contentIndex"} + buckets := []string{"__config", "__users", "__contentIndex", "__addons"} for _, name := range buckets { _, err := tx.CreateBucketIfNotExists([]byte(name)) if err != nil { @@ -90,7 +94,6 @@ func Init() { SortContent(t) } }() - } // SystemInitComplete checks if there is at least 1 admin user in the db which diff --git a/system/item/types.go b/system/item/types.go index b4b361b..bcae58a 100644 --- a/system/item/types.go +++ b/system/item/types.go @@ -1,8 +1,9 @@ package item +import "errors" + const ( - // ErrTypeNotRegistered means content type isn't registered (not found in Types map) - ErrTypeNotRegistered = `Error: + typeNotRegistered = `Error: There is no type registered for %[1]s Add this to the file which defines %[1]s{} in the 'content' package: @@ -14,13 +15,22 @@ Add this to the file which defines %[1]s{} in the 'content' package: ` +) - // AllowHiddenItem should be used as an error to tell a caller of Hideable#Hide +var ( + // ErrTypeNotRegistered means content type isn't registered (not found in Types map) + ErrTypeNotRegistered = errors.New(typeNotRegistered) + + // ErrAllowHiddenItem should be used as an error to tell a caller of Hideable#Hide // that this type is hidden, but should be shown in a particular case, i.e. // if requested by a valid admin or user - AllowHiddenItem = `Allow hidden item` + ErrAllowHiddenItem = errors.New(`Allow hidden item`) + + // Types is a map used to reference a type name to its actual Editable type + // mainly for lookups in /admin route based utilities + Types map[string]func() interface{} ) -// Types is a map used to reference a type name to its actual Editable type -// mainly for lookups in /admin route based utilities -var Types = make(map[string]func() interface{}) +func init() { + Types = make(map[string]func() interface{}) +} |