From e9e9ea47d8bcbdbcf91a2ad822e107d8561a9822 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Mon, 7 Nov 2016 19:35:40 -0800 Subject: updating Identifiable interface, renaming its method and changing name where used throughout codebase --- system/admin/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'system') diff --git a/system/admin/handlers.go b/system/admin/handlers.go index 1f277e0..e653d29 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -946,7 +946,7 @@ func editHandler(res http.ResponseWriter, req *http.Request) { log.Println("Content type", t, "doesn't implement editor.Identifiable") return } - s.SetContentID(-1) + s.SetItemID(-1) } -- cgit v1.2.3 From dfa5e33f99f8727d36420981156c1ba8400b17b8 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Tue, 8 Nov 2016 16:48:12 -0800 Subject: adding before/after hooks to actions: save, delete, approve and reject --- system/admin/handlers.go | 215 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 208 insertions(+), 7 deletions(-) (limited to 'system') diff --git a/system/admin/handlers.go b/system/admin/handlers.go index e653d29..65d846d 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -822,6 +822,20 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { post := content.Types[t]() + // run hooks + hook, ok := post.(content.Hookable) + if !ok { + log.Println("Type", t, "does not implement content.Hookable or embed content.Item.") + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + // check if we have a Mergeable m, ok := post.(api.Mergeable) if !ok { @@ -851,6 +865,18 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { return } + err = hook.BeforeApprove(req) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + // call its Approve method err = m.Approve(req) if err != nil { @@ -864,6 +890,30 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { return } + err = hook.AfterApprove(req) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + err = hook.BeforeSave(req) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + // Store the content in the bucket t id, err := db.SetContent(t+":-1", req.Form) if err != nil { @@ -877,6 +927,18 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { return } + err = hook.AfterSave(req) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + // redirect to the new approved content's editor redir := req.URL.Scheme + req.URL.Host + strings.TrimSuffix(req.URL.Path, "/approve") redir += fmt.Sprintf("?type=%s&id=%d", t, id) @@ -1040,6 +1102,50 @@ func editHandler(res http.ResponseWriter, req *http.Request) { req.PostForm.Del(discardKey) } + if strings.Contains(t, "_") { + t = strings.Split(t, "_")[0] + } + + p, ok := content.Types[t] + if !ok { + log.Println("Type", t, "is not a content type. Cannot edit or save.") + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + post := p() + hook, ok := post.(content.Hookable) + if !ok { + log.Println("Type", t, "does not implement content.Hookable or embed content.Item.") + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + err = hook.BeforeSave(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + id, err := db.SetContent(t+":"+cid, req.PostForm) if err != nil { log.Println(err) @@ -1053,13 +1159,23 @@ func editHandler(res http.ResponseWriter, req *http.Request) { return } + err = hook.AfterSave(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + scheme := req.URL.Scheme host := req.URL.Host path := req.URL.Path sid := fmt.Sprintf("%d", id) - if strings.Contains(t, "_") { - t = strings.Split(t, "_")[0] - } redir := scheme + host + path + "?type=" + t + "&id=" + sid if req.URL.Query().Get("status") == "pending" { @@ -1088,12 +1204,75 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { id := req.FormValue("id") t := req.FormValue("type") + ct := t if id == "" || t == "" { res.WriteHeader(http.StatusBadRequest) return } + // catch specifier suffix from delete form value + if strings.Contains(t, "_") { + spec := strings.Split(t, "_") + ct = spec[0] + } + + p, ok := content.Types[ct] + if !ok { + log.Println("Type", t, "does not implement content.Hookable or embed content.Item.") + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + post := p() + hook, ok := post.(content.Hookable) + if !ok { + log.Println("Type", t, "does not implement content.Hookable or embed content.Item.") + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + reject := req.URL.Query().Get("reject") + if reject == "true" { + err = hook.BeforeReject(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + } + + err = hook.BeforeDelete(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + err = db.DeleteContent(t + ":" + id) if err != nil { log.Println(err) @@ -1101,10 +1280,32 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { return } - // catch specifier suffix from delete form value - if strings.Contains(t, "_") { - spec := strings.Split(t, "_") - t = spec[0] + err = hook.AfterDelete(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + if reject == "true" { + err = hook.AfterReject(req) + if err != nil { + log.Println(err) + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } } redir := strings.TrimSuffix(req.URL.Scheme+req.URL.Host+req.URL.Path, "/edit/delete") -- cgit v1.2.3 From 7fc144bee449641fb7d135cd6fdf59c8f5869a80 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Tue, 8 Nov 2016 17:01:53 -0800 Subject: adding remaining methods to Item for Hookable implementation and adding hooks to external submissions --- system/api/external.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) (limited to 'system') diff --git a/system/api/external.go b/system/api/external.go index c9fb2a7..7eade92 100644 --- a/system/api/external.go +++ b/system/api/external.go @@ -99,11 +99,32 @@ func externalPostHandler(res http.ResponseWriter, req *http.Request) { req.PostForm.Del(discardKey) } + hook, ok := post.(content.Hookable) + if !ok { + log.Println("[External] error: Type", t, "does not implement content.Hookable or embed content.Item.") + res.WriteHeader(http.StatusBadRequest) + return + } + + err = hook.BeforeSave(req) + if err != nil { + log.Println("[External] error:", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + _, err = db.SetContent(t+"_pending:-1", req.PostForm) if err != nil { log.Println("[External] error:", err) res.WriteHeader(http.StatusInternalServerError) return } + + err = hook.AfterSave(req) + if err != nil { + log.Println("[External] error:", err) + res.WriteHeader(http.StatusInternalServerError) + return + } } } -- cgit v1.2.3 From 5d8e0b1c247cd9c81c42f4625eeb9cc435c0e9f0 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Tue, 8 Nov 2016 17:35:19 -0800 Subject: updating layout of buttons and css to make UI nicer --- system/admin/static/dashboard/css/admin.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'system') diff --git a/system/admin/static/dashboard/css/admin.css b/system/admin/static/dashboard/css/admin.css index 3cffc5d..ce6c214 100644 --- a/system/admin/static/dashboard/css/admin.css +++ b/system/admin/static/dashboard/css/admin.css @@ -154,7 +154,7 @@ footer p { color: #9e9e9e; } -.post-controls .save-post { +.post-controls .save-post. .post-controls .approve-post { margin-left: 10px; } -- cgit v1.2.3 From c9ab1e332822bf11f77dd902e8ed15e2199ce419 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Tue, 8 Nov 2016 17:36:25 -0800 Subject: fixing css syntax error --- system/admin/static/dashboard/css/admin.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'system') diff --git a/system/admin/static/dashboard/css/admin.css b/system/admin/static/dashboard/css/admin.css index ce6c214..8d2fb89 100644 --- a/system/admin/static/dashboard/css/admin.css +++ b/system/admin/static/dashboard/css/admin.css @@ -154,7 +154,7 @@ footer p { color: #9e9e9e; } -.post-controls .save-post. .post-controls .approve-post { +.post-controls .save-post, .post-controls .approve-post { margin-left: 10px; } -- cgit v1.2.3 From 6b727270a9b861118c4bc3629a59ddf109030293 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Tue, 8 Nov 2016 19:01:55 -0800 Subject: fixing redirect to cleaned type --- system/admin/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'system') diff --git a/system/admin/handlers.go b/system/admin/handlers.go index 65d846d..a6e636b 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -1309,7 +1309,7 @@ func deleteHandler(res http.ResponseWriter, req *http.Request) { } redir := strings.TrimSuffix(req.URL.Scheme+req.URL.Host+req.URL.Path, "/edit/delete") - redir = redir + "/posts?type=" + t + redir = redir + "/posts?type=" + ct http.Redirect(res, req, redir, http.StatusFound) } -- cgit v1.2.3 From bb59898de5856f414ba428c9ec436a4843471a66 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Tue, 8 Nov 2016 19:03:28 -0800 Subject: fixing error message --- system/admin/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'system') diff --git a/system/admin/handlers.go b/system/admin/handlers.go index a6e636b..a53ebd3 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -544,7 +544,7 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { if _, ok := content.Types[t]; !ok { res.WriteHeader(http.StatusBadRequest) - errView, err := Error405() + errView, err := Error400() if err != nil { return } -- cgit v1.2.3 From c8c4ae95a14e9b976a8bf0aa796e9c995ef071e7 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Tue, 8 Nov 2016 19:44:21 -0800 Subject: moving interface Mergable from api package to manager package. It avoids a cyclical import and also makes more sense logically as the management of content owns the scope of merging external to interal items --- system/admin/handlers.go | 2 +- system/api/external.go | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) (limited to 'system') diff --git a/system/admin/handlers.go b/system/admin/handlers.go index a53ebd3..59f6874 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -837,7 +837,7 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { } // check if we have a Mergeable - m, ok := post.(api.Mergeable) + m, ok := post.(manager.Mergeable) if !ok { log.Println("Content type", t, "must implement api.Mergable before it can bee approved.") res.WriteHeader(http.StatusBadRequest) diff --git a/system/api/external.go b/system/api/external.go index 7eade92..5c50172 100644 --- a/system/api/external.go +++ b/system/api/external.go @@ -19,14 +19,6 @@ type Externalable interface { Accepts() bool } -// Mergeable allows external post content to be approved and published through -// the public-facing API -type Mergeable interface { - // Approve copies an external post to the internal collection and triggers - // a re-sort of its content type posts - Approve(req *http.Request) error -} - func externalPostHandler(res http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { res.WriteHeader(http.StatusMethodNotAllowed) -- cgit v1.2.3 From f0eeb90641a85f30ea364a506fa548ac3b24fd09 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Tue, 8 Nov 2016 19:49:08 -0800 Subject: moving Mergable interface to content package. Still hit cyclical import issue, still makes sense to have in content --- system/admin/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'system') diff --git a/system/admin/handlers.go b/system/admin/handlers.go index 59f6874..fe04601 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -837,7 +837,7 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) { } // check if we have a Mergeable - m, ok := post.(manager.Mergeable) + m, ok := post.(content.Mergeable) if !ok { log.Println("Content type", t, "must implement api.Mergable before it can bee approved.") res.WriteHeader(http.StatusBadRequest) -- cgit v1.2.3 From e70c231df82f4e5f09869f8d570d809a5f70993c Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 9 Nov 2016 18:09:36 -0800 Subject: adding initial partial implementation of account recovery flow --- system/admin/admin.go | 115 +++++++++++++++++++++++++++++- system/admin/handlers.go | 182 ++++++++++++++++++++++++++++++++++++++++++++++- system/admin/server.go | 4 ++ system/db/user.go | 55 ++++++++++++++ 4 files changed, 352 insertions(+), 4 deletions(-) (limited to 'system') diff --git a/system/admin/admin.go b/system/admin/admin.go index 8c13582..e0689b3 100644 --- a/system/admin/admin.go +++ b/system/admin/admin.go @@ -205,7 +205,8 @@ var loginAdminHTML = `
- + Forgot password? +
@@ -246,6 +247,118 @@ func Login() ([]byte, error) { return buf.Bytes(), nil } +var forgotPasswordHTML = ` +
+
+
+
Account Recovery
+
Please enter the email for your account and a recovery message will be sent to you at this address. Check your spam folder in case the message was flagged.
+
+
+ + +
+ + +
+
+
+
+ +` + +// ForgotPassword ... +func ForgotPassword() ([]byte, error) { + html := startAdminHTML + forgotPasswordHTML + endAdminHTML + + cfg, err := db.Config("name") + if err != nil { + return nil, err + } + + if cfg == nil { + cfg = []byte("") + } + + a := admin{ + Logo: string(cfg), + } + + buf := &bytes.Buffer{} + tmpl := template.Must(template.New("forgotPassword").Parse(html)) + err = tmpl.Execute(buf, a) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +var recoveryKeyHTML = ` +
+
+
+
Account Recovery
+
Please check for your recovery key inside an email sent to the address you provided. Check your spam folder in case the message was flagged.
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+ +` + +// RecoveryKey ... +func RecoveryKey() ([]byte, error) { + html := startAdminHTML + recoveryKeyHTML + endAdminHTML + + cfg, err := db.Config("name") + if err != nil { + return nil, err + } + + if cfg == nil { + cfg = []byte("") + } + + a := admin{ + Logo: string(cfg), + } + + buf := &bytes.Buffer{} + tmpl := template.Must(template.New("recoveryKey").Parse(html)) + err = tmpl.Execute(buf, a) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + // UsersList ... func UsersList(req *http.Request) ([]byte, error) { html := ` diff --git a/system/admin/handlers.go b/system/admin/handlers.go index fe04601..998db6f 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -21,6 +21,7 @@ import ( "github.com/bosssauce/ponzu/system/db" "github.com/gorilla/schema" + emailer "github.com/nilslice/email" "github.com/nilslice/jwt" ) @@ -521,12 +522,187 @@ func logoutHandler(res http.ResponseWriter, req *http.Request) { http.Redirect(res, req, req.URL.Scheme+req.URL.Host+"/admin/login", http.StatusFound) } +func forgotPasswordHandler(res http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + view, err := ForgotPassword() + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + res.Write(view) + + case http.MethodPost: + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + // check email for user, if no user return Error + email := strings.ToLower(req.FormValue("email")) + if email == "" { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + err, u := db.User(email) + if err.Error() == db.ErrNoUserExists { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + if err.Error() != db.ErrNoUserExists && err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + // create temporary key to verify user + key, err := db.SetRecoveryKey(email) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + domain := db.ConfigCache("domain") + body := fmt.Sprintf(` + There has been an account recovery request made for the user with email: + %s + + To recover your account, please go to http://%s/admin/recover/key and enter + this email address along with the following secret key: + + %s + + If you did not make the request, ignore this message and your password + will remain as-is. + + + Thank you, + Ponzu CMS at %s + + `, email, domain, key, domain) + msg := emailer.Message{ + To: email, + From: fmt.Sprintf("Ponzu CMS Date: Wed, 9 Nov 2016 18:12:16 -0800 Subject: fixing multiple assignment issue --- system/admin/handlers.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'system') diff --git a/system/admin/handlers.go b/system/admin/handlers.go index 998db6f..7b1a09c 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -565,8 +565,8 @@ func forgotPasswordHandler(res http.ResponseWriter, req *http.Request) { return } - err, u := db.User(email) - if err.Error() == db.ErrNoUserExists { + _, err = db.User(email) + if err == db.ErrNoUserExists { res.WriteHeader(http.StatusBadRequest) errView, err := Error400() if err != nil { @@ -577,7 +577,7 @@ func forgotPasswordHandler(res http.ResponseWriter, req *http.Request) { return } - if err.Error() != db.ErrNoUserExists && err != nil { + if err != db.ErrNoUserExists && err != nil { res.WriteHeader(http.StatusInternalServerError) errView, err := Error500() if err != nil { -- cgit v1.2.3 From cffc8906b5cff73d25aef71e83a79e361ecad917 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 16 Nov 2016 02:17:09 -0800 Subject: testing restricted file server to limit public access from listing directory contents --- system/admin/filesystem.go | 36 ++++++++++++++++++++++++++++++++++++ system/admin/server.go | 4 ++-- 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 system/admin/filesystem.go (limited to 'system') diff --git a/system/admin/filesystem.go b/system/admin/filesystem.go new file mode 100644 index 0000000..4e64a26 --- /dev/null +++ b/system/admin/filesystem.go @@ -0,0 +1,36 @@ +package admin + +import ( + "net/http" + "os" +) + + +func restrict(dir http.Dir) justFilesFilesystem { + return justFilesFilesystem{dir} +} + +// the code below removes the open directory listing when accessing a URL which +// normally would point to a directory. code from golang-nuts mailing list: +// https://groups.google.com/d/msg/golang-nuts/bStLPdIVM6w/hidTJgDZpHcJ +// credit: Brad Fitzpatrick (c) 2012 + +type justFilesFilesystem struct { + fs http.FileSystem +} + +func (fs justFilesFilesystem) Open(name string) (http.File, error) { + f, err := fs.fs.Open(name) + if err != nil { + return nil, err + } + return neuteredReaddirFile{f}, nil +} + +type neuteredReaddirFile struct { + http.File +} + +func (f neuteredReaddirFile) Readdir(count int) ([]os.FileInfo, error) { + return nil, nil +} diff --git a/system/admin/server.go b/system/admin/server.go index ef2ae4b..75b48f6 100644 --- a/system/admin/server.go +++ b/system/admin/server.go @@ -41,11 +41,11 @@ func Run() { } staticDir := filepath.Join(pwd, "cmd", "ponzu", "vendor", "github.com", "bosssauce", "ponzu", "system") - http.Handle("/admin/static/", CacheControl(http.FileServer(http.Dir(staticDir)))) + http.Handle("/admin/static/", CacheControl(http.FileServer(restrict(http.Dir(staticDir))))) // API path needs to be registered within server package so that it is handled // even if the API server is not running. Otherwise, images/files uploaded // through the editor will not load within the admin system. uploadsDir := filepath.Join(pwd, "uploads") - http.Handle("/api/uploads/", CacheControl(http.StripPrefix("/api/uploads/", http.FileServer(http.Dir(uploadsDir))))) + http.Handle("/api/uploads/", CacheControl(http.StripPrefix("/api/uploads/", http.FileServer(restrict(http.Dir(uploadsDir)))))) } -- cgit v1.2.3 From 27f65ee344f094adeac73d33b524d6b805ab8910 Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 16 Nov 2016 02:28:51 -0800 Subject: adding non-Externalable content listing --- system/admin/handlers.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) (limited to 'system') diff --git a/system/admin/handlers.go b/system/admin/handlers.go index 7b1a09c..38ce41d 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -893,7 +893,22 @@ func postsHandler(res http.ResponseWriter, req *http.Request) { } } + } else { + for i := range posts { + err := json.Unmarshal(posts[i], &p) + if err != nil { + log.Println("Error unmarshal json into", t, err, posts[i]) + + post := `
  • Error decoding data. Possible file corruption.
  • ` + b.Write([]byte(post)) + continue + } + + post := adminPostListItem(p, t, status) + b.Write(post) + } } + html += `
      ` b.Write([]byte(`
    `)) -- cgit v1.2.3