~evanj/cms

991b370ac6b6208e5f2fd7dcada177d52be58d9f — Evan M Jones 1 year, 10 days ago 8efa6eb
Feat(Big): Added a few things here...

* MVP stripe integration (new controller/server).
* Moved tmpl to views (package v).
* Added ability to create migrations.
* Added first migration. Remove user_to_space in favor of org table.
60 files changed, 629 insertions(+), 710 deletions(-)

M TODO
M cms.go
M internal/c/c_test.go
M internal/c/content/content.go
M internal/c/content/content_test.go
M internal/c/contenttype/contenttype.go
M internal/c/contenttype/contenttype_test.go
M internal/c/doc/doc.go
M internal/c/doc/doc_test.go
M internal/c/hook/hook.go
M internal/c/hook/hook_test.go
M internal/c/redirect/redirect.go
M internal/c/space/space.go
M internal/c/space/space_test.go
M internal/c/stripe/stripe.go
R internal/c/user/{mock/mock_user.go => mock.go}
M internal/c/user/user.go
M internal/c/user/user_test.go
M internal/m/user/user.go
M internal/s/db/db.go
A internal/s/db/migrations_embed.go
A internal/s/db/org.go
M internal/s/db/space.go
M internal/s/db/sql/00001.sql
A internal/s/db/sql/00002.sql
A internal/s/db/sql/00003.sql
M internal/s/db/user.go
M internal/s/stripe/stripe.go
D internal/s/tmpl/tmpl.go
D internal/s/tmpl/tmpl_test.go
R internal/{s/tmpl/css/bootstrap.css => v/css/bootstrap.css}
R internal/{s/tmpl/css/main.css => v/css/main.css}
R internal/{s/tmpl/css/mvp.css => v/css/mvp.css}
R internal/{s/tmpl/html/_footer.html => v/html/_footer.html}
R internal/{s/tmpl/html/_head.html => v/html/_head.html}
R internal/{s/tmpl/html/_header.html => v/html/_header.html}
R internal/{s/tmpl/html/_scripts.html => v/html/_scripts.html}
R internal/{s/tmpl/html/billing.html => v/html/billing.html}
R internal/{s/tmpl/html/contact.html => v/html/contact.html}
R internal/{s/tmpl/html/content.html => v/html/content.html}
R internal/{s/tmpl/html/contenttype.html => v/html/contenttype.html}
R internal/{s/tmpl/html/doc.html => v/html/doc.html}
R internal/{s/tmpl/html/faq.html => v/html/faq.html}
R internal/{s/tmpl/html/hook.html => v/html/hook.html}
R internal/{s/tmpl/html/index.html => v/html/index.html}
R internal/{s/tmpl/html/privacy.html => v/html/privacy.html}
R internal/{s/tmpl/html/redirect.html => v/html/redirect.html}
R internal/{s/tmpl/html/space.html => v/html/space.html}
R internal/{s/tmpl/html/stripe.html => v/html/stripe.html}
R internal/{s/tmpl/html/terms.html => v/html/terms.html}
R internal/{s/tmpl/js/bootstrap.js => v/js/bootstrap.js}
R internal/{s/tmpl/js/content.js => v/js/content.js}
R internal/{s/tmpl/js/main.js => v/js/main.js}
R internal/{s/tmpl/js/popper.js => v/js/popper.js}
R internal/{s/tmpl/js/space.js => v/js/space.js}
R internal/{s/tmpl/tmpls_embed.go => v/tmpls_embed.go}
M internal/v/v.go
M makefile
D vendor/github.com/stripe/stripe-go/v71/webhook/client.go
M vendor/modules.txt
M TODO => TODO +2 -0
@@ 12,3 12,5 @@ Object storage implementation BYOB
Payment integration
When editing existing references don't blow away prev inputs
Invite a user (the user will have access to all the same spaces -- to your "org" basically)
Migrations for user/org/space
Save nav button broke on content page.

M cms.go => cms.go +2 -1
@@ 108,7 108,7 @@ func init() {

	fs := e3.New(e3user, e3pass, e3url)
	c := c.New(log.New(w, "[cms:content] ", 0), cacher, analyticsEnabled)
	libs := libstripe.New(stripeSuccessURL, stripeErrorURL, stripePK, stripeSK)
	libs := libstripe.New(stripeSuccessURL, stripeErrorURL, stripePK, stripeSK, cacher)

	app = &App{
		applogger,


@@ 165,6 165,7 @@ func init() {
				c,
				log.New(w, "[cms:stripe] ", 0),
				cacher,
				libs,
				stripeWebhookSecret,
			)),
		},

