~evanj/cms

99a3bb7fc511e3f448a358691896ba1300f73a93 — Evan M Jones 9 months ago 398a875
WIP(rbac): Decorator object complete. TODO: Specify role in invite.
Allow admin to change other user's roles.
M TODO => TODO +3 -0
@@ 7,6 7,9 @@ Depth option on APIs
When editing existing references don't blow away prev inputs
Forgot password
Warn & delete excess users/spaces on downgrade.
Cache: org?, invite?, hook
Admin: change role level of users.
Invite: specify role level.

[med]
Cache lists

M internal/c/invite/invite.go => internal/c/invite/invite.go +4 -4
@@ 28,10 28,10 @@ type Invite struct {
}

type dber interface {
	InviteNew(o org.Org) (invite.Invite, error)
	InviteNew(u user.User, o org.Org) (invite.Invite, error)
	InviteGetByToken(tok string) (invite.Invite, error)
	InviteAccept(i invite.Invite, u, p, v string) (user.User, invite.Invite, error)
	InviteList(o org.Org) (r []invite.Invite, err error)
	InviteList(u user.User, o org.Org) (r []invite.Invite, err error)
}

func New(c *c.Controller, log *log.Logger, db dber) Invite {


@@ 53,7 53,7 @@ func (i Invite) ServeHTTP(w http.ResponseWriter, r *http.Request) {
			return
		}

		invites, err := i.db.InviteList(user.Org())
		invites, err := i.db.InviteList(user, user.Org())
		if err != nil {
			i.Error2(w, r, http.StatusInternalServerError, err)
			return


@@ 73,7 73,7 @@ func (i Invite) ServeHTTP(w http.ResponseWriter, r *http.Request) {
			return
		}

		_, err = i.db.InviteNew(user.Org())
		_, err = i.db.InviteNew(user, user.Org())
		if errors.Is(err, invite.ErrExpired) || errors.Is(err, invite.ErrUsed) {
			i.Error2(w, r, http.StatusBadRequest, err)
			return

M internal/c/stripe/stripe.go => internal/c/stripe/stripe.go +4 -7
@@ 5,7 5,6 @@ import (
	"net/http"

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

type StripeEndpoint struct {


@@ 15,19 14,17 @@ type StripeEndpoint struct {
	stripe Striper
}

type DBer interface {
	UserGetFromToken(token string) (user.User, error)
}
type DBer interface{}

type Striper interface {
	CompleteCheckout(sessionID string) error
}

func New(c *c.Controller, l *log.Logger, db DBer, striper Striper) *StripeEndpoint {
	return &StripeEndpoint{c, l, db, striper}
func New(c *c.Controller, l *log.Logger, db DBer, striper Striper) StripeEndpoint {
	return StripeEndpoint{c, l, db, striper}
}

func (s *StripeEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (s StripeEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	switch r.URL.Path {
	case "/success":
		err := s.stripe.CompleteCheckout(r.FormValue("session_id"))

M internal/m/role/role.go => internal/m/role/role.go +17 -3
@@ 1,11 1,17 @@
package role

import "errors"

var (
	ErrNoPermission = errors.New("you do not have permission for this action")
)

type Role struct {
	Name         string
	capabilities map[Capability]bool
}

func (r Role) Has(test Capability) bool {
func (r Role) Can(test Capability) bool {
	_, ok := r.capabilities[test]
	return ok
}


@@ 19,8 25,8 @@ const (
	SpaceDelete
	InviteGet
	InviteCreate
	InviteUpdate
	InviteDelete
	InviteUpdate // Not used.
	InviteDelete // Not used.
	ContentTypeGet
	ContentTypeCreate
	ContentTypeUpdate


@@ 33,6 39,10 @@ const (
	ContentCreate
	ContentUpdate
	ContentDelete
	OrgGet    // Not used.
	OrgCreate // Not used.
	OrgUpdate
	OrgDelete // Not used.
)

var (


@@ 57,6 67,10 @@ var (
		ContentCreate:     true,
		ContentUpdate:     true,
		ContentDelete:     true,
		OrgGet:            true,
		OrgCreate:         true,
		OrgUpdate:         true,
		OrgDelete:         true,
	}}

	Developer = Role{"Developer", map[Capability]bool{

M internal/m/user/user.go => internal/m/user/user.go +5 -1
@@ 1,6 1,9 @@
package user

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

type User interface {
	ID() string


@@ 9,4 12,5 @@ type User interface {
	Org() org.Org
	HasEmail() bool
	Email() string
	Role() role.Role
}

M internal/s/db/hook.go => internal/s/db/hook.go +7 -5
@@ 28,7 28,9 @@ const (
)

type Hook struct {
	id, url, spaceID string
	HookID  string
	HookURL string
	spaceID string
}

func (db *DB) HookNew(u user.User, space space.Space, url string) (hook.Hook, error) {


@@ 49,7 51,7 @@ func (db *DB) HookNew(u user.User, space space.Space, url string) (hook.Hook, er
	}

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



@@ 58,7 60,7 @@ func (db *DB) HookNew(u user.User, space space.Space, url string) (hook.Hook, er

func (db *DB) hookGet(t *sql.Tx, space space.Space, id string) (hook.Hook, error) {
	var hook Hook
	if err := t.QueryRow(query, id).Scan(&hook.id, &hook.url, &hook.spaceID); err != nil {
	if err := t.QueryRow(query, id).Scan(&hook.HookID, &hook.HookURL, &hook.spaceID); err != nil {
		return nil, err
	}



@@ 152,8 154,8 @@ func (db *DB) HooksPerSpace(u user.User, space space.Space, before int) (hook.Ho

// Interface impl.

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

// HOOK ITER STRUCT / INTERFACE


M internal/s/db/invite.go => internal/s/db/invite.go +4 -4
@@ 46,7 46,7 @@ var (
	mysqlTimeLayout = "2006-01-02 15:04:05"
)

func (db *DB) InviteNew(o org.Org) (invite.Invite, error) {
func (db *DB) InviteNew(u user.User, o org.Org) (invite.Invite, error) {
	t, err := db.Begin()
	if err != nil {
		return nil, err


@@ 75,7 75,7 @@ func (db *DB) inviteNew(t *sql.Tx, o org.Org) (invite.Invite, error) {
	return db.inviteGet(t, strconv.FormatInt(id, 10))
}

func (db *DB) InviteGet(id string) (invite.Invite, error) {
func (db *DB) InviteGet(u user.User, id string) (invite.Invite, error) {
	t, err := db.Begin()
	if err != nil {
		return nil, err


@@ 159,7 159,7 @@ func (db *DB) inviteAccept(t *sql.Tx, i invite.Invite, u, p, v string) (user.Use
	return user, i, nil
}

func (db *DB) InviteList(o org.Org) (r []invite.Invite, err error) {
func (db *DB) InviteList(u user.User, o org.Org) (r []invite.Invite, err error) {
	var (
		now  = time.Now().UTC()
		from = now.Format(mysqlTimeLayout)


@@ 178,7 178,7 @@ func (db *DB) InviteList(o org.Org) (r []invite.Invite, err error) {
			return nil, err
		}

		i, err := db.InviteGet(id)
		i, err := db.InviteGet(u, id)
		if err != nil {
			return nil, err
		}

M internal/s/db/user.go => internal/s/db/user.go +9 -7
@@ 5,6 5,7 @@ import (
	"fmt"

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


@@ 91,7 92,7 @@ func (db *DB) userNew(t *sql.Tx, username, password, verifyPassword string) (use
		return nil, fmt.Errorf("user '%s' already exists", username)
	}

	return db.UserGet(username, password)
	return db.userGet(t, username, password)
}

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


@@ 201,9 202,10 @@ func (db *DB) UserSetPassword(u user.User, current, password, verifyPassword str
	return db.UserGet(u.Name(), password)
}

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 }
func (u *User) HasEmail() bool { return u.userEmail.Valid }
func (u *User) Email() string  { return u.userEmail.String }
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 }
func (u *User) HasEmail() bool  { return u.userEmail.Valid }
func (u *User) Email() string   { return u.userEmail.String }
func (u *User) Role() role.Role { return role.Admin } // TODO: Actual impl.

M internal/s/rbac/rbac.go => internal/s/rbac/rbac.go +206 -0
@@ 1,8 1,20 @@
// Code generated by "rbac_gen"; DO NOT EDIT.

package rbac

import (
	"log"

	"git.sr.ht/~evanj/cms/internal/m/content"
	"git.sr.ht/~evanj/cms/internal/m/contenttype"
	"git.sr.ht/~evanj/cms/internal/m/hook"
	"git.sr.ht/~evanj/cms/internal/m/invite"
	"git.sr.ht/~evanj/cms/internal/m/org"
	"git.sr.ht/~evanj/cms/internal/m/role"
	"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/db"
	"git.sr.ht/~evanj/cms/internal/s/rl"
)



@@ 15,3 27,197 @@ type RBAC struct {
func New(l *log.Logger, db rl.RL) RBAC {
	return RBAC{db, l, db}
}

// SPACE

func (rbac RBAC) SpaceNew(user user.User, name, desc string) (space.Space, error) {
	if user.Role().Can(role.SpaceCreate) {
		return rbac.db.SpaceNew(user, name, desc)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) SpaceCopy(user user.User, prevS space.Space, name, desc string) (space.Space, error) {
	if user.Role().Can(role.SpaceCreate) {
		return rbac.db.SpaceCopy(user, prevS, name, desc)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) SpaceUpdate(user user.User, space space.Space, name, desc string) (space.Space, error) {
	if user.Role().Can(role.SpaceUpdate) {
		return rbac.db.SpaceUpdate(user, space, name, desc)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) SpaceGet(user user.User, spaceID string) (space.Space, error) {
	if user.Role().Can(role.SpaceGet) {
		return rbac.db.SpaceGet(user, spaceID)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) SpacesPerUser(user user.User, before int) (space.SpaceList, error) {
	if user.Role().Can(role.SpaceGet) {
		return rbac.db.SpacesPerUser(user, before)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) SpaceDelete(user user.User, space space.Space) error {
	if user.Role().Can(role.SpaceDelete) {
		return rbac.db.SpaceDelete(user, space)
	}
	return role.ErrNoPermission
}

// INVITE

func (rbac RBAC) InviteNew(u user.User, o org.Org) (invite.Invite, error) {
	if u.Role().Can(role.InviteCreate) {
		return rbac.db.InviteNew(u, o)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) InviteGet(u user.User, id string) (invite.Invite, error) {
	if u.Role().Can(role.InviteGet) {
		return rbac.db.InviteGet(u, id)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) InviteList(u user.User, o org.Org) (r []invite.Invite, err error) {
	if u.Role().Can(role.InviteGet) {
		return rbac.db.InviteList(u, o)
	}
	return nil, role.ErrNoPermission
}

// CONTENTTYPE

func (rbac RBAC) ContentTypeNew(u user.User, space space.Space, name string, params []db.ContentTypeNewParam) (contenttype.ContentType, error) {
	if u.Role().Can(role.ContentTypeCreate) {
		return rbac.db.ContentTypeNew(u, space, name, params)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) ContentTypeUpdate(u user.User, space space.Space, contenttype contenttype.ContentType, name string, newParams []db.ContentTypeNewParam, updateParams []db.ContentTypeUpdateParam) (contenttype.ContentType, error) {
	if u.Role().Can(role.ContentTypeUpdate) {
		return rbac.db.ContentTypeUpdate(u, space, contenttype, name, newParams, updateParams)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) ContentTypesPerSpace(u user.User, space space.Space, before int) (contenttype.ContentTypeList, error) {
	if u.Role().Can(role.ContentTypeGet) {
		return rbac.db.ContentTypesPerSpace(u, space, before)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) ContentTypeGet(u user.User, space space.Space, contenttypeID string) (contenttype.ContentType, error) {
	if u.Role().Can(role.ContentTypeGet) {
		return rbac.db.ContentTypeGet(u, space, contenttypeID)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) ContentTypeSearch(u user.User, space space.Space, query string, before int) (contenttype.ContentTypeList, error) {
	if u.Role().Can(role.ContentTypeGet) {
		return rbac.db.ContentTypeSearch(u, space, query, before)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) ContentTypeDelete(u user.User, space space.Space, ct contenttype.ContentType) error {
	if u.Role().Can(role.ContentTypeDelete) {
		rbac.db.ContentTypeDelete(u, space, ct)
	}
	return role.ErrNoPermission
}

// HOOK

func (rbac RBAC) HookNew(u user.User, space space.Space, url string) (hook.Hook, error) {
	if u.Role().Can(role.HookCreate) {
		return rbac.db.HookNew(u, space, url)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) HookGet(u user.User, space space.Space, id string) (hook.Hook, error) {
	if u.Role().Can(role.HookGet) {
		return rbac.db.HookGet(u, space, id)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) HookDelete(u user.User, s space.Space, h hook.Hook) error {
	if u.Role().Can(role.HookDelete) {
		return rbac.db.HookDelete(u, s, h)
	}
	return role.ErrNoPermission
}

func (rbac RBAC) HooksPerSpace(u user.User, space space.Space, before int) (hook.HookList, error) {
	if u.Role().Can(role.HookGet) {
		return rbac.db.HooksPerSpace(u, space, before)
	}
	return nil, role.ErrNoPermission
}

// CONTENT

func (rbac RBAC) ContentNew(u user.User, space space.Space, ct contenttype.ContentType, params []db.ContentNewParam) (content.Content, error) {
	if u.Role().Can(role.ContentCreate) {
		return rbac.db.ContentNew(u, space, ct, params)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) ContentUpdate(u user.User, space space.Space, ct contenttype.ContentType, content content.Content, newParams []db.ContentNewParam, updateParams []db.ContentUpdateParam) (content.Content, error) {
	if u.Role().Can(role.ContentUpdate) {
		return rbac.db.ContentUpdate(u, space, ct, content, newParams, updateParams)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) ContentDelete(u user.User, space space.Space, ct contenttype.ContentType, content content.Content) error {
	if u.Role().Can(role.ContentDelete) {
		return rbac.db.ContentDelete(u, space, ct, content)
	}
	return role.ErrNoPermission
}

func (rbac RBAC) ContentPerContentType(u user.User, space space.Space, ct contenttype.ContentType, before int, order db.OrderType, sortField string) (content.ContentList, error) {
	if u.Role().Can(role.ContentGet) {
		return rbac.db.ContentPerContentType(u, space, ct, before, order, sortField)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) ContentSearch(u user.User, space space.Space, ct contenttype.ContentType, sortField, query string, before int) (content.ContentList, error) {
	if u.Role().Can(role.ContentGet) {
		return rbac.db.ContentSearch(u, space, ct, sortField, query, before)
	}
	return nil, role.ErrNoPermission
}

func (rbac RBAC) ContentGet(u user.User, space space.Space, ct contenttype.ContentType, contentID string) (content.Content, error) {
	if u.Role().Can(role.ContentGet) {
		return rbac.db.ContentGet(u, space, ct, contentID)
	}
	return nil, role.ErrNoPermission
}

// ORG

func (rbac RBAC) OrgUpdateTier(u user.User, o org.Org, t tier.Tier, paymentCustomerID string) error {
	if u.Role().Can(role.OrgUpdate) {
		return rbac.db.OrgUpdateTier(u, o, t, paymentCustomerID)
	}
	return role.ErrNoPermission
}

M internal/s/rl/rl.go => internal/s/rl/rl.go +3 -3
@@ 176,11 176,11 @@ func (rl RL) ContentTypeUpdate(u user.User, space space.Space, contenttype conte

// Rate limit users to org.

func (rl RL) InviteNew(o org.Org) (invite.Invite, error) {
func (rl RL) InviteNew(u user.User, o org.Org) (invite.Invite, error) {
	limit, ok := userLimits[o.Tier().Name]
	if !ok {
		// If not in map, unlimited.
		return rl.db.InviteNew(o)
		return rl.db.InviteNew(u, o)
	}

	c, err := rl.db.OrgGetUserCount(o)


@@ 192,7 192,7 @@ func (rl RL) InviteNew(o org.Org) (invite.Invite, error) {
		return nil, fmt.Errorf("can't invite new users: %w", ErrHitLimit)
	}

	return rl.db.InviteNew(o)
	return rl.db.InviteNew(u, o)
}

func (rl RL) InviteAccept(i invite.Invite, u, p, v string) (user.User, invite.Invite, error) {

M internal/s/stripe/stripe.go => internal/s/stripe/stripe.go +17 -7
@@ 47,6 47,11 @@ func (s Stripe) StartCheckout(user user.User, t tier.Tier) (string, string, erro
		return "", "", err
	}

	// Make sure we're on free tier.
	if err := s.db.OrgUpdateTier(user, user.Org(), tier.Free, c.ID); err != nil {
		return "", "", err
	}

	params := &lib.CheckoutSessionParams{
		Customer: lib.String(c.ID),
		PaymentMethodTypes: lib.StringSlice([]string{


@@ 127,17 132,22 @@ func (s Stripe) CancelSubscription(user user.User) error {
		return errors.New("corrupted customer object")
	}

	for _, s := range c.Subscriptions.Data {
		_, err := sub.Cancel(s.ID, nil)
	currTier := user.Org().Tier()
	if err := s.db.OrgUpdateTier(user, user.Org(), tier.Free, c.ID); err != nil {
		return err
	}

	for _, subItem := range c.Subscriptions.Data {
		_, err := sub.Cancel(subItem.ID, nil)
		if err != nil {
			if err := s.db.OrgUpdateTier(user, user.Org(), currTier, c.ID); err != nil {
				// TODO: Better way to report this to ourselves?
				s.log.Println("big error: sub update in DB but not reflected in stripe")
				return errors.Wrap(err, "your subscription has been cancelled within Skipper CMS but not our payment processor: please contact an admin")
			}
			return errors.Wrap(err, "failed to cancel subscription")
		}
	}

	// TODO: But what if this fails? Can we do "transactions" with stripe?
	if err := s.db.OrgUpdateTier(user, user.Org(), tier.Free, c.ID); err != nil {
		s.log.Println("big error: cancelled sub in Stripe but failed to update in db")
	}

	return nil
}

M internal/v/html/billing.html => internal/v/html/billing.html +4 -4
@@ 24,7 24,7 @@
            </div>
            <form action='/user/update/email' method=POST class='mb-5'>
              <label for=email>Email</label>
              <input id=email name=email type=email class="mb-3 form-control" placeholder="email" required {{if .User.HasEmail}}value="{{.User.Email}}"{{end}}>
              <input id=email name=email type=email class="mb-3 form-control" required {{if .User.HasEmail}}value="{{.User.Email}}"{{end}}>
              <button type="submit" class="btn btn-primary">Go</button>
            </form>
            <div class='text-center'>


@@ 32,11 32,11 @@
            </div>
            <form action='/user/update/password' method=POST class='mb-5'>
              <label for=current >Current Password</label>
              <input id=current name=current type=password class="mb-3 form-control" placeholder="current" required>
              <input id=current name=current type=password class="mb-3 form-control" required>
              <label for=password>New Password</label>
              <input id=password name=password type=password class="mb-3 form-control" placeholder="password" required>
              <input id=password name=password type=password class="mb-3 form-control" required>
              <label for=verify>Verify</label>
              <input id=verify name=verify type=password class="mb-3 form-control" placeholder="verify" required>
              <input id=verify name=verify type=password class="mb-3 form-control" required>
              <button type="submit" class="btn btn-primary">Go</button>
            </form>
            {{if .User | paid}}

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

	tmpls["html/_scripts.html"] = tostring("PHNjcmlwdCBzcmM9Jy9zdGF0aWMvanMvcG9wcGVyLm1pbi5qcyc+PC9zY3JpcHQ+CjxzY3JpcHQgc3JjPScvc3RhdGljL2pzL2Jvb3RzdHJhcC5taW4uanMnPjwvc2NyaXB0Pgo=")

	tmpls["html/billing.html"] = tostring("PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ZW4+CjxoZWFkPgogIHt7IHRlbXBsYXRlICJodG1sL19oZWFkLmh0bWwiIH19CiAgPHRpdGxlPlNraXBwZXIgQ01TIHwgQmlsbGluZzwvdGl0bGU+CjwvaGVhZD4KPGJvZHkgY2xhc3M9J3BhZ2UgYmctbGlnaHQnPgogIDxzdHlsZT57eyB0ZW1wbGF0ZSAiY3NzL21haW4uY3NzIiB9fTwvc3R5bGU+CiAgPG1haW4+CiAgICB7eyB0ZW1wbGF0ZSAiaHRtbC9faGVhZGVyLmh0bWwiICQgfX0KICAgIDxkaXYgY2xhc3M9InByaWNpbmctaGVhZGVyIHB4LTMgcHktMyBwdC1tZC01IHBiLW1kLTQgbXgtYXV0byB0ZXh0LWNlbnRlciI+CiAgICAgIDxoMSBjbGFzcz0iZGlzcGxheS00Ij5CaWxsaW5nPC9oMT4KICAgIDwvZGl2PgogICAge3tpZiAuVXNlcn19CiAgICAgIDxkaXYgY2xhc3M9J2NvbnRhaW5lcic+CiAgICAgICAgPGRpdiBjbGFzcz0ncm93Jz4KICAgICAgICAgIDxkaXYgY2xhc3M9ImNvbC0xMiBjb2wtbWQtNiBvZmZzZXQtbWQtMyI+CiAgICAgICAgICAgIDxkaXYgY2xhc3M9J3RleHQtY2VudGVyJz4KICAgICAgICAgICAgICB7e2lmIC5Vc2VyLkhhc0VtYWlsfX0KICAgICAgICAgICAgICA8cD5VcGRhdGUgeW91ciBlbWFpbC48L3A+CiAgICAgICAgICAgICAge3tlbHNlfX0KICAgICAgICAgICAgICA8cD5TZXQgeW91ciBlbWFpbCBpbiBjYXNlIHlvdSBnZXQgbG9ja2VkIG91dCBvZiB5b3VyIGFjY291bnQuPC9wPgogICAgICAgICAgICAgIHt7ZW5kfX0KICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgIDxmb3JtIGFjdGlvbj0nL3VzZXIvdXBkYXRlL2VtYWlsJyBtZXRob2Q9UE9TVCBjbGFzcz0nbWItNSc+CiAgICAgICAgICAgICAgPGxhYmVsIGZvcj1lbWFpbD5FbWFpbDwvbGFiZWw+CiAgICAgICAgICAgICAgPGlucHV0IGlkPWVtYWlsIG5hbWU9ZW1haWwgdHlwZT1lbWFpbCBjbGFzcz0ibWItMyBmb3JtLWNvbnRyb2wiIHBsYWNlaG9sZGVyPSJlbWFpbCIgcmVxdWlyZWQge3tpZiAuVXNlci5IYXNFbWFpbH19dmFsdWU9Int7LlVzZXIuRW1haWx9fSJ7e2VuZH19PgogICAgICAgICAgICAgIDxidXR0b24gdHlwZT0ic3VibWl0IiBjbGFzcz0iYnRuIGJ0bi1wcmltYXJ5Ij5HbzwvYnV0dG9uPgogICAgICAgICAgICA8L2Zvcm0+CiAgICAgICAgICAgIDxkaXYgY2xhc3M9J3RleHQtY2VudGVyJz4KICAgICAgICAgICAgICA8cD5VcGRhdGUgeW91ciBwYXNzd29yZC48L3A+CiAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICA8Zm9ybSBhY3Rpb249Jy91c2VyL3VwZGF0ZS9wYXNzd29yZCcgbWV0aG9kPVBPU1QgY2xhc3M9J21iLTUnPgogICAgICAgICAgICAgIDxsYWJlbCBmb3I9Y3VycmVudCA+Q3VycmVudCBQYXNzd29yZDwvbGFiZWw+CiAgICAgICAgICAgICAgPGlucHV0IGlkPWN1cnJlbnQgbmFtZT1jdXJyZW50IHR5cGU9cGFzc3dvcmQgY2xhc3M9Im1iLTMgZm9ybS1jb250cm9sIiBwbGFjZWhvbGRlcj0iY3VycmVudCIgcmVxdWlyZWQ+CiAgICAgICAgICAgICAgPGxhYmVsIGZvcj1wYXNzd29yZD5OZXcgUGFzc3dvcmQ8L2xhYmVsPgogICAgICAgICAgICAgIDxpbnB1dCBpZD1wYXNzd29yZCBuYW1lPXBhc3N3b3JkIHR5cGU9cGFzc3dvcmQgY2xhc3M9Im1iLTMgZm9ybS1jb250cm9sIiBwbGFjZWhvbGRlcj0icGFzc3dvcmQiIHJlcXVpcmVkPgogICAgICAgICAgICAgIDxsYWJlbCBmb3I9dmVyaWZ5PlZlcmlmeTwvbGFiZWw+CiAgICAgICAgICAgICAgPGlucHV0IGlkPXZlcmlmeSBuYW1lPXZlcmlmeSB0eXBlPXBhc3N3b3JkIGNsYXNzPSJtYi0zIGZvcm0tY29udHJvbCIgcGxhY2Vob2xkZXI9InZlcmlmeSIgcmVxdWlyZWQ+CiAgICAgICAgICAgICAgPGJ1dHRvbiB0eXBlPSJzdWJtaXQiIGNsYXNzPSJidG4gYnRuLXByaW1hcnkiPkdvPC9idXR0b24+CiAgICAgICAgICAgIDwvZm9ybT4KICAgICAgICAgICAge3tpZiAuVXNlciB8IHBhaWR9fQogICAgICAgICAgICAgIDxmb3JtIGFjdGlvbj0nL3VzZXIvY2FuY2VsL2JpbGxpbmcnIG1ldGhvZD1QT1NUIGNsYXNzPSJ0ZXh0LWNlbnRlciI+CiAgICAgICAgICAgICAgICA8cCBjbGFzcz0nbWItNSc+Q2FuY2VsIHlvdXIge3suVXNlci5PcmcuVGllci5OYW1lfX0gc3Vic2NyaXB0aW9uLjwvcD4KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9ImQtaW5saW5lLWJsb2NrIj4KICAgICAgICAgICAgICAgICAgPGlucHV0IHR5cGU9aGlkZGVuIG5hbWU9dGllciB2YWx1ZT0ie3suVXNlci5PcmcuVGllci5OYW1lfX0iIC8+CiAgICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9ImNhcmQgbWItNCBzaGFkb3ctc20iPgogICAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJjYXJkLWhlYWRlciI+CiAgICAgICAgICAgICAgICAgICAgPGg0IGNsYXNzPSJteS0wIGZvbnQtd2VpZ2h0LW5vcm1hbCI+e3suVXNlci5PcmcuVGllci5OYW1lfX08L2g0PgogICAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0iY2FyZC1ib2R5Ij4KICAgICAgICAgICAgICAgICAgICA8aDEgY2xhc3M9ImNhcmQtdGl0bGUgcHJpY2luZy1jYXJkLXRpdGxlIj57ey5Vc2VyLk9yZy5UaWVyLlByaWNlfX0gPHNtYWxsIGNsYXNzPSJ0ZXh0LW11dGVkIj4vIHt7LlVzZXIuT3JnLlRpZXIuVGltZVVuaXR9fTwvc21hbGw+PC9oMT4KICAgICAgICAgICAgICAgICAgICA8dWwgY2xhc3M9Imxpc3QtdW5zdHlsZWQgbXQtMyBtYi00Ij4KICAgICAgICAgICAgICAgICAgICAgIHt7cmFuZ2UgLlVzZXIuT3JnLlRpZXIuT3B0c319CiAgICAgICAgICAgICAgICAgICAgICAgIDxsaT57ey5UZXh0fX08L2xpPgogICAgICAgICAgICAgICAgICAgICAge3tlbmR9fQogICAgICAgICAgICAgICAgICAgIDwvdWw+CiAgICAgICAgICAgICAgICAgICAgPGJ1dHRvbiB0eXBlPSJzdWJtaXQiIGNsYXNzPSJidG4gYnRuLXByaW1hcnkgdy0xMDAiPkNhbmNlbDwvYnV0dG9uPgogICAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICAgIDwvZm9ybT4KICAgICAgICAgICAge3tlbHNlfX0KICAgICAgICAgICAgICA8cCBjbGFzcz0ndGV4dC1jZW50ZXIgbWItNSc+VXBncmFkZSB0byBhIHBhaWQgdGllci48YnI+R2V0IG1vcmUgYWNjZXNzLjwvcD4KICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJyb3cgcm93LWNvbHMtMSByb3ctY29scy1tZC0yIHJvdy1jb2xzLWxnLTIgbWItNSB0ZXh0LWNlbnRlciI+CiAgICAgICAgICAgICAgICB7e3JhbmdlIC5UaWVyc319CiAgICAgICAgICAgICAgICAgIHt7aWYgbm90ICgufGlzRnJlZSl9fQogICAgICAgICAgICAgICAgICAgIDxmb3JtIGFjdGlvbj0nL3VzZXIvdXBkYXRlL2JpbGxpbmcnIG1ldGhvZD1QT1NUIGNsYXNzPSJjb2wiPgogICAgICAgICAgICAgICAgICAgICAgPGlucHV0IHR5cGU9aGlkZGVuIG5hbWU9dGllciB2YWx1ZT0ie3suTmFtZX19IiAvPgogICAgICAgICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0iY2FyZCBtYi00IHNoYWRvdy1zbSI+CiAgICAgICAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJjYXJkLWhlYWRlciI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxoNCBjbGFzcz0ibXktMCBmb250LXdlaWdodC1ub3JtYWwiPnt7Lk5hbWV9fTwvaDQ+CiAgICAgICAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9ImNhcmQtYm9keSI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxoMSBjbGFzcz0iY2FyZC10aXRsZSBwcmljaW5nLWNhcmQtdGl0bGUiPnt7LlByaWNlfX0gPHNtYWxsIGNsYXNzPSJ0ZXh0LW11dGVkIj4vIHt7LlRpbWVVbml0fX08L3NtYWxsPjwvaDE+CiAgICAgICAgICAgICAgICAgICAgICAgIDx1bCBjbGFzcz0ibGlzdC11bnN0eWxlZCBtdC0zIG1iLTQiPgogICAgICAgICAgICAgICAgICAgICAgICAgIHt7cmFuZ2UgLk9wdHN9fQogICAgICAgICAgICAgICAgICAgICAgICAgICAgPGxpPnt7LlRleHR9fTwvbGk+CiAgICAgICAgICAgICAgICAgICAgICAgICAge3tlbmR9fQogICAgICAgICAgICAgICAgICAgICAgICA8L3VsPgogICAgICAgICAgICAgICAgICAgICAgICA8YnV0dG9uIHR5cGU9InN1Ym1pdCIgY2xhc3M9ImJ0biBidG4tcHJpbWFyeSB3LTEwMCI+R288L2J1dHRvbj4KICAgICAgICAgICAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICAgICAgICAgIDwvZm9ybT4KICAgICAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICAgICAge3tlbmR9fQogICAgICAgICAgICAgICAge3tlbmR9fQogICAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICB7e2VuZH19CiAgICAgICAgICA8L2Rpdj4KICAgICAgICA8L2Rpdj4KICAgICAgPC9kaXY+CiAgICB7e2Vsc2V9fQogICAgICA8ZGl2IGNsYXNzPSdjb250YWluZXInPgogICAgICAgIDxkaXYgY2xhc3M9J3Jvdyc+CiAgICAgICAgICA8ZGl2IGNsYXNzPSJjb2wtMTIiPgogICAgICAgICAgICA8aDE+T29wczwvaDE+CiAgICAgICAgICAgIDxwPlNvcnJ5LCBvdXIgZGV2ZWxvcGVycyBhcmUgbGF6eS4gVGhpcyBzaG91bGQgcmVhbGx5IHJlZGlyZWN0IHlvdS48L3A+CiAgICAgICAgICA8L2Rpdj4KICAgICAgICA8L2Rpdj4KICAgICAgPC9kaXY+CiAgICB7e2VuZH19CiAgICB7eyB0ZW1wbGF0ZSAiaHRtbC9fZm9vdGVyLmh0bWwiICQgfX0KICA8L21haW4+CiAge3sgdGVtcGxhdGUgImh0bWwvX3NjcmlwdHMuaHRtbCIgfX0KPC9ib2R5Pgo8L2h0bWw+Cg==")
	tmpls["html/billing.html"] = tostring("PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ZW4+CjxoZWFkPgogIHt7IHRlbXBsYXRlICJodG1sL19oZWFkLmh0bWwiIH19CiAgPHRpdGxlPlNraXBwZXIgQ01TIHwgQmlsbGluZzwvdGl0bGU+CjwvaGVhZD4KPGJvZHkgY2xhc3M9J3BhZ2UgYmctbGlnaHQnPgogIDxzdHlsZT57eyB0ZW1wbGF0ZSAiY3NzL21haW4uY3NzIiB9fTwvc3R5bGU+CiAgPG1haW4+CiAgICB7eyB0ZW1wbGF0ZSAiaHRtbC9faGVhZGVyLmh0bWwiICQgfX0KICAgIDxkaXYgY2xhc3M9InByaWNpbmctaGVhZGVyIHB4LTMgcHktMyBwdC1tZC01IHBiLW1kLTQgbXgtYXV0byB0ZXh0LWNlbnRlciI+CiAgICAgIDxoMSBjbGFzcz0iZGlzcGxheS00Ij5CaWxsaW5nPC9oMT4KICAgIDwvZGl2PgogICAge3tpZiAuVXNlcn19CiAgICAgIDxkaXYgY2xhc3M9J2NvbnRhaW5lcic+CiAgICAgICAgPGRpdiBjbGFzcz0ncm93Jz4KICAgICAgICAgIDxkaXYgY2xhc3M9ImNvbC0xMiBjb2wtbWQtNiBvZmZzZXQtbWQtMyI+CiAgICAgICAgICAgIDxkaXYgY2xhc3M9J3RleHQtY2VudGVyJz4KICAgICAgICAgICAgICB7e2lmIC5Vc2VyLkhhc0VtYWlsfX0KICAgICAgICAgICAgICA8cD5VcGRhdGUgeW91ciBlbWFpbC48L3A+CiAgICAgICAgICAgICAge3tlbHNlfX0KICAgICAgICAgICAgICA8cD5TZXQgeW91ciBlbWFpbCBpbiBjYXNlIHlvdSBnZXQgbG9ja2VkIG91dCBvZiB5b3VyIGFjY291bnQuPC9wPgogICAgICAgICAgICAgIHt7ZW5kfX0KICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgIDxmb3JtIGFjdGlvbj0nL3VzZXIvdXBkYXRlL2VtYWlsJyBtZXRob2Q9UE9TVCBjbGFzcz0nbWItNSc+CiAgICAgICAgICAgICAgPGxhYmVsIGZvcj1lbWFpbD5FbWFpbDwvbGFiZWw+CiAgICAgICAgICAgICAgPGlucHV0IGlkPWVtYWlsIG5hbWU9ZW1haWwgdHlwZT1lbWFpbCBjbGFzcz0ibWItMyBmb3JtLWNvbnRyb2wiIHJlcXVpcmVkIHt7aWYgLlVzZXIuSGFzRW1haWx9fXZhbHVlPSJ7ey5Vc2VyLkVtYWlsfX0ie3tlbmR9fT4KICAgICAgICAgICAgICA8YnV0dG9uIHR5cGU9InN1Ym1pdCIgY2xhc3M9ImJ0biBidG4tcHJpbWFyeSI+R288L2J1dHRvbj4KICAgICAgICAgICAgPC9mb3JtPgogICAgICAgICAgICA8ZGl2IGNsYXNzPSd0ZXh0LWNlbnRlcic+CiAgICAgICAgICAgICAgPHA+VXBkYXRlIHlvdXIgcGFzc3dvcmQuPC9wPgogICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgPGZvcm0gYWN0aW9uPScvdXNlci91cGRhdGUvcGFzc3dvcmQnIG1ldGhvZD1QT1NUIGNsYXNzPSdtYi01Jz4KICAgICAgICAgICAgICA8bGFiZWwgZm9yPWN1cnJlbnQgPkN1cnJlbnQgUGFzc3dvcmQ8L2xhYmVsPgogICAgICAgICAgICAgIDxpbnB1dCBpZD1jdXJyZW50IG5hbWU9Y3VycmVudCB0eXBlPXBhc3N3b3JkIGNsYXNzPSJtYi0zIGZvcm0tY29udHJvbCIgcmVxdWlyZWQ+CiAgICAgICAgICAgICAgPGxhYmVsIGZvcj1wYXNzd29yZD5OZXcgUGFzc3dvcmQ8L2xhYmVsPgogICAgICAgICAgICAgIDxpbnB1dCBpZD1wYXNzd29yZCBuYW1lPXBhc3N3b3JkIHR5cGU9cGFzc3dvcmQgY2xhc3M9Im1iLTMgZm9ybS1jb250cm9sIiByZXF1aXJlZD4KICAgICAgICAgICAgICA8bGFiZWwgZm9yPXZlcmlmeT5WZXJpZnk8L2xhYmVsPgogICAgICAgICAgICAgIDxpbnB1dCBpZD12ZXJpZnkgbmFtZT12ZXJpZnkgdHlwZT1wYXNzd29yZCBjbGFzcz0ibWItMyBmb3JtLWNvbnRyb2wiIHJlcXVpcmVkPgogICAgICAgICAgICAgIDxidXR0b24gdHlwZT0ic3VibWl0IiBjbGFzcz0iYnRuIGJ0bi1wcmltYXJ5Ij5HbzwvYnV0dG9uPgogICAgICAgICAgICA8L2Zvcm0+CiAgICAgICAgICAgIHt7aWYgLlVzZXIgfCBwYWlkfX0KICAgICAgICAgICAgICA8Zm9ybSBhY3Rpb249Jy91c2VyL2NhbmNlbC9iaWxsaW5nJyBtZXRob2Q9UE9TVCBjbGFzcz0idGV4dC1jZW50ZXIiPgogICAgICAgICAgICAgICAgPHAgY2xhc3M9J21iLTUnPkNhbmNlbCB5b3VyIHt7LlVzZXIuT3JnLlRpZXIuTmFtZX19IHN1YnNjcmlwdGlvbi48L3A+CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJkLWlubGluZS1ibG9jayI+CiAgICAgICAgICAgICAgICAgIDxpbnB1dCB0eXBlPWhpZGRlbiBuYW1lPXRpZXIgdmFsdWU9Int7LlVzZXIuT3JnLlRpZXIuTmFtZX19IiAvPgogICAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJjYXJkIG1iLTQgc2hhZG93LXNtIj4KICAgICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0iY2FyZC1oZWFkZXIiPgogICAgICAgICAgICAgICAgICAgIDxoNCBjbGFzcz0ibXktMCBmb250LXdlaWdodC1ub3JtYWwiPnt7LlVzZXIuT3JnLlRpZXIuTmFtZX19PC9oND4KICAgICAgICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9ImNhcmQtYm9keSI+CiAgICAgICAgICAgICAgICAgICAgPGgxIGNsYXNzPSJjYXJkLXRpdGxlIHByaWNpbmctY2FyZC10aXRsZSI+e3suVXNlci5PcmcuVGllci5QcmljZX19IDxzbWFsbCBjbGFzcz0idGV4dC1tdXRlZCI+LyB7ey5Vc2VyLk9yZy5UaWVyLlRpbWVVbml0fX08L3NtYWxsPjwvaDE+CiAgICAgICAgICAgICAgICAgICAgPHVsIGNsYXNzPSJsaXN0LXVuc3R5bGVkIG10LTMgbWItNCI+CiAgICAgICAgICAgICAgICAgICAgICB7e3JhbmdlIC5Vc2VyLk9yZy5UaWVyLk9wdHN9fQogICAgICAgICAgICAgICAgICAgICAgICA8bGk+e3suVGV4dH19PC9saT4KICAgICAgICAgICAgICAgICAgICAgIHt7ZW5kfX0KICAgICAgICAgICAgICAgICAgICA8L3VsPgogICAgICAgICAgICAgICAgICAgIDxidXR0b24gdHlwZT0ic3VibWl0IiBjbGFzcz0iYnRuIGJ0bi1wcmltYXJ5IHctMTAwIj5DYW5jZWw8L2J1dHRvbj4KICAgICAgICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICA8L2Zvcm0+CiAgICAgICAgICAgIHt7ZWxzZX19CiAgICAgICAgICAgICAgPHAgY2xhc3M9J3RleHQtY2VudGVyIG1iLTUnPlVwZ3JhZGUgdG8gYSBwYWlkIHRpZXIuPGJyPkdldCBtb3JlIGFjY2Vzcy48L3A+CiAgICAgICAgICAgICAgPGRpdiBjbGFzcz0icm93IHJvdy1jb2xzLTEgcm93LWNvbHMtbWQtMiByb3ctY29scy1sZy0yIG1iLTUgdGV4dC1jZW50ZXIiPgogICAgICAgICAgICAgICAge3tyYW5nZSAuVGllcnN9fQogICAgICAgICAgICAgICAgICB7e2lmIG5vdCAoLnxpc0ZyZWUpfX0KICAgICAgICAgICAgICAgICAgICA8Zm9ybSBhY3Rpb249Jy91c2VyL3VwZGF0ZS9iaWxsaW5nJyBtZXRob2Q9UE9TVCBjbGFzcz0iY29sIj4KICAgICAgICAgICAgICAgICAgICAgIDxpbnB1dCB0eXBlPWhpZGRlbiBuYW1lPXRpZXIgdmFsdWU9Int7Lk5hbWV9fSIgLz4KICAgICAgICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9ImNhcmQgbWItNCBzaGFkb3ctc20iPgogICAgICAgICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0iY2FyZC1oZWFkZXIiPgogICAgICAgICAgICAgICAgICAgICAgICA8aDQgY2xhc3M9Im15LTAgZm9udC13ZWlnaHQtbm9ybWFsIj57ey5OYW1lfX08L2g0PgogICAgICAgICAgICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJjYXJkLWJvZHkiPgogICAgICAgICAgICAgICAgICAgICAgICA8aDEgY2xhc3M9ImNhcmQtdGl0bGUgcHJpY2luZy1jYXJkLXRpdGxlIj57ey5QcmljZX19IDxzbWFsbCBjbGFzcz0idGV4dC1tdXRlZCI+LyB7ey5UaW1lVW5pdH19PC9zbWFsbD48L2gxPgogICAgICAgICAgICAgICAgICAgICAgICA8dWwgY2xhc3M9Imxpc3QtdW5zdHlsZWQgbXQtMyBtYi00Ij4KICAgICAgICAgICAgICAgICAgICAgICAgICB7e3JhbmdlIC5PcHRzfX0KICAgICAgICAgICAgICAgICAgICAgICAgICAgIDxsaT57ey5UZXh0fX08L2xpPgogICAgICAgICAgICAgICAgICAgICAgICAgIHt7ZW5kfX0KICAgICAgICAgICAgICAgICAgICAgICAgPC91bD4KICAgICAgICAgICAgICAgICAgICAgICAgPGJ1dHRvbiB0eXBlPSJzdWJtaXQiIGNsYXNzPSJidG4gYnRuLXByaW1hcnkgdy0xMDAiPkdvPC9idXR0b24+CiAgICAgICAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICAgICAgICA8L2Zvcm0+CiAgICAgICAgICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgICAgICAgIHt7ZW5kfX0KICAgICAgICAgICAgICAgIHt7ZW5kfX0KICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAge3tlbmR9fQogICAgICAgICAgPC9kaXY+CiAgICAgICAgPC9kaXY+CiAgICAgIDwvZGl2PgogICAge3tlbHNlfX0KICAgICAgPGRpdiBjbGFzcz0nY29udGFpbmVyJz4KICAgICAgICA8ZGl2IGNsYXNzPSdyb3cnPgogICAgICAgICAgPGRpdiBjbGFzcz0iY29sLTEyIj4KICAgICAgICAgICAgPGgxPk9vcHM8L2gxPgogICAgICAgICAgICA8cD5Tb3JyeSwgb3VyIGRldmVsb3BlcnMgYXJlIGxhenkuIFRoaXMgc2hvdWxkIHJlYWxseSByZWRpcmVjdCB5b3UuPC9wPgogICAgICAgICAgPC9kaXY+CiAgICAgICAgPC9kaXY+CiAgICAgIDwvZGl2PgogICAge3tlbmR9fQogICAge3sgdGVtcGxhdGUgImh0bWwvX2Zvb3Rlci5odG1sIiAkIH19CiAgPC9tYWluPgogIHt7IHRlbXBsYXRlICJodG1sL19zY3JpcHRzLmh0bWwiIH19CjwvYm9keT4KPC9odG1sPgo=")

	tmpls["html/contact.html"] = tostring("PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ZW4+CjxoZWFkPgogIHt7IHRlbXBsYXRlICJodG1sL19oZWFkLmh0bWwiIH19CiAgPHRpdGxlPlNraXBwZXIgQ01TIHwgQ29udGFjdDwvdGl0bGU+CjwvaGVhZD4KPGJvZHkgY2xhc3M9J3BhZ2UgYmctbGlnaHQnPgogIDxzdHlsZT57eyB0ZW1wbGF0ZSAiY3NzL21haW4uY3NzIiB9fTwvc3R5bGU+CiAgPG1haW4+CiAgICB7eyB0ZW1wbGF0ZSAiaHRtbC9faGVhZGVyLmh0bWwiICQgfX0KICAgIDxkaXYgY2xhc3M9InByaWNpbmctaGVhZGVyIHB4LTMgcHktMyBwdC1tZC01IHBiLW1kLTQgbXgtYXV0byB0ZXh0LWNlbnRlciI+CiAgICAgIDxoMSBjbGFzcz0iZGlzcGxheS00Ij5Db250YWN0PC9oMT4KICAgIDwvZGl2PgogICAgPGRpdiBjbGFzcz0nY29udGFpbmVyJz4KICAgICAgPGRpdiBjbGFzcz0ncm93Jz4KICAgICAgICA8ZGl2IGNsYXNzPSJjb2wtMTIgb2Zmc2V0LTAgY29sLWxnLTggb2Zmc2V0LWxnLTIiPgogICAgICAgICAgSGkuIE15IG5hbWUgaXMgRXZhbi4gSSdtIHRoZSBvbmx5IHNvdWwgY3VycmVudGx5IHdvcmtpbmcgb24gU2tpcHBlcgogICAgICAgICAgQ01TLiBJZiB5b3UgbmVlZCBzb21lb25lIHRvIGNvbnRhY3QgSSdtIHRoYXQgc29tZW9uZS4gWW91IGNhbiByZWFjaCBtZQogICAgICAgICAgYXQgbWUgQVQgZXZhbmpvbiBET1QgZXMgb3IgdmlhIFR3aXR0ZXIsIAogICAgICAgICAgPGEgaHJlZj0naHR0cHM6Ly90d2l0dGVyLmNvbS9taW5pZWdnczQwJz5AbWluaWVnZ3M0MDwvYT4uIENhb2khCiAgICAgICAgPC9kaXY+CiAgICAgIDwvZGl2PgogICAgPC9kaXY+CiAgICB7eyB0ZW1wbGF0ZSAiaHRtbC9fZm9vdGVyLmh0bWwiICQgfX0KICA8L21haW4+CiAge3sgdGVtcGxhdGUgImh0bWwvX3NjcmlwdHMuaHRtbCIgfX0KPC9ib2R5Pgo8L2h0bWw+Cg==")