Enter your message content here...
12 | 13 | 14 | 15 | {{end}} -------------------------------------------------------------------------------- /cmd/cli/templates/mailer/mail.plain.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | Enter your message content here... 3 | {{end}} -------------------------------------------------------------------------------- /cmd/cli/templates/mailer/password-reset.html.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |Hello:
12 |You recently requested a link to reset your password.
13 |Visit the link below to get started. Note that the link expires in 60 minutes.
14 |Click here to reset your password 15 | 16 | 17 | 18 | {{end}} -------------------------------------------------------------------------------- /cmd/cli/templates/mailer/password-reset.plain.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | Hello: 3 | 4 | You recently requested a link to reset your password. 5 | 6 | Visit the link below to get started. Note that the link expires in 60 minutes. 7 | 8 | {{.Link}} 9 | 10 | {{end}} -------------------------------------------------------------------------------- /cmd/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.Tokens.AuthenticateToken(r) 8 | if err != nil { 9 | var payload struct { 10 | Error bool `json:"error"` 11 | Message string `json:"message"` 12 | } 13 | 14 | payload.Error = true 15 | payload.Message = "invalid authentication credentials" 16 | 17 | _ = m.App.WriteJSON(w, http.StatusUnauthorized, payload) 18 | } 19 | }) 20 | } -------------------------------------------------------------------------------- /cmd/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 | } -------------------------------------------------------------------------------- /cmd/cli/templates/middleware/remember.go.txt: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "myapp/data" 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 | } -------------------------------------------------------------------------------- /cmd/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; -------------------------------------------------------------------------------- /cmd/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(); -------------------------------------------------------------------------------- /cmd/cli/templates/migrations/migration.postgres.down.sql: -------------------------------------------------------------------------------- 1 | -- drop table some_table; -------------------------------------------------------------------------------- /cmd/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(); -------------------------------------------------------------------------------- /cmd/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); -------------------------------------------------------------------------------- /cmd/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); -------------------------------------------------------------------------------- /cmd/cli/templates/views/forgot.jet: -------------------------------------------------------------------------------- 1 | {{extends "./layouts/base.jet"}} 2 | 3 | {{block browserTitle()}} 4 | Forgot Password 5 | {{end}} 6 | 7 | {{block css()}} {{end}} 8 | 9 | {{block pageContent()}} 10 |
28 | Enter your email address in the form below, and we'll 29 | email you a link to reset your password. 30 |
31 | 32 | 52 | 53 |59 | {{end}} 60 | 61 | {{ block js()}} 62 | 75 | {{end}} 76 | -------------------------------------------------------------------------------- /cmd/cli/templates/views/login.jet: -------------------------------------------------------------------------------- 1 | {{extends "./layouts/base.jet"}} 2 | 3 | {{block browserTitle()}} 4 | Login 5 | {{end}} 6 | 7 | {{block css()}} {{end}} 8 | 9 | {{block pageContent()}} 10 |
59 | 60 | {{end}} 61 | 62 | {{block js()}} 63 | 77 | {{end}} -------------------------------------------------------------------------------- /cmd/cli/templates/views/reset-password.jet: -------------------------------------------------------------------------------- 1 | {{extends "./layouts/base.jet"}} 2 | 3 | {{block browserTitle()}} 4 | Form 5 | {{end}} 6 | 7 | {{block css()}} {{end}} 8 | 9 | {{block pageContent()}} 10 |
63 | {{end}} 64 | 65 | {{ block js()}} 66 | 84 | {{end}} 85 | -------------------------------------------------------------------------------- /driver.go: -------------------------------------------------------------------------------- 1 | package ghostly 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 | // OpenDB opens a connection to a sql database. dbType must be one of postgres (or pgx). 12 | // TODO: add support for mysql/mariadb 13 | func (g *Ghostly) OpenDB(dbType, dsn string) (*sql.DB, error) { 14 | if dbType == "postgres" || dbType == "postgresql" { 15 | dbType = "pgx" 16 | } 17 | 18 | db, err := sql.Open(dbType, dsn) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | err = db.Ping() 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return db, nil 29 | 30 | } 31 | -------------------------------------------------------------------------------- /filesystems/filesystems.go: -------------------------------------------------------------------------------- 1 | package filesystems 2 | 3 | import "time" 4 | 5 | // FS is the interface for file systems 6 | type FS interface { 7 | Put(fileName, folder string) error 8 | Get(destination string, items ...string) error 9 | List(prefix string) ([]Listing, error) 10 | Delete(itemsToDelete []string) bool 11 | } 12 | 13 | // Listing describes one file on a remote file system 14 | type Listing struct { 15 | Etag string 16 | LastModified time.Time 17 | Key string 18 | Size float64 19 | IsDir bool 20 | } 21 | -------------------------------------------------------------------------------- /filesystems/miniofilesystem/minio.go: -------------------------------------------------------------------------------- 1 | package miniofilesystem 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "path" 8 | "strings" 9 | 10 | "github.com/dominic-wassef/ghostly/filesystems" 11 | "github.com/minio/minio-go/v7" 12 | "github.com/minio/minio-go/v7/pkg/credentials" 13 | ) 14 | 15 | // Minio is the overall type for the minio filesystem, and contains 16 | // the connection credentials, endpoint, and the bucket to use 17 | type Minio struct { 18 | Endpoint string 19 | Key string 20 | Secret string 21 | UseSSL bool 22 | Region string 23 | Bucket string 24 | } 25 | 26 | // getCredentials generates a minio client using the credentials stored in 27 | // the Minio type 28 | func (m *Minio) getCredentials() *minio.Client { 29 | client, err := minio.New(m.Endpoint, &minio.Options{ 30 | Creds: credentials.NewStaticV4(m.Key, m.Secret, ""), 31 | Secure: m.UseSSL, 32 | }) 33 | if err != nil { 34 | log.Println(err) 35 | } 36 | return client 37 | } 38 | 39 | // Put transfers a file to the remote file system 40 | func (m *Minio) Put(fileName, folder string) error { 41 | ctx, cancel := context.WithCancel(context.Background()) 42 | defer cancel() 43 | 44 | objectName := path.Base(fileName) 45 | client := m.getCredentials() 46 | uploadInfo, err := client.FPutObject(ctx, m.Bucket, fmt.Sprintf("%s/%s", folder, objectName), fileName, minio.PutObjectOptions{}) 47 | if err != nil { 48 | log.Println("Failed with FPutObject") 49 | log.Println(err) 50 | log.Println("UploadInfo:", uploadInfo) 51 | return err 52 | } 53 | 54 | return nil 55 | } 56 | 57 | // List returns a listing of all files in the remote bucket with the 58 | // given prefix, except for files with a leading . in the name 59 | func (m *Minio) List(prefix string) ([]filesystems.Listing, error) { 60 | var listing []filesystems.Listing 61 | 62 | ctx, cancel := context.WithCancel(context.Background()) 63 | defer cancel() 64 | 65 | client := m.getCredentials() 66 | 67 | objectCh := client.ListObjects(ctx, m.Bucket, minio.ListObjectsOptions{ 68 | Prefix: prefix, 69 | Recursive: true, 70 | }) 71 | 72 | for object := range objectCh { 73 | if object.Err != nil { 74 | fmt.Println(object.Err) 75 | return listing, object.Err 76 | } 77 | 78 | if !strings.HasPrefix(object.Key, ".") { 79 | b := float64(object.Size) 80 | kb := b / 1024 81 | mb := kb / 1024 82 | item := filesystems.Listing{ 83 | Etag: object.ETag, 84 | LastModified: object.LastModified, 85 | Key: object.Key, 86 | Size: mb, 87 | } 88 | listing = append(listing, item) 89 | } 90 | } 91 | 92 | return listing, nil 93 | } 94 | 95 | // Delete removes one or more files from the remote filesystem 96 | func (m *Minio) Delete(itemsToDelete []string) bool { 97 | ctx, cancel := context.WithCancel(context.Background()) 98 | defer cancel() 99 | 100 | client := m.getCredentials() 101 | 102 | opts := minio.RemoveObjectOptions{ 103 | GovernanceBypass: true, 104 | } 105 | 106 | for _, item := range itemsToDelete { 107 | err := client.RemoveObject(ctx, m.Bucket, item, opts) 108 | if err != nil { 109 | fmt.Println(err) 110 | return false 111 | } 112 | } 113 | return true 114 | } 115 | 116 | // Get pulls a file from the remote file system and saves it somewhere on our server 117 | func (m *Minio) Get(destination string, items ...string) error { 118 | ctx, cancel := context.WithCancel(context.Background()) 119 | defer cancel() 120 | 121 | client := m.getCredentials() 122 | 123 | for _, item := range items { 124 | err := client.FGetObject(ctx, m.Bucket, item, fmt.Sprintf("%s/%s", destination, path.Base(item)), minio.GetObjectOptions{}) 125 | if err != nil { 126 | fmt.Println(err) 127 | return err 128 | } 129 | } 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /filesystems/s3filesystem/s3.go: -------------------------------------------------------------------------------- 1 | package s3filesystem 2 | 3 | import "github.com/dominic-wassef/ghostly/filesystems" 4 | 5 | type S3 struct { 6 | Key string 7 | Secret string 8 | Region string 9 | Endpoint string 10 | Bucket string 11 | } 12 | 13 | func (s *S3) Put(fileName, folder string) error { 14 | return nil 15 | } 16 | 17 | func (s *S3) List(prefix string) ([]filesystems.Listing, error) { 18 | var listing []filesystems.Listing 19 | return listing, nil 20 | } 21 | 22 | func (s *S3) Delete(itemsToDelete []string) bool { 23 | return true 24 | } 25 | 26 | func (s *S3) Get(destination string, items ...string) error { 27 | return nil 28 | } -------------------------------------------------------------------------------- /filesystems/sftpfilesystem/sftp.go: -------------------------------------------------------------------------------- 1 | package sftpfilesystem 2 | 3 | import "github.com/dominic-wassef/ghostly/filesystems" 4 | 5 | type SFTP struct { 6 | Host string 7 | User string 8 | Pass string 9 | Port string 10 | } 11 | 12 | func (s *SFTP) Put(fileName, folder string) error { 13 | return nil 14 | } 15 | 16 | func (s *SFTP) List(prefix string) ([]filesystems.Listing, error) { 17 | var listing []filesystems.Listing 18 | return listing, nil 19 | } 20 | 21 | func (s *SFTP) Delete(itemsToDelete []string) bool { 22 | return true 23 | } 24 | 25 | func (s *SFTP) Get(destination string, items ...string) error { 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /filesystems/webdevfilesystem/webdev.go: -------------------------------------------------------------------------------- 1 | package webdevfilesystem 2 | 3 | import "github.com/dominic-wassef/ghostly/filesystems" 4 | 5 | type WebDAV struct { 6 | Host string 7 | User string 8 | Pass string 9 | } 10 | 11 | func (s *WebDAV) Put(fileName, folder string) error { 12 | return nil 13 | } 14 | 15 | func (s *WebDAV) List(prefix string) ([]filesystems.Listing, error) { 16 | var listing []filesystems.Listing 17 | return listing, nil 18 | } 19 | 20 | func (s *WebDAV) Delete(itemsToDelete []string) bool { 21 | return true 22 | } 23 | 24 | func (s *WebDAV) Get(destination string, items ...string) error { 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /ghostly.go: -------------------------------------------------------------------------------- 1 | package ghostly 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/CloudyKit/jet/v6" 13 | "github.com/alexedwards/scs/v2" 14 | "github.com/dgraph-io/badger/v3" 15 | "github.com/dominic-wassef/ghostly/cache" 16 | "github.com/dominic-wassef/ghostly/mailer" 17 | "github.com/dominic-wassef/ghostly/render" 18 | "github.com/dominic-wassef/ghostly/session" 19 | "github.com/go-chi/chi/v5" 20 | "github.com/gomodule/redigo/redis" 21 | "github.com/joho/godotenv" 22 | "github.com/robfig/cron/v3" 23 | ) 24 | 25 | const version = "1.0.0" 26 | 27 | var myRedisCache *cache.RedisCache 28 | var myBadgerCache *cache.BadgerCache 29 | var redisPool *redis.Pool 30 | var badgerConn *badger.DB 31 | 32 | // Ghostly is the overall type for the Ghostly package. Members that are exported in this type 33 | // are available to any application that uses it. 34 | type Ghostly struct { 35 | AppName string 36 | Debug bool 37 | Version string 38 | ErrorLog *log.Logger 39 | InfoLog *log.Logger 40 | RootPath string 41 | Routes *chi.Mux 42 | Render *render.Render 43 | Session *scs.SessionManager 44 | DB Database 45 | JetViews *jet.Set 46 | config config 47 | EncryptionKey string 48 | Cache cache.Cache 49 | Scheduler *cron.Cron 50 | Mail mailer.Mail 51 | Server Server 52 | } 53 | 54 | type Server struct { 55 | ServerName string 56 | Port string 57 | Secure bool 58 | URL string 59 | } 60 | 61 | type config struct { 62 | port string 63 | renderer string 64 | cookie cookieConfig 65 | sessionType string 66 | database databaseConfig 67 | redis redisConfig 68 | } 69 | 70 | // New reads the .env file, creates our application config, populates the Ghostly type with settings 71 | // based on .env values, and creates necessary folders and files if they don't exist 72 | func (g *Ghostly) New(rootPath string) error { 73 | pathConfig := initPaths{ 74 | rootPath: rootPath, 75 | folderNames: []string{"handlers", "migrations", "views", "mail", "data", "public", "tmp", "logs", "middleware"}, 76 | } 77 | 78 | err := g.Init(pathConfig) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | err = g.checkDotEnv(rootPath) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | // read .env 89 | err = godotenv.Load(rootPath + "/.env") 90 | if err != nil { 91 | return err 92 | } 93 | 94 | // create loggers 95 | infoLog, errorLog := g.startLoggers() 96 | 97 | // connect to database 98 | if os.Getenv("DATABASE_TYPE") != "" { 99 | db, err := g.OpenDB(os.Getenv("DATABASE_TYPE"), g.BuildDSN()) 100 | if err != nil { 101 | errorLog.Println(err) 102 | os.Exit(1) 103 | } 104 | g.DB = Database{ 105 | DataType: os.Getenv("DATABASE_TYPE"), 106 | Pool: db, 107 | } 108 | } 109 | 110 | scheduler := cron.New() 111 | g.Scheduler = scheduler 112 | 113 | if os.Getenv("CACHE") == "redis" || os.Getenv("SESSION_TYPE") == "redis" { 114 | myRedisCache = g.createClientRedisCache() 115 | g.Cache = myRedisCache 116 | redisPool = myRedisCache.Conn 117 | } 118 | 119 | if os.Getenv("CACHE") == "badger" { 120 | myBadgerCache = g.createClientBadgerCache() 121 | g.Cache = myBadgerCache 122 | badgerConn = myBadgerCache.Conn 123 | 124 | _, err = g.Scheduler.AddFunc("@daily", func() { 125 | _ = myBadgerCache.Conn.RunValueLogGC(0.7) 126 | }) 127 | if err != nil { 128 | return err 129 | } 130 | } 131 | 132 | g.InfoLog = infoLog 133 | g.ErrorLog = errorLog 134 | g.Debug, _ = strconv.ParseBool(os.Getenv("DEBUG")) 135 | g.Version = version 136 | g.RootPath = rootPath 137 | g.Mail = g.createMailer() 138 | g.Routes = g.routes().(*chi.Mux) 139 | 140 | g.config = config{ 141 | port: os.Getenv("PORT"), 142 | renderer: os.Getenv("RENDERER"), 143 | cookie: cookieConfig{ 144 | name: os.Getenv("COOKIE_NAME"), 145 | lifetime: os.Getenv("COOKIE_LIFETIME"), 146 | persist: os.Getenv("COOKIE_PERSISTS"), 147 | secure: os.Getenv("COOKIE_SECURE"), 148 | domain: os.Getenv("COOKIE_DOMAIN"), 149 | }, 150 | sessionType: os.Getenv("SESSION_TYPE"), 151 | database: databaseConfig{ 152 | database: os.Getenv("DATABASE_TYPE"), 153 | dsn: g.BuildDSN(), 154 | }, 155 | redis: redisConfig{ 156 | host: os.Getenv("REDIS_HOST"), 157 | password: os.Getenv("REDIS_PASSWORD"), 158 | prefix: os.Getenv("REDIS_PREFIX"), 159 | }, 160 | } 161 | 162 | secure := true 163 | if strings.ToLower(os.Getenv("SECURE")) == "false" { 164 | secure = false 165 | } 166 | 167 | g.Server = Server{ 168 | ServerName: os.Getenv("SERVER_NAME"), 169 | Port: os.Getenv("PORT"), 170 | Secure: secure, 171 | URL: os.Getenv("APP_URL"), 172 | } 173 | 174 | // create session 175 | 176 | sess := session.Session{ 177 | CookieLifetime: g.config.cookie.lifetime, 178 | CookiePersist: g.config.cookie.persist, 179 | CookieName: g.config.cookie.name, 180 | SessionType: g.config.sessionType, 181 | CookieDomain: g.config.cookie.domain, 182 | } 183 | 184 | switch g.config.sessionType { 185 | case "redis": 186 | sess.RedisPool = myRedisCache.Conn 187 | case "mysql", "postgres", "mariadb", "postgresql": 188 | sess.DBPool = g.DB.Pool 189 | } 190 | 191 | g.Session = sess.InitSession() 192 | g.EncryptionKey = os.Getenv("KEY") 193 | 194 | if g.Debug { 195 | var views = jet.NewSet( 196 | jet.NewOSFileSystemLoader(fmt.Sprintf("%s/views", rootPath)), 197 | jet.InDevelopmentMode(), 198 | ) 199 | g.JetViews = views 200 | } else { 201 | var views = jet.NewSet( 202 | jet.NewOSFileSystemLoader(fmt.Sprintf("%s/views", rootPath)), 203 | ) 204 | g.JetViews = views 205 | } 206 | 207 | g.createRenderer() 208 | go g.Mail.ListenForMail() 209 | 210 | return nil 211 | } 212 | 213 | // Init creates necessary folders for our Ghostly application 214 | func (g *Ghostly) Init(p initPaths) error { 215 | root := p.rootPath 216 | for _, path := range p.folderNames { 217 | // create folder if it doesn't exist 218 | err := g.CreateDirIfNotExist(root + "/" + path) 219 | if err != nil { 220 | return err 221 | } 222 | } 223 | return nil 224 | } 225 | 226 | // ListenAndServe starts the web server 227 | func (g *Ghostly) ListenAndServe() { 228 | srv := &http.Server{ 229 | Addr: fmt.Sprintf(":%s", os.Getenv("PORT")), 230 | ErrorLog: g.ErrorLog, 231 | Handler: g.Routes, 232 | IdleTimeout: 30 * time.Second, 233 | ReadTimeout: 30 * time.Second, 234 | WriteTimeout: 600 * time.Second, 235 | } 236 | 237 | if g.DB.Pool != nil { 238 | defer g.DB.Pool.Close() 239 | } 240 | 241 | if redisPool != nil { 242 | defer redisPool.Close() 243 | } 244 | 245 | if badgerConn != nil { 246 | defer badgerConn.Close() 247 | } 248 | 249 | g.InfoLog.Printf("Listening on port %s", os.Getenv("PORT")) 250 | err := srv.ListenAndServe() 251 | g.ErrorLog.Fatal(err) 252 | } 253 | 254 | func (g *Ghostly) checkDotEnv(path string) error { 255 | err := g.CreateFileIfNotExists(fmt.Sprintf("%s/.env", path)) 256 | if err != nil { 257 | return err 258 | } 259 | return nil 260 | } 261 | 262 | func (g *Ghostly) startLoggers() (*log.Logger, *log.Logger) { 263 | var infoLog *log.Logger 264 | var errorLog *log.Logger 265 | 266 | infoLog = log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime) 267 | errorLog = log.New(os.Stdout, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile) 268 | 269 | return infoLog, errorLog 270 | } 271 | 272 | func (g *Ghostly) createRenderer() { 273 | myRenderer := render.Render{ 274 | Renderer: g.config.renderer, 275 | RootPath: g.RootPath, 276 | Port: g.config.port, 277 | JetViews: g.JetViews, 278 | Session: g.Session, 279 | } 280 | g.Render = &myRenderer 281 | } 282 | 283 | func (g *Ghostly) createMailer() mailer.Mail { 284 | port, _ := strconv.Atoi(os.Getenv("SMTP_PORT")) 285 | m := mailer.Mail{ 286 | Domain: os.Getenv("MAIL_DOMAIN"), 287 | Templates: g.RootPath + "/mail", 288 | Host: os.Getenv("SMTP_HOST"), 289 | Port: port, 290 | Username: os.Getenv("SMTP_USERNAME"), 291 | Password: os.Getenv("SMTP_PASSWORD"), 292 | Encryption: os.Getenv("SMTP_ENCRYPTION"), 293 | FromName: os.Getenv("FROM_NAME"), 294 | FromAddress: os.Getenv("FROM_ADDRESS"), 295 | Jobs: make(chan mailer.Message, 20), 296 | Results: make(chan mailer.Result, 20), 297 | API: os.Getenv("MAILER_API"), 298 | APIKey: os.Getenv("MAILER_KEY"), 299 | APIUrl: os.Getenv("MAILER_URL"), 300 | } 301 | return m 302 | } 303 | 304 | func (g *Ghostly) createClientRedisCache() *cache.RedisCache { 305 | cacheClient := cache.RedisCache{ 306 | Conn: g.createRedisPool(), 307 | Prefix: g.config.redis.prefix, 308 | } 309 | return &cacheClient 310 | } 311 | 312 | func (g *Ghostly) createClientBadgerCache() *cache.BadgerCache { 313 | cacheClient := cache.BadgerCache{ 314 | Conn: g.createBadgerConn(), 315 | } 316 | return &cacheClient 317 | } 318 | 319 | func (g *Ghostly) createRedisPool() *redis.Pool { 320 | return &redis.Pool{ 321 | MaxIdle: 50, 322 | MaxActive: 10000, 323 | IdleTimeout: 240 * time.Second, 324 | Dial: func() (redis.Conn, error) { 325 | return redis.Dial("tcp", 326 | g.config.redis.host, 327 | redis.DialPassword(g.config.redis.password)) 328 | }, 329 | 330 | TestOnBorrow: func(conn redis.Conn, t time.Time) error { 331 | _, err := conn.Do("PING") 332 | return err 333 | }, 334 | } 335 | } 336 | 337 | func (g *Ghostly) createBadgerConn() *badger.DB { 338 | db, err := badger.Open(badger.DefaultOptions(g.RootPath + "/tmp/badger")) 339 | if err != nil { 340 | return nil 341 | } 342 | return db 343 | } 344 | 345 | // BuildDSN builds the datasource name for our database, and returns it as a string 346 | func (g *Ghostly) BuildDSN() string { 347 | var dsn string 348 | 349 | switch os.Getenv("DATABASE_TYPE") { 350 | case "postgres", "postgresql": 351 | dsn = fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=%s timezone=UTC connect_timeout=5", 352 | os.Getenv("DATABASE_HOST"), 353 | os.Getenv("DATABASE_PORT"), 354 | os.Getenv("DATABASE_USER"), 355 | os.Getenv("DATABASE_NAME"), 356 | os.Getenv("DATABASE_SSL_MODE")) 357 | 358 | // we check to see if a database password has been supplied, since including "password=" with nothing 359 | // after it sometimes causes postgres to fail to allow a connection. 360 | if os.Getenv("DATABASE_PASS") != "" { 361 | dsn = fmt.Sprintf("%s password=%s", dsn, os.Getenv("DATABASE_PASS")) 362 | } 363 | 364 | case "mysql", "mariadb": 365 | dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?collation=utf8_unicode_ci&timeout=5s&parseTime=true&tls=%s&readTimeout=5s", 366 | os.Getenv("DATABASE_USER"), 367 | os.Getenv("DATABASE_PASS"), 368 | os.Getenv("DATABASE_HOST"), 369 | os.Getenv("DATABASE_PORT"), 370 | os.Getenv("DATABASE_NAME"), 371 | os.Getenv("DATABASE_SSL_MODE")) 372 | 373 | default: 374 | 375 | } 376 | 377 | return dsn 378 | } 379 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dominic-wassef/ghostly 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/CloudyKit/jet/v6 v6.1.0 7 | github.com/ainsleyclark/go-mail v1.0.3 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/bwmarrin/go-alone v0.0.0-20190806015146-742bb55d1631 15 | github.com/dgraph-io/badger/v3 v3.2103.1 16 | github.com/fatih/color v1.12.0 17 | github.com/gertd/go-pluralize v0.1.7 18 | github.com/go-chi/chi/v5 v5.0.4 19 | github.com/go-git/go-git/v5 v5.4.2 20 | github.com/go-sql-driver/mysql v1.5.0 21 | github.com/golang-migrate/migrate/v4 v4.14.1 22 | github.com/gomodule/redigo v1.8.5 23 | github.com/iancoleman/strcase v0.2.0 24 | github.com/jackc/pgconn v1.10.0 25 | github.com/jackc/pgx/v4 v4.13.0 26 | github.com/joho/godotenv v1.3.0 27 | github.com/justinas/nosurf v1.1.1 28 | github.com/minio/minio-go/v7 v7.0.43 29 | github.com/ory/dockertest/v3 v3.8.0 30 | github.com/robfig/cron/v3 v3.0.1 31 | github.com/vanng822/go-premailer v1.20.1 32 | github.com/xhit/go-simple-mail/v2 v2.10.0 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/SparkPost/gosparkpost v0.2.0 // indirect 43 | github.com/acomagu/bufpipe v1.0.3 // indirect 44 | github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect 45 | github.com/andybalholm/cascadia v1.1.0 // indirect 46 | github.com/cenkalti/backoff/v4 v4.1.1 // indirect 47 | github.com/cespare/xxhash v1.1.0 // indirect 48 | github.com/cespare/xxhash/v2 v2.1.1 // indirect 49 | github.com/containerd/continuity v0.2.0 // indirect 50 | github.com/dgraph-io/ristretto v0.1.0 // indirect 51 | github.com/docker/cli v20.10.8+incompatible // indirect 52 | github.com/docker/docker v20.10.7+incompatible // indirect 53 | github.com/docker/go-connections v0.4.0 // indirect 54 | github.com/docker/go-units v0.4.0 // indirect 55 | github.com/dustin/go-humanize v1.0.0 // indirect 56 | github.com/emirpasic/gods v1.12.0 // indirect 57 | github.com/gabriel-vasile/mimetype v1.3.1 // indirect 58 | github.com/go-git/gcfg v1.5.0 // indirect 59 | github.com/go-git/go-billy/v5 v5.3.1 // indirect 60 | github.com/gogo/protobuf v1.3.2 // indirect 61 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect 62 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect 63 | github.com/golang/protobuf v1.5.0 // indirect 64 | github.com/golang/snappy v0.0.3 // indirect 65 | github.com/google/flatbuffers v1.12.0 // indirect 66 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 67 | github.com/google/uuid v1.3.0 // indirect 68 | github.com/gorilla/css v1.0.0 // indirect 69 | github.com/gorilla/mux v1.8.0 // indirect 70 | github.com/hashicorp/errwrap v1.0.0 // indirect 71 | github.com/hashicorp/go-multierror v1.1.0 // indirect 72 | github.com/imdario/mergo v0.3.12 // indirect 73 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 74 | github.com/jackc/pgio v1.0.0 // indirect 75 | github.com/jackc/pgpassfile v1.0.0 // indirect 76 | github.com/jackc/pgproto3/v2 v2.1.1 // indirect 77 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect 78 | github.com/jackc/pgtype v1.8.1 // indirect 79 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 80 | github.com/json-iterator/go v1.1.12 // indirect 81 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect 82 | github.com/klauspost/compress v1.15.9 // indirect 83 | github.com/klauspost/cpuid/v2 v2.1.0 // indirect 84 | github.com/lib/pq v1.10.2 // indirect 85 | github.com/mailgun/mailgun-go/v4 v4.5.3 // indirect 86 | github.com/mattn/go-colorable v0.1.8 // indirect 87 | github.com/mattn/go-isatty v0.0.12 // indirect 88 | github.com/minio/md5-simd v1.1.2 // indirect 89 | github.com/minio/sha256-simd v1.0.0 // indirect 90 | github.com/mitchellh/go-homedir v1.1.0 // indirect 91 | github.com/mitchellh/mapstructure v1.4.1 // indirect 92 | github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect 93 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 94 | github.com/modern-go/reflect2 v1.0.2 // indirect 95 | github.com/opencontainers/go-digest v1.0.0 // indirect 96 | github.com/opencontainers/image-spec v1.0.1 // indirect 97 | github.com/opencontainers/runc v1.0.2 // indirect 98 | github.com/pkg/errors v0.9.1 // indirect 99 | github.com/rs/xid v1.4.0 // indirect 100 | github.com/sendgrid/rest v2.6.5+incompatible // indirect 101 | github.com/sendgrid/sendgrid-go v3.10.1+incompatible // indirect 102 | github.com/sergi/go-diff v1.1.0 // indirect 103 | github.com/sirupsen/logrus v1.9.0 // indirect 104 | github.com/vanng822/css v1.0.1 // indirect 105 | github.com/xanzy/ssh-agent v0.3.0 // indirect 106 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect 107 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 108 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 109 | github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da // indirect 110 | go.opencensus.io v0.22.5 // indirect 111 | golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect 112 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect 113 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect 114 | golang.org/x/text v0.3.7 // indirect 115 | google.golang.org/protobuf v1.26.0 // indirect 116 | gopkg.in/ini.v1 v1.66.6 // indirect 117 | gopkg.in/warnings.v0 v0.1.2 // indirect 118 | gopkg.in/yaml.v2 v2.3.0 // indirect 119 | ) 120 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package ghostly 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "io" 9 | "os" 10 | ) 11 | 12 | const ( 13 | randomString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0987654321_+" 14 | ) 15 | 16 | // RandomString generates a random string length n from values in the const randomString 17 | func (g *Ghostly) RandomString(n int) string { 18 | s, r := make([]rune, n), []rune(randomString) 19 | 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 a new directory if it does not exist 29 | func (g *Ghostly) 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 | 38 | return nil 39 | } 40 | 41 | // CreateFileIfNotExists creates a new file at path if it does not exist 42 | func (g *Ghostly) CreateFileIfNotExists(path string) error { 43 | var _, err = os.Stat(path) 44 | if os.IsNotExist(err) { 45 | var file, err = os.Create(path) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | defer func(file *os.File) { 51 | _ = file.Close() 52 | }(file) 53 | } 54 | return nil 55 | } 56 | 57 | type Encryption struct { 58 | Key []byte 59 | } 60 | 61 | func (e *Encryption) Encrypt(text string) (string, error) { 62 | plaintext := []byte(text) 63 | 64 | block, err := aes.NewCipher(e.Key) 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | ciphertext := make([]byte, aes.BlockSize+len(plaintext)) 70 | iv := ciphertext[:aes.BlockSize] 71 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 72 | return "", err 73 | } 74 | 75 | stream := cipher.NewCFBEncrypter(block, iv) 76 | stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext) 77 | 78 | return base64.URLEncoding.EncodeToString(ciphertext), nil 79 | } 80 | 81 | func (e *Encryption) Decrypt(cryptoText string) (string, error) { 82 | ciphertext, _ := base64.URLEncoding.DecodeString(cryptoText) 83 | 84 | block, err := aes.NewCipher(e.Key) 85 | if err != nil { 86 | return "", err 87 | } 88 | 89 | if len(ciphertext) < aes.BlockSize { 90 | return "", err 91 | } 92 | 93 | iv := ciphertext[:aes.BlockSize] 94 | ciphertext = ciphertext[aes.BlockSize:] 95 | 96 | stream := cipher.NewCFBDecrypter(block, iv) 97 | stream.XORKeyStream(ciphertext, ciphertext) 98 | 99 | return string(ciphertext), nil 100 | } 101 | -------------------------------------------------------------------------------- /mailer/mail.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "io/ioutil" 8 | "path/filepath" 9 | "time" 10 | 11 | apimail "github.com/ainsleyclark/go-mail" 12 | "github.com/vanng822/go-premailer/premailer" 13 | mail "github.com/xhit/go-simple-mail/v2" 14 | ) 15 | 16 | // Mail holds the information necessary to connect to an SMTP server 17 | type Mail struct { 18 | Domain string 19 | Templates string 20 | Host string 21 | Port int 22 | Username string 23 | Password string 24 | Encryption string 25 | FromAddress string 26 | FromName string 27 | Jobs chan Message 28 | Results chan Result 29 | API string 30 | APIKey string 31 | APIUrl string 32 | } 33 | 34 | // Message is the type for an email message 35 | type Message struct { 36 | From string 37 | FromName string 38 | To string 39 | Subject string 40 | Template string 41 | Attachments []string 42 | Data interface{} 43 | } 44 | 45 | // Result contains information regarding the status of the sent email message 46 | type Result struct { 47 | Success bool 48 | Error error 49 | } 50 | 51 | // ListenForMail listens to the mail channel and sends mail 52 | // when it receives a payload. It runs continually in the background, 53 | // and sends error/success messages back on the Results channel. 54 | // Note that if api and api key are set, it will prefer using 55 | // an api to send mail 56 | func (m *Mail) ListenForMail() { 57 | for { 58 | msg := <-m.Jobs 59 | err := m.Send(msg) 60 | if err != nil { 61 | m.Results <- Result{false, err} 62 | } else { 63 | m.Results <- Result{true, nil} 64 | } 65 | } 66 | } 67 | 68 | // Send sends an email message using correct method. If API values are set, 69 | // it will send using the appropriate api; otherwise, it sends via smtp 70 | func (m *Mail) Send(msg Message) error { 71 | if len(m.API) > 0 && len(m.APIKey) > 0 && len(m.APIUrl) > 0 && m.API != "smtp" { 72 | return m.ChooseAPI(msg) 73 | } 74 | return m.SendSMTPMessage(msg) 75 | } 76 | 77 | // ChooseAPI chooses api to use (specified in .env) 78 | func (m *Mail) ChooseAPI(msg Message) error { 79 | switch m.API { 80 | case "mailgun", "sparkpost", "sendgrid": 81 | return m.SendUsingAPI(msg, m.API) 82 | default: 83 | return fmt.Errorf("unknown api %s; only mailgun, sparkpost or sendgrid accepted", m.API) 84 | } 85 | } 86 | 87 | // SendUsingAPI sends a message using the appropriate API. It can be called directly, if necessary. 88 | // transport can be one of sparkpost, sendgrid, or mailgun 89 | func (m *Mail) SendUsingAPI(msg Message, transport string) error { 90 | if msg.From == "" { 91 | msg.From = m.FromAddress 92 | } 93 | 94 | if msg.FromName == "" { 95 | msg.FromName = m.FromName 96 | } 97 | 98 | cfg := apimail.Config{ 99 | URL: m.APIUrl, 100 | APIKey: m.APIKey, 101 | Domain: m.Domain, 102 | FromAddress: msg.From, 103 | FromName: msg.FromName, 104 | } 105 | 106 | driver, err := apimail.NewClient(transport, cfg) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | formattedMessage, err := m.buildHTMLMessage(msg) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | plainMessage, err := m.buildPlainTextMessage(msg) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | tx := &apimail.Transmission{ 122 | Recipients: []string{msg.To}, 123 | Subject: msg.Subject, 124 | HTML: formattedMessage, 125 | PlainText: plainMessage, 126 | } 127 | 128 | // add attachments 129 | err = m.addAPIAttachments(msg, tx) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | _, err = driver.Send(tx) 135 | if err != nil { 136 | return err 137 | } 138 | 139 | return nil 140 | } 141 | 142 | // addAPIAttachments adds attachments, if any, to mail being sent via api 143 | func (m *Mail) addAPIAttachments(msg Message, tx *apimail.Transmission) error { 144 | if len(msg.Attachments) > 0 { 145 | var attachments []apimail.Attachment 146 | 147 | for _, x := range msg.Attachments { 148 | var attach apimail.Attachment 149 | content, err := ioutil.ReadFile(x) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | fileName := filepath.Base(x) 155 | attach.Bytes = content 156 | attach.Filename = fileName 157 | attachments = append(attachments, attach) 158 | } 159 | 160 | tx.Attachments = attachments 161 | } 162 | 163 | return nil 164 | } 165 | 166 | // SendSMTPMessage builds and sends an email message using SMTP. This is called by ListenForMail, 167 | // and can also be called directly when necessary 168 | func (m *Mail) SendSMTPMessage(msg Message) error { 169 | formattedMessage, err := m.buildHTMLMessage(msg) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | plainMessage, err := m.buildPlainTextMessage(msg) 175 | if err != nil { 176 | return err 177 | } 178 | 179 | server := mail.NewSMTPClient() 180 | server.Host = m.Host 181 | server.Port = m.Port 182 | server.Username = m.Username 183 | server.Password = m.Password 184 | server.Encryption = m.getEncryption(m.Encryption) 185 | server.KeepAlive = false 186 | server.ConnectTimeout = 10 * time.Second 187 | server.SendTimeout = 10 * time.Second 188 | 189 | smtpClient, err := server.Connect() 190 | if err != nil { 191 | return err 192 | } 193 | 194 | email := mail.NewMSG() 195 | email.SetFrom(msg.From). 196 | AddTo(msg.To). 197 | SetSubject(msg.Subject) 198 | 199 | email.SetBody(mail.TextHTML, formattedMessage) 200 | email.AddAlternative(mail.TextPlain, plainMessage) 201 | 202 | if len(msg.Attachments) > 0 { 203 | for _, x := range msg.Attachments { 204 | email.AddAttachment(x) 205 | } 206 | } 207 | 208 | err = email.Send(smtpClient) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | return nil 214 | } 215 | 216 | // getEncryption returns the appropriate encryption type based on a string value 217 | func (m *Mail) getEncryption(e string) mail.Encryption { 218 | switch e { 219 | case "tls": 220 | return mail.EncryptionSTARTTLS 221 | case "ssl": 222 | return mail.EncryptionSSL 223 | case "none": 224 | return mail.EncryptionNone 225 | default: 226 | return mail.EncryptionSTARTTLS 227 | } 228 | } 229 | 230 | // buildHTMLMessage creates the html version of the message 231 | func (m *Mail) buildHTMLMessage(msg Message) (string, error) { 232 | templateToRender := fmt.Sprintf("%s/%s.html.tmpl", m.Templates, msg.Template) 233 | 234 | t, err := template.New("email-html").ParseFiles(templateToRender) 235 | if err != nil { 236 | return "", err 237 | } 238 | 239 | var tpl bytes.Buffer 240 | if err = t.ExecuteTemplate(&tpl, "body", msg.Data); err != nil { 241 | return "", err 242 | } 243 | 244 | formattedMessage := tpl.String() 245 | formattedMessage, err = m.inlineCSS(formattedMessage) 246 | if err != nil { 247 | return "", err 248 | } 249 | 250 | return formattedMessage, nil 251 | } 252 | 253 | // buildPlainTextMessage creates the plaintext version of the message 254 | func (m *Mail) buildPlainTextMessage(msg Message) (string, error) { 255 | templateToRender := fmt.Sprintf("%s/%s.plain.tmpl", m.Templates, msg.Template) 256 | 257 | t, err := template.New("email-html").ParseFiles(templateToRender) 258 | if err != nil { 259 | return "", err 260 | } 261 | 262 | var tpl bytes.Buffer 263 | if err = t.ExecuteTemplate(&tpl, "body", msg.Data); err != nil { 264 | return "", err 265 | } 266 | 267 | plainMessage := tpl.String() 268 | 269 | return plainMessage, nil 270 | } 271 | 272 | // inlineCSS takes html input as a string, and inlines css where possible 273 | func (m *Mail) inlineCSS(s string) (string, error) { 274 | options := premailer.Options{ 275 | RemoveClasses: false, 276 | CssToAttributes: false, 277 | KeepBangImportant: true, 278 | } 279 | 280 | prem, err := premailer.NewPremailerFromString(s, &options) 281 | if err != nil { 282 | return "", err 283 | } 284 | 285 | html, err := prem.Transform() 286 | if err != nil { 287 | return "", err 288 | } 289 | 290 | return html, nil 291 | } 292 | -------------------------------------------------------------------------------- /mailer/mail_test.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | 9 | func TestMail_SendSMTPMessage(t *testing.T) { 10 | msg := Message{ 11 | From: "me@here.com", 12 | FromName: "Joe", 13 | To: "you@there.com", 14 | Subject: "test", 15 | Template: "test", 16 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 17 | } 18 | 19 | err := mailer.SendSMTPMessage(msg) 20 | if err != nil { 21 | t.Error(err) 22 | } 23 | } 24 | 25 | func TestMail_SendUsingChan(t *testing.T) { 26 | msg := Message{ 27 | From: "me@here.com", 28 | FromName: "Joe", 29 | To: "you@there.com", 30 | Subject: "test", 31 | Template: "test", 32 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 33 | } 34 | 35 | mailer.Jobs <-msg 36 | res := <-mailer.Results 37 | if res.Error != nil { 38 | t.Error(errors.New("failed to send over channel")) 39 | } 40 | 41 | msg.To = "not_an_email_address" 42 | mailer.Jobs <- msg 43 | res = <-mailer.Results 44 | if res.Error == nil { 45 | t.Error(errors.New("no error received with invalid to address")) 46 | } 47 | } 48 | 49 | func TestMail_SendUsingAPI(t *testing.T) { 50 | msg := Message{ 51 | To: "you@there.com", 52 | Subject: "test", 53 | Template: "test", 54 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 55 | } 56 | 57 | mailer.API = "unknown" 58 | mailer.APIKey = "abc123" 59 | mailer.APIUrl = "https://www.fake.com" 60 | 61 | err := mailer.SendUsingAPI(msg, "unknown") 62 | if err == nil { 63 | t.Error(err) 64 | } 65 | mailer.API = "" 66 | mailer.APIKey = "" 67 | mailer.APIUrl = "" 68 | } 69 | 70 | func TestMail_buildHTMLMessage(t *testing.T) { 71 | msg := Message{ 72 | From: "me@here.com", 73 | FromName: "Joe", 74 | To: "you@there.com", 75 | Subject: "test", 76 | Template: "test", 77 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 78 | } 79 | 80 | _, err := mailer.buildHTMLMessage(msg) 81 | if err != nil { 82 | t.Error(err) 83 | } 84 | } 85 | 86 | func TestMail_buildPlainMessage(t *testing.T) { 87 | msg := Message{ 88 | From: "me@here.com", 89 | FromName: "Joe", 90 | To: "you@there.com", 91 | Subject: "test", 92 | Template: "test", 93 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 94 | } 95 | 96 | _, err := mailer.buildPlainTextMessage(msg) 97 | if err != nil { 98 | t.Error(err) 99 | } 100 | } 101 | 102 | func TestMail_send(t *testing.T) { 103 | msg := Message{ 104 | From: "me@here.com", 105 | FromName: "Joe", 106 | To: "you@there.com", 107 | Subject: "test", 108 | Template: "test", 109 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 110 | } 111 | 112 | err := mailer.Send(msg) 113 | if err != nil { 114 | t.Error(err) 115 | } 116 | 117 | mailer.API = "unknown" 118 | mailer.APIKey = "abc123" 119 | mailer.APIUrl = "https://www.fake.com" 120 | 121 | err = mailer.Send(msg) 122 | if err == nil { 123 | t.Error("did not not get an error when we should have") 124 | } 125 | 126 | mailer.API = "" 127 | mailer.APIKey = "" 128 | mailer.APIUrl = "" 129 | } 130 | 131 | func TestMail_ChooseAPI(t *testing.T) { 132 | msg := Message{ 133 | From: "me@here.com", 134 | FromName: "Joe", 135 | To: "you@there.com", 136 | Subject: "test", 137 | Template: "test", 138 | Attachments: []string{"./testdata/mail/test.html.tmpl"}, 139 | } 140 | mailer.API = "unknown" 141 | err := mailer.ChooseAPI(msg) 142 | if err == nil { 143 | t.Error(err) 144 | } 145 | } -------------------------------------------------------------------------------- /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 | 14 | var pool *dockertest.Pool 15 | var resource *dockertest.Resource 16 | 17 | var mailer = Mail{ 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(2 * time.Second) 59 | 60 | go mailer.ListenForMail() 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 | } -------------------------------------------------------------------------------- /mailer/testdata/mail/test.html.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
Enter your message content here...
12 | 13 | 14 | 15 | {{end}} -------------------------------------------------------------------------------- /mailer/testdata/mail/test.plain.tmpl: -------------------------------------------------------------------------------- 1 | {{define "body"}} 2 | Enter your message content here... 3 | {{end}} -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package ghostly 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/justinas/nosurf" 8 | ) 9 | 10 | func (g *Ghostly) SessionLoad(next http.Handler) http.Handler { 11 | return g.Session.LoadAndSave(next) 12 | } 13 | 14 | func (g *Ghostly) NoSurf(next http.Handler) http.Handler { 15 | csrfHandler := nosurf.New(next) 16 | secure, _ := strconv.ParseBool(g.config.cookie.secure) 17 | 18 | csrfHandler.ExemptGlob("/api/*") 19 | 20 | csrfHandler.SetBaseCookie(http.Cookie{ 21 | HttpOnly: true, 22 | Path: "/", 23 | Secure: secure, 24 | SameSite: http.SameSiteStrictMode, 25 | Domain: g.config.cookie.domain, 26 | }) 27 | 28 | return csrfHandler 29 | } 30 | -------------------------------------------------------------------------------- /migrations.go: -------------------------------------------------------------------------------- 1 | package ghostly 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/golang-migrate/migrate/v4" 7 | 8 | _ "github.com/go-sql-driver/mysql" 9 | _ "github.com/golang-migrate/migrate/v4/database/mysql" 10 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 11 | _ "github.com/golang-migrate/migrate/v4/source/file" 12 | ) 13 | 14 | func (g *Ghostly) MigrateUp(dsn string) error { 15 | m, err := migrate.New("file://"+g.RootPath+"/migrations", dsn) 16 | if err != nil { 17 | return err 18 | } 19 | defer m.Close() 20 | 21 | if err := m.Up(); err != nil { 22 | log.Println("Error running migration:", err) 23 | return err 24 | } 25 | return nil 26 | } 27 | 28 | func (g *Ghostly) MigrateDownAll(dsn string) error { 29 | m, err := migrate.New("file://"+g.RootPath+"/migrations", dsn) 30 | if err != nil { 31 | return err 32 | } 33 | defer m.Close() 34 | 35 | if err := m.Down(); err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func (g *Ghostly) Steps(n int, dsn string) error { 43 | m, err := migrate.New("file://"+g.RootPath+"/migrations", dsn) 44 | if err != nil { 45 | return err 46 | } 47 | defer m.Close() 48 | 49 | if err := m.Steps(n); err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func (g *Ghostly) MigrateForce(dsn string) error { 57 | m, err := migrate.New("file://"+g.RootPath+"/migrations", dsn) 58 | if err != nil { 59 | return err 60 | } 61 | defer m.Close() 62 | 63 | if err := m.Force(-1); err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /public/ghostly.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dominic-Wassef/ghostly/794ab1f0c1977c4a73ab8f6295d8c126ae44c057/public/ghostly.jpg -------------------------------------------------------------------------------- /render/render.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/CloudyKit/jet/v6" 12 | "github.com/alexedwards/scs/v2" 13 | "github.com/justinas/nosurf" 14 | ) 15 | 16 | type Render struct { 17 | Renderer string 18 | RootPath string 19 | Secure bool 20 | Port string 21 | ServerName string 22 | JetViews *jet.Set 23 | Session *scs.SessionManager 24 | } 25 | 26 | type TemplateData struct { 27 | IsAuthenticated bool 28 | IntMap map[string]int 29 | StringMap map[string]string 30 | FloatMap map[string]float32 31 | Data map[string]interface{} 32 | CSRFToken string 33 | Port string 34 | ServerName string 35 | Secure bool 36 | Error string 37 | Flash string 38 | } 39 | 40 | func (c *Render) defaultData(td *TemplateData, r *http.Request) *TemplateData { 41 | td.Secure = c.Secure 42 | td.ServerName = c.ServerName 43 | td.CSRFToken = nosurf.Token(r) 44 | td.Port = c.Port 45 | if c.Session.Exists(r.Context(), "userID") { 46 | td.IsAuthenticated = true 47 | } 48 | td.Error = c.Session.PopString(r.Context(), "error") 49 | td.Flash = c.Session.PopString(r.Context(), "flash") 50 | return td 51 | } 52 | 53 | func (c *Render) Page(w http.ResponseWriter, r *http.Request, view string, variables, data interface{}) error { 54 | switch strings.ToLower(c.Renderer) { 55 | case "go": 56 | return c.GoPage(w, r, view, data) 57 | case "jet": 58 | return c.JetPage(w, r, view, variables, data) 59 | default: 60 | 61 | } 62 | return errors.New("no rendering engine specified") 63 | } 64 | 65 | // GoPage renders a standard Go template 66 | func (c *Render) GoPage(w http.ResponseWriter, r *http.Request, view string, data interface{}) error { 67 | tmpl, err := template.ParseFiles(fmt.Sprintf("%s/views/%s.page.tmpl", c.RootPath, view)) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | td := &TemplateData{} 73 | if data != nil { 74 | td = data.(*TemplateData) 75 | } 76 | 77 | err = tmpl.Execute(w, &td) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | return nil 83 | } 84 | 85 | // JetPage renders a template using the Jet templating engine 86 | func (c *Render) JetPage(w http.ResponseWriter, r *http.Request, templateName string, variables, data interface{}) error { 87 | var vars jet.VarMap 88 | 89 | if variables == nil { 90 | vars = make(jet.VarMap) 91 | } else { 92 | vars = variables.(jet.VarMap) 93 | } 94 | 95 | td := &TemplateData{} 96 | if data != nil { 97 | td = data.(*TemplateData) 98 | } 99 | 100 | td = c.defaultData(td, r) 101 | 102 | t, err := c.JetViews.GetTemplate(fmt.Sprintf("%s.jet", templateName)) 103 | if err != nil { 104 | log.Println(err) 105 | return err 106 | } 107 | 108 | if err = t.Execute(w, vars, td); err != nil { 109 | log.Println(err) 110 | return err 111 | } 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /render/render_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | var pageData = []struct { 10 | name string 11 | renderer string 12 | template string 13 | errorExpected bool 14 | errorMessage string 15 | }{ 16 | {"go_page", "go", "home", false, "error rendering go template"}, 17 | {"go_page_no_template", "go", "no-file", true, "no error rendering non-existent go template, when one is expected"}, 18 | {"jet_page", "jet", "home", false, "error rendering jet template"}, 19 | {"jet_page_no_template", "jet", "no-file", true, "no error rendering non-existent jet template, when one is expected"}, 20 | {"invalid_render_engine", "foo", "home", true, "no error rendering with non-existent template engine"}, 21 | } 22 | 23 | func TestRender_Page(t *testing.T) { 24 | for _, e := range pageData{ 25 | r, err := http.NewRequest("GET", "/some-url", nil) 26 | if err != nil { 27 | t.Error(err) 28 | } 29 | 30 | w := httptest.NewRecorder() 31 | 32 | testRenderer.Renderer = e.renderer 33 | testRenderer.RootPath = "./testdata" 34 | 35 | err = testRenderer.Page(w, r, e.template, nil, nil) 36 | if e.errorExpected { 37 | if err == nil { 38 | t.Errorf("%s: %s", e.name, e.errorMessage) 39 | } 40 | } else { 41 | if err != nil { 42 | t.Errorf("%s: %s: %s", e.name, e.errorMessage, err.Error()) 43 | } 44 | } 45 | } 46 | } 47 | 48 | func TestRender_GoPage(t *testing.T) { 49 | w := httptest.NewRecorder() 50 | r, err := http.NewRequest("GET", "/url", nil) 51 | if err != nil { 52 | t.Error(err) 53 | } 54 | 55 | testRenderer.Renderer = "go" 56 | testRenderer.RootPath = "./testdata" 57 | 58 | err = testRenderer.Page(w, r, "home", nil, nil) 59 | if err != nil { 60 | t.Error("Error rendering page", err) 61 | } 62 | 63 | } 64 | 65 | func TestRender_JetPage(t *testing.T) { 66 | w := httptest.NewRecorder() 67 | r, err := http.NewRequest("GET", "/url", nil) 68 | if err != nil { 69 | t.Error(err) 70 | } 71 | 72 | testRenderer.Renderer = "jet" 73 | 74 | err = testRenderer.Page(w, r, "home", nil, nil) 75 | if err != nil { 76 | t.Error("Error rendering page", err) 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /render/setup_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/CloudyKit/jet/v6" 8 | ) 9 | 10 | var views = jet.NewSet( 11 | jet.NewOSFileSystemLoader("./testdata/views"), 12 | jet.InDevelopmentMode(), 13 | ) 14 | 15 | var testRenderer = Render{ 16 | Renderer: "", 17 | RootPath: "", 18 | JetViews: views, 19 | } 20 | 21 | func TestMain(m *testing.M) { 22 | os.Exit(m.Run()) 23 | } -------------------------------------------------------------------------------- /render/testdata/views/home.jet: -------------------------------------------------------------------------------- 1 | Hello, jet. -------------------------------------------------------------------------------- /render/testdata/views/home.page.tmpl: -------------------------------------------------------------------------------- 1 | Hello world. -------------------------------------------------------------------------------- /response-utils.go: -------------------------------------------------------------------------------- 1 | package ghostly 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 | func (g *Ghostly) ReadJSON(w http.ResponseWriter, r *http.Request, data interface{}) error { 15 | maxBytes := 1048576 // one megabyte 16 | r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) 17 | 18 | dec := json.NewDecoder(r.Body) 19 | err := dec.Decode(data) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | err = dec.Decode(&struct{}{}) 25 | if err != io.EOF { 26 | return errors.New("body must only have a single json value") 27 | } 28 | 29 | return nil 30 | } 31 | 32 | // WriteJSON writes json from arbitrary data 33 | func (g *Ghostly) WriteJSON(w http.ResponseWriter, status int, data interface{}, headers ...http.Header) error { 34 | out, err := json.MarshalIndent(data, "", "\t") 35 | if err != nil { 36 | return err 37 | } 38 | 39 | if len(headers) > 0 { 40 | for key, value := range headers[0] { 41 | w.Header()[key] = value 42 | } 43 | } 44 | 45 | w.Header().Set("Content-Type", "application/json") 46 | w.WriteHeader(status) 47 | _, err = w.Write(out) 48 | if err != nil { 49 | return err 50 | } 51 | return nil 52 | } 53 | 54 | // WriteXML writes xml from arbitrary data 55 | func (g *Ghostly) WriteXML(w http.ResponseWriter, status int, data interface{}, headers ...http.Header) error { 56 | out, err := xml.MarshalIndent(data, "", " ") 57 | if err != nil { 58 | return err 59 | } 60 | 61 | if len(headers) > 0 { 62 | for key, value := range headers[0] { 63 | w.Header()[key] = value 64 | } 65 | } 66 | 67 | w.Header().Set("Content-Type", "application/xml") 68 | w.WriteHeader(status) 69 | _, err = w.Write(out) 70 | if err != nil { 71 | return err 72 | } 73 | return nil 74 | } 75 | 76 | // DownloadFile downloads a file 77 | func (g *Ghostly) DownloadFile(w http.ResponseWriter, r *http.Request, pathToFile, fileName string) error { 78 | fp := path.Join(pathToFile, fileName) 79 | fileToServe := filepath.Clean(fp) 80 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; file=\"%s\"", fileName)) 81 | http.ServeFile(w, r, fileToServe) 82 | return nil 83 | } 84 | 85 | // Error404 returns page not found response 86 | func (g *Ghostly) Error404(w http.ResponseWriter, r *http.Request) { 87 | g.ErrorStatus(w, http.StatusNotFound) 88 | } 89 | 90 | // Error500 returns internal server error response 91 | func (g *Ghostly) Error500(w http.ResponseWriter, r *http.Request) { 92 | g.ErrorStatus(w, http.StatusInternalServerError) 93 | } 94 | 95 | // ErrorUnauthorized sends an unauthorized status (client is not known) 96 | func (g *Ghostly) ErrorUnauthorized(w http.ResponseWriter, r *http.Request) { 97 | g.ErrorStatus(w, http.StatusUnauthorized) 98 | } 99 | 100 | // ErrorForbidden returns a forbidden status message (client is known) 101 | func (g *Ghostly) ErrorForbidden(w http.ResponseWriter, r *http.Request) { 102 | g.ErrorStatus(w, http.StatusForbidden) 103 | } 104 | 105 | // ErrorStatus returns a response with the supplied http status 106 | func (g *Ghostly) ErrorStatus(w http.ResponseWriter, status int) { 107 | http.Error(w, http.StatusText(status), status) 108 | } 109 | -------------------------------------------------------------------------------- /routes.go: -------------------------------------------------------------------------------- 1 | package ghostly 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | "github.com/go-chi/chi/v5/middleware" 8 | ) 9 | 10 | func (g *Ghostly) routes() http.Handler { 11 | mux := chi.NewRouter() 12 | mux.Use(middleware.RequestID) 13 | mux.Use(middleware.RealIP) 14 | if g.Debug { 15 | mux.Use(middleware.Logger) 16 | } 17 | mux.Use(middleware.Recoverer) 18 | mux.Use(g.SessionLoad) 19 | mux.Use(g.NoSurf) 20 | 21 | return mux 22 | } 23 | 24 | // Routes are ghostly specific routes, which are mounted in the routes file 25 | // in Ghostly applications 26 | func Routes() http.Handler { 27 | r := chi.NewRouter() 28 | r.Get("/test-c", func(w http.ResponseWriter, r *http.Request) { 29 | w.Write([]byte("it works!")) 30 | }) 31 | return r 32 | } 33 | -------------------------------------------------------------------------------- /session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "database/sql" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/alexedwards/scs/mysqlstore" 11 | "github.com/alexedwards/scs/postgresstore" 12 | "github.com/alexedwards/scs/redisstore" 13 | "github.com/alexedwards/scs/v2" 14 | "github.com/gomodule/redigo/redis" 15 | ) 16 | 17 | type Session struct { 18 | CookieLifetime string 19 | CookiePersist string 20 | CookieName string 21 | CookieDomain string 22 | SessionType string 23 | CookieSecure string 24 | DBPool *sql.DB 25 | RedisPool *redis.Pool 26 | } 27 | 28 | func (c *Session) InitSession() *scs.SessionManager { 29 | var persist, secure bool 30 | 31 | // how long should sessions last? 32 | minutes, err := strconv.Atoi(c.CookieLifetime) 33 | if err != nil { 34 | minutes = 60 35 | } 36 | 37 | // should cookies persist? 38 | if strings.ToLower(c.CookiePersist) == "true" { 39 | persist = true 40 | } 41 | 42 | // must cookies be secure? 43 | if strings.ToLower(c.CookieSecure) == "true" { 44 | secure = true 45 | } 46 | 47 | // create session 48 | session := scs.New() 49 | session.Lifetime = time.Duration(minutes) * time.Minute 50 | session.Cookie.Persist = persist 51 | session.Cookie.Name = c.CookieName 52 | session.Cookie.Secure = secure 53 | session.Cookie.Domain = c.CookieDomain 54 | session.Cookie.SameSite = http.SameSiteLaxMode 55 | 56 | // which session store? 57 | switch strings.ToLower(c.SessionType) { 58 | case "redis": 59 | session.Store = redisstore.New(c.RedisPool) 60 | case "mysql", "mariadb": 61 | session.Store = mysqlstore.New(c.DBPool) 62 | case "postgres", "postgresql": 63 | session.Store = postgresstore.New(c.DBPool) 64 | default: 65 | // cookie 66 | } 67 | 68 | return session 69 | } 70 | -------------------------------------------------------------------------------- /session/session_test.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/alexedwards/scs/v2" 9 | ) 10 | 11 | func TestSession_InitSession(t *testing.T) { 12 | 13 | c := &Session{ 14 | CookieLifetime: "100", 15 | CookiePersist: "true", 16 | CookieName: "ghostly", 17 | CookieDomain: "localhost", 18 | SessionType: "cookie", 19 | } 20 | 21 | var sm *scs.SessionManager 22 | 23 | ses := c.InitSession() 24 | 25 | var sessKind reflect.Kind 26 | var sessType reflect.Type 27 | 28 | rv := reflect.ValueOf(ses) 29 | 30 | for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { 31 | fmt.Println("For loop:", rv.Kind(), rv.Type(), rv) 32 | sessKind = rv.Kind() 33 | sessType = rv.Type() 34 | 35 | rv = rv.Elem() 36 | } 37 | 38 | if !rv.IsValid() { 39 | t.Error("invalid type or kind; kind:", rv.Kind(), "type:", rv.Type()) 40 | } 41 | 42 | if sessKind != reflect.ValueOf(sm).Kind() { 43 | t.Error("wrong kind returned testing cookie session. Expected", reflect.ValueOf(sm).Kind(), "and got", sessKind) 44 | } 45 | 46 | if sessType != reflect.ValueOf(sm).Type() { 47 | t.Error("wrong type returned testing cookie session. Expected", reflect.ValueOf(sm).Type(), "and got", sessType) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /session/setup_test.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMain(m *testing.M) { 9 | 10 | os.Exit(m.Run()) 11 | } -------------------------------------------------------------------------------- /testfolder/test.go: -------------------------------------------------------------------------------- 1 | package testfolder 2 | 3 | import "net/http" 4 | 5 | func TestHandler(w http.ResponseWriter, r *http.Request) { 6 | w.Write([]byte("it works")) 7 | } 8 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package ghostly 2 | 3 | import "database/sql" 4 | 5 | // initPaths is used when initializing the application. It holds the root 6 | // path for the application, and a slice of strings with the names of 7 | // folders that the application expects to find. 8 | type initPaths struct { 9 | rootPath string 10 | folderNames []string 11 | } 12 | 13 | // cookieConfig holds cookie config values 14 | type cookieConfig struct { 15 | name string 16 | lifetime string 17 | persist string 18 | secure string 19 | domain string 20 | } 21 | 22 | type databaseConfig struct { 23 | dsn string 24 | database string 25 | } 26 | 27 | type Database struct { 28 | DataType string 29 | Pool *sql.DB 30 | } 31 | 32 | type redisConfig struct { 33 | host string 34 | password string 35 | prefix string 36 | } 37 | -------------------------------------------------------------------------------- /urlsigner/signer.go: -------------------------------------------------------------------------------- 1 | package urlsigner 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/bwmarrin/go-alone" 9 | ) 10 | 11 | type Signer struct { 12 | Secret []byte 13 | } 14 | 15 | func (s *Signer) GenerateTokenFromString(data string) string { 16 | var urlToSign string 17 | 18 | crypt := goalone.New(s.Secret, goalone.Timestamp) 19 | if strings.Contains(data, "?") { 20 | urlToSign = fmt.Sprintf("%s&hash=", data) 21 | } else { 22 | urlToSign = fmt.Sprintf("%s?hash=", data) 23 | } 24 | 25 | tokenBytes := crypt.Sign([]byte(urlToSign)) 26 | token := string(tokenBytes) 27 | 28 | return token 29 | } 30 | 31 | func (s *Signer) VerifyToken(token string) bool { 32 | crypt := goalone.New(s.Secret, goalone.Timestamp) 33 | _, err := crypt.Unsign([]byte(token)) 34 | if err != nil { 35 | return false 36 | } 37 | 38 | return true 39 | } 40 | 41 | func (s *Signer) Expired(token string, minutesUntilExpire int) bool { 42 | crypt := goalone.New(s.Secret, goalone.Timestamp) 43 | ts := crypt.Parse([]byte(token)) 44 | 45 | return time.Since(ts.Timestamp) > time.Duration(minutesUntilExpire)*time.Minute 46 | } -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package ghostly 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "runtime" 7 | "time" 8 | ) 9 | 10 | // LoadTime calculates function execution time. To use, add 11 | // defer g.LoadTime(time.Now()) to the function body 12 | func (g *Ghostly) LoadTime(start time.Time) { 13 | elapsed := time.Since(start) 14 | pc, _, _, _ := runtime.Caller(1) 15 | funcObj := runtime.FuncForPC(pc) 16 | runtimeFunc := regexp.MustCompile(`^.*\.(.*)$`) 17 | name := runtimeFunc.ReplaceAllString(funcObj.Name(), "$1") 18 | 19 | g.InfoLog.Println(fmt.Sprintf("Load Time: %s took %s", name, elapsed)) 20 | } 21 | -------------------------------------------------------------------------------- /validator.go: -------------------------------------------------------------------------------- 1 | package ghostly 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 (g *Ghostly) 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 | if x == "" { 38 | return false 39 | } 40 | return true 41 | } 42 | 43 | func (v *Validation) Required(r *http.Request, fields ...string) { 44 | for _, field := range fields { 45 | value := r.Form.Get(field) 46 | if strings.TrimSpace(value) == "" { 47 | v.AddError(field, "This field cannot be blank") 48 | } 49 | } 50 | } 51 | 52 | func (v *Validation) Check(ok bool, key, message string) { 53 | if !ok { 54 | v.AddError(key, message) 55 | } 56 | } 57 | 58 | func (v *Validation) IsEmail(field, value string) { 59 | if !govalidator.IsEmail(value) { 60 | v.AddError(field, "Invalid email address") 61 | } 62 | } 63 | 64 | func (v *Validation) IsInt(field, value string) { 65 | _, err := strconv.Atoi(value) 66 | if err != nil { 67 | v.AddError(field, "This field must be an integer") 68 | } 69 | } 70 | 71 | func (v *Validation) IsFloat(field, value string) { 72 | _, err := strconv.ParseFloat(value, 64) 73 | if err != nil { 74 | v.AddError(field, "This field must be a floating point number") 75 | } 76 | } 77 | 78 | func (v *Validation) IsDateISO(field, value string) { 79 | _, err := time.Parse("2006-01-02", value) 80 | if err != nil { 81 | v.AddError(field, "This field must be a date in the form of YYYY-MM-DD") 82 | } 83 | } 84 | 85 | func (v *Validation) NoSpaces(field, value string) { 86 | if govalidator.HasWhitespace(value) { 87 | v.AddError(field, "Spaces are not permitted") 88 | } 89 | } 90 | --------------------------------------------------------------------------------