Compare commits

..

5 Commits

Author SHA1 Message Date
01073aca78 latest
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2023-01-30 00:15:38 -08:00
49709ad3d2 latest
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2023-01-28 19:05:02 -08:00
3f49d528e7 builds and runs! not sure if it works, needs testing
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-11-28 06:00:44 +01:00
46b67f8a6e more progress
Some checks failed
continuous-integration/drone/push Build is failing
2022-11-28 05:33:52 +01:00
5fc22c36b0 getting closer
Some checks failed
continuous-integration/drone/push Build is failing
2022-11-28 05:09:23 +01:00
28 changed files with 467 additions and 288 deletions

View File

@ -5,7 +5,9 @@ import (
"git.eeqj.de/sneak/gohttpserver/internal/database" "git.eeqj.de/sneak/gohttpserver/internal/database"
"git.eeqj.de/sneak/gohttpserver/internal/globals" "git.eeqj.de/sneak/gohttpserver/internal/globals"
"git.eeqj.de/sneak/gohttpserver/internal/handlers" "git.eeqj.de/sneak/gohttpserver/internal/handlers"
"git.eeqj.de/sneak/gohttpserver/internal/healthcheck"
"git.eeqj.de/sneak/gohttpserver/internal/logger" "git.eeqj.de/sneak/gohttpserver/internal/logger"
"git.eeqj.de/sneak/gohttpserver/internal/middleware"
"git.eeqj.de/sneak/gohttpserver/internal/server" "git.eeqj.de/sneak/gohttpserver/internal/server"
"go.uber.org/fx" "go.uber.org/fx"
) )
@ -29,6 +31,8 @@ func main() {
handlers.New, handlers.New,
logger.New, logger.New,
server.New, server.New,
middleware.New,
healthcheck.New,
), ),
fx.Invoke(func(*server.Server) {}), fx.Invoke(func(*server.Server) {}),
).Run() ).Run()

5
go.mod
View File

@ -12,6 +12,8 @@ require (
github.com/rs/zerolog v1.28.0 github.com/rs/zerolog v1.28.0
github.com/slok/go-http-metrics v0.10.0 github.com/slok/go-http-metrics v0.10.0
github.com/spf13/viper v1.14.0 github.com/spf13/viper v1.14.0
go.uber.org/fx v1.18.2
google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e
) )
require ( require (
@ -37,11 +39,12 @@ require (
github.com/subosito/gotenv v1.4.1 // indirect github.com/subosito/gotenv v1.4.1 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/dig v1.15.0 // indirect go.uber.org/dig v1.15.0 // indirect
go.uber.org/fx v1.18.2 // indirect
go.uber.org/multierr v1.8.0 // indirect go.uber.org/multierr v1.8.0 // indirect
go.uber.org/zap v1.21.0 // indirect go.uber.org/zap v1.21.0 // indirect
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b // indirect
golang.org/x/sys v0.2.0 // indirect golang.org/x/sys v0.2.0 // indirect
golang.org/x/text v0.4.0 // indirect golang.org/x/text v0.4.0 // indirect
google.golang.org/grpc v1.50.1 // indirect
google.golang.org/protobuf v1.28.1 // indirect google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect

5
go.sum
View File

@ -380,6 +380,7 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b h1:tvrvnPFcdzp294diPnrdZZZ8XUt2Tyj7svb7X52iDuU= golang.org/x/net v0.0.0-20221014081412-f15817d10f9b h1:tvrvnPFcdzp294diPnrdZZZ8XUt2Tyj7svb7X52iDuU=
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -584,6 +585,8 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e h1:S9GbmC1iCgvbLyAokVCwiO6tVIrU9Y7c5oMx1V/ki/Y=
google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@ -600,6 +603,8 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.50.1 h1:DS/BukOZWp8s6p4Dt/tOaJaTQyPyOoCcrjroHuCeLzY=
google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

View File

@ -20,19 +20,23 @@ import (
) )
type ConfigParams struct { type ConfigParams struct {
Globals globals.Globals fx.In
Logger logger.Logger Globals *globals.Globals
Logger *logger.Logger
} }
type Config struct { type Config struct {
DBURL string DBURL string
Debug bool Debug bool
MaintenanceMode bool MaintenanceMode bool
DevelopmentMode bool
DevAdminUsername string
DevAdminPassword string
MetricsPassword string MetricsPassword string
MetricsUsername string MetricsUsername string
Port int Port int
SentryDSN string SentryDSN string
params ConfigParams params *ConfigParams
log *zerolog.Logger log *zerolog.Logger
} }
@ -51,6 +55,9 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
viper.SetDefault("DEBUG", "false") viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false") viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("DEVELOPMENT_MODE", "false")
viper.SetDefault("DEV_ADMIN_USERNAME", "")
viper.SetDefault("DEV_ADMIN_PASSWORD", "")
viper.SetDefault("PORT", "8080") viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", "") viper.SetDefault("DBURL", "")
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
@ -74,9 +81,18 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
Port: viper.GetInt("PORT"), Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
DevelopmentMode: viper.GetBool("DEVELOPMENT_MODE"),
DevAdminUsername: viper.GetString("DEV_ADMIN_USERNAME"),
DevAdminPassword: viper.GetString("DEV_ADMIN_PASSWORD"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
log: log, log: log,
params: &params,
}
if s.Debug {
params.Logger.EnableDebugLogging()
s.log = params.Logger.Get()
} }
return s, nil return s, nil

View File

@ -20,29 +20,32 @@ import (
type DatabaseParams struct { type DatabaseParams struct {
fx.In fx.In
Logger logger.Logger Logger *logger.Logger
Config config.Config Config *config.Config
} }
type Database struct { type Database struct {
URL string URL string
log *zerolog.Logger log *zerolog.Logger
params DatabaseParams params *DatabaseParams
} }
func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) { func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
s.log.Info().Msg("Database instantiated")
s := new(Database) s := new(Database)
s.params = params s.params = &params
s.log = params.Logger.Get() s.log = params.Logger.Get()
s.log.Info().Msg("Database instantiated")
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(ctx context.Context) error {
s.log.Info().Msg("Database OnStart Hook") s.log.Info().Msg("Database OnStart Hook")
// FIXME connect to db // FIXME connect to db
return nil
}, },
OnStop: func(ctx context.Context) error { OnStop: func(ctx context.Context) error {
// FIXME disconnect from db // FIXME disconnect from db
return nil
}, },
}) })
return s, nil return s, nil

