~evanj/cms

3863856254c05f96be34bdb90837e6a0d9ef82ea — Evan M Jones 1 year, 1 month ago 9aa5246
Feat(webhooks): Webhooks have been added.
M cms.go => cms.go +11 -0
@@ 9,11 9,13 @@ import (

	"git.sr.ht/~evanj/cms/internal/c/content"
	"git.sr.ht/~evanj/cms/internal/c/contenttype"
	"git.sr.ht/~evanj/cms/internal/c/hook"
	"git.sr.ht/~evanj/cms/internal/c/ping"
	"git.sr.ht/~evanj/cms/internal/c/space"
	"git.sr.ht/~evanj/cms/internal/c/user"
	"git.sr.ht/~evanj/cms/internal/s/cache"
	"git.sr.ht/~evanj/cms/internal/s/db"
	webhook "git.sr.ht/~evanj/cms/internal/s/hook"
	"git.sr.ht/~evanj/cms/pkg/e3"
	"git.sr.ht/~evanj/security"
)


@@ 34,6 36,7 @@ type App struct {
	contenttype http.Handler
	space       http.Handler
	user        http.Handler
	hook        http.Handler
	ping        http.Handler
}



@@ 53,6 56,9 @@ func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	case "user":
		a.user.ServeHTTP(w, r)
		return
	case "hook":
		a.hook.ServeHTTP(w, r)
		return
	case "space":
		a.space.ServeHTTP(w, r)
		return


