├── .gitignore ├── CHANGELOG ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── cache ├── badgerCache.go ├── badgerCache_test.go ├── cache.go ├── cache_test.go └── setup_test.go ├── docs ├── cover.png └── logo.png ├── driver.go ├── go.mod ├── go.sum ├── helpers.go ├── install.sh ├── mailer ├── mail_test.go ├── mailer.go ├── setup_test.go └── testdata │ └── mail │ ├── test.html.tmpl │ └── test.plain.tmpl ├── microGo.go ├── middleware.go ├── migrations.go ├── render ├── render.go ├── render_test.go └── setup_test.go ├── requests ├── requests.go └── responses.go ├── responce-utils.go ├── routes.go ├── session ├── session.go ├── session_test.go └── setup_test.go ├── terminal └── cli │ ├── admin.go │ ├── auth.go │ ├── files.go │ ├── helpers.go │ ├── main.go │ ├── make.go │ ├── migrate.go │ ├── new.go │ ├── session.go │ └── templates │ ├── data │ ├── model.go.txt │ ├── remember_token.go.txt │ ├── token.go.txt │ └── user.go.txt │ ├── env.txt │ ├── go.mod.txt │ ├── handlers │ ├── auth-handlers.go.txt │ └── handler.go.txt │ ├── mailer │ ├── mail.html.tmpl │ ├── mail.plain.tmpl │ ├── reset-password.html.tmpl │ └── reset-password.plain.tmpl │ ├── middleware │ ├── auth-token.go.txt │ ├── auth.go.txt │ └── remember.go.txt │ ├── migrations │ ├── admin_tables.mysql.sql │ ├── admin_tables.postgress.sql │ ├── auth_tables.mysql.sql │ ├── auth_tables.postgres.sql │ ├── migration.mysql.down.sql │ ├── migration.mysql.up.sql │ ├── migration.postgres.down.sql │ ├── migration.postgres.up.sql │ ├── mysql_session.sql │ └── postgres_session.sql │ └── views │ ├── forgot.html │ ├── login.html │ └── reset-password.html ├── types.go ├── url_signer ├── signer.go └── token.go ├── utilities.go └── validator.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /app/microGo 3 | /microGo/vendor 4 | app/.DS_Store 5 | app/data/.DS_Store 6 | app/middleware/auth-token.go 7 | app/middleware/auth.go 8 | app/middleware/remember.go 9 | app/data/token.go 10 | app/data/user.go 11 | app/data/remember_token.go 12 | app/handlers/auth-handlers.go 13 | app/mail/reset-password.html.tmpl 14 | app/mail/reset-password.plain.tmpl 15 | app/migrations 16 | app/tmp 17 | app/vendor 18 | app/migrations/*.sql 19 | cache/testdata/tmp/badger/* 20 | app/views/login.jet 21 | app/views/forgot.jet 22 | app/views/reset-password.jet 23 | .vscode/* 24 | .DS_Store 25 | dist/* -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | ### Version 1.0.9 2 | - Fix bug for go templates to render data 3 | ### Version 1.0.8 4 | - Remove .page prefix for go templates 5 | - Update Dependencies 6 | ### Version 1.0.7 7 | - Fix install script for MacOS and Linux for ARM architecture 8 | - Fix bug for mailgun api 9 | - Fix bug for sendgrid api 10 | - Fix bug for sendinblue api 11 | - Fix bug for mysql and mariadb database for timestamp fields 12 | - Update README.md 13 | - Update install script 14 | ### Version 1.0.5 15 | - Create Request package 16 | - Fix import issues 17 | - Fix bug in `get` method 18 | - Fix bug in `post` method 19 | - Fix bug in `put` method 20 | ### Version 1.0.4 21 | - Create install script for MacOS 22 | - Fix import issues 23 | ### Version 1.0.3 24 | ### Version 1.0.2 25 | ### Version 1.0.1 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Christos Ploutarchou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ## test: runs all tests 2 | test: 3 | @go test -v ./... 4 | ## cover: opens coverage in browser 5 | cover: 6 | @go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out 7 | ## coverage: displays test coverage 8 | coverage: 9 | @go test -cover ./... 10 | 11 | ## build_cli: builds the command line tool microGo and copies it to app folder 12 | build_cli: 13 | @go build -o ../app/microGo ./terminal/cli 14 | 15 | ## build: builds the command line tool dist directory 16 | build: 17 | @go build -o ./dist/microGo ./terminal/cli 18 | # windows users should delete the line above this one, and use the line below instead (uncommented) 19 | #@go build -o dist/microGo.exe ./cmd/cli -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](https://raw.githubusercontent.com/cploutarchou/MicroGO/master/docs/cover.png) 2 | # The Go version of Django Framework 3 | 4 | In MicroGO, I take some of the most valuable features in Django and implement similar functionality in Go. 5 | 6 | Since Go is compiled and type-safe, web applications written in this language are typically much faster and far less 7 | error-prone than an equivalent application, Django, written in Python. 8 | 9 | ## Requirements 10 | make sure you have the following dependencies: 11 | 1. make - utility for building and maintaining groups of programs. 12 | 2. GoLang - the compiler that MicroGO uses. 13 | 14 | ### How to use MicroGO 15 | 1. Download or clone MicroGO repository from [GitHub](https://github.com/cploutarchou/MicroGO.git) 16 | 2. Run make build command in the root directory of MicroGO. 17 | 18 | ## Alternative ways to install MicroGO binaries 19 | Currently, the auto install script is only available for Linux OS fo other OS, you can manually install MicroGO binaries. See section [How to use MicroGO](#how-to-use-microgo) for more information.: 20 | 1. Download the binaries from [GitHub Releases](https://github.com/cploutarchou/MicroGO/releases) 21 | 2. Or run the following command: 22 | ```bash 23 | curl -L https://raw.githubusercontent.com/cploutarchou/MicroGO/master/install.sh | bash 24 | ``` 25 | ### MicroGO Terminal Commands: 26 | 27 | * **help** - Show the help commands 28 | * **version** - Print application version 29 | * **make auth** - Create and runs migrations for auth tables, create models and middleware. 30 | * **migrate** - Runs all up migrations that have not been run previously 31 | * **migrate down** - Reverses the most recent migration 32 | * **migrate reset** - Runs all down migrations in reverse order, and then all up migrations 33 | * **make migration migration_name** - Create two new up and down migrations in the migrations folder 34 | * **make handler handler_name** - Create a stub handler on handlers directory 35 | * **make model model_name** - Create a new mode in the models directory 36 | * **make key** - Create a random key of 32 characters. 37 | * **make mail** - Create two starter mail templates in the mail directory. 38 | 39 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/donate?hosted_button_id=EH6BNRFVPZ63N) 40 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 0.0.x | :white_check_mark: | 11 | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Use this section to tell people how to report a vulnerability. 16 | 17 | Tell them where to go, how often they can expect to get an update on a 18 | reported vulnerability, what to expect if the vulnerability is accepted or 19 | declined, etc. 20 | -------------------------------------------------------------------------------- /cache/badgerCache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "github.com/dgraph-io/badger/v3" 5 | "time" 6 | ) 7 | 8 | type BadgerCache struct { 9 | Connection *badger.DB 10 | Prefix string 11 | } 12 | 13 | func (b *BadgerCache) Exists(str string) (bool, error) { 14 | _, err := b.Get(str) 15 | if err != nil { 16 | return false, nil 17 | } 18 | return true, nil 19 | } 20 | 21 | func (b *BadgerCache) Get(str string) (interface{}, error) { 22 | var fromCache []byte 23 | err := b.Connection.View(func(txn *badger.Txn) error { 24 | item, err := txn.Get([]byte(str)) 25 | if err != nil { 26 | return err 27 | } 28 | err = item.Value(func(val []byte) error { 29 | fromCache = append([]byte{}, val...) 30 | return nil 31 | }) 32 | if err != nil { 33 | return err 34 | } 35 | return nil 36 | }) 37 | if err != nil { 38 | return nil, err 39 | } 40 | decoded, err := decode(string(fromCache)) 41 | if err != nil { 42 | return nil, err 43 | } 44 | item := decoded[str] 45 | return item, nil 46 | } 47 | 48 | func (b *BadgerCache) Set(str string, value interface{}, expires ...int) error { 49 | entry := Entry{} 50 | entry[str] = value 51 | encoded, err := encode(entry) 52 | if err != nil { 53 | return err 54 | } 55 | if len(expires) > 0 { 56 | err = b.Connection.Update(func(txn *badger.Txn) error { 57 | e := badger.NewEntry([]byte(str), encoded).WithTTL(time.Second * time.Duration(expires[0])) 58 | err = txn.SetEntry(e) 59 | return err 60 | }) 61 | } else { 62 | err = b.Connection.Update(func(txn *badger.Txn) error { 63 | e := badger.NewEntry([]byte(str), encoded) 64 | err = txn.SetEntry(e) 65 | return err 66 | }) 67 | } 68 | return nil 69 | } 70 | 71 | func (b *BadgerCache) Delete(str string) error { 72 | err := b.Connection.Update(func(txn *badger.Txn) error { 73 | err := txn.Delete([]byte(str)) 74 | return err 75 | }) 76 | 77 | return err 78 | } 79 | 80 | func (b *BadgerCache) DeleteIfMatch(str string) error { 81 | return b.deleteIfMatch(str) 82 | } 83 | 84 | func (b *BadgerCache) Clean() error { 85 | return b.deleteIfMatch("") 86 | } 87 | func (b *BadgerCache) deleteIfMatch(str string) error { 88 | deleteKeys := func(keysForDelete [][]byte) error { 89 | if err := b.Connection.Update(func(txn *badger.Txn) error { 90 | for _, key := range keysForDelete { 91 | if err := txn.Delete(key); err != nil { 92 | return err 93 | } 94 | } 95 | return nil 96 | }); err != nil { 97 | return err 98 | } 99 | return nil 100 | } 101 | 102 | collectSize := 100000 103 | 104 | err := b.Connection.View(func(txn *badger.Txn) error { 105 | opts := badger.DefaultIteratorOptions 106 | opts.AllVersions = false 107 | opts.PrefetchValues = false 108 | it := txn.NewIterator(opts) 109 | defer it.Close() 110 | 111 | keysForDelete := make([][]byte, 0, collectSize) 112 | keysCollected := 0 113 | 114 | for it.Seek([]byte(str)); it.ValidForPrefix([]byte(str)); it.Next() { 115 | key := it.Item().KeyCopy(nil) 116 | keysForDelete = append(keysForDelete, key) 117 | keysCollected++ 118 | if keysCollected == collectSize { 119 | if err := deleteKeys(keysForDelete); err != nil { 120 | return err 121 | } 122 | } 123 | } 124 | 125 | if keysCollected > 0 { 126 | if err := deleteKeys(keysForDelete); err != nil { 127 | return err 128 | } 129 | } 130 | 131 | return nil 132 | }) 133 | 134 | return err 135 | } 136 | -------------------------------------------------------------------------------- /cache/badgerCache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import "testing" 4 | 5 | func TestBadgerCache_Exists(t *testing.T) { 6 | err := testBadgerCache.Delete("foo") 7 | if err != nil { 8 | t.Error(err) 9 | } 10 | 11 | inCache, err := testBadgerCache.Exists("foo") 12 | if err != nil { 13 | t.Error(err) 14 | } 15 | 16 | if inCache { 17 | t.Error("foo found in cache, and it shouldn't be there") 18 | } 19 | 20 | _ = testBadgerCache.Set("foo", "bar") 21 | inCache, err = testBadgerCache.Exists("foo") 22 | if err != nil { 23 | t.Error(err) 24 | } 25 | 26 | if !inCache { 27 | t.Error("foo not found in cache") 28 | } 29 | 30 | err = testBadgerCache.Delete("foo") 31 | if err != nil { 32 | t.Error(err) 33 | } 34 | } 35 | 36 | func TestBadgerCache_Get(t *testing.T) { 37 | err := testBadgerCache.Set("foo", "bar") 38 | if err != nil { 39 | t.Error(err) 40 | } 41 | 42 | x, err := testBadgerCache.Get("foo") 43 | if err != nil { 44 | t.Error(err) 45 | } 46 | 47 | if x != "bar" { 48 | t.Error("did not get correct value from cache") 49 | } 50 | } 51 | 52 | func TestBadgerCache_Forget(t *testing.T) { 53 | err := testBadgerCache.Set("foo", "foo") 54 | if err != nil { 55 | t.Error(err) 56 | } 57 | 58 | err = testBadgerCache.Delete("foo") 59 | if err != nil { 60 | t.Error(err) 61 | } 62 | 63 | inCache, err := testBadgerCache.Exists("foo") 64 | if err != nil { 65 | t.Error(err) 66 | } 67 | 68 | if inCache { 69 | t.Error("foo found in cache, and it shouldn't be there") 70 | } 71 | 72 | } 73 | 74 | func TestBadgerCache_Clean(t *testing.T) { 75 | err := testBadgerCache.Set("alpha", "beta") 76 | if err != nil { 77 | t.Error(err) 78 | } 79 | 80 | err = testBadgerCache.Clean() 81 | if err != nil { 82 | t.Error(err) 83 | } 84 | 85 | inCache, err := testBadgerCache.Exists("alpha") 86 | if err != nil { 87 | t.Error(err) 88 | } 89 | 90 | if inCache { 91 | t.Error("alpha found in cache, and it shouldn't be there") 92 | } 93 | } 94 | 95 | func TestBadgerCache_DeleteIfMatch(t *testing.T) { 96 | err := testBadgerCache.Set("alpha", "beta") 97 | if err != nil { 98 | t.Error(err) 99 | } 100 | 101 | err = testBadgerCache.Set("alpha2", "beta2") 102 | if err != nil { 103 | t.Error(err) 104 | } 105 | 106 | err = testBadgerCache.Set("beta", "beta") 107 | if err != nil { 108 | t.Error(err) 109 | } 110 | 111 | err = testBadgerCache.DeleteIfMatch("a") 112 | if err != nil { 113 | t.Error(err) 114 | } 115 | 116 | inCache, err := testBadgerCache.Exists("alpha") 117 | if err != nil { 118 | t.Error(err) 119 | } 120 | 121 | if inCache { 122 | t.Error("alpha found in cache, and it shouldn't be there") 123 | } 124 | 125 | inCache, err = testBadgerCache.Exists("alpha2") 126 | if err != nil { 127 | t.Error(err) 128 | } 129 | 130 | if inCache { 131 | t.Error("alpha2 found in cache, and it shouldn't be there") 132 | } 133 | 134 | inCache, err = testBadgerCache.Exists("beta") 135 | if err != nil { 136 | t.Error(err) 137 | } 138 | 139 | if !inCache { 140 | t.Error("beta not found in cache, and it should be there") 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "fmt" 7 | "github.com/gomodule/redigo/redis" 8 | ) 9 | 10 | type Cache interface { 11 | Exists(string) (bool, error) 12 | Get(string) (interface{}, error) 13 | Set(string, interface{}, ...int) error 14 | Delete(string) error 15 | DeleteIfMatch(string) error 16 | Clean() error 17 | } 18 | 19 | type RedisCache struct { 20 | Connection *redis.Pool 21 | Prefix string 22 | } 23 | 24 | type Entry map[string]interface{} 25 | 26 | // Exists : Check if the key exists 27 | func (c *RedisCache) Exists(str string) (bool, error) { 28 | key := fmt.Sprintf("%s:%s", c.Prefix, str) 29 | conn := c.Connection.Get() 30 | defer func(conn redis.Conn) { 31 | _ = conn.Close() 32 | }(conn) 33 | 34 | ok, err := redis.Bool(conn.Do("EXISTS", key)) 35 | if err != nil { 36 | return false, err 37 | } 38 | 39 | return ok, nil 40 | } 41 | 42 | // encode : Encode a string/s of type entry 43 | func encode(item Entry) ([]byte, error) { 44 | b := bytes.Buffer{} 45 | e := gob.NewEncoder(&b) 46 | err := e.Encode(item) 47 | if err != nil { 48 | return nil, err 49 | } 50 | return b.Bytes(), nil 51 | } 52 | 53 | // decode : Decode a string/s of type entry 54 | func decode(str string) (Entry, error) { 55 | item := Entry{} 56 | b := bytes.Buffer{} 57 | b.Write([]byte(str)) 58 | d := gob.NewDecoder(&b) 59 | err := d.Decode(&item) 60 | if err != nil { 61 | return nil, err 62 | } 63 | return item, nil 64 | } 65 | 66 | // Get : Return Key values from Redis if it exists. 67 | func (c *RedisCache) Get(str string) (interface{}, error) { 68 | key := fmt.Sprintf("%s:%s", c.Prefix, str) 69 | conn := c.Connection.Get() 70 | defer func(conn redis.Conn) { 71 | _ = conn.Close() 72 | }(conn) 73 | 74 | cacheEntry, err := redis.Bytes(conn.Do("GET", key)) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | decoded, err := decode(string(cacheEntry)) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | item := decoded[key] 85 | 86 | return item, nil 87 | } 88 | 89 | // Set : Set a key value in Redis 90 | func (c *RedisCache) Set(str string, value interface{}, expires ...int) error { 91 | key := fmt.Sprintf("%s:%s", c.Prefix, str) 92 | conn := c.Connection.Get() 93 | defer func(conn redis.Conn) { 94 | _ = conn.Close() 95 | }(conn) 96 | 97 | entry := Entry{} 98 | entry[key] = value 99 | encoded, err := encode(entry) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | if len(expires) > 0 { 105 | _, err := conn.Do("SETEX", key, expires[0], string(encoded)) 106 | if err != nil { 107 | return err 108 | } 109 | } else { 110 | _, err := conn.Do("SET", key, string(encoded)) 111 | if err != nil { 112 | return err 113 | } 114 | } 115 | 116 | return nil 117 | } 118 | 119 | // Delete : Delete a key value in Redis. 120 | func (c *RedisCache) Delete(str string) error { 121 | key := fmt.Sprintf("%s:%s", c.Prefix, str) 122 | conn := c.Connection.Get() 123 | defer func(conn redis.Conn) { 124 | _ = conn.Close() 125 | }(conn) 126 | 127 | _, err := conn.Do("DEL", key) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | return nil 133 | } 134 | 135 | // DeleteIfMatch : Delete a key values where match the with the key value 136 | func (c *RedisCache) DeleteIfMatch(str string) error { 137 | key := fmt.Sprintf("%s:%s", c.Prefix, str) 138 | conn := c.Connection.Get() 139 | defer func(conn redis.Conn) { 140 | _ = conn.Close() 141 | }(conn) 142 | 143 | keys, err := c.getKeys(key) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | for _, x := range keys { 149 | _, err := conn.Do("DEL", x) 150 | if err != nil { 151 | return err 152 | } 153 | } 154 | 155 | return nil 156 | } 157 | 158 | // Clean : Delete all entries from redis. 159 | func (c *RedisCache) Clean() error { 160 | key := fmt.Sprintf("%s:", c.Prefix) 161 | conn := c.Connection.Get() 162 | defer func(conn redis.Conn) { 163 | _ = conn.Close() 164 | }(conn) 165 | 166 | keys, err := c.getKeys(key) 167 | if err != nil { 168 | return err 169 | } 170 | 171 | for _, x := range keys { 172 | _, err := conn.Do("DEL", x) 173 | if err != nil { 174 | return err 175 | } 176 | } 177 | 178 | return nil 179 | } 180 | 181 | // getKeys : Return all keys that match to the pattern. 182 | func (c *RedisCache) getKeys(pattern string) ([]string, error) { 183 | conn := c.Connection.Get() 184 | defer func(conn redis.Conn) { 185 | _ = conn.Close() 186 | }(conn) 187 | 188 | iter := 0 189 | var keys []string 190 | 191 | for { 192 | arr, err := redis.Values(conn.Do("SCAN", iter, "MATCH", fmt.Sprintf("%s*", pattern))) 193 | if err != nil { 194 | return keys, err 195 | } 196 | 197 | iter, _ = redis.Int(arr[0], nil) 198 | k, _ := redis.Strings(arr[1], nil) 199 | keys = append(keys, k...) 200 | 201 | if iter == 0 { 202 | break 203 | } 204 | } 205 | 206 | return keys, nil 207 | } 208 | -------------------------------------------------------------------------------- /cache/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import "testing" 4 | 5 | func TestRedisCache_Has(t *testing.T) { 6 | err := testRedisCache.Delete("foo") 7 | if err != nil { 8 | t.Error(err) 9 | } 10 | 11 | inCache, err := testRedisCache.Exists("foo") 12 | if err != nil { 13 | t.Error(err) 14 | } 15 | 16 | if inCache { 17 | t.Error("foo key already exists in cache, and it shouldn't be there") 18 | } 19 | 20 | err = testRedisCache.Set("foo", "bar") 21 | if err != nil { 22 | t.Error(err) 23 | } 24 | 25 | inCache, err = testRedisCache.Exists("foo") 26 | if err != nil { 27 | t.Error(err) 28 | } 29 | 30 | if !inCache { 31 | t.Error("foo key not found in cache, but it should be there") 32 | } 33 | } 34 | 35 | func TestRedisCache_Get(t *testing.T) { 36 | err := testRedisCache.Set("foo", "bar") 37 | if err != nil { 38 | t.Error(err) 39 | } 40 | 41 | x, err := testRedisCache.Get("foo") 42 | if err != nil { 43 | t.Error(err) 44 | } 45 | 46 | if x != "bar" { 47 | t.Error("Unable to get the correct value from cache") 48 | } 49 | } 50 | 51 | func TestRedisCache_Forget(t *testing.T) { 52 | err := testRedisCache.Set("alpha", "beta") 53 | if err != nil { 54 | t.Error(err) 55 | } 56 | 57 | err = testRedisCache.Delete("alpha") 58 | if err != nil { 59 | t.Error(err) 60 | } 61 | 62 | inCache, err := testRedisCache.Exists("alpha") 63 | if err != nil { 64 | t.Error(err) 65 | } 66 | 67 | if inCache { 68 | t.Error("alpha key already exists in cache, and it should not be there") 69 | } 70 | } 71 | 72 | func TestRedisCache_Empty(t *testing.T) { 73 | err := testRedisCache.Set("alpha", "beta") 74 | if err != nil { 75 | t.Error(err) 76 | } 77 | 78 | err = testRedisCache.Clean() 79 | if err != nil { 80 | t.Error(err) 81 | } 82 | 83 | inCache, err := testRedisCache.Exists("alpha") 84 | if err != nil { 85 | t.Error(err) 86 | } 87 | 88 | if inCache { 89 | t.Error("The alpha key found in cache, and it should not be there") 90 | } 91 | 92 | } 93 | 94 | func TestRedisCache_EmptyByMatch(t *testing.T) { 95 | err := testRedisCache.Set("alpha", "foo") 96 | if err != nil { 97 | t.Error(err) 98 | } 99 | 100 | err = testRedisCache.Set("alpha2", "foo") 101 | if err != nil { 102 | t.Error(err) 103 | } 104 | 105 | err = testRedisCache.Set("beta", "foo") 106 | if err != nil { 107 | t.Error(err) 108 | } 109 | 110 | err = testRedisCache.DeleteIfMatch("alpha") 111 | if err != nil { 112 | t.Error(err) 113 | } 114 | 115 | inCache, err := testRedisCache.Exists("alpha") 116 | if err != nil { 117 | t.Error(err) 118 | } 119 | 120 | if inCache { 121 | t.Error("alpha key found in cache, and it should not be there") 122 | } 123 | 124 | inCache, err = testRedisCache.Exists("alpha2") 125 | if err != nil { 126 | t.Error(err) 127 | } 128 | 129 | if inCache { 130 | t.Error("alpha2 found in cache, and it should not be there") 131 | } 132 | 133 | inCache, err = testRedisCache.Exists("beta") 134 | if err != nil { 135 | t.Error(err) 136 | } 137 | 138 | if !inCache { 139 | t.Error("beta key not exists in cache, and it should be there") 140 | } 141 | } 142 | 143 | func TestEncodeDecode(t *testing.T) { 144 | entry := Entry{} 145 | entry["foo"] = "bar" 146 | bytes, err := encode(entry) 147 | if err != nil { 148 | t.Error(err) 149 | } 150 | 151 | _, err = decode(string(bytes)) 152 | if err != nil { 153 | t.Error(err) 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /cache/setup_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "github.com/dgraph-io/badger/v3" 5 | "log" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/alicebob/miniredis/v2" 11 | "github.com/gomodule/redigo/redis" 12 | ) 13 | 14 | var testRedisCache RedisCache 15 | var testBadgerCache BadgerCache 16 | 17 | func TestMain(m *testing.M) { 18 | s, err := miniredis.Run() 19 | if err != nil { 20 | panic(err) 21 | } 22 | defer s.Close() 23 | 24 | pool := redis.Pool{ 25 | MaxIdle: 50, 26 | MaxActive: 1000, 27 | IdleTimeout: 240 * time.Second, 28 | Dial: func() (redis.Conn, error) { 29 | return redis.Dial("tcp", s.Addr()) 30 | }, 31 | } 32 | 33 | testRedisCache.Connection = &pool 34 | testRedisCache.Prefix = "test-microGO" 35 | 36 | defer func(Connect *redis.Pool) { 37 | _ = Connect.Close() 38 | }(testRedisCache.Connection) 39 | 40 | _ = os.RemoveAll("./testdata/tmp/badger") 41 | // create a badger database 42 | 43 | if _, err := os.Stat("./testdata/tmp"); os.IsNotExist(err) { 44 | err := os.Mkdir("./testdata/tmp", 0755) 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | } 49 | err = os.MkdirAll("./testdata/tmp/badger", 0755) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | db, _ := badger.Open(badger.DefaultOptions("./testdata/tmp/badger")) 54 | testBadgerCache.Connection = db 55 | 56 | os.Exit(m.Run()) 57 | } 58 | -------------------------------------------------------------------------------- /docs/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cploutarchou/microgo/1e42850e6a32450d60c3ca0a4f89649e94e0f89f/docs/cover.png -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cploutarchou/microgo/1e42850e6a32450d60c3ca0a4f89649e94e0f89f/docs/logo.png -------------------------------------------------------------------------------- /driver.go: -------------------------------------------------------------------------------- 1 | package MicroGO 2 | 3 | import ( 4 | "database/sql" 5 | 6 | _ "github.com/jackc/pgconn" 7 | _ "github.com/jackc/pgx/v4" 8 | _ "github.com/jackc/pgx/v4/stdlib" 9 | ) 10 | 11 | func (m *MicroGo) OpenDB(driverName, dataSourceName string) (*sql.DB, error) { 12 | if driverName == "postgres" || driverName == "postgresql" { 13 | driverName = "pgx" 14 | } 15 | 16 | if driverName == "mysql" || driverName == "mariadb" { 17 | driverName = "mysql" 18 | } 19 | db, err := sql.Open(driverName, dataSourceName) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | err = db.Ping() 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return db, nil 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cploutarchou/MicroGO 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/CloudyKit/jet/v6 v6.2.0 7 | github.com/ainsleyclark/go-mail v1.1.1 8 | github.com/alexedwards/scs/mysqlstore v0.0.0-20230305114126-a07530f96ced 9 | github.com/alexedwards/scs/postgresstore v0.0.0-20230305114126-a07530f96ced 10 | github.com/alexedwards/scs/redisstore v0.0.0-20230305114126-a07530f96ced 11 | github.com/alexedwards/scs/v2 v2.5.1 12 | github.com/alicebob/miniredis/v2 v2.15.1 13 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 14 | github.com/dgraph-io/badger/v3 v3.2103.5 15 | github.com/fatih/color v1.13.0 16 | github.com/gertd/go-pluralize v0.1.7 17 | github.com/go-chi/chi/v5 v5.0.8 18 | github.com/go-git/go-git/v5 v5.4.2 19 | github.com/go-sql-driver/mysql v1.7.0 20 | github.com/golang-migrate/migrate/v4 v4.15.2 21 | github.com/gomodule/redigo v1.8.9 22 | github.com/iancoleman/strcase v0.2.0 23 | github.com/jackc/pgconn v1.14.0 24 | github.com/jackc/pgx/v4 v4.18.1 25 | github.com/joho/godotenv v1.5.1 26 | github.com/justinas/nosurf v1.1.1 27 | github.com/kataras/blocks v0.0.7 28 | github.com/ory/dockertest/v3 v3.8.0 29 | github.com/robfig/cron/v3 v3.0.1 30 | github.com/vanng822/go-premailer v1.20.1 31 | github.com/xhit/go-simple-mail/v2 v2.13.0 32 | golang.org/x/crypto v0.7.0 33 | ) 34 | 35 | require ( 36 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 37 | github.com/jackc/pgio v1.0.0 // indirect 38 | github.com/jackc/pgpassfile v1.0.0 // indirect 39 | github.com/jackc/pgproto3/v2 v2.3.2 // indirect 40 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 41 | github.com/jackc/pgtype v1.14.0 // indirect 42 | golang.org/x/text v0.8.0 // indirect 43 | ) 44 | 45 | require ( 46 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 47 | github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect 48 | github.com/Microsoft/go-winio v0.5.2 // indirect 49 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect 50 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect 51 | github.com/PuerkitoBio/goquery v1.8.1 // indirect 52 | github.com/acomagu/bufpipe v1.0.3 // indirect 53 | github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect 54 | github.com/andybalholm/cascadia v1.3.1 // indirect 55 | github.com/cenkalti/backoff/v4 v4.1.2 // indirect 56 | github.com/cespare/xxhash v1.1.0 // indirect 57 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 58 | github.com/containerd/containerd v1.6.18 // indirect 59 | github.com/containerd/continuity v0.3.0 // indirect 60 | github.com/dgraph-io/ristretto v0.1.1 // indirect 61 | github.com/docker/cli v20.10.8+incompatible // indirect 62 | github.com/docker/docker v20.10.13+incompatible // indirect 63 | github.com/docker/go-connections v0.4.0 // indirect 64 | github.com/docker/go-units v0.4.0 // indirect 65 | github.com/dustin/go-humanize v1.0.1 // indirect 66 | github.com/emirpasic/gods v1.12.0 // indirect 67 | github.com/go-git/gcfg v1.5.0 // indirect 68 | github.com/go-git/go-billy/v5 v5.3.1 // indirect 69 | github.com/go-test/deep v1.1.0 // indirect 70 | github.com/gogo/protobuf v1.3.2 // indirect 71 | github.com/golang/glog v1.1.0 // indirect 72 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 73 | github.com/golang/protobuf v1.5.2 // indirect 74 | github.com/golang/snappy v0.0.4 // indirect 75 | github.com/google/flatbuffers v23.3.3+incompatible // indirect 76 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 77 | github.com/gorilla/css v1.0.0 // indirect 78 | github.com/hashicorp/errwrap v1.1.0 // indirect 79 | github.com/hashicorp/go-multierror v1.1.1 // indirect 80 | github.com/imdario/mergo v0.3.12 // indirect 81 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 82 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect 83 | github.com/klauspost/compress v1.16.0 // indirect 84 | github.com/lib/pq v1.10.7 // indirect 85 | github.com/mattn/go-colorable v0.1.9 // indirect 86 | github.com/mattn/go-isatty v0.0.16 // indirect 87 | github.com/mitchellh/go-homedir v1.1.0 // indirect 88 | github.com/mitchellh/mapstructure v1.4.1 // indirect 89 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect 90 | github.com/opencontainers/go-digest v1.0.0 // indirect 91 | github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect 92 | github.com/opencontainers/runc v1.1.2 // indirect 93 | github.com/pkg/errors v0.9.1 // indirect 94 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 95 | github.com/sergi/go-diff v1.1.0 // indirect 96 | github.com/sirupsen/logrus v1.8.1 // indirect 97 | github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect 98 | github.com/valyala/bytebufferpool v1.0.0 // indirect 99 | github.com/vanng822/css v1.0.1 // indirect 100 | github.com/xanzy/ssh-agent v0.3.0 // indirect 101 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect 102 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 103 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 104 | github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da // indirect 105 | go.opencensus.io v0.24.0 // indirect 106 | go.uber.org/atomic v1.10.0 // indirect 107 | golang.org/x/net v0.8.0 // indirect 108 | golang.org/x/sys v0.6.0 // indirect 109 | google.golang.org/protobuf v1.28.1 // indirect 110 | gopkg.in/warnings.v0 v0.1.2 // indirect 111 | gopkg.in/yaml.v2 v2.4.0 // indirect 112 | ) 113 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package MicroGO 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "io" 9 | "os" 10 | ) 11 | 12 | const ( 13 | rndString = "sd54qw54dfg2365swe$445dfg765SDFDff" 14 | ) 15 | 16 | // CreateRandomString A Random String Generator function based on n value length. 17 | // From the values in the rndString const 18 | func (m *MicroGo) CreateRandomString(n int) string { 19 | s, r := make([]rune, n), []rune(rndString) 20 | for i := range s { 21 | p, _ := rand.Prime(rand.Reader, len(r)) 22 | x, y := p.Uint64(), uint64(len(r)) 23 | s[i] = r[x%y] 24 | } 25 | return string(s) 26 | } 27 | 28 | // CreateDirIfNotExist creates the necessary folder if not exist. 29 | func (m *MicroGo) CreateDirIfNotExist(path string) error { 30 | const mode = 0755 31 | if _, err := os.Stat(path); os.IsNotExist(err) { 32 | err := os.Mkdir(path, mode) 33 | if err != nil { 34 | return err 35 | } 36 | } 37 | return nil 38 | } 39 | 40 | // CreateFileIfNotExists creates the necessary files if not exist. 41 | func (m *MicroGo) CreateFileIfNotExists(path string) error { 42 | var _, err = os.Stat(path) 43 | if os.IsNotExist(err) { 44 | var file, err = os.Create(path) 45 | if err != nil { 46 | return err 47 | } 48 | defer func(file *os.File) { 49 | _ = file.Close() 50 | }(file) 51 | } 52 | return nil 53 | } 54 | 55 | type Encryption struct { 56 | Key []byte 57 | } 58 | 59 | func (e *Encryption) Encrypt(text string) (string, error) { 60 | plaintext := []byte(text) 61 | 62 | block, err := aes.NewCipher(e.Key) 63 | if err != nil { 64 | return "", err 65 | } 66 | 67 | ciphertext := make([]byte, aes.BlockSize+len(plaintext)) 68 | iv := ciphertext[:aes.BlockSize] 69 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 70 | return "", err 71 | } 72 | 73 | stream := cipher.NewCFBEncrypter(block, iv) 74 | stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext) 75 | 76 | return base64.URLEncoding.EncodeToString(ciphertext), nil 77 | } 78 | 79 | func (e *Encryption) Decrypt(cryptoText string) (string, error) { 80 | ciphertext, _ := base64.URLEncoding.DecodeString(cryptoText) 81 | 82 | block, err := aes.NewCipher(e.Key) 83 | if err != nil { 84 | return "", err 85 | } 86 | 87 | if len(ciphertext) < aes.BlockSize { 88 | return "", err 89 | } 90 | 91 | iv := ciphertext[:aes.BlockSize] 92 | ciphertext = ciphertext[aes.BlockSize:] 93 | 94 | stream := cipher.NewCFBDecrypter(block, iv) 95 | stream.XORKeyStream(ciphertext, ciphertext) 96 | 97 | return string(ciphertext), nil 98 | } 99 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "MicroGO Installation" 3 | echo " ____ ____ _ ______ ___ " 4 | echo "|_ \ / _| (_) .' ___ | .' \`. " 5 | echo " | \/ | __ .---. _ .--. .--. / .' \_| / .-. \\ " 6 | echo " | |\ /| | [ | / /'\`\\] [ \`/'\`\\ ] / .'\`\\ \ | | ____ | | | | " 7 | echo " _| |_\/_| |_ | | | \\__. | | | \\__. | \\ \`\.___] | \\ \`-' / " 8 | echo "|_____||_____| [___] '.___.' [___] '.__.' \`._____.' \`.___.' " 9 | echo "" 10 | echo "1.0.9" 11 | echo "Author: Christos Ploutarchou" 12 | echo "Installing MicroGO binaries..." 13 | userDir=$USER 14 | 15 | spinner() { 16 | local pid=$1 17 | local delay=0.75 18 | # shellcheck disable=SC1003 19 | local spinstr='|/-\' 20 | # shellcheck disable=SC2143 21 | while [ "$(ps a | awk '{print $1}' | grep "$pid")" ]; do 22 | local temp=${spinstr#?} 23 | printf " [%c] " "$spinstr" 24 | local spinstr=$temp${spinstr%"$temp"} 25 | sleep $delay 26 | printf "\b\b\b\b\b\b" 27 | done 28 | printf " \b\b\b\b" 29 | } 30 | 31 | if [[ "$OSTYPE" == "darwin"* ]]; then 32 | if [[ $(uname -m) == "x86_64" ]]; then 33 | curl -LJO https://github.com/cploutarchou/MicroGO/releases/download/v1.0.9/microGo-MacOS-x86_64 & 34 | spinner $! 35 | mv microGo-MacOS-x86_64 /Users/"$userDir"/go/bin/microGo 36 | else 37 | curl -LJO https://github.com/cploutarchou/MicroGO/releases/download/v1.0.9/microGo-MacOS-ARM64 & 38 | spinner $! 39 | mv microGo-MacOS-ARM64 /Users/"$userDir"/go/bin/microGo 40 | fi 41 | 42 | echo "Enter your password to install MicroGO binaries using sudo" 43 | chmod +x /Users/"$userDir"/go/bin/microGo 44 | echo "MicroGO binaries installed" 45 | echo "Export env" 46 | 47 | if grep -q "alias microGo" ~/.zprofile; then 48 | echo "Already exported" 49 | else 50 | # shellcheck disable=SC1090 51 | source ~/.zprofile 52 | comm="alias microGo=/Users/$userDir/go/bin/microGo" 53 | echo "$comm" >>/Users/"$userDir"/.zprofile 54 | echo "MicroGO binaries exported to PATH" 55 | # shellcheck disable=SC1090 56 | source ~/.zprofile 57 | fi 58 | 59 | elif [[ "$OSTYPE" == "linux-gnu" ]]; then 60 | if [[ $(uname -m) == "x86_64" ]]; then 61 | curl -LJO https://github.com/cploutarchou/MicroGO/releases/download/v1.0.9/microGo-Linux-x86_64 & 62 | spinner $! 63 | mv microGo-Linux-x86_64 ~/go/bin/microGo 64 | else 65 | curl -LJO https://github.com/cploutarchou/MicroGO/releases/download/v1.0.9/microGo-Linux-ARM64 & 66 | spinner $! 67 | mv microGo-Linux-ARM64 ~/go/bin/microGo 68 | fi 69 | 70 | chmod +x ~/go/bin/microGo 71 | echo "MicroGO binaries installed" 72 | echo "Export env" 73 | 74 | if grep -q "alias microGo" ~/.bashrc; then 75 | echo "Already exported" 76 | else 77 | # shellcheck disable=SC1090 78 | source ~/.bashrc 79 | comm="alias microGo=~/go/bin/microGo" 80 | echo "$comm" >>~/.bashrc 81 | echo "MicroGO binaries exported to PATH" 82 | # shellcheck disable=SC1090 83 | source ~/.bashrc 84 | fi 85 | else 86 | echo "Unsupported OS" 87 | fi 88 | -------------------------------------------------------------------------------- /mailer/mail_test.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestMail_SendSMTPMessage(t *testing.T) { 9 | msg := Message{ 10 | From: "me@here.com", 11 | FromName: "Joe", 12 | To: "you@there.com", 13 | Subject: "test", 14 | Template: "test", 15 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 16 | } 17 | 18 | err := mailer.SentSMTPMessage(msg) 19 | if err != nil { 20 | t.Error(err) 21 | } 22 | } 23 | 24 | func TestMail_SendUsingChan(t *testing.T) { 25 | msg := Message{ 26 | From: "me@here.com", 27 | FromName: "Joe", 28 | To: "you@there.com", 29 | Subject: "test", 30 | Template: "test", 31 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 32 | } 33 | 34 | mailer.Jobs <- msg 35 | res := <-mailer.Results 36 | if res.Error != nil { 37 | t.Error(errors.New("failed to send over channel")) 38 | } 39 | 40 | msg.To = "not_an_email_address" 41 | mailer.Jobs <- msg 42 | res = <-mailer.Results 43 | if res.Error == nil { 44 | t.Error(errors.New("no error received with invalid to address")) 45 | } 46 | } 47 | 48 | func TestMail_SendUsingAPI(t *testing.T) { 49 | msg := Message{ 50 | To: "you@there.com", 51 | Subject: "test", 52 | Template: "test", 53 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 54 | } 55 | 56 | mailer.API = "unknown" 57 | mailer.ApiKey = "abc123" 58 | mailer.ApiUrl = "https://www.fake.com" 59 | 60 | err := mailer.SendViaAPI(msg, "unknown") 61 | if err == nil { 62 | t.Error(err) 63 | } 64 | mailer.API = "" 65 | mailer.ApiKey = "" 66 | mailer.ApiUrl = "" 67 | } 68 | 69 | func TestMail_buildHTMLMessage(t *testing.T) { 70 | msg := Message{ 71 | From: "test@mymail.com", 72 | FromName: "Christos Ploutarchou", 73 | To: "you@mymail.com", 74 | Subject: "test", 75 | Template: "test", 76 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 77 | } 78 | 79 | _, err := mailer.createHTMLMessage(msg) 80 | if err != nil { 81 | t.Error(err) 82 | } 83 | } 84 | 85 | func TestMail_buildPlainMessage(t *testing.T) { 86 | msg := Message{ 87 | From: "test@mymail.com", 88 | FromName: "Christos Ploutarchou", 89 | To: "you@mymail.com", 90 | Subject: "test", 91 | Template: "test", 92 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 93 | } 94 | 95 | _, err := mailer.createPlanMessage(msg) 96 | if err != nil { 97 | t.Error(err) 98 | } 99 | } 100 | 101 | func TestMail_send(t *testing.T) { 102 | msg := Message{ 103 | From: "test@mymail.com", 104 | FromName: "Christos Ploutarchou", 105 | To: "you@mymail.com", 106 | Subject: "test", 107 | Template: "test", 108 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 109 | } 110 | 111 | err := mailer.Send(msg) 112 | if err != nil { 113 | t.Error(err) 114 | } 115 | 116 | mailer.API = "unknown" 117 | mailer.ApiKey = "abc123" 118 | mailer.ApiUrl = "https://www.fake.com" 119 | 120 | err = mailer.Send(msg) 121 | if err == nil { 122 | t.Error("did not not get an error when we should have") 123 | } 124 | 125 | mailer.API = "" 126 | mailer.ApiKey = "" 127 | mailer.ApiKey = "" 128 | } 129 | 130 | func TestMail_ChooseAPI(t *testing.T) { 131 | msg := Message{ 132 | From: "test@mymail.com", 133 | FromName: "Christos Ploutarchou", 134 | To: "you@mymail.com", 135 | Subject: "test", 136 | Template: "test", 137 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 138 | } 139 | mailer.API = "unknown" 140 | err := mailer.SelectAPI(msg) 141 | if err == nil { 142 | t.Error(err) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /mailer/mailer.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/ainsleyclark/go-mail/drivers" 7 | "github.com/ainsleyclark/go-mail/mail" 8 | "github.com/vanng822/go-premailer/premailer" 9 | defaultMail "github.com/xhit/go-simple-mail/v2" 10 | "html/template" 11 | "os" 12 | "path/filepath" 13 | "time" 14 | ) 15 | 16 | type Mailer struct { 17 | Domain string 18 | Templates string 19 | Host string 20 | Port int 21 | Username string 22 | Password string 23 | Encryption string 24 | FromAddress string 25 | FromName string 26 | Jobs chan Message 27 | Results chan Result 28 | API string 29 | ApiKey string 30 | ApiUrl string 31 | } 32 | type Message struct { 33 | From string 34 | FromName string 35 | To string 36 | BCC []string 37 | CC []string 38 | Subject string 39 | Template string 40 | TemplateFormat TemplateFormat 41 | Attachments []string 42 | Data interface{} 43 | } 44 | 45 | type Result struct { 46 | Success bool 47 | Error error 48 | } 49 | type TemplateFormat string 50 | 51 | const ( 52 | HTMLTemplateFormat TemplateFormat = "html" 53 | PlainTextTemplateFormat TemplateFormat = "plain/text" 54 | ) 55 | 56 | func (m *Mailer) ListenForMessage() { 57 | for { 58 | msg := <-m.Jobs 59 | err := m.Send(msg) 60 | if err != nil { 61 | m.Results <- Result{Success: false, Error: err} 62 | } else { 63 | m.Results <- Result{Success: true, Error: nil} 64 | } 65 | } 66 | } 67 | 68 | func (m *Mailer) Send(msg Message) error { 69 | if len(m.API) > 0 && len(m.ApiKey) > 0 && len(m.ApiUrl) > 0 && m.API != "smtp" { 70 | return m.SelectAPI(msg) 71 | } 72 | return m.SentSMTPMessage(msg) 73 | } 74 | 75 | func (m *Mailer) SentSMTPMessage(msg Message) error { 76 | 77 | formattedMessage, err := m.createHTMLMessage(msg) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | plainMessage, err := m.createPlanMessage(msg) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | svr := defaultMail.NewSMTPClient() 88 | svr.Host = m.Host 89 | svr.Port = m.Port 90 | svr.Username = m.Username 91 | svr.Password = m.Password 92 | svr.KeepAlive = false 93 | svr.ConnectTimeout = 15 * time.Second 94 | svr.SendTimeout = 15 * time.Second 95 | svr.Encryption = m.getEncryption(m.Encryption) 96 | smtpClient, err := svr.Connect() 97 | if err != nil { 98 | return err 99 | } 100 | 101 | email := defaultMail.NewMSG() 102 | email.SetFrom(msg.From).AddTo(msg.To).SetSubject(msg.Subject) 103 | if msg.TemplateFormat == HTMLTemplateFormat { 104 | email.SetBody(defaultMail.TextHTML, formattedMessage) 105 | } 106 | if msg.TemplateFormat == PlainTextTemplateFormat { 107 | email.AddAlternative(defaultMail.TextPlain, plainMessage) 108 | } 109 | if len(msg.Attachments) > 0 { 110 | for _, x := range msg.Attachments { 111 | email.AddAttachment(x) 112 | } 113 | } 114 | if len(msg.CC) > 0 { 115 | for _, x := range msg.Attachments { 116 | email.AddCc(x) 117 | } 118 | } 119 | if len(msg.BCC) > 0 { 120 | for _, x := range msg.Attachments { 121 | email.AddBcc(x) 122 | } 123 | } 124 | err = email.Send(smtpClient) 125 | if err != nil { 126 | return err 127 | } 128 | return nil 129 | } 130 | 131 | // createHTMLMessage Build HTML Message using html template. 132 | func (m *Mailer) createHTMLMessage(msg Message) (string, error) { 133 | renderTemplate := fmt.Sprintf("%s/%s.html.tmpl", m.Templates, msg.Template) 134 | t, err := template.New("email-html").ParseFiles(renderTemplate) 135 | if err != nil { 136 | return "", err 137 | } 138 | var tml bytes.Buffer 139 | if err = t.ExecuteTemplate(&tml, "body", msg.Data); err != nil { 140 | return "", err 141 | } 142 | fmtMSG := tml.String() 143 | fmtMSG, err = m.inlineCSS(fmtMSG) 144 | return fmtMSG, nil 145 | } 146 | 147 | // createPlanMessage : Build HTML Message using plan template. 148 | func (m *Mailer) createPlanMessage(msg Message) (string, error) { 149 | renderTemplate := fmt.Sprintf("%s/%s.plain.tmpl", m.Templates, msg.Template) 150 | t, err := template.New("email-html").ParseFiles(renderTemplate) 151 | if err != nil { 152 | return "", err 153 | } 154 | var tml bytes.Buffer 155 | if err = t.ExecuteTemplate(&tml, "body", msg.Data); err != nil { 156 | return "", err 157 | } 158 | plainMSG := tml.String() 159 | return plainMSG, nil 160 | } 161 | 162 | func (m *Mailer) getEncryption(str string) defaultMail.Encryption { 163 | switch str { 164 | case "tls": 165 | return defaultMail.EncryptionSTARTTLS 166 | case "ssl": 167 | return defaultMail.EncryptionSSL 168 | case "none": 169 | return defaultMail.EncryptionNone 170 | default: 171 | return defaultMail.EncryptionSTARTTLS 172 | } 173 | } 174 | 175 | func (m *Mailer) inlineCSS(str string) (string, error) { 176 | opt := premailer.Options{ 177 | RemoveClasses: false, 178 | CssToAttributes: false, 179 | KeepBangImportant: true, 180 | } 181 | prem, err := premailer.NewPremailerFromString(str, &opt) 182 | if err != nil { 183 | return "", err 184 | } 185 | newHtml, err := prem.Transform() 186 | if err != nil { 187 | return "", err 188 | } 189 | return newHtml, nil 190 | } 191 | 192 | func (m *Mailer) SelectAPI(msg Message) error { 193 | switch m.API { 194 | case "mailgun", "sparkpost", "sendgrid", "postal", "postmark": 195 | return m.SendViaAPI(msg, m.API) 196 | default: 197 | return fmt.Errorf("Not supported api: %s ", m.API) 198 | } 199 | 200 | } 201 | 202 | func (m *Mailer) SendViaAPI(msg Message, transport string) error { 203 | if msg.From == "" { 204 | msg.From = m.FromAddress 205 | } 206 | 207 | if msg.FromName == "" { 208 | msg.FromName = m.FromName 209 | } 210 | 211 | cfg := mail.Config{ 212 | URL: m.ApiUrl, 213 | APIKey: m.ApiKey, 214 | Domain: m.Domain, 215 | FromAddress: msg.From, 216 | FromName: msg.FromName, 217 | } 218 | 219 | formattedMessage, err := m.createHTMLMessage(msg) 220 | if err != nil { 221 | return err 222 | } 223 | 224 | plainMessage, err := m.createPlanMessage(msg) 225 | 226 | if err != nil { 227 | return err 228 | } 229 | 230 | tx := &mail.Transmission{ 231 | Recipients: []string{msg.To}, 232 | Subject: msg.Subject, 233 | } 234 | if msg.TemplateFormat == PlainTextTemplateFormat { 235 | tx.PlainText = plainMessage 236 | } 237 | if msg.TemplateFormat == HTMLTemplateFormat { 238 | tx.HTML = formattedMessage 239 | } 240 | _mailer, err := m.SelectAPIDriver(transport, cfg) 241 | if err != nil { 242 | return err 243 | } 244 | // add attachments 245 | err = m.addAPIAttachments(msg, tx) 246 | if err != nil { 247 | return err 248 | } 249 | 250 | _, err = _mailer.Send(tx) 251 | if err != nil { 252 | return err 253 | } 254 | 255 | return nil 256 | } 257 | 258 | func (m *Mailer) SelectAPIDriver(transport string, config mail.Config) (mail.Mailer, error) { 259 | switch transport { 260 | case "sparkpost": 261 | return drivers.NewSparkPost(config) 262 | case "mailgun": 263 | return drivers.NewMailgun(config) 264 | case "postal": 265 | return drivers.NewPostal(config) 266 | case "postmark": 267 | return drivers.NewPostmark(config) 268 | case "sendgrid": 269 | return drivers.NewSendGrid(config) 270 | default: 271 | return nil, fmt.Errorf("No valid transport specified : %s ", transport) 272 | } 273 | } 274 | 275 | func (m *Mailer) addAPIAttachments(msg Message, tx *mail.Transmission) error { 276 | if len(msg.Attachments) > 0 { 277 | var attachments []mail.Attachment 278 | 279 | for _, x := range msg.Attachments { 280 | var attach mail.Attachment 281 | content, err := os.ReadFile(x) 282 | if err != nil { 283 | return err 284 | } 285 | 286 | fileName := filepath.Base(x) 287 | attach.Bytes = content 288 | attach.Filename = fileName 289 | attachments = append(attachments, attach) 290 | } 291 | 292 | tx.Attachments = attachments 293 | } 294 | 295 | return nil 296 | } 297 | -------------------------------------------------------------------------------- /mailer/setup_test.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/ory/dockertest/v3" 10 | "github.com/ory/dockertest/v3/docker" 11 | ) 12 | 13 | var ( 14 | pool *dockertest.Pool 15 | resource *dockertest.Resource 16 | ) 17 | var mailer = Mailer{ 18 | Domain: "localhost", 19 | Templates: "./testdata/mail", 20 | Host: "localhost", 21 | Port: 1026, 22 | Encryption: "none", 23 | FromAddress: "me@here.com", 24 | FromName: "Joe", 25 | Jobs: make(chan Message, 1), 26 | Results: make(chan Result, 1), 27 | } 28 | 29 | func TestMain(m *testing.M) { 30 | p, err := dockertest.NewPool("") 31 | if err != nil { 32 | log.Fatal("could not connect to docker", err) 33 | } 34 | pool = p 35 | 36 | opts := dockertest.RunOptions{ 37 | Repository: "mailhog/mailhog", 38 | Tag: "latest", 39 | Env: []string{}, 40 | ExposedPorts: []string{"1025", "8025"}, 41 | PortBindings: map[docker.Port][]docker.PortBinding{ 42 | "1025": { 43 | {HostIP: "0.0.0.0", HostPort: "1026"}, 44 | }, 45 | "8025": { 46 | {HostIP: "0.0.0.0", HostPort: "8026"}, 47 | }, 48 | }, 49 | } 50 | 51 | resource, err = pool.RunWithOptions(&opts) 52 | if err != nil { 53 | log.Println(err) 54 | _ = pool.Purge(resource) 55 | log.Fatal("Could not start resource!") 56 | } 57 | 58 | time.Sleep(3 * time.Second) 59 | 60 | go mailer.ListenForMessage() 61 | 62 | code := m.Run() 63 | 64 | if err := pool.Purge(resource); err != nil { 65 | log.Fatalf("could not purge resource: %s", err) 66 | } 67 | 68 | os.Exit(code) 69 | } 70 | -------------------------------------------------------------------------------- /mailer/testdata/mail/test.html.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's 12 | standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make 13 | a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, 14 | remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing 15 | Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions 16 | of Lorem Ipsum

17 | 18 | 19 | 20 | {{end}} -------------------------------------------------------------------------------- /mailer/testdata/mail/test.plain.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's 3 | standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make 4 | a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, 5 | remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing 6 | Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions 7 | of Lorem Ipsum. 8 | {{end}} -------------------------------------------------------------------------------- /microGo.go: -------------------------------------------------------------------------------- 1 | package MicroGO 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/CloudyKit/jet/v6" 14 | "github.com/alexedwards/scs/v2" 15 | "github.com/dgraph-io/badger/v3" 16 | "github.com/go-chi/chi/v5" 17 | "github.com/gomodule/redigo/redis" 18 | "github.com/joho/godotenv" 19 | "github.com/kataras/blocks" 20 | "github.com/robfig/cron/v3" 21 | 22 | "github.com/cploutarchou/MicroGO/cache" 23 | "github.com/cploutarchou/MicroGO/mailer" 24 | "github.com/cploutarchou/MicroGO/render" 25 | "github.com/cploutarchou/MicroGO/requests" 26 | "github.com/cploutarchou/MicroGO/session" 27 | ) 28 | 29 | const version = "1.0.9" 30 | 31 | var ( 32 | redisCache *cache.RedisCache 33 | badgerCache *cache.BadgerCache 34 | redisPool *redis.Pool 35 | badgerConnection *badger.DB 36 | ) 37 | 38 | // MicroGo is the overall type for the MicroGo package. Members that are exported in this type 39 | // are available to any application that uses it. 40 | type MicroGo struct { 41 | AppName string 42 | Debug bool 43 | Version string 44 | ErrorLog *log.Logger 45 | InfoLog *log.Logger 46 | WarningLog *log.Logger 47 | BuildLog *log.Logger 48 | RootPath string 49 | Routes *chi.Mux 50 | Render *render.Render 51 | JetView *jet.Set 52 | BlocksView *blocks.Blocks 53 | config config 54 | Session *scs.SessionManager 55 | DB Database 56 | EncryptionKey string 57 | Cache cache.Cache 58 | Scheduler *cron.Cron 59 | Mailer mailer.Mailer 60 | Server Server 61 | Requests *requests.Requests 62 | } 63 | 64 | type Server struct { 65 | ServerName string 66 | Port string 67 | Secure bool 68 | URL string 69 | } 70 | type config struct { 71 | port string 72 | renderer string 73 | cookie cookieConfig 74 | sessionType string 75 | database databaseConfig 76 | redis redisConfig 77 | } 78 | 79 | // New reads the .env file, creates our application config, populates the MicroGo type with settings 80 | // based on .env values, and creates the necessary folders and files if they don't exist on the system. 81 | func (m *MicroGo) New(rootPath string) error { 82 | pathConfig := initPaths{ 83 | rootPath: rootPath, 84 | folderNames: []string{"handlers", "migrations", "views", "mail", "data", "public", "tmp", "logs", "middleware"}, 85 | } 86 | 87 | err := m.Init(pathConfig) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | err = m.checkDotEnv(rootPath) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | // Read values from .env file 98 | err = godotenv.Load(rootPath + "/.env") 99 | if err != nil { 100 | return err 101 | } 102 | 103 | // initiate the loggers 104 | infoLog, errorLog, warnLog, buildLog := m.startLoggers() 105 | m.InfoLog = infoLog 106 | m.ErrorLog = errorLog 107 | m.WarningLog = warnLog 108 | m.BuildLog = buildLog 109 | 110 | // Initiate database connection 111 | if os.Getenv("DATABASE_TYPE") != "" { 112 | var db *sql.DB 113 | switch os.Getenv("DATABASE_TYPE") { 114 | case "": 115 | m.ErrorLog.Println("DATABASE_TYPE is not set") 116 | 117 | case "mysql", "mariadb": 118 | db, err = m.OpenDB("mysql", m.BuildDataSourceName()) 119 | if err != nil { 120 | errorLog.Println(err) 121 | os.Exit(1) 122 | } 123 | case "postgres", "postgresql": 124 | db, err = m.OpenDB("postgres", m.BuildDataSourceName()) 125 | if err != nil { 126 | errorLog.Println(err) 127 | os.Exit(1) 128 | } 129 | 130 | } 131 | m.DB = Database{ 132 | DatabaseType: os.Getenv("DATABASE_TYPE"), 133 | Pool: db, 134 | } 135 | } 136 | scheduler := cron.New() 137 | m.Scheduler = scheduler 138 | 139 | if os.Getenv("CACHE") == "redis" || os.Getenv("SESSION_TYPE") == "redis" { 140 | redisCache = m.createRedisCacheClient() 141 | m.Cache = redisCache 142 | redisPool = redisCache.Connection 143 | } 144 | 145 | if os.Getenv("CACHE") == "badger" { 146 | badgerCache = m.createBadgerCacheClient() 147 | m.Cache = badgerCache 148 | badgerConnection = badgerCache.Connection 149 | 150 | _, err = m.Scheduler.AddFunc("@daily", func() { 151 | _ = badgerCache.Connection.RunValueLogGC(0.7) 152 | }) 153 | if err != nil { 154 | return err 155 | } 156 | } 157 | m.Debug, _ = strconv.ParseBool(os.Getenv("DEBUG")) 158 | m.Version = version 159 | m.RootPath = rootPath 160 | m.Routes = m.routes().(*chi.Mux) 161 | // initiate mailer 162 | m.Mailer = m.createMailer() 163 | m.config = config{ 164 | port: os.Getenv("PORT"), 165 | renderer: os.Getenv("RENDERER"), 166 | cookie: cookieConfig{ 167 | name: os.Getenv("COOKIE_NAME"), 168 | lifetime: os.Getenv("COOKIE_LIFETIME"), 169 | persist: os.Getenv("COOKIE_PERSISTS"), 170 | secure: os.Getenv("COOKIE_SECURE"), 171 | domain: os.Getenv("COOKIE_DOMAIN"), 172 | }, 173 | sessionType: os.Getenv("SESSION_TYPE"), 174 | database: databaseConfig{ 175 | database: os.Getenv("DATABASE_TYPE"), 176 | dataSourceName: m.BuildDataSourceName(), 177 | }, 178 | redis: redisConfig{ 179 | host: os.Getenv("REDIS_HOST"), 180 | port: os.Getenv("REDIS_PORT"), 181 | password: os.Getenv("REDIS_PASSWORD"), 182 | prefix: os.Getenv("REDIS_PREFIX"), 183 | }, 184 | } 185 | 186 | secure := true 187 | if strings.ToLower(os.Getenv("SECURE")) == "false" { 188 | secure = false 189 | } 190 | m.Server = Server{ 191 | ServerName: os.Getenv("SERVER_NAME"), 192 | Port: os.Getenv("PORT"), 193 | Secure: secure, 194 | URL: os.Getenv("APP_URL"), 195 | } 196 | // initiate session 197 | _session := session.Session{ 198 | CookieLifetime: m.config.cookie.lifetime, 199 | CookiePersist: m.config.cookie.persist, 200 | CookieName: m.config.cookie.name, 201 | SessionType: m.config.sessionType, 202 | CookieDomain: m.config.cookie.domain, 203 | DBPool: m.DB.Pool, 204 | } 205 | switch m.config.sessionType { 206 | case "redis": 207 | _session.RedisPool = redisCache.Connection 208 | case "mysql", "mariadb", "postgres", "postgresql": 209 | _session.DBPool = m.DB.Pool 210 | } 211 | 212 | m.Session = _session.InitializeSession() 213 | m.EncryptionKey = os.Getenv("ENCRYPTION_KEY") 214 | if m.Debug { 215 | var views = jet.NewSet( 216 | jet.NewOSFileSystemLoader(fmt.Sprintf("%s/views", rootPath)), 217 | jet.InDevelopmentMode(), 218 | ) 219 | m.JetView = views 220 | } else { 221 | var views = jet.NewSet( 222 | jet.NewOSFileSystemLoader(fmt.Sprintf("%s/views", rootPath)), 223 | ) 224 | 225 | m.JetView = views 226 | } 227 | 228 | m.createRenderer() 229 | go m.Mailer.ListenForMessage() 230 | return nil 231 | } 232 | 233 | // Init creates the necessary folders for MicroGo application 234 | func (m *MicroGo) Init(p initPaths) error { 235 | root := p.rootPath 236 | for _, path := range p.folderNames { 237 | // create folder if it doesn't exist 238 | err := m.CreateDirIfNotExist(root + "/" + path) 239 | if err != nil { 240 | return err 241 | } 242 | } 243 | return nil 244 | } 245 | 246 | // ListenAndServe starts the application web server 247 | func (m *MicroGo) ListenAndServe() { 248 | srv := &http.Server{ 249 | Addr: fmt.Sprintf(":%s", os.Getenv("PORT")), 250 | ErrorLog: m.ErrorLog, 251 | Handler: m.Routes, 252 | IdleTimeout: 30 * time.Second, 253 | ReadTimeout: 30 * time.Second, 254 | WriteTimeout: 600 * time.Second, 255 | } 256 | if m.DB.Pool != nil { 257 | defer func(Pool *sql.DB) { 258 | err := Pool.Close() 259 | if err != nil { 260 | m.WarningLog.Println(err) 261 | } 262 | }(m.DB.Pool) 263 | } 264 | 265 | if redisPool != nil { 266 | defer func(redisPool *redis.Pool) { 267 | err := redisPool.Close() 268 | if err != nil { 269 | m.WarningLog.Println(err) 270 | } 271 | }(redisPool) 272 | } 273 | if badgerConnection != nil { 274 | defer func(badgerConnection *badger.DB) { 275 | err := badgerConnection.Close() 276 | if err != nil { 277 | m.WarningLog.Println(err) 278 | } 279 | }(badgerConnection) 280 | } 281 | m.InfoLog.Printf("Listening on port %s", os.Getenv("PORT")) 282 | err := srv.ListenAndServe() 283 | m.ErrorLog.Fatal(err) 284 | } 285 | 286 | func (m *MicroGo) checkDotEnv(path string) error { 287 | err := m.CreateFileIfNotExists(fmt.Sprintf("%s/.env", path)) 288 | if err != nil { 289 | return err 290 | } 291 | return nil 292 | } 293 | 294 | // startLoggers Initializes all loggers for microGo application. 295 | func (m *MicroGo) startLoggers() (*log.Logger, *log.Logger, *log.Logger, *log.Logger) { 296 | var infoLog *log.Logger 297 | var errorLog *log.Logger 298 | var warnLog *log.Logger 299 | var buildLog *log.Logger 300 | warnLog = log.New(os.Stderr, "[ WARNING ] ", log.Ldate|log.Ltime|log.Lshortfile) 301 | infoLog = log.New(os.Stderr, "[ INFO ] ", log.Ldate|log.Ltime|log.Lshortfile) 302 | buildLog = log.New(os.Stderr, "[ BUILD ] ", log.Ldate|log.Ltime|log.Lshortfile) 303 | errorLog = log.New(os.Stderr, "[ ERROR ] ", log.Ldate|log.Ltime|log.Lshortfile) 304 | return infoLog, errorLog, warnLog, buildLog 305 | } 306 | 307 | // createRenderer Create a Renderer for microGo application. 308 | func (m *MicroGo) createRenderer() { 309 | renderer := render.Render{ 310 | Renderer: m.config.renderer, 311 | RootPath: m.RootPath, 312 | Port: m.config.port, 313 | JetViews: m.JetView, 314 | BlocksViews: m.BlocksView, 315 | Session: m.Session, 316 | } 317 | m.Render = &renderer 318 | } 319 | 320 | // createRenderer Create a Renderer for microGo application. 321 | func (m *MicroGo) createMailer() mailer.Mailer { 322 | port, _ := strconv.Atoi(os.Getenv("SMTP_PORT")) 323 | _mailer := mailer.Mailer{ 324 | Domain: os.Getenv("MAIL_DOMAIN"), 325 | Templates: m.RootPath + "/mail", 326 | Host: os.Getenv("SMTP_HOST"), 327 | Port: port, 328 | Username: os.Getenv("SMTP_USERNAME"), 329 | Password: os.Getenv("SMTP_PASSWORD"), 330 | Encryption: os.Getenv("SMTP_ENCRYPTION"), 331 | FromAddress: os.Getenv("FROM_ADDRESS"), 332 | FromName: os.Getenv("FROM_NAME"), 333 | Jobs: make(chan mailer.Message, 20), 334 | Results: make(chan mailer.Result, 20), 335 | API: os.Getenv("MAILER_API"), 336 | ApiKey: os.Getenv("MAILER_KEY"), 337 | ApiUrl: os.Getenv("MAILER_URL"), 338 | } 339 | return _mailer 340 | } 341 | 342 | // BuildDataSourceName builds the datasource name for our database, and returns it as a string 343 | func (m *MicroGo) BuildDataSourceName() string { 344 | var dsn string 345 | 346 | switch os.Getenv("DATABASE_TYPE") { 347 | case "postgres", "postgresql": 348 | dsn = fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=%s timezone=UTC connect_timeout=5", 349 | os.Getenv("DATABASE_HOST"), 350 | os.Getenv("DATABASE_PORT"), 351 | os.Getenv("DATABASE_USER"), 352 | os.Getenv("DATABASE_NAME"), 353 | os.Getenv("DATABASE_SSL_MODE")) 354 | if os.Getenv("DATABASE_PASS") != "" { 355 | dsn = fmt.Sprintf("%s password=%s", dsn, os.Getenv("DATABASE_PASS")) 356 | } 357 | return dsn 358 | case "mysql", "mariadb": 359 | return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", 360 | os.Getenv("DATABASE_USER"), 361 | os.Getenv("DATABASE_PASS"), 362 | os.Getenv("DATABASE_HOST"), 363 | os.Getenv("DATABASE_PORT"), 364 | os.Getenv("DATABASE_NAME")) 365 | default: 366 | 367 | } 368 | return "" 369 | } 370 | 371 | func (m *MicroGo) createRedisPool() *redis.Pool { 372 | return &redis.Pool{ 373 | Dial: func() (redis.Conn, error) { 374 | return redis.Dial( 375 | "tcp", 376 | fmt.Sprintf("%s:%s", m.config.redis.host, m.config.redis.port), 377 | redis.DialPassword(m.config.redis.password), 378 | redis.DialUsername(m.config.redis.username), 379 | ) 380 | }, 381 | DialContext: nil, 382 | TestOnBorrow: func(conn redis.Conn, t time.Time) error { 383 | _, err := conn.Do("PING") 384 | return err 385 | 386 | }, 387 | MaxIdle: 50, 388 | MaxActive: 10000, 389 | IdleTimeout: 240 * time.Second, 390 | Wait: false, 391 | MaxConnLifetime: 0, 392 | } 393 | } 394 | 395 | func (m *MicroGo) createRedisCacheClient() *cache.RedisCache { 396 | _client := cache.RedisCache{ 397 | Connection: m.createRedisPool(), 398 | Prefix: m.config.redis.prefix, 399 | } 400 | return &_client 401 | } 402 | func (m *MicroGo) createBadgerCacheClient() *cache.BadgerCache { 403 | cacheClient := cache.BadgerCache{ 404 | Connection: m.connectToBadgerCache(), 405 | } 406 | return &cacheClient 407 | } 408 | func (m *MicroGo) connectToBadgerCache() *badger.DB { 409 | db, err := badger.Open(badger.DefaultOptions(m.RootPath + "/tmp/badger")) 410 | if err != nil { 411 | return nil 412 | } 413 | return db 414 | } 415 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package MicroGO 2 | 3 | import ( 4 | "github.com/justinas/nosurf" 5 | "net/http" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | func (m *MicroGo) LoadSessions(next http.Handler) http.Handler { 11 | m.InfoLog.Println("Triggering LoadSessions for MicroGo.") 12 | return m.Session.LoadAndSave(next) 13 | } 14 | 15 | func (m *MicroGo) NoSurf(next http.Handler) http.Handler { 16 | csrf := nosurf.New(next) 17 | isSecure, _ := strconv.ParseBool(m.config.cookie.secure) 18 | csrf.ExemptGlob("/api/*") 19 | csrf.SetBaseCookie(http.Cookie{ 20 | Name: "", 21 | Value: "", 22 | Path: "", 23 | Domain: m.config.cookie.domain, 24 | Expires: time.Time{}, 25 | RawExpires: "", 26 | MaxAge: 0, 27 | Secure: isSecure, 28 | HttpOnly: true, 29 | SameSite: http.SameSiteStrictMode, 30 | Raw: "", 31 | Unparsed: nil, 32 | }) 33 | return csrf 34 | } 35 | -------------------------------------------------------------------------------- /migrations.go: -------------------------------------------------------------------------------- 1 | package MicroGO 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "runtime" 7 | "strings" 8 | 9 | _ "github.com/go-sql-driver/mysql" 10 | "github.com/golang-migrate/migrate/v4" 11 | _ "github.com/golang-migrate/migrate/v4/database/mysql" 12 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 13 | _ "github.com/golang-migrate/migrate/v4/source/file" 14 | ) 15 | 16 | func (m *MicroGo) MigrateUp(dsn string) error { 17 | var path string 18 | path = "file://" + m.RootPath + "/migrations" 19 | if runtime.GOOS == "windows" { 20 | path = fmt.Sprintf(strings.Replace(path, "/", "\\", -1)) 21 | } else { 22 | path = "file://" + m.RootPath + "/migrations" 23 | } 24 | mig, err := migrate.New(path, dsn) 25 | if err != nil { 26 | return err 27 | } 28 | defer func(mig *migrate.Migrate) { 29 | _, _ = mig.Close() 30 | }(mig) 31 | 32 | if err := mig.Up(); err != nil { 33 | log.Println("Error running migration:", err) 34 | return err 35 | } 36 | return nil 37 | } 38 | 39 | func (m *MicroGo) MigrateDownAll(dsn string) error { 40 | // TODO: Add windows support. 41 | mig, err := migrate.New("file://"+m.RootPath+"/migrations", dsn) 42 | if err != nil { 43 | return err 44 | } 45 | defer func(mig *migrate.Migrate) { 46 | _, _ = mig.Close() 47 | }(mig) 48 | 49 | if err := mig.Down(); err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func (m *MicroGo) Steps(n int, dsn string) error { 57 | // TODO: Add windows support. 58 | mig, err := migrate.New("file://"+m.RootPath+"/migrations", dsn) 59 | if err != nil { 60 | return err 61 | } 62 | defer func(mig *migrate.Migrate) { 63 | _, _ = mig.Close() 64 | }(mig) 65 | 66 | if err := mig.Steps(n); err != nil { 67 | return err 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (m *MicroGo) MigrateForce(dsn string) error { 74 | // TODO: Add windows support. 75 | mig, err := migrate.New("file://"+m.RootPath+"/migrations", dsn) 76 | if err != nil { 77 | return err 78 | } 79 | defer func(mig *migrate.Migrate) { 80 | _, _ = mig.Close() 81 | }(mig) 82 | 83 | if err := mig.Force(-1); err != nil { 84 | return err 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /render/render.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/CloudyKit/jet/v6" 7 | "github.com/alexedwards/scs/v2" 8 | "github.com/justinas/nosurf" 9 | "github.com/kataras/blocks" 10 | "html/template" 11 | "log" 12 | "net/http" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | type Render struct { 18 | Renderer string 19 | RootPath string 20 | Secure bool 21 | Port string 22 | ServerName string 23 | JetViews *jet.Set 24 | BlocksViews *blocks.Blocks 25 | Session *scs.SessionManager 26 | } 27 | 28 | type TemplateData struct { 29 | IsAuthenticated bool 30 | IntMap map[string]int 31 | StringMap map[string]string 32 | FloatMap map[string]float64 33 | Data any 34 | CSRFToken string 35 | Port string 36 | ServerName string 37 | Secure bool 38 | Error string 39 | Flash string 40 | } 41 | 42 | func (r *Render) DefaultData(templateData *TemplateData, request *http.Request) *TemplateData { 43 | templateData.Secure = r.Secure 44 | templateData.ServerName = r.ServerName 45 | templateData.Port = r.Port 46 | templateData.CSRFToken = nosurf.Token(request) 47 | fmt.Println("CSRFToken: ", templateData.CSRFToken) 48 | fmt.Println("Session: ", r.Session.Exists(request.Context(), "userID")) 49 | if r.Session.Exists(request.Context(), "userID") { 50 | templateData.IsAuthenticated = true 51 | } 52 | templateData.Error = r.Session.PopString(request.Context(), "error") 53 | templateData.Flash = r.Session.PopString(request.Context(), "flash") 54 | return templateData 55 | } 56 | 57 | // Page The page render function. You can use it to render pages using go or jet templates. 58 | func (r *Render) Page(writer http.ResponseWriter, request *http.Request, view, layout string, variables interface{}, data any) error { 59 | switch strings.ToLower(r.Renderer) { 60 | case "go": 61 | return r.GoPage(writer, request, view, data) 62 | case "jet": 63 | return r.JetPage(writer, request, view, variables, data) 64 | case "blocks": 65 | return r.BlocksPage(writer, request, view, layout, data) 66 | 67 | default: 68 | } 69 | return errors.New("No rendering engine available. Please fill the required value (go or jet) in .env file ") 70 | } 71 | 72 | // GoPage The default go template engine renderer function. 73 | func (r *Render) GoPage(writer http.ResponseWriter, request *http.Request, view string, data any) error { 74 | tmpl, err := template.ParseFiles(fmt.Sprintf("%s/views/%s.html", r.RootPath, view)) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | td := &TemplateData{} 80 | if data != nil { 81 | td.Data = data 82 | } 83 | td = r.DefaultData(td, request) 84 | err = tmpl.Execute(writer, td) 85 | if err != nil { 86 | return err 87 | } 88 | return nil 89 | } 90 | 91 | // JetPage The jet engine template renderer function. 92 | func (r *Render) JetPage(writer http.ResponseWriter, request *http.Request, view string, variables interface{}, data any) error { 93 | var vars jet.VarMap 94 | if variables == nil { 95 | vars = make(jet.VarMap) 96 | } else { 97 | vars = variables.(jet.VarMap) 98 | } 99 | td := &TemplateData{} 100 | 101 | if data != nil { 102 | td.Data = data 103 | } 104 | 105 | td = r.DefaultData(td, request) 106 | t, err := r.JetViews.GetTemplate(fmt.Sprintf("%s.jet", view)) 107 | if err != nil { 108 | log.Println(err) 109 | return err 110 | } 111 | if err = t.Execute(writer, vars, td); err != nil { 112 | log.Println(err) 113 | return err 114 | } 115 | return nil 116 | } 117 | 118 | // BlocksPage The Blocks' engine template renderer function. 119 | func (r *Render) BlocksPage(writer http.ResponseWriter, request *http.Request, view, layout string, data any) error { 120 | writer.Header().Set("Content-Type", "text/html; charset=utf-8") 121 | r.BlocksViews = blocks.New("./views"). 122 | Reload(true). 123 | Funcs(map[string]interface{}{ 124 | "year": func() int { 125 | return time.Now().Year() 126 | }, 127 | }) 128 | err := r.BlocksViews.Load() 129 | if err != nil { 130 | return err 131 | } 132 | 133 | td := &TemplateData{} 134 | 135 | if data != nil { 136 | 137 | td.Data = data 138 | td = r.DefaultData(td, request) 139 | } 140 | err = r.BlocksViews.ExecuteTemplate(writer, view, layout, td) 141 | if err != nil { 142 | return err 143 | } 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /render/render_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | var templateTests = []struct { 10 | name string 11 | renderer string 12 | template string 13 | errorExpected bool 14 | errorMSG string 15 | }{ 16 | {"goTemplate", "go", "home", 17 | false, "Unable to render go template.", 18 | }, 19 | {"goTemplateNoTemplate", "go", "not-exists", 20 | true, "Something went wrong. Unable to render a not existing go template.", 21 | }, 22 | {"jetTemplate", "jet", "home", 23 | false, "Unable to render jet template.", 24 | }, 25 | {"jetTemplateNoTemplate", "jet", "not-exists", 26 | true, "Something went wrong. Unable to render a not existing jet template.", 27 | }, {"blocksTemplate", "blocks", "index", 28 | false, "Unable to render jet template.", 29 | }, 30 | {"blocksTemplateNoTemplate", "blocks", "not-exists", 31 | true, "Something went wrong. Unable to render a not existing jet template.", 32 | }, 33 | {"invalidRenderEngine", "foo", "home", 34 | true, "No error while trying to render template with no valid engine.", 35 | }, 36 | } 37 | 38 | func TestRenderPage(t *testing.T) { 39 | 40 | for _, task := range templateTests { 41 | r, err := http.NewRequest("GET", "/test/render", nil) 42 | if err != nil { 43 | t.Error(err) 44 | } 45 | w := httptest.NewRecorder() 46 | 47 | testRenderer.Renderer = task.renderer 48 | testRenderer.RootPath = "./test" 49 | err = testRenderer.Page(w, r, task.template, "", nil, nil) 50 | 51 | if task.errorExpected { 52 | if err == nil { 53 | t.Errorf("%s: %s:", task.errorMSG, err.Error()) 54 | 55 | } 56 | } else { 57 | if err != nil { 58 | t.Errorf("%s: %s: %s:", task.name, task.errorMSG, err.Error()) 59 | } 60 | } 61 | 62 | } 63 | 64 | } 65 | 66 | func TestRenderGoPage(t *testing.T) { 67 | w := httptest.NewRecorder() 68 | r, err := http.NewRequest("GET", "/url", nil) 69 | if err != nil { 70 | t.Error(err) 71 | } 72 | testRenderer.Renderer = "go" 73 | testRenderer.RootPath = "./test" 74 | err = testRenderer.Page(w, r, "home", "", nil, nil) 75 | if err != nil { 76 | t.Error("Unable to render Go template. ", err) 77 | } 78 | } 79 | func TestRenderJetPage(t *testing.T) { 80 | w := httptest.NewRecorder() 81 | r, err := http.NewRequest("GET", "/url", nil) 82 | if err != nil { 83 | t.Error(err) 84 | } 85 | testRenderer.Renderer = "jet" 86 | testRenderer.RootPath = "./test" 87 | err = testRenderer.Page(w, r, "home", "", nil, nil) 88 | if err != nil { 89 | t.Error("Unable to render Jet template. ", err) 90 | } 91 | } 92 | func TestRenderBlocksPage(t *testing.T) { 93 | w := httptest.NewRecorder() 94 | r, err := http.NewRequest("GET", "/url", nil) 95 | if err != nil { 96 | t.Error(err) 97 | } 98 | testRenderer.Renderer = "blocks" 99 | testRenderer.RootPath = "./test" 100 | err = testRenderer.Page(w, r, "index", "main", nil, nil) 101 | if err != nil { 102 | t.Error("Unable to render blocks template. ", err) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /render/setup_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "github.com/CloudyKit/jet/v6" 5 | "github.com/kataras/blocks" 6 | "os" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | var views = jet.NewSet( 12 | jet.NewOSFileSystemLoader("./test/views"), 13 | jet.InDevelopmentMode(), 14 | ) 15 | var blocksViews = blocks.New("./test/views"). 16 | Reload(true). 17 | Funcs(map[string]interface{}{ 18 | "year": func() int { 19 | return time.Now().Year() 20 | }, 21 | }) 22 | 23 | var testRenderer = Render{ 24 | Renderer: "", 25 | RootPath: "", 26 | JetViews: views, 27 | BlocksViews: blocksViews, 28 | } 29 | 30 | func TestMain(m *testing.M) { 31 | os.Exit(m.Run()) 32 | } 33 | -------------------------------------------------------------------------------- /requests/requests.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | type Requests struct { 12 | *http.Request 13 | *http.Header 14 | *http.Client 15 | *http.Transport 16 | Params url.Values 17 | } 18 | 19 | // do make http request to the provided 20 | func (r *Requests) do(method, requestURL string, body io.Reader, options []func(*Requests)) (*Requests, error) { 21 | newRequest, err := http.NewRequest(method, requestURL, body) 22 | if err != nil { 23 | return nil, err 24 | } 25 | request := &Requests{ 26 | Request: newRequest, 27 | Client: &http.Client{}, 28 | Transport: &http.Transport{}, 29 | Params: url.Values{}, 30 | } 31 | 32 | // Apply the options parameters to request. 33 | for _, option := range options { 34 | option(request) 35 | } 36 | parsedURL, err := url.Parse(requestURL) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | // If the request.Params is set, replace raw query with that. 42 | if len(request.Params) > 0 { 43 | parsedURL.RawQuery = request.Params.Encode() 44 | } 45 | 46 | newRequest.URL = parsedURL 47 | 48 | // Parse query values into request Form 49 | err = newRequest.ParseForm() 50 | if err != nil { 51 | return nil, err 52 | } 53 | return request, nil 54 | } 55 | 56 | // Head sends an HTTP HEAD request to the specified URL, 57 | // with the ability to add query parameters, headers, and timeout, among other options.. 58 | func (r *Requests) Head(requestURL string, options ...func(*Requests)) (*Response, error) { 59 | request, err := r.do("HEAD", requestURL, nil, options) 60 | if err != nil { 61 | return nil, err 62 | } 63 | resp, err := request.Client.Do(request.Request) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | // Encapsulate *http.Response in *Response 69 | response := &Response{Response: resp} 70 | return response, nil 71 | } 72 | 73 | // Get sends a GET request to the provided url 74 | func (r *Requests) Get(requestURL string, options ...func(*Requests)) (*Response, error) { 75 | request, err := r.do("GET", requestURL, nil, options) 76 | if err != nil { 77 | return nil, err 78 | } 79 | resp, err := request.Client.Do(request.Request) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | // Encapsulate *http.Response in *Response 85 | response := &Response{Response: resp} 86 | return response, nil 87 | } 88 | 89 | // AsyncGet sends an HTTP GET request to the provided URL and 90 | // returns ch <-chan *http.Response immediately. 91 | func (r *Requests) AsyncGet(requestURL string, options ...func(*Requests)) (<-chan *Response, error) { 92 | request, err := r.do("GET", requestURL, nil, options) 93 | if err != nil { 94 | return nil, err 95 | } 96 | ch := make(chan *Response) 97 | go func() { 98 | resp, err := request.Client.Do(request.Request) 99 | response := &Response{} 100 | if err != nil { 101 | response.Error = err 102 | ch <- response 103 | } 104 | response.Response = resp 105 | ch <- response 106 | close(ch) 107 | }() 108 | return ch, nil 109 | } 110 | 111 | // Post sends an HTTP POST request to the provided URL 112 | func (r *Requests) Post(requestURL, bodyType string, body io.Reader, options ...func(*Requests)) (*Response, error) { 113 | request, err := r.do("POST", requestURL, body, options) 114 | if err != nil { 115 | return nil, err 116 | } 117 | request.Header.Set("Content-Type", bodyType) 118 | resp, err := request.Client.Do(request.Request) 119 | if err != nil { 120 | return nil, err 121 | } 122 | 123 | // Encapsulate *http.Response in *Response 124 | response := &Response{Response: resp} 125 | return response, nil 126 | } 127 | 128 | // AsyncPost sends an HTTP POST request to the provided URL and returns a <-chan *http.Response immediately. 129 | func (r *Requests) AsyncPost(requestURL, bodyType string, body io.Reader, options ...func(*Requests)) (<-chan *Response, error) { 130 | request, err := r.do("POST", requestURL, body, options) 131 | if err != nil { 132 | return nil, err 133 | } 134 | request.Header.Set("Content-Type", bodyType) 135 | resChannel := make(chan *Response) 136 | go func() { 137 | resp, err := request.Client.Do(request.Request) 138 | // Encapsulate *http.Response in *Response 139 | response := &Response{} 140 | if err != nil { 141 | response.Error = err 142 | resChannel <- response 143 | } 144 | response.Response = resp 145 | resChannel <- response 146 | close(resChannel) 147 | }() 148 | return resChannel, nil 149 | } 150 | 151 | // JSONPost Marshals request data as JSON and set the content type to "application/json". 152 | func (r *Requests) JSONPost(requestURL string, body interface{}, options ...func(*Requests)) (*Response, error) { 153 | buff := new(bytes.Buffer) 154 | err := json.NewEncoder(buff).Encode(body) 155 | if err != nil { 156 | return nil, err 157 | } 158 | request, err := r.do("POST", requestURL, buff, options) 159 | if err != nil { 160 | return nil, err 161 | } 162 | request.Header.Set("Content-Type", "application/json") 163 | resp, err := request.Client.Do(request.Request) 164 | if err != nil { 165 | return nil, err 166 | } 167 | 168 | // Encapsulate *http.Response in *Response 169 | response := &Response{Response: resp} 170 | return response, nil 171 | } 172 | 173 | // XMLPost Marshals request data as XML and set the content type to "application/xml". 174 | func (r *Requests) XMLPost(requestURL string, body interface{}, options ...func(*Requests)) (*Response, error) { 175 | buff := new(bytes.Buffer) 176 | err := json.NewEncoder(buff).Encode(body) 177 | if err != nil { 178 | return nil, err 179 | } 180 | request, err := r.do("POST", requestURL, buff, options) 181 | if err != nil { 182 | return nil, err 183 | } 184 | request.Header.Set("Content-Type", "application/xml") 185 | resp, err := request.Client.Do(request.Request) 186 | if err != nil { 187 | return nil, err 188 | } 189 | 190 | // Encapsulate *http.Response in *Response 191 | response := &Response{Response: resp} 192 | return response, nil 193 | } 194 | 195 | // Put sends HTTP PUT request to the provided URL. 196 | func (r *Requests) Put(requestURL, bodyType string, body io.Reader, options ...func(*Requests)) (*Response, error) { 197 | request, err := r.do("PUT", requestURL, body, options) 198 | if err != nil { 199 | return nil, err 200 | } 201 | request.Header.Set("Content-Type", bodyType) 202 | resp, err := request.Client.Do(request.Request) 203 | if err != nil { 204 | return nil, err 205 | } 206 | 207 | // Encapsulate *http.Response in *Response 208 | response := &Response{Response: resp} 209 | return response, nil 210 | } 211 | 212 | // Patch sends an HTTP PATCH request to the provided URL with optional body to update the data. 213 | func (r *Requests) Patch(requestURL, bodyType string, body io.Reader, options ...func(*Requests)) (*Response, error) { 214 | request, err := r.do("PATCH", requestURL, body, options) 215 | if err != nil { 216 | return nil, err 217 | } 218 | request.Header.Set("Content-Type", bodyType) 219 | resp, err := request.Client.Do(request.Request) 220 | if err != nil { 221 | return nil, err 222 | } 223 | 224 | // Encapsulate *http.Response in *Response 225 | response := &Response{Response: resp} 226 | return response, nil 227 | } 228 | 229 | // Delete sends an HTTP DELETE request to the provided URL. 230 | func (r *Requests) Delete(requestURL string, options ...func(*Requests)) (*Response, error) { 231 | request, err := r.do("DELETE", requestURL, nil, options) 232 | if err != nil { 233 | return nil, err 234 | } 235 | resp, err := request.Client.Do(request.Request) 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | // Encapsulate *http.Response in *Response 241 | response := &Response{Response: resp} 242 | return response, nil 243 | } 244 | 245 | // Options sends a rarely-used HTTP OPTIONS request to the provided URL. 246 | // Options only permit a single parameter, which is the destination URL string. 247 | func (r *Requests) Options(requestURL string) (*Response, error) { 248 | request, err := r.do("OPTIONS", requestURL, nil, []func(r *Requests){}) 249 | if err != nil { 250 | return nil, err 251 | } 252 | resp, err := request.Client.Do(request.Request) 253 | if err != nil { 254 | return nil, err 255 | } 256 | 257 | // Encapsulate *http.Response in *Response 258 | response := &Response{Response: resp} 259 | return response, nil 260 | } 261 | -------------------------------------------------------------------------------- /requests/responses.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "mime" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | type Response struct { 12 | *http.Response 13 | Error error 14 | StatusCode int 15 | } 16 | 17 | func (r *Response) ContentType() (string, map[string]string, error) { 18 | ct := r.Header.Get("content-type") 19 | filtered, params, err := mime.ParseMediaType(ct) 20 | if err != nil { 21 | return "", nil, err 22 | } 23 | return filtered, params, nil 24 | } 25 | 26 | func (r *Response) String() (string, error) { 27 | defer func(Body io.ReadCloser) { 28 | _ = Body.Close() 29 | }(r.Body) 30 | res := new(bytes.Buffer) 31 | _, err := res.ReadFrom(r.Body) 32 | if err != nil { 33 | return "", err 34 | } 35 | bodyStr := res.String() 36 | return bodyStr, nil 37 | } 38 | 39 | func (r *Response) Bytes() ([]byte, error) { 40 | defer func(Body io.ReadCloser) { 41 | _ = Body.Close() 42 | }(r.Body) 43 | res := new(bytes.Buffer) 44 | _, err := res.ReadFrom(r.Body) 45 | if err != nil { 46 | return nil, err 47 | } 48 | bodyBytes := res.Bytes() 49 | return bodyBytes, nil 50 | } 51 | 52 | func (r *Response) JSON() ([]byte, error) { 53 | var res []byte 54 | for _, content := range r.Header["Content-Type"] { 55 | t, _, err := mime.ParseMediaType(content) 56 | if err != nil { 57 | return nil, err 58 | } 59 | if strings.Contains(t, "application/json") { 60 | res, err = r.Bytes() 61 | if err != nil { 62 | return nil, err 63 | } 64 | } 65 | } 66 | return res, nil 67 | } 68 | 69 | func (r *Response) XML() ([]byte, error) { 70 | var res []byte 71 | for _, content := range r.Header["Content-Type"] { 72 | t, _, err := mime.ParseMediaType(content) 73 | if err != nil { 74 | return nil, err 75 | } 76 | if strings.Contains(t, "application/xml") { 77 | res, err = r.Bytes() 78 | if err != nil { 79 | return nil, err 80 | } 81 | } 82 | } 83 | return res, nil 84 | } 85 | -------------------------------------------------------------------------------- /responce-utils.go: -------------------------------------------------------------------------------- 1 | package MicroGO 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "path" 11 | "path/filepath" 12 | ) 13 | 14 | // WriteJson : Create a JSON response. 15 | func (m *MicroGo) WriteJson(w http.ResponseWriter, status int, data interface{}, headers ...http.Header) error { 16 | out, err := json.MarshalIndent(data, "", "\t") 17 | if err != nil { 18 | return err 19 | } 20 | if len(headers) > 0 { 21 | for key, val := range headers[0] { 22 | w.Header()[key] = val 23 | } 24 | } 25 | w.Header().Set("Content-Type", "application/json") 26 | w.WriteHeader(status) 27 | _, err = w.Write(out) 28 | if err != nil { 29 | return err 30 | } 31 | return nil 32 | } 33 | 34 | func (m *MicroGo) ReadJson(w http.ResponseWriter, r *http.Request, data interface{}) error { 35 | maxBytesSize := 1 * 1024 * 1024 //1MB 36 | r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytesSize)) 37 | dec := json.NewDecoder(r.Body) 38 | err := dec.Decode(data) 39 | if err != nil { 40 | return err 41 | } 42 | err = dec.Decode(&struct{}{}) 43 | if err != io.EOF { 44 | return errors.New("the JSON Body need to have only single value. ") 45 | } 46 | return nil 47 | } 48 | 49 | // WriteXML : Create XML response. 50 | func (m *MicroGo) WriteXML(w http.ResponseWriter, status int, data interface{}, headers ...http.Header) error { 51 | out, err := xml.MarshalIndent(data, "", "") 52 | if err != nil { 53 | return err 54 | } 55 | if len(headers) > 0 { 56 | for key, val := range headers[0] { 57 | w.Header()[key] = val 58 | } 59 | } 60 | w.Header().Set("Content-Type", "application/xml") 61 | w.WriteHeader(status) 62 | _, err = w.Write(out) 63 | if err != nil { 64 | return err 65 | } 66 | return nil 67 | } 68 | 69 | // SentFile : Send a file on response. 70 | func (m *MicroGo) SentFile(w http.ResponseWriter, r *http.Request, fileLocation, fileName string) error { 71 | _path := path.Join(fileLocation, fileName) 72 | fileToServe := filepath.Clean(_path) 73 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; file=\"%s\"", fileName)) 74 | http.ServeFile(w, r, fileToServe) 75 | return nil 76 | } 77 | 78 | // Error404 : Return Not Found HTTP response . 79 | // 80 | // Status Code : 404 81 | func (m *MicroGo) Error404(w http.ResponseWriter, r *http.Request) { 82 | m.ErrorStatus(w, http.StatusNotFound) 83 | } 84 | 85 | // Error500 : Return StatusInternal Server Error HTTP response . 86 | // 87 | // Status Code : 500 88 | func (m *MicroGo) Error500(w http.ResponseWriter, r *http.Request) { 89 | 90 | m.ErrorStatus(w, http.StatusInternalServerError) 91 | } 92 | 93 | // ErrorUnauthorized : Return Unauthorized response on request error. 94 | // 95 | // Status Code : 401 96 | func (m *MicroGo) ErrorUnauthorized(w http.ResponseWriter, r *http.Request) { 97 | m.ErrorStatus(w, http.StatusUnauthorized) 98 | } 99 | 100 | // ErrorForbidden : Return StatusForbidden HTTP response. 101 | // 102 | // Status Code : 403 103 | func (m *MicroGo) ErrorForbidden(w http.ResponseWriter, r *http.Request) { 104 | m.ErrorStatus(w, http.StatusForbidden) 105 | } 106 | 107 | // ErrorUnprocessable : Return Unprocessable entity HTTP response. 108 | // 109 | // Status Code 422. 110 | func (m *MicroGo) ErrorUnprocessable(w http.ResponseWriter, r *http.Request) { 111 | m.ErrorStatus(w, http.StatusUnprocessableEntity) 112 | } 113 | 114 | // ErrorStatus : Construct Error HTTP response 115 | func (m *MicroGo) ErrorStatus(w http.ResponseWriter, status int) { 116 | http.Error(w, http.StatusText(status), status) 117 | 118 | } 119 | -------------------------------------------------------------------------------- /routes.go: -------------------------------------------------------------------------------- 1 | package MicroGO 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | "github.com/go-chi/chi/v5/middleware" 6 | "net/http" 7 | ) 8 | 9 | // routes Return a Mux object that implements the Router interface. 10 | func (m *MicroGo) routes() http.Handler { 11 | mux := chi.NewRouter() 12 | mux.Use(middleware.RequestID) 13 | mux.Use(middleware.RealIP) 14 | if m.Debug { 15 | mux.Use(middleware.Logger) 16 | } 17 | mux.Use(middleware.Recoverer) 18 | mux.Use(m.LoadSessions) 19 | mux.Use(m.NoSurf) 20 | return mux 21 | } 22 | -------------------------------------------------------------------------------- /session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/alexedwards/scs/mysqlstore" 6 | "github.com/alexedwards/scs/postgresstore" 7 | "github.com/alexedwards/scs/redisstore" 8 | "github.com/alexedwards/scs/v2" 9 | "github.com/gomodule/redigo/redis" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | type Session struct { 17 | CookieLifetime string 18 | CookiePersist string 19 | CookieName string 20 | CookieDomain string 21 | SessionType string 22 | IsCookieSecure string 23 | DBPool *sql.DB 24 | RedisPool *redis.Pool 25 | } 26 | 27 | func (s *Session) InitializeSession() *scs.SessionManager { 28 | var persist, secure bool 29 | // Session lifetime 30 | minutes, err := strconv.Atoi(s.CookieLifetime) 31 | if err != nil { 32 | minutes = 60 33 | } 34 | // Should cookies persists? 35 | if strings.ToLower(s.CookiePersist) == "true" { 36 | persist = true 37 | } else { 38 | persist = false 39 | } 40 | 41 | // is cookie secure? 42 | if strings.ToLower(s.IsCookieSecure) == "true" { 43 | secure = true 44 | } 45 | 46 | // initiate session 47 | session := scs.New() 48 | session.Lifetime = time.Duration(minutes) * time.Minute 49 | session.Cookie.Persist = persist 50 | session.Cookie.Name = s.CookieName 51 | session.Cookie.Secure = secure 52 | session.Cookie.Domain = s.CookieDomain 53 | session.Cookie.SameSite = http.SameSiteLaxMode 54 | 55 | // select session store 56 | 57 | switch strings.ToLower(s.SessionType) { 58 | case "redis": 59 | session.Store = redisstore.New(s.RedisPool) 60 | case "mysql", "mariadb": 61 | session.Store = mysqlstore.New(s.DBPool) 62 | case "postgres", "postgresql": 63 | session.Store = postgresstore.New(s.DBPool) 64 | default: 65 | 66 | } 67 | return session 68 | } 69 | -------------------------------------------------------------------------------- /session/session_test.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alexedwards/scs/v2" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func TestSessionInitSession(t *testing.T) { 11 | 12 | c := &Session{ 13 | CookieLifetime: "100", 14 | CookiePersist: "true", 15 | CookieName: "microGo", 16 | CookieDomain: "localhost", 17 | SessionType: "cookie", 18 | } 19 | var sm *scs.SessionManager 20 | ses := c.InitializeSession() 21 | var sessionKind reflect.Kind 22 | var sessionType reflect.Type 23 | rv := reflect.ValueOf(ses) 24 | for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { 25 | fmt.Print("In Loop : ", rv.Kind(), rv.Type(), rv) 26 | sessionKind = rv.Kind() 27 | sessionType = rv.Type() 28 | rv.Type() 29 | rv = rv.Elem() 30 | 31 | } 32 | if !rv.IsValid() { 33 | t.Error("Invalid type or Kind! Kind:", rv.Kind(), "type:", rv.Type()) 34 | } 35 | if sessionKind != reflect.ValueOf(sm).Kind() { 36 | t.Error("wrong kind returned testing cookie session. Expected", reflect.ValueOf(sm).Kind(), "and got", sessionKind) 37 | } 38 | 39 | if sessionType != reflect.ValueOf(sm).Type() { 40 | t.Error("wrong type returned testing cookie session. Expected", reflect.ValueOf(sm).Type(), "and got", sessionType) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /session/setup_test.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMain(m *testing.M) { 9 | os.Exit(m.Run()) 10 | } 11 | -------------------------------------------------------------------------------- /terminal/cli/admin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func createAdminUser() error { 9 | askForInput("") 10 | dbType := micro.DB.DatabaseType 11 | 12 | if dbType == "mariadb" { 13 | dbType = "mysql" 14 | } 15 | 16 | if dbType == "postgresql" { 17 | dbType = "postgres" 18 | } 19 | fileName := fmt.Sprintf("%d_create_auth_tables", time.Now().UnixMicro()) 20 | upFile := micro.RootPath + "/migrations/" + fileName + ".up.sql" 21 | downFile := micro.RootPath + "/migrations/" + fileName + ".down.sql" 22 | 23 | err := copyTemplateFile("templates/migrations/admin_tables."+dbType+".sql", upFile) 24 | if err != nil { 25 | gracefullyExit(err) 26 | } 27 | 28 | err = copyDataToFile([]byte("drop table if exists users cascade; drop table if exists tokens cascade; drop table if exists remember_tokens;"), downFile) 29 | if err != nil { 30 | gracefullyExit(err) 31 | } 32 | 33 | // run migrations 34 | err = doMigrate("up", "") 35 | if err != nil { 36 | gracefullyExit(err) 37 | } 38 | // ask for email 39 | email, err := askForInput("Enter admin email: ") 40 | if err != nil { 41 | return err 42 | } 43 | // ask for password 44 | password, err := askForInput("Enter admin password: ") 45 | if err != nil { 46 | return err 47 | } 48 | // ask for password again 49 | password2, err := askForInput("Enter admin password again: ") 50 | if err != nil { 51 | return err 52 | } 53 | // check if passwords match 54 | if password != password2 { 55 | return fmt.Errorf("passwords do not match") 56 | } 57 | // create user 58 | 59 | fmt.Println("Creating admin user...") 60 | _, err = micro.DB.Pool.Exec("INSERT INTO users (email, password, created_at, updated_at) VALUES ($1, $2, $3, $4)", email, password, time.Now(), time.Now()) 61 | if err != nil { 62 | return err 63 | } 64 | fmt.Println("Admin user created successfully!") 65 | return nil 66 | 67 | } 68 | 69 | func askForInput(question string) (string, error) { 70 | fmt.Print(question) 71 | var input string 72 | _, err := fmt.Scanln(&input) 73 | if err != nil { 74 | return "", err 75 | } 76 | return input, nil 77 | } 78 | -------------------------------------------------------------------------------- /terminal/cli/auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/fatih/color" 8 | ) 9 | 10 | func doAuth() error { 11 | // migrations 12 | dbType := micro.DB.DatabaseType 13 | fileName := fmt.Sprintf("%d_create_auth_tables", time.Now().UnixMicro()) 14 | upFile := micro.RootPath + "/migrations/" + fileName + ".up.sql" 15 | downFile := micro.RootPath + "/migrations/" + fileName + ".down.sql" 16 | 17 | err := copyTemplateFile("templates/migrations/auth_tables."+dbType+".sql", upFile) 18 | if err != nil { 19 | gracefullyExit(err) 20 | } 21 | 22 | err = copyDataToFile([]byte("drop table if exists users cascade; drop table if exists tokens cascade; drop table if exists remember_tokens;"), downFile) 23 | if err != nil { 24 | gracefullyExit(err) 25 | } 26 | 27 | // run migrations 28 | err = doMigrate("up", "") 29 | if err != nil { 30 | gracefullyExit(err) 31 | } 32 | //Copy over data/models 33 | err = copyTemplateFile("templates/data/user.go.txt", micro.RootPath+"/data/user.go") 34 | if err != nil { 35 | gracefullyExit(err) 36 | } 37 | err = copyTemplateFile("templates/data/token.go.txt", micro.RootPath+"/data/token.go") 38 | if err != nil { 39 | gracefullyExit(err) 40 | } 41 | err = copyTemplateFile("templates/data/remember_token.go.txt", micro.RootPath+"/data/remember_token.go") 42 | if err != nil { 43 | gracefullyExit(err) 44 | } 45 | 46 | //Copy over middleware 47 | err = copyTemplateFile("templates/middleware/auth.go.txt", micro.RootPath+"/middleware/auth.go") 48 | if err != nil { 49 | gracefullyExit(err) 50 | } 51 | 52 | err = copyTemplateFile("templates/middleware/auth-token.go.txt", micro.RootPath+"/middleware/auth-token.go") 53 | if err != nil { 54 | gracefullyExit(err) 55 | } 56 | err = copyTemplateFile("templates/middleware/remember.go.txt", micro.RootPath+"/middleware/remember.go") 57 | if err != nil { 58 | gracefullyExit(err) 59 | } 60 | //Copy over handlers 61 | err = copyTemplateFile("templates/handlers/auth-handlers.go.txt", micro.RootPath+"/handlers/auth-handlers.go") 62 | if err != nil { 63 | gracefullyExit(err) 64 | } 65 | 66 | //Copy over the views 67 | 68 | err = copyTemplateFile("templates/mailer/reset-password.html.tmpl", micro.RootPath+"/mail/reset-password.html.tmpl") 69 | if err != nil { 70 | gracefullyExit(err) 71 | } 72 | err = copyTemplateFile("templates/mailer/reset-password.plain.tmpl", micro.RootPath+"/mail/reset-password.plain.tmpl") 73 | if err != nil { 74 | gracefullyExit(err) 75 | } 76 | err = copyTemplateFile("templates/views/login.html", micro.RootPath+"/views/login.html") 77 | if err != nil { 78 | gracefullyExit(err) 79 | } 80 | err = copyTemplateFile("templates/views/forgot.html", micro.RootPath+"/views/forgot.html") 81 | if err != nil { 82 | gracefullyExit(err) 83 | } 84 | err = copyTemplateFile("templates/views/reset-password.html", micro.RootPath+"/views/reset-password.html") 85 | if err != nil { 86 | gracefullyExit(err) 87 | } 88 | 89 | color.Yellow(" - users, tokens, and remember_tokens migrations successfully created and executed") 90 | color.Yellow(" - user and tokens models successfully created") 91 | color.Yellow(" - auth middleware successfully created") 92 | color.Yellow("") 93 | color.Red("Don't forget to add user and tokens models in data/models.go, and add the appropriate " + 94 | "middleware to your Routes!") 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /terminal/cli/files.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "strings" 11 | ) 12 | 13 | //go:embed templates 14 | var templateFS embed.FS 15 | 16 | func copyTemplateFile(templatePath, targetFile string) error { 17 | if fileExists(targetFile) { 18 | return errors.New(targetFile + " already exists!") 19 | } 20 | fileUrl := "https://raw.githubusercontent.com/cploutarchou/MicroGO/master/terminal/cli/" 21 | resp, err := http.Get(fileUrl + templatePath) 22 | if err != nil { 23 | return err 24 | } 25 | defer func(Body io.ReadCloser) { 26 | _ = Body.Close() 27 | }(resp.Body) 28 | data, err := io.ReadAll(resp.Body) 29 | 30 | if err != nil { 31 | gracefullyExit(err) 32 | } 33 | if strings.Contains(string(data), "$APPNAME$/data") { 34 | 35 | appName := strings.Split(micro.RootPath, "/") 36 | micro.AppName = strings.ReplaceAll(appName[len(appName)-1], "/data", "") 37 | err = copyDataToFile( 38 | []byte(strings.ReplaceAll(string(data), 39 | "$APPNAME$/", fmt.Sprintf("%s/", micro.AppName))), 40 | targetFile, 41 | ) 42 | } else { 43 | err = copyDataToFile(data, targetFile) 44 | } 45 | 46 | if err != nil { 47 | gracefullyExit(err) 48 | } 49 | 50 | return nil 51 | } 52 | func readFromRepo(fileToRead string) ([]byte, error) { 53 | fileUrl := "https://raw.githubusercontent.com/cploutarchou/MicroGO/master/terminal/cli/" 54 | resp, err := http.Get(fileUrl + fileToRead) 55 | if err != nil { 56 | return nil, err 57 | } 58 | defer func(Body io.ReadCloser) { 59 | _ = Body.Close() 60 | }(resp.Body) 61 | data, err := io.ReadAll(resp.Body) 62 | if err != nil { 63 | return nil, err 64 | } 65 | return data, nil 66 | } 67 | func copyDataToFile(data []byte, to string) error { 68 | err := os.WriteFile(to, data, 0644) 69 | if err != nil { 70 | return err 71 | } 72 | return nil 73 | } 74 | 75 | func fileExists(fileToCheck string) bool { 76 | if _, err := os.Stat(fileToCheck); os.IsNotExist(err) { 77 | return false 78 | } 79 | return true 80 | } 81 | -------------------------------------------------------------------------------- /terminal/cli/helpers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/fatih/color" 10 | "github.com/joho/godotenv" 11 | ) 12 | 13 | func setup(arg1, arg2 string) { 14 | if arg1 != "new" && arg1 != "version" && arg1 != "help" { 15 | err := godotenv.Load() 16 | if err != nil { 17 | gracefullyExit(err) 18 | } 19 | 20 | path, err := os.Getwd() 21 | if err != nil { 22 | gracefullyExit(err) 23 | } 24 | 25 | micro.RootPath = path 26 | micro.DB.DatabaseType = os.Getenv("DATABASE_TYPE") 27 | } 28 | } 29 | 30 | func getDSN() string { 31 | dbType := micro.DB.DatabaseType 32 | 33 | if dbType == "pgx" { 34 | dbType = "postgres" 35 | } 36 | 37 | if dbType == "postgres" { 38 | var dsn string 39 | if os.Getenv("DATABASE_PASS") != "" { 40 | dsn = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", 41 | os.Getenv("DATABASE_USER"), 42 | os.Getenv("DATABASE_PASS"), 43 | os.Getenv("DATABASE_HOST"), 44 | os.Getenv("DATABASE_PORT"), 45 | os.Getenv("DATABASE_NAME"), 46 | os.Getenv("DATABASE_SSL_MODE")) 47 | } else { 48 | dsn = fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=%s", 49 | os.Getenv("DATABASE_USER"), 50 | os.Getenv("DATABASE_HOST"), 51 | os.Getenv("DATABASE_PORT"), 52 | os.Getenv("DATABASE_NAME"), 53 | os.Getenv("DATABASE_SSL_MODE")) 54 | } 55 | return dsn 56 | } 57 | return "mysql://" + micro.BuildDataSourceName() 58 | } 59 | 60 | func help() { 61 | color.Yellow(`Available commands: 62 | 63 | help - show the help commands 64 | version - print application version 65 | make auth - Create and runs migrations for auth tables, create models and middleware. 66 | migrate - runs all up migrations that have not been run previously 67 | migrate down - reverses the most recent migration 68 | migrate reset - runs all down migrations in reverse order, and then all up migrations 69 | make migration - creates two new up and down migrations in the migrations folder 70 | make handler - create a stub handler on handlers directory 71 | make model - create a new mode in the data directory 72 | make session - create a new table in the database as a session storage 73 | make key - create a random key of 32 characters. 74 | make mail - create two starter mail templates in the mail directory. 75 | 76 | `) 77 | } 78 | 79 | func updateSrcFiles(path string, fi os.FileInfo, err error) error { 80 | // check for errors 81 | if err != nil { 82 | return err 83 | } 84 | // check if is dir 85 | if fi.IsDir() { 86 | return nil 87 | } 88 | // check if is go file 89 | match, err := filepath.Match("*.go", fi.Name()) 90 | if err != nil { 91 | return err 92 | } 93 | if match { 94 | //read file 95 | read, err := os.ReadFile(path) 96 | if err != nil { 97 | gracefullyExit(err) 98 | } 99 | newContent := strings.Replace(string(read), "app", appURL, -1) 100 | // save file 101 | err = os.WriteFile(path, []byte(newContent), 0) 102 | if err != nil { 103 | gracefullyExit(err) 104 | } 105 | } 106 | return nil 107 | } 108 | 109 | // updateSrcFolders walks the given path and updates the src files and the sub folders 110 | func updateSrcFolders() error { 111 | err := filepath.Walk(".", updateSrcFiles) 112 | if err != nil { 113 | return err 114 | } 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /terminal/cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | 7 | microGo "github.com/cploutarchou/MicroGO" 8 | "github.com/fatih/color" 9 | ) 10 | 11 | const version = "1.0.9" 12 | 13 | var micro microGo.MicroGo 14 | 15 | func main() { 16 | var message string 17 | arg1, arg2, arg3, err := validateInput() 18 | if err != nil { 19 | gracefullyExit(err) 20 | } 21 | 22 | setup(arg1, arg2) 23 | 24 | switch arg1 { 25 | case "help": 26 | help() 27 | 28 | case "new": 29 | if arg2 == "" { 30 | gracefullyExit(errors.New("no project name specified! ")) 31 | } 32 | createNew(arg2) 33 | 34 | case "version": 35 | color.Yellow("Application version: " + version) 36 | 37 | case "migrate": 38 | if arg2 == "" { 39 | arg2 = "up" 40 | } 41 | err = doMigrate(arg2, arg3) 42 | if err != nil { 43 | gracefullyExit(err) 44 | } 45 | message = "Migration successfully completed." 46 | 47 | case "make": 48 | if arg2 == "" { 49 | gracefullyExit(errors.New("make command requires an argument . Available options: migration|model|handler ")) 50 | } 51 | err = makeDo(arg2, arg3) 52 | if err != nil { 53 | gracefullyExit(err) 54 | } 55 | default: 56 | help() 57 | } 58 | gracefullyExit(nil, message) 59 | } 60 | 61 | func validateInput() (string, string, string, error) { 62 | var arg1, arg2, arg3 string 63 | if len(os.Args) > 1 { 64 | arg1 = os.Args[1] 65 | if len(os.Args) >= 3 { 66 | arg2 = os.Args[2] 67 | } 68 | if len(os.Args) >= 4 { 69 | arg3 = os.Args[3] 70 | } 71 | } else { 72 | color.Red("Error : No valid input.") 73 | help() 74 | return "", "", "", errors.New("command line arguments is required! ") 75 | } 76 | return arg1, arg2, arg3, nil 77 | } 78 | 79 | func gracefullyExit(err error, msg ...string) { 80 | message := "" 81 | if len(msg) > 0 { 82 | message = msg[0] 83 | } 84 | 85 | if err != nil { 86 | color.Red("Error: %v\n", err) 87 | } 88 | if len(message) > 0 { 89 | color.Yellow(message) 90 | } else { 91 | color.Green("Completed") 92 | } 93 | os.Exit(0) 94 | } 95 | -------------------------------------------------------------------------------- /terminal/cli/make.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/fatih/color" 11 | "github.com/gertd/go-pluralize" 12 | "github.com/iancoleman/strcase" 13 | ) 14 | 15 | func makeDo(arg2, arg3 string) error { 16 | 17 | switch arg2 { 18 | case "key": 19 | rnd := micro.CreateRandomString(32) 20 | color.Yellow("Successfully created a 32 chars encryption key : %s", rnd) 21 | case "auth": 22 | err := doAuth() 23 | if err != nil { 24 | gracefullyExit(err) 25 | } 26 | case "migration": 27 | dbType := micro.DB.DatabaseType 28 | if arg3 == "" { 29 | gracefullyExit(errors.New("you must give the migration a name")) 30 | } 31 | 32 | fileName := fmt.Sprintf("%d_%s", time.Now().UnixMicro(), arg3) 33 | 34 | upFile := micro.RootPath + "/migrations/" + fileName + "." + dbType + ".up.sql" 35 | downFile := micro.RootPath + "/migrations/" + fileName + "." + dbType + ".down.sql" 36 | 37 | err := copyTemplateFile("templates/migrations/migration."+dbType+".up.sql", upFile) 38 | if err != nil { 39 | gracefullyExit(err) 40 | } 41 | 42 | err = copyTemplateFile("templates/migrations/migration."+dbType+".down.sql", downFile) 43 | if err != nil { 44 | gracefullyExit(err) 45 | } 46 | case "handler": 47 | if arg3 == "" { 48 | gracefullyExit(errors.New("you must give the handler a name")) 49 | } 50 | 51 | fileName := micro.RootPath + "/handlers/" + strings.ToLower(arg3) + ".go" 52 | if fileExists(fileName) { 53 | gracefullyExit(errors.New(fileName + " already exists!")) 54 | } 55 | 56 | data, err := readFromRepo("templates/handlers/handler.go.txt") 57 | if err != nil { 58 | gracefullyExit(err) 59 | } 60 | 61 | handler := string(data) 62 | handler = strings.ReplaceAll(handler, "$HANDLERNAME$", strcase.ToCamel(arg3)) 63 | 64 | err = os.WriteFile(fileName, []byte(handler), 0644) 65 | if err != nil { 66 | gracefullyExit(err) 67 | } 68 | case "model": 69 | if arg3 == "" { 70 | gracefullyExit(errors.New("you must give a name to your model")) 71 | } 72 | data, err := readFromRepo("templates/data/model.go.txt") 73 | if err != nil { 74 | gracefullyExit(err) 75 | } 76 | model := string(data) 77 | prul := pluralize.NewClient() 78 | var modelName = arg3 79 | var tableName = arg3 80 | if prul.IsPlural(arg3) { 81 | modelName = prul.Singular(arg3) 82 | tableName = strings.ToLower(tableName) 83 | } else { 84 | tableName = strings.ToLower(prul.Plural(arg3)) 85 | } 86 | fileName := micro.RootPath + "/data/" + strings.ToLower(modelName) + ".go" 87 | if fileExists(fileName) { 88 | gracefullyExit(errors.New(fileName + " already exists!")) 89 | } 90 | model = strings.ReplaceAll(model, "$MODELNAME$", strcase.ToCamel(modelName)) 91 | model = strings.ReplaceAll(model, "$TABLENAME$", tableName) 92 | 93 | err = copyDataToFile([]byte(model), fileName) 94 | if err != nil { 95 | gracefullyExit(err) 96 | } 97 | case "session": 98 | err := createSessionTable() 99 | if err != nil { 100 | gracefullyExit(err) 101 | } 102 | case "mail": 103 | if arg3 == "" { 104 | gracefullyExit(errors.New("you must specify template file name! ")) 105 | } 106 | htmlMail := micro.RootPath + "/mail/" + strings.ToLower(arg3) + ".html.tmpl" 107 | plainTextMail := micro.RootPath + "/mail/" + strings.ToLower(arg3) + ".plain.tmpl" 108 | err := copyTemplateFile("templates/mailer/mail.html.tmpl", htmlMail) 109 | if err != nil { 110 | gracefullyExit(err) 111 | } 112 | err = copyTemplateFile("templates/mailer/mail.plain.tmpl", plainTextMail) 113 | if err != nil { 114 | gracefullyExit(err) 115 | } 116 | case "admin": 117 | // create admin user 118 | err := createAdminUser() 119 | 120 | 121 | if err != nil { 122 | gracefullyExit(err) 123 | } 124 | } 125 | return nil 126 | } 127 | -------------------------------------------------------------------------------- /terminal/cli/migrate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func doMigrate(arg2, arg3 string) error { 4 | dsn := getDSN() 5 | 6 | // run the migration command 7 | switch arg2 { 8 | case "up": 9 | err := micro.MigrateUp(dsn) 10 | if err != nil { 11 | return err 12 | } 13 | 14 | case "down": 15 | if arg3 == "all" { 16 | err := micro.MigrateDownAll(dsn) 17 | if err != nil { 18 | return err 19 | } 20 | } else { 21 | err := micro.Steps(-1, dsn) 22 | if err != nil { 23 | return err 24 | } 25 | } 26 | case "reset": 27 | err := micro.MigrateDownAll(dsn) 28 | if err != nil { 29 | return err 30 | } 31 | err = micro.MigrateUp(dsn) 32 | if err != nil { 33 | return err 34 | } 35 | default: 36 | help() 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /terminal/cli/new.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/fatih/color" 14 | "github.com/go-git/go-git/v5" 15 | ) 16 | 17 | var appURL string 18 | 19 | func createNew(applicationName string) { 20 | applicationName = strings.TrimSpace(applicationName) 21 | applicationName = strings.ToLower(applicationName) 22 | appURL = applicationName 23 | if applicationName == "" { 24 | gracefullyExit(errors.New("no project name specified! ")) 25 | } 26 | 27 | // sanitize the application name 28 | if strings.Contains(applicationName, "/") { 29 | exploded := strings.SplitAfter(applicationName, "/") 30 | applicationName = exploded[len(exploded)-1] 31 | } 32 | log.Println("Application name: ", applicationName) 33 | // git clone skeleton application 34 | color.Green("\tCloning skeleton application from git repository...") 35 | 36 | _, err := git.PlainClone("./"+applicationName, false, &git.CloneOptions{ 37 | URL: "https://github.com/cploutarchou/microGo_skeleton_app.git", 38 | Progress: os.Stdout, 39 | Depth: 1, 40 | }) 41 | if err != nil { 42 | gracefullyExit(err) 43 | } 44 | //remove the .git directory 45 | err = os.RemoveAll(fmt.Sprintf("./%s/.git", applicationName)) 46 | if err != nil { 47 | gracefullyExit(err) 48 | } 49 | // create a new .env file 50 | color.Yellow("Creating a new .env file...") 51 | data, err := templateFS.ReadFile("templates/env.txt") 52 | if err != nil { 53 | gracefullyExit(err) 54 | } 55 | env := string(data) 56 | env = strings.ReplaceAll(env, "${APP_NAME}", applicationName) 57 | env = strings.ReplaceAll(env, "${KEY}", micro.CreateRandomString(32)) 58 | err = copyDataToFile([]byte(env), fmt.Sprintf("./%s/.env", applicationName)) 59 | if err != nil { 60 | gracefullyExit(err) 61 | } 62 | 63 | // create a makefile 64 | if runtime.GOOS == "windows" { 65 | source, err := os.Open(fmt.Sprintf("./%s/Makefile.windows", applicationName)) 66 | if err != nil { 67 | gracefullyExit(err) 68 | } 69 | defer func(source *os.File) { 70 | _ = source.Close() 71 | }(source) 72 | 73 | destination, err := os.Create(fmt.Sprintf("./%s/Makefile", applicationName)) 74 | if err != nil { 75 | gracefullyExit(err) 76 | } 77 | defer func(destination *os.File) { 78 | _ = destination.Close() 79 | }(destination) 80 | 81 | _, err = io.Copy(destination, source) 82 | if err != nil { 83 | gracefullyExit(err) 84 | } 85 | } else { 86 | src, err := os.Open(fmt.Sprintf("./%s/Makefile.mac", applicationName)) 87 | if err != nil { 88 | gracefullyExit(err) 89 | } 90 | defer func(src *os.File) { 91 | _ = src.Close() 92 | }(src) 93 | 94 | dest, err := os.Create(fmt.Sprintf("./%s/Makefile", applicationName)) 95 | if err != nil { 96 | gracefullyExit(err) 97 | } 98 | defer dest.Close() 99 | 100 | _, err = io.Copy(dest, src) 101 | if err != nil { 102 | gracefullyExit(err) 103 | } 104 | } 105 | _ = os.Remove("./" + applicationName + "/Makefile.mac") 106 | _ = os.Remove("./" + applicationName + "/Makefile.windows") 107 | 108 | // update the go.mod file 109 | color.Yellow("\tUpdating go.mod file...") 110 | _ = os.Remove("./" + applicationName + "/go.mod") 111 | data, err = templateFS.ReadFile("templates/go.mod.txt") 112 | if err != nil { 113 | gracefullyExit(err) 114 | } 115 | mod := string(data) 116 | mod = strings.ReplaceAll(mod, "${APP_NAME}", appURL) 117 | err = copyDataToFile([]byte(mod), fmt.Sprintf("./"+applicationName+"/go.mod")) 118 | if err != nil { 119 | gracefullyExit(err) 120 | } 121 | // update the existing .go files with th correct package names 122 | color.Yellow("\tUpdating go files...") 123 | err = os.Chdir("./" + applicationName) 124 | if err != nil { 125 | gracefullyExit(err) 126 | } 127 | err = updateSrcFolders() 128 | if err != nil { 129 | gracefullyExit(err) 130 | } 131 | // run go mod tidy 132 | color.Yellow("\tRunning go mod tidy...") 133 | cmd := exec.Command("go", "mod", "tidy") 134 | err = cmd.Run() 135 | if err != nil { 136 | gracefullyExit(err) 137 | } 138 | 139 | color.Green("\tSuccessfully created a new microGo application!") 140 | color.Green("Go build something amazing!") 141 | 142 | } 143 | -------------------------------------------------------------------------------- /terminal/cli/session.go: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2022 Christos Ploutarchou 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package main 26 | 27 | import ( 28 | "fmt" 29 | "time" 30 | ) 31 | 32 | func createSessionTable() error { 33 | dbType := micro.DB.DatabaseType 34 | 35 | if dbType == "mariadb" { 36 | dbType = "mysql" 37 | } 38 | 39 | if dbType == "postgresql" { 40 | dbType = "postgres" 41 | } 42 | 43 | fileName := fmt.Sprintf("%d_create_sessions_table", time.Now().UnixMicro()) 44 | 45 | upFile := micro.RootPath + "/migrations/" + fileName + "." + dbType + ".up.sql" 46 | downFile := micro.RootPath + "/migrations/" + fileName + "." + dbType + ".down.sql" 47 | 48 | err := copyTemplateFile("templates/migrations/"+dbType+"_session.sql", upFile) 49 | if err != nil { 50 | gracefullyExit(err) 51 | } 52 | 53 | err = copyDataToFile([]byte("drop table sessions"), downFile) 54 | if err != nil { 55 | gracefullyExit(err) 56 | } 57 | 58 | err = doMigrate("up", "") 59 | if err != nil { 60 | gracefullyExit(err) 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /terminal/cli/templates/data/model.go.txt: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | up "github.com/upper/db/v4" 5 | "time" 6 | ) 7 | // $MODELNAME$ struct 8 | type $MODELNAME$ struct { 9 | ID int `db:"id,omitempty"` 10 | CreatedAt time.Time `db:"created_at"` 11 | UpdatedAt time.Time `db:"updated_at"` 12 | } 13 | 14 | // Table returns the table name 15 | func (t *$MODELNAME$) Table() string { 16 | return "$TABLENAME$" 17 | } 18 | 19 | // GetAll gets all records from the database, using upper 20 | func (t *$MODELNAME$) GetAll(condition up.Cond) ([]*$MODELNAME$, error) { 21 | collection := upper.Collection(t.Table()) 22 | var all []*$MODELNAME$ 23 | 24 | res := collection.Find(condition) 25 | err := res.All(&all) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return all, err 31 | } 32 | 33 | // Get gets one record from the database, by id, using upper 34 | func (t *$MODELNAME$) Get(id int) (*$MODELNAME$, error) { 35 | var one $MODELNAME$ 36 | collection := upper.Collection(t.Table()) 37 | 38 | res := collection.Find(up.Cond{"id": id}) 39 | err := res.One(&one) 40 | if err != nil { 41 | return nil, err 42 | } 43 | return &one, nil 44 | } 45 | 46 | // Update updates a record in the database, using upper 47 | func (t *$MODELNAME$) Update(m $MODELNAME$) error { 48 | m.UpdatedAt = time.Now() 49 | collection := upper.Collection(t.Table()) 50 | res := collection.Find(m.ID) 51 | err := res.Update(&m) 52 | if err != nil { 53 | return err 54 | } 55 | return nil 56 | } 57 | 58 | // Delete deletes a record from the database by id, using upper 59 | func (t *$MODELNAME$) Delete(id int) error { 60 | collection := upper.Collection(t.Table()) 61 | res := collection.Find(id) 62 | err := res.Delete() 63 | if err != nil { 64 | return err 65 | } 66 | return nil 67 | } 68 | 69 | // Insert inserts a model into the database, using upper 70 | func (t *$MODELNAME$) Insert(m $MODELNAME$) (int, error) { 71 | m.CreatedAt = time.Now() 72 | m.UpdatedAt = time.Now() 73 | collection := upper.Collection(t.Table()) 74 | res, err := collection.Insert(m) 75 | if err != nil { 76 | return 0, err 77 | } 78 | 79 | id := getInsertID(res.ID()) 80 | 81 | return id, nil 82 | } 83 | 84 | // Builder is an example of using upper's sql builder 85 | func (t *$MODELNAME$) Builder(id int) ([]*$MODELNAME$, error) { 86 | collection := upper.Collection(t.Table()) 87 | 88 | var result []*$MODELNAME$ 89 | 90 | err := collection.Session(). 91 | SQL(). 92 | SelectFrom(t.Table()). 93 | Where("id > ?", id). 94 | OrderBy("id"). 95 | All(&result) 96 | if err != nil { 97 | return nil, err 98 | } 99 | return result, nil 100 | } 101 | 102 | -------------------------------------------------------------------------------- /terminal/cli/templates/data/remember_token.go.txt: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "time" 5 | 6 | up "github.com/upper/db/v4" 7 | ) 8 | 9 | type RememberToken struct { 10 | ID int `db:"id,omitempty"` 11 | UserID int `db:"user_id"` 12 | RememberToken string `db:"remember_token"` 13 | CreatedAt time.Time `db:"created_at"` 14 | UpdatedAt time.Time `db:"updated_at"` 15 | } 16 | 17 | func (r *RememberToken) Table() string { 18 | return "remember_tokens" 19 | } 20 | 21 | func (r *RememberToken) InsertToken(userID int, token string) error { 22 | collection := upper.Collection(r.Table()) 23 | rememberToken := RememberToken{ 24 | UserID: userID, 25 | RememberToken: token, 26 | CreatedAt: time.Now(), 27 | UpdatedAt: time.Now(), 28 | } 29 | _, err := collection.Insert(rememberToken) 30 | if err != nil { 31 | return err 32 | } 33 | return nil 34 | } 35 | 36 | func (r *RememberToken) Delete(rememberToken string) error { 37 | collection := upper.Collection(r.Table()) 38 | res := collection.Find(up.Cond{"remember_token": rememberToken}) 39 | err := res.Delete() 40 | if err != nil { 41 | return err 42 | } 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /terminal/cli/templates/data/token.go.txt: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base32" 6 | "errors" 7 | "math/rand" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | up "github.com/upper/db/v4" 13 | ) 14 | 15 | type Token struct { 16 | ID int `db:"id,omitempty" json:"id"` 17 | UserID int `db:"user_id" json:"user_id"` 18 | FirstName string `db:"first_name" json:"first_name"` 19 | Email string `db:"email" json:"email"` 20 | PlainText string `db:"token" json:"token"` 21 | Hash []byte `db:"token_hash" json:"-"` 22 | CreatedAt time.Time `db:"created_at" json:"created_at"` 23 | UpdatedAt time.Time `db:"updated_at" json:"updated_at"` 24 | Expires time.Time `db:"expiry" json:"expiry"` 25 | } 26 | 27 | func (t *Token) Table() string { 28 | return "tokens" 29 | } 30 | 31 | func (t *Token) GetUserForToken(token string) (*User, error) { 32 | var u User 33 | var theToken Token 34 | 35 | collection := upper.Collection(t.Table()) 36 | res := collection.Find(up.Cond{"token": token}) 37 | err := res.One(&theToken) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | collection = upper.Collection("users") 43 | res = collection.Find(up.Cond{"id": theToken.UserID}) 44 | err = res.One(&u) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | u.Token = theToken 50 | 51 | return &u, nil 52 | } 53 | 54 | func (t *Token) GetTokensForUser(id int) ([]*Token, error) { 55 | var tokens []*Token 56 | collection := upper.Collection(t.Table()) 57 | res := collection.Find(up.Cond{"user_id": id}) 58 | err := res.All(&tokens) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | return tokens, nil 64 | } 65 | 66 | func (t *Token) Get(id int) (*Token, error) { 67 | var token Token 68 | collection := upper.Collection(t.Table()) 69 | res := collection.Find(up.Cond{"id": id}) 70 | err := res.One(&token) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return &token, nil 76 | } 77 | 78 | func (t *Token) GetByToken(plainText string) (*Token, error) { 79 | var token Token 80 | collection := upper.Collection(t.Table()) 81 | res := collection.Find(up.Cond{"token": plainText}) 82 | err := res.One(&token) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return &token, nil 88 | } 89 | 90 | func (t *Token) Delete(id int) error { 91 | collection := upper.Collection(t.Table()) 92 | res := collection.Find(id) 93 | err := res.Delete() 94 | if err != nil { 95 | return err 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func (t *Token) DeleteByToken(plainText string) error { 102 | collection := upper.Collection(t.Table()) 103 | res := collection.Find(up.Cond{"token": plainText}) 104 | err := res.Delete() 105 | if err != nil { 106 | return err 107 | } 108 | 109 | return nil 110 | } 111 | 112 | func (t *Token) Insert(token Token, u User) error { 113 | collection := upper.Collection(t.Table()) 114 | 115 | // delete existing tokens 116 | res := collection.Find(up.Cond{"user_id": u.ID}) 117 | err := res.Delete() 118 | if err != nil { 119 | return err 120 | } 121 | 122 | token.CreatedAt = time.Now() 123 | token.UpdatedAt = time.Now() 124 | token.FirstName = u.FirstName 125 | token.Email = u.Email 126 | 127 | _, err = collection.Insert(token) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | return nil 133 | } 134 | 135 | func (t *Token) GenerateToken(userID int, ttl time.Duration) (*Token, error) { 136 | token := &Token{ 137 | UserID: userID, 138 | Expires: time.Now().Add(ttl), 139 | } 140 | 141 | randomBytes := make([]byte, 16) 142 | _, err := rand.Read(randomBytes) 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | token.PlainText = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes) 148 | hash := sha256.Sum256([]byte(token.PlainText)) 149 | token.Hash = hash[:] 150 | 151 | return token, nil 152 | } 153 | 154 | func (t *Token) AuthenticateToken(r *http.Request) (*User, error) { 155 | authorizationHeader := r.Header.Get("Authorization") 156 | if authorizationHeader == "" { 157 | return nil, errors.New("no authorization header received") 158 | } 159 | 160 | headerParts := strings.Split(authorizationHeader, " ") 161 | if len(headerParts) != 2 || headerParts[0] != "Bearer" { 162 | return nil, errors.New("no authorization header received") 163 | } 164 | 165 | token := headerParts[1] 166 | 167 | if len(token) != 26 { 168 | return nil, errors.New("token wrong size") 169 | } 170 | 171 | tkn, err := t.GetByToken(token) 172 | if err != nil { 173 | return nil, errors.New("no matching token found") 174 | } 175 | 176 | if tkn.Expires.Before(time.Now()) { 177 | return nil, errors.New("expired token") 178 | } 179 | 180 | user, err := t.GetUserForToken(token) 181 | if err != nil { 182 | return nil, errors.New("no matching user found") 183 | } 184 | 185 | return user, nil 186 | } 187 | 188 | func (t *Token) ValidToken(token string) (bool, error) { 189 | user, err := t.GetUserForToken(token) 190 | if err != nil { 191 | return false, errors.New("no matching user found") 192 | } 193 | 194 | if user.Token.PlainText == "" { 195 | return false, errors.New("no matching token found") 196 | } 197 | 198 | if user.Token.Expires.Before(time.Now()) { 199 | return false, errors.New("expired token") 200 | } 201 | 202 | return true, nil 203 | } 204 | -------------------------------------------------------------------------------- /terminal/cli/templates/data/user.go.txt: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | microGo "github.com/cploutarchou/MicroGO" 5 | "errors" 6 | "time" 7 | 8 | up "github.com/upper/db/v4" 9 | "golang.org/x/crypto/bcrypt" 10 | ) 11 | 12 | // User is the type for a user 13 | type User struct { 14 | ID int `db:"id,omitempty"` 15 | FirstName string `db:"first_name"` 16 | LastName string `db:"last_name"` 17 | Email string `db:"email"` 18 | Active int `db:"user_active"` 19 | Password string `db:"password"` 20 | CreatedAt time.Time `db:"created_at"` 21 | UpdatedAt time.Time `db:"updated_at"` 22 | Token Token `db:"-"` 23 | } 24 | 25 | // Table returns the table name associated with this model in the database 26 | func (u *User) Table() string { 27 | return "users" 28 | } 29 | func (u *User) Validate(validator *microGo.Validation) { 30 | validator.Check(u.LastName != "", "last_name", "Last name must be provided") 31 | validator.Check(u.FirstName != "", "first_name", "First name must be provided") 32 | validator.Check(u.Email != "", "email", "Email must be provided") 33 | validator.IsEmail("email", u.Email) 34 | } 35 | 36 | // GetAll returns a slice of all users 37 | func (u *User) GetAll() ([]*User, error) { 38 | collection := upper.Collection(u.Table()) 39 | 40 | var all []*User 41 | 42 | res := collection.Find().OrderBy("last_name") 43 | err := res.All(&all) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return all, nil 49 | } 50 | 51 | // GetByEmail gets one user, by email 52 | func (u *User) GetByEmail(email string) (*User, error) { 53 | var theUser User 54 | collection := upper.Collection(u.Table()) 55 | res := collection.Find(up.Cond{"email =": email}) 56 | err := res.One(&theUser) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | var token Token 62 | collection = upper.Collection(token.Table()) 63 | res = collection.Find(up.Cond{"user_id =": theUser.ID, "expiry >": time.Now()}).OrderBy("created_at desc") 64 | err = res.One(&token) 65 | if err != nil { 66 | if err != up.ErrNilRecord && err != up.ErrNoMoreRows { 67 | return nil, err 68 | } 69 | } 70 | 71 | theUser.Token = token 72 | 73 | return &theUser, nil 74 | } 75 | 76 | // Get gets one user by id 77 | func (u *User) Get(id int) (*User, error) { 78 | var theUser User 79 | collection := upper.Collection(u.Table()) 80 | res := collection.Find(up.Cond{"id =": id}) 81 | 82 | err := res.One(&theUser) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | var token Token 88 | collection = upper.Collection(token.Table()) 89 | res = collection.Find(up.Cond{"user_id =": theUser.ID, "expiry >": time.Now()}).OrderBy("created_at desc") 90 | err = res.One(&token) 91 | if err != nil { 92 | if err != up.ErrNilRecord && err != up.ErrNoMoreRows { 93 | return nil, err 94 | } 95 | } 96 | 97 | theUser.Token = token 98 | 99 | return &theUser, nil 100 | } 101 | 102 | // Update updates a user record in the database 103 | func (u *User) Update(theUser User) error { 104 | theUser.UpdatedAt = time.Now() 105 | collection := upper.Collection(u.Table()) 106 | res := collection.Find(theUser.ID) 107 | err := res.Update(&theUser) 108 | if err != nil { 109 | return err 110 | } 111 | return nil 112 | } 113 | 114 | // Delete deletes a user by id 115 | func (u *User) Delete(id int) error { 116 | collection := upper.Collection(u.Table()) 117 | res := collection.Find(id) 118 | err := res.Delete() 119 | if err != nil { 120 | return err 121 | } 122 | return nil 123 | 124 | } 125 | 126 | // Insert inserts a new user, and returns the newly inserted id 127 | func (u *User) Insert(theUser User) (int, error) { 128 | newHash, err := bcrypt.GenerateFromPassword([]byte(theUser.Password), 12) 129 | if err != nil { 130 | return 0, err 131 | } 132 | 133 | theUser.CreatedAt = time.Now() 134 | theUser.UpdatedAt = time.Now() 135 | theUser.Password = string(newHash) 136 | 137 | collection := upper.Collection(u.Table()) 138 | res, err := collection.Insert(theUser) 139 | if err != nil { 140 | return 0, err 141 | } 142 | 143 | id := getInsertID(res.ID()) 144 | 145 | return id, nil 146 | } 147 | 148 | // ResetPassword resets a user's password, by id, using supplied password 149 | func (u *User) ResetPassword(id int, password string) error { 150 | newHash, err := bcrypt.GenerateFromPassword([]byte(password), 12) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | theUser, err := u.Get(id) 156 | if err != nil { 157 | return err 158 | } 159 | 160 | u.Password = string(newHash) 161 | 162 | err = theUser.Update(*u) 163 | if err != nil { 164 | return err 165 | } 166 | 167 | return nil 168 | } 169 | 170 | // PasswordMatches verifies a supplied password against the hash stored in the database. 171 | // It returns true if valid, and false if the password does not match, or if there is an 172 | // error. Note that an error is only returned if something goes wrong (since an invalid password 173 | // is not an error -- it's just the wrong password)) 174 | func (u *User) PasswordMatches(plainText string) (bool, error) { 175 | err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plainText)) 176 | if err != nil { 177 | switch { 178 | case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): 179 | // invalid password 180 | return false, nil 181 | default: 182 | // some kind of error occurred 183 | return false, err 184 | } 185 | } 186 | 187 | return true, nil 188 | } 189 | 190 | func (u *User) CheckForRememberToken(id int, token string) bool { 191 | var rememberToken RememberToken 192 | rt := RememberToken{} 193 | collection := upper.Collection(rt.Table()) 194 | res := collection.Find(up.Cond{"user_id": id, "remember_token": token}) 195 | err := res.One(&rememberToken) 196 | return err == nil 197 | } 198 | -------------------------------------------------------------------------------- /terminal/cli/templates/env.txt: -------------------------------------------------------------------------------- 1 | # Give your application a unique name (no spaces) 2 | APP_NAME=test 3 | APP_URL=http://localhost:4000 4 | 5 | # false for production, true for development 6 | DEBUG=true 7 | 8 | # the port should we listen on 9 | PORT=4000 10 | 11 | # the server name, e.g, www.mysite.com 12 | SERVER_NAME=localhost 13 | 14 | # should we use https? 15 | SECURE=false 16 | 17 | # database config - postgres or mysql 18 | DATABASE_TYPE=mysql 19 | DATABASE_HOST=localhost 20 | DATABASE_PORT=3306 21 | DATABASE_USER=mariadb 22 | DATABASE_PASS=password 23 | DATABASE_NAME=microGo 24 | DATABASE_SSL_MODE=true 25 | DATABASE_TIME_ZONE=Asia/Nicosia 26 | 27 | # redis config 28 | REDIS_HOST=localhost 29 | REDIS_PORT=6379 30 | REDIS_PASSWORD= 31 | REDIS_PREFIX=test 32 | 33 | # cache (Supported cache databases redis/badger) 34 | CACHE=badger 35 | 36 | # cooking settings 37 | COOKIE_NAME=test 38 | COOKIE_LIFETIME=1 39 | COOKIE_PERSIST=true 40 | COOKIE_SECURE=false 41 | COOKIE_DOMAIN=localhost 42 | 43 | # session store: cookie, redis, mysql, or postgres 44 | SESSION_TYPE=cookie 45 | 46 | # mail settings 47 | SMTP_HOST= 48 | SMTP_USERNAME= 49 | SMTP_PASSWORD= 50 | SMTP_PORT=25 51 | SMTP_ENCRYPTION=none 52 | FROM_NAME= 53 | FROM_ADDRESS= 54 | MAIL_DOMAIN= 55 | 56 | # mail settings for api services 57 | MAILER_API= 58 | MAILER_KEY= 59 | MAILER_URL= 60 | 61 | # template engine: go/blocks/jet 62 | RENDERER=blocks 63 | 64 | # the encryption key; must be exactly 32 characters long 65 | ENCRYPTION_KEY=4ff4D44d4s5fsw6D64D4Df4f47d44fw5 -------------------------------------------------------------------------------- /terminal/cli/templates/go.mod.txt: -------------------------------------------------------------------------------- 1 | module ${APP_NAME} 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/CloudyKit/jet/v6 v6.1.0 7 | github.com/ainsleyclark/go-mail v1.1.1 8 | github.com/alexedwards/scs/mysqlstore v0.0.0-20210904201103-9ffa4cfa9323 9 | github.com/alexedwards/scs/postgresstore v0.0.0-20210904201103-9ffa4cfa9323 10 | github.com/alexedwards/scs/redisstore v0.0.0-20210904201103-9ffa4cfa9323 11 | github.com/alexedwards/scs/v2 v2.4.0 12 | github.com/alicebob/miniredis/v2 v2.15.1 13 | github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d 14 | github.com/dgraph-io/badger/v3 v3.2103.1 15 | github.com/fatih/color v1.13.0 16 | github.com/gertd/go-pluralize v0.1.7 17 | github.com/go-chi/chi/v5 v5.0.4 18 | github.com/go-git/go-git/v5 v5.4.2 19 | github.com/go-sql-driver/mysql v1.6.0 20 | github.com/golang-migrate/migrate/v4 v4.14.1 21 | github.com/gomodule/redigo v1.8.5 22 | github.com/iancoleman/strcase v0.2.0 23 | github.com/jackc/pgconn v1.10.1 24 | github.com/jackc/pgx/v4 v4.13.0 25 | github.com/joho/godotenv v1.4.0 26 | github.com/justinas/nosurf v1.1.1 27 | github.com/kataras/blocks v0.0.6 28 | github.com/ory/dockertest/v3 v3.8.0 29 | github.com/robfig/cron/v3 v3.0.1 30 | github.com/vanng822/go-premailer v1.20.1 31 | github.com/xhit/go-simple-mail/v2 v2.10.0 32 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 33 | ) 34 | 35 | require ( 36 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect 37 | github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect 38 | github.com/Microsoft/go-winio v0.5.0 // indirect 39 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect 40 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect 41 | github.com/PuerkitoBio/goquery v1.5.1 // indirect 42 | github.com/acomagu/bufpipe v1.0.3 // indirect 43 | github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect 44 | github.com/andybalholm/cascadia v1.1.0 // indirect 45 | github.com/cenkalti/backoff/v4 v4.1.1 // indirect 46 | github.com/cespare/xxhash v1.1.0 // indirect 47 | github.com/cespare/xxhash/v2 v2.1.1 // indirect 48 | github.com/containerd/continuity v0.2.0 // indirect 49 | github.com/dgraph-io/ristretto v0.1.0 // indirect 50 | github.com/docker/cli v20.10.8+incompatible // indirect 51 | github.com/docker/docker v20.10.7+incompatible // indirect 52 | github.com/docker/go-connections v0.4.0 // indirect 53 | github.com/docker/go-units v0.4.0 // indirect 54 | github.com/dustin/go-humanize v1.0.0 // indirect 55 | github.com/emirpasic/gods v1.12.0 // indirect 56 | github.com/go-git/gcfg v1.5.0 // indirect 57 | github.com/go-git/go-billy/v5 v5.3.1 // indirect 58 | github.com/gofrs/uuid v4.1.0+incompatible // indirect 59 | github.com/gogo/protobuf v1.3.2 // indirect 60 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect 61 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect 62 | github.com/golang/protobuf v1.5.2 // indirect 63 | github.com/golang/snappy v0.0.3 // indirect 64 | github.com/google/flatbuffers v1.12.0 // indirect 65 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 66 | github.com/gorilla/css v1.0.0 // indirect 67 | github.com/hashicorp/errwrap v1.0.0 // indirect 68 | github.com/hashicorp/go-multierror v1.1.0 // indirect 69 | github.com/imdario/mergo v0.3.12 // indirect 70 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 71 | github.com/jackc/pgio v1.0.0 // indirect 72 | github.com/jackc/pgpassfile v1.0.0 // indirect 73 | github.com/jackc/pgproto3/v2 v2.1.1 // indirect 74 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect 75 | github.com/jackc/pgtype v1.8.1 // indirect 76 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 77 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect 78 | github.com/klauspost/compress v1.13.5 // indirect 79 | github.com/lib/pq v1.10.4 // indirect 80 | github.com/mattn/go-colorable v0.1.9 // indirect 81 | github.com/mattn/go-isatty v0.0.14 // indirect 82 | github.com/mitchellh/go-homedir v1.1.0 // indirect 83 | github.com/mitchellh/mapstructure v1.4.1 // indirect 84 | github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect 85 | github.com/opencontainers/go-digest v1.0.0 // indirect 86 | github.com/opencontainers/image-spec v1.0.1 // indirect 87 | github.com/opencontainers/runc v1.0.2 // indirect 88 | github.com/pkg/errors v0.9.1 // indirect 89 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 90 | github.com/sergi/go-diff v1.1.0 // indirect 91 | github.com/sirupsen/logrus v1.8.1 // indirect 92 | github.com/valyala/bytebufferpool v1.0.0 // indirect 93 | github.com/vanng822/css v1.0.1 // indirect 94 | github.com/xanzy/ssh-agent v0.3.0 // indirect 95 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect 96 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 97 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 98 | github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da // indirect 99 | go.opencensus.io v0.23.0 // indirect 100 | golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect 101 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect 102 | golang.org/x/text v0.3.6 // indirect 103 | google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c // indirect 104 | google.golang.org/grpc v1.38.0 // indirect 105 | google.golang.org/protobuf v1.26.0 // indirect 106 | gopkg.in/warnings.v0 v0.1.2 // indirect 107 | gopkg.in/yaml.v2 v2.4.0 // indirect 108 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 109 | ) 110 | -------------------------------------------------------------------------------- /terminal/cli/templates/handlers/auth-handlers.go.txt: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "$APPNAME$/data" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "fmt" 8 | "github.com/cploutarchou/MicroGO/mailer" 9 | "github.com/cploutarchou/MicroGO/url_signer" 10 | "net/http" 11 | "time" 12 | ) 13 | 14 | func (h *Handlers) UserLogin(w http.ResponseWriter, r *http.Request) { 15 | _data := map[string]interface{}{ 16 | "Title": "Login", 17 | } 18 | err := h.APP.Render.Page(w, r, "login", "main", nil, _data) 19 | if err != nil { 20 | h.APP.ErrorLog.Println(err) 21 | } 22 | } 23 | 24 | func (h *Handlers) PostUserLogin(w http.ResponseWriter, r *http.Request) { 25 | err := r.ParseForm() 26 | if err != nil { 27 | _, err := w.Write([]byte(err.Error())) 28 | if err != nil { 29 | h.APP.ErrorLog.Println(err) 30 | return 31 | } 32 | return 33 | } 34 | email := r.Form.Get("email") 35 | password := r.Form.Get("password") 36 | user, err := h.Models.User.GetByEmail(email) 37 | 38 | if err != nil { 39 | _, err := w.Write([]byte(err.Error())) 40 | if err != nil { 41 | h.APP.ErrorLog.Println(err) 42 | return 43 | } 44 | return 45 | } 46 | valid, err := user.PasswordMatches(password) 47 | if err != nil { 48 | _, err := w.Write([]byte(err.Error())) 49 | if err != nil { 50 | h.APP.ErrorLog.Println(err) 51 | return 52 | } 53 | return 54 | } 55 | if !valid { 56 | _, err := w.Write([]byte("Invalid password")) 57 | if err != nil { 58 | h.APP.ErrorLog.Println(err.Error()) 59 | return 60 | } 61 | return 62 | } 63 | // Check if user set remember me flag and 64 | if r.Form.Get("remember") == "remember" { 65 | randomStr := h.createRandomString(12) 66 | hashed := sha256.New() 67 | _, err := hashed.Write([]byte(randomStr)) 68 | if err != nil { 69 | h.APP.ErrorStatus(w, http.StatusBadRequest) 70 | return 71 | } 72 | sha := base64.URLEncoding.EncodeToString(hashed.Sum(nil)) 73 | rm := data.RememberToken{} 74 | err = rm.InsertToken(user.ID, sha) 75 | if err != nil { 76 | h.APP.ErrorStatus(w, http.StatusBadRequest) 77 | return 78 | } 79 | 80 | // set cookie to remember 81 | expire := time.Now().Add(365 * 24 * 60 * 60 * time.Second) 82 | cookie := http.Cookie{ 83 | Name: fmt.Sprintf("_%s_remember", h.APP.AppName), 84 | Value: fmt.Sprintf("%d|%s", user.ID, sha), 85 | Path: "/", 86 | HttpOnly: true, 87 | Expires: expire, 88 | Domain: h.APP.Session.Cookie.Domain, 89 | MaxAge: 31535000, 90 | Secure: h.APP.Session.Cookie.Secure, 91 | SameSite: http.SameSiteStrictMode, 92 | } 93 | http.SetCookie(w, &cookie) 94 | h.APP.Session.Put(r.Context(), "remember_token", sha) 95 | 96 | } 97 | h.APP.Session.Put(r.Context(), "userID", user.ID) 98 | http.Redirect(w, r, "/", http.StatusSeeOther) 99 | 100 | } 101 | func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) { 102 | 103 | // delete remember token from session if exists 104 | if h.APP.Session.Exists(r.Context(), "remember_token") { 105 | rt := data.RememberToken{} 106 | _ = rt.Delete(h.APP.Session.GetString(r.Context(), "remember_token")) 107 | } 108 | cookie := http.Cookie{ 109 | Name: fmt.Sprintf("_%s_remember", h.APP.AppName), 110 | Value: "", 111 | Path: "/", 112 | HttpOnly: true, 113 | Expires: time.Now().Add(-100 * time.Hour), 114 | Domain: h.APP.Session.Cookie.Domain, 115 | MaxAge: -1, 116 | Secure: h.APP.Session.Cookie.Secure, 117 | SameSite: http.SameSiteStrictMode, 118 | } 119 | http.SetCookie(w, &cookie) 120 | err := h.APP.Session.RenewToken(r.Context()) 121 | if err != nil { 122 | _, err := w.Write([]byte(err.Error())) 123 | if err != nil { 124 | h.APP.ErrorLog.Println(err.Error()) 125 | return 126 | } 127 | return 128 | } 129 | h.APP.Session.Remove(r.Context(), "userID") 130 | h.APP.Session.Remove(r.Context(), "remember_token") 131 | err = h.APP.Session.Destroy(r.Context()) 132 | if err != nil { 133 | _, err := w.Write([]byte(err.Error())) 134 | if err != nil { 135 | h.APP.ErrorLog.Println(err.Error()) 136 | return 137 | } 138 | return 139 | } 140 | err = h.APP.Session.RenewToken(r.Context()) 141 | if err != nil { 142 | _, err := w.Write([]byte(err.Error())) 143 | if err != nil { 144 | h.APP.ErrorLog.Println(err.Error()) 145 | return 146 | } 147 | return 148 | } 149 | h.APP.Session.Remove(r.Context(), "userID") 150 | http.Redirect(w, r, "/users/login", http.StatusSeeOther) 151 | } 152 | 153 | func (h *Handlers) Forgot(w http.ResponseWriter, r *http.Request) { 154 | _data := map[string]interface{}{ 155 | "Title": "Forgot password", 156 | } 157 | err := h.render(w, r, "forgot", "main", nil, _data) 158 | if err != nil { 159 | h.APP.ErrorLog.Println("Something went wrong unable to render page : ", err) 160 | h.APP.Error500(w, r) 161 | } 162 | 163 | } 164 | func (h *Handlers) PostForgot(w http.ResponseWriter, r *http.Request) { 165 | // parse form msgData 166 | err := r.ParseForm() 167 | if err != nil { 168 | h.APP.ErrorStatus(w, http.StatusBadRequest) 169 | return 170 | } 171 | // verify email if exists 172 | var u *data.User 173 | email := r.Form.Get("email") 174 | u, err = u.GetByEmail(email) 175 | if err != nil { 176 | h.APP.ErrorStatus(w, http.StatusBadRequest) 177 | return 178 | } 179 | // create link to reset password form 180 | link := fmt.Sprintf("%s/users/reset-password?email=%s", h.APP.Server.URL, email) 181 | sign := url_signer.Signer{ 182 | Secret: []byte(h.APP.EncryptionKey), 183 | } 184 | // sing the link and send it to the user 185 | signed := sign.GenerateToken(link) 186 | h.APP.InfoLog.Println("Signed link : ", signed) 187 | 188 | var msgData struct { 189 | Name string 190 | Link string 191 | } 192 | msgData.Link = signed 193 | msgData.Name = fmt.Sprintf("%s %s", u.FirstName, u.LastName) 194 | msg := mailer.Message{ 195 | To: u.Email, 196 | Subject: "Reset Password", 197 | Template: "reset-password", 198 | TemplateFormat: mailer.HTMLTemplateFormat, 199 | Data: msgData, 200 | From: "cploutarchou@gmail.com", 201 | } 202 | h.APP.Mailer.Jobs <- msg 203 | res := <-h.APP.Mailer.Results 204 | if res.Error != nil { 205 | h.APP.ErrorStatus(w, http.StatusBadRequest) 206 | fmt.Println(res.Error) 207 | return 208 | } 209 | // redirect to login page 210 | http.Redirect(w, r, "/users/login", http.StatusSeeOther) 211 | } 212 | 213 | func (h *Handlers) ResetPasswordForm(w http.ResponseWriter, r *http.Request) { 214 | // Get the email from the query string 215 | email := r.URL.Query().Get("email") 216 | theUrl := r.RequestURI 217 | testUrl := fmt.Sprintf("%s%s", h.APP.Server.URL, theUrl) 218 | // VerifyToken the link 219 | signer := url_signer.Signer{ 220 | Secret: []byte(h.APP.EncryptionKey), 221 | } 222 | valid := signer.VerifyToken(testUrl) 223 | if !valid { 224 | h.APP.ErrorLog.Print("Invalid link") 225 | h.APP.ErrorUnauthorized(w, r) 226 | return 227 | } 228 | // VerifyToken the link 229 | exprired := signer.Expired(testUrl, 60) 230 | if exprired { 231 | h.APP.ErrorLog.Print("Link expired") 232 | h.APP.ErrorUnauthorized(w, r) 233 | return 234 | } 235 | // Display the form 236 | encEmail, _ := h.encrypt(email) 237 | _data := map[string]interface{}{ 238 | "Title": "Forgot password", 239 | "email": encEmail, 240 | } 241 | err := h.render(w, r, "reset-password", "main", nil, _data) 242 | if err != nil { 243 | h.APP.ErrorLog.Println("Something went wrong unable to render page : ", err) 244 | h.APP.ErrorUnauthorized(w, r) 245 | } 246 | 247 | } 248 | 249 | func (h *Handlers) PostResetPassword(w http.ResponseWriter, r *http.Request) { 250 | // Parse the form data 251 | err := r.ParseForm() 252 | if err != nil { 253 | h.APP.Error500(w, r) 254 | return 255 | } 256 | // Get the email from the query string and decrypt it 257 | email, err := h.decrypt(r.Form.Get("email")) 258 | if err != nil { 259 | h.APP.Error500(w, r) 260 | return 261 | } 262 | // Get the use from the database 263 | var u data.User 264 | user, err := u.GetByEmail(email) 265 | if err != nil { 266 | h.APP.Error500(w, r) 267 | return 268 | } 269 | // Reset the password 270 | err = user.ResetPassword(user.ID, r.Form.Get("password")) 271 | if err != nil { 272 | h.APP.Error500(w, r) 273 | return 274 | } 275 | // Redirect to the login page 276 | h.APP.Session.Put(r.Context(), "flash", "Your password has been reset. You can now login.") 277 | http.Redirect(w, r, "/users/login", http.StatusSeeOther) 278 | } 279 | -------------------------------------------------------------------------------- /terminal/cli/templates/handlers/handler.go.txt: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // $HANDLERNAME$ comment goes here 8 | func (h *Handlers) $HANDLERNAME$(w http.ResponseWriter, r *http.Request) { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /terminal/cli/templates/mailer/mail.html.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's 12 | standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make 13 | a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, 14 | remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing 15 | Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions 16 | of Lorem Ipsum

17 | 18 | 19 | 20 | {{end}} -------------------------------------------------------------------------------- /terminal/cli/templates/mailer/mail.plain.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's 3 | standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make 4 | a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, 5 | remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing 6 | Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions 7 | of Lorem Ipsum. 8 | {{end}} -------------------------------------------------------------------------------- /terminal/cli/templates/mailer/reset-password.html.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

Dear {{.Name}}

12 | 13 |

You recently requested a password reset for your account.

14 | 15 |

Visit the following link to reset your password:

16 | 17 | Click here to reset your password 18 | 19 | 20 | {{end}} -------------------------------------------------------------------------------- /terminal/cli/templates/mailer/reset-password.plain.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | Dear {{.Name}} 3 | You recently requested a password reset for your account. 4 | 5 | Visit the following link to reset your password: 6 | 7 | {{.Link}} 8 | {{end}} -------------------------------------------------------------------------------- /terminal/cli/templates/middleware/auth-token.go.txt: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "net/http" 4 | 5 | func (m *Middleware) AuthToken(next http.Handler) http.Handler { 6 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 7 | _, err := m.Models.Token.AuthenticateToken(r) 8 | if err != nil { 9 | var payload struct { 10 | Error bool `json:"error"` 11 | Message string `json:"message"` 12 | } 13 | payload.Error = true 14 | payload.Message = "invalid authentication credentials" 15 | _ = m.App.WriteJson(w, http.StatusUnauthorized, payload) 16 | } 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /terminal/cli/templates/middleware/auth.go.txt: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "net/http" 4 | 5 | func (m *Middleware) Auth(next http.Handler) http.Handler { 6 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 7 | if !m.App.Session.Exists(r.Context(), "UserID") { 8 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 9 | } 10 | }) 11 | 12 | } 13 | -------------------------------------------------------------------------------- /terminal/cli/templates/middleware/remember.go.txt: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "$APPNAME$/data" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | func (m *Middleware) CheckRemember(next http.Handler) http.Handler { 13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | if !m.App.Session.Exists(r.Context(), "userID") { 15 | // user is not logged in 16 | cookie, err := r.Cookie(fmt.Sprintf("_%s_remember", m.App.AppName)) 17 | if err != nil { 18 | // no cookie, so on to the next middleware 19 | next.ServeHTTP(w, r) 20 | } else { 21 | // we found a cookie, so check it 22 | key := cookie.Value 23 | var u data.User 24 | if len(key) > 0 { 25 | // cookie has some data, so validate it 26 | split := strings.Split(key, "|") 27 | uid, hash := split[0], split[1] 28 | id, _ := strconv.Atoi(uid) 29 | validHash := u.CheckForRememberToken(id, hash) 30 | if !validHash { 31 | m.deleteRememberCookie(w, r) 32 | m.App.Session.Put(r.Context(), "error", "You've been logged out from another device") 33 | next.ServeHTTP(w, r) 34 | } else { 35 | // valid hash, so log the user in 36 | user, _ := u.Get(id) 37 | m.App.Session.Put(r.Context(), "userID", user.ID) 38 | m.App.Session.Put(r.Context(), "remember_token", hash) 39 | next.ServeHTTP(w, r) 40 | } 41 | } else { 42 | // key length is zero, so it's probably a leftover cookie (user has not closed browser) 43 | m.deleteRememberCookie(w, r) 44 | next.ServeHTTP(w, r) 45 | } 46 | } 47 | } else { 48 | // user is logged in 49 | next.ServeHTTP(w, r) 50 | } 51 | }) 52 | } 53 | 54 | func (m *Middleware) deleteRememberCookie(w http.ResponseWriter, r *http.Request) { 55 | _ = m.App.Session.RenewToken(r.Context()) 56 | // delete the cookie 57 | newCookie := http.Cookie{ 58 | Name: fmt.Sprintf("_%s_remember", m.App.AppName), 59 | Value: "", 60 | Path: "/", 61 | Expires: time.Now().Add(-100 * time.Hour), 62 | HttpOnly: true, 63 | Domain: m.App.Session.Cookie.Domain, 64 | MaxAge: -1, 65 | Secure: m.App.Session.Cookie.Secure, 66 | SameSite: http.SameSiteStrictMode, 67 | } 68 | http.SetCookie(w, &newCookie) 69 | 70 | // log the user out 71 | m.App.Session.Remove(r.Context(), "userID") 72 | m.App.Session.Destroy(r.Context()) 73 | _ = m.App.Session.RenewToken(r.Context()) 74 | } 75 | -------------------------------------------------------------------------------- /terminal/cli/templates/migrations/admin_tables.mysql.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `role_users`, 2 | `permission_roles` CASCADE; 3 | DROP TABLE IF EXISTS `roles`, 4 | `permissions`; 5 | 6 | 7 | CREATE TABLE `roles` ( 8 | `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, 9 | `name` VARCHAR(255) NOT NULL, 10 | `description` VARCHAR(255) NOT NULL, 11 | `created_at` TIMESTAMP NULL DEFAULT NULL, 12 | `updated_at` TIMESTAMP NULL DEFAULT NULL, 13 | PRIMARY KEY (`id`), 14 | UNIQUE KEY `roles_name_unique` (`name`) 15 | ) ENGINE = InnoDB AUTO_INCREMENT = 17 DEFAULT CHARSET = utf8mb4; 16 | 17 | 18 | CREATE TABLE `role_users` ( 19 | `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, 20 | `user_id` INT(10) UNSIGNED NOT NULL, 21 | `role_id` INT(10) UNSIGNED NOT NULL, 22 | `created_at` TIMESTAMP NULL DEFAULT NULL, 23 | `updated_at` TIMESTAMP NULL DEFAULT NULL, 24 | PRIMARY KEY (`id`), 25 | KEY `role_users_user_id_foreign` (`user_id`), 26 | KEY `role_users_role_id_foreign` (`role_id`), 27 | CONSTRAINT `role_users_role_id_foreign` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, 28 | CONSTRAINT `role_users_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 29 | ) ENGINE = InnoDB AUTO_INCREMENT = 17 DEFAULT CHARSET = utf8mb4; 30 | 31 | 32 | CREATE TABLE `permissions` ( 33 | `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, 34 | `name` VARCHAR(255) NOT NULL, 35 | `description` VARCHAR(255) NOT NULL, 36 | `created_at` TIMESTAMP NULL DEFAULT NULL, 37 | `updated_at` TIMESTAMP NULL DEFAULT NULL, 38 | PRIMARY KEY (`id`), 39 | UNIQUE KEY `permissions_name_unique` (`name`) 40 | ) ENGINE = InnoDB AUTO_INCREMENT = 17 DEFAULT CHARSET = utf8mb4; 41 | 42 | 43 | CREATE TABLE permission_roles ( 44 | id int(10) unsigned NOT NULL AUTO_INCREMENT, 45 | permission_id int(10) unsigned NOT NULL, 46 | role_id int(10) unsigned NOT NULL, 47 | created_at timestamp NULL DEFAULT NULL, 48 | updated_at timestamp NULL DEFAULT NULL, 49 | PRIMARY KEY (id), 50 | KEY permission_roles_permission_id_foreign (permission_id), 51 | KEY permission_roles_role_id_foreign (role_id), 52 | CONSTRAINT permission_roles_permission_id_foreign FOREIGN KEY (permission_id) REFERENCES permissions (id) ON DELETE CASCADE ON UPDATE CASCADE, 53 | CONSTRAINT permission_roles_role_id_foreign FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE ON UPDATE CASCADE 54 | ) ENGINE = InnoDB AUTO_INCREMENT = 17 DEFAULT CHARSET = utf8mb4; 55 | 56 | -- create cms roles and permissions for admin and user roles and assign them to the admin and user users respectively (admin and user) 57 | -- admin user has all permissions 58 | -- user user has only the permission to view the dashboard 59 | -- subcriber user has only the permission to view the dashboard 60 | 61 | INSERT INTO TABLE roles (name, description, created_at, updated_at) VALUES ('admin', 'Administrator', NOW(), NOW()); 62 | INSERT INTO TABLE roles (name, description, created_at, updated_at) VALUES ('user', 'User', NOW(), NOW()); 63 | INSERT INTO TABLE roles (name, description, created_at, updated_at) VALUES ('subscriber', 'Subscriber', NOW(), NOW()); 64 | 65 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_dashboard', 'View Dashboard', NOW(), NOW()); 66 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_admin', 'View Admin', NOW(), NOW()); 67 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_users', 'View Users', NOW(), NOW()); 68 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('create_users', 'Create Users', NOW(), NOW()); 69 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('edit_users', 'Edit Users', NOW(), NOW()); 70 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('delete_users', 'Delete Users', NOW(), NOW()); 71 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_roles', 'View Roles', NOW(), NOW()); 72 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('create_roles', 'Create Roles', NOW(), NOW()); 73 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('edit_roles', 'Edit Roles', NOW(), NOW()); 74 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('delete_roles', 'Delete Roles', NOW(), NOW()); 75 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_permissions', 'View Permissions', NOW(), NOW()); 76 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('create_permissions', 'Create Permissions', NOW(), NOW()); 77 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('edit_permissions', 'Edit Permissions', NOW(), NOW()); 78 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('delete_permissions', 'Delete Permissions', NOW(), NOW()); 79 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_posts', 'View Posts', NOW(), NOW()); 80 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('create_posts', 'Create Posts', NOW(), NOW()); 81 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('edit_posts', 'Edit Posts', NOW(), NOW()); 82 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('delete_posts', 'Delete Posts', NOW(), NOW()); 83 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_categories', 'View Categories', NOW(), NOW()); 84 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('create_categories', 'Create Categories', NOW(), NOW()); 85 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('edit_categories', 'Edit Categories', NOW(), NOW()); 86 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('delete_categories', 'Delete Categories', NOW(), NOW()); 87 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_tags', 'View Tags', NOW(), NOW()); 88 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('create_tags', 'Create Tags', NOW(), NOW()); 89 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('edit_tags', 'Edit Tags', NOW(), NOW()); 90 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('delete_tags', 'Delete Tags', NOW(), NOW()); 91 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_comments', 'View Comments', NOW(), NOW()); 92 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('create_comments', 'Create Comments', NOW(), NOW()); 93 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('edit_comments', 'Edit Comments', NOW(), NOW()); 94 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('delete_comments', 'Delete Comments', NOW(), NOW()); 95 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_pages', 'View Pages', NOW(), NOW()); 96 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('create_pages', 'Create Pages', NOW(), NOW()); 97 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('edit_pages', 'Edit Pages', NOW(), NOW()); 98 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('delete_pages', 'Delete Pages', NOW(), NOW()); 99 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_media', 'View Media', NOW(), NOW()); 100 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('create_media', 'Create Media', NOW(), NOW()); 101 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('edit_media', 'Edit Media', NOW(), NOW()); 102 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('delete_media', 'Delete Media', NOW(), NOW()); 103 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('view_settings', 'View Settings', NOW(), NOW()); 104 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('create_settings', 'Create Settings', NOW(), NOW()); 105 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('edit_settings', 'Edit Settings', NOW(), NOW()); 106 | INSERT INTO TABLE permissions (name, description, created_at, updated_at) VALUES ('delete_settings', 'Delete Settings', NOW(), NOW()); -------------------------------------------------------------------------------- /terminal/cli/templates/migrations/admin_tables.postgress.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN IF EXISTS ( 2 | SELECT 1 3 | FROM information_schema.tables 4 | WHERE table_name = 'roles' 5 | ) THEN DROP TABLE roles CASCADE; 6 | END IF; 7 | END $$; 8 | CREATE TABLE roles ( 9 | id serial PRIMARY KEY, 10 | name varchar(255) NOT NULL, 11 | description varchar(255) NOT NULL, 12 | created_at timestamp DEFAULT NULL, 13 | updated_at timestamp DEFAULT NULL, 14 | UNIQUE (name) 15 | ); 16 | DO $$ BEGIN IF EXISTS ( 17 | SELECT 1 18 | FROM information_schema.tables 19 | WHERE table_name = 'role_users' 20 | ) THEN DROP TABLE role_users CASCADE; 21 | END IF; 22 | END $$; 23 | CREATE TABLE role_users ( 24 | id serial PRIMARY KEY, 25 | user_id integer NOT NULL, 26 | role_id integer NOT NULL, 27 | created_at timestamp DEFAULT NULL, 28 | updated_at timestamp DEFAULT NULL, 29 | FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE, 30 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE 31 | ); 32 | DO $$ BEGIN IF EXISTS ( 33 | SELECT 1 34 | FROM information_schema.tables 35 | WHERE table_name = 'permissions' 36 | ) THEN DROP TABLE permissions CASCADE; 37 | END IF; 38 | END $$; 39 | CREATE TABLE permissions ( 40 | id serial PRIMARY KEY, 41 | name varchar(255) NOT NULL, 42 | description varchar(255) NOT NULL, 43 | created_at timestamp DEFAULT NULL, 44 | updated_at timestamp DEFAULT NULL, 45 | UNIQUE (name) 46 | ); 47 | DO $$ BEGIN IF EXISTS ( 48 | SELECT 1 49 | FROM information_schema.tables 50 | WHERE table_name = 'permission_roles' 51 | ) THEN DROP TABLE permission_roles CASCADE; 52 | END IF; 53 | END $$; 54 | CREATE TABLE permission_roles ( 55 | id serial PRIMARY KEY, 56 | permission_id integer NOT NULL, 57 | role_id integer NOT NULL, 58 | created_at timestamp DEFAULT NULL, 59 | updated_at timestamp DEFAULT NULL, 60 | FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE ON UPDATE CASCADE, 61 | FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE 62 | ); -------------------------------------------------------------------------------- /terminal/cli/templates/migrations/auth_tables.mysql.sql: -------------------------------------------------------------------------------- 1 | drop table if exists users cascade; 2 | 3 | CREATE TABLE `users` ( 4 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 5 | `first_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, 6 | `last_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, 7 | `user_active` int(11) NOT NULL, 8 | `email` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, 9 | `password` char(60) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, 10 | `created_at` timestamp NULL DEFAULT NULL, 11 | `updated_at` timestamp NULL DEFAULT NULL, 12 | PRIMARY KEY (`id`), 13 | UNIQUE KEY `users_email_unique` (`email`), 14 | KEY `users_email_index` (`email`) 15 | ) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4; 16 | 17 | drop table if exists remember_tokens cascade; 18 | 19 | CREATE TABLE `remember_tokens` ( 20 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 21 | `user_id` int(10) unsigned NOT NULL, 22 | `remember_token` varchar(100) NOT NULL DEFAULT '', 23 | `created_at` timestamp NOT NULL DEFAULT current_timestamp(), 24 | `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), 25 | PRIMARY KEY (`id`), 26 | KEY `remember_token` (`remember_token`), 27 | KEY `remember_tokens_user_id_foreign` (`user_id`), 28 | CONSTRAINT `remember_tokens_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 29 | ) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8; 30 | 31 | drop table if exists tokens cascade; 32 | 33 | CREATE TABLE `tokens` ( 34 | `id` int(11) NOT NULL AUTO_INCREMENT, 35 | `user_id` int(11) unsigned NOT NULL, 36 | `name` varchar(255) NOT NULL, 37 | `email` varchar(255) NOT NULL, 38 | `token` varchar(255) NOT NULL, 39 | `token_hash` varbinary(255) DEFAULT NULL, 40 | `created_at` datetime NOT NULL DEFAULT current_timestamp(), 41 | `updated_at` datetime NOT NULL DEFAULT current_timestamp(), 42 | `expiry` datetime NOT NULL, 43 | PRIMARY KEY (`id`), 44 | FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE cascade ON DELETE cascade 45 | ) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8mb4; -------------------------------------------------------------------------------- /terminal/cli/templates/migrations/auth_tables.postgres.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION trigger_set_timestamp() 2 | RETURNS TRIGGER AS $$ 3 | BEGIN 4 | NEW.updated_at = NOW(); 5 | RETURN NEW; 6 | END; 7 | $$ LANGUAGE plpgsql; 8 | 9 | drop table if exists users cascade; 10 | 11 | CREATE TABLE users ( 12 | id SERIAL PRIMARY KEY, 13 | first_name character varying(255) NOT NULL, 14 | last_name character varying(255) NOT NULL, 15 | user_active integer NOT NULL DEFAULT 0, 16 | email character varying(255) NOT NULL UNIQUE, 17 | password character varying(60) NOT NULL, 18 | created_at timestamp without time zone NOT NULL DEFAULT now(), 19 | updated_at timestamp without time zone NOT NULL DEFAULT now() 20 | ); 21 | 22 | CREATE TRIGGER set_timestamp 23 | BEFORE UPDATE ON users 24 | FOR EACH ROW 25 | EXECUTE PROCEDURE trigger_set_timestamp(); 26 | 27 | drop table if exists remember_tokens; 28 | 29 | CREATE TABLE remember_tokens ( 30 | id SERIAL PRIMARY KEY, 31 | user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, 32 | remember_token character varying(100) NOT NULL, 33 | created_at timestamp without time zone NOT NULL DEFAULT now(), 34 | updated_at timestamp without time zone NOT NULL DEFAULT now() 35 | ); 36 | 37 | CREATE TRIGGER set_timestamp 38 | BEFORE UPDATE ON remember_tokens 39 | FOR EACH ROW 40 | EXECUTE PROCEDURE trigger_set_timestamp(); 41 | 42 | drop table if exists tokens; 43 | 44 | CREATE TABLE tokens ( 45 | id SERIAL PRIMARY KEY, 46 | user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, 47 | first_name character varying(255) NOT NULL, 48 | email character varying(255) NOT NULL, 49 | token character varying(255) NOT NULL, 50 | token_hash bytea NOT NULL, 51 | created_at timestamp without time zone NOT NULL DEFAULT now(), 52 | updated_at timestamp without time zone NOT NULL DEFAULT now(), 53 | expiry timestamp without time zone NOT NULL 54 | ); 55 | 56 | CREATE TRIGGER set_timestamp 57 | BEFORE UPDATE ON tokens 58 | FOR EACH ROW 59 | EXECUTE PROCEDURE trigger_set_timestamp(); -------------------------------------------------------------------------------- /terminal/cli/templates/migrations/migration.mysql.down.sql: -------------------------------------------------------------------------------- 1 | drop table if exists some_table -------------------------------------------------------------------------------- /terminal/cli/templates/migrations/migration.mysql.up.sql: -------------------------------------------------------------------------------- 1 | drop table if exists some_table cascade; 2 | 3 | CREATE TABLE some_table 4 | ( 5 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 6 | `some_field` varchar(250) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, 7 | `created_at` timestamp NULL DEFAULT NULL, 8 | `updated_at` timestamp NULL DEFAULT NULL, 9 | PRIMARY KEY (`id`), 10 | UNIQUE KEY `some_field_unique` (`some_field`), 11 | KEY `some_field_index` (`some_field`) 12 | ) ENGINE = InnoDB 13 | AUTO_INCREMENT = 17 14 | DEFAULT CHARSET = utf8mb4; 15 | 16 | -------------------------------------------------------------------------------- /terminal/cli/templates/migrations/migration.postgres.down.sql: -------------------------------------------------------------------------------- 1 | -- drop table some_table; -------------------------------------------------------------------------------- /terminal/cli/templates/migrations/migration.postgres.up.sql: -------------------------------------------------------------------------------- 1 | -- CREATE TABLE some_table ( 2 | -- id serial PRIMARY KEY, 3 | -- some_field VARCHAR ( 255 ) NOT NULL, 4 | -- created_at TIMESTAMP, 5 | -- updated_at TIMESTAMP 6 | -- ); 7 | 8 | -- add auto update of updated_at. If you already have this trigger 9 | -- you can delete the next 7 lines 10 | -- CREATE OR REPLACE FUNCTION trigger_set_timestamp() 11 | -- RETURNS TRIGGER AS $$ 12 | -- BEGIN 13 | -- NEW.updated_at = NOW(); 14 | -- RETURN NEW; 15 | -- END; 16 | -- $$ LANGUAGE plpgsql; 17 | 18 | -- CREATE TRIGGER set_timestamp 19 | -- BEFORE UPDATE ON some_table 20 | -- FOR EACH ROW 21 | -- EXECUTE PROCEDURE trigger_set_timestamp(); -------------------------------------------------------------------------------- /terminal/cli/templates/migrations/mysql_session.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE sessions ( 2 | token CHAR(43) PRIMARY KEY, 3 | data BLOB NOT NULL, 4 | expiry TIMESTAMP(6) NOT NULL 5 | ); 6 | 7 | CREATE INDEX sessions_expiry_idx ON sessions (expiry); -------------------------------------------------------------------------------- /terminal/cli/templates/migrations/postgres_session.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE sessions ( 2 | token TEXT PRIMARY KEY, 3 | data BYTEA NOT NULL, 4 | expiry TIMESTAMPTZ NOT NULL 5 | ); 6 | 7 | CREATE INDEX sessions_expiry_idx ON sessions (expiry); -------------------------------------------------------------------------------- /terminal/cli/templates/views/forgot.html: -------------------------------------------------------------------------------- 1 |

Forgot Password

2 | 3 |
4 | {{ if .Error }} 5 |
6 |
7 | {{end}} 8 | 9 | {{if .Flash }} 10 |
11 |
12 | {{end}} 13 | 14 | 15 |

16 | Enter your email address in the form below, and we'll 17 | email you a link to reset your password. 18 |

19 | 20 |
22 | 23 | 24 |
25 | 26 | 28 |
29 |
30 | Send Reset Password Email 31 | 32 |
33 | 34 |
35 | Back... 36 |
37 | 38 | 39 |

 

40 | 41 | 54 | 55 | -------------------------------------------------------------------------------- /terminal/cli/templates/views/login.html: -------------------------------------------------------------------------------- 1 |

Login

2 |
3 | 4 | {{ if .Flash }} 5 | 6 |
7 | 8 |
9 | {{ end }} 10 | 11 |
15 | 16 | 17 | 18 |
19 | 20 | 22 |
23 | 24 |
25 | 26 | 29 |
30 | 31 |
32 |
33 | 34 | 35 |
36 | Login 37 |

38 | Forgot password? 39 |

40 | 41 |
42 | 43 |
44 | Back... 45 |
46 | 47 |

 

48 | 49 | 50 | 64 | -------------------------------------------------------------------------------- /terminal/cli/templates/views/reset-password.html: -------------------------------------------------------------------------------- 1 |

Reset Password

2 | 3 | {{if .Error }} 4 |
5 |
6 | {{end}} 7 | 8 | {{if .Flash }} 9 |
10 |
11 | {{end}} 12 | 13 |
20 | 21 | 22 | 23 | 24 |
25 | 26 | 28 |
29 | 30 |
31 | 32 | 34 |
35 | 36 |
37 | 38 | 39 | 40 |
41 | 42 |
43 | 44 | 45 |
46 | Back... 47 |
48 | 49 | 50 |

 

51 | 52 | 70 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package MicroGO 2 | 3 | import "database/sql" 4 | 5 | type initPaths struct { 6 | rootPath string 7 | folderNames []string 8 | } 9 | 10 | type cookieConfig struct { 11 | name string 12 | lifetime string 13 | persist string 14 | secure string 15 | domain string 16 | } 17 | 18 | type databaseConfig struct { 19 | dataSourceName string 20 | database string 21 | } 22 | 23 | type Database struct { 24 | DatabaseType string 25 | Pool *sql.DB 26 | } 27 | 28 | type redisConfig struct { 29 | host string 30 | port string 31 | username string 32 | password string 33 | prefix string 34 | } 35 | -------------------------------------------------------------------------------- /url_signer/signer.go: -------------------------------------------------------------------------------- 1 | package url_signer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type Signer struct { 10 | Secret []byte 11 | } 12 | 13 | func (s *Signer) GenerateToken(data string) string { 14 | var urlToSing string 15 | crypt := New(s.Secret, Timestamp) 16 | if strings.Contains(data, "?") { 17 | urlToSing = fmt.Sprintf("%s&hash=", data) 18 | } else { 19 | urlToSing = fmt.Sprintf("%s&hash=", data) 20 | } 21 | tokenBytes := crypt.Sign([]byte(urlToSing)) 22 | token := string(tokenBytes) 23 | return token 24 | } 25 | 26 | func (s *Signer) VerifyToken(token string) bool { 27 | crypt := New(s.Secret, Timestamp) 28 | _, err := crypt.UnSing([]byte(token)) 29 | if err != nil { 30 | return false 31 | } 32 | return true 33 | } 34 | 35 | func (s *Signer) Expired(token string, minUntilExpire int64) bool { 36 | crypt := New(s.Secret, Timestamp) 37 | ts := crypt.Parse([]byte(token)) 38 | return time.Since(ts.Timestamp) > time.Duration(minUntilExpire)*time.Minute 39 | 40 | } 41 | -------------------------------------------------------------------------------- /url_signer/token.go: -------------------------------------------------------------------------------- 1 | package url_signer 2 | 3 | import ( 4 | "crypto/subtle" 5 | "encoding/base64" 6 | "errors" 7 | "hash" 8 | "sync" 9 | "time" 10 | 11 | "golang.org/x/crypto/blake2b" 12 | ) 13 | 14 | // Protect is the main struct used to sign and unsigned data 15 | type Protect struct { 16 | sync.Mutex 17 | hash hash.Hash 18 | dirty bool 19 | timestamp bool 20 | epoch int64 21 | } 22 | 23 | var ( 24 | ErrInvalidSignature = errors.New("invalid signature") 25 | ErrShortToken = errors.New("token is too small to be valid") 26 | ) 27 | 28 | // New takes a secret key and returns a new Protect struct. If no Options are provided 29 | // then minimal defaults will be used. NOTE: The key must be 64 bytes or fewer 30 | // . If a larger key is provided it will be truncated to 64 bytes. 31 | func New(key []byte, options ...func(*Protect)) *Protect { 32 | 33 | var err error 34 | 35 | // Create a map for decoding Base58. This speeds up the process tremendously. 36 | for i := 0; i < len(encBase58Map); i++ { 37 | decBase58Map[encBase58Map[i]] = byte(i) 38 | } 39 | 40 | s := &Protect{} 41 | 42 | for _, opt := range options { 43 | opt(s) 44 | } 45 | 46 | s.hash, err = blake2b.New256(key) 47 | if err != nil { 48 | // The only possible error that can be returned here is if the key 49 | // is larger than 64 bytes - which the blake2b hash will not accept. 50 | // This is a case that is so easily avoidable when using this package 51 | // and since chaining is convenient for this package. We're going 52 | // to do the below to handle this possible case, so we don't have 53 | // to return an error. 54 | s.hash, _ = blake2b.New256(key[0:64]) 55 | } 56 | 57 | return s 58 | } 59 | 60 | // Epoch is a functional option that can be passed to New() to set the Epoch 61 | // to be used. 62 | func Epoch(e int64) func(*Protect) { 63 | return func(s *Protect) { 64 | s.epoch = e 65 | } 66 | } 67 | 68 | // Timestamp is a functional option that can be passed to New() to add a 69 | // timestamp to signatures. 70 | func Timestamp(s *Protect) { 71 | s.timestamp = true 72 | } 73 | 74 | // Sign signs data and returns []byte in the format `data.signature`. Optionally 75 | // add a timestamp and return to the format `data.timestamp.signature` 76 | func (s *Protect) Sign(data []byte) []byte { 77 | 78 | // Build the payload 79 | el := base64.RawURLEncoding.EncodedLen(s.hash.Size()) 80 | var t []byte 81 | 82 | if s.timestamp { 83 | ts := time.Now().Unix() - s.epoch 84 | etl := encodeBase58Len(ts) 85 | t = make([]byte, 0, len(data)+etl+el+2) // +2 for "." chars 86 | t = append(t, data...) 87 | t = append(t, '.') 88 | t = t[0 : len(t)+etl] // expand for timestamp 89 | encodeBase58(ts, t) 90 | } else { 91 | t = make([]byte, 0, len(data)+el+1) 92 | t = append(t, data...) 93 | } 94 | 95 | // Append and encode signature to token 96 | t = append(t, '.') 97 | tl := len(t) 98 | t = t[0 : tl+el] 99 | 100 | // Add the signature to the token 101 | s.sign(t[tl:], t[0:tl-1]) 102 | 103 | // Return the token to the caller 104 | return t 105 | } 106 | 107 | // UnSing validates a signature and if successful returns the data portion of []byte. 108 | // If unsuccessful it will return an error and nil for the data. 109 | func (s *Protect) UnSing(token []byte) ([]byte, error) { 110 | 111 | tl := len(token) 112 | el := base64.RawURLEncoding.EncodedLen(s.hash.Size()) 113 | 114 | // A token must be at least el+2 bytes long to be valid. 115 | if tl < el+2 { 116 | return nil, ErrShortToken 117 | } 118 | 119 | // Get the signature of the payload 120 | dst := make([]byte, el) 121 | s.sign(dst, token[0:tl-(el+1)]) 122 | 123 | if subtle.ConstantTimeCompare(token[tl-el:], dst) != 1 { 124 | return nil, ErrInvalidSignature 125 | } 126 | 127 | return token[0 : tl-(el+1)], nil 128 | } 129 | 130 | // This is the map of characters used during base58 encoding. 131 | const encBase58Map = "789924579abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ" 132 | 133 | // Used to create a decoded map, so we can decode base58 fairly fast. 134 | var decBase58Map [256]byte 135 | 136 | // sign creates the encoded signature of payload and writes to dst 137 | func (s *Protect) sign(dst, payload []byte) { 138 | 139 | s.Lock() 140 | if s.dirty { 141 | s.hash.Reset() 142 | } 143 | s.dirty = true 144 | s.hash.Write(payload) 145 | h := s.hash.Sum(nil) 146 | s.Unlock() 147 | 148 | base64.RawURLEncoding.Encode(dst, h) 149 | } 150 | 151 | // returns the len of base58 encoded i 152 | func encodeBase58Len(i int64) int { 153 | 154 | var l = 1 155 | for i >= 58 { 156 | l++ 157 | i /= 58 158 | } 159 | return l 160 | } 161 | 162 | // encode time int64 into []byte 163 | func encodeBase58(i int64, b []byte) { 164 | p := len(b) - 1 165 | for i >= 58 { 166 | b[p] = encBase58Map[i%58] 167 | p-- 168 | i /= 58 169 | } 170 | b[p] = encBase58Map[i] 171 | } 172 | 173 | // parses a base58 []byte into an int64 174 | func decodeBase58(b []byte) int64 { 175 | var id int64 176 | for p := range b { 177 | id = id*58 + int64(decBase58Map[b[p]]) 178 | } 179 | return id 180 | } 181 | 182 | type Token struct { 183 | Payload []byte 184 | Timestamp time.Time 185 | } 186 | 187 | func (s *Protect) Parse(t []byte) Token { 188 | 189 | tl := len(t) 190 | el := base64.RawURLEncoding.EncodedLen(s.hash.Size()) 191 | 192 | token := Token{} 193 | 194 | if s.timestamp { 195 | for i := tl - (el + 2); i >= 0; i-- { 196 | if t[i] == '.' { 197 | token.Payload = t[0:i] 198 | token.Timestamp = time.Unix(decodeBase58(t[i+1:tl-(el+1)])+s.epoch, 0) 199 | break 200 | } 201 | } 202 | } else { 203 | token.Payload = t[0 : tl-(el+1)] 204 | } 205 | 206 | return token 207 | } 208 | -------------------------------------------------------------------------------- /utilities.go: -------------------------------------------------------------------------------- 1 | package MicroGO 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "runtime" 7 | "time" 8 | ) 9 | 10 | func (m *MicroGo) LoadTime(start time.Time) { 11 | elapsed := time.Since(start) 12 | programCaller, _, _, _ := runtime.Caller(1) 13 | funcObj := runtime.FuncForPC(programCaller) 14 | runtimeFunc := regexp.MustCompile(`^.*\.(.*)$`) 15 | name := runtimeFunc.ReplaceAllString(funcObj.Name(), "$1") 16 | 17 | m.InfoLog.Printf(fmt.Sprintf("Load Time: %s took %s", name, elapsed)) 18 | } 19 | -------------------------------------------------------------------------------- /validator.go: -------------------------------------------------------------------------------- 1 | package MicroGO 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/asaskevich/govalidator" 11 | ) 12 | 13 | type Validation struct { 14 | Data url.Values 15 | Errors map[string]string 16 | } 17 | 18 | func (m *MicroGo) Validator(data url.Values) *Validation { 19 | return &Validation{ 20 | Errors: make(map[string]string), 21 | Data: data, 22 | } 23 | } 24 | 25 | func (v *Validation) Valid() bool { 26 | return len(v.Errors) == 0 27 | } 28 | 29 | func (v *Validation) AddError(key, message string) { 30 | if _, exists := v.Errors[key]; !exists { 31 | v.Errors[key] = message 32 | } 33 | } 34 | 35 | func (v *Validation) Has(field string, r *http.Request) bool { 36 | x := r.Form.Get(field) 37 | return x != "" 38 | } 39 | 40 | func (v *Validation) Required(r *http.Request, fields ...string) { 41 | for _, field := range fields { 42 | value := r.Form.Get(field) 43 | if strings.TrimSpace(value) == "" { 44 | v.AddError(field, "This field cannot be blank") 45 | } 46 | } 47 | } 48 | 49 | func (v *Validation) Check(ok bool, key, message string) { 50 | if !ok { 51 | v.AddError(key, message) 52 | } 53 | } 54 | 55 | func (v *Validation) IsEmail(field, value string) { 56 | if !govalidator.IsEmail(value) { 57 | v.AddError(field, "Invalid email address") 58 | } 59 | } 60 | 61 | func (v *Validation) IsInt(field, value string) { 62 | _, err := strconv.Atoi(value) 63 | if err != nil { 64 | v.AddError(field, "This field must be an integer") 65 | } 66 | } 67 | 68 | func (v *Validation) IsFloat(field, value string) { 69 | _, err := strconv.ParseFloat(value, 64) 70 | if err != nil { 71 | v.AddError(field, "This field must be a floating point number") 72 | } 73 | } 74 | 75 | func (v *Validation) IsDateISO(field, value string) { 76 | _, err := time.Parse("2006-01-02", value) 77 | if err != nil { 78 | v.AddError(field, "This field must be a date in the form of YYYY-MM-DD") 79 | } 80 | } 81 | 82 | func (v *Validation) NoSpaces(field, value string) { 83 | if govalidator.HasWhitespace(value) { 84 | v.AddError(field, "Spaces are not permitted") 85 | } 86 | } 87 | --------------------------------------------------------------------------------