summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlexander Kiryukhin <a.kiryukhin@mail.ru>2021-03-18 02:06:42 +0300
committerAlexander Kiryukhin <a.kiryukhin@mail.ru>2021-03-18 02:06:42 +0300
commit6f31f35c7b38fbf63d7a0c9322458e0b75828495 (patch)
tree2fcb8cb31bb6604e85cf390dbc01f2e9a8b26ee7
Initial
-rw-r--r--.gitignore1
-rw-r--r--Dockerfile29
-rw-r--r--go.mod12
-rw-r--r--go.sum70
-rw-r--r--internal/encryption/encryption.go93
-rw-r--r--internal/renderer/renderer.go45
-rw-r--r--internal/storer/storer.go124
-rw-r--r--main.go127
-rw-r--r--public/css/index.css280
-rw-r--r--templates/includes/layout.gohtml19
-rw-r--r--templates/pages/error.gohtml5
-rw-r--r--templates/pages/index.gohtml21
-rw-r--r--templates/pages/memo.gohtml13
-rw-r--r--templates/pages/notfound.gohtml10
-rw-r--r--templates/pages/save.gohtml13
15 files changed, 862 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8450c95
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+memos.db
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bb4712f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,29 @@
+FROM golang:1.16-alpine AS builder
+
+COPY ${PWD} /app
+WORKDIR /app
+
+# Toggle CGO based on your app requirement. CGO_ENABLED=1 for enabling CGO
+RUN CGO_ENABLED=0 go build -ldflags '-s -w -extldflags "-static"' -o /app/appbin *.go
+# Use below if using vendor
+# RUN CGO_ENABLED=0 go build -mod=vendor -ldflags '-s -w -extldflags "-static"' -o /app/appbin *.go
+
+FROM alpine:latest
+
+# Following commands are for installing CA certs (for proper functioning of HTTPS and other TLS)
+RUN apk --update add ca-certificates && \
+ rm -rf /var/cache/apk/*
+
+# Add new user 'appuser'
+RUN adduser -D appuser
+USER appuser
+
+COPY --from=builder /app /home/appuser/app
+
+WORKDIR /home/appuser/app
+
+# Since running as a non-root user, port bindings < 1024 is not possible
+# 8000 for HTTP; 8443 for HTTPS;
+EXPOSE 3000
+
+CMD ["./appbin"]
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..981d790
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,12 @@
+module github.com/neonxp/sendsafe
+
+go 1.16
+
+require (
+ github.com/boltdb/bolt v1.3.1
+ github.com/dgraph-io/badger v1.6.2
+ github.com/go-chi/chi/v5 v5.0.1
+ github.com/google/uuid v1.2.0
+ github.com/rs/xid v1.2.1
+ golang.org/x/sys v0.0.0-20210317091845-390168757d9c // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..1fc8f3c
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,70 @@
+github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M=
+github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
+github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
+github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
+github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgraph-io/badger v1.6.2 h1:mNw0qs90GVgGGWylh0umH5iag1j6n/PeJtNvL6KY/x8=
+github.com/dgraph-io/badger v1.6.2/go.mod h1:JW2yswe3V058sS0kZ2h/AXeDSqFjxnZcRrVH//y2UQE=
+github.com/dgraph-io/ristretto v0.0.2 h1:a5WaUrDa0qm0YrAAS1tUykT5El3kt62KNZZeMxQn3po=
+github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E=
+github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
+github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/go-chi/chi/v5 v5.0.1 h1:ALxjCrTf1aflOlkhMnCUP86MubbWFrzB3gkRPReLpTo=
+github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
+github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
+github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=
+github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
+github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
+github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
+github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
+github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
+golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210317091845-390168757d9c h1:WGyvPg8lhdtSkb8BiYWdtPlLSommHOmJHFvzWODI7BQ=
+golang.org/x/sys v0.0.0-20210317091845-390168757d9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/internal/encryption/encryption.go b/internal/encryption/encryption.go
new file mode 100644
index 0000000..0f9c748
--- /dev/null
+++ b/internal/encryption/encryption.go
@@ -0,0 +1,93 @@
+package encryption
+
+import (
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "io"
+ "strings"
+)
+
+func addBase64Padding(value string) string {
+ m := len(value) % 4
+ if m != 0 {
+ value += strings.Repeat("=", 4-m)
+ }
+
+ return value
+}
+
+func removeBase64Padding(value string) string {
+ return strings.Replace(value, "=", "", -1)
+}
+
+func pad(src []byte) []byte {
+ padding := aes.BlockSize - len(src)%aes.BlockSize
+ padtext := bytes.Repeat([]byte{byte(padding)}, padding)
+
+ return append(src, padtext...)
+}
+
+func Unpad(src []byte) ([]byte, error) {
+ length := len(src)
+ unpadding := int(src[length-1])
+
+ if unpadding > length {
+ return nil, errors.New("unpad error. This could happen when incorrect encryption key is used")
+ }
+
+ return src[:(length - unpadding)], nil
+}
+
+func Encrypt(key []byte, text string) (string, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
+ }
+
+ msg := pad([]byte(text))
+ ciphertext := make([]byte, aes.BlockSize+len(msg))
+ iv := ciphertext[:aes.BlockSize]
+
+ if _, err := io.ReadFull(rand.Reader, iv); err != nil {
+ return "", err
+ }
+
+ cfb := cipher.NewCFBEncrypter(block, iv)
+ cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(msg))
+ finalMsg := removeBase64Padding(base64.URLEncoding.EncodeToString(ciphertext))
+
+ return finalMsg, nil
+}
+
+func Decrypt(key []byte, text string) (string, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
+ }
+
+ decodedMsg, err := base64.URLEncoding.DecodeString(addBase64Padding(text))
+ if err != nil {
+ return "", err
+ }
+
+ if (len(decodedMsg) % aes.BlockSize) != 0 {
+ return "", errors.New("blocksize must be multipe of decoded message length")
+ }
+
+ iv := decodedMsg[:aes.BlockSize]
+ msg := decodedMsg[aes.BlockSize:]
+
+ cfb := cipher.NewCFBDecrypter(block, iv)
+ cfb.XORKeyStream(msg, msg)
+
+ unpadMsg, err := Unpad(msg)
+ if err != nil {
+ return "", err
+ }
+
+ return string(unpadMsg), nil
+}
diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go
new file mode 100644
index 0000000..bed603c
--- /dev/null
+++ b/internal/renderer/renderer.go
@@ -0,0 +1,45 @@
+package renderer
+
+import (
+ "html/template"
+ "net/http"
+ "path"
+ "path/filepath"
+)
+
+// Renderer represents html template renderer
+type Renderer struct {
+ templates map[string]*template.Template
+}
+
+// New Renderer
+func New(gpath string) (*Renderer, error) {
+ t := &Renderer{
+ templates: make(map[string]*template.Template),
+ }
+
+ includes, err := filepath.Glob(path.Join(gpath, "includes/*.gohtml"))
+ if err != nil {
+ return nil, err
+ }
+
+ pages, err := filepath.Glob(path.Join(gpath, "pages/*.gohtml"))
+ if err != nil {
+ return nil, err
+ }
+
+ for _, p := range pages {
+ tpls := append(includes, p)
+ tname := path.Base(p)
+ t.templates[tname] = template.Must(template.New(tname).ParseFiles(tpls...))
+ }
+
+ return t, nil
+}
+
+func (r *Renderer) Render(tpl string, data Map, rw http.ResponseWriter) error {
+ rw.Header().Set("Content-Type", "text/html")
+ return r.templates[tpl].Execute(rw, data)
+}
+
+type Map map[string]interface{}
diff --git a/internal/storer/storer.go b/internal/storer/storer.go
new file mode 100644
index 0000000..b8b4ffd
--- /dev/null
+++ b/internal/storer/storer.go
@@ -0,0 +1,124 @@
+package storer
+
+import (
+ "bytes"
+ "encoding/gob"
+ "time"
+
+ "github.com/dgraph-io/badger"
+ "github.com/neonxp/sendsafe/internal/encryption"
+ "github.com/rs/xid"
+)
+
+type Store struct {
+ db *badger.DB
+}
+
+func New(dbFile string) (*Store, error) {
+ db, err := badger.Open(badger.DefaultOptions(dbFile))
+ if err != nil {
+ return nil, err
+ }
+
+ return &Store{
+ db: db,
+ }, nil
+}
+
+func (s *Store) Save(text string, pin string, ttl int) (string, error) {
+ var err error
+
+ encrypted := false
+
+ if pin != "" {
+ text, err = encryption.Encrypt([]byte(pin), text)
+ if err != nil {
+ return "", err
+ }
+
+ encrypted = true
+ }
+
+ record := memo{
+ Text: text,
+ Encrypted: encrypted,
+ }
+
+ buf := bytes.NewBuffer([]byte{})
+ if err := gob.NewEncoder(buf).Encode(record); err != nil {
+ return "", err
+ }
+
+ id := xid.New()
+ err = s.db.Update(func(txn *badger.Txn) error {
+ return txn.SetEntry(&badger.Entry{
+ Key: id.Bytes(),
+ Value: buf.Bytes(),
+ ExpiresAt: uint64(time.Now().Add(time.Duration(ttl) * time.Minute).Unix()),
+ })
+ })
+
+ return id.String(), err
+}
+
+func (s *Store) IsEncrypted(id string) (bool, error) {
+ var encrypted bool
+
+ return encrypted, s.db.View(func(txn *badger.Txn) error {
+ value, err := txn.Get([]byte(id))
+ if err != nil {
+ return err
+ }
+ record := new(memo)
+ return value.Value(func(val []byte) error {
+ if err := gob.NewDecoder(bytes.NewBuffer(val)).Decode(record); err != nil {
+ return err
+ }
+ encrypted = record.Encrypted
+ return nil
+ })
+ })
+}
+
+func (s *Store) Get(id string, pin string) (string, error) {
+ var text string
+
+ return text, s.db.Update(func(txn *badger.Txn) error {
+ uid, err := xid.FromString(id)
+ if err != nil {
+ return err
+ }
+ value, err := txn.Get(uid.Bytes())
+ if err != nil {
+ return err
+ }
+ record := new(memo)
+ err = value.Value(func(val []byte) error {
+ if err := gob.NewDecoder(bytes.NewBuffer(val)).Decode(record); err != nil {
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ text = record.Text
+ if record.Encrypted {
+ text, err = encryption.Decrypt([]byte(pin), text)
+ if err != nil {
+ return err
+ }
+ }
+ return txn.Delete(uid.Bytes())
+ })
+}
+
+func init() {
+ gob.Register(memo{})
+}
+
+type memo struct {
+ Text string
+ Encrypted bool
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..f3a9c22
--- /dev/null
+++ b/main.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "strconv"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/google/uuid"
+
+ "github.com/neonxp/sendsafe/internal/renderer"
+ "github.com/neonxp/sendsafe/internal/storer"
+)
+
+var workDir, _ = os.Getwd()
+
+func main() {
+ s, err := storer.New(path.Join(workDir, "memos.db"))
+ if err != nil {
+ log.Fatal(err)
+ }
+ r := chi.NewRouter()
+ r.Use(middleware.Logger)
+ r.Use(middleware.Recoverer)
+ r.Use(middleware.Compress(9))
+
+ tpl, err := renderer.New(path.Join(workDir, "templates"))
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ r.Get("/", func(rw http.ResponseWriter, _ *http.Request) {
+ csrf := uuid.NewString()
+ http.SetCookie(rw, &http.Cookie{
+ Name: "csrf",
+ Value: csrf,
+ Expires: time.Now().Add(30 * time.Minute),
+ HttpOnly: true,
+ })
+
+ if err := tpl.Render("index.gohtml", renderer.Map{"csrf": csrf}, rw); err != nil {
+ log.Println(err)
+ _ = tpl.Render("error.gohtml", nil, rw)
+ }
+ })
+
+ r.Post("/save", func(rw http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ log.Println(err)
+ _ = tpl.Render("error.gohtml", nil, rw)
+ return
+ }
+ secret := r.FormValue("secret")
+ days, err := strconv.Atoi(r.FormValue("days"))
+ if err != nil {
+ log.Println(err)
+ _ = tpl.Render("error.gohtml", nil, rw)
+ return
+ }
+ hours, err := strconv.Atoi(r.FormValue("hours"))
+ if err != nil {
+ log.Println(err)
+ _ = tpl.Render("error.gohtml", nil, rw)
+ return
+ }
+ minutes, err := strconv.Atoi(r.FormValue("minutes"))
+ if err != nil {
+ log.Println(err)
+ _ = tpl.Render("error.gohtml", nil, rw)
+ return
+ }
+ pin := r.FormValue("pin")
+ csrf := r.FormValue("csrf")
+ csrfCookie, err := r.Cookie("csrf")
+ if err != nil {
+ log.Println(err)
+ _ = tpl.Render("error.gohtml", nil, rw)
+ return
+ }
+ if csrfCookie.Value != csrf {
+ log.Println(err)
+ _ = tpl.Render("error.gohtml", nil, rw)
+ return
+ }
+ ttl := days*24*60 + hours*60 + minutes
+ id, err := s.Save(secret, pin, ttl)
+ if err != nil {
+ log.Println(err)
+ _ = tpl.Render("error.gohtml", nil, rw)
+ return
+ }
+ if err := tpl.Render("save.gohtml", renderer.Map{"id": id}, rw); err != nil {
+ log.Println(err)
+ _ = tpl.Render("error.gohtml", nil, rw)
+ }
+ })
+
+ r.Get("/s/{id}", func(rw http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ secret, err := s.Get(id, "")
+ if err != nil {
+ log.Println(err)
+ _ = tpl.Render("notfound.gohtml", nil, rw)
+ return
+ }
+ if err := tpl.Render("memo.gohtml", renderer.Map{"secret": secret}, rw); err != nil {
+ log.Println(err)
+ _ = tpl.Render("error.gohtml", nil, rw)
+ }
+ })
+
+ filesDir := http.Dir(filepath.Join(workDir, "public"))
+
+ r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
+ fs := http.StripPrefix("/", http.FileServer(filesDir))
+ fs.ServeHTTP(w, r)
+ })
+
+ if err := http.ListenAndServe(":3000", r); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/public/css/index.css b/public/css/index.css
new file mode 100644
index 0000000..db341af
--- /dev/null
+++ b/public/css/index.css
@@ -0,0 +1,280 @@
+* {
+ --active: rgb(43, 43, 48);
+ --active-inner: #f3f3f3;
+ --focus: 2px rgba(39, 94, 254, .3);
+ --border: #a3a3a3;
+ --border-hover: #f3f3f3;
+ --background: rgb(255, 255, 255);
+ --background-secondary: rgb(230, 230, 230);
+ --disabled: rgb(43, 43, 48);
+ --disabled-inner: #rgb(43, 43, 48);
+ --text: #000;
+}
+
+@media (prefers-color-scheme: dark) {
+ * {
+ --active: rgb(43, 43, 48);
+ --active-inner: #f3f3f3;
+ --focus: 2px rgba(39, 94, 254, .3);
+ --border: #f3f3f3;
+ --border-hover: #f3f3f3;
+ --background: rgb(31, 32, 39);
+ --background-secondary: rgb(43, 43, 48);
+ --disabled: rgb(43, 43, 48);
+ --disabled-inner: #rgb(43, 43, 48);
+ --text: #f3f3f3;
+ }
+}
+
+html, body {
+ padding: 0;
+ margin: 0;
+ background-color: var(--background);
+ color: var(--text);
+ font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
+ font-size: 16px;
+}
+
+.turbolinks-progress-bar {
+ height: 5px;
+ background-color: rgb(52, 52, 175);
+}
+
+a, a:hover, a:active, a:visited {
+ color: var(--text);
+}
+
+body {
+ padding-top: 60px;
+}
+
+header {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 50px;
+ width: 100%;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ background-color: var(--background);
+}
+
+hr {
+ width: auto;
+ border-top: 0;
+ border-bottom: 1px solid var(--border);
+ margin: 16px 8px;
+}
+
+h1 {
+ margin: 16px 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+#header-logo {
+ display: block;
+ padding: 16px;
+ text-decoration: none;
+ font-family: 'Courier New', Courier, monospace;
+ cursor: pointer;
+}
+
+.header-menu {
+ list-style: none;
+ display: flex;
+ flex-direction: row;
+}
+
+.header-menu .header-menu--item {
+ transition: background 0.3s, border-color 0.3s, box-shadow 0.2s;
+ display: inline-block;
+ padding: 16px;
+ text-decoration: none;
+}
+
+.header-menu .header-menu--item:hover {
+ background-color: var(--background-secondary);
+}
+
+.layout-center {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-around;
+}
+
+form {
+ display: flex;
+ flex-direction: column;
+ /* max-width: 50vh; */
+ width: 600px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 16px;
+}
+
+form .required {
+ font-size: 14px;
+ font-style: italic;
+ color: rgb(255, 100, 100);
+ align-self: flex-end;
+}
+
+form .optional {
+ font-size: 14px;
+ font-style: italic;
+ align-self: flex-end;
+}
+
+form label {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.form-row {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+input, textarea {
+ background-color: var(--background-secondary);
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 8px;
+ margin: 8px;
+ border-radius: 4px;
+}
+
+textarea {
+ resize: vertical;
+}
+
+label {
+ padding: 8px;
+}
+
+input:active {
+ background-color: rgb(33, 33, 38);
+}
+
+@supports (-webkit-appearance: none) or (-moz-appearance: none) {
+ input[type=checkbox], input[type=radio] {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ height: 21px;
+ outline: none;
+ display: inline-block;
+ vertical-align: top;
+ position: relative;
+ margin: 0 8px 0 0;
+ cursor: pointer;
+ border: 1px solid var(--bc, var(--border));
+ background: var(--b, var(--background-secondary));
+ transition: background 0.3s, border-color 0.3s, box-shadow 0.2s;
+ }
+ input[type=checkbox]:after, input[type=radio]:after {
+ content: "";
+ display: block;
+ left: 0;
+ top: 0;
+ position: absolute;
+ transition: transform var(--d-t, 0.3s) var(--d-t-e, ease), opacity var(--d-o, 0.2s);
+ }
+ input[type=checkbox]:checked, input[type=radio]:checked {
+ --b: var(--active);
+ --bc: var(--active);
+ --d-o: .3s;
+ --d-t: .6s;
+ --d-t-e: cubic-bezier(.2, .85, .32, 1.2);
+ }
+ input[type=checkbox]:disabled, input[type=radio]:disabled {
+ --b: var(--disabled);
+ cursor: not-allowed;
+ opacity: 0.9;
+ }
+ input[type=checkbox]:disabled:checked, input[type=radio]:disabled:checked {
+ --b: var(--disabled-inner);
+ --bc: var(--border);
+ }
+ input[type=checkbox]:disabled+label, input[type=radio]:disabled+label {
+ cursor: not-allowed;
+ }
+ input[type=checkbox]:hover:not(:checked):not(:disabled), input[type=radio]:hover:not(:checked):not(:disabled) {
+ --bc: var(--border-hover);
+ }
+ input[type=checkbox]:focus, input[type=radio]:focus {
+ box-shadow: 0 0 0 var(--focus);
+ }
+ input[type=checkbox]:not(.switch), input[type=radio]:not(.switch) {
+ width: 21px;
+ }
+ input[type=checkbox]:not(.switch):after, input[type=radio]:not(.switch):after {
+ opacity: var(--o, 0);
+ }
+ input[type=checkbox]:not(.switch):checked, input[type=radio]:not(.switch):checked {
+ --o: 1;
+ }
+ input[type=checkbox]+label, input[type=radio]+label {
+ font-size: 14px;
+ line-height: 21px;
+ display: inline-block;
+ vertical-align: top;
+ cursor: pointer;
+ margin-left: 4px;
+ }
+ input[type=checkbox]:not(.switch) {
+ border-radius: 7px;
+ }
+ input[type=checkbox]:not(.switch):after {
+ width: 5px;
+ height: 9px;
+ border: 2px solid var(--active-inner);
+ border-top: 0;
+ border-left: 0;
+ left: 7px;
+ top: 4px;
+ transform: rotate(var(--r, 20deg));
+ }
+ input[type=checkbox]:not(.switch):checked {
+ --r: 43deg;
+ }
+ input[type=checkbox].switch {
+ width: 38px;
+ border-radius: 11px;
+ }
+ input[type=checkbox].switch:after {
+ left: 2px;
+ top: 2px;
+ border-radius: 50%;
+ width: 15px;
+ height: 15px;
+ background: var(--ab, var(--border));
+ transform: translateX(var(--x, 0));
+ }
+ input[type=checkbox].switch:checked {
+ --ab: var(--active-inner);
+ --x: 17px;
+ }
+ input[type=checkbox].switch:disabled:not(:checked):after {
+ opacity: 0.6;
+ }
+ input[type=radio] {
+ border-radius: 50%;
+ }
+ input[type=radio]:after {
+ width: 19px;
+ height: 19px;
+ border-radius: 50%;
+ background: var(--active-inner);
+ opacity: 0;
+ transform: scale(var(--s, 0.7));
+ }
+ input[type=radio]:checked {
+ --s: .5;
+ }
+}
diff --git a/templates/includes/layout.gohtml b/templates/includes/layout.gohtml
new file mode 100644
index 0000000..adebd99
--- /dev/null
+++ b/templates/includes/layout.gohtml
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>SendSafe</title>
+ <link rel="stylesheet" href="/css/index.css" />
+ <script src="https://unpkg.com/turbolinks/dist/turbolinks.js" crossorigin="anonymous"></script>
+</head>
+
+<body>
+<header>
+ <a id="header-logo" href="/"></> SendSafe</a>
+</header>
+{{template "content" .}}
+</body>
+</html>
diff --git a/templates/pages/error.gohtml b/templates/pages/error.gohtml
new file mode 100644
index 0000000..8d63bbd
--- /dev/null
+++ b/templates/pages/error.gohtml
@@ -0,0 +1,5 @@
+{{template "layout.gohtml" .}}
+{{define "content"}}
+<h1>Error happened.</h1>
+<h2>Nothing can be done.</h2>
+{{ end }}
diff --git a/templates/pages/index.gohtml b/templates/pages/index.gohtml
new file mode 100644
index 0000000..8cfcae1
--- /dev/null
+++ b/templates/pages/index.gohtml
@@ -0,0 +1,21 @@
+{{template "layout.gohtml" .}}
+{{define "content"}}
+<div class="layout-center">
+ <form method="POST" action="/save">
+ <h1>New secret</h1>
+ <label for="secret">Secret content:<span class="required">(required)</span></label>
+ <textarea id="secret" name="secret" rows="6" required></textarea>
+ <label><span>Expires after (or <b>first</b> read):</span><span class="required">(required)</span></label>
+ <div class="form-row">
+ <label><input id="days" name="days" type="number" value="0" min="0" max="365" required />days</label>
+ <label><input id="hours" name="hours" type="number" value="0" min="0" max="23" required />hours</label>
+ <label><input id="minutes" name="minutes" type="number" value="30" min="0" max="59" required />minutes</label>
+ </div>
+ {{/* <label for="pin">Pin code to open:<span class="optional">(optional)</span></label> */}}
+ {{/* <input id="pin" name="pin" type="string" value="" /> */}}
+ <hr />
+ <input type="hidden" name="csrf" value={{.csrf}} />
+ <input type="submit" value="Send" />
+ </form>
+</div>
+{{ end }}
diff --git a/templates/pages/memo.gohtml b/templates/pages/memo.gohtml
new file mode 100644
index 0000000..804d7b7
--- /dev/null
+++ b/templates/pages/memo.gohtml
@@ -0,0 +1,13 @@
+{{template "layout.gohtml" .}}
+{{define "content"}}
+<div class="layout-center">
+ <form action="/">
+ <h1>Secret:</h1>
+ <b>Warning!</b> This text already deleted from server! If you refresh or close page - secret will be completely lost!
+ <hr />
+ <pre>{{.secret}}</pre>
+ <hr />
+ <input type="submit" value="&larr; Back">
+ </form>
+</div>
+{{ end }}
diff --git a/templates/pages/notfound.gohtml b/templates/pages/notfound.gohtml
new file mode 100644
index 0000000..911f8e1
--- /dev/null
+++ b/templates/pages/notfound.gohtml
@@ -0,0 +1,10 @@
+{{template "layout.gohtml" .}}
+{{define "content"}}
+<div class="layout-center">
+ <form action="/">
+ <h1>Not found.</h1>
+ <p>Link expired or already viewed.</p>
+ <input type="submit" value="&larr; Back">
+ </form>
+</div>
+{{ end }}
diff --git a/templates/pages/save.gohtml b/templates/pages/save.gohtml
new file mode 100644
index 0000000..f940502
--- /dev/null
+++ b/templates/pages/save.gohtml
@@ -0,0 +1,13 @@
+{{template "layout.gohtml" .}}
+{{define "content"}}
+<div class="layout-center">
+ <form action="/">
+ <h1>Saved</h1>
+ Secret url:
+ <hr />
+ <a href="https://sendsafe.xyz/s/{{.id}}">https://sendsafe.xyz/s/{{.id}}</a>
+ <hr />
+ <input type="submit" value="&larr; Back">
+ </form>
+</div>
+{{ end }}