View File

@ -1,30 +0,0 @@
package handlers
import (
"net/http"
"time"
)
func (s *Handlers) handleHealthCheck() http.HandlerFunc {
type response struct {
Status string `json:"status"`
Now string `json:"now"`
UptimeSeconds int64 `json:"uptime_seconds"`
UptimeHuman string `json:"uptime_human"`
Version string `json:"version"`
Appname string `json:"appname"`
Maintenance bool `json:"maintenance_mode"`
}
return func(w http.ResponseWriter, req *http.Request) {
resp := &response{
Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(s.uptime().Seconds()),
UptimeHuman: s.uptime().String(),
Maintenance: s.maintenance(),
Appname: s.appname,
Version: s.version,
}
s.respondJSON(w, req, resp, 200)
}
}

View File

@ -1,12 +0,0 @@
package handlers
import (
"fmt"
"net/http"
)
func (s *Handlers) HandleLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello login")
}
}

View File

@ -4,47 +4,43 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"time"
"git.eeqj.de/sneak/gohttpserver/internal/database"
"git.eeqj.de/sneak/gohttpserver/internal/globals" "git.eeqj.de/sneak/gohttpserver/internal/globals"
"git.eeqj.de/sneak/gohttpserver/internal/healthcheck"
"git.eeqj.de/sneak/gohttpserver/internal/logger"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.uber.org/fx" "go.uber.org/fx"
"google.golang.org/genproto/googleapis/spanner/admin/database/v1"
) )
type HandlersParams struct { type HandlersParams struct {
fx.In fx.In
Logger *zerolog.Logger Logger *logger.Logger
Globals globals.Globals Globals *globals.Globals
Database database.Database Database *database.Database
Healthcheck *healthcheck.Healthcheck
} }
type Handlers struct { type Handlers struct {
params HandlersParams params *HandlersParams
log *zerolog.Logger log *zerolog.Logger
hc *healthcheck.Healthcheck
} }
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
s := new(Handlers) s := new(Handlers)
s.params = params s.params = &params
s.log = params.Logger.Get() s.log = params.Logger.Get()
s.hc = params.Healthcheck
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(ctx context.Context) error {
// FIXME compile some templates here or something // FIXME compile some templates here or something
return nil
}, },
}) })
return s, nil return s, nil
} }
func (s *Handlers) HandleNow() http.HandlerFunc {
type response struct {
Now time.Time `json:"now"`
}
return func(w http.ResponseWriter, r *http.Request) {
s.respondJSON(w, r, &response{Now: time.Now()}, 200)
}
}
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) { func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
w.WriteHeader(status) w.WriteHeader(status)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@ -0,0 +1,12 @@
package handlers
import (
"net/http"
)
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
resp := s.hc.Healthcheck()
s.respondJSON(w, req, resp, 200)
}
}

