~evanj/smscp

7a88549857e7b5c3e9afd5a221cdcbda0e62b658 — Evan M Jones 8 months ago fadbfb7
Feat(CLI file support + Starting Stripe integration): File support has
been added to CLI. It's the MVP and should be treated as WIP. It's a
quick impl. to play and iterate w/. Stripe integration has just begun.
Much to do!
M cmd/smscp/main.go => cmd/smscp/main.go +23 -5
@@ 22,7 22,6 @@ import (

const (
	// BASE        = "http://localhost:3000"
	// BASE        = "https://beta.smscp.xyz"
	BASE        = "https://smscp.xyz"
	APILogin    = BASE + "/cli/user/login"
	APIRegister = BASE + "/cli/user/create"


@@ 189,6 188,13 @@ func login(c *cli.Context) error {
}

func create(c *cli.Context) error {
	// TODO:
	// 1. Check if this is a file
	//    a. If so read file.
	// 2. Check if contents have mime type
	//    a. If so send to server as file.
	// 3. Otherwise business as usual.

	usr, err := user.Current()
	if err != nil {
		return errors.Wrap(err, "failed to retrieve current user from operating system")


@@ 212,10 218,22 @@ func create(c *cli.Context) error {
		return err
	}

	resp, err := post(APICreate, hash{
		"Token": cfg.Token,
		"Text":  string(text),
	})
	var resp *http.Response
	// If has a file type other than plain text upload to server as a file.
	contentType := http.DetectContentType(text)
	if strings.Contains(contentType, "text/plain") {
		// Otherwise business as usual.
		resp, err = post(APICreate, hash{
			"Token": cfg.Token,
			"Text":  string(text),
		})
	} else if contentType != "" {
		// Upload as file to server.
		resp, err = post(APICreate, hash{
			"Token": cfg.Token,
			"File":  string(text),
		})
	}
	if err != nil {
		return errors.Wrap(err, "failed to create request to remote server")
	}

A cmd/smscp/smscp => cmd/smscp/smscp +0 -0

M go.mod => go.mod +1 -0
@@ 13,6 13,7 @@ require (
	github.com/pkg/errors v0.8.1
	github.com/sfreiberg/gotwilio v0.0.0-20191120211240-38187998ae52
	github.com/stretchr/testify v1.4.0
	github.com/stripe/stripe-go v69.4.0+incompatible
	github.com/tdewolff/minify v2.3.6+incompatible
	github.com/tdewolff/parse v2.3.4+incompatible // indirect
	github.com/tdewolff/test v1.0.6 // indirect

M go.sum => go.sum +2 -0
@@ 131,6 131,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stripe/stripe-go v69.4.0+incompatible h1:KLp4h/eAe0J+FNTZSHh6sVETSKYxSUifumkxJFNMZpg=
github.com/stripe/stripe-go v69.4.0+incompatible/go.mod h1:A1dQZmO/QypXmsL0T8axYZkSN/uA/T/A64pfKdBAMiY=
github.com/tdewolff/minify v2.3.6+incompatible h1:2hw5/9ZvxhWLvBUnHE06gElGYz+Jv9R4Eys0XUzItYo=
github.com/tdewolff/minify v2.3.6+incompatible/go.mod h1:9Ov578KJUmAWpS6NeZwRZyT56Uf6o3Mcz9CEsg8USYs=
github.com/tdewolff/parse v2.3.4+incompatible h1:x05/cnGwIMf4ceLuDMBOdQ1qGniMoxpP46ghf0Qzh38=

M internal/api/api.go => internal/api/api.go +40 -9
@@ 4,6 4,7 @@ import (
	"context"
	"fmt"
	"io/ioutil"
	"mime"
	"net/http"
	"net/url"
	"os"


@@ 219,10 220,8 @@ func (app App) NoteCreate(c *gin.Context) {
}

func (app App) NoteCreateCLI(c *gin.Context) {
	// TODO: File support?

	var payload struct {
		Token, Text string
		Token, Text, File string
	}

	err := c.Bind(&payload)


@@ 237,14 236,46 @@ func (app App) NoteCreateCLI(c *gin.Context) {
		return
	}

	note, err := app.data.NoteCreate(c, user, payload.Text)
	if err != nil {
		app.errorCLI(c, err)
		return
	var noteText string
	var mediaURL string

	if payload.Text != "" {
		note, err := app.data.NoteCreate(c, user, payload.Text)
		if err != nil {
			app.errorCLI(c, errors.Wrap(err, "failed to create note"))
			return
		}
		noteText = note.Text()
	}

	if err := app.sms.Send(user.Phone(), note.Text(), ""); err != nil {
		app.errorCLI(c, err)
	if payload.File != "" {
		contentType := http.DetectContentType([]byte(payload.File))
		exts, err := mime.ExtensionsByType(contentType)
		if err != nil {
			app.errorCLI(c, errors.Wrap(err, "failed to find file type"))
			return
		}

		ext := exts[0]
		fn := fmt.Sprintf("user_upload%s", ext)

		url, err := app.file.Upload([]byte(payload.File), fn)
		if err != nil {
			app.errorCLI(c, errors.Wrap(err, "failed to upload file"))
			return
		}

		media, err := app.data.MediaCreate(c, user, url)
		if err != nil {
			app.errorCLI(c, errors.Wrap(err, "failed to create file record"))
			return
		}

		mediaURL = fmt.Sprintf("%s?usertoken=%s", media.Text(), user.Token())
	}

	if err := app.sms.Send(user.Phone(), noteText, mediaURL); err != nil {
		app.error(c, errors.Wrap(err, "failed to send text"))
		return
	}


M internal/common/common.go => internal/common/common.go +5 -0
@@ 14,6 14,11 @@ type User interface {
	SetPass(string)
	SetPhone(string)
	Save(context.Context) error

	// For Stripe.
	HasPlan(planID string) bool
	StripeSession() (id string, ok bool)
	HasStripeSession() bool
}

type Note interface {

M internal/fs/fs.go => internal/fs/fs.go +30 -0
@@ 6,6 6,7 @@ import (
	"log"
	"os"
	"path/filepath"
	"strings"
	"time"

	"cloud.google.com/go/firestore"


@@ 306,6 307,11 @@ func (fs FS) NoteCreate(ctx context.Context, user common.User, text string) (com
	return fs.noteCreate(ctx, user, text, "")
}

func (fs FS) UserAddStripePlan(ctx context.Context, stripeSessionID, planID string) error {
	// TODO: Complete this.
	return errors.New("NOT COMPLETE")
}

func (fs FS) UserGet(ctx context.Context, token string) (common.User, error) {
	claims, err := fs.sec.TokenFrom(token)
	if err != nil {


@@ 420,6 426,10 @@ type User struct {
	UserEncryptedPassword string
	UserCreatedAt         int64

	// For Stripe.
	UserStripePlans   []string
	UserStripeSession string // The current Stripe session user is in (i.e. currently making payment).

	// Set when retrieved:
	token string
	fs    FS


@@ 434,6 444,26 @@ func (user *User) Phone() string    { return user.UserPhone }
func (user *User) ID() string       { return user.ref.ID }
func (user *User) Token() string    { return user.token }

func (user *User) HasPlan(planID string) bool {
	for _, plan := range user.UserStripePlans {
		if strings.Contains(plan, planID) {
			return true
		}
	}
	return false
}

func (user *User) HasStripeSession() bool {
	return false
}

func (user *User) StripeSession() (id string, ok bool) {
	if user.UserStripeSession == "" {
		return "", false
	}
	return user.UserStripeSession, true
}

func (user *User) SetUsername(value string) {
	ctx := context.Background()


A internal/stripe/stripe.go => internal/stripe/stripe.go +77 -0
@@ 0,0 1,77 @@
package stripe

import (
	"errors"
	"fmt"

	"github.com/gin-gonic/gin"
	s "github.com/stripe/stripe-go"
	"github.com/stripe/stripe-go/checkout/session"
)

type Stripe struct {
	key, monthlyBasic, baseURL string
}

func New(key, monthlyBasic, baseURL string) *Stripe {
	return &Stripe{
		key,
		monthlyBasic,
		baseURL,
	}
}

func (stripe Stripe) CreateMonthlyBascic() (string, error) {
	p := &s.CheckoutSessionParams{
		PaymentMethodTypes: s.StringSlice([]string{
			"card",
		}),
		SubscriptionData: &s.CheckoutSessionSubscriptionDataParams{
			Items: []*s.CheckoutSessionSubscriptionDataItemsParams{
				&s.CheckoutSessionSubscriptionDataItemsParams{
					Plan: s.String(stripe.monthlyBasic),
				},
			},
		},
		// SuccessURL: s.String("https://example.com/success?session_id={CHECKOUT_SESSION_ID}"),
		SuccessURL: s.String(fmt.Sprintf("%s", stripe.baseURL)),
		CancelURL:  s.String(stripe.baseURL),
	}

	sesh, err := session.New(p)
	if err != nil {
		return "", err
	}

	return sesh.ID, nil
}

type HookPayload struct {
	ID           string // This will be same as sesh.ID above.
	DisplayItems []struct {
		Plan struct {
			ID string // This will be the same as monthlyBasic above.
		}
	}
}

func (stripe Stripe) Hook(c *gin.Context) (checkoutSessionID string, paymentPlanIDs []string, err error) {
	var payload HookPayload
	err = c.Bind(&payload)
	if err != nil {
		return
	}

	if len(payload.DisplayItems) < 1 {
		err = errors.New("no purchases")
		return
	}

	checkoutSessionID = payload.ID

	for _, item := range payload.DisplayItems {
		paymentPlanIDs = append(paymentPlanIDs, item.Plan.ID)
	}

	return checkoutSessionID, paymentPlanIDs, nil
}

M pkg/builder/builder.go => pkg/builder/builder.go +3 -0
@@ 11,6 11,7 @@ import (
	"git.evanjon.es/minieggs/smscp/internal/fs"
	"git.evanjon.es/minieggs/smscp/internal/securetoken"
	"git.evanjon.es/minieggs/smscp/internal/sms/twilio"
	"git.evanjon.es/minieggs/smscp/internal/stripe"

	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/cookie"


@@ 28,6 29,8 @@ func Build() *gin.Engine {
	// User reset forms are only valid for five minutes.
	tok := securetoken.New(sec, data, "UserToken", 5*time.Minute)
	e3 := e3.New(os.Getenv("E3_USERNAME"), os.Getenv("E3_PASSWORD"))
	stripe := stripe.New(os.Getenv("STRIPE_KEY"), os.Getenv("STRIPE_MONTHLY_BASIC"), os.Getenv("BASE_URL"))
	_ = stripe
	app := api.AppDefault(data, sms, csv, sec, tok, e3)

	// gin

M web/html/_main.js => web/html/_main.js +17 -0
@@ 423,5 423,22 @@
  })();
  {{ end }}

  // TODO: Complete.
  {{ if .User.HasStripeSession }}
  (function() {
    var stripeScript = 'https://js.stripe.com/v3/'
    var el = document.createElement('script')
    el.setAttribute('src', stripeScript)
    el.addEventListener('load', function() { 
      stripe.redirectToCheckout({
        sessionId: '{{ .User.StripeSession }}'
      }).then(function (result) {
        console.log({result: result, error: result.error})
      });
    })
    document.body.appendChild(el)
  })();
  {{ end }}

{{ end }}