~evanj/smscp

fadbfb7fda67d14e370a29aba63b3f1fecc89332 — Evan M Jones 8 months ago cb72bac
Feat(file support): File supporting working. Needs a bit of cleanup but
mostly  there.
M go.mod => go.mod +1 -1
@@ 18,7 18,7 @@ require (
	github.com/tdewolff/test v1.0.6 // indirect
	github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 // indirect
	github.com/ttacon/libphonenumber v1.0.1
	github.com/urfave/cli/v2 v2.1.1 // indirect
	golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d // indirect
	golang.org/x/exp v0.0.0-20191129062945-2f5052295587
	golang.org/x/sync v0.0.0-20190423024810-112230192c58
	google.golang.org/api v0.14.0

M go.sum => go.sum +2 -9
@@ 30,8 30,6 @@ github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P
github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=


@@ 123,12 121,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sfreiberg/gotwilio v0.0.0-20191120211240-38187998ae52 h1:eSEqKfQ/c2IiETwCvVyhJfkrv50zgWFSe0Pr6X1n/II=
github.com/sfreiberg/gotwilio v0.0.0-20191120211240-38187998ae52/go.mod h1:dhtsjtHOWmTLjCOyNloce1diOIs9H1mvVmcOG7qmZUc=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=


@@ 152,9 146,6 @@ github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/urfave/cli v1.22.2 h1:gsqYFH8bb9ekPA12kRo0hfjngWQjkJPlN9R0N78BoUo=
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=


@@ 163,6 154,8 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1 h1:anGSYQpPhQwXlwsu5wmfq0nWkCNaMEMUwAv13Y92hd8=
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d h1:1ZiEyfaQIg3Qh0EoqpwAakHVhecoE5wlSg5GjnafJGw=
golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=

M internal/api/api.go => internal/api/api.go +102 -28
@@ 7,7 7,9 @@ import (
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"git.evanjon.es/minieggs/errors"


@@ 53,6 55,9 @@ type dataLayer interface {
	NoteGetLatest(ctx context.Context, user common.User) (common.Note, error)
	NoteGetLatestWithTime(ctx context.Context, user common.User, t time.Duration) (common.Note, error)
	NoteCreate(ctx context.Context, user common.User, text string) (common.Note, error)
	// media
	MediaCreate(ctx context.Context, user common.User, URL string) (common.Media, error)
	MediaGet(ctx context.Context, user common.User, URL string) (common.Media, error)
	// special gdpr
	UserAll(context.Context, common.User) ([]common.Note, error)
	UserDel(context.Context, common.User) error


@@ 63,7 68,7 @@ type csvLayer interface {
}

type smsLayer interface {
	Send(number, text string) error
	Send(number, text, mediaURL string) error
	Hook(c *gin.Context) (number, text string, medias []string, err error)
}



@@ 108,34 113,38 @@ func (app App) HookSMS(c *gin.Context) {
		return
	}

	// Create note for each media.
	eg := errgroup.Group{}
	if user.Admin() {
		// Create all medias.
		eg := errgroup.Group{}

		for _, media := range medias {
			func(fromURL string) {
				eg.Go(func() error {
					url, err := app.file.TwilioUpload(fromURL)
					if err != nil {
						return err
					}

					if _, err := app.data.MediaCreate(c, user, url); err != nil {
						return err
					}

	for _, media := range medias {
		func(fromURL string) {
			eg.Go(func() error {
				toURL, err := app.file.TwilioUpload(fromURL)
				if err != nil {
					return nil
				}
				if _, err := app.data.NoteCreate(c, user, toURL); err != nil {
					return err
				}
				return nil
			})
		}(media)
	}
				})
			}(media)
		}

	if err := eg.Wait(); err != nil {
		app.error(c, err)
		return
		if err := eg.Wait(); err != nil {
			app.errorCLI(c, err)
			return
		}
	}

	// If there is text in the received message we always want to create that as
	// the latest note.
	if text != "" {
		if _, err := app.data.NoteCreate(c, user, text); err != nil {
			app.error(c, err)
			app.errorCLI(c, err)
			return
		}
	}


@@ 148,15 157,46 @@ func (app App) NoteCreate(c *gin.Context) {
		Text string
	}

	err := c.Bind(&payload)
	user, err := app.currentUser(c)
	if err != nil {
		app.error(c, err)
		app.error(c, errors.New("not logged in; or something else terribly wrong"))
		return
	}

	user, err := app.currentUser(c)
	if err != nil {
		app.error(c, errors.New("not logged in; or something else terribly wrong"))
	var mediaURL string

	file, header, err := c.Request.FormFile("File")
	if err == nil {
		// We have a file! Let's upload it.
		bytes, err := ioutil.ReadAll(file)
		if err != nil {
			app.error(c, err)
			return
		}

		url, err := app.file.Upload(bytes, header.Filename)
		if err != nil {
			app.error(c, err)
			return
		}

		media, err := app.data.MediaCreate(c, user, url)
		if err != nil {
			app.error(c, err)
			return
		}

		// TODO: Should we really send this as a text? Or send with reg SMS message?
		// if err := app.sms.Send(user.Phone(), media.Text()); err != nil {
		// 	app.error(c, err)
		// 	return
		// }

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

	if err := c.Bind(&payload); err != nil {
		app.error(c, err)
		return
	}



@@ 166,7 206,7 @@ func (app App) NoteCreate(c *gin.Context) {
		return
	}

	if err := app.sms.Send(user.Phone(), note.Text()); err != nil {
	if err := app.sms.Send(user.Phone(), note.Text(), mediaURL); err != nil {
		app.error(c, err)
		return
	}


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

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

	var payload struct {
		Token, Text string
	}


@@ 201,7 243,7 @@ func (app App) NoteCreateCLI(c *gin.Context) {
		return
	}

	if err := app.sms.Send(user.Phone(), note.Text()); err != nil {
	if err := app.sms.Send(user.Phone(), note.Text(), ""); err != nil {
		app.errorCLI(c, err)
		return
	}


@@ 641,7 683,7 @@ func (app App) UserForgotPassword(c *gin.Context) {
		return
	}

	err = app.sms.Send(user.Phone(), passwordResetForeword+fmt.Sprintf(app.cfg.resetPassswordLink, tokenID))
	err = app.sms.Send(user.Phone(), passwordResetForeword+fmt.Sprintf(app.cfg.resetPassswordLink, tokenID), "")
	if err != nil {
		app.formError(c, common.NewUserForgotFormError(errors.Wrap(err, "failed to send sms")))
		return


@@ 702,3 744,35 @@ func (app App) UserForgotPasswordNewPassword(c *gin.Context) {

	c.Redirect(http.StatusTemporaryRedirect, "/")
}

func (app App) MediaGet(c *gin.Context) {
	user, err := app.currentUser(c)
	if err != nil {
		// Or try it from query param (this is how Twilio receives image).
		user, err = app.currentUserFromToken(c, c.Query("usertoken"))
		if err != nil {
			app.error(c, errors.New("not logged in; or something else terribly wrong"))
			return
		}
	}

	filename := c.Param("id")
	id := strings.TrimSuffix(filename, filepath.Ext(filename))

	media, err := app.data.MediaGet(c, user, id)
	if err != nil {
		app.error(c, err)
		return
	}

	// Raw is the URL on e3's side.
	bytes, err := app.file.Get(media.Raw())
	if err != nil {
		app.error(c, err)
		return
	}

	c.Writer.Header().Add("Cache-Control", "maxage=31536000, s-maxage=31536000") // Cache for one year.
	c.Writer.WriteHeader(http.StatusOK)
	c.Writer.Write(bytes)
}

M internal/common/common.go => internal/common/common.go +6 -0
@@ 6,6 6,7 @@ import (

type User interface {
	ID() string
	Admin() bool
	Username() string
	Phone() string
	Token() string /* Stored in session, secret, unique per session. */


@@ 19,9 20,14 @@ type Note interface {
	ID() string
	Short() string
	Text() string
	Raw() string
	Token() string /* Unique per note (i.e. like an ID), only let author see. */
}

type Media interface {
	Note
}

// Reset is used for password resets.
type Reset interface {
	ID() string

M internal/e3/e3.go => internal/e3/e3.go +34 -7
@@ 6,6 6,7 @@ import (
	"io/ioutil"
	"mime/multipart"
	"net/http"
	"path/filepath"
	"strings"

	"git.evanjon.es/minieggs/errors"


@@ 25,7 26,7 @@ func New(username, password string) *E3 {

func (e3 E3) TwilioUpload(fromURL string) (toURL string, err error) {
	// Get image from Twilio.
	twilioResp, err := http.Get(fromURL)
	twilioResp, err := http.Get(strings.TrimSuffix(fromURL, filepath.Ext(fromURL)))
	if err != nil {
		return "", errors.Wrap(err, "failed to contact twilio servers")
	}


@@ 48,8 49,11 @@ func (e3 *E3) Upload(imgBytes []byte, imageName string) (url string, err error) 
	buf := bytes.Buffer{}
	formWriter := multipart.NewWriter(&buf)
	formFile, err := formWriter.CreateFormFile("data", imageName)
	if err != nil {
		return "", err
	}
	io.Copy(formFile, fileReader)
	defer formWriter.Close()
	formWriter.Close() // NO DEFER! It messed this up. File won't be sent to e3.

	// Upload image to e3.
	req, err := http.NewRequest("POST", baseURL, &buf)


@@ 68,17 72,40 @@ func (e3 *E3) Upload(imgBytes []byte, imageName string) (url string, err error) 
	// Receive final URL and return.
	body, err := ioutil.ReadAll(e3Resp.Body)
	if err != nil {
		return "", errors.Wrap(err, "failed to read results from e3 object storage")
		return "", errors.Wrap(err, "failed to read result from e3 object storage")
	}
	if e3Resp.StatusCode != http.StatusOK {
		return "", errors.New(string(body))
	}

	// TODO: Proxy this url.
	return strings.TrimSpace(string(body)), nil
}

func (e3 *E3) Get(url string) ([]byte, error) {
	// TODO: Not complete.
	return []byte(""), errors.New("not complete")
func (e3 *E3) Get(url string) (_image []byte, err error) {
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		err = errors.Wrap(err, "failed to create request to e3 object storage")
		return
	}
	req.SetBasicAuth(e3.username, e3.password)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		err = errors.Wrap(err, "failed to complete request to e3 object storage")
		return
	}
	defer resp.Body.Close()

	// Receive image bytes and return.
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		err = errors.Wrap(err, "failed to read result from e3 object storage")
		return
	}
	if resp.StatusCode != http.StatusOK {
		err = errors.New(string(body))
		return
	}

	return body, nil
}

M internal/fs/fs.go => internal/fs/fs.go +80 -4
@@ 2,8 2,10 @@ package fs

import (
	"context"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"time"

	"cloud.google.com/go/firestore"


@@ 60,6 62,23 @@ func (fs FS) snaptouser(ctx context.Context, doc *firestore.DocumentSnapshot) (c
	return &user, nil
}

func (fs FS) snaptonote(ctx context.Context, doc *firestore.DocumentSnapshot) (common.Note, error) {
	note := Note{ref: doc.Ref}
	if err := doc.DataTo(&note); err != nil {
		return nil, errors.Wrap(err, "note value corrupted")
	}

	token, err := fs.sec.TokenCreate(security.TokenMap{"NoteID": note.ID()})
	if err != nil {
		return nil, errors.Wrap(err, "failed to create unique token for note")
	}

	note.token = token
	note.fs = fs

	return &note, nil
}

func (fs FS) itertouser(ctx context.Context, iter *firestore.DocumentIterator) (common.User, error) {
	doc, err := iter.Next()
	if err != nil {


@@ 158,10 177,10 @@ func (fs FS) NoteGetLatest(ctx context.Context, user common.User) (common.Note, 

	doc, err := iter.Next()
	if err == iterator.Done {
		return nil, nil
		return nil, errors.New("no note")
	}
	if err != nil {
		return nil, errors.Wrap(err, "failed to find note")
		return nil, errors.New("failed to find note")
	}

	note := Note{ref: doc.Ref}


@@ 194,7 213,7 @@ func (fs FS) NoteGetLatestWithTime(ctx context.Context, user common.User, t time
		return nil, nil
	}
	if err != nil {
		return nil, errors.Wrap(err, "failed to find note")
		return nil, errors.New("failed to find note")
	}

	note := Note{ref: doc.Ref}


@@ 257,10 276,11 @@ func (fs FS) NoteGetList(ctx context.Context, user common.User, page, count int)
	return ret, hasMore, nil
}

func (fs FS) NoteCreate(ctx context.Context, user common.User, text string) (common.Note, error) {
func (fs FS) noteCreate(ctx context.Context, user common.User, text, raw string) (common.Note, error) {
	note := Note{
		ref:           fs.conn.Collection("notes").NewDoc(),
		NoteText:      text,
		NoteRaw:       raw,
		NoteShort:     fs.toshort(text),
		NoteCreatedAt: time.Now().UTC().Unix(),
		UserID:        user.ID(),


@@ 282,6 302,10 @@ func (fs FS) NoteCreate(ctx context.Context, user common.User, text string) (com
	return &note, nil
}

func (fs FS) NoteCreate(ctx context.Context, user common.User, text string) (common.Note, error) {
	return fs.noteCreate(ctx, user, text, "")
}

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


@@ 404,6 428,7 @@ type User struct {
	err error
}

func (user *User) Admin() bool      { return user.UserUsername == "minieggs" }
func (user *User) Username() string { return user.UserUsername }
func (user *User) Phone() string    { return user.UserPhone }
func (user *User) ID() string       { return user.ref.ID }


@@ 468,6 493,7 @@ func (user *User) Save(ctx context.Context) error {
type Note struct {
	ref           *firestore.DocumentRef
	NoteText      string
	NoteRaw       string
	NoteShort     string
	NoteCreatedAt int64



@@ 481,5 507,55 @@ type Note struct {

func (Note Note) Short() string { return Note.NoteShort }
func (Note Note) Text() string  { return Note.NoteText }
func (Note Note) Raw() string   { return Note.NoteRaw }
func (Note Note) ID() string    { return Note.ref.ID }
func (Note Note) Token() string { return Note.token }

// media has the same backend as note. This is on purpose.

type Media struct {
	Note
}

func (fs FS) MediaCreate(ctx context.Context, user common.User, fromURL string) (common.Media, error) {
	if !user.Admin() {
		return nil, errors.New("you are not allowed to do that")
	}

	// TODO: Non optimal way to do this. FIX.
	cMedia, err := fs.noteCreate(ctx, user, fromURL, fromURL)
	if err != nil {
		return nil, err
	}

	media, ok := cMedia.(*Note)
	if !ok {
		return nil, errors.New("media has became corrupted")
	}

	// TODO: Real URL.
	toURL := fmt.Sprintf("https://smscp.xyz/media/%s%s", media.ID(), filepath.Ext(fromURL))
	media.NoteText = toURL
	media.NoteShort = fs.toshort(toURL)

	if _, err := fs.conn.Collection("notes").Doc(media.ID()).Set(ctx, media); err != nil {
		return nil, err
	}

	return media, nil
}

func (fs FS) MediaGet(ctx context.Context, user common.User, id string) (common.Media, error) {
	if !user.Admin() {
		return nil, errors.New("you are not allowed to do that")
	}

	snap, err := fs.conn.Collection("notes").Doc(id).Get(ctx)
	if err != nil {
		return nil, errors.New("failed to find media")
	}

	media, err := fs.snaptonote(ctx, snap)

	return media, err
}

M internal/sms/twilio/twilio.go => internal/sms/twilio/twilio.go +78 -23
@@ 2,6 2,7 @@ package twilio

import (
	"fmt"
	"mime"
	"strings"

	"git.evanjon.es/minieggs/errors"


@@ 21,14 22,27 @@ func Default(id, secret, from string) SMS { return SMS{id, secret, from} }

// Send pushes the `text` to `to` via Twilio. May return an error from
// `twilio.SendMMS`.
func (sms SMS) Send(to, text string) error {
func (sms SMS) Send(to, text, mediaURL string) error {
	twilio := gotwilio.NewTwilioClient(sms.id, sms.secret)
	if _, _, err := twilio.SendMMS(sms.from, to, text, "", "", ""); err != nil {
	if _, _, err := twilio.SendMMS(sms.from, to, text, mediaURL, "", ""); err != nil {
		return errors.Wrap(err, "failed to send message")
	}
	return nil
}

func getmedia(fn, ext string) (string, error) {
	exts, err := mime.ExtensionsByType(ext)
	if err != nil {
		return "", err
	}

	if len(exts) < 1 {
		return "", errors.New("no mine found")
	}

	return fmt.Sprintf("%s%s", fn, exts[0]), nil
}

// Hook handles webhooks from Twilio. Used to receive text messages from users.
// Returns user phone number, user text, and potential error from reading and
// parsing payload from Twilio.


@@ 38,51 52,92 @@ func (sms SMS) Hook(c *gin.Context) (_number, _text string, _medias []string, _e
	var payload struct {
		Body, From, FromCountry string
		// Yep, nice API you got there, Twilio.
		MediaUrl0 string
		MediaUrl1 string
		MediaUrl2 string
		MediaUrl3 string
		MediaUrl4 string
		MediaUrl5 string
		MediaUrl6 string
		MediaUrl7 string
		MediaUrl8 string
		MediaUrl9 string
		MediaContentType0, MediaUrl0 string
		MediaContentType1, MediaUrl1 string
		MediaContentType2, MediaUrl2 string
		MediaContentType3, MediaUrl3 string
		MediaContentType4, MediaUrl4 string
		MediaContentType5, MediaUrl5 string
		MediaContentType6, MediaUrl6 string
		MediaContentType7, MediaUrl7 string
		MediaContentType8, MediaUrl8 string
		MediaContentType9, MediaUrl9 string
	}
	if err := c.Bind(&payload); err != nil {
		return "", "", medias, err
	}

	// Annoying.
	// Annoying. My Lord.

	if payload.MediaUrl0 != "" {
		medias = append(medias, payload.MediaUrl0)
		media, err := getmedia(payload.MediaUrl0, payload.MediaContentType0)
		if err != nil {
			return "", "", medias, err
		}
		medias = append(medias, media)
	}
	if payload.MediaUrl1 != "" {
		medias = append(medias, payload.MediaUrl1)
		media, err := getmedia(payload.MediaUrl1, payload.MediaContentType1)
		if err != nil {
			return "", "", medias, err
		}
		medias = append(medias, media)
	}
	if payload.MediaUrl2 != "" {
		medias = append(medias, payload.MediaUrl2)
		media, err := getmedia(payload.MediaUrl2, payload.MediaContentType2)
		if err != nil {
			return "", "", medias, err
		}
		medias = append(medias, media)
	}
	if payload.MediaUrl3 != "" {
		medias = append(medias, payload.MediaUrl3)
		media, err := getmedia(payload.MediaUrl3, payload.MediaContentType3)
		if err != nil {
			return "", "", medias, err
		}
		medias = append(medias, media)
	}
	if payload.MediaUrl4 != "" {
		medias = append(medias, payload.MediaUrl4)
		media, err := getmedia(payload.MediaUrl4, payload.MediaContentType4)
		if err != nil {
			return "", "", medias, err
		}
		medias = append(medias, media)
	}
	if payload.MediaUrl5 != "" {
		medias = append(medias, payload.MediaUrl5)
		media, err := getmedia(payload.MediaUrl5, payload.MediaContentType5)
		if err != nil {
			return "", "", medias, err
		}
		medias = append(medias, media)
	}
	if payload.MediaUrl6 != "" {
		medias = append(medias, payload.MediaUrl6)
		media, err := getmedia(payload.MediaUrl6, payload.MediaContentType6)
		if err != nil {
			return "", "", medias, err
		}
		medias = append(medias, media)
	}
	if payload.MediaUrl7 != "" {
		medias = append(medias, payload.MediaUrl7)
		media, err := getmedia(payload.MediaUrl7, payload.MediaContentType7)
		if err != nil {
			return "", "", medias, err
		}
		medias = append(medias, media)
	}
	if payload.MediaUrl8 != "" {
		medias = append(medias, payload.MediaUrl8)
		media, err := getmedia(payload.MediaUrl8, payload.MediaContentType8)
		if err != nil {
			return "", "", medias, err
		}
		medias = append(medias, media)
	}
	if payload.MediaUrl9 != "" {
		medias = append(medias, payload.MediaUrl9)
		media, err := getmedia(payload.MediaUrl9, payload.MediaContentType9)
		if err != nil {
			return "", "", medias, err
		}
		medias = append(medias, media)
	}

	phone, err := libphonenumber.Parse(payload.From, payload.FromCountry)

M pkg/builder/builder.go => pkg/builder/builder.go +3 -0
@@ 59,6 59,9 @@ func Build() *gin.Engine {
	router.POST("/note/create", app.NoteCreate)
	router.GET("/note/list/:page", app.NoteListJSON)

	// media
	router.GET("/media/:id", app.MediaGet)

	// cli
	router.POST("/cli/user/login", app.UserLoginCLI)
	router.POST("/cli/user/create", app.UserCreateCLI)

M web/html/_main.js => web/html/_main.js +22 -1
@@ 14,8 14,29 @@
  }
})();

// for fake and real file input
(function() { 
  var fake = document.querySelector('input[id="fake-file"]')
  var real = document.querySelector('input[id="real-file"]')
  if (!fake || !real) return;

  fake.addEventListener('click', moveInput)
  fake.addEventListener('focus', moveInput)
  function moveInput() { 
    fake.blur()
    real.click()
  }

  real.addEventListener('input', onInput)
  real.addEventListener('change', onInput)
  function onInput() { 
    if (real.files.length > 0) fake.value = real.files[0].name
    else fake.value = ''
  }
})();

// form errors, scroll to 
(function () {
(function() {
  // get first form error and scroll to it
  window.addEventListener('load', function() {
    var input = document.querySelector('input.border-red-500')

M web/html/main.html => web/html/main.html +11 -1
@@ 46,7 46,7 @@
        <div class='max-w-4xl mx-auto w-full rounded'>
          <div class='bg-white shadow-xl rounded p-5 pt-0'>

            <form id='create' action='/note/create' method='POST' class='py-5'>
            <form id='create' action='/note/create' method='POST' class='py-5' enctype='multipart/form-data'>
              <fieldset>
                <legend class='block text-gray-700 text-xl font-bold mb-5'>
                  Send to phone


@@ 69,6 69,16 @@
                           class='appearance-none border w-full py-2 px-3
                                  rounded-l text-gray-700 leading-tight focus:outline-none text-md' />

                    {{ if .User.Admin }}
                    <input class="bg-gray-200 hover:bg-gray-300 text-gray-500 hover:text-gray-600
                                  font-bold py-2 px-4  text-md cursor-pointer"
                           id='fake-file'
                           value='file'
                           type="button"/>

                    <input class='hidden' id='real-file' type=file name=File multiple=false>
                    {{ end }}

                    <input class="bg-gray-200 hover:bg-gray-300 text-gray-500 hover:text-gray-600
                                  font-bold py-2 px-4  text-md cursor-pointer"
                           id='large'