View File

@ -1,19 +1,18 @@
package handlers package handlers
import ( import (
"html/template"
"net/http" "net/http"
"git.eeqj.de/sneak/gohttpserver/templates" "git.eeqj.de/sneak/gohttpserver/templates"
) )
func (s *Handlers) HandleIndex() http.HandlerFunc { func (s *Handlers) HandleIndex() http.HandlerFunc {
indexTemplate := template.Must(template.New("index").Parse(templates.MustString("index.html"))) t := templates.GetParsed()
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
err := indexTemplate.ExecuteTemplate(w, "index", nil) err := t.ExecuteTemplate(w, "index.html", nil)
if err != nil { if err != nil {
s.log.Println(err.Error()) s.log.Error().Err(err).Msg("")
http.Error(w, http.StatusText(500), 500) http.Error(w, http.StatusText(500), 500)
} }
} }

View File

@ -0,0 +1,19 @@
package handlers
import (
"net/http"
"git.eeqj.de/sneak/gohttpserver/templates"
)
func (s *Handlers) HandleLoginGET() http.HandlerFunc {
t := templates.GetParsed()
return func(w http.ResponseWriter, r *http.Request) {
err := t.ExecuteTemplate(w, "login.html", nil)
if err != nil {
s.log.Error().Err(err).Msg("")
http.Error(w, http.StatusText(500), 500)
}
}
}

View File

@ -0,0 +1,34 @@
package handlers
import (
"net/http"
"git.eeqj.de/sneak/gohttpserver/templates"
)
func (s *Handlers) HandleSignupGET() http.HandlerFunc {
t := templates.GetParsed()
return func(w http.ResponseWriter, r *http.Request) {
err := t.ExecuteTemplate(w, "signup.html", nil)
if err != nil {
s.log.Error().Err(err).Msg("")
http.Error(w, http.StatusText(500), 500)
}
}
}
func (s *Handlers) HandleSignupPOST() http.HandlerFunc {
t := templates.GetParsed()
return func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
err := t.ExecuteTemplate(w, "signup.html", nil)
if err != nil {
s.log.Error().Err(err).Msg("")
http.Error(w, http.StatusText(500), 500)
}
}
}

View File

@ -0,0 +1,71 @@
package healthcheck
import (
"context"
"time"
"git.eeqj.de/sneak/gohttpserver/internal/config"
"git.eeqj.de/sneak/gohttpserver/internal/database"
"git.eeqj.de/sneak/gohttpserver/internal/globals"
"git.eeqj.de/sneak/gohttpserver/internal/logger"
"github.com/rs/zerolog"
"go.uber.org/fx"
)
type HealthcheckParams struct {
fx.In
Globals *globals.Globals
Config *config.Config
Logger *logger.Logger
Database *database.Database
}
type Healthcheck struct {
StartupTime time.Time
log *zerolog.Logger
params *HealthcheckParams
}
func New(lc fx.Lifecycle, params HealthcheckParams) (*Healthcheck, error) {
s := new(Healthcheck)
s.params = &params
s.log = params.Logger.Get()
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
s.StartupTime = time.Now()
return nil
},
OnStop: func(ctx context.Context) error {
// FIXME do server shutdown here
return nil
},
})
return s, nil
}
type HealthcheckResponse struct {
Status string `json:"status"`
Now string `json:"now"`
UptimeSeconds int64 `json:"uptime_seconds"`
UptimeHuman string `json:"uptime_human"`
Version string `json:"version"`
Appname string `json:"appname"`
Maintenance bool `json:"maintenance_mode"`
}
func (s *Healthcheck) uptime() time.Duration {
return time.Since(s.StartupTime)
}
func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
resp := &HealthcheckResponse{
Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(s.uptime().Seconds()),
UptimeHuman: s.uptime().String(),
Appname: s.params.Globals.Appname,
Version: s.params.Globals.Version,
}
return resp
}

