~evanj/cms

f1e528608a86af0e12340ac10d5037ce532c2d8f — Evan M Jones 9 months ago 201c77b
Feat(stripe/billing): cms_billing table complete. NEXT: Allow users to
adjust billing/cancel.
M TODO => TODO +6 -7
@@ 2,15 2,14 @@ Testing: 100% happy path and 80% total
Documentation
Cache lists
Official Go API
Depth option on APIs
No zero length varchar
Pay Goatounter
Pay logo: mybrandnewlogo.com
Doc pages: Contact, FAQ, Terms, Privacy 
Restrict file uploads for free users
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.
Restrict API requests for free users
Payment cancel/update settings
Depth option on APIs
No zero length varchar
Optional memcached
When editing existing references don't blow away prev inputs

M internal/c/c.go => internal/c/c.go +2 -1
@@ 21,7 21,8 @@ type KeyCookie = string
var (
	KeyUserLogin KeyCookie = "KeyUserLogin"

	ErrNoLogin = errors.New("must be logged in")
	ErrNoLogin  = errors.New("must be logged in")
	ErrNoAccess = errors.New("you don't have access to that feature")
)

type Controller struct {

M internal/c/content/content.go => internal/c/content/content.go +14 -8
@@ 15,6 15,7 @@ import (
	"git.sr.ht/~evanj/cms/internal/m/content"
	"git.sr.ht/~evanj/cms/internal/m/contenttype"
	"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/m/valuetype"
	"git.sr.ht/~evanj/cms/internal/s/db"


@@ 25,10 26,11 @@ import (
var (
	contentHTML = v.MustParse("html/content.html")

	ErrNoLogin = c.ErrNoLogin
	ErrNoSpace = errors.New("failed to find required space")
	ErrNoCT    = errors.New("failed to find required contenttype")
	ErrNoC     = errors.New("failed to find desired content")
	ErrNoLogin  = c.ErrNoLogin
	ErrNoAccess = c.ErrNoAccess
	ErrNoSpace  = errors.New("failed to find required space")
	ErrNoCT     = errors.New("failed to find required contenttype")
	ErrNoC      = errors.New("failed to find desired content")
)

type Content struct {


@@ 71,7 73,11 @@ func New(c *c.Controller, log *log.Logger, db DBer, e3 E3er, hook Hooker, baseUR
	}
}

func (c *Content) upload(ctx context.Context, filename string, file io.Reader) (string, error) {
func (c *Content) upload(ctx context.Context, filename string, file io.Reader, u user.User) (string, error) {
	if u.Org().Tier().Is(tier.Free) {
		return "", fmt.Errorf("can't upload file: %w", ErrNoAccess)
	}

	raw, err := c.e3.Upload(ctx, false, filename, file)
	if err != nil {
		return "", err


@@ 165,7 171,7 @@ func (c *Content) create(w http.ResponseWriter, r *http.Request) {
				return
			}

			url, err := c.upload(r.Context(), header.Filename, file)
			url, err := c.upload(r.Context(), header.Filename, file, user)
			if err != nil {
				c.Error(w, r, http.StatusInternalServerError, "failed to upload file")
				return


@@ 248,7 254,7 @@ func (c *Content) update(w http.ResponseWriter, r *http.Request) {
	contenttypeID := r.FormValue("contenttype")
	contentID := r.FormValue("content")

	_, space, ct, content, err := c.tree(w, r, spaceID, contenttypeID, contentID)
	user, space, ct, content, err := c.tree(w, r, spaceID, contenttypeID, contentID)
	if err != nil {
		c.Error2(w, r, http.StatusBadRequest, err)
		return


@@ 319,7 325,7 @@ func (c *Content) update(w http.ResponseWriter, r *http.Request) {
				return
			}

			url, err := c.upload(r.Context(), header.Filename, file)
			url, err := c.upload(r.Context(), header.Filename, file, user)
			if err != nil {
				c.Error(w, r, http.StatusInternalServerError, "failed to upload file")
				return

M internal/c/contenttype/contenttype.go => internal/c/contenttype/contenttype.go +46 -25
@@ 12,7 12,9 @@ import (
	"git.sr.ht/~evanj/cms/internal/m/content"
	"git.sr.ht/~evanj/cms/internal/m/contenttype"
	"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/m/valuetype"
	"git.sr.ht/~evanj/cms/internal/s/db"
	"git.sr.ht/~evanj/cms/internal/v"
)


@@ 70,12 72,12 @@ func (c *ContentType) tree(w http.ResponseWriter, r *http.Request) (user.User, s
	return user, space, nil
}

func (c *ContentType) create(w http.ResponseWriter, r *http.Request) {
func (ct *ContentType) create(w http.ResponseWriter, r *http.Request) {
	name := r.FormValue("name")

	_, space, err := c.tree(w, r)
	user, space, err := ct.tree(w, r)
	if err != nil {
		c.Error2(w, r, http.StatusBadRequest, err)
		ct.Error2(w, r, http.StatusBadRequest, err)
		return
	}



@@ 92,10 94,17 @@ func (c *ContentType) create(w http.ResponseWriter, r *http.Request) {
		keyType := fmt.Sprintf("field_type_%d", e+1)
		valName := r.FormValue(keyName)
		valType := r.FormValue(keyType)

		if valName == "" || valType == "" {
			c.Error2(w, r, http.StatusBadRequest, ErrBadForm)
			ct.Error2(w, r, http.StatusBadRequest, ErrBadForm)
			return
		}

		if valType == valuetype.File && user.Org().Tier().Is(tier.Free) {
			ct.Error2(w, r, http.StatusBadRequest, fmt.Errorf("can't create value type of file: %w", c.ErrNoAccess))
			return
		}

		params = append(params, db.ContentTypeNewParam{
			Name: valName,
			Type: valType,


@@ 103,7 112,7 @@ func (c *ContentType) create(w http.ResponseWriter, r *http.Request) {
	}

	if len(params) < 1 {
		c.Error2(w, r, http.StatusBadRequest, ErrNoFields)
		ct.Error2(w, r, http.StatusBadRequest, ErrNoFields)
		return
	}



@@ 115,33 124,33 @@ func (c *ContentType) create(w http.ResponseWriter, r *http.Request) {
		}
	}
	if !hasName {
		c.Error2(w, r, http.StatusBadRequest, ErrNoNameField)
		ct.Error2(w, r, http.StatusBadRequest, ErrNoNameField)
		return
	}

	ct, err := c.db.ContentTypeNew(space, name, params)
	ctype, err := ct.db.ContentTypeNew(space, name, params)
	if err != nil {
		c.Error2(w, r, http.StatusInternalServerError, ErrFailedCreate)
		ct.Error2(w, r, http.StatusInternalServerError, ErrFailedCreate)
		return
	}

	url := fmt.Sprintf("/contenttype/%s/%s", space.ID(), ct.ID())
	c.Redirect(w, r, url)
	url := fmt.Sprintf("/contenttype/%s/%s", space.ID(), ctype.ID())
	ct.Redirect(w, r, url)
}

func (c *ContentType) update(w http.ResponseWriter, r *http.Request) {
func (ct *ContentType) update(w http.ResponseWriter, r *http.Request) {
	name := r.FormValue("name")
	ctID := r.FormValue("contenttype")

	_, space, err := c.tree(w, r)
	user, space, err := ct.tree(w, r)
	if err != nil {
		c.Error2(w, r, http.StatusBadRequest, err)
		ct.Error2(w, r, http.StatusBadRequest, err)
		return
	}

	old, err := c.db.ContentTypeGet(space, ctID)
	old, err := ct.db.ContentTypeGet(space, ctID)
	if err != nil {
		c.Error2(w, r, http.StatusInternalServerError, ErrNoCT)
		ct.Error2(w, r, http.StatusInternalServerError, ErrNoCT)
		return
	}



@@ 160,10 169,17 @@ func (c *ContentType) update(w http.ResponseWriter, r *http.Request) {
		keyType := fmt.Sprintf("field_type_%d", e+2)
		valName := r.FormValue(keyName)
		valType := r.FormValue(keyType)

		if valName == "" || valType == "" {
			c.Error2(w, r, http.StatusBadRequest, ErrBadForm)
			ct.Error2(w, r, http.StatusBadRequest, ErrBadForm)
			return
		}

		if valType == valuetype.File && user.Org().Tier().Is(tier.Free) {
			ct.Error2(w, r, http.StatusBadRequest, fmt.Errorf("can't create value type of file: %w", c.ErrNoAccess))
			return
		}

		newParams = append(newParams, db.ContentTypeNewParam{
			Name: valName,
			Type: valType,


@@ 186,7 202,12 @@ func (c *ContentType) update(w http.ResponseWriter, r *http.Request) {
		}

		if valName == "" || valType == "" || valID == "" {
			c.Error2(w, r, http.StatusBadRequest, ErrBadForm)
			ct.Error2(w, r, http.StatusBadRequest, ErrBadForm)
			return
		}

		if valType == valuetype.File && user.Org().Tier().Is(tier.Free) {
			ct.Error2(w, r, http.StatusBadRequest, fmt.Errorf("can't create value type of file: %w", c.ErrNoAccess))
			return
		}



@@ 198,7 219,7 @@ func (c *ContentType) update(w http.ResponseWriter, r *http.Request) {
	}

	if len(updateParams) < 1 {
		c.Error2(w, r, http.StatusBadRequest, ErrNoFields)
		ct.Error2(w, r, http.StatusBadRequest, ErrNoFields)
		return
	}



@@ 215,24 236,24 @@ func (c *ContentType) update(w http.ResponseWriter, r *http.Request) {
		}
	}
	if !hasName {
		c.Error2(w, r, http.StatusBadRequest, ErrNoNameField)
		ct.Error2(w, r, http.StatusBadRequest, ErrNoNameField)
		return
	}

	ct, err := c.db.ContentTypeGet(space, ctID)
	ctype, err := ct.db.ContentTypeGet(space, ctID)
	if err != nil {
		c.Error2(w, r, http.StatusInternalServerError, ErrNoCT)
		ct.Error2(w, r, http.StatusInternalServerError, ErrNoCT)
		return
	}

	ct, err = c.db.ContentTypeUpdate(space, ct, name, newParams, updateParams)
	ctype, err = ct.db.ContentTypeUpdate(space, ctype, name, newParams, updateParams)
	if err != nil {
		c.Error2(w, r, http.StatusInternalServerError, ErrFailedUpdate)
		ct.Error2(w, r, http.StatusInternalServerError, ErrFailedUpdate)
		return
	}

	url := fmt.Sprintf("/contenttype/%s/%s", space.ID(), ct.ID())
	c.Redirect(w, r, url)
	url := fmt.Sprintf("/contenttype/%s/%s", space.ID(), ctype.ID())
	ct.Redirect(w, r, url)
}

func (c *ContentType) serve(w http.ResponseWriter, r *http.Request) {

A internal/m/org/org.go => internal/m/org/org.go +8 -0
@@ 0,0 1,8 @@
package org

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

type Org interface {
	ID() string
	Tier() tier.Tier
}

A internal/m/org/org_test.go => internal/m/org/org_test.go +1 -0
@@ 0,0 1,1 @@
package org_test

M internal/m/user/user.go => internal/m/user/user.go +3 -1
@@ 1,8 1,10 @@
package user

import "git.sr.ht/~evanj/cms/internal/m/org"

type User interface {
	ID() string
	Name() string
	Token() string
	OrgID() string
	Org() org.Org
}

M internal/s/db/migrations_embed.go => internal/s/db/migrations_embed.go +2 -0
@@ 20,6 20,8 @@ func init() {

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

	migrations["sql/00004.sql"] = tostring("Q1JFQVRFIFRBQkxFIGNtc19iaWxsaW5nICgKCUlEIElOVEVHRVIgUFJJTUFSWSBLRVkgQVVUT19JTkNSRU1FTlQsCiAgUEFZTUVOVF9DVVNUT01FUiB2YXJjaGFyKDI1NikgTk9UIE5VTEwsCiAgVElFUl9OQU1FIHZhcmNoYXIoMjU2KSBOT1QgTlVMTCwKICBPUkdfSUQgSU5URUdFUiBOT1QgTlVMTCwKICBDT05TVFJBSU5UIENNU19CSUxMSU5HX1RPX09SR19GSyBGT1JFSUdOIEtFWShPUkdfSUQpIFJFRkVSRU5DRVMgY21zX29yZyhJRCkKKTsKCg==")

}

func Get(name string) (string, bool) {

M internal/s/db/org.go => internal/s/db/org.go +90 -3
@@ 1,12 1,99 @@
package db

import (
	"errors"
	"database/sql"

	"git.sr.ht/~evanj/cms/internal/m/org"
	"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("not complete")
type Org struct {
	OrgID              string
	OrgBillingTierName sql.NullString
}

func (o Org) ID() string { return o.OrgID }

func (o Org) Tier() tier.Tier {
	t, ok := tier.ByName(o.OrgBillingTierName.String)
	if !ok {
		return tier.Free
	}
	return t
}

var (
	queryOrgByID = `
		SELECT cms_org.ID, cms_billing.TIER_NAME FROM cms_org
		LEFT JOIN cms_billing ON cms_billing.ORG_ID=cms_org.ID
		WHERE cms_org.ID=?
	`

	queryOrgByUserAndID = `
		SELECT cms_org.ID, cms_billing.TIER_NAME FROM cms_org
		LEFT JOIN cms_billing ON cms_billing.ORG_ID=cms_org.ID
		JOIN cms_user ON cms_user.ORG_ID=cms_org.ID
		WHERE cms_user.ID=? AND cms_org.ID=?
	`
)

func (db *DB) OrgNew() (org.Org, error) {
	tx, err := db.Begin()
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()

	org, err := db.orgNew(tx)
	if err != nil {
		return nil, err
	}

	return org, tx.Commit()
}

func (db *DB) orgNew(t *sql.Tx) (org.Org, error) {
	res, err := t.Exec("INSERT INTO cms_org () VALUES ()")
	if err != nil {
		return nil, err
	}

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

	var org Org
	if err := t.QueryRow(queryOrgByID, orgID).Scan(&org.OrgID, &org.OrgBillingTierName); err != nil {
		return nil, err
	}

	return org, nil
}

func (db *DB) OrgGet(u user.User, orgID string) (org.Org, error) {
	var org Org
	if err := db.QueryRow(queryOrgByUserAndID, u.ID(), orgID).Scan(&org.OrgID, &org.OrgBillingTierName); err != nil {
		return nil, err
	}
	return org, nil
}

func (db *DB) OrgUpdateTier(u user.User, o org.Org, t tier.Tier, paymentCustomerID string) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	if _, err := tx.Exec("DELETE FROM cms_billing WHERE ORG_ID=?", o.ID()); err != nil {
		return err
	}

	if _, err := tx.Exec("INSERT INTO cms_billing (PAYMENT_CUSTOMER, TIER_NAME, ORG_ID) values(?, ?, ?)", paymentCustomerID, t.Name, o.ID()); err != nil {
		return err
	}

	return tx.Commit()
}

M internal/s/db/space.go => internal/s/db/space.go +2 -2
@@ 63,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, user.OrgID())
	res, err := t.Exec(queryCreateNewSpace, name, desc, user.Org().ID())
	if err != nil {
		return nil, fmt.Errorf("space '%s' already exists", name)
	}


@@ 133,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, user.OrgID())
	res, err := t.Exec(queryCreateNewSpace, name, desc, user.Org().ID())
	if err != nil {
		return nil, err
	}

A internal/s/db/sql/00004.sql => internal/s/db/sql/00004.sql +8 -0
@@ 0,0 1,8 @@
CREATE TABLE cms_billing (
	ID INTEGER PRIMARY KEY AUTO_INCREMENT,
  PAYMENT_CUSTOMER varchar(256) NOT NULL,
  TIER_NAME varchar(256) NOT NULL,
  ORG_ID INTEGER NOT NULL,
  CONSTRAINT CMS_BILLING_TO_ORG_FK FOREIGN KEY(ORG_ID) REFERENCES cms_org(ID)
);


M internal/s/db/user.go => internal/s/db/user.go +40 -27
@@ 1,8 1,10 @@
package db

import (
	"database/sql"
	"fmt"

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


@@ 15,18 17,33 @@ type User struct {
	userOrgID string
	// Set on read.
	userToken string
	userOrg   org.Org
}

// SQL QUERIES

var (
	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) {
	t, err := db.Begin()
	if err != nil {
		return nil, err
	}
	defer t.Rollback()

	user, err := db.userNew(t, username, password, verifyPassword)
	if err != nil {
		return nil, err
	}

	return user, t.Commit()
}

func (db *DB) userNew(t *sql.Tx, username, password, verifyPassword string) (user.User, error) {
	if password != verifyPassword {
		return nil, fmt.Errorf("passwords do not match")
	}


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

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

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

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

	id, err := res.LastInsertId()
	if err != nil {
		db.log.Println(err)
		return nil, fmt.Errorf("failed to create user")
	}

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

	tok, err := db.sec.TokenCreate(security.TokenMap{"ID": user.UserID})
	if err != nil {
		db.log.Println(err)
		return nil, fmt.Errorf("failed to create token for user")
	}

	user.userToken = tok
	user.userOrg = org
	return &user, nil
}



@@ 92,6 102,13 @@ func (db *DB) UserGet(username, password string) (user.User, error) {
	}

	user.userToken = tok

	org, err := db.OrgGet(&user, user.userOrgID)
	if err != nil {
		return nil, err
	}
	user.userOrg = org

	return &user, nil
}



@@ 121,21 138,17 @@ func (db *DB) UserGetFromToken(token string) (user.User, error) {
	}

	user.userToken = tok
	return &user, nil
}

func (u *User) ID() string {
	return u.UserID
}

func (u *User) Name() string {
	return u.UserName
}
	org, err := db.OrgGet(&user, user.userOrgID)
	if err != nil {
		return nil, err
	}
	user.userOrg = org

func (u *User) Token() string {
	return u.userToken
	return &user, nil
}

func (u *User) OrgID() string {
	return u.userOrgID
}
func (u *User) ID() string    { return u.UserID }
func (u *User) Name() string  { return u.UserName }
func (u *User) Token() string { return u.userToken }
func (u *User) Org() org.Org  { return u.userOrg }

M internal/s/stripe/stripe.go => internal/s/stripe/stripe.go +3 -2
@@ 1,6 1,7 @@
package stripe

import (
	"git.sr.ht/~evanj/cms/internal/m/org"
	"git.sr.ht/~evanj/cms/internal/m/tier"
	"git.sr.ht/~evanj/cms/internal/m/user"
	"github.com/pkg/errors"


@@ 17,7 18,7 @@ type Stripe struct {

type DBer interface {
	UserGetFromToken(token string) (user.User, error)
	OrgGiveTier(user.User, tier.Tier) error
	OrgUpdateTier(u user.User, o org.Org, t tier.Tier, paymentCustomerID string) error
}

func New(sucesssURL, cancelURL, pk, sk string, db DBer) Stripe {


@@ 102,5 103,5 @@ func (s Stripe) CompleteCheckout(sessionID string) error {
		return errors.Wrap(err, "mangled user token")
	}

	return s.db.OrgGiveTier(user, t)
	return s.db.OrgUpdateTier(user, user.Org(), t, c.ID)
}

M internal/v/js/space.js => internal/v/js/space.js +1 -1
@@ 18,7 18,7 @@
              <option value="StringBig">String Big</option>
              <option value="InputHTML">HTML</option>
              <option value="InputMarkdown">Markdown</option>
              <option value="File">File</option>
              <option {{if not (paid .User)}}disabled{{end}} value="File">File</option>
              <option value="Date">Date</option>
              <option value="Reference">Reference</option>
              <option value="ReferenceList">ReferenceList</option>

M internal/v/tmpls_embed.go => internal/v/tmpls_embed.go +1 -1
@@ 62,7 62,7 @@ func init() {

	tmpls["js/popper.js"] = tostring("LyoKIENvcHlyaWdodCAoQykgRmVkZXJpY28gWml2b2xvIDIwMTkKIERpc3RyaWJ1dGVkIHVuZGVyIHRoZSBNSVQgTGljZW5zZSAobGljZW5zZSB0ZXJtcyBhcmUgYXQgaHR0cDovL29wZW5zb3VyY2Uub3JnL2xpY2Vuc2VzL01JVCkuCiAqLyhmdW5jdGlvbihlLHQpeydvYmplY3QnPT10eXBlb2YgZXhwb3J0cyYmJ3VuZGVmaW5lZCchPXR5cGVvZiBtb2R1bGU/bW9kdWxlLmV4cG9ydHM9dCgpOidmdW5jdGlvbic9PXR5cGVvZiBkZWZpbmUmJmRlZmluZS5hbWQ/ZGVmaW5lKHQpOmUuUG9wcGVyPXQoKX0pKHRoaXMsZnVuY3Rpb24oKXsndXNlIHN0cmljdCc7ZnVuY3Rpb24gZShlKXtyZXR1cm4gZSYmJ1tvYmplY3QgRnVuY3Rpb25dJz09PXt9LnRvU3RyaW5nLmNhbGwoZSl9ZnVuY3Rpb24gdChlLHQpe2lmKDEhPT1lLm5vZGVUeXBlKXJldHVybltdO3ZhciBvPWUub3duZXJEb2N1bWVudC5kZWZhdWx0VmlldyxuPW8uZ2V0Q29tcHV0ZWRTdHlsZShlLG51bGwpO3JldHVybiB0P25bdF06bn1mdW5jdGlvbiBvKGUpe3JldHVybidIVE1MJz09PWUubm9kZU5hbWU/ZTplLnBhcmVudE5vZGV8fGUuaG9zdH1mdW5jdGlvbiBuKGUpe2lmKCFlKXJldHVybiBkb2N1bWVudC5ib2R5O3N3aXRjaChlLm5vZGVOYW1lKXtjYXNlJ0hUTUwnOmNhc2UnQk9EWSc6cmV0dXJuIGUub3duZXJEb2N1bWVudC5ib2R5O2Nhc2UnI2RvY3VtZW50JzpyZXR1cm4gZS5ib2R5O312YXIgaT10KGUpLHI9aS5vdmVyZmxvdyxwPWkub3ZlcmZsb3dYLHM9aS5vdmVyZmxvd1k7cmV0dXJuIC8oYXV0b3xzY3JvbGx8b3ZlcmxheSkvLnRlc3QocitzK3ApP2U6bihvKGUpKX1mdW5jdGlvbiBpKGUpe3JldHVybiBlJiZlLnJlZmVyZW5jZU5vZGU/ZS5yZWZlcmVuY2VOb2RlOmV9ZnVuY3Rpb24gcihlKXtyZXR1cm4gMTE9PT1lP3JlOjEwPT09ZT9wZTpyZXx8cGV9ZnVuY3Rpb24gcChlKXtpZighZSlyZXR1cm4gZG9jdW1lbnQuZG9jdW1lbnRFbGVtZW50O2Zvcih2YXIgbz1yKDEwKT9kb2N1bWVudC5ib2R5Om51bGwsbj1lLm9mZnNldFBhcmVudHx8bnVsbDtuPT09byYmZS5uZXh0RWxlbWVudFNpYmxpbmc7KW49KGU9ZS5uZXh0RWxlbWVudFNpYmxpbmcpLm9mZnNldFBhcmVudDt2YXIgaT1uJiZuLm5vZGVOYW1lO3JldHVybiBpJiYnQk9EWSchPT1pJiYnSFRNTCchPT1pPy0xIT09WydUSCcsJ1REJywnVEFCTEUnXS5pbmRleE9mKG4ubm9kZU5hbWUpJiYnc3RhdGljJz09PXQobiwncG9zaXRpb24nKT9wKG4pOm46ZT9lLm93bmVyRG9jdW1lbnQuZG9jdW1lbnRFbGVtZW50OmRvY3VtZW50LmRvY3VtZW50RWxlbWVudH1mdW5jdGlvbiBzKGUpe3ZhciB0PWUubm9kZU5hbWU7cmV0dXJuJ0JPRFknIT09dCYmKCdIVE1MJz09PXR8fHAoZS5maXJzdEVsZW1lbnRDaGlsZCk9PT1lKX1mdW5jdGlvbiBkKGUpe3JldHVybiBudWxsPT09ZS5wYXJlbnROb2RlP2U6ZChlLnBhcmVudE5vZGUpfWZ1bmN0aW9uIGEoZSx0KXtpZighZXx8IWUubm9kZVR5cGV8fCF0fHwhdC5ub2RlVHlwZSlyZXR1cm4gZG9jdW1lbnQuZG9jdW1lbnRFbGVtZW50O3ZhciBvPWUuY29tcGFyZURvY3VtZW50UG9zaXRpb24odCkmTm9kZS5ET0NVTUVOVF9QT1NJVElPTl9GT0xMT1dJTkcsbj1vP2U6dCxpPW8/dDplLHI9ZG9jdW1lbnQuY3JlYXRlUmFuZ2UoKTtyLnNldFN0YXJ0KG4sMCksci5zZXRFbmQoaSwwKTt2YXIgbD1yLmNvbW1vbkFuY2VzdG9yQ29udGFpbmVyO2lmKGUhPT1sJiZ0IT09bHx8bi5jb250YWlucyhpKSlyZXR1cm4gcyhsKT9sOnAobCk7dmFyIGY9ZChlKTtyZXR1cm4gZi5ob3N0P2EoZi5ob3N0LHQpOmEoZSxkKHQpLmhvc3QpfWZ1bmN0aW9uIGwoZSl7dmFyIHQ9MTxhcmd1bWVudHMubGVuZ3RoJiZ2b2lkIDAhPT1hcmd1bWVudHNbMV0/YXJndW1lbnRzWzFdOid0b3AnLG89J3RvcCc9PT10PydzY3JvbGxUb3AnOidzY3JvbGxMZWZ0JyxuPWUubm9kZU5hbWU7aWYoJ0JPRFknPT09bnx8J0hUTUwnPT09bil7dmFyIGk9ZS5vd25lckRvY3VtZW50LmRvY3VtZW50RWxlbWVudCxyPWUub3duZXJEb2N1bWVudC5zY3JvbGxpbmdFbGVtZW50fHxpO3JldHVybiByW29dfXJldHVybiBlW29dfWZ1bmN0aW9uIGYoZSx0KXt2YXIgbz0yPGFyZ3VtZW50cy5sZW5ndGgmJnZvaWQgMCE9PWFyZ3VtZW50c1syXSYmYXJndW1lbnRzWzJdLG49bCh0LCd0b3AnKSxpPWwodCwnbGVmdCcpLHI9bz8tMToxO3JldHVybiBlLnRvcCs9bipyLGUuYm90dG9tKz1uKnIsZS5sZWZ0Kz1pKnIsZS5yaWdodCs9aSpyLGV9ZnVuY3Rpb24gbShlLHQpe3ZhciBvPSd4Jz09PXQ/J0xlZnQnOidUb3AnLG49J0xlZnQnPT1vPydSaWdodCc6J0JvdHRvbSc7cmV0dXJuIHBhcnNlRmxvYXQoZVsnYm9yZGVyJytvKydXaWR0aCddLDEwKStwYXJzZUZsb2F0KGVbJ2JvcmRlcicrbisnV2lkdGgnXSwxMCl9ZnVuY3Rpb24gaChlLHQsbyxuKXtyZXR1cm4gZWUodFsnb2Zmc2V0JytlXSx0WydzY3JvbGwnK2VdLG9bJ2NsaWVudCcrZV0sb1snb2Zmc2V0JytlXSxvWydzY3JvbGwnK2VdLHIoMTApP3BhcnNlSW50KG9bJ29mZnNldCcrZV0pK3BhcnNlSW50KG5bJ21hcmdpbicrKCdIZWlnaHQnPT09ZT8nVG9wJzonTGVmdCcpXSkrcGFyc2VJbnQoblsnbWFyZ2luJysoJ0hlaWdodCc9PT1lPydCb3R0b20nOidSaWdodCcpXSk6MCl9ZnVuY3Rpb24gYyhlKXt2YXIgdD1lLmJvZHksbz1lLmRvY3VtZW50RWxlbWVudCxuPXIoMTApJiZnZXRDb21wdXRlZFN0eWxlKG8pO3JldHVybntoZWlnaHQ6aCgnSGVpZ2h0Jyx0LG8sbiksd2lkdGg6aCgnV2lkdGgnLHQsbyxuKX19ZnVuY3Rpb24gZyhlKXtyZXR1cm4gbGUoe30sZSx7cmlnaHQ6ZS5sZWZ0K2Uud2lkdGgsYm90dG9tOmUudG9wK2UuaGVpZ2h0fSl9ZnVuY3Rpb24gdShlKXt2YXIgbz17fTt0cnl7aWYocigxMCkpe289ZS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKTt2YXIgbj1sKGUsJ3RvcCcpLGk9bChlLCdsZWZ0Jyk7by50b3ArPW4sby5sZWZ0Kz1pLG8uYm90dG9tKz1uLG8ucmlnaHQrPWl9ZWxzZSBvPWUuZ2V0Qm91bmRpbmdDbGllbnRSZWN0KCl9Y2F0Y2godCl7fXZhciBwPXtsZWZ0Om8ubGVmdCx0b3A6by50b3Asd2lkdGg6by5yaWdodC1vLmxlZnQsaGVpZ2h0Om8uYm90dG9tLW8udG9wfSxzPSdIVE1MJz09PWUubm9kZU5hbWU/YyhlLm93bmVyRG9jdW1lbnQpOnt9LGQ9cy53aWR0aHx8ZS5jbGllbnRXaWR0aHx8cC53aWR0aCxhPXMuaGVpZ2h0fHxlLmNsaWVudEhlaWdodHx8cC5oZWlnaHQsZj1lLm9mZnNldFdpZHRoLWQsaD1lLm9mZnNldEhlaWdodC1hO2lmKGZ8fGgpe3ZhciB1PXQoZSk7Zi09bSh1LCd4JyksaC09bSh1LCd5JykscC53aWR0aC09ZixwLmhlaWdodC09aH1yZXR1cm4gZyhwKX1mdW5jdGlvbiBiKGUsbyl7dmFyIGk9Mjxhcmd1bWVudHMubGVuZ3RoJiZ2b2lkIDAhPT1hcmd1bWVudHNbMl0mJmFyZ3VtZW50c1syXSxwPXIoMTApLHM9J0hUTUwnPT09by5ub2RlTmFtZSxkPXUoZSksYT11KG8pLGw9bihlKSxtPXQobyksaD1wYXJzZUZsb2F0KG0uYm9yZGVyVG9wV2lkdGgsMTApLGM9cGFyc2VGbG9hdChtLmJvcmRlckxlZnRXaWR0aCwxMCk7aSYmcyYmKGEudG9wPWVlKGEudG9wLDApLGEubGVmdD1lZShhLmxlZnQsMCkpO3ZhciBiPWcoe3RvcDpkLnRvcC1hLnRvcC1oLGxlZnQ6ZC5sZWZ0LWEubGVmdC1jLHdpZHRoOmQud2lkdGgsaGVpZ2h0OmQuaGVpZ2h0fSk7aWYoYi5tYXJnaW5Ub3A9MCxiLm1hcmdpbkxlZnQ9MCwhcCYmcyl7dmFyIHc9cGFyc2VGbG9hdChtLm1hcmdpblRvcCwxMCkseT1wYXJzZUZsb2F0KG0ubWFyZ2luTGVmdCwxMCk7Yi50b3AtPWgtdyxiLmJvdHRvbS09aC13LGIubGVmdC09Yy15LGIucmlnaHQtPWMteSxiLm1hcmdpblRvcD13LGIubWFyZ2luTGVmdD15fXJldHVybihwJiYhaT9vLmNvbnRhaW5zKGwpOm89PT1sJiYnQk9EWSchPT1sLm5vZGVOYW1lKSYmKGI9ZihiLG8pKSxifWZ1bmN0aW9uIHcoZSl7dmFyIHQ9MTxhcmd1bWVudHMubGVuZ3RoJiZ2b2lkIDAhPT1hcmd1bWVudHNbMV0mJmFyZ3VtZW50c1sxXSxvPWUub3duZXJEb2N1bWVudC5kb2N1bWVudEVsZW1lbnQsbj1iKGUsbyksaT1lZShvLmNsaWVudFdpZHRoLHdpbmRvdy5pbm5lcldpZHRofHwwKSxyPWVlKG8uY2xpZW50SGVpZ2h0LHdpbmRvdy5pbm5lckhlaWdodHx8MCkscD10PzA6bChvKSxzPXQ/MDpsKG8sJ2xlZnQnKSxkPXt0b3A6cC1uLnRvcCtuLm1hcmdpblRvcCxsZWZ0OnMtbi5sZWZ0K24ubWFyZ2luTGVmdCx3aWR0aDppLGhlaWdodDpyfTtyZXR1cm4gZyhkKX1mdW5jdGlvbiB5KGUpe3ZhciBuPWUubm9kZU5hbWU7aWYoJ0JPRFknPT09bnx8J0hUTUwnPT09bilyZXR1cm4hMTtpZignZml4ZWQnPT09dChlLCdwb3NpdGlvbicpKXJldHVybiEwO3ZhciBpPW8oZSk7cmV0dXJuISFpJiZ5KGkpfWZ1bmN0aW9uIEUoZSl7aWYoIWV8fCFlLnBhcmVudEVsZW1lbnR8fHIoKSlyZXR1cm4gZG9jdW1lbnQuZG9jdW1lbnRFbGVtZW50O2Zvcih2YXIgbz1lLnBhcmVudEVsZW1lbnQ7byYmJ25vbmUnPT09dChvLCd0cmFuc2Zvcm0nKTspbz1vLnBhcmVudEVsZW1lbnQ7cmV0dXJuIG98fGRvY3VtZW50LmRvY3VtZW50RWxlbWVudH1mdW5jdGlvbiB2KGUsdCxyLHApe3ZhciBzPTQ8YXJndW1lbnRzLmxlbmd0aCYmdm9pZCAwIT09YXJndW1lbnRzWzRdJiZhcmd1bWVudHNbNF0sZD17dG9wOjAsbGVmdDowfSxsPXM/RShlKTphKGUsaSh0KSk7aWYoJ3ZpZXdwb3J0Jz09PXApZD13KGwscyk7ZWxzZXt2YXIgZjsnc2Nyb2xsUGFyZW50Jz09PXA/KGY9bihvKHQpKSwnQk9EWSc9PT1mLm5vZGVOYW1lJiYoZj1lLm93bmVyRG9jdW1lbnQuZG9jdW1lbnRFbGVtZW50KSk6J3dpbmRvdyc9PT1wP2Y9ZS5vd25lckRvY3VtZW50LmRvY3VtZW50RWxlbWVudDpmPXA7dmFyIG09YihmLGwscyk7aWYoJ0hUTUwnPT09Zi5ub2RlTmFtZSYmIXkobCkpe3ZhciBoPWMoZS5vd25lckRvY3VtZW50KSxnPWguaGVpZ2h0LHU9aC53aWR0aDtkLnRvcCs9bS50b3AtbS5tYXJnaW5Ub3AsZC5ib3R0b209ZyttLnRvcCxkLmxlZnQrPW0ubGVmdC1tLm1hcmdpbkxlZnQsZC5yaWdodD11K20ubGVmdH1lbHNlIGQ9bX1yPXJ8fDA7dmFyIHY9J251bWJlcic9PXR5cGVvZiByO3JldHVybiBkLmxlZnQrPXY/cjpyLmxlZnR8fDAsZC50b3ArPXY/cjpyLnRvcHx8MCxkLnJpZ2h0LT12P3I6ci5yaWdodHx8MCxkLmJvdHRvbS09dj9yOnIuYm90dG9tfHwwLGR9ZnVuY3Rpb24geChlKXt2YXIgdD1lLndpZHRoLG89ZS5oZWlnaHQ7cmV0dXJuIHQqb31mdW5jdGlvbiBPKGUsdCxvLG4saSl7dmFyIHI9NTxhcmd1bWVudHMubGVuZ3RoJiZ2b2lkIDAhPT1hcmd1bWVudHNbNV0/YXJndW1lbnRzWzVdOjA7aWYoLTE9PT1lLmluZGV4T2YoJ2F1dG8nKSlyZXR1cm4gZTt2YXIgcD12KG8sbixyLGkpLHM9e3RvcDp7d2lkdGg6cC53aWR0aCxoZWlnaHQ6dC50b3AtcC50b3B9LHJpZ2h0Ont3aWR0aDpwLnJpZ2h0LXQucmlnaHQsaGVpZ2h0OnAuaGVpZ2h0fSxib3R0b206e3dpZHRoOnAud2lkdGgsaGVpZ2h0OnAuYm90dG9tLXQuYm90dG9tfSxsZWZ0Ont3aWR0aDp0LmxlZnQtcC5sZWZ0LGhlaWdodDpwLmhlaWdodH19LGQ9T2JqZWN0LmtleXMocykubWFwKGZ1bmN0aW9uKGUpe3JldHVybiBsZSh7a2V5OmV9LHNbZV0se2FyZWE6eChzW2VdKX0pfSkuc29ydChmdW5jdGlvbihlLHQpe3JldHVybiB0LmFyZWEtZS5hcmVhfSksYT1kLmZpbHRlcihmdW5jdGlvbihlKXt2YXIgdD1lLndpZHRoLG49ZS5oZWlnaHQ7cmV0dXJuIHQ+PW8uY2xpZW50V2lkdGgmJm4+PW8uY2xpZW50SGVpZ2h0fSksbD0wPGEubGVuZ3RoP2FbMF0ua2V5OmRbMF0ua2V5LGY9ZS5zcGxpdCgnLScpWzFdO3JldHVybiBsKyhmPyctJytmOicnKX1mdW5jdGlvbiBMKGUsdCxvKXt2YXIgbj0zPGFyZ3VtZW50cy5sZW5ndGgmJnZvaWQgMCE9PWFyZ3VtZW50c1szXT9hcmd1bWVudHNbM106bnVsbCxyPW4/RSh0KTphKHQsaShvKSk7cmV0dXJuIGIobyxyLG4pfWZ1bmN0aW9uIFMoZSl7dmFyIHQ9ZS5vd25lckRvY3VtZW50LmRlZmF1bHRWaWV3LG89dC5nZXRDb21wdXRlZFN0eWxlKGUpLG49cGFyc2VGbG9hdChvLm1hcmdpblRvcHx8MCkrcGFyc2VGbG9hdChvLm1hcmdpbkJvdHRvbXx8MCksaT1wYXJzZUZsb2F0KG8ubWFyZ2luTGVmdHx8MCkrcGFyc2VGbG9hdChvLm1hcmdpblJpZ2h0fHwwKSxyPXt3aWR0aDplLm9mZnNldFdpZHRoK2ksaGVpZ2h0OmUub2Zmc2V0SGVpZ2h0K259O3JldHVybiByfWZ1bmN0aW9uIFQoZSl7dmFyIHQ9e2xlZnQ6J3JpZ2h0JyxyaWdodDonbGVmdCcsYm90dG9tOid0b3AnLHRvcDonYm90dG9tJ307cmV0dXJuIGUucmVwbGFjZSgvbGVmdHxyaWdodHxib3R0b218dG9wL2csZnVuY3Rpb24oZSl7cmV0dXJuIHRbZV19KX1mdW5jdGlvbiBDKGUsdCxvKXtvPW8uc3BsaXQoJy0nKVswXTt2YXIgbj1TKGUpLGk9e3dpZHRoOm4ud2lkdGgsaGVpZ2h0Om4uaGVpZ2h0fSxyPS0xIT09WydyaWdodCcsJ2xlZnQnXS5pbmRleE9mKG8pLHA9cj8ndG9wJzonbGVmdCcscz1yPydsZWZ0JzondG9wJyxkPXI/J2hlaWdodCc6J3dpZHRoJyxhPXI/J3dpZHRoJzonaGVpZ2h0JztyZXR1cm4gaVtwXT10W3BdK3RbZF0vMi1uW2RdLzIsaVtzXT1vPT09cz90W3NdLW5bYV06dFtUKHMpXSxpfWZ1bmN0aW9uIEQoZSx0KXtyZXR1cm4gQXJyYXkucHJvdG90eXBlLmZpbmQ/ZS5maW5kKHQpOmUuZmlsdGVyKHQpWzBdfWZ1bmN0aW9uIE4oZSx0LG8pe2lmKEFycmF5LnByb3RvdHlwZS5maW5kSW5kZXgpcmV0dXJuIGUuZmluZEluZGV4KGZ1bmN0aW9uKGUpe3JldHVybiBlW3RdPT09b30pO3ZhciBuPUQoZSxmdW5jdGlvbihlKXtyZXR1cm4gZVt0XT09PW99KTtyZXR1cm4gZS5pbmRleE9mKG4pfWZ1bmN0aW9uIFAodCxvLG4pe3ZhciBpPXZvaWQgMD09PW4/dDp0LnNsaWNlKDAsTih0LCduYW1lJyxuKSk7cmV0dXJuIGkuZm9yRWFjaChmdW5jdGlvbih0KXt0WydmdW5jdGlvbiddJiZjb25zb2xlLndhcm4oJ2Btb2RpZmllci5mdW5jdGlvbmAgaXMgZGVwcmVjYXRlZCwgdXNlIGBtb2RpZmllci5mbmAhJyk7dmFyIG49dFsnZnVuY3Rpb24nXXx8dC5mbjt0LmVuYWJsZWQmJmUobikmJihvLm9mZnNldHMucG9wcGVyPWcoby5vZmZzZXRzLnBvcHBlciksby5vZmZzZXRzLnJlZmVyZW5jZT1nKG8ub2Zmc2V0cy5yZWZlcmVuY2UpLG89bihvLHQpKX0pLG99ZnVuY3Rpb24gaygpe2lmKCF0aGlzLnN0YXRlLmlzRGVzdHJveWVkKXt2YXIgZT17aW5zdGFuY2U6dGhpcyxzdHlsZXM6e30sYXJyb3dTdHlsZXM6e30sYXR0cmlidXRlczp7fSxmbGlwcGVkOiExLG9mZnNldHM6e319O2Uub2Zmc2V0cy5yZWZlcmVuY2U9TCh0aGlzLnN0YXRlLHRoaXMucG9wcGVyLHRoaXMucmVmZXJlbmNlLHRoaXMub3B0aW9ucy5wb3NpdGlvbkZpeGVkKSxlLnBsYWNlbWVudD1PKHRoaXMub3B0aW9ucy5wbGFjZW1lbnQsZS5vZmZzZXRzLnJlZmVyZW5jZSx0aGlzLnBvcHBlcix0aGlzLnJlZmVyZW5jZSx0aGlzLm9wdGlvbnMubW9kaWZpZXJzLmZsaXAuYm91bmRhcmllc0VsZW1lbnQsdGhpcy5vcHRpb25zLm1vZGlmaWVycy5mbGlwLnBhZGRpbmcpLGUub3JpZ2luYWxQbGFjZW1lbnQ9ZS5wbGFjZW1lbnQsZS5wb3NpdGlvbkZpeGVkPXRoaXMub3B0aW9ucy5wb3NpdGlvbkZpeGVkLGUub2Zmc2V0cy5wb3BwZXI9Qyh0aGlzLnBvcHBlcixlLm9mZnNldHMucmVmZXJlbmNlLGUucGxhY2VtZW50KSxlLm9mZnNldHMucG9wcGVyLnBvc2l0aW9uPXRoaXMub3B0aW9ucy5wb3NpdGlvbkZpeGVkPydmaXhlZCc6J2Fic29sdXRlJyxlPVAodGhpcy5tb2RpZmllcnMsZSksdGhpcy5zdGF0ZS5pc0NyZWF0ZWQ/dGhpcy5vcHRpb25zLm9uVXBkYXRlKGUpOih0aGlzLnN0YXRlLmlzQ3JlYXRlZD0hMCx0aGlzLm9wdGlvbnMub25DcmVhdGUoZSkpfX1mdW5jdGlvbiBXKGUsdCl7cmV0dXJuIGUuc29tZShmdW5jdGlvbihlKXt2YXIgbz1lLm5hbWUsbj1lLmVuYWJsZWQ7cmV0dXJuIG4mJm89PT10fSl9ZnVuY3Rpb24gQihlKXtmb3IodmFyIHQ9WyExLCdtcycsJ1dlYmtpdCcsJ01veicsJ08nXSxvPWUuY2hhckF0KDApLnRvVXBwZXJDYXNlKCkrZS5zbGljZSgxKSxuPTA7bjx0Lmxlbmd0aDtuKyspe3ZhciBpPXRbbl0scj1pPycnK2krbzplO2lmKCd1bmRlZmluZWQnIT10eXBlb2YgZG9jdW1lbnQuYm9keS5zdHlsZVtyXSlyZXR1cm4gcn1yZXR1cm4gbnVsbH1mdW5jdGlvbiBIKCl7cmV0dXJuIHRoaXMuc3RhdGUuaXNEZXN0cm95ZWQ9ITAsVyh0aGlzLm1vZGlmaWVycywnYXBwbHlTdHlsZScpJiYodGhpcy5wb3BwZXIucmVtb3ZlQXR0cmlidXRlKCd4LXBsYWNlbWVudCcpLHRoaXMucG9wcGVyLnN0eWxlLnBvc2l0aW9uPScnLHRoaXMucG9wcGVyLnN0eWxlLnRvcD0nJyx0aGlzLnBvcHBlci5zdHlsZS5sZWZ0PScnLHRoaXMucG9wcGVyLnN0eWxlLnJpZ2h0PScnLHRoaXMucG9wcGVyLnN0eWxlLmJvdHRvbT0nJyx0aGlzLnBvcHBlci5zdHlsZS53aWxsQ2hhbmdlPScnLHRoaXMucG9wcGVyLnN0eWxlW0IoJ3RyYW5zZm9ybScpXT0nJyksdGhpcy5kaXNhYmxlRXZlbnRMaXN0ZW5lcnMoKSx0aGlzLm9wdGlvbnMucmVtb3ZlT25EZXN0cm95JiZ0aGlzLnBvcHBlci5wYXJlbnROb2RlLnJlbW92ZUNoaWxkKHRoaXMucG9wcGVyKSx0aGlzfWZ1bmN0aW9uIEEoZSl7dmFyIHQ9ZS5vd25lckRvY3VtZW50O3JldHVybiB0P3QuZGVmYXVsdFZpZXc6d2luZG93fWZ1bmN0aW9uIE0oZSx0LG8saSl7dmFyIHI9J0JPRFknPT09ZS5ub2RlTmFtZSxwPXI/ZS5vd25lckRvY3VtZW50LmRlZmF1bHRWaWV3OmU7cC5hZGRFdmVudExpc3RlbmVyKHQsbyx7cGFzc2l2ZTohMH0pLHJ8fE0obihwLnBhcmVudE5vZGUpLHQsbyxpKSxpLnB1c2gocCl9ZnVuY3Rpb24gRihlLHQsbyxpKXtvLnVwZGF0ZUJvdW5kPWksQShlKS5hZGRFdmVudExpc3RlbmVyKCdyZXNpemUnLG8udXBkYXRlQm91bmQse3Bhc3NpdmU6ITB9KTt2YXIgcj1uKGUpO3JldHVybiBNKHIsJ3Njcm9sbCcsby51cGRhdGVCb3VuZCxvLnNjcm9sbFBhcmVudHMpLG8uc2Nyb2xsRWxlbWVudD1yLG8uZXZlbnRzRW5hYmxlZD0hMCxvfWZ1bmN0aW9uIEkoKXt0aGlzLnN0YXRlLmV2ZW50c0VuYWJsZWR8fCh0aGlzLnN0YXRlPUYodGhpcy5yZWZlcmVuY2UsdGhpcy5vcHRpb25zLHRoaXMuc3RhdGUsdGhpcy5zY2hlZHVsZVVwZGF0ZSkpfWZ1bmN0aW9uIFIoZSx0KXtyZXR1cm4gQShlKS5yZW1vdmVFdmVudExpc3RlbmVyKCdyZXNpemUnLHQudXBkYXRlQm91bmQpLHQuc2Nyb2xsUGFyZW50cy5mb3JFYWNoKGZ1bmN0aW9uKGUpe2UucmVtb3ZlRXZlbnRMaXN0ZW5lcignc2Nyb2xsJyx0LnVwZGF0ZUJvdW5kKX0pLHQudXBkYXRlQm91bmQ9bnVsbCx0LnNjcm9sbFBhcmVudHM9W10sdC5zY3JvbGxFbGVtZW50PW51bGwsdC5ldmVudHNFbmFibGVkPSExLHR9ZnVuY3Rpb24gVSgpe3RoaXMuc3RhdGUuZXZlbnRzRW5hYmxlZCYmKGNhbmNlbEFuaW1hdGlvbkZyYW1lKHRoaXMuc2NoZWR1bGVVcGRhdGUpLHRoaXMuc3RhdGU9Uih0aGlzLnJlZmVyZW5jZSx0aGlzLnN0YXRlKSl9ZnVuY3Rpb24gWShlKXtyZXR1cm4nJyE9PWUmJiFpc05hTihwYXJzZUZsb2F0KGUpKSYmaXNGaW5pdGUoZSl9ZnVuY3Rpb24gVihlLHQpe09iamVjdC5rZXlzKHQpLmZvckVhY2goZnVuY3Rpb24obyl7dmFyIG49Jyc7LTEhPT1bJ3dpZHRoJywnaGVpZ2h0JywndG9wJywncmlnaHQnLCdib3R0b20nLCdsZWZ0J10uaW5kZXhPZihvKSYmWSh0W29dKSYmKG49J3B4JyksZS5zdHlsZVtvXT10W29dK259KX1mdW5jdGlvbiBqKGUsdCl7T2JqZWN0LmtleXModCkuZm9yRWFjaChmdW5jdGlvbihvKXt2YXIgbj10W29dOyExPT09bj9lLnJlbW92ZUF0dHJpYnV0ZShvKTplLnNldEF0dHJpYnV0ZShvLHRbb10pfSl9ZnVuY3Rpb24gcShlLHQpe3ZhciBvPWUub2Zmc2V0cyxuPW8ucG9wcGVyLGk9by5yZWZlcmVuY2Uscj0kLHA9ZnVuY3Rpb24oZSl7cmV0dXJuIGV9LHM9cihpLndpZHRoKSxkPXIobi53aWR0aCksYT0tMSE9PVsnbGVmdCcsJ3JpZ2h0J10uaW5kZXhPZihlLnBsYWNlbWVudCksbD0tMSE9PWUucGxhY2VtZW50LmluZGV4T2YoJy0nKSxmPXQ/YXx8bHx8cyUyPT1kJTI/cjpaOnAsbT10P3I6cDtyZXR1cm57bGVmdDpmKDE9PXMlMiYmMT09ZCUyJiYhbCYmdD9uLmxlZnQtMTpuLmxlZnQpLHRvcDptKG4udG9wKSxib3R0b206bShuLmJvdHRvbSkscmlnaHQ6ZihuLnJpZ2h0KX19ZnVuY3Rpb24gSyhlLHQsbyl7dmFyIG49RChlLGZ1bmN0aW9uKGUpe3ZhciBvPWUubmFtZTtyZXR1cm4gbz09PXR9KSxpPSEhbiYmZS5zb21lKGZ1bmN0aW9uKGUpe3JldHVybiBlLm5hbWU9PT1vJiZlLmVuYWJsZWQmJmUub3JkZXI8bi5vcmRlcn0pO2lmKCFpKXt2YXIgcj0nYCcrdCsnYCc7Y29uc29sZS53YXJuKCdgJytvKydgJysnIG1vZGlmaWVyIGlzIHJlcXVpcmVkIGJ5ICcrcisnIG1vZGlmaWVyIGluIG9yZGVyIHRvIHdvcmssIGJlIHN1cmUgdG8gaW5jbHVkZSBpdCBiZWZvcmUgJytyKychJyl9cmV0dXJuIGl9ZnVuY3Rpb24geihlKXtyZXR1cm4nZW5kJz09PWU/J3N0YXJ0Jzonc3RhcnQnPT09ZT8nZW5kJzplfWZ1bmN0aW9uIEcoZSl7dmFyIHQ9MTxhcmd1bWVudHMubGVuZ3RoJiZ2b2lkIDAhPT1hcmd1bWVudHNbMV0mJmFyZ3VtZW50c1sxXSxvPWhlLmluZGV4T2YoZSksbj1oZS5zbGljZShvKzEpLmNvbmNhdChoZS5zbGljZSgwLG8pKTtyZXR1cm4gdD9uLnJldmVyc2UoKTpufWZ1bmN0aW9uIF8oZSx0LG8sbil7dmFyIGk9ZS5tYXRjaCgvKCg/OlwtfFwrKT9cZCpcLj9cZCopKC4qKS8pLHI9K2lbMV0scD1pWzJdO2lmKCFyKXJldHVybiBlO2lmKDA9PT1wLmluZGV4T2YoJyUnKSl7dmFyIHM7c3dpdGNoKHApe2Nhc2UnJXAnOnM9bzticmVhaztjYXNlJyUnOmNhc2UnJXInOmRlZmF1bHQ6cz1uO312YXIgZD1nKHMpO3JldHVybiBkW3RdLzEwMCpyfWlmKCd2aCc9PT1wfHwndncnPT09cCl7dmFyIGE7cmV0dXJuIGE9J3ZoJz09PXA/ZWUoZG9jdW1lbnQuZG9jdW1lbnRFbGVtZW50LmNsaWVudEhlaWdodCx3aW5kb3cuaW5uZXJIZWlnaHR8fDApOmVlKGRvY3VtZW50LmRvY3VtZW50RWxlbWVudC5jbGllbnRXaWR0aCx3aW5kb3cuaW5uZXJXaWR0aHx8MCksYS8xMDAqcn1yZXR1cm4gcn1mdW5jdGlvbiBYKGUsdCxvLG4pe3ZhciBpPVswLDBdLHI9LTEhPT1bJ3JpZ2h0JywnbGVmdCddLmluZGV4T2YobikscD1lLnNwbGl0KC8oXCt8XC0pLykubWFwKGZ1bmN0aW9uKGUpe3JldHVybiBlLnRyaW0oKX0pLHM9cC5pbmRleE9mKEQocCxmdW5jdGlvbihlKXtyZXR1cm4tMSE9PWUuc2VhcmNoKC8sfFxzLyl9KSk7cFtzXSYmLTE9PT1wW3NdLmluZGV4T2YoJywnKSYmY29uc29sZS53YXJuKCdPZmZzZXRzIHNlcGFyYXRlZCBieSB3aGl0ZSBzcGFjZShzKSBhcmUgZGVwcmVjYXRlZCwgdXNlIGEgY29tbWEgKCwpIGluc3RlYWQuJyk7dmFyIGQ9L1xzKixccyp8XHMrLyxhPS0xPT09cz9bcF06W3Auc2xpY2UoMCxzKS5jb25jYXQoW3Bbc10uc3BsaXQoZClbMF1dKSxbcFtzXS5zcGxpdChkKVsxXV0uY29uY2F0KHAuc2xpY2UocysxKSldO3JldHVybiBhPWEubWFwKGZ1bmN0aW9uKGUsbil7dmFyIGk9KDE9PT1uPyFyOnIpPydoZWlnaHQnOid3aWR0aCcscD0hMTtyZXR1cm4gZS5yZWR1Y2UoZnVuY3Rpb24oZSx0KXtyZXR1cm4nJz09PWVbZS5sZW5ndGgtMV0mJi0xIT09WycrJywnLSddLmluZGV4T2YodCk/KGVbZS5sZW5ndGgtMV09dCxwPSEwLGUpOnA/KGVbZS5sZW5ndGgtMV0rPXQscD0hMSxlKTplLmNvbmNhdCh0KX0sW10pLm1hcChmdW5jdGlvbihlKXtyZXR1cm4gXyhlLGksdCxvKX0pfSksYS5mb3JFYWNoKGZ1bmN0aW9uKGUsdCl7ZS5mb3JFYWNoKGZ1bmN0aW9uKG8sbil7WShvKSYmKGlbdF0rPW8qKCctJz09PWVbbi0xXT8tMToxKSl9KX0pLGl9ZnVuY3Rpb24gSihlLHQpe3ZhciBvLG49dC5vZmZzZXQsaT1lLnBsYWNlbWVudCxyPWUub2Zmc2V0cyxwPXIucG9wcGVyLHM9ci5yZWZlcmVuY2UsZD1pLnNwbGl0KCctJylbMF07cmV0dXJuIG89WSgrbik/WytuLDBdOlgobixwLHMsZCksJ2xlZnQnPT09ZD8ocC50b3ArPW9bMF0scC5sZWZ0LT1vWzFdKToncmlnaHQnPT09ZD8ocC50b3ArPW9bMF0scC5sZWZ0Kz1vWzFdKTondG9wJz09PWQ/KHAubGVmdCs9b1swXSxwLnRvcC09b1sxXSk6J2JvdHRvbSc9PT1kJiYocC5sZWZ0Kz1vWzBdLHAudG9wKz1vWzFdKSxlLnBvcHBlcj1wLGV9dmFyIFE9TWF0aC5taW4sWj1NYXRoLmZsb29yLCQ9TWF0aC5yb3VuZCxlZT1NYXRoLm1heCx0ZT0ndW5kZWZpbmVkJyE9dHlwZW9mIHdpbmRvdyYmJ3VuZGVmaW5lZCchPXR5cGVvZiBkb2N1bWVudCYmJ3VuZGVmaW5lZCchPXR5cGVvZiBuYXZpZ2F0b3Isb2U9ZnVuY3Rpb24oKXtmb3IodmFyIGU9WydFZGdlJywnVHJpZGVudCcsJ0ZpcmVmb3gnXSx0PTA7dDxlLmxlbmd0aDt0Kz0xKWlmKHRlJiYwPD1uYXZpZ2F0b3IudXNlckFnZW50LmluZGV4T2YoZVt0XSkpcmV0dXJuIDE7cmV0dXJuIDB9KCksbmU9dGUmJndpbmRvdy5Qcm9taXNlLGllPW5lP2Z1bmN0aW9uKGUpe3ZhciB0PSExO3JldHVybiBmdW5jdGlvbigpe3R8fCh0PSEwLHdpbmRvdy5Qcm9taXNlLnJlc29sdmUoKS50aGVuKGZ1bmN0aW9uKCl7dD0hMSxlKCl9KSl9fTpmdW5jdGlvbihlKXt2YXIgdD0hMTtyZXR1cm4gZnVuY3Rpb24oKXt0fHwodD0hMCxzZXRUaW1lb3V0KGZ1bmN0aW9uKCl7dD0hMSxlKCl9LG9lKSl9fSxyZT10ZSYmISEod2luZG93Lk1TSW5wdXRNZXRob2RDb250ZXh0JiZkb2N1bWVudC5kb2N1bWVudE1vZGUpLHBlPXRlJiYvTVNJRSAxMC8udGVzdChuYXZpZ2F0b3IudXNlckFnZW50KSxzZT1mdW5jdGlvbihlLHQpe2lmKCEoZSBpbnN0YW5jZW9mIHQpKXRocm93IG5ldyBUeXBlRXJyb3IoJ0Nhbm5vdCBjYWxsIGEgY2xhc3MgYXMgYSBmdW5jdGlvbicpfSxkZT1mdW5jdGlvbigpe2Z1bmN0aW9uIGUoZSx0KXtmb3IodmFyIG8sbj0wO248dC5sZW5ndGg7bisrKW89dFtuXSxvLmVudW1lcmFibGU9by5lbnVtZXJhYmxlfHwhMSxvLmNvbmZpZ3VyYWJsZT0hMCwndmFsdWUnaW4gbyYmKG8ud3JpdGFibGU9ITApLE9iamVjdC5kZWZpbmVQcm9wZXJ0eShlLG8ua2V5LG8pfXJldHVybiBmdW5jdGlvbih0LG8sbil7cmV0dXJuIG8mJmUodC5wcm90b3R5cGUsbyksbiYmZSh0LG4pLHR9fSgpLGFlPWZ1bmN0aW9uKGUsdCxvKXtyZXR1cm4gdCBpbiBlP09iamVjdC5kZWZpbmVQcm9wZXJ0eShlLHQse3ZhbHVlOm8sZW51bWVyYWJsZTohMCxjb25maWd1cmFibGU6ITAsd3JpdGFibGU6ITB9KTplW3RdPW8sZX0sbGU9T2JqZWN0LmFzc2lnbnx8ZnVuY3Rpb24oZSl7Zm9yKHZhciB0LG89MTtvPGFyZ3VtZW50cy5sZW5ndGg7bysrKWZvcih2YXIgbiBpbiB0PWFyZ3VtZW50c1tvXSx0KU9iamVjdC5wcm90b3R5cGUuaGFzT3duUHJvcGVydHkuY2FsbCh0LG4pJiYoZVtuXT10W25dKTtyZXR1cm4gZX0sZmU9dGUmJi9GaXJlZm94L2kudGVzdChuYXZpZ2F0b3IudXNlckFnZW50KSxtZT1bJ2F1dG8tc3RhcnQnLCdhdXRvJywnYXV0by1lbmQnLCd0b3Atc3RhcnQnLCd0b3AnLCd0b3AtZW5kJywncmlnaHQtc3RhcnQnLCdyaWdodCcsJ3JpZ2h0LWVuZCcsJ2JvdHRvbS1lbmQnLCdib3R0b20nLCdib3R0b20tc3RhcnQnLCdsZWZ0LWVuZCcsJ2xlZnQnLCdsZWZ0LXN0YXJ0J10saGU9bWUuc2xpY2UoMyksY2U9e0ZMSVA6J2ZsaXAnLENMT0NLV0lTRTonY2xvY2t3aXNlJyxDT1VOVEVSQ0xPQ0tXSVNFOidjb3VudGVyY2xvY2t3aXNlJ30sZ2U9ZnVuY3Rpb24oKXtmdW5jdGlvbiB0KG8sbil7dmFyIGk9dGhpcyxyPTI8YXJndW1lbnRzLmxlbmd0aCYmdm9pZCAwIT09YXJndW1lbnRzWzJdP2FyZ3VtZW50c1syXTp7fTtzZSh0aGlzLHQpLHRoaXMuc2NoZWR1bGVVcGRhdGU9ZnVuY3Rpb24oKXtyZXR1cm4gcmVxdWVzdEFuaW1hdGlvbkZyYW1lKGkudXBkYXRlKX0sdGhpcy51cGRhdGU9aWUodGhpcy51cGRhdGUuYmluZCh0aGlzKSksdGhpcy5vcHRpb25zPWxlKHt9LHQuRGVmYXVsdHMsciksdGhpcy5zdGF0ZT17aXNEZXN0cm95ZWQ6ITEsaXNDcmVhdGVkOiExLHNjcm9sbFBhcmVudHM6W119LHRoaXMucmVmZXJlbmNlPW8mJm8uanF1ZXJ5P29bMF06byx0aGlzLnBvcHBlcj1uJiZuLmpxdWVyeT9uWzBdOm4sdGhpcy5vcHRpb25zLm1vZGlmaWVycz17fSxPYmplY3Qua2V5cyhsZSh7fSx0LkRlZmF1bHRzLm1vZGlmaWVycyxyLm1vZGlmaWVycykpLmZvckVhY2goZnVuY3Rpb24oZSl7aS5vcHRpb25zLm1vZGlmaWVyc1tlXT1sZSh7fSx0LkRlZmF1bHRzLm1vZGlmaWVyc1tlXXx8e30sci5tb2RpZmllcnM/ci5tb2RpZmllcnNbZV06e30pfSksdGhpcy5tb2RpZmllcnM9T2JqZWN0LmtleXModGhpcy5vcHRpb25zLm1vZGlmaWVycykubWFwKGZ1bmN0aW9uKGUpe3JldHVybiBsZSh7bmFtZTplfSxpLm9wdGlvbnMubW9kaWZpZXJzW2VdKX0pLnNvcnQoZnVuY3Rpb24oZSx0KXtyZXR1cm4gZS5vcmRlci10Lm9yZGVyfSksdGhpcy5tb2RpZmllcnMuZm9yRWFjaChmdW5jdGlvbih0KXt0LmVuYWJsZWQmJmUodC5vbkxvYWQpJiZ0Lm9uTG9hZChpLnJlZmVyZW5jZSxpLnBvcHBlcixpLm9wdGlvbnMsdCxpLnN0YXRlKX0pLHRoaXMudXBkYXRlKCk7dmFyIHA9dGhpcy5vcHRpb25zLmV2ZW50c0VuYWJsZWQ7cCYmdGhpcy5lbmFibGVFdmVudExpc3RlbmVycygpLHRoaXMuc3RhdGUuZXZlbnRzRW5hYmxlZD1wfXJldHVybiBkZSh0LFt7a2V5Oid1cGRhdGUnLHZhbHVlOmZ1bmN0aW9uKCl7cmV0dXJuIGsuY2FsbCh0aGlzKX19LHtrZXk6J2Rlc3Ryb3knLHZhbHVlOmZ1bmN0aW9uKCl7cmV0dXJuIEguY2FsbCh0aGlzKX19LHtrZXk6J2VuYWJsZUV2ZW50TGlzdGVuZXJzJyx2YWx1ZTpmdW5jdGlvbigpe3JldHVybiBJLmNhbGwodGhpcyl9fSx7a2V5OidkaXNhYmxlRXZlbnRMaXN0ZW5lcnMnLHZhbHVlOmZ1bmN0aW9uKCl7cmV0dXJuIFUuY2FsbCh0aGlzKX19XSksdH0oKTtyZXR1cm4gZ2UuVXRpbHM9KCd1bmRlZmluZWQnPT10eXBlb2Ygd2luZG93P2dsb2JhbDp3aW5kb3cpLlBvcHBlclV0aWxzLGdlLnBsYWNlbWVudHM9bWUsZ2UuRGVmYXVsdHM9e3BsYWNlbWVudDonYm90dG9tJyxwb3NpdGlvbkZpeGVkOiExLGV2ZW50c0VuYWJsZWQ6ITAscmVtb3ZlT25EZXN0cm95OiExLG9uQ3JlYXRlOmZ1bmN0aW9uKCl7fSxvblVwZGF0ZTpmdW5jdGlvbigpe30sbW9kaWZpZXJzOntzaGlmdDp7b3JkZXI6MTAwLGVuYWJsZWQ6ITAsZm46ZnVuY3Rpb24oZSl7dmFyIHQ9ZS5wbGFjZW1lbnQsbz10LnNwbGl0KCctJylbMF0sbj10LnNwbGl0KCctJylbMV07aWYobil7dmFyIGk9ZS5vZmZzZXRzLHI9aS5yZWZlcmVuY2UscD1pLnBvcHBlcixzPS0xIT09Wydib3R0b20nLCd0b3AnXS5pbmRleE9mKG8pLGQ9cz8nbGVmdCc6J3RvcCcsYT1zPyd3aWR0aCc6J2hlaWdodCcsbD17c3RhcnQ6YWUoe30sZCxyW2RdKSxlbmQ6YWUoe30sZCxyW2RdK3JbYV0tcFthXSl9O2Uub2Zmc2V0cy5wb3BwZXI9bGUoe30scCxsW25dKX1yZXR1cm4gZX19LG9mZnNldDp7b3JkZXI6MjAwLGVuYWJsZWQ6ITAsZm46SixvZmZzZXQ6MH0scHJldmVudE92ZXJmbG93OntvcmRlcjozMDAsZW5hYmxlZDohMCxmbjpmdW5jdGlvbihlLHQpe3ZhciBvPXQuYm91bmRhcmllc0VsZW1lbnR8fHAoZS5pbnN0YW5jZS5wb3BwZXIpO2UuaW5zdGFuY2UucmVmZXJlbmNlPT09byYmKG89cChvKSk7dmFyIG49QigndHJhbnNmb3JtJyksaT1lLmluc3RhbmNlLnBvcHBlci5zdHlsZSxyPWkudG9wLHM9aS5sZWZ0LGQ9aVtuXTtpLnRvcD0nJyxpLmxlZnQ9JycsaVtuXT0nJzt2YXIgYT12KGUuaW5zdGFuY2UucG9wcGVyLGUuaW5zdGFuY2UucmVmZXJlbmNlLHQucGFkZGluZyxvLGUucG9zaXRpb25GaXhlZCk7aS50b3A9cixpLmxlZnQ9cyxpW25dPWQsdC5ib3VuZGFyaWVzPWE7dmFyIGw9dC5wcmlvcml0eSxmPWUub2Zmc2V0cy5wb3BwZXIsbT17cHJpbWFyeTpmdW5jdGlvbihlKXt2YXIgbz1mW2VdO3JldHVybiBmW2VdPGFbZV0mJiF0LmVzY2FwZVdpdGhSZWZlcmVuY2UmJihvPWVlKGZbZV0sYVtlXSkpLGFlKHt9LGUsbyl9LHNlY29uZGFyeTpmdW5jdGlvbihlKXt2YXIgbz0ncmlnaHQnPT09ZT8nbGVmdCc6J3RvcCcsbj1mW29dO3JldHVybiBmW2VdPmFbZV0mJiF0LmVzY2FwZVdpdGhSZWZlcmVuY2UmJihuPVEoZltvXSxhW2VdLSgncmlnaHQnPT09ZT9mLndpZHRoOmYuaGVpZ2h0KSkpLGFlKHt9LG8sbil9fTtyZXR1cm4gbC5mb3JFYWNoKGZ1bmN0aW9uKGUpe3ZhciB0PS0xPT09WydsZWZ0JywndG9wJ10uaW5kZXhPZihlKT8nc2Vjb25kYXJ5JzoncHJpbWFyeSc7Zj1sZSh7fSxmLG1bdF0oZSkpfSksZS5vZmZzZXRzLnBvcHBlcj1mLGV9LHByaW9yaXR5OlsnbGVmdCcsJ3JpZ2h0JywndG9wJywnYm90dG9tJ10scGFkZGluZzo1LGJvdW5kYXJpZXNFbGVtZW50OidzY3JvbGxQYXJlbnQnfSxrZWVwVG9nZXRoZXI6e29yZGVyOjQwMCxlbmFibGVkOiEwLGZuOmZ1bmN0aW9uKGUpe3ZhciB0PWUub2Zmc2V0cyxvPXQucG9wcGVyLG49dC5yZWZlcmVuY2UsaT1lLnBsYWNlbWVudC5zcGxpdCgnLScpWzBdLHI9WixwPS0xIT09Wyd0b3AnLCdib3R0b20nXS5pbmRleE9mKGkpLHM9cD8ncmlnaHQnOidib3R0b20nLGQ9cD8nbGVmdCc6J3RvcCcsYT1wPyd3aWR0aCc6J2hlaWdodCc7cmV0dXJuIG9bc108cihuW2RdKSYmKGUub2Zmc2V0cy5wb3BwZXJbZF09cihuW2RdKS1vW2FdKSxvW2RdPnIobltzXSkmJihlLm9mZnNldHMucG9wcGVyW2RdPXIobltzXSkpLGV9fSxhcnJvdzp7b3JkZXI6NTAwLGVuYWJsZWQ6ITAsZm46ZnVuY3Rpb24oZSxvKXt2YXIgbjtpZighSyhlLmluc3RhbmNlLm1vZGlmaWVycywnYXJyb3cnLCdrZWVwVG9nZXRoZXInKSlyZXR1cm4gZTt2YXIgaT1vLmVsZW1lbnQ7aWYoJ3N0cmluZyc9PXR5cGVvZiBpKXtpZihpPWUuaW5zdGFuY2UucG9wcGVyLnF1ZXJ5U2VsZWN0b3IoaSksIWkpcmV0dXJuIGU7fWVsc2UgaWYoIWUuaW5zdGFuY2UucG9wcGVyLmNvbnRhaW5zKGkpKXJldHVybiBjb25zb2xlLndhcm4oJ1dBUk5JTkc6IGBhcnJvdy5lbGVtZW50YCBtdXN0IGJlIGNoaWxkIG9mIGl0cyBwb3BwZXIgZWxlbWVudCEnKSxlO3ZhciByPWUucGxhY2VtZW50LnNwbGl0KCctJylbMF0scD1lLm9mZnNldHMscz1wLnBvcHBlcixkPXAucmVmZXJlbmNlLGE9LTEhPT1bJ2xlZnQnLCdyaWdodCddLmluZGV4T2YociksbD1hPydoZWlnaHQnOid3aWR0aCcsZj1hPydUb3AnOidMZWZ0JyxtPWYudG9Mb3dlckNhc2UoKSxoPWE/J2xlZnQnOid0b3AnLGM9YT8nYm90dG9tJzoncmlnaHQnLHU9UyhpKVtsXTtkW2NdLXU8c1ttXSYmKGUub2Zmc2V0cy5wb3BwZXJbbV0tPXNbbV0tKGRbY10tdSkpLGRbbV0rdT5zW2NdJiYoZS5vZmZzZXRzLnBvcHBlclttXSs9ZFttXSt1LXNbY10pLGUub2Zmc2V0cy5wb3BwZXI9ZyhlLm9mZnNldHMucG9wcGVyKTt2YXIgYj1kW21dK2RbbF0vMi11LzIsdz10KGUuaW5zdGFuY2UucG9wcGVyKSx5PXBhcnNlRmxvYXQod1snbWFyZ2luJytmXSwxMCksRT1wYXJzZUZsb2F0KHdbJ2JvcmRlcicrZisnV2lkdGgnXSwxMCksdj1iLWUub2Zmc2V0cy5wb3BwZXJbbV0teS1FO3JldHVybiB2PWVlKFEoc1tsXS11LHYpLDApLGUuYXJyb3dFbGVtZW50PWksZS5vZmZzZXRzLmFycm93PShuPXt9LGFlKG4sbSwkKHYpKSxhZShuLGgsJycpLG4pLGV9LGVsZW1lbnQ6J1t4LWFycm93XSd9LGZsaXA6e29yZGVyOjYwMCxlbmFibGVkOiEwLGZuOmZ1bmN0aW9uKGUsdCl7aWYoVyhlLmluc3RhbmNlLm1vZGlmaWVycywnaW5uZXInKSlyZXR1cm4gZTtpZihlLmZsaXBwZWQmJmUucGxhY2VtZW50PT09ZS5vcmlnaW5hbFBsYWNlbWVudClyZXR1cm4gZTt2YXIgbz12KGUuaW5zdGFuY2UucG9wcGVyLGUuaW5zdGFuY2UucmVmZXJlbmNlLHQucGFkZGluZyx0LmJvdW5kYXJpZXNFbGVtZW50LGUucG9zaXRpb25GaXhlZCksbj1lLnBsYWNlbWVudC5zcGxpdCgnLScpWzBdLGk9VChuKSxyPWUucGxhY2VtZW50LnNwbGl0KCctJylbMV18fCcnLHA9W107c3dpdGNoKHQuYmVoYXZpb3Ipe2Nhc2UgY2UuRkxJUDpwPVtuLGldO2JyZWFrO2Nhc2UgY2UuQ0xPQ0tXSVNFOnA9RyhuKTticmVhaztjYXNlIGNlLkNPVU5URVJDTE9DS1dJU0U6cD1HKG4sITApO2JyZWFrO2RlZmF1bHQ6cD10LmJlaGF2aW9yO31yZXR1cm4gcC5mb3JFYWNoKGZ1bmN0aW9uKHMsZCl7aWYobiE9PXN8fHAubGVuZ3RoPT09ZCsxKXJldHVybiBlO249ZS5wbGFjZW1lbnQuc3BsaXQoJy0nKVswXSxpPVQobik7dmFyIGE9ZS5vZmZzZXRzLnBvcHBlcixsPWUub2Zmc2V0cy5yZWZlcmVuY2UsZj1aLG09J2xlZnQnPT09biYmZihhLnJpZ2h0KT5mKGwubGVmdCl8fCdyaWdodCc9PT1uJiZmKGEubGVmdCk8ZihsLnJpZ2h0KXx8J3RvcCc9PT1uJiZmKGEuYm90dG9tKT5mKGwudG9wKXx8J2JvdHRvbSc9PT1uJiZmKGEudG9wKTxmKGwuYm90dG9tKSxoPWYoYS5sZWZ0KTxmKG8ubGVmdCksYz1mKGEucmlnaHQpPmYoby5yaWdodCksZz1mKGEudG9wKTxmKG8udG9wKSx1PWYoYS5ib3R0b20pPmYoby5ib3R0b20pLGI9J2xlZnQnPT09biYmaHx8J3JpZ2h0Jz09PW4mJmN8fCd0b3AnPT09biYmZ3x8J2JvdHRvbSc9PT1uJiZ1LHc9LTEhPT1bJ3RvcCcsJ2JvdHRvbSddLmluZGV4T2YobikseT0hIXQuZmxpcFZhcmlhdGlvbnMmJih3JiYnc3RhcnQnPT09ciYmaHx8dyYmJ2VuZCc9PT1yJiZjfHwhdyYmJ3N0YXJ0Jz09PXImJmd8fCF3JiYnZW5kJz09PXImJnUpLEU9ISF0LmZsaXBWYXJpYXRpb25zQnlDb250ZW50JiYodyYmJ3N0YXJ0Jz09PXImJmN8fHcmJidlbmQnPT09ciYmaHx8IXcmJidzdGFydCc9PT1yJiZ1fHwhdyYmJ2VuZCc9PT1yJiZnKSx2PXl8fEU7KG18fGJ8fHYpJiYoZS5mbGlwcGVkPSEwLChtfHxiKSYmKG49cFtkKzFdKSx2JiYocj16KHIpKSxlLnBsYWNlbWVudD1uKyhyPyctJytyOicnKSxlLm9mZnNldHMucG9wcGVyPWxlKHt9LGUub2Zmc2V0cy5wb3BwZXIsQyhlLmluc3RhbmNlLnBvcHBlcixlLm9mZnNldHMucmVmZXJlbmNlLGUucGxhY2VtZW50KSksZT1QKGUuaW5zdGFuY2UubW9kaWZpZXJzLGUsJ2ZsaXAnKSl9KSxlfSxiZWhhdmlvcjonZmxpcCcscGFkZGluZzo1LGJvdW5kYXJpZXNFbGVtZW50Oid2aWV3cG9ydCcsZmxpcFZhcmlhdGlvbnM6ITEsZmxpcFZhcmlhdGlvbnNCeUNvbnRlbnQ6ITF9LGlubmVyOntvcmRlcjo3MDAsZW5hYmxlZDohMSxmbjpmdW5jdGlvbihlKXt2YXIgdD1lLnBsYWNlbWVudCxvPXQuc3BsaXQoJy0nKVswXSxuPWUub2Zmc2V0cyxpPW4ucG9wcGVyLHI9bi5yZWZlcmVuY2UscD0tMSE9PVsnbGVmdCcsJ3JpZ2h0J10uaW5kZXhPZihvKSxzPS0xPT09Wyd0b3AnLCdsZWZ0J10uaW5kZXhPZihvKTtyZXR1cm4gaVtwPydsZWZ0JzondG9wJ109cltvXS0ocz9pW3A/J3dpZHRoJzonaGVpZ2h0J106MCksZS5wbGFjZW1lbnQ9VCh0KSxlLm9mZnNldHMucG9wcGVyPWcoaSksZX19LGhpZGU6e29yZGVyOjgwMCxlbmFibGVkOiEwLGZuOmZ1bmN0aW9uKGUpe2lmKCFLKGUuaW5zdGFuY2UubW9kaWZpZXJzLCdoaWRlJywncHJldmVudE92ZXJmbG93JykpcmV0dXJuIGU7dmFyIHQ9ZS5vZmZzZXRzLnJlZmVyZW5jZSxvPUQoZS5pbnN0YW5jZS5tb2RpZmllcnMsZnVuY3Rpb24oZSl7cmV0dXJuJ3ByZXZlbnRPdmVyZmxvdyc9PT1lLm5hbWV9KS5ib3VuZGFyaWVzO2lmKHQuYm90dG9tPG8udG9wfHx0LmxlZnQ+by5yaWdodHx8dC50b3A+by5ib3R0b218fHQucmlnaHQ8by5sZWZ0KXtpZighMD09PWUuaGlkZSlyZXR1cm4gZTtlLmhpZGU9ITAsZS5hdHRyaWJ1dGVzWyd4LW91dC1vZi1ib3VuZGFyaWVzJ109Jyd9ZWxzZXtpZighMT09PWUuaGlkZSlyZXR1cm4gZTtlLmhpZGU9ITEsZS5hdHRyaWJ1dGVzWyd4LW91dC1vZi1ib3VuZGFyaWVzJ109ITF9cmV0dXJuIGV9fSxjb21wdXRlU3R5bGU6e29yZGVyOjg1MCxlbmFibGVkOiEwLGZuOmZ1bmN0aW9uKGUsdCl7dmFyIG89dC54LG49dC55LGk9ZS5vZmZzZXRzLnBvcHBlcixyPUQoZS5pbnN0YW5jZS5tb2RpZmllcnMsZnVuY3Rpb24oZSl7cmV0dXJuJ2FwcGx5U3R5bGUnPT09ZS5uYW1lfSkuZ3B1QWNjZWxlcmF0aW9uO3ZvaWQgMCE9PXImJmNvbnNvbGUud2FybignV0FSTklORzogYGdwdUFjY2VsZXJhdGlvbmAgb3B0aW9uIG1vdmVkIHRvIGBjb21wdXRlU3R5bGVgIG1vZGlmaWVyIGFuZCB3aWxsIG5vdCBiZSBzdXBwb3J0ZWQgaW4gZnV0dXJlIHZlcnNpb25zIG9mIFBvcHBlci5qcyEnKTt2YXIgcyxkLGE9dm9pZCAwPT09cj90LmdwdUFjY2VsZXJhdGlvbjpyLGw9cChlLmluc3RhbmNlLnBvcHBlciksZj11KGwpLG09e3Bvc2l0aW9uOmkucG9zaXRpb259LGg9cShlLDI+d2luZG93LmRldmljZVBpeGVsUmF0aW98fCFmZSksYz0nYm90dG9tJz09PW8/J3RvcCc6J2JvdHRvbScsZz0ncmlnaHQnPT09bj8nbGVmdCc6J3JpZ2h0JyxiPUIoJ3RyYW5zZm9ybScpO2lmKGQ9J2JvdHRvbSc9PWM/J0hUTUwnPT09bC5ub2RlTmFtZT8tbC5jbGllbnRIZWlnaHQraC5ib3R0b206LWYuaGVpZ2h0K2guYm90dG9tOmgudG9wLHM9J3JpZ2h0Jz09Zz8nSFRNTCc9PT1sLm5vZGVOYW1lPy1sLmNsaWVudFdpZHRoK2gucmlnaHQ6LWYud2lkdGgraC5yaWdodDpoLmxlZnQsYSYmYiltW2JdPSd0cmFuc2xhdGUzZCgnK3MrJ3B4LCAnK2QrJ3B4LCAwKScsbVtjXT0wLG1bZ109MCxtLndpbGxDaGFuZ2U9J3RyYW5zZm9ybSc7ZWxzZXt2YXIgdz0nYm90dG9tJz09Yz8tMToxLHk9J3JpZ2h0Jz09Zz8tMToxO21bY109ZCp3LG1bZ109cyp5LG0ud2lsbENoYW5nZT1jKycsICcrZ312YXIgRT17IngtcGxhY2VtZW50IjplLnBsYWNlbWVudH07cmV0dXJuIGUuYXR0cmlidXRlcz1sZSh7fSxFLGUuYXR0cmlidXRlcyksZS5zdHlsZXM9bGUoe30sbSxlLnN0eWxlcyksZS5hcnJvd1N0eWxlcz1sZSh7fSxlLm9mZnNldHMuYXJyb3csZS5hcnJvd1N0eWxlcyksZX0sZ3B1QWNjZWxlcmF0aW9uOiEwLHg6J2JvdHRvbScseToncmlnaHQnfSxhcHBseVN0eWxlOntvcmRlcjo5MDAsZW5hYmxlZDohMCxmbjpmdW5jdGlvbihlKXtyZXR1cm4gVihlLmluc3RhbmNlLnBvcHBlcixlLnN0eWxlcyksaihlLmluc3RhbmNlLnBvcHBlcixlLmF0dHJpYnV0ZXMpLGUuYXJyb3dFbGVtZW50JiZPYmplY3Qua2V5cyhlLmFycm93U3R5bGVzKS5sZW5ndGgmJlYoZS5hcnJvd0VsZW1lbnQsZS5hcnJvd1N0eWxlcyksZX0sb25Mb2FkOmZ1bmN0aW9uKGUsdCxvLG4saSl7dmFyIHI9TChpLHQsZSxvLnBvc2l0aW9uRml4ZWQpLHA9TyhvLnBsYWNlbWVudCxyLHQsZSxvLm1vZGlmaWVycy5mbGlwLmJvdW5kYXJpZXNFbGVtZW50LG8ubW9kaWZpZXJzLmZsaXAucGFkZGluZyk7cmV0dXJuIHQuc2V0QXR0cmlidXRlKCd4LXBsYWNlbWVudCcscCksVih0LHtwb3NpdGlvbjpvLnBvc2l0aW9uRml4ZWQ/J2ZpeGVkJzonYWJzb2x1dGUnfSksb30sZ3B1QWNjZWxlcmF0aW9uOnZvaWQgMH19fSxnZX0pOwovLyMgc291cmNlTWFwcGluZ1VSTD1wb3BwZXIubWluLmpzLm1hcAo=")

	tmpls["js/space.js"] = tostring("Ly8gQWRkIG1vcmUgZmllbGRzIHRvIHNwYWNlIGNyZWF0ZS4KKGZ1bmN0aW9uKCkgeyAKICB2YXIgYWRkRmllbGRCdG4gPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnYWRkLWZpZWxkYnRuJykKICB2YXIgaSA9IDEKICBhZGRGaWVsZEJ0bi5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsIGZ1bmN0aW9uKGUpIHsgCiAgICBpKysKICAgIGUucHJldmVudERlZmF1bHQoKQogICAgZS5zdG9wUHJvcGFnYXRpb24oKQogICAgdmFyIGVsID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2JykKICAgIGVsLmlubmVySFRNTCA9IGAKICAgICAgPGRpdiBjbGFzcz0nY29udGFpbmVyLWZsdWlkIHB4LTAgbWItMyc+CiAgICAgICAgPGlucHV0IGNsYXNzPSJtYi0zIGZvcm0tY29udHJvbCIgcmVxdWlyZWQgdHlwZT10ZXh0IG5hbWU9ImZpZWxkX25hbWVfJHtpfSIgdmFsdWU9IiIgLz4KICAgICAgICA8ZGl2IGNsYXNzPSdmb3JtLWdyb3VwIHJvdyc+CiAgICAgICAgICA8ZGl2IGNsYXNzPSdjb2wtNic+CiAgICAgICAgICAgIDxzZWxlY3QgY2xhc3M9InctMTAwIGZvcm0tY29udHJvbCIgcmVxdWlyZWQgbmFtZT0iZmllbGRfdHlwZV8ke2l9Ij4KICAgICAgICAgICAgICA8b3B0aW9uIGRpc2FibGVkIHZhbHVlPkZpZWxkIFR5cGU8L29wdGlvbj4KICAgICAgICAgICAgICA8b3B0aW9uIHNlbGVjdGVkIHZhbHVlPSJTdHJpbmdTbWFsbCI+U3RyaW5nIFNtYWxsPC9vcHRpb24+CiAgICAgICAgICAgICAgPG9wdGlvbiB2YWx1ZT0iU3RyaW5nQmlnIj5TdHJpbmcgQmlnPC9vcHRpb24+CiAgICAgICAgICAgICAgPG9wdGlvbiB2YWx1ZT0iSW5wdXRIVE1MIj5IVE1MPC9vcHRpb24+CiAgICAgICAgICAgICAgPG9wdGlvbiB2YWx1ZT0iSW5wdXRNYXJrZG93biI+TWFya2Rvd248L29wdGlvbj4KICAgICAgICAgICAgICA8b3B0aW9uIHZhbHVlPSJGaWxlIj5GaWxlPC9vcHRpb24+CiAgICAgICAgICAgICAgPG9wdGlvbiB2YWx1ZT0iRGF0ZSI+RGF0ZTwvb3B0aW9uPgogICAgICAgICAgICAgIDxvcHRpb24gdmFsdWU9IlJlZmVyZW5jZSI+UmVmZXJlbmNlPC9vcHRpb24+CiAgICAgICAgICAgICAgPG9wdGlvbiB2YWx1ZT0iUmVmZXJlbmNlTGlzdCI+UmVmZXJlbmNlTGlzdDwvb3B0aW9uPgogICAgICAgICAgICA8L3NlbGVjdD4KICAgICAgICAgIDwvZGl2PgogICAgICAgICAgPGRpdiBjbGFzcz0nY29sLTYnPgogICAgICAgICAgICA8YnV0dG9uIGlkPSdyZW1vdmUtZmllbGRidG5fJHtpfScgY2xhc3M9J3ctMTAwIGJ0biBidG4tcHJpbWFyeScgdHlwZT1idXR0b24+UmVtb3ZlIEZpZWxkPC9idXR0b24+CiAgICAgICAgICA8L2Rpdj4KICAgICAgICA8L2Rpdj4KICAgICAgPC9kaXY+CiAgICBgCiAgICBhZGRGaWVsZEJ0bi5wYXJlbnROb2RlLmluc2VydEJlZm9yZShlbCwgYWRkRmllbGRCdG4pCiAgICB2YXIgcmVtb3ZlRmllbGRCdG4gPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZChgcmVtb3ZlLWZpZWxkYnRuXyR7aX1gKQogICAgcmVtb3ZlRmllbGRCdG4uYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCBmdW5jdGlvbihlKSB7IAogICAgICBpLS0KICAgICAgZS5wcmV2ZW50RGVmYXVsdCgpCiAgICAgIGUuc3RvcFByb3BhZ2F0aW9uKCkKICAgICAgZWwucGFyZW50Tm9kZS5yZW1vdmVDaGlsZChlbCkKICAgIH0pCiAgfSkKfSkoKTsKCi8vIEZvciB1cGRhdGU6IHJlbW92ZSBvbGQgZmllbGRzCihmdW5jdGlvbigpIHsgCiAgdmFyIGJ0bnMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCIuYnRuLXJlbW92ZSIpOwogIGZvciAodmFyIGUgPSAwOyBlIDwgYnRucy5sZW5ndGg7IGUrKykgewogICAgKGZ1bmN0aW9uKGJ0bikgewogICAgICBidG4uYWRkRXZlbnRMaXN0ZW5lcigiY2xpY2siLCBmdW5jdGlvbiBoYW5kZWxDbGljaygpIHsgCiAgICAgICAgYnRuID0gYnRuLnBhcmVudEVsZW1lbnQucGFyZW50RWxlbWVudAogICAgICAgIGJ0bi5wYXJlbnRFbGVtZW50LnBhcmVudEVsZW1lbnQucmVtb3ZlQ2hpbGQoYnRuLnBhcmVudEVsZW1lbnQpCiAgICAgIH0pOwogICAgfSkoYnRuc1tlXSk7CiAgfQp9KSgpOwo=")
	tmpls["js/space.js"] = tostring("Ly8gQWRkIG1vcmUgZmllbGRzIHRvIHNwYWNlIGNyZWF0ZS4KKGZ1bmN0aW9uKCkgeyAKICB2YXIgYWRkRmllbGRCdG4gPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnYWRkLWZpZWxkYnRuJykKICB2YXIgaSA9IDEKICBhZGRGaWVsZEJ0bi5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsIGZ1bmN0aW9uKGUpIHsgCiAgICBpKysKICAgIGUucHJldmVudERlZmF1bHQoKQogICAgZS5zdG9wUHJvcGFnYXRpb24oKQogICAgdmFyIGVsID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2JykKICAgIGVsLmlubmVySFRNTCA9IGAKICAgICAgPGRpdiBjbGFzcz0nY29udGFpbmVyLWZsdWlkIHB4LTAgbWItMyc+CiAgICAgICAgPGlucHV0IGNsYXNzPSJtYi0zIGZvcm0tY29udHJvbCIgcmVxdWlyZWQgdHlwZT10ZXh0IG5hbWU9ImZpZWxkX25hbWVfJHtpfSIgdmFsdWU9IiIgLz4KICAgICAgICA8ZGl2IGNsYXNzPSdmb3JtLWdyb3VwIHJvdyc+CiAgICAgICAgICA8ZGl2IGNsYXNzPSdjb2wtNic+CiAgICAgICAgICAgIDxzZWxlY3QgY2xhc3M9InctMTAwIGZvcm0tY29udHJvbCIgcmVxdWlyZWQgbmFtZT0iZmllbGRfdHlwZV8ke2l9Ij4KICAgICAgICAgICAgICA8b3B0aW9uIGRpc2FibGVkIHZhbHVlPkZpZWxkIFR5cGU8L29wdGlvbj4KICAgICAgICAgICAgICA8b3B0aW9uIHNlbGVjdGVkIHZhbHVlPSJTdHJpbmdTbWFsbCI+U3RyaW5nIFNtYWxsPC9vcHRpb24+CiAgICAgICAgICAgICAgPG9wdGlvbiB2YWx1ZT0iU3RyaW5nQmlnIj5TdHJpbmcgQmlnPC9vcHRpb24+CiAgICAgICAgICAgICAgPG9wdGlvbiB2YWx1ZT0iSW5wdXRIVE1MIj5IVE1MPC9vcHRpb24+CiAgICAgICAgICAgICAgPG9wdGlvbiB2YWx1ZT0iSW5wdXRNYXJrZG93biI+TWFya2Rvd248L29wdGlvbj4KICAgICAgICAgICAgICA8b3B0aW9uIHt7aWYgbm90IChwYWlkIC5Vc2VyKX19ZGlzYWJsZWR7e2VuZH19IHZhbHVlPSJGaWxlIj5GaWxlPC9vcHRpb24+CiAgICAgICAgICAgICAgPG9wdGlvbiB2YWx1ZT0iRGF0ZSI+RGF0ZTwvb3B0aW9uPgogICAgICAgICAgICAgIDxvcHRpb24gdmFsdWU9IlJlZmVyZW5jZSI+UmVmZXJlbmNlPC9vcHRpb24+CiAgICAgICAgICAgICAgPG9wdGlvbiB2YWx1ZT0iUmVmZXJlbmNlTGlzdCI+UmVmZXJlbmNlTGlzdDwvb3B0aW9uPgogICAgICAgICAgICA8L3NlbGVjdD4KICAgICAgICAgIDwvZGl2PgogICAgICAgICAgPGRpdiBjbGFzcz0nY29sLTYnPgogICAgICAgICAgICA8YnV0dG9uIGlkPSdyZW1vdmUtZmllbGRidG5fJHtpfScgY2xhc3M9J3ctMTAwIGJ0biBidG4tcHJpbWFyeScgdHlwZT1idXR0b24+UmVtb3ZlIEZpZWxkPC9idXR0b24+CiAgICAgICAgICA8L2Rpdj4KICAgICAgICA8L2Rpdj4KICAgICAgPC9kaXY+CiAgICBgCiAgICBhZGRGaWVsZEJ0bi5wYXJlbnROb2RlLmluc2VydEJlZm9yZShlbCwgYWRkRmllbGRCdG4pCiAgICB2YXIgcmVtb3ZlRmllbGRCdG4gPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZChgcmVtb3ZlLWZpZWxkYnRuXyR7aX1gKQogICAgcmVtb3ZlRmllbGRCdG4uYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCBmdW5jdGlvbihlKSB7IAogICAgICBpLS0KICAgICAgZS5wcmV2ZW50RGVmYXVsdCgpCiAgICAgIGUuc3RvcFByb3BhZ2F0aW9uKCkKICAgICAgZWwucGFyZW50Tm9kZS5yZW1vdmVDaGlsZChlbCkKICAgIH0pCiAgfSkKfSkoKTsKCi8vIEZvciB1cGRhdGU6IHJlbW92ZSBvbGQgZmllbGRzCihmdW5jdGlvbigpIHsgCiAgdmFyIGJ0bnMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCIuYnRuLXJlbW92ZSIpOwogIGZvciAodmFyIGUgPSAwOyBlIDwgYnRucy5sZW5ndGg7IGUrKykgewogICAgKGZ1bmN0aW9uKGJ0bikgewogICAgICBidG4uYWRkRXZlbnRMaXN0ZW5lcigiY2xpY2siLCBmdW5jdGlvbiBoYW5kZWxDbGljaygpIHsgCiAgICAgICAgYnRuID0gYnRuLnBhcmVudEVsZW1lbnQucGFyZW50RWxlbWVudAogICAgICAgIGJ0bi5wYXJlbnRFbGVtZW50LnBhcmVudEVsZW1lbnQucmVtb3ZlQ2hpbGQoYnRuLnBhcmVudEVsZW1lbnQpCiAgICAgIH0pOwogICAgfSkoYnRuc1tlXSk7CiAgfQp9KSgpOwo=")

}


M internal/v/v.go => internal/v/v.go +4 -0
@@ 3,6 3,9 @@ package v
import (
	"html/template"
	"strings"

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

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


@@ 15,6 18,7 @@ func MustParse(name string) *template.Template {
		fns := template.FuncMap{
			"inc":   func(i int) int { return i + 1 },
			"title": func(str string) string { return strings.Title(str) },
			"paid":  func(u user.User) bool { return u.Org().Tier().Is(tier.Business) || u.Org().Tier().Is(tier.Enterprise) },
		}

		all = template.New("cms")