M internal/c/c_test.go => internal/c/c_test.go +4 -3
@@ 199,9 199,10 @@ func (s server4) ServeHTTP(w http.ResponseWriter, r *http.Request) {

type FakeUser struct{ u, p string }

func (u FakeUser) ID() string    { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u FakeUser) Name() string  { return u.u }
func (u FakeUser) Token() string { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u FakeUser) ID() string        { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u FakeUser) Name() string      { return u.u }
func (u FakeUser) Token() string     { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u FakeUser) OrgID() (r string) { return }

func TestUserFromBasicAuth(t *testing.T) {
	t.Parallel()

M internal/c/content/content.go => internal/c/content/content.go +2 -2
@@ 19,11 19,11 @@ import (
	"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"
	"git.sr.ht/~evanj/cms/internal/v"
)

var (
	contentHTML = tmpl.MustParse("html/content.html")
	contentHTML = v.MustParse("html/content.html")

	ErrNoLogin = c.ErrNoLogin
	ErrNoSpace = errors.New("failed to find required space")

M internal/c/content/content_test.go => internal/c/content/content_test.go +4 -3
@@ 113,9 113,10 @@ func (pm umatcher) String() string {

type fakeuser struct{ u, p string }

func (u fakeuser) ID() string    { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u fakeuser) Name() string  { return u.u }
func (u fakeuser) Token() string { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u fakeuser) ID() string        { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u fakeuser) Name() string      { return u.u }
func (u fakeuser) Token() string     { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u fakeuser) OrgID() (r string) { return }

type fakespace struct{ n, d string }


M internal/c/contenttype/contenttype.go => internal/c/contenttype/contenttype.go +2 -2
@@ 14,11 14,11 @@ import (
	"git.sr.ht/~evanj/cms/internal/m/space"
	"git.sr.ht/~evanj/cms/internal/m/user"
	"git.sr.ht/~evanj/cms/internal/s/db"
	"git.sr.ht/~evanj/cms/internal/s/tmpl"
	"git.sr.ht/~evanj/cms/internal/v"
)

var (
	contenttypeHTML = tmpl.MustParse("html/contenttype.html")
	contenttypeHTML = v.MustParse("html/contenttype.html")

	ErrNoLogin      = c.ErrNoLogin // TODO: Refactor.
	ErrNoSpace      = errors.New("failed to find required space")

M internal/c/contenttype/contenttype_test.go => internal/c/contenttype/contenttype_test.go +4 -3
@@ 44,9 44,10 @@ func TestNoUser(t *testing.T) {

type FakeUser struct{ u, p string }

func (u FakeUser) ID() string    { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u FakeUser) Name() string  { return u.u }
func (u FakeUser) Token() string { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u FakeUser) ID() string        { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u FakeUser) Name() string      { return u.u }
func (u FakeUser) Token() string     { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u FakeUser) OrgID() (r string) { return }

type FakeSpace struct{ n, d string }


M internal/c/doc/doc.go => internal/c/doc/doc.go +8 -8
@@ 7,17 7,17 @@ import (

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

var pages = map[string]*template.Template{
	"/page/doc":     tmpl.MustParse("html/doc.html"),
	"/page/faq":     tmpl.MustParse("html/faq.html"),
	"/page/terms":   tmpl.MustParse("html/terms.html"),
	"/page/privacy": tmpl.MustParse("html/privacy.html"),
	"/page/contact": tmpl.MustParse("html/contact.html"),
	"/page/billing": tmpl.MustParse("html/billing.html"),
	"/page/stripe":  tmpl.MustParse("html/stripe.html"),
	"/page/doc":     v.MustParse("html/doc.html"),
	"/page/faq":     v.MustParse("html/faq.html"),
	"/page/terms":   v.MustParse("html/terms.html"),
	"/page/privacy": v.MustParse("html/privacy.html"),
	"/page/contact": v.MustParse("html/contact.html"),
	"/page/billing": v.MustParse("html/billing.html"),
	"/page/stripe":  v.MustParse("html/stripe.html"),
}

type Doc struct {

M internal/c/doc/doc_test.go => internal/c/doc/doc_test.go +18 -3
@@ 30,9 30,10 @@ func TestWithoutUser(t *testing.T) {

type FakeUser struct{}

func (u FakeUser) ID() string    { return "98" }
func (u FakeUser) Name() string  { return "Spike" }
func (u FakeUser) Token() string { return uuid.New().String() }
func (u FakeUser) ID() string        { return "98" }
func (u FakeUser) Name() string      { return "Spike" }
func (u FakeUser) Token() string     { return uuid.New().String() }
func (u FakeUser) OrgID() (r string) { return }

func TestWithUser(t *testing.T) {
	t.Parallel()


@@ 76,3 77,17 @@ func TestWithInvalidUserToken(t *testing.T) {
	res, _ := http.DefaultClient.Do(req)
	assert.Equal(t, res.StatusCode, http.StatusOK)
}

func TestNotFound(t *testing.T) {
	t.Parallel()

	var (
		ctrl = gomock.NewController(t)
		db   = doc.NewMockdber(ctrl)
		s    = doc.New(c.New(nil, db, true), nil, db)
		ts   = httptest.NewServer(s)
	)

	res, _ := http.Get(ts.URL + "/page/aye,aye,captain")
	assert.Equal(t, res.StatusCode, http.StatusNotFound)
}

M internal/c/hook/hook.go => internal/c/hook/hook.go +2 -2
@@ 11,11 11,11 @@ import (
	"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"
	"git.sr.ht/~evanj/cms/internal/v"
)

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

	ErrNoSpace      = errors.New("failed to find required space")
	ErrNoHook       = errors.New("failed to find desired webhook")

M internal/c/hook/hook_test.go => internal/c/hook/hook_test.go +4 -3
@@ 41,9 41,10 @@ func TestNoUser(t *testing.T) {

type FakeUser struct{ u, p string }

func (u FakeUser) ID() string    { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u FakeUser) Name() string  { return u.u }
func (u FakeUser) Token() string { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u FakeUser) ID() string        { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u FakeUser) Name() string      { return u.u }
func (u FakeUser) Token() string     { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u FakeUser) OrgID() (r string) { return }

type FakeSpace struct{ n, d string }


M internal/c/redirect/redirect.go => internal/c/redirect/redirect.go +2 -2
@@ 6,10 6,10 @@ import (

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

var redirectHTML = tmpl.MustParse("html/redirect.html")
var redirectHTML = v.MustParse("html/redirect.html")

type Redirect struct {
	*c.Controller

M internal/c/space/space.go => internal/c/space/space.go +2 -2
@@ 14,11 14,11 @@ import (
	"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"
	"git.sr.ht/~evanj/cms/internal/v"
)

var (
	spaceHTML = tmpl.MustParse("html/space.html")
	spaceHTML = v.MustParse("html/space.html")

	ErrNoSpace = errors.New("failed to find space")
)

M internal/c/space/space_test.go => internal/c/space/space_test.go +1 -0
@@ 36,6 36,7 @@ func fakeuser(u, p string) FakeUser { return FakeUser{u, p} }
func (u FakeUser) ID() string       { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u FakeUser) Name() string     { return u.u }
func (u FakeUser) Token() string    { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u FakeUser) OrgID() string    { return fmt.Sprintf("org-id-%s-%s", u.u, u.p) }

type FakeSpace struct{ n, d string }


M internal/c/stripe/stripe.go => internal/c/stripe/stripe.go +10 -63
@@ 1,23 1,18 @@
package stripe

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"

	"git.sr.ht/~evanj/cms/internal/c"
	"git.sr.ht/~evanj/cms/internal/m/user"
	"git.sr.ht/~evanj/cms/internal/s/stripe"
	libstripe "github.com/stripe/stripe-go/v71"
	webhook "github.com/stripe/stripe-go/v71/webhook"
)

type StripeEndpoint struct {
	*c.Controller
	log                 *log.Logger
	db                  DBer
	stripe              Striper
	stripeWebhookSecret string
}



@@ 25,75 20,27 @@ type DBer interface {
	UserGetFromToken(token string) (user.User, error)
}

func New(c *c.Controller, l *log.Logger, db DBer, stripeWebhookSecret string) *StripeEndpoint {
	return &StripeEndpoint{c, l, db, stripeWebhookSecret}
type Striper interface {
	CompleteCheckout(sessionID string) error
}

func (s *StripeEndpoint) webhook(w http.ResponseWriter, r *http.Request) {
	// webhook from Stripe.
	bytes, err := ioutil.ReadAll(r.Body)
	if err != nil {
		s.ErrorString(w, r, http.StatusBadRequest, "failed to ready body")
		return
	}

	event, err := webhook.ConstructEvent(bytes, r.Header.Get("Stripe-Signature"), s.stripeWebhookSecret)
	if err != nil {
		s.log.Println("you do not have access")
		s.ErrorString(w, r, http.StatusForbidden, "you do not have access")
		return
	}

	switch event.Type {
	case "payment_intent.succeeded":
		var pi libstripe.PaymentIntent
		err := json.Unmarshal(event.Data.Raw, &pi)
		if err != nil {
			s.Error2(w, r, http.StatusBadRequest, fmt.Errorf("error parsing webhook JSON: %w\n", err))
			return
		}
		if pi.Customer == nil {
			s.ErrorString(w, r, http.StatusBadRequest, "no customer for payment")
			return
		}

		userToken, ok := pi.Customer.Metadata[stripe.KeyUserToken]
		if !ok {
			s.ErrorString(w, r, http.StatusBadRequest, "no user associated with customer")
			return
		}

		user, err := s.db.UserGetFromToken(userToken)
		if err != nil {
			s.ErrorString(w, r, http.StatusBadRequest, "no user for token")
			return
		}

		// TODO: Add payment to user/org.
		_ = user
		s.log.Println("success with getting user from PI")

	default:
		s.Error2(w, r, http.StatusBadRequest, fmt.Errorf("unexpected event type: %s\n", event.Type))
		return
	}

	s.log.Println("successfully completed stripe webhook")
	s.Redirect(w, r, "/")
func New(c *c.Controller, l *log.Logger, db DBer, striper Striper, stripeWebhookSecret string) *StripeEndpoint {
	return &StripeEndpoint{c, l, db, striper, stripeWebhookSecret}
}

func (s *StripeEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// Handle users requests.
	switch r.URL.Path {
	case "/success":
		s.log.Println("redirect user success")
		err := s.stripe.CompleteCheckout(r.FormValue("session_id"))
		if err != nil {
			s.Error2(w, r, http.StatusInternalServerError, err)
		}
		s.Redirect(w, r, "/")
		return
	case "/error":
		s.log.Println("user error")
		s.ErrorString(w, r, http.StatusInternalServerError, "failed to process request: you have not been charged")
		return
	}

	s.webhook(w, r)
	http.NotFound(w, r)
}

R internal/c/user/mock/mock_user.go => internal/c/user/mock.go +43 -4
@@ 1,15 1,15 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: user.go

// Package mock_user is a generated GoMock package.
package mock_user
// Package user is a generated GoMock package.
package user

import (
	reflect "reflect"

	space "git.sr.ht/~evanj/cms/internal/m/space"
	tier "git.sr.ht/~evanj/cms/internal/m/tier"
	user "git.sr.ht/~evanj/cms/internal/m/user"
	gomock "github.com/golang/mock/gomock"
	reflect "reflect"
)

// Mockdber is a mock of dber interface


@@ 94,3 94,42 @@ func (mr *MockdberMockRecorder) SpacesPerUser(user, before interface{}) *gomock.
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SpacesPerUser", reflect.TypeOf((*Mockdber)(nil).SpacesPerUser), user, before)
}

// MockStriper is a mock of Striper interface
type MockStriper struct {
	ctrl     *gomock.Controller
	recorder *MockStriperMockRecorder
}

// MockStriperMockRecorder is the mock recorder for MockStriper
type MockStriperMockRecorder struct {
	mock *MockStriper
}

// NewMockStriper creates a new mock instance
func NewMockStriper(ctrl *gomock.Controller) *MockStriper {
	mock := &MockStriper{ctrl: ctrl}
	mock.recorder = &MockStriperMockRecorder{mock}
	return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockStriper) EXPECT() *MockStriperMockRecorder {
	return m.recorder
}

// StartCheckout mocks base method
func (m *MockStriper) StartCheckout(user user.User, t tier.Tier) (string, string, error) {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "StartCheckout", user, t)
	ret0, _ := ret[0].(string)
	ret1, _ := ret[1].(string)
	ret2, _ := ret[2].(error)
	return ret0, ret1, ret2
}

// StartCheckout indicates an expected call of StartCheckout
func (mr *MockStriperMockRecorder) StartCheckout(user, t interface{}) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartCheckout", reflect.TypeOf((*MockStriper)(nil).StartCheckout), user, t)
}

M internal/c/user/user.go => internal/c/user/user.go +2 -4
@@ 12,11 12,11 @@ import (
	"git.sr.ht/~evanj/cms/internal/m/space"
	"git.sr.ht/~evanj/cms/internal/m/tier"
	"git.sr.ht/~evanj/cms/internal/m/user"
	"git.sr.ht/~evanj/cms/internal/s/tmpl"
	"git.sr.ht/~evanj/cms/internal/v"
)

var (
	indexHTML   = tmpl.MustParse("html/index.html")
	indexHTML   = v.MustParse("html/index.html")
	ErrNoUser   = errors.New("incorrect user credentials")
	ErrNoSignup = errors.New("signups are forbidden at this time")
	ErrNoTier   = errors.New("invalid tier")


@@ 117,10 117,8 @@ func (l *User) home(w http.ResponseWriter, r *http.Request) {
	if err != nil {
		l.HTML(w, r, indexHTML, map[string]interface{}{
			"Tiers": tier.Tiers,
			"User":  user,
		})
		return

	}

	// Don't care about the error value here. When error occurs before is zero

M internal/c/user/user_test.go => internal/c/user/user_test.go +112 -34
@@ 10,8 10,8 @@ import (

	"git.sr.ht/~evanj/cms/internal/c"
	"git.sr.ht/~evanj/cms/internal/c/user"
	mock_user "git.sr.ht/~evanj/cms/internal/c/user/mock"
	spaceType "git.sr.ht/~evanj/cms/internal/m/space"
	tier "git.sr.ht/~evanj/cms/internal/m/tier"
	"github.com/bmizerany/assert"
	"github.com/golang/mock/gomock"
	"github.com/google/uuid"


@@ 19,9 19,10 @@ import (

type FakeUser struct{ u, p string }

func (u FakeUser) ID() string    { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u FakeUser) Name() string  { return u.u }
func (u FakeUser) Token() string { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u FakeUser) ID() string        { return fmt.Sprintf("id-%s-%s", u.u, u.p) }
func (u FakeUser) Name() string      { return u.u }
func (u FakeUser) Token() string     { return fmt.Sprintf("token-%s-%s", u.u, u.p) }
func (u FakeUser) OrgID() (r string) { return }

type FakeSpaceList struct{}



@@ 33,11 34,12 @@ func TestCreate(t *testing.T) {
	t.Parallel()

	var (
		ctrl = gomock.NewController(t)
		db   = mock_user.NewMockdber(ctrl)
		l    *log.Logger
		s    = user.New(c.New(l, db, true), l, db, true)
		ts   = httptest.NewServer(s)
		ctrl   = gomock.NewController(t)
		db     = user.NewMockdber(ctrl)
		l      *log.Logger
		stripe = user.NewMockStriper(ctrl)
		s      = user.New(c.New(l, db, true), l, db, true, stripe)
		ts     = httptest.NewServer(s)

		uname = uuid.New().String()
		upass = uuid.New().String()


@@ 47,6 49,7 @@ func TestCreate(t *testing.T) {
			"username": {uname},
			"password": {upass},
			"verify":   {upass},
			"tier":     {tier.Free.Name},
		}
	)



@@ 60,11 63,12 @@ func TestLogin(t *testing.T) {
	t.Parallel()

	var (
		ctrl = gomock.NewController(t)
		db   = mock_user.NewMockdber(ctrl)
		l    *log.Logger
		s    = user.New(c.New(l, db, true), l, db, true)
		ts   = httptest.NewServer(s)
		ctrl   = gomock.NewController(t)
		db     = user.NewMockdber(ctrl)
		l      *log.Logger
		stripe = user.NewMockStriper(ctrl)
		s      = user.New(c.New(l, db, true), l, db, true, stripe)
		ts     = httptest.NewServer(s)

		uname = uuid.New().String()
		upass = uuid.New().String()


@@ 86,11 90,12 @@ func TestLogout(t *testing.T) {
	t.Parallel()

	var (
		ctrl = gomock.NewController(t)
		db   = mock_user.NewMockdber(ctrl)
		l    *log.Logger
		s    = user.New(c.New(l, db, true), l, db, true)
		ts   = httptest.NewServer(s)
		ctrl   = gomock.NewController(t)
		db     = user.NewMockdber(ctrl)
		l      *log.Logger
		stripe = user.NewMockStriper(ctrl)
		s      = user.New(c.New(l, db, true), l, db, true, stripe)
		ts     = httptest.NewServer(s)
	)

	res, _ := http.PostForm(ts.URL+"/user/logout", url.Values{})


@@ 101,11 106,12 @@ func TestHome(t *testing.T) {
	t.Parallel()

	var (
		ctrl = gomock.NewController(t)
		db   = mock_user.NewMockdber(ctrl)
		l    *log.Logger
		s    = user.New(c.New(l, db, true), l, db, true)
		ts   = httptest.NewServer(s)
		ctrl   = gomock.NewController(t)
		db     = user.NewMockdber(ctrl)
		l      *log.Logger
		stripe = user.NewMockStriper(ctrl)
		s      = user.New(c.New(l, db, true), l, db, true, stripe)
		ts     = httptest.NewServer(s)

		uname = uuid.New().String()
		upass = uuid.New().String()


@@ 115,6 121,7 @@ func TestHome(t *testing.T) {
			"username": {uname},
			"password": {upass},
			"verify":   {upass},
			"tier":     {tier.Free.Name},
		}

		sl = FakeSpaceList{}


@@ 138,11 145,12 @@ func TestBadLogin(t *testing.T) {
	t.Parallel()

	var (
		ctrl = gomock.NewController(t)
		db   = mock_user.NewMockdber(ctrl)
		l    *log.Logger
		s    = user.New(c.New(l, db, true), l, db, true)
		ts   = httptest.NewServer(s)
		ctrl   = gomock.NewController(t)
		db     = user.NewMockdber(ctrl)
		l      *log.Logger
		stripe = user.NewMockStriper(ctrl)
		s      = user.New(c.New(l, db, true), l, db, true, stripe)
		ts     = httptest.NewServer(s)

		uname = uuid.New().String()
		upass = uuid.New().String()


@@ 163,11 171,12 @@ func TestNoSignups(t *testing.T) {
	t.Parallel()

	var (
		ctrl = gomock.NewController(t)
		db   = mock_user.NewMockdber(ctrl)
		l    *log.Logger
		s    = user.New(c.New(l, db, true), l, db, false)
		ts   = httptest.NewServer(s)
		ctrl   = gomock.NewController(t)
		db     = user.NewMockdber(ctrl)
		l      *log.Logger
		stripe = user.NewMockStriper(ctrl)
		s      = user.New(c.New(l, db, true), l, db, false, stripe)
		ts     = httptest.NewServer(s)

		uname = uuid.New().String()
		upass = uuid.New().String()


@@ 176,6 185,7 @@ func TestNoSignups(t *testing.T) {
			"username": {uname},
			"password": {upass},
			"verify":   {upass},
			"tier":     {tier.Free.Name},
		}
	)



@@ 184,3 194,71 @@ func TestNoSignups(t *testing.T) {
	res, _ := http.PostForm(ts.URL+"/user/signup", form)
	assert.Equal(t, res.StatusCode, http.StatusForbidden)
}

func TestCreatePaid(t *testing.T) {
	t.Parallel()

	var (
		ctrl   = gomock.NewController(t)
		db     = user.NewMockdber(ctrl)
		l      *log.Logger
		stripe = user.NewMockStriper(ctrl)
		s      = user.New(c.New(l, db, true), l, db, true, stripe)
		ts     = httptest.NewServer(s)

		uname = uuid.New().String()
		upass = uuid.New().String()
		u     = FakeUser{uname, upass}

		form = url.Values{
			"username": {uname},
			"password": {upass},
			"verify":   {upass},
			"tier":     {tier.Business.Name},
		}
	)

	db.EXPECT().UserNew(uname, upass, upass).Return(u, nil).AnyTimes()
	stripe.EXPECT().StartCheckout(u, tier.Business).Return("SomeSessionID", "SomePublicKey", nil).AnyTimes()

	res, _ := http.PostForm(ts.URL+"/user/signup", form)
	assert.Equal(t, res.StatusCode, http.StatusOK)
}

func TestHomeNoUser(t *testing.T) {
	t.Parallel()

	var (
		ctrl   = gomock.NewController(t)
		db     = user.NewMockdber(ctrl)
		l      *log.Logger
		stripe = user.NewMockStriper(ctrl)
		s      = user.New(c.New(l, db, true), l, db, true, stripe)
		ts     = httptest.NewServer(s)

		uname = uuid.New().String()
		upass = uuid.New().String()
		u     = FakeUser{uname, upass}

		form = url.Values{
			"username": {uname},
			"password": {upass},
			"verify":   {upass},
			"tier":     {tier.Free.Name},
		}

		sl = FakeSpaceList{}
	)

	db.EXPECT().UserNew(uname, upass, upass).Return(u, nil).AnyTimes()
	db.EXPECT().UserGet(uname, upass).Return(u, nil).AnyTimes()
	db.EXPECT().SpacesPerUser(u, 0).Return(sl, nil).AnyTimes()

	res, _ := http.PostForm(ts.URL+"/user/signup", form)
	assert.Equal(t, res.StatusCode, http.StatusOK)

	req, _ := http.NewRequest("POST", ts.URL, nil)
	req.Header.Set("Accept", "text/html")
	res, _ = http.DefaultClient.Do(req)
	assert.Equal(t, res.StatusCode, http.StatusOK)
}

M internal/m/user/user.go => internal/m/user/user.go +1 -0
@@ 4,4 4,5 @@ type User interface {
	ID() string
	Name() string
	Token() string
	OrgID() string
}

M internal/s/db/db.go => internal/s/db/db.go +84 -240
@@ 3,6 3,7 @@ package db
import (
	"database/sql"
	"log"
	"sort"
	"strconv"
	"strings"



@@ 11,6 12,26 @@ import (
	_ "github.com/go-sql-driver/mysql"
)

//go:generate embed -pattern */* -id migrations

func newMigrationSlice(m map[string]string) [][]string {
	migrations := make([][]string, len(m))
	i := 0

	for key, val := range m {
		migrations[i] = []string{key, val}
		i++
	}

	sort.Slice(migrations, func(i, j int) bool {
		iKey := migrations[i][0]
		jKey := migrations[j][0]
		return strings.Compare(iKey, jKey) < 1
	})

	return migrations
}

const (
	// Default pagination amount. For use in LIMIT/OFFSET.
	perPage = 25


@@ 102,243 123,80 @@ func NewWithConn(log *log.Logger, sec securer, conn *sql.DB) (*DB, error) {
// migrate does our "migration" -migration in quotes as we just dummy
// attempt to create tables on every server startup and ignore "table already
// exists" errors.
func (db *DB) migrate() []error {
	var errors []error
	var err error

	var _ interface{}

	// user
	_, err = db.Exec(`
		CREATE TABLE cms_user (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) UNIQUE NOT NULL,
			HASH varchar(256) NOT NULL
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// space
	_, err = db.Exec(`
		CREATE TABLE cms_space (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) NOT NULL,
			DESCRIPTION varchar(256) NOT NULL
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// user to space
	_, err = db.Exec(`
		CREATE TABLE cms_user_to_space (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			USER_ID INTEGER NOT NULL,
			SPACE_ID INTEGER NOT NULL,
			FOREIGN KEY(USER_ID) REFERENCES cms_user(ID),
			FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// contenttype
	_, err = db.Exec(`
		CREATE TABLE cms_contenttype (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) NOT NULL,
			SPACE_ID INTEGER NOT NULL,
			CONSTRAINT FG FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID) ON DELETE CASCADE,
			CONSTRAINT UNIQUEPERCONN UNIQUE(SPACE_ID, NAME)
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// valuetype
	// This will never be created by users.
	_, err = db.Exec(`
		CREATE TABLE cms_valuetype (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE varchar(256) UNIQUE NOT NULL
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// contenttype to valuetype
	// TODO: Make name + contenttype_id unique.
	_, err = db.Exec(`
		CREATE TABLE cms_contenttype_to_valuetype (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) NOT NULL,
			CONTENTTYPE_ID INTEGER NOT NULL,
			VALUETYPE_ID INTEGER NOT NULL,
			FOREIGN KEY(CONTENTTYPE_ID) REFERENCES cms_contenttype(ID) ON DELETE CASCADE,
			FOREIGN KEY(VALUETYPE_ID) REFERENCES cms_valuetype(ID)
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// content
	_, err = db.Exec(`
		CREATE TABLE cms_content (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			CONTENTTYPE_ID INTEGER NOT NULL,
			FOREIGN KEY(CONTENTTYPE_ID) REFERENCES cms_contenttype(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// content_to_value
	_, err = db.Exec(`
		CREATE TABLE cms_value (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			CONTENT_ID INTEGER NOT NULL,
			CONTENTTYPE_TO_VALUETYPE_ID INTEGER NOT NULL, 
			VALUE_ID INTEGER NOT NULL, -- Should be a foreign key but impossible to make it for two+ tables.
			FOREIGN KEY(CONTENT_ID) REFERENCES cms_content(ID) ON DELETE CASCADE,
			FOREIGN KEY(CONTENTTYPE_TO_VALUETYPE_ID) REFERENCES cms_contenttype_to_valuetype(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// value StringSmall, File
	_, err = db.Exec(`
		CREATE TABLE cms_value_string_small ( 
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE VARCHAR(256) NOT NULL
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// value StringBig, InputHTML, InputMarkdown
	_, err = db.Exec(`
		CREATE TABLE cms_value_string_big (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE TEXT NOT NULL
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// value Date
	_, err = db.Exec(`
		CREATE TABLE cms_value_date (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE DATE NOT NULL
		);
	`)

	// TODO: Reconsider these ON DELETE CASCADES after this point.

	// value Reference
	_, err = db.Exec(`
		CREATE TABLE cms_value_reference (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE INTEGER NOT NULL,
			FOREIGN KEY(VALUE) REFERENCES cms_content(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// value ReferenceList
	_, err = db.Exec(`
		CREATE TABLE cms_value_reference_list (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// augment to ReferenceList
	_, err = db.Exec(`
		CREATE TABLE cms_value_reference_list_values (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE_ID INTEGER NOT NULL,
			CONTENT_ID INTEGER NOT NULL,
			FOREIGN KEY(VALUE_ID) REFERENCES cms_value_reference_list(ID) ON DELETE CASCADE,
			FOREIGN KEY(CONTENT_ID) REFERENCES cms_content(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// Webhook
	_, err = db.Exec(`
		CREATE TABLE cms_hooks (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			URL varchar(256) NOT NULL,
			SPACE_ID INTEGER NOT NULL,
			FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID) ON DELETE CASCADE,
			CONSTRAINT UNIQUEPERCONN UNIQUE(SPACE_ID, URL)
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}
func (db *DB) migrate() error {
	var (
		err error
		_   interface{}
	)

	for _, migrationSet := range newMigrationSlice(migrations) {
		key := migrationSet[0]
		m := migrationSet[1]

		var count int
		if err := db.QueryRow("SELECT COUNT(*) FROM cms_migrate WHERE NAME=?", key).Scan(&count); err != nil {
			// Catch first error of DB setup.
			if !strings.Contains(err.Error(), "cms_migrate' doesn't exist") {
				return err
			}
		}

	// Only valuetypes cms supports.
	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringSmall)
	if err != nil {
		errors = append(errors, err)
	}
		if count > 0 {
			continue
		}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringBig)
	if err != nil {
		errors = append(errors, err)
	}
		t, err := db.Begin()
		if err != nil {
			return err
		}
		defer t.Rollback()

		for _, q := range strings.Split(m, ";") {
			q = strings.TrimSpace(q)
			if q == "" {
				continue
			}
			if _, err := t.Exec(q); err != nil {
				return err
			}
		}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.InputHTML)
	if err != nil {
		errors = append(errors, err)
	}
		if err := t.Commit(); err != nil {
			return err
		}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.InputMarkdown)
	if err != nil {
		errors = append(errors, err)
		if _, err := db.Exec("INSERT INTO cms_migrate (NAME) VALUES (?)", key); err != nil {
			return err
		}
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.File)
	if err != nil {
		errors = append(errors, err)
	vtypes := []valuetype.ValueTypeEnum{
		valuetype.StringSmall,
		valuetype.StringBig,
		valuetype.InputHTML,
		valuetype.InputMarkdown,
		valuetype.File,
		valuetype.Date,
		valuetype.Reference,
		valuetype.ReferenceList,
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.Date)
	if err != nil {
		errors = append(errors, err)
	}
	for _, vt := range vtypes {
		var count int
		if err := db.QueryRow("SELECT COUNT(*) FROM cms_valuetype WHERE VALUE=?", count).Scan(&count); err != nil {
			return err
		}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.Reference)
	if err != nil {
		errors = append(errors, err)
	}
		if count > 0 {
			continue
		}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.ReferenceList)
	if err != nil {
		errors = append(errors, err)
		if _, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, vt); err != nil {
			return err
		}
	}

	return errors
	return nil
}

// Ensure we have the tables we require. If we receive an error other


@@ 347,21 205,7 @@ func (db *DB) migrate() []error {
// Mainly, we are runnin CREATE TABLE and some INSERT INTO of predefined
// value types.
func (db *DB) EnsureSetup() error {
	for _, err := range db.migrate() {
		errmsg := err.Error()

		if err != nil && strings.Contains(errmsg, "Table ") && strings.Contains(errmsg, "already exists") {
			continue
		}

		if err != nil && strings.Contains(errmsg, "Duplicate entry ") && strings.Contains(errmsg, "VALUE") {
			continue
		}

		return err
	}

	return nil
	return db.migrate()
}

// FileExists makes sure SOME space and content owns the file. I.E. deleted

A internal/s/db/migrations_embed.go => internal/s/db/migrations_embed.go +33 -0
@@ 0,0 1,33 @@
// Code generated by "embed -pattern */* -id migrations"; DO NOT EDIT.

package db

import "encoding/base64"

var migrations map[string]string

func tostring(in string) string {
	bytes, _ := base64.StdEncoding.DecodeString(in)
	return string(bytes)
}

func init() {
	migrations = make(map[string]string)

	migrations["sql/00001.sql"] = tostring("Q1JFQVRFIFRBQkxFIGNtc19taWdyYXRlICgKCUlEIElOVEVHRVIgUFJJTUFSWSBLRVkgQVVUT19JTkNSRU1FTlQsCglOQU1FIHZhcmNoYXIoMjU2KSBVTklRVUUgTk9UIE5VTEwsCiAgREFURSBUSU1FU1RBTVAgTk9UIE5VTEwgREVGQVVMVCBDVVJSRU5UX1RJTUVTVEFNUAopOwoKQ1JFQVRFIFRBQkxFIGNtc191c2VyICgKCUlEIElOVEVHRVIgUFJJTUFSWSBLRVkgQVVUT19JTkNSRU1FTlQsCglOQU1FIHZhcmNoYXIoMjU2KSBVTklRVUUgTk9UIE5VTEwsCglIQVNIIHZhcmNoYXIoMjU2KSBOT1QgTlVMTAopOwoKQ1JFQVRFIFRBQkxFIGNtc19zcGFjZSAoCglJRCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9fSU5DUkVNRU5ULAoJTkFNRSB2YXJjaGFyKDI1NikgTk9UIE5VTEwsCglERVNDUklQVElPTiB2YXJjaGFyKDI1NikgTk9UIE5VTEwKKTsKCkNSRUFURSBUQUJMRSBjbXNfdXNlcl90b19zcGFjZSAoCglJRCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9fSU5DUkVNRU5ULAoJVVNFUl9JRCBJTlRFR0VSIE5PVCBOVUxMLAoJU1BBQ0VfSUQgSU5URUdFUiBOT1QgTlVMTCwKCUZPUkVJR04gS0VZKFVTRVJfSUQpIFJFRkVSRU5DRVMgY21zX3VzZXIoSUQpLAoJRk9SRUlHTiBLRVkoU1BBQ0VfSUQpIFJFRkVSRU5DRVMgY21zX3NwYWNlKElEKSBPTiBERUxFVEUgQ0FTQ0FERQopOwoKQ1JFQVRFIFRBQkxFIGNtc19jb250ZW50dHlwZSAoCglJRCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9fSU5DUkVNRU5ULAoJTkFNRSB2YXJjaGFyKDI1NikgTk9UIE5VTEwsCglTUEFDRV9JRCBJTlRFR0VSIE5PVCBOVUxMLAoJQ09OU1RSQUlOVCBGRyBGT1JFSUdOIEtFWShTUEFDRV9JRCkgUkVGRVJFTkNFUyBjbXNfc3BhY2UoSUQpIE9OIERFTEVURSBDQVNDQURFLAoJQ09OU1RSQUlOVCBVTklRVUVQRVJDT05OIFVOSVFVRShTUEFDRV9JRCwgTkFNRSkKKTsKCkNSRUFURSBUQUJMRSBjbXNfdmFsdWV0eXBlICgKCUlEIElOVEVHRVIgUFJJTUFSWSBLRVkgQVVUT19JTkNSRU1FTlQsCglWQUxVRSB2YXJjaGFyKDI1NikgVU5JUVVFIE5PVCBOVUxMCik7CgpDUkVBVEUgVEFCTEUgY21zX2NvbnRlbnR0eXBlX3RvX3ZhbHVldHlwZSAoCglJRCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9fSU5DUkVNRU5ULAoJTkFNRSB2YXJjaGFyKDI1NikgTk9UIE5VTEwsCglDT05URU5UVFlQRV9JRCBJTlRFR0VSIE5PVCBOVUxMLAoJVkFMVUVUWVBFX0lEIElOVEVHRVIgTk9UIE5VTEwsCglGT1JFSUdOIEtFWShDT05URU5UVFlQRV9JRCkgUkVGRVJFTkNFUyBjbXNfY29udGVudHR5cGUoSUQpIE9OIERFTEVURSBDQVNDQURFLAoJRk9SRUlHTiBLRVkoVkFMVUVUWVBFX0lEKSBSRUZFUkVOQ0VTIGNtc192YWx1ZXR5cGUoSUQpCik7CgpDUkVBVEUgVEFCTEUgY21zX2NvbnRlbnQgKAoJSUQgSU5URUdFUiBQUklNQVJZIEtFWSBBVVRPX0lOQ1JFTUVOVCwKCUNPTlRFTlRUWVBFX0lEIElOVEVHRVIgTk9UIE5VTEwsCglGT1JFSUdOIEtFWShDT05URU5UVFlQRV9JRCkgUkVGRVJFTkNFUyBjbXNfY29udGVudHR5cGUoSUQpIE9OIERFTEVURSBDQVNDQURFCik7CgpDUkVBVEUgVEFCTEUgY21zX3ZhbHVlICgKCUlEIElOVEVHRVIgUFJJTUFSWSBLRVkgQVVUT19JTkNSRU1FTlQsCglDT05URU5UX0lEIElOVEVHRVIgTk9UIE5VTEwsCglDT05URU5UVFlQRV9UT19WQUxVRVRZUEVfSUQgSU5URUdFUiBOT1QgTlVMTCwgCglWQUxVRV9JRCBJTlRFR0VSIE5PVCBOVUxMLCAtLSBTaG91bGQgYmUgYSBmb3JlaWduIGtleSBidXQgaW1wb3NzaWJsZSB0byBtYWtlIGl0IGZvciB0d28rIHRhYmxlcy4KCUZPUkVJR04gS0VZKENPTlRFTlRfSUQpIFJFRkVSRU5DRVMgY21zX2NvbnRlbnQoSUQpIE9OIERFTEVURSBDQVNDQURFLAoJRk9SRUlHTiBLRVkoQ09OVEVOVFRZUEVfVE9fVkFMVUVUWVBFX0lEKSBSRUZFUkVOQ0VTIGNtc19jb250ZW50dHlwZV90b192YWx1ZXR5cGUoSUQpIE9OIERFTEVURSBDQVNDQURFCik7CgpDUkVBVEUgVEFCTEUgY21zX3ZhbHVlX3N0cmluZ19zbWFsbCAoIAoJSUQgSU5URUdFUiBQUklNQVJZIEtFWSBBVVRPX0lOQ1JFTUVOVCwKCVZBTFVFIFZBUkNIQVIoMjU2KSBOT1QgTlVMTAopOwoKQ1JFQVRFIFRBQkxFIGNtc192YWx1ZV9zdHJpbmdfYmlnICgKCUlEIElOVEVHRVIgUFJJTUFSWSBLRVkgQVVUT19JTkNSRU1FTlQsCglWQUxVRSBURVhUIE5PVCBOVUxMCik7CgpDUkVBVEUgVEFCTEUgY21zX3ZhbHVlX2RhdGUgKAoJSUQgSU5URUdFUiBQUklNQVJZIEtFWSBBVVRPX0lOQ1JFTUVOVCwKCVZBTFVFIERBVEUgTk9UIE5VTEwKKTsKCi0tIFRPRE86IFJlY29uc2lkZXIgdGhlc2UgT04gREVMRVRFIENBU0NBREVTIGFmdGVyIHRoaXMgcG9pbnQuCgpDUkVBVEUgVEFCTEUgY21zX3ZhbHVlX3JlZmVyZW5jZSAoCglJRCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9fSU5DUkVNRU5ULAoJVkFMVUUgSU5URUdFUiBOT1QgTlVMTCwKCUZPUkVJR04gS0VZKFZBTFVFKSBSRUZFUkVOQ0VTIGNtc19jb250ZW50KElEKSBPTiBERUxFVEUgQ0FTQ0FERQopOwoKQ1JFQVRFIFRBQkxFIGNtc192YWx1ZV9yZWZlcmVuY2VfbGlzdCAoCglJRCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9fSU5DUkVNRU5UCik7CgpDUkVBVEUgVEFCTEUgY21zX3ZhbHVlX3JlZmVyZW5jZV9saXN0X3ZhbHVlcyAoCglJRCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9fSU5DUkVNRU5ULAoJVkFMVUVfSUQgSU5URUdFUiBOT1QgTlVMTCwKCUNPTlRFTlRfSUQgSU5URUdFUiBOT1QgTlVMTCwKCUZPUkVJR04gS0VZKFZBTFVFX0lEKSBSRUZFUkVOQ0VTIGNtc192YWx1ZV9yZWZlcmVuY2VfbGlzdChJRCkgT04gREVMRVRFIENBU0NBREUsCglGT1JFSUdOIEtFWShDT05URU5UX0lEKSBSRUZFUkVOQ0VTIGNtc19jb250ZW50KElEKSBPTiBERUxFVEUgQ0FTQ0FERQopOwoKQ1JFQVRFIFRBQkxFIGNtc19ob29rcyAoCglJRCBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9fSU5DUkVNRU5ULAoJVVJMIHZhcmNoYXIoMjU2KSBOT1QgTlVMTCwKCVNQQUNFX0lEIElOVEVHRVIgTk9UIE5VTEwsCglGT1JFSUdOIEtFWShTUEFDRV9JRCkgUkVGRVJFTkNFUyBjbXNfc3BhY2UoSUQpIE9OIERFTEVURSBDQVNDQURFLAoJQ09OU1RSQUlOVCBVTklRVUVQRVJDT05OIFVOSVFVRShTUEFDRV9JRCwgVVJMKQopOwo=")

	migrations["sql/00002.sql"] = tostring("U0VMRUNUIDE7Cg==")

	migrations["sql/00003.sql"] = tostring("Q1JFQVRFIFRBQkxFIGNtc19vcmcgKAoJSUQgSU5URUdFUiBQUklNQVJZIEtFWSBBVVRPX0lOQ1JFTUVOVCwKCUJPR1VTX1VTRVJfSUQgSU5URUdFUiBOT1QgTlVMTCwKCUNPTlNUUkFJTlQgQk9HVVNfVVNFUl9JRF9GSyBGT1JFSUdOIEtFWShCT0dVU19VU0VSX0lEKSBSRUZFUkVOQ0VTIGNtc191c2VyKElEKSBPTiBERUxFVEUgQ0FTQ0FERQopOwoKLS0gRml4IHVzZXIgdG8gb3JnLgoKQUxURVIgVEFCTEUgY21zX3VzZXIgQUREIE9SR19JRCBJTlRFR0VSIE5PVCBOVUxMOwoKLS0gTk9URTogQXQgdGhpcyBwb2ludCBpbiB0aW1lIHdlIGhhdmVuJ3Qgc3VwcG9ydGVkIHVzZXI8LT5zcGFjZS4KCklOU0VSVCBJTlRPIGNtc19vcmcgKEJPR1VTX1VTRVJfSUQpClNFTEVDVCBjbXNfdXNlci5JRCBGUk9NIGNtc191c2VyOwoKVVBEQVRFIGNtc191c2VyIApKT0lOIGNtc19vcmcgT04gY21zX3VzZXIuSUQ9Y21zX29yZy5CT0dVU19VU0VSX0lEClNFVCBjbXNfdXNlci5PUkdfSUQ9Y21zX29yZy5JRDsKCkFMVEVSIFRBQkxFIGNtc19vcmcgRFJPUCBGT1JFSUdOIEtFWSBCT0dVU19VU0VSX0lEX0ZLOwpBTFRFUiBUQUJMRSBjbXNfb3JnIERST1AgQ09MVU1OIEJPR1VTX1VTRVJfSUQ7CgpBTFRFUiBUQUJMRSBjbXNfdXNlciBBREQgQ09OU1RSQUlOVCBDTVNfVVNFUl9PUkdfSURfRksgRk9SRUlHTiBLRVkoT1JHX0lEKSBSRUZFUkVOQ0VTIGNtc19vcmcoSUQpOwoKLS0gRml4IHNwYWNlIHRvIG9yZy4KCkFMVEVSIFRBQkxFIGNtc19zcGFjZSBBREQgT1JHX0lEIElOVEVHRVIgTk9UIE5VTEw7CgpVUERBVEUgY21zX3NwYWNlCkpPSU4gY21zX3VzZXJfdG9fc3BhY2UgT04gU1BBQ0VfSUQ9Y21zX3NwYWNlLklEIApKT0lOIGNtc191c2VyIE9OIFVTRVJfSUQ9Y21zX3VzZXIuSUQKSk9JTiBjbXNfb3JnIE9OIGNtc191c2VyLk9SR19JRD1jbXNfb3JnLklEClNFVCBjbXNfc3BhY2UuT1JHX0lEPWNtc19vcmcuSUQ7CgpBTFRFUiBUQUJMRSBjbXNfc3BhY2UgQUREIENPTlNUUkFJTlQgQ01TX1NQQUNFX09SR19JRF9GSyBGT1JFSUdOIEtFWShPUkdfSUQpIFJFRkVSRU5DRVMgY21zX29yZyhJRCk7CgotLSBEcm9wIGV4dHJhcy4KCkRST1AgVEFCTEUgY21zX3VzZXJfdG9fc3BhY2U7Cg==")

}

func Get(name string) (string, bool) {
	val, ok := migrations[name]
	return val, ok
}

func Must(name string) string {
	val, _ := migrations[name]
	return val
}

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

import (
	"errors"

	"git.sr.ht/~evanj/cms/internal/m/tier"
	"git.sr.ht/~evanj/cms/internal/m/user"
)

func (db *DB) OrgGiveTier(u user.User, t tier.Tier) error {
	return errors.New("no complete")
}

M internal/s/db/space.go => internal/s/db/space.go +24 -25
@@ 21,17 21,23 @@ type Space struct {
}

var (
	queryCreateNewSpace       = `INSERT INTO cms_space (NAME, DESCRIPTION) VALUES (?, ?);`
	queryUpdateSpace          = `UPDATE cms_space SET NAME = ?, DESCRIPTION = ? WHERE ID = ?;`
	queryDeleteSpace          = `DELETE cms_user_to_space, cms_space FROM cms_space JOIN cms_user_to_space ON cms_user_to_space.SPACE_ID = cms_space.ID WHERE SPACE_ID = ? AND USER_ID = ?;`
	queryCreateNewUserToSpace = `INSERT INTO cms_user_to_space (USER_ID, SPACE_ID) VALUES (?, ?);`
	queryFindSpaceByUserAndID = `SELECT cms_space.ID, cms_space.NAME, cms_space.DESCRIPTION FROM cms_space JOIN cms_user_to_space ON SPACE_ID=cms_space.ID WHERE USER_ID=? AND SPACE_ID=?;`

	copyUsersQuery = `
		INSERT INTO cms_user_to_space (USER_ID, SPACE_ID)
		SELECT USER_ID, ?
		FROM cms_user_to_space
		WHERE SPACE_ID=?
	queryCreateNewSpace = `INSERT INTO cms_space (NAME, DESCRIPTION, ORG_ID) VALUES (?, ?, ?);`
	queryUpdateSpace    = `UPDATE cms_space SET NAME = ?, DESCRIPTION = ? WHERE ID = ?;`

	queryDeleteSpace = `
		DELETE cms_space FROM cms_space
		JOIN cms_org ON cms_org.ID=cms_space.ORG_ID
		JOIN cms_user ON cms_user.ORG_ID=cms_org.ID
		WHERE cms_user.ID=? 
		AND cms_space.ID=?
	`

	queryFindSpaceByUserAndID = `
		SELECT cms_space.ID, cms_space.NAME, cms_space.DESCRIPTION FROM cms_space
		JOIN cms_org ON cms_org.ID=cms_space.ORG_ID
		JOIN cms_user ON cms_user.ORG_ID=cms_org.ID
		WHERE cms_user.ID=? 
		AND cms_space.ID=?
	`

	copyHooksQuery = `


@@ 57,7 63,7 @@ var (
)

func (db *DB) spaceNew(t *sql.Tx, user user.User, name, desc string) (space.Space, error) {
	res, err := t.Exec(queryCreateNewSpace, name, desc)
	res, err := t.Exec(queryCreateNewSpace, name, desc, user.OrgID())
	if err != nil {
		return nil, fmt.Errorf("space '%s' already exists", name)
	}


@@ 67,10 73,6 @@ func (db *DB) spaceNew(t *sql.Tx, user user.User, name, desc string) (space.Spac
		return nil, fmt.Errorf("failed to create space")
	}

	if _, err := t.Exec(queryCreateNewUserToSpace, user.ID(), id); err != nil {
		return nil, fmt.Errorf("failed to attach space to user")
	}

	var space Space
	if err := t.QueryRow(queryFindSpaceByUserAndID, user.ID(), id).Scan(&space.SpaceID, &space.SpaceName, &space.SpaceDesc); err != nil {
		return nil, fmt.Errorf("failed to find space created")


@@ 131,7 133,7 @@ func (db *DB) SpaceCopy(user user.User, prevS space.Space, name, desc string) (s
	}
	defer t.Rollback()

	res, err := t.Exec(queryCreateNewSpace, name, desc)
	res, err := t.Exec(queryCreateNewSpace, name, desc, user.OrgID())
	if err != nil {
		return nil, err
	}


@@ 141,11 143,6 @@ func (db *DB) SpaceCopy(user user.User, prevS space.Space, name, desc string) (s
		return nil, err
	}

	// Copy all users.
	if _, err := t.Exec(copyUsersQuery, nextID, prevS.ID()); err != nil {
		return nil, err
	}

	// Copy all webhooks.
	if _, err := t.Exec(copyHooksQuery, nextID, prevS.ID()); err != nil {
		return nil, err


@@ 393,9 390,11 @@ func (db *DB) spacesPerUser(t *sql.Tx, user user.User, before int) (space.SpaceL
	before = beformat(before)

	q := `
		SELECT SPACE_ID FROM cms_user_to_space
		WHERE USER_ID = ? AND SPACE_ID < ?
		ORDER BY SPACE_ID DESC LIMIT ?
		SELECT cms_space.ID FROM cms_space
		JOIN cms_org ON cms_org.ID=cms_space.ORG_ID
		JOIN cms_user on cms_user.ORG_ID=cms_org.ID
		WHERE cms_user.ID=? AND cms_space.ID<?
		ORDER BY cms_space.ID DESC LIMIT ?
	`

	rows, err := db.Query(q, user.ID(), before, perPage+1)

M internal/s/db/sql/00001.sql => internal/s/db/sql/00001.sql +105 -0
@@ 0,0 1,105 @@
CREATE TABLE cms_migrate (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	NAME varchar(256) UNIQUE NOT NULL,
  DATE TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE cms_user (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	NAME varchar(256) UNIQUE NOT NULL,
	HASH varchar(256) NOT NULL
);

CREATE TABLE cms_space (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	NAME varchar(256) NOT NULL,
	DESCRIPTION varchar(256) NOT NULL
);

CREATE TABLE cms_user_to_space (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	USER_ID INTEGER NOT NULL,
	SPACE_ID INTEGER NOT NULL,
	FOREIGN KEY(USER_ID) REFERENCES cms_user(ID),
	FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID) ON DELETE CASCADE
);

CREATE TABLE cms_contenttype (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	NAME varchar(256) NOT NULL,
	SPACE_ID INTEGER NOT NULL,
	CONSTRAINT FG FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID) ON DELETE CASCADE,
	CONSTRAINT UNIQUEPERCONN UNIQUE(SPACE_ID, NAME)
);

CREATE TABLE cms_valuetype (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	VALUE varchar(256) UNIQUE NOT NULL
);

CREATE TABLE cms_contenttype_to_valuetype (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	NAME varchar(256) NOT NULL,
	CONTENTTYPE_ID INTEGER NOT NULL,
	VALUETYPE_ID INTEGER NOT NULL,
	FOREIGN KEY(CONTENTTYPE_ID) REFERENCES cms_contenttype(ID) ON DELETE CASCADE,
	FOREIGN KEY(VALUETYPE_ID) REFERENCES cms_valuetype(ID)
);

CREATE TABLE cms_content (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	CONTENTTYPE_ID INTEGER NOT NULL,
	FOREIGN KEY(CONTENTTYPE_ID) REFERENCES cms_contenttype(ID) ON DELETE CASCADE
);

CREATE TABLE cms_value (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	CONTENT_ID INTEGER NOT NULL,
	CONTENTTYPE_TO_VALUETYPE_ID INTEGER NOT NULL, 
	VALUE_ID INTEGER NOT NULL, -- Should be a foreign key but impossible to make it for two+ tables.
	FOREIGN KEY(CONTENT_ID) REFERENCES cms_content(ID) ON DELETE CASCADE,
	FOREIGN KEY(CONTENTTYPE_TO_VALUETYPE_ID) REFERENCES cms_contenttype_to_valuetype(ID) ON DELETE CASCADE
);

CREATE TABLE cms_value_string_small ( 
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	VALUE VARCHAR(256) NOT NULL
);

CREATE TABLE cms_value_string_big (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	VALUE TEXT NOT NULL
);

CREATE TABLE cms_value_date (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	VALUE DATE NOT NULL
);

-- TODO: Reconsider these ON DELETE CASCADES after this point.

CREATE TABLE cms_value_reference (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	VALUE INTEGER NOT NULL,
	FOREIGN KEY(VALUE) REFERENCES cms_content(ID) ON DELETE CASCADE
);

CREATE TABLE cms_value_reference_list (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT
);

CREATE TABLE cms_value_reference_list_values (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	VALUE_ID INTEGER NOT NULL,
	CONTENT_ID INTEGER NOT NULL,
	FOREIGN KEY(VALUE_ID) REFERENCES cms_value_reference_list(ID) ON DELETE CASCADE,
	FOREIGN KEY(CONTENT_ID) REFERENCES cms_content(ID) ON DELETE CASCADE
);

CREATE TABLE cms_hooks (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	URL varchar(256) NOT NULL,
	SPACE_ID INTEGER NOT NULL,
	FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID) ON DELETE CASCADE,
	CONSTRAINT UNIQUEPERCONN UNIQUE(SPACE_ID, URL)
);

A internal/s/db/sql/00002.sql => internal/s/db/sql/00002.sql +1 -0
@@ 0,0 1,1 @@
SELECT 1;

A internal/s/db/sql/00003.sql => internal/s/db/sql/00003.sql +39 -0
@@ 0,0 1,39 @@
CREATE TABLE cms_org (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
	BOGUS_USER_ID INTEGER NOT NULL,
	CONSTRAINT BOGUS_USER_ID_FK FOREIGN KEY(BOGUS_USER_ID) REFERENCES cms_user(ID) ON DELETE CASCADE
);

-- Fix user to org.

ALTER TABLE cms_user ADD ORG_ID INTEGER NOT NULL;

-- NOTE: At this point in time we haven't supported user<->space.

INSERT INTO cms_org (BOGUS_USER_ID)
SELECT cms_user.ID FROM cms_user;

UPDATE cms_user 
JOIN cms_org ON cms_user.ID=cms_org.BOGUS_USER_ID
SET cms_user.ORG_ID=cms_org.ID;

ALTER TABLE cms_org DROP FOREIGN KEY BOGUS_USER_ID_FK;
ALTER TABLE cms_org DROP COLUMN BOGUS_USER_ID;

ALTER TABLE cms_user ADD CONSTRAINT CMS_USER_ORG_ID_FK FOREIGN KEY(ORG_ID) REFERENCES cms_org(ID);

-- Fix space to org.

ALTER TABLE cms_space ADD ORG_ID INTEGER NOT NULL;

UPDATE cms_space
JOIN cms_user_to_space ON SPACE_ID=cms_space.ID 
JOIN cms_user ON USER_ID=cms_user.ID
JOIN cms_org ON cms_user.ORG_ID=cms_org.ID
SET cms_space.ORG_ID=cms_org.ID;

ALTER TABLE cms_space ADD CONSTRAINT CMS_SPACE_ORG_ID_FK FOREIGN KEY(ORG_ID) REFERENCES cms_org(ID);

-- Drop extras.

DROP TABLE cms_user_to_space;

M internal/s/db/user.go => internal/s/db/user.go +26 -11
@@ 9,9 9,10 @@ import (

type User struct {
	// Stored in DB.
	UserID   string
	UserName string
	userHash string
	UserID    string
	UserName  string
	userHash  string
	userOrgID string
	// Set on read.
	userToken string
}


@@ 19,9 20,10 @@ type User struct {
// SQL QUERIES

var (
	queryCreateNewUser  = `INSERT INTO cms_user (NAME, HASH) VALUES (?, ?);`
	queryFindUserByID   = `SELECT ID, NAME, HASH FROM cms_user WHERE ID = ?;`
	queryFindUserByName = `SELECT ID, NAME, HASH FROM cms_user WHERE NAME = ?;`
	queryCreateNewOrg   = `INSERT INTO cms_org () VALUES ();`
	queryCreateNewUser  = `INSERT INTO cms_user (NAME, HASH, ORG_ID) VALUES (?, ?, ?);`
	queryFindUserByID   = `SELECT ID, NAME, HASH, ORG_ID FROM cms_user WHERE ID = ?;`
	queryFindUserByName = `SELECT ID, NAME, HASH, ORG_ID FROM cms_user WHERE NAME = ?;`
)

func (db *DB) UserNew(username, password, verifyPassword string) (user.User, error) {


@@ 35,9 37,18 @@ func (db *DB) UserNew(username, password, verifyPassword string) (user.User, err
		return nil, fmt.Errorf("failed to create password hash")
	}

	res, err := db.Exec(queryCreateNewUser, username, hash)
	res, err := db.Exec(queryCreateNewOrg)
	if err != nil {
		return nil, err // Fat chance.
	}

	orgID, err := res.LastInsertId()
	if err != nil {
		return nil, err // Fat chance.
	}

	res, err = db.Exec(queryCreateNewUser, username, hash, orgID)
	if err != nil {
		db.log.Println(err)
		return nil, fmt.Errorf("user '%s' already exists", username)
	}



@@ 48,7 59,7 @@ func (db *DB) UserNew(username, password, verifyPassword string) (user.User, err
	}

	var user User
	if err := db.QueryRow(queryFindUserByID, id).Scan(&user.UserID, &user.UserName, &user.userHash); err != nil {
	if err := db.QueryRow(queryFindUserByID, id).Scan(&user.UserID, &user.UserName, &user.userHash, &user.userOrgID); err != nil {
		db.log.Println(err)
		return nil, fmt.Errorf("failed to find user created")
	}


@@ 65,7 76,7 @@ func (db *DB) UserNew(username, password, verifyPassword string) (user.User, err

func (db *DB) UserGet(username, password string) (user.User, error) {
	var user User
	if err := db.QueryRow(queryFindUserByName, username).Scan(&user.UserID, &user.UserName, &user.userHash); err != nil {
	if err := db.QueryRow(queryFindUserByName, username).Scan(&user.UserID, &user.UserName, &user.userHash, &user.userOrgID); err != nil {
		db.log.Println(err)
		return nil, fmt.Errorf("failed to find user '%s'", username)
	}


@@ 98,7 109,7 @@ func (db *DB) UserGetFromToken(token string) (user.User, error) {
	}

	var user User
	if err := db.QueryRow(queryFindUserByID, id).Scan(&user.UserID, &user.UserName, &user.userHash); err != nil {
	if err := db.QueryRow(queryFindUserByID, id).Scan(&user.UserID, &user.UserName, &user.userHash, &user.userOrgID); err != nil {
		db.log.Println(err)
		return nil, fmt.Errorf("failed to find user")
	}


@@ 124,3 135,7 @@ func (u *User) Name() string {
func (u *User) Token() string {
	return u.userToken
}

func (u *User) OrgID() string {
	return u.userOrgID
}

M internal/s/stripe/stripe.go => internal/s/stripe/stripe.go +51 -35
@@ 1,41 1,39 @@
package stripe

import (
	"fmt"

	"git.sr.ht/~evanj/cms/internal/m/tier"
	"git.sr.ht/~evanj/cms/internal/m/user"
	"github.com/pkg/errors"
	lib "github.com/stripe/stripe-go/v71"
	"github.com/stripe/stripe-go/v71/checkout/session"
	"github.com/stripe/stripe-go/v71/customer"
)

const (
	KeyUserToken = "StripeKeyCMSUserID"
)

type Stripe struct {
	sucesssURL, cancelURL string
	pk, sk                string
	db                    DBer
}

func New(sucesssURL, cancelURL, pk, sk string) Stripe {
type DBer interface {
	UserGetFromToken(token string) (user.User, error)
	OrgGiveTier(user.User, tier.Tier) error
}

func New(sucesssURL, cancelURL, pk, sk string, db DBer) Stripe {
	// Stripe sucks.
	lib.Key = sk
	return Stripe{
		sucesssURL, cancelURL,
		pk, sk,
		db,
	}
}

func (s Stripe) StartCheckout(user user.User, t tier.Tier) (string, string, error) {
	customerParams := &lib.CustomerParams{
		Params: lib.Params{
			Metadata: map[string]string{
				KeyUserToken: user.Token(),
			},
		},
		Name: lib.String(user.Name()),
		Name:        lib.String(user.Name()),
		Description: lib.String(user.Token()),
	}

	c, err := customer.New(customerParams)


@@ 55,7 53,7 @@ func (s Stripe) StartCheckout(user user.User, t tier.Tier) (string, string, erro
			},
		},
		Mode:       lib.String("subscription"),
		SuccessURL: lib.String(fmt.Sprintf("%s?session_id={CHECKOUT_SESSION_ID}", s.sucesssURL)),
		SuccessURL: lib.String(s.sucesssURL),
		CancelURL:  lib.String(s.cancelURL),
	}



@@ 67,24 65,42 @@ func (s Stripe) StartCheckout(user user.User, t tier.Tier) (string, string, erro
	return sess.ID, s.pk, nil
}

// func (s Stripe) CompleteCheckout(sessionID string) (tier.Tier, error) {
// 	sess, _ := session.Get(sessionID, nil)
// 	userID, ok := sess.Metadata[KeyUserToken]
// 	if !ok {
// 		return tier.Tier{}, errors.New("no user associated with transaction")
// 	}
//
// 	i := session.ListLineItems(sessionID, nil)
// 	for i.Next() {
// 		li := i.LineItem()
// 		if li.Price != nil {
// 			t, ok := tier.ByStripePriceID(li.Price.ID)
// 			if !ok {
// 				return tier.Tier{}, errors.New("invalid stripe response: checkout not complete")
// 			}
// 			return t, nil
// 		}
// 	}
//
// 	return tier.Tier{}, errors.New("tier subscription could not be found")
// }
func (s Stripe) CompleteCheckout(sessionID string) error {
	sess, err := session.Get(sessionID, &lib.CheckoutSessionParams{
		PaymentIntentData: &lib.CheckoutSessionPaymentIntentDataParams{},
	})
	if err != nil {
		return errors.Wrap(err, "transaction session not found")
	}

	c, err := customer.Get(sess.Customer.ID, nil)
	if err != nil {
		return errors.Wrap(err, "customer not found")
	}

	// NOTE: Only one subscription can exist at once per user/org.
	var (
		t   tier.Tier
		def tier.Tier
		ok  bool
	)
	for _, sub := range c.Subscriptions.Data {
		for _, li := range sub.Items.Data {
			t, ok = tier.ByStripePriceID(li.Price.ID)
			if !ok {
				return errors.New("mangled subscription item")
			}
		}
	}

	if t.Name == def.Name {
		return errors.New("subscription could not be found")
	}

	user, err := s.db.UserGetFromToken(c.Description)
	if err != nil {
		return errors.Wrap(err, "mangled user token")
	}

	return s.db.OrgGiveTier(user, t)
}

D internal/s/tmpl/tmpl.go => internal/s/tmpl/tmpl.go +0 -28
@@ 1,28 0,0 @@
package tmpl

import (
	"html/template"
	"strings"
)

//go:generate embed -pattern */* -id tmpls

var all *template.Template

func MustParse(name string) *template.Template {
	if all == nil {

		fns := template.FuncMap{
			"inc":   func(i int) int { return i + 1 },
			"title": func(str string) string { return strings.Title(str) },
		}

		all = template.New("cms")
		for key, val := range tmpls {
			all = template.Must(all.New(key).Funcs(fns).Parse(val))
		}

	}

	return all.Lookup(name)
}

D internal/s/tmpl/tmpl_test.go => internal/s/tmpl/tmpl_test.go +0 -1
@@ 1,1 0,0 @@
package tmpl_test

R internal/s/tmpl/css/bootstrap.css => internal/v/css/bootstrap.css +0 -0
R internal/s/tmpl/css/main.css => internal/v/css/main.css +0 -0
R internal/s/tmpl/css/mvp.css => internal/v/css/mvp.css +0 -0
R internal/s/tmpl/html/_footer.html => internal/v/html/_footer.html +0 -0
R internal/s/tmpl/html/_head.html => internal/v/html/_head.html +0 -0
R internal/s/tmpl/html/_header.html => internal/v/html/_header.html +0 -0
R internal/s/tmpl/html/_scripts.html => internal/v/html/_scripts.html +0 -0
R internal/s/tmpl/html/billing.html => internal/v/html/billing.html +0 -0
R internal/s/tmpl/html/contact.html => internal/v/html/contact.html +0 -0
R internal/s/tmpl/html/content.html => internal/v/html/content.html +0 -0
R internal/s/tmpl/html/contenttype.html => internal/v/html/contenttype.html +0 -0
R internal/s/tmpl/html/doc.html => internal/v/html/doc.html +0 -0
R internal/s/tmpl/html/faq.html => internal/v/html/faq.html +0 -0
R internal/s/tmpl/html/hook.html => internal/v/html/hook.html +0 -0
R internal/s/tmpl/html/index.html => internal/v/html/index.html +0 -0
R internal/s/tmpl/html/privacy.html => internal/v/html/privacy.html +0 -0
R internal/s/tmpl/html/redirect.html => internal/v/html/redirect.html +0 -0
R internal/s/tmpl/html/space.html => internal/v/html/space.html +0 -0
R internal/s/tmpl/html/stripe.html => internal/v/html/stripe.html +0 -0
R internal/s/tmpl/html/terms.html => internal/v/html/terms.html +0 -0
R internal/s/tmpl/js/bootstrap.js => internal/v/js/bootstrap.js +0 -0
R internal/s/tmpl/js/content.js => internal/v/js/content.js +0 -0
R internal/s/tmpl/js/main.js => internal/v/js/main.js +0 -0
R internal/s/tmpl/js/popper.js => internal/v/js/popper.js +0 -0
R internal/s/tmpl/js/space.js => internal/v/js/space.js +0 -0
R internal/s/tmpl/tmpls_embed.go => internal/v/tmpls_embed.go +1 -1
@@ 1,6 1,6 @@
// Code generated by "embed -pattern */* -id tmpls"; DO NOT EDIT.

package tmpl
package v

import "encoding/base64"


M internal/v/v.go => internal/v/v.go +27 -0
@@ 1,1 1,28 @@
package v

import (
	"html/template"
	"strings"
)

//go:generate embed -pattern */* -id tmpls

var all *template.Template

func MustParse(name string) *template.Template {
	if all == nil {

		fns := template.FuncMap{
			"inc":   func(i int) int { return i + 1 },
			"title": func(str string) string { return strings.Title(str) },
		}

		all = template.New("cms")
		for key, val := range tmpls {
			all = template.Must(all.New(key).Funcs(fns).Parse(val))
		}

	}

	return all.Lookup(name)
}

M makefile => makefile +1 -1
@@ 37,4 37,4 @@ run: gen build
	@env $(ENV) ./$(BIN)

dev:
	@find * -not -name '*_embed.go' | grep -E '*.(go|js|css|html)' | entr -r make run
	@find * -not -name '*_embed.go' | grep -E '*.(sql|go|js|css|html)' | entr -r make run

D vendor/github.com/stripe/stripe-go/v71/webhook/client.go => vendor/github.com/stripe/stripe-go/v71/webhook/client.go +0 -228
@@ 1,228 0,0 @@
package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"

	"github.com/stripe/stripe-go/v71"
)

//
// Public constants
//

const (
	// DefaultTolerance indicates that signatures older than this will be rejected by ConstructEvent.
	DefaultTolerance time.Duration = 300 * time.Second
	// signingVersion represents the version of the signature we currently use.
	signingVersion string = "v1"
)

//
// Public variables
//

// This block represents the list of errors that could be raised when using the webhook package.
var (
	ErrInvalidHeader    = errors.New("webhook has invalid Stripe-Signature header")
	ErrNoValidSignature = errors.New("webhook had no valid signature")
	ErrNotSigned        = errors.New("webhook has no Stripe-Signature header")
	ErrTooOld           = errors.New("timestamp wasn't within tolerance")
)

//
// Public functions
//

// ComputeSignature computes a webhook signature using Stripe's v1 signing
// method.
//
// See https://stripe.com/docs/webhooks#signatures for more information.
func ComputeSignature(t time.Time, payload []byte, secret string) []byte {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(fmt.Sprintf("%d", t.Unix())))
	mac.Write([]byte("."))
	mac.Write(payload)
	return mac.Sum(nil)
}

// ConstructEvent initializes an Event object from a JSON webhook payload, validating
// the Stripe-Signature header using the specified signing secret. Returns an error
// if the body or Stripe-Signature header provided are unreadable, if the
// signature doesn't match, or if the timestamp for the signature is older than
// DefaultTolerance.
//
// NOTE: Stripe will only send Webhook signing headers after you have retrieved
// your signing secret from the Stripe dashboard:
// https://dashboard.stripe.com/webhooks
//
func ConstructEvent(payload []byte, header string, secret string) (stripe.Event, error) {
	return ConstructEventWithTolerance(payload, header, secret, DefaultTolerance)
}

// ConstructEventIgnoringTolerance initializes an Event object from a JSON webhook
// payload, validating the Stripe-Signature header using the specified signing secret.
// Returns an error if the body or Stripe-Signature header provided are unreadable or
// if the signature doesn't match. Does not check the signature's timestamp.
//
// NOTE: Stripe will only send Webhook signing headers after you have retrieved
// your signing secret from the Stripe dashboard:
// https://dashboard.stripe.com/webhooks
//
func ConstructEventIgnoringTolerance(payload []byte, header string, secret string) (stripe.Event, error) {
	return constructEvent(payload, header, secret, 0*time.Second, false)
}

// ConstructEventWithTolerance initializes an Event object from a JSON webhook payload,
// validating the signature in the Stripe-Signature header using the specified signing
// secret and tolerance window. Returns an error if the body or Stripe-Signature header
// provided are unreadable, if the signature doesn't match, or if the timestamp
// for the signature is older than the specified tolerance.
//
// NOTE: Stripe will only send Webhook signing headers after you have retrieved
// your signing secret from the Stripe dashboard:
// https://dashboard.stripe.com/webhooks
//
func ConstructEventWithTolerance(payload []byte, header string, secret string, tolerance time.Duration) (stripe.Event, error) {
	return constructEvent(payload, header, secret, tolerance, true)
}

// ValidatePayload validates the payload against the Stripe-Signature header
// using the specified signing secret. Returns an error if the body or
// Stripe-Signature header provided are unreadable, if the signature doesn't
// match, or if the timestamp for the signature is older than DefaultTolerance.
//
// NOTE: Stripe will only send Webhook signing headers after you have retrieved
// your signing secret from the Stripe dashboard:
// https://dashboard.stripe.com/webhooks
//
func ValidatePayload(payload []byte, header string, secret string) error {
	return ValidatePayloadWithTolerance(payload, header, secret, DefaultTolerance)
}

// ValidatePayloadIgnoringTolerance validates the payload against the Stripe-Signature header
// header using the specified signing secret. Returns an error if the body or
// Stripe-Signature header provided are unreadable or if the signature doesn't match.
// Does not check the signature's timestamp.
//
// NOTE: Stripe will only send Webhook signing headers after you have retrieved
// your signing secret from the Stripe dashboard:
// https://dashboard.stripe.com/webhooks
//
func ValidatePayloadIgnoringTolerance(payload []byte, header string, secret string) error {
	return validatePayload(payload, header, secret, 0*time.Second, false)
}

// ValidatePayloadWithTolerance validates the payload against the Stripe-Signature header
// using the specified signing secret and tolerance window. Returns an error if the body
// or Stripe-Signature header provided are unreadable, if the signature doesn't match, or
// if the timestamp for the signature is older than the specified tolerance.
//
// NOTE: Stripe will only send Webhook signing headers after you have retrieved
// your signing secret from the Stripe dashboard:
// https://dashboard.stripe.com/webhooks
//
func ValidatePayloadWithTolerance(payload []byte, header string, secret string, tolerance time.Duration) error {
	return validatePayload(payload, header, secret, tolerance, true)
}

//
// Private types
//

type signedHeader struct {
	timestamp  time.Time
	signatures [][]byte
}

//
// Private functions
//

func constructEvent(payload []byte, sigHeader string, secret string, tolerance time.Duration, enforceTolerance bool) (stripe.Event, error) {
	e := stripe.Event{}

	if err := validatePayload(payload, sigHeader, secret, tolerance, enforceTolerance); err != nil {
		return e, err
	}

	if err := json.Unmarshal(payload, &e); err != nil {
		return e, fmt.Errorf("Failed to parse webhook body json: %s", err.Error())
	}

	return e, nil

}

func parseSignatureHeader(header string) (*signedHeader, error) {
	sh := &signedHeader{}

	if header == "" {
		return sh, ErrNotSigned
	}

	// Signed header looks like "t=1495999758,v1=ABC,v1=DEF,v0=GHI"
	pairs := strings.Split(header, ",")
	for _, pair := range pairs {
		parts := strings.Split(pair, "=")
		if len(parts) != 2 {
			return sh, ErrInvalidHeader
		}

		switch parts[0] {
		case "t":
			timestamp, err := strconv.ParseInt(parts[1], 10, 64)
			if err != nil {
				return sh, ErrInvalidHeader
			}
			sh.timestamp = time.Unix(timestamp, 0)

		case signingVersion:
			sig, err := hex.DecodeString(parts[1])
			if err != nil {
				continue // Ignore invalid signatures
			}

			sh.signatures = append(sh.signatures, sig)

		default:
			continue // Ignore unknown parts of the header
		}
	}

	if len(sh.signatures) == 0 {
		return sh, ErrNoValidSignature
	}

	return sh, nil
}

func validatePayload(payload []byte, sigHeader string, secret string, tolerance time.Duration, enforceTolerance bool) error {

	header, err := parseSignatureHeader(sigHeader)
	if err != nil {
		return err
	}

	expectedSignature := ComputeSignature(header.timestamp, payload, secret)
	expiredTimestamp := time.Since(header.timestamp) > tolerance
	if enforceTolerance && expiredTimestamp {
		return ErrTooOld
	}

	// Check all given v1 signatures, multiple signatures will be sent temporarily in the case of a rolled signature secret
	for _, sig := range header.signatures {
		if hmac.Equal(expectedSignature, sig) {
			return nil
		}
	}

	return ErrNoValidSignature
}

M vendor/modules.txt => vendor/modules.txt +0 -1
@@ 38,7 38,6 @@ github.com/stripe/stripe-go/v71/checkout/session
github.com/stripe/stripe-go/v71/customer
github.com/stripe/stripe-go/v71/form
github.com/stripe/stripe-go/v71/lineitem
github.com/stripe/stripe-go/v71/webhook
# golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d
golang.org/x/crypto/bcrypt
golang.org/x/crypto/blowfish