View File

@ -1,4 +1,4 @@
package server package logger
import ( import (
"io" "io"
@ -8,13 +8,11 @@ import (
"git.eeqj.de/sneak/gohttpserver/internal/globals" "git.eeqj.de/sneak/gohttpserver/internal/globals"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.uber.org/fx" "go.uber.org/fx"
"honnef.co/go/tools/config"
) )
type LoggerParams struct { type LoggerParams struct {
fx.In fx.In
Globals globals.Globals Globals *globals.Globals
Config config.Config
} }
type Logger struct { type Logger struct {
@ -78,12 +76,12 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
l.log = &logger l.log = &logger
// log.Logger = logger // log.Logger = logger
if l.params.Config.Debug { return l, nil
}
func (l *Logger) EnableDebugLogging() {
zerolog.SetGlobalLevel(zerolog.DebugLevel) zerolog.SetGlobalLevel(zerolog.DebugLevel)
l.log.Debug().Bool("debug", true).Send() l.log.Debug().Bool("debug", true).Send()
}
return l, nil
} }
func (l *Logger) Get() *zerolog.Logger { func (l *Logger) Get() *zerolog.Logger {

View File

@ -1,19 +1,43 @@
package server package middleware
import ( import (
"net" "net"
"net/http" "net/http"
"time" "time"
"git.eeqj.de/sneak/gohttpserver/internal/config"
"git.eeqj.de/sneak/gohttpserver/internal/globals"
"git.eeqj.de/sneak/gohttpserver/internal/logger"
basicauth "github.com/99designs/basicauth-go" basicauth "github.com/99designs/basicauth-go"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/rs/zerolog"
metrics "github.com/slok/go-http-metrics/metrics/prometheus" metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware" ghmm "github.com/slok/go-http-metrics/middleware"
"github.com/slok/go-http-metrics/middleware/std" "github.com/slok/go-http-metrics/middleware/std"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx"
) )
type MiddlewareParams struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
}
type Middleware struct {
log *zerolog.Logger
params *MiddlewareParams
}
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
s := new(Middleware)
s.params = &params
s.log = params.Logger.Get()
return s, nil
}
// the following is from // the following is from
// https://learning-cloud-native-go.github.io/docs/a6.adding_zerolog_logger/ // https://learning-cloud-native-go.github.io/docs/a6.adding_zerolog_logger/
@ -45,7 +69,7 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
// type Middleware func(http.Handler) http.Handler // type Middleware func(http.Handler) http.Handler
// this returns a Middleware that is designed to do every request through the // this returns a Middleware that is designed to do every request through the
// mux, note the signature: // mux, note the signature:
func (s *Server) LoggingMiddleware() func(http.Handler) http.Handler { func (s *Middleware) Logging() func(http.Handler) http.Handler {
// FIXME this should use https://github.com/google/go-cloud/blob/master/server/requestlog/requestlog.go // FIXME this should use https://github.com/google/go-cloud/blob/master/server/requestlog/requestlog.go
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -73,7 +97,7 @@ func (s *Server) LoggingMiddleware() func(http.Handler) http.Handler {
} }
} }
func (s *Server) CORSMiddleware() func(http.Handler) http.Handler { func (s *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ return cors.Handler(cors.Options{
// CHANGEME! these are defaults, change them to suit your needs or // CHANGEME! these are defaults, change them to suit your needs or
// read from environment/viper. // read from environment/viper.
@ -88,7 +112,7 @@ func (s *Server) CORSMiddleware() func(http.Handler) http.Handler {
}) })
} }
func (s *Server) AuthMiddleware() func(http.Handler) http.Handler { func (s *Middleware) Auth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CHANGEME you'll want to change this to do stuff. // CHANGEME you'll want to change this to do stuff.
@ -98,7 +122,7 @@ func (s *Server) AuthMiddleware() func(http.Handler) http.Handler {
} }
} }
func (s *Server) MetricsMiddleware() func(http.Handler) http.Handler { func (s *Middleware) Metrics() func(http.Handler) http.Handler {
mdlw := ghmm.New(ghmm.Config{ mdlw := ghmm.New(ghmm.Config{
Recorder: metrics.NewRecorder(metrics.Config{}), Recorder: metrics.NewRecorder(metrics.Config{}),
}) })
@ -107,7 +131,7 @@ func (s *Server) MetricsMiddleware() func(http.Handler) http.Handler {
} }
} }
func (s *Server) MetricsAuthMiddleware() func(http.Handler) http.Handler { func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
return basicauth.New( return basicauth.New(
"metrics", "metrics",
map[string][]string{ map[string][]string{

View File

@ -7,7 +7,7 @@ import (
) )
func (s *Server) serveUntilShutdown() { func (s *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(":%d", s.port) listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
s.httpServer = &http.Server{ s.httpServer = &http.Server{
Addr: listenAddr, Addr: listenAddr,
ReadTimeout: 10 * time.Second, ReadTimeout: 10 * time.Second,
@ -18,7 +18,7 @@ func (s *Server) serveUntilShutdown() {
// add routes // add routes
// this does any necessary setup in each handler // this does any necessary setup in each handler
s.routes() s.SetupRoutes()
s.log.Info().Str("listenaddr", listenAddr).Msg("http begin listen") s.log.Info().Str("listenaddr", listenAddr).Msg("http begin listen")
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {

View File

@ -23,16 +23,16 @@ func (s *Server) SetupRoutes() {
s.router.Use(middleware.Recoverer) s.router.Use(middleware.Recoverer)
s.router.Use(middleware.RequestID) s.router.Use(middleware.RequestID)
s.router.Use(s.LoggingMiddleware()) s.router.Use(s.mw.Logging())
// add metrics middleware only if we can serve them behind auth // add metrics middleware only if we can serve them behind auth
if viper.GetString("METRICS_USERNAME") != "" { if viper.GetString("METRICS_USERNAME") != "" {
s.router.Use(s.MetricsMiddleware()) s.router.Use(s.mw.Metrics())
} }
// set up CORS headers. you'll probably want to configure that // set up CORS headers. you'll probably want to configure that
// in middlewares.go. // in middlewares.go.
s.router.Use(s.CORSMiddleware()) s.router.Use(s.mw.CORS())
// CHANGEME to suit your needs, or pull from config. // CHANGEME to suit your needs, or pull from config.
// timeout for request context; your handlers must finish within // timeout for request context; your handlers must finish within
@ -57,39 +57,48 @@ func (s *Server) SetupRoutes() {
// complete docs: https://github.com/go-chi/chi // complete docs: https://github.com/go-chi/chi
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
s.router.Get("/", s.handleIndex()) s.router.Get("/", s.h.HandleIndex())
s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static)))) s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static))))
s.router.Route("/api/v1", func(r chi.Router) { s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/now", s.handleNow()) r.Get("/now", s.h.HandleNow())
}) })
// if you want to use a general purpose middleware (http.Handler // if you want to use a general purpose middleware (http.Handler
// wrapper) on a specific HandleFunc route, you need to take the // wrapper) on a specific HandleFunc route, you need to take the
// .ServeHTTP of the http.Handler to get its HandleFunc, viz: // .ServeHTTP of the http.Handler to get its HandleFunc, viz:
authMiddleware := s.AuthMiddleware() auth := s.mw.Auth()
s.router.Get( s.router.Get(
"/login", "/login",
authMiddleware(s.handleLogin()).ServeHTTP, auth(s.h.HandleLoginGET()).ServeHTTP,
) )
s.router.Get(
"/signup",
auth(s.h.HandleSignupGET()).ServeHTTP,
)
s.router.Post(
"/signup",
auth(s.h.HandleSignupPOST()).ServeHTTP,
)
// route that panics for testing // route that panics for testing
// CHANGEME remove this // CHANGEME remove this
s.router.Get( s.router.Get(
"/panic", "/panic",
s.handlePanic(), s.h.HandlePanic(),
) )
s.router.Get( s.router.Get(
"/.well-known/healthcheck.json", "/.well-known/healthcheck.json",
s.handleHealthCheck(), s.h.HandleHealthCheck(),
) )
// set up authenticated /metrics route: // set up authenticated /metrics route:
if viper.GetString("METRICS_USERNAME") != "" { if viper.GetString("METRICS_USERNAME") != "" {
s.router.Group(func(r chi.Router) { s.router.Group(func(r chi.Router) {
r.Use(s.MetricsAuthMiddleware()) r.Use(s.mw.MetricsAuth())
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP)) r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
}) })
} }

View File

@ -9,13 +9,13 @@ import (
"syscall" "syscall"
"time" "time"
"git.eeqj.de/sneak/gohttpserver/internal/config"
"git.eeqj.de/sneak/gohttpserver/internal/globals" "git.eeqj.de/sneak/gohttpserver/internal/globals"
"github.com/docker/docker/daemon/logger" "git.eeqj.de/sneak/gohttpserver/internal/handlers"
"git.eeqj.de/sneak/gohttpserver/internal/logger"
"git.eeqj.de/sneak/gohttpserver/internal/middleware"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
"honnef.co/go/tools/config"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi" "github.com/go-chi/chi"
@ -32,17 +32,15 @@ import (
type ServerParams struct { type ServerParams struct {
fx.In fx.In
Logger logger.Logger Logger *logger.Logger
Globals globals.Globals Globals *globals.Globals
Config config.Config Config *config.Config
Middleware *middleware.Middleware
Handlers *handlers.Handlers
} }
type Server struct { type Server struct {
appname string
version string
buildarch string
startupTime time.Time startupTime time.Time
port int
exitCode int exitCode int
sentryEnabled bool sentryEnabled bool
log *zerolog.Logger log *zerolog.Logger
@ -51,21 +49,26 @@ type Server struct {
httpServer *http.Server httpServer *http.Server
router *chi.Mux router *chi.Mux
params ServerParams params ServerParams
mw *middleware.Middleware
h *handlers.Handlers
} }
func New(lc fx.Lifecycle, params ServerParams) (*Server, error) { func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
s := new(Server) s := new(Server)
s.params = params s.params = params
s.mw = params.Middleware
s.h = params.Handlers
s.log = params.Logger.Get() s.log = params.Logger.Get()
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(ctx context.Context) error {
s.startupTime = time.Now() s.startupTime = time.Now()
s.version = params.Globals.Version go s.Run() // background FIXME
s.Run() return nil
}, },
OnStop: func(ctx context.Context) error { OnStop: func(ctx context.Context) error {
// FIXME do server shutdown here // FIXME do server shutdown here
return nil
}, },
}) })
return s, nil return s, nil
@ -85,22 +88,22 @@ func (s *Server) Run() {
// logging before sentry, because sentry logs // logging before sentry, because sentry logs
s.enableSentry() s.enableSentry()
return s.serve() s.serve() // FIXME deal with return value
} }
func (s *Server) enableSentry() { func (s *Server) enableSentry() {
s.sentryEnabled = false s.sentryEnabled = false
if s.Config.SentryDSN == "" { if s.params.Config.SentryDSN == "" {
return return
} }
err := sentry.Init(sentry.ClientOptions{ err := sentry.Init(sentry.ClientOptions{
Dsn: viper.GetString("SENTRY_DSN"), Dsn: s.params.Config.SentryDSN,
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version), Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
}) })
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("sentry init failure") s.log.Fatal().Err(err).Msg("sentry init failure")
return return
} }
s.log.Info().Msg("sentry error reporting activated") s.log.Info().Msg("sentry error reporting activated")
@ -162,11 +165,7 @@ func (s *Server) cleanShutdown() {
} }
} }
func (s *Server) uptime() time.Duration { func (s *Server) MaintenanceMode() bool {
return time.Since(s.startupTime)
}
func (s *Server) maintenance() bool {
return s.params.Config.MaintenanceMode return s.params.Config.MaintenanceMode
} }

View File

@ -0,0 +1,3 @@
<script src="/s/js/jquery-3.5.1.slim.min.js"></script>
<script src="/s/js/bootstrap-4.5.3.bundle.min.js"></script>
<script src="/s/js/main.js"></script>

View File

@ -0,0 +1,4 @@
<meta charset="utf-8" />
<title>{{ .HTMLTitle }}</title>
<link rel="stylesheet" href="/s/css/bootstrap-4.5.3.min.css" />
<link rel="stylesheet" href="/s/css/style.css" />

View File

@ -1,84 +1,29 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> {{ template "htmlheader.html" . }}
<title>Changeme: Go HTTP Server Boilerplate</title>
<link rel="stylesheet" href="/s/css/bootstrap-4.5.3.min.css" />
<link rel="stylesheet" href="/s/css/style.css" />
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark"> {{ template "navbar.html" .}}
<a class="navbar-brand" href="#">Quickstart</a>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarsExampleDefault"
aria-controls="navbarsExampleDefault"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarsExampleDefault">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#"
>Home <span class="sr-only">(current)</span></a
>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a
class="nav-link disabled"
href="#"
tabindex="-1"
aria-disabled="true"
>Disabled</a
>
</li>
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="dropdown01"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>Dropdown</a
>
<div class="dropdown-menu" aria-labelledby="dropdown01">
<a class="dropdown-item" href="#">Action</a>
<a class="dropdown-item" href="#">Another action</a>
<a class="dropdown-item" href="#">Something else here</a>
</div>
</li>
</ul>
<form class="form-inline my-2 my-lg-0">
<input
class="form-control mr-sm-2"
type="text"
placeholder="Search"
aria-label="Search"
/>
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">
Search
</button>
</form>
</div>
</nav>
<main role="main"> <main role="main">
<div class="jumbotron"> <div class="jumbotron">
<div class="container"> <div class="container">
<h1 class="display-3">Hello, world!</h1> <h1 class="display-3">Hello, world!</h1>
<h2><a
href="https://git.eeqj.de/sneak/gohttpserver">gohttpserver</a></h2>
<p> <p>
This is a boilerplate application for you to use as a base for your This is a boilerplate application for you to use as a base for your
own sites and services. own sites and services.
</p> </p>
<p>
Find more info at <a
href="https://git.eeqj.de/sneak/gohttpserver">https://git.eeqj.de/sneak/gohttpserver</a>.
</p>
<p>
This software is provided by <a
href="https://sneak.berlin">@sneak</a>
and is released unconditionally into the public domain.
</p>
<p> <p>
<a class="btn btn-primary btn-lg" href="#" role="button" <a class="btn btn-primary btn-lg" href="#" role="button"
>Learn more &raquo;</a >Learn more &raquo;</a
@ -130,11 +75,7 @@
<!-- /container --> <!-- /container -->
</main> </main>
<footer class="container"> {{ template "pagefooter.html" . }}
<p>&copy; No rights reserved - This is in the public domain!</p>
</footer>
<script src="/s/js/jquery-3.5.1.slim.min.js"></script>
<script src="/s/js/bootstrap-4.5.3.bundle.min.js"></script>
<script src="/s/js/main.js"></script>
</body> </body>
{{ template "htmlfooter.html" . }}
</html> </html>