@@ 106,6 112,7 @@ func init() {
			log.New(w, "[cms:content] ", 0),
			cacher,
			fs,
			webhook.New(log.New(w, "[cms:hook] ", 0), cacher),
		),
		contenttype: contenttype.New(
			log.New(w, "[cms:contenttype] ", 0),


@@ 120,6 127,10 @@ func init() {
			cacher,
			os.Getenv("SIGNUP_ENABLE") == "true",
		),
		hook: hook.New(
			log.New(w, "[cms:hook] ", 0),
			cacher,
		),
		ping: ping.New(
			log.New(w, "[cms:ping] ", 0),
			cacher,

M go.mod => go.mod +1 -0
@@ 7,4 7,5 @@ require (
	github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b
	github.com/go-playground/assert/v2 v2.0.1
	github.com/go-sql-driver/mysql v1.5.0
	golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a
)

M go.sum => go.sum +2 -0
@@ 14,6 14,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d h1:1ZiEyfaQIg3Qh0EoqpwAakHVhecoE5wlSg5GjnafJGw=
golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=

M internal/c/content/content.go => internal/c/content/content.go +18 -5
@@ 16,6 16,7 @@ import (
	"git.sr.ht/~evanj/cms/internal/m/user"
	"git.sr.ht/~evanj/cms/internal/m/valuetype"
	"git.sr.ht/~evanj/cms/internal/s/db"
	webhook "git.sr.ht/~evanj/cms/internal/s/hook"
	"git.sr.ht/~evanj/cms/internal/s/tmpl"
)



@@ 25,9 26,10 @@ var (

type Content struct {
	*c.Controller
	log *log.Logger
	db  dber
	e3  e3er
	log  *log.Logger
	db   dber
	e3   e3er
	hook hooker
}

type dber interface {


@@ 40,19 42,24 @@ type dber interface {
	ContentUpdate(space space.Space, ct contenttype.ContentType, content content.Content, params []db.ContentUpdateParam) (content.Content, error)
	ContentDelete(space space.Space, ct contenttype.ContentType, content content.Content) error
	ContentSearch(space space.Space, ct contenttype.ContentType, name, query string, page int) ([]content.Content, error)
	ContentPerContentType(space space.Space, ct contenttype.ContentType, page int) ([]content.Content, error)
	ContentPerContentType(space space.Space, ct contenttype.ContentType, page int, order db.OrderType, sortField string) ([]content.Content, error)
}

type e3er interface {
	Upload(ctx context.Context, public bool, filename string, file io.Reader) (string, error)
}

func New(log *log.Logger, db dber, e3 e3er) *Content {
type hooker interface {
	Do(space space.Space, content content.Content, ht webhook.HookType)
}

func New(log *log.Logger, db dber, e3 e3er, hook hooker) *Content {
	return &Content{
		c.New(log, db),
		log,
		db,
		e3,
		hook,
	}
}



@@ 165,6 172,8 @@ func (c *Content) create(w http.ResponseWriter, r *http.Request) {
		return
	}

	go c.hook.Do(space, content, webhook.HookNew)

	url := fmt.Sprintf("/content/%s/%s/%s", space.ID(), ct.ID(), content.ID())
	c.log.Println("successfully created new content for user", user.Name(), "in space", space.Name(), "for contenttype", ct.Name(), "redirecting to", url)
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)


@@ 280,6 289,8 @@ func (c *Content) update(w http.ResponseWriter, r *http.Request) {
		return
	}

	go c.hook.Do(space, content, webhook.HookUpdate)

	url := fmt.Sprintf("/content/%s/%s/%s", space.ID(), ct.ID(), content.ID())
	c.log.Println("successfully updated content for user", user.Name(), "in space", space.Name(), "for contenttype", ct.Name(), "redirecting to", url)
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)


@@ 303,6 314,8 @@ func (c *Content) delete(w http.ResponseWriter, r *http.Request) {
		return
	}

	go c.hook.Do(space, content, webhook.HookDelete)

	url := fmt.Sprintf("/contenttype/%s/%s", space.ID(), ct.ID())
	c.log.Println("successfully deleted content for user", user.Name(), "in space", space.Name(), "for contenttype", ct.Name(), "redirecting to", url)
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)

M internal/c/contenttype/contenttype.go => internal/c/contenttype/contenttype.go +22 -2
@@ 34,7 34,7 @@ type dber interface {
	ContentTypeGet(space space.Space, contenttypeID string) (contenttype.ContentType, error)
	ContentTypeDelete(space space.Space, ct contenttype.ContentType) error
	ContentTypeSearch(space space.Space, query string, page int) ([]contenttype.ContentType, error)
	ContentPerContentType(space space.Space, ct contenttype.ContentType, page int) ([]content.Content, error)
	ContentPerContentType(space space.Space, ct contenttype.ContentType, page int, order db.OrderType, sortField string) ([]content.Content, error)
}

func New(log *log.Logger, db dber) *ContentType {


@@ 138,7 138,27 @@ func (c *ContentType) serve(w http.ResponseWriter, r *http.Request, spaceID, con
	}
	page-- // Show one to user but start counting at zero for us.

	list, err := c.db.ContentPerContentType(space, ct, page)
	// How to order by.
	var o db.OrderType
	switch r.FormValue("order") {
	case "asc":
		o = db.OrderAsc
	case "desc":
		o = db.OrderDesc
	case "":
		o = db.OrderAsc
	default:
		c.Error(w, r, http.StatusBadRequest, "invalid order value")
		return
	}

	// Order by this field.
	f := r.FormValue("field")
	if f == "" {
		f = "name" // All guaranteed to have.
	}

	list, err := c.db.ContentPerContentType(space, ct, page, o, f)
	if err != nil {
		c.log.Println(err)
		c.Error(w, r, http.StatusInternalServerError, "failed to find content for contenttype")

A internal/c/hook/hook.go => internal/c/hook/hook.go +120 -0
@@ 0,0 1,120 @@
package hook

import (
	"fmt"
	"log"
	"net/http"
	"strings"

	"git.sr.ht/~evanj/cms/internal/c"
	"git.sr.ht/~evanj/cms/internal/m/hook"
	"git.sr.ht/~evanj/cms/internal/m/space"
	"git.sr.ht/~evanj/cms/internal/m/user"
	"git.sr.ht/~evanj/cms/internal/s/tmpl"
)

var (
	hookHTML = tmpl.MustParse("html/hook.html")
)

type Content struct {
	*c.Controller
	log *log.Logger
	db  dber
}

type dber interface {
	UserGet(username, password string) (user.User, error)
	UserGetFromToken(token string) (user.User, error)
	SpaceGet(user user.User, spaceID string) (space.Space, error)
	HookNew(space space.Space, url string) (hook.Hook, error)
	HookGet(space space.Space, id string) (hook.Hook, error)
	HookDelete(space space.Space, hook hook.Hook) error
}

func New(log *log.Logger, db dber) *Content {
	return &Content{
		c.New(log, db),
		log,
		db,
	}
}

func (c *Content) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	user, err := c.GetCookieUser(w, r)
	if err != nil {
		c.Error(w, r, http.StatusBadRequest, "must be logged int")
		return
	}

	switch r.URL.Path {

	case "/hook/new":
		space, err := c.db.SpaceGet(user, r.FormValue("space"))
		if err != nil {
			c.Error(w, r, http.StatusBadRequest, "failed to find required space")
			return
		}

		hook, err := c.db.HookNew(space, r.FormValue("url"))
		if err != nil {
			c.log.Println(err)
			c.Error(w, r, http.StatusInternalServerError, "failed to create webhook")
			return
		}

		http.Redirect(w, r, fmt.Sprintf("/hook/%s/%s", space.ID(), hook.ID()), http.StatusTemporaryRedirect)
		return

	case "/hook/delete":
		space, err := c.db.SpaceGet(user, r.FormValue("space"))
		if err != nil {
			c.log.Println(err)
			c.Error(w, r, http.StatusBadRequest, "failed to find required space")
			return
		}

		hook, err := c.db.HookGet(space, r.FormValue("hook"))
		if err != nil {
			c.log.Println(err)
			c.Error(w, r, http.StatusBadRequest, "failed to find desired webhook")
			return
		}

		if err := c.db.HookDelete(space, hook); err != nil {
			c.log.Println(err)
			c.Error(w, r, http.StatusBadRequest, "failed to delete webhook")
			return
		}

		http.Redirect(w, r, fmt.Sprintf("/space/%s", space.ID()), http.StatusTemporaryRedirect)
		return

	default:
		parts := strings.Split(r.URL.Path, "/")
		if len(parts) < 3 {
			http.NotFound(w, r)
			return
		}

		space, err := c.db.SpaceGet(user, parts[2])
		if err != nil {
			c.Error(w, r, http.StatusBadRequest, "failed to find required space")
			return
		}

		hook, err := c.db.HookGet(space, parts[3])
		if err != nil {
			c.log.Println(err)
			c.Error(w, r, http.StatusBadRequest, "failed to find desired webhook")
			return
		}

		c.HTML(w, r, hookHTML, map[string]interface{}{
			"User":  user,
			"Space": space,
			"Hook":  hook,
		})

	}
}

A internal/c/hook/hook_test.go => internal/c/hook/hook_test.go +1 -0
@@ 0,0 1,1 @@
package hook_test

M internal/c/space/space.go => internal/c/space/space.go +11 -0
@@ 9,6 9,7 @@ import (

	"git.sr.ht/~evanj/cms/internal/c"
	"git.sr.ht/~evanj/cms/internal/m/contenttype"
	"git.sr.ht/~evanj/cms/internal/m/hook"
	"git.sr.ht/~evanj/cms/internal/m/space"
	"git.sr.ht/~evanj/cms/internal/m/user"
	"git.sr.ht/~evanj/cms/internal/s/tmpl"


@@ 31,6 32,7 @@ type dber interface {
	SpaceGet(user user.User, spaceID string) (space.Space, error)
	SpaceDelete(space space.Space) error
	ContentTypesPerSpace(space space.Space, page int) ([]contenttype.ContentType, error)
	HookPerSpace(space space.Space, page int) ([]hook.Hook, error)
}

func New(log *log.Logger, db dber) *Space {


@@ 66,10 68,19 @@ func (s *Space) serve(w http.ResponseWriter, r *http.Request, spaceID string) {
		return
	}

	// TODO: Page differently? Yes.
	hooks, err := s.db.HookPerSpace(space, page)
	if err != nil {
		s.log.Println(err)
		s.Error(w, r, http.StatusInternalServerError, "failed to find webhooks for space")
		return
	}

	s.HTML(w, r, spaceHTML, map[string]interface{}{
		"User":         user,
		"Space":        space,
		"ContentTypes": cts,
		"Hooks":        hooks,
	})
}


A internal/m/hook/hook.go => internal/m/hook/hook.go +6 -0
@@ 0,0 1,6 @@
package hook

type Hook interface {
	ID() string
	URL() string
}

A internal/m/hook/hook_test.go => internal/m/hook/hook_test.go +1 -0
@@ 0,0 1,1 @@
package hook_test

M internal/s/db/content.go => internal/s/db/content.go +42 -7
@@ 13,6 13,14 @@ import (
	"git.sr.ht/~evanj/cms/internal/m/valuetype"
)

// iota doesn't quite pass the compile time checks I want.
type OrderType struct{ val string }

var (
	OrderAsc  OrderType = OrderType{val: "ASC"}
	OrderDesc OrderType = OrderType{val: "DESC"}
)

const (
	defaultDepth = 3 // For fetching reference types.
)


@@ 63,11 71,38 @@ var (
		WHERE ID = ?;
	`

	queryContentListByContentType = `
		SELECT ID, CONTENTTYPE_ID 
		FROM cms_content 
		WHERE CONTENTTYPE_ID = ? LIMIT ? OFFSET ?;
	`
	queryContentListByContentType = func(order OrderType) string {
		// Careful, we only do this for OrderType.
		return fmt.Sprintf(`
			SELECT cms_content.ID, cms_content.CONTENTTYPE_ID
			FROM cms_value
	
			JOIN cms_content
			ON cms_value.CONTENT_ID = cms_content.ID
	
			JOIN cms_contenttype_to_valuetype
			ON cms_contenttype_to_valuetype.ID = cms_value.CONTENTTYPE_TO_VALUETYPE_ID
	
			JOIN cms_valuetype
			ON cms_valuetype.ID = cms_contenttype_to_valuetype.VALUETYPE_ID
	
			LEFT JOIN cms_value_string_small
			ON cms_value_string_small.ID = cms_value.VALUE_ID
	
			LEFT JOIN cms_value_string_big
			ON cms_value_string_big.ID = cms_value.VALUE_ID
			
			LEFT JOIN cms_value_date
			ON cms_value_date.ID = cms_value.VALUE_ID
	
		 	WHERE cms_content.CONTENTTYPE_ID = ? 
		 	AND cms_contenttype_to_valuetype.NAME = ?
	
			ORDER BY cms_value_string_small.VALUE %s, cms_value_string_big.VALUE %s, cms_value_date.VALUE %s
	
		 	LIMIT ? OFFSET ?
		`, order.val, order.val, order.val)
	}

	queryValueNew = `
		INSERT INTO cms_value (CONTENT_ID, CONTENTTYPE_TO_VALUETYPE_ID, VALUE_ID)


@@ 546,9 581,9 @@ func (db *DB) ContentDelete(space space.Space, ct contenttype.ContentType, conte
	return nil
}

func (db *DB) ContentPerContentType(space space.Space, ct contenttype.ContentType, page int) ([]content.Content, error) {
func (db *DB) ContentPerContentType(space space.Space, ct contenttype.ContentType, page int, order OrderType, sortField string) ([]content.Content, error) {
	var ret []content.Content
	rows, err := db.Query(queryContentListByContentType, ct.ID(), perPage, perPage*page)
	rows, err := db.Query(queryContentListByContentType(order), ct.ID(), sortField, perPage, perPage*page)
	if err != nil {
		return nil, err
	}

M internal/s/db/db.go => internal/s/db/db.go +13 -0
@@ 43,8 43,11 @@ func New(log *log.Logger, typ, creds string, sec securer) (*DB, error) {
}

func (db *DB) CreateTables() error {
	var err error
	var _ interface{}

	_ = err

	// user
	_, _ = db.Exec(`
		CREATE TABLE cms_user (


@@ 181,6 184,16 @@ func (db *DB) CreateTables() error {
		);
	`)

	// Webhook
	_, _ = db.Exec(`
		CREATE TABLE cms_hooks (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			URL varchar(256) UNIQUE NOT NULL,
			SPACE_ID INTEGER NOT NULL,
			FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID) ON DELETE CASCADE
		);
	`)

	// Only valuetypes cms supports.
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringSmall)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringBig)

A internal/s/db/hook.go => internal/s/db/hook.go +119 -0
@@ 0,0 1,119 @@
package db

import (
	"git.sr.ht/~evanj/cms/internal/m/hook"
	"git.sr.ht/~evanj/cms/internal/m/space"
)

const (
	insert = `
		INSERT INTO cms_hooks (SPACE_ID, URL) 
		VALUES (?, ?)
	`

	query = `
		SELECT ID, URL, SPACE_ID
		FROM cms_hooks
		WHERE ID = ?
	`

	queryBySpace = `
		SELECT ID, URL, SPACE_ID
		FROM cms_hooks
		WHERE SPACE_ID = ?
		LIMIT ? OFFSET ?
	`

	delete = `
		DELETE FROM cms_hooks
		WHERE ID = ?
	`
)

type Hook struct {
	id, url, spaceID string
}

func (db *DB) HookNew(space space.Space, url string) (hook.Hook, error) {
	t, err := db.Begin()
	if err != nil {
		return nil, err
	}
	defer t.Rollback()

	res, err := t.Exec(insert, space.ID(), url)
	if err != nil {
		return nil, err
	}

	id, err := res.LastInsertId()
	if err != nil {
		return nil, err
	}

	var hook Hook
	if err := t.QueryRow(query, id).Scan(&hook.id, &hook.url, &hook.spaceID); err != nil {
		return nil, err
	}

	return &hook, t.Commit()
}

func (db *DB) HookGet(space space.Space, id string) (hook.Hook, error) {
	t, err := db.Begin()
	if err != nil {
		return nil, err
	}
	defer t.Rollback()

	var hook Hook
	if err := t.QueryRow(query, id).Scan(&hook.id, &hook.url, &hook.spaceID); err != nil {
		return nil, err
	}

	return &hook, t.Commit()
}

func (db *DB) HookDelete(s space.Space, h hook.Hook) error {
	t, err := db.Begin()
	if err != nil {
		return err
	}
	defer t.Rollback()

	if _, err := t.Exec(delete, h.ID()); err != nil {
		return err
	}

	return t.Commit()
}

func (db *DB) HookPerSpace(space space.Space, page int) ([]hook.Hook, error) {
	t, err := db.Begin()
	if err != nil {
		return nil, err
	}
	defer t.Rollback()

	var ret []hook.Hook
	rows, err := t.Query(queryBySpace, space.ID(), perPage, perPage*page)
	if err != nil {
		return nil, err
	}

	for rows.Next() {
		var hook Hook
		if err := rows.Scan(&hook.id, &hook.url, &hook.spaceID); err != nil {
			return nil, err
		}

		ret = append(ret, &hook)
	}

	return ret, t.Commit()
}

// Interface impl.

func (h *Hook) ID() string  { return h.id }
func (h *Hook) URL() string { return h.url }

A internal/s/hook/hook.go => internal/s/hook/hook.go +111 -0
@@ 0,0 1,111 @@
package hook

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"mime/multipart"
	"net/http"
	"strings"
	"time"

	"git.sr.ht/~evanj/cms/internal/m/content"
	"git.sr.ht/~evanj/cms/internal/m/hook"
	"git.sr.ht/~evanj/cms/internal/m/space"
	"golang.org/x/sync/errgroup"
)

type HookType string

const (
	HookNew    HookType = "created"
	HookUpdate HookType = "modified"
	HookDelete HookType = "removed"
)

type Hook struct {
	log    *log.Logger
	db     dber
	client *http.Client
}

type dber interface {
	HookPerSpace(space space.Space, page int) ([]hook.Hook, error)
}

func New(log *log.Logger, db dber) *Hook {
	return &Hook{log, db, http.DefaultClient}
}

func (h *Hook) do(ctx context.Context, content content.Content, hook hook.Hook, ht HookType) error {
	var bod bytes.Buffer

	data := make(map[string]interface{})
	data["Type"] = string(ht)
	data["Content"] = content

	bytes, err := json.Marshal(data)
	if err != nil {
		return err
	}
	bod.Write(bytes)

	mpwriter := multipart.NewWriter(&bod)

	req, err := http.NewRequestWithContext(ctx, "POST", hook.URL(), &bod)
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", mpwriter.FormDataContentType())

	resp, err := h.client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	bytes, err = ioutil.ReadAll(resp.Body)
	if err != nil {
		return err
	}

	res := strings.TrimSpace(string(bytes))
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("%s", res)
	}

	return nil
}

func (h *Hook) Do(space space.Space, content content.Content, ht HookType) {
	// TODO: Page this.
	page := 0
	hooks, err := h.db.HookPerSpace(space, page)
	if err != nil {
		h.log.Println("failed to find webhooks for", space.ID(), space.Name(), err)
		return
	}

	var eg errgroup.Group

	ctx, _ := context.WithTimeout(
		context.Background(),
		10*time.Second, // TODO: May want to lower?
	)

	for _, hook := range hooks {
		eg.Go(func() error {
			return h.do(ctx, content, hook, ht)
		})
	}

	if err := eg.Wait(); err != nil {
		h.log.Println("webhooks failed for", space.ID(), space.Name(), err)
		return
	}

	h.log.Println("webhooks performed successfully for space", space.ID(), space.Name())
}

A internal/s/hook/hook_test.go => internal/s/hook/hook_test.go +1 -0
@@ 0,0 1,1 @@
package hook_test

A internal/s/tmpl/html/hook.html => internal/s/tmpl/html/hook.html +35 -0
@@ 0,0 1,35 @@
<!DOCTYPE html>
<html lang=en>

<head>
  {{ template "html/_head.html" }}
  <title>CMS | {{ .Space.Name }} | {{ .Hook.URL }}</title>
</head>

<body>
  <style>{{ template "css/mvp.css" }}</style>
  <style>{{ template "css/main.css" }}</style>

  <main>
    {{ template "html/_header.html" $ }}
    <hr/>
    <article>
      <h1>{{ .Space.Name }}, {{ .Hook.URL }}</h1>
      <details>
        <summary>Delete Webhook</summary>
        <form method=POST action='/hook/delete' enctype='multipart/form-data'>
          <input required type=hidden name=space value="{{ .Space.ID }}" />
          <input required type=hidden name=hook value="{{ .Hook.ID }}" />
          <input type=submit value=Delete />
        </form>
      </details>
    </article>
    <hr/>
    {{ template "html/_footer.html" }}
  </main>
  <script src='//unpkg.com/tinymce@5.2.0/tinymce.min.js'></script>
  <script src='//unpkg.com/autocomplete.js@0.37.1/dist/autocomplete.min.js'></script>
  <script>{{ template "js/content.js" $ }}</script>
</body>

</html>

M internal/s/tmpl/html/space.html => internal/s/tmpl/html/space.html +20 -0
@@ 44,6 44,15 @@
      </details>

      <details>
        <summary>Create Webhook</summary>
        <form method=POST action='/hook/new' enctype='multipart/form-data'>
          <input required type=hidden name=space value="{{ .Space.ID }}" />
          <input required type=url name=url placeholder='Must enter full URL of target' />
          <input type=submit value=Create />
        </form>
      </details>

      <details>
        <summary>Delete {{ .Space.Name }} Space</summary>
        <form method=POST action='/space/delete' enctype='multipart/form-data'>
          <input required type=hidden name=space value="{{ .Space.ID }}" />


@@ 62,6 71,17 @@
        <p>You haven't created any content types yet.</p>
      {{ end }}

      <h2>Browse Webhooks</h2>
      {{ if .Hooks}}
        <ul>
          {{ range .Hooks}}
            <li><a href='/hook/{{ $.Space.ID }}/{{ .ID }}'>{{ .URL }}</a></li>
          {{ end }}
        </ul>
      {{ else }}
        <p>You haven't created any webhooks yet.</p>
      {{ end }}

    </article>
    <hr/>
    {{ template "html/_footer.html" }}

M internal/s/tmpl/tmpls_embed.go => internal/s/tmpl/tmpls_embed.go +57 -0
@@ 780,6 780,43 @@ blockquote footer {
</html>
`

	tmpls["html/hook.html"] = `<!DOCTYPE html>
<html lang=en>

<head>
  {{ template "html/_head.html" }}
  <title>CMS | {{ .Space.Name }} | {{ .Hook.URL }}</title>
</head>

<body>
  <style>{{ template "css/mvp.css" }}</style>
  <style>{{ template "css/main.css" }}</style>

  <main>
    {{ template "html/_header.html" $ }}
    <hr/>
    <article>
      <h1>{{ .Space.Name }}, {{ .Hook.URL }}</h1>
      <details>
        <summary>Delete Webhook</summary>
        <form method=POST action='/hook/delete' enctype='multipart/form-data'>
          <input required type=hidden name=space value="{{ .Space.ID }}" />
          <input required type=hidden name=hook value="{{ .Hook.ID }}" />
          <input type=submit value=Delete />
        </form>
      </details>
    </article>
    <hr/>
    {{ template "html/_footer.html" }}
  </main>
  <script src='//unpkg.com/tinymce@5.2.0/tinymce.min.js'></script>
  <script src='//unpkg.com/autocomplete.js@0.37.1/dist/autocomplete.min.js'></script>
  <script>{{ template "js/content.js" $ }}</script>
</body>

</html>
`

	tmpls["html/index.html"] = `<!DOCTYPE html>
<html lang=en>



@@ 906,6 943,15 @@ blockquote footer {
      </details>

      <details>
        <summary>Create Webhook</summary>
        <form method=POST action='/hook/new' enctype='multipart/form-data'>
          <input required type=hidden name=space value="{{ .Space.ID }}" />
          <input required type=url name=url placeholder='Must enter full URL of target' />
          <input type=submit value=Create />
        </form>
      </details>

      <details>
        <summary>Delete {{ .Space.Name }} Space</summary>
        <form method=POST action='/space/delete' enctype='multipart/form-data'>
          <input required type=hidden name=space value="{{ .Space.ID }}" />


@@ 924,6 970,17 @@ blockquote footer {
        <p>You haven't created any content types yet.</p>
      {{ end }}

      <h2>Browse Webhooks</h2>
      {{ if .Hooks}}
        <ul>
          {{ range .Hooks}}
            <li><a href='/hook/{{ $.Space.ID }}/{{ .ID }}'>{{ .URL }}</a></li>
          {{ end }}
        </ul>
      {{ else }}
        <p>You haven't created any webhooks yet.</p>
      {{ end }}

    </article>
    <hr/>
    {{ template "html/_footer.html" }}

A vendor/golang.org/x/sync/AUTHORS => vendor/golang.org/x/sync/AUTHORS +3 -0
@@ 0,0 1,3 @@
# This source code refers to The Go Authors for copyright purposes.
# The master list of authors is in the main Go distribution,
# visible at http://tip.golang.org/AUTHORS.

A vendor/golang.org/x/sync/CONTRIBUTORS => vendor/golang.org/x/sync/CONTRIBUTORS +3 -0
@@ 0,0 1,3 @@
# This source code was written by the Go contributors.
# The master list of contributors is in the main Go distribution,
# visible at http://tip.golang.org/CONTRIBUTORS.

A vendor/golang.org/x/sync/LICENSE => vendor/golang.org/x/sync/LICENSE +27 -0
@@ 0,0 1,27 @@
Copyright (c) 2009 The Go Authors. All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

   * Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
   * Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
   * Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

A vendor/golang.org/x/sync/PATENTS => vendor/golang.org/x/sync/PATENTS +22 -0
@@ 0,0 1,22 @@
Additional IP Rights Grant (Patents)

"This implementation" means the copyrightable works distributed by
Google as part of the Go project.

Google hereby grants to You a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable (except as stated in this section)
patent license to make, have made, use, offer to sell, sell, import,
transfer and otherwise run, modify and propagate the contents of this
implementation of Go, where such license applies only to those patent
claims, both currently owned or controlled by Google and acquired in
the future, licensable by Google that are necessarily infringed by this
implementation of Go.  This grant does not include claims that would be
infringed only as a consequence of further modification of this
implementation.  If you or your agent or exclusive licensee institute or
order or agree to the institution of patent litigation against any
entity (including a cross-claim or counterclaim in a lawsuit) alleging
that this implementation of Go or any code incorporated within this
implementation of Go constitutes direct or contributory patent
infringement, or inducement of patent infringement, then any patent
rights granted to you under this License for this implementation of Go
shall terminate as of the date such litigation is filed.

A vendor/golang.org/x/sync/errgroup/errgroup.go => vendor/golang.org/x/sync/errgroup/errgroup.go +66 -0
@@ 0,0 1,66 @@
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package errgroup provides synchronization, error propagation, and Context
// cancelation for groups of goroutines working on subtasks of a common task.
package errgroup

import (
	"context"
	"sync"
)

// A Group is a collection of goroutines working on subtasks that are part of
// the same overall task.
//
// A zero Group is valid and does not cancel on error.
type Group struct {
	cancel func()

	wg sync.WaitGroup

	errOnce sync.Once
	err     error
}

// WithContext returns a new Group and an associated Context derived from ctx.
//
// The derived Context is canceled the first time a function passed to Go
// returns a non-nil error or the first time Wait returns, whichever occurs
// first.
func WithContext(ctx context.Context) (*Group, context.Context) {
	ctx, cancel := context.WithCancel(ctx)
	return &Group{cancel: cancel}, ctx
}

// Wait blocks until all function calls from the Go method have returned, then
// returns the first non-nil error (if any) from them.
func (g *Group) Wait() error {
	g.wg.Wait()
	if g.cancel != nil {
		g.cancel()
	}
	return g.err
}

// Go calls the given function in a new goroutine.
//
// The first call to return a non-nil error cancels the group; its error will be
// returned by Wait.
func (g *Group) Go(f func() error) {
	g.wg.Add(1)

	go func() {
		defer g.wg.Done()

		if err := f(); err != nil {
			g.errOnce.Do(func() {
				g.err = err
				if g.cancel != nil {
					g.cancel()
				}
			})
		}
	}()
}

M vendor/modules.txt => vendor/modules.txt +2 -0
@@ 11,3 11,5 @@ github.com/go-sql-driver/mysql
# golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d
golang.org/x/crypto/bcrypt
golang.org/x/crypto/blowfish
# golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a
golang.org/x/sync/errgroup