View File

@ -1,10 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> {{ template "htmlheader.html" . }}
<title>Changeme: Go HTTP Server Boilerplate</title>
<link rel="stylesheet" href="/s/css/bootstrap-4.5.3.min.css" />
<style> <style>
body { body {
padding-top: 3.5rem; padding-top: 3.5rem;
@ -28,73 +25,9 @@
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark"> {{ template "navbar.html" .}}
<a class="navbar-brand" href="#">Quickstart</a>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarsExampleDefault"
aria-controls="navbarsExampleDefault"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarsExampleDefault">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#"
>Home <span class="sr-only">(current)</span></a
>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a
class="nav-link disabled"
href="#"
tabindex="-1"
aria-disabled="true"
>Disabled</a
>
</li>
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="dropdown01"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>Dropdown</a
>
<div class="dropdown-menu" aria-labelledby="dropdown01">
<a class="dropdown-item" href="#">Action</a>
<a class="dropdown-item" href="#">Another action</a>
<a class="dropdown-item" href="#">Something else here</a>
</div>
</li>
</ul>
<form class="form-inline my-2 my-lg-0">
<input
class="form-control mr-sm-2"
type="text"
placeholder="Search"
aria-label="Search"
/>
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">
Search
</button>
</form>
</div>
</nav>
<main role="main"> <main role="main">
<div class="login-form"> <div class="login-form">
<form action="/login" method="post"> <form action="/login" method="post">
<h2 class="text-center">Log in</h2> <h2 class="text-center">Log in</h2>
@ -118,11 +51,6 @@
</main> </main>
<footer class="container"> {{ template "pagefooter.html" . }}
<p>&copy; No rights reserved - This is in the public domain!</p>
</footer>
<script src="/s/js/jquery-3.5.1.slim.min.js"></script>
<script src="/s/js/bootstrap-4.5.3.bundle.min.js"></script>
<script src="/s/js/main.js"></script>
</body> </body>
</html> </html>

64
templates/navbar.html Normal file
View File

@ -0,0 +1,64 @@
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="/">{{.SiteName}}</a>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarsExampleDefault"
aria-controls="navbarsExampleDefault"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon">&nbsp;</span>
</button>
<div class="collapse navbar-collapse" id="navbarsExampleDefault">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#"
>Home <span class="sr-only">(current)</span></a
>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" aria-disabled="true"
>Disabled</a
>
</li>
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="dropdown01"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>Dropdown</a
>
<div class="dropdown-menu" aria-labelledby="dropdown01">
<a class="dropdown-item" href="#">Action</a>
<a class="dropdown-item" href="#">Another action</a>
<a class="dropdown-item" href="#"
>Something else here</a
>
</div>
</li>
</ul>
<form action="POST" class="form-inline my-2 my-lg-0">
<input
class="form-control mr-sm-2"
type="text"
placeholder="Search"
aria-label="Search"
/>
<button
class="btn btn-outline-success my-2 my-sm-0"
type="submit"
>
Search
</button>
</form>
</div>
</nav>

View File

@ -0,0 +1,3 @@
<footer class="container">
<p>&copy; No rights reserved - This is in the public domain!</p>
</footer>

76
templates/signup.html Normal file
View File

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{ template "htmlheader.html" .HTMLHeader }}
<style>
body {
padding-top: 3.5rem;
}
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</style>
</head>
<body>
{{ template "navbar.html" .Navbar}}
<main role="main">
<div class="signup-form">
<form action="/signup" method="post">
<h2 class="text-center">Create New Account</h2>
<div class="form-group">
<span>Email:</span>
<input type="text" class="form-control" name="email"
placeholder="user@domain.com" required="required">
</div>
<div class="form-group">
<span>Desired Username:</span>
<input type="text" class="form-control" name="desiredUsername" placeholder="Username" required="required">
</div>
<div class="form-group">
<p>Please use a unique password that you don't use anywhere
else. Minimum 12 characters.</p>
<span>New Password:</span>
<input type="password" class="form-control"
name="desiredPassword1" placeholder="Password" required="required">
</div>
<div class="form-group">
<span>New Password (again):</span>
<input type="password" class="form-control"
name="desiredPassword2" placeholder="Password
(again)" required="required">
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary btn-block">Create
New Account</button>
</div>
<!-- <div class="clearfix">
<label class="float-left form-check-label"><input type="checkbox"> Remember me</label>
<a href="#" class="float-right">Forgot Password?</a>
</div>
-->
</form>
<p class="text-center"><a href="#">Create an Account</a></p>
</div>
</main>
{{ template "pagefooter.html" .PageFooter }}
</body>
</html>

View File

@ -2,12 +2,21 @@ package templates
import ( import (
"embed" "embed"
"strings" "text/template"
) )
//go:embed *.html //go:embed *.html
var Templates embed.FS var TemplatesRaw embed.FS
var TemplatesParsed *template.Template
func GetParsed() *template.Template {
if TemplatesParsed == nil {
TemplatesParsed = template.Must(template.ParseFS(TemplatesRaw, "*"))
}
return TemplatesParsed
}
/*
func MustString(filename string) string { func MustString(filename string) string {
bytes, error := Templates.ReadFile(filename) bytes, error := Templates.ReadFile(filename)
if error != nil { if error != nil {
@ -17,3 +26,4 @@ func MustString(filename string) string {
out.Write(bytes) out.Write(bytes)
return out.String() return out.String()
} }
*/