embed static files now (#14)
Some checks failed
continuous-integration/drone/push Build is failing

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2022-11-28 00:19:47 +00:00
parent 4e663d848a
commit 61228b4586
23 changed files with 795 additions and 406 deletions

View File

@@ -0,0 +1,30 @@
package server
import (
"net/http"
"time"
)
func (s *server) 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

@@ -0,0 +1,21 @@
package server
import (
"html/template"
"log"
"net/http"
"git.eeqj.de/sneak/gohttpserver/templates"
)
func (s *server) handleIndex() http.HandlerFunc {
indexTemplate := template.Must(template.New("index").Parse(templates.MustString("index.html")))
return func(w http.ResponseWriter, r *http.Request) {
err := indexTemplate.ExecuteTemplate(w, "index", nil)
if err != nil {
log.Println(err.Error())
http.Error(w, http.StatusText(500), 500)
}
}
}

View File

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

View File

@@ -0,0 +1,14 @@
package server
import (
"net/http"
)
// CHANGEME you probably want to remove this,
// this is just a handler/route that throws a panic to test
// sentry events.
func (s *server) handlePanic() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
panic("y tho")
}
}

View File

@@ -0,0 +1,15 @@
package server
import (
"net/http"
"time"
)
func (s *server) 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)
}
}

50
internal/server/http.go Normal file
View File

@@ -0,0 +1,50 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
func (s *server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(":%d", s.port)
s.httpServer = &http.Server{
Addr: listenAddr,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
Handler: s,
}
// add routes
// this does any necessary setup in each handler
s.routes()
s.log.Info().Str("listenaddr", listenAddr).Msg("http begin listen")
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.log.Error().Msgf("listen:%+s\n", err)
if s.cancelFunc != nil {
s.cancelFunc()
}
}
}
func (s *server) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
w.WriteHeader(status)
w.Header().Set("Content-Type", "application/json")
if data != nil {
err := json.NewEncoder(w).Encode(data)
if err != nil {
s.log.Error().Err(err).Msg("json encode error")
}
}
}
func (s *server) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error { // nolint
return json.NewDecoder(r.Body).Decode(v)
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}

269
internal/server/main.go Normal file
View File

@@ -0,0 +1,269 @@
package server
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
// spooky action at a distance!
// this populates the environment
// from a ./.env file automatically
// for development configuration.
// .env contents should be things like
// `DBURL=postgres://user:pass@.../`
// (without the backticks, of course)
_ "github.com/joho/godotenv/autoload"
)
type server struct {
appname string
version string
buildarch string
databaseURL string
startupTime time.Time
port int
exitCode int
sentryEnabled bool
log *zerolog.Logger
ctx context.Context
cancelFunc context.CancelFunc
httpServer *http.Server
router *chi.Mux
}
func newServer(options ...func(s *server)) *server {
n := new(server)
n.startupTime = time.Now()
n.version = "unknown"
for _, opt := range options {
opt(n)
}
return n
}
// FIXME change this to use uber/fx DI and an Invoke()
// this is where we come in from package main.
func Run(appname, version, buildarch string) int {
s := newServer(func(i *server) {
i.appname = appname
if version != "" {
i.version = version
}
i.buildarch = buildarch
})
// this does nothing if SENTRY_DSN is unset in env.
// TODO remove:
if s.sentryEnabled {
sentry.CaptureMessage("It works!")
}
s.configure()
s.setupLogging()
// logging before sentry, because sentry logs
s.enableSentry()
s.databaseURL = viper.GetString("DBURL")
s.port = viper.GetInt("PORT")
return s.serve()
}
func (s *server) enableSentry() {
s.sentryEnabled = false
if viper.GetString("SENTRY_DSN") == "" {
return
}
err := sentry.Init(sentry.ClientOptions{
Dsn: viper.GetString("SENTRY_DSN"),
Release: fmt.Sprintf("%s-%s", s.appname, s.version),
})
if err != nil {
log.Fatal().Err(err).Msg("sentry init failure")
return
}
s.log.Info().Msg("sentry error reporting activated")
s.sentryEnabled = true
}
func (s *server) serve() int {
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
// signal watcher
go func() {
c := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
// block and wait for signal
sig := <-c
s.log.Info().Msgf("signal received: %+v", sig)
if s.cancelFunc != nil {
// cancelling the main context will trigger a clean
// shutdown.
s.cancelFunc()
}
}()
go s.serveUntilShutdown()
for range s.ctx.Done() {
// aforementioned clean shutdown upon main context
// cancellation
}
s.cleanShutdown()
return s.exitCode
}
func (s *server) cleanupForExit() {
s.log.Info().Msg("cleaning up")
// FIXME unimplemented
// close database connections or whatever
}
func (s *server) cleanShutdown() {
// initiate clean shutdown
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
s.log.Error().
Err(err).
Msg("server clean shutdown failed")
}
if shutdownCancel != nil {
shutdownCancel()
}
s.cleanupForExit()
if s.sentryEnabled {
sentry.Flush(2 * time.Second)
}
}
func (s *server) uptime() time.Duration {
return time.Since(s.startupTime)
}
func (s *server) maintenance() bool {
return viper.GetBool("MAINTENANCE_MODE")
}
func (s *server) configure() {
viper.SetConfigName(s.appname)
viper.SetConfigType("yaml")
// path to look for the config file in:
viper.AddConfigPath(fmt.Sprintf("/etc/%s", s.appname))
// call multiple times to add many search paths:
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", s.appname))
// viper.SetEnvPrefix(strings.ToUpper(s.appname))
viper.AutomaticEnv()
viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", "")
viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
// Config file not found; ignore error if desired
} else {
// Config file was found but another error was produced
log.Panic().
Err(err).
Msg("config file malformed")
}
}
// if viper.GetBool("DEBUG") {
// pp.Print(viper.AllSettings())
// }
}
func (s *server) setupLogging() {
// always log in UTC
zerolog.TimestampFunc = func() time.Time {
return time.Now().UTC()
}
zerolog.SetGlobalLevel(zerolog.InfoLevel)
tty := false
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
tty = true
}
var writers []io.Writer
if tty {
// this does cool colorization for console/dev
consoleWriter := zerolog.NewConsoleWriter(
func(w *zerolog.ConsoleWriter) {
// Customize time format
w.TimeFormat = time.RFC3339Nano
},
)
writers = append(writers, consoleWriter)
} else {
// log json in prod for the machines
writers = append(writers, os.Stdout)
}
/*
// this is how you log to a file, if you do that
// sort of thing still
logfile := viper.GetString("Logfile")
if logfile != "" {
logfileDir := filepath.Dir(logfile)
err := goutil.Mkdirp(logfileDir)
if err != nil {
log.Error().Err(err).Msg("unable to create log dir")
}
hp.logfh, err = os.OpenFile(logfile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
panic("unable to open logfile: " + err.Error())
}
writers = append(writers, hp.logfh)
*/
multi := zerolog.MultiLevelWriter(writers...)
logger := zerolog.New(multi).With().Timestamp().Logger().With().Caller().Logger()
s.log = &logger
// log.Logger = logger
if viper.GetBool("debug") {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
s.log.Debug().Bool("debug", true).Send()
}
s.identify()
}
func (s *server) identify() {
s.log.Info().
Str("appname", s.appname).
Str("version", s.version).
Str("buildarch", s.buildarch).
Msg("starting")
}

View File

@@ -0,0 +1,119 @@
package server
import (
"net"
"net/http"
"time"
basicauth "github.com/99designs/basicauth-go"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware"
"github.com/slok/go-http-metrics/middleware/std"
"github.com/spf13/viper"
)
// the following is from
// https://learning-cloud-native-go.github.io/docs/a6.adding_zerolog_logger/
func ipFromHostPort(hp string) string {
h, _, err := net.SplitHostPort(hp)
if err != nil {
return ""
}
if len(h) > 0 && h[0] == '[' {
return h[1 : len(h)-1]
}
return h
}
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
return &loggingResponseWriter{w, http.StatusOK}
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
// type Middleware func(http.Handler) http.Handler
// this returns a Middleware that is designed to do every request through the
// mux, note the signature:
func (s *server) LoggingMiddleware() func(http.Handler) http.Handler {
// FIXME this should use https://github.com/google/go-cloud/blob/master/server/requestlog/requestlog.go
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lrw := NewLoggingResponseWriter(w)
ctx := r.Context()
defer func() {
latency := time.Since(start)
s.log.Info().
Time("request_start", start).
Str("method", r.Method).
Str("url", r.URL.String()).
Str("useragent", r.UserAgent()).
Str("request_id", ctx.Value(middleware.RequestIDKey).(string)).
Str("referer", r.Referer()).
Str("proto", r.Proto).
Str("remoteIP", ipFromHostPort(r.RemoteAddr)).
Int("status", lrw.statusCode).
Int("latency_ms", int(latency.Milliseconds())).
Send()
}()
next.ServeHTTP(lrw, r)
})
}
}
func (s *server) CORSMiddleware() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
// CHANGEME! these are defaults, change them to suit your needs or
// read from environment/viper.
// AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts
AllowedOrigins: []string{"*"},
// AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300, // Maximum value not ignored by any of major browsers
})
}
func (s *server) AuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CHANGEME you'll want to change this to do stuff.
s.log.Info().Msg("AUTH: before request")
next.ServeHTTP(w, r)
})
}
}
func (s *server) MetricsMiddleware() func(http.Handler) http.Handler {
mdlw := ghmm.New(ghmm.Config{
Recorder: metrics.NewRecorder(metrics.Config{}),
})
return func(next http.Handler) http.Handler {
return std.Handler("", mdlw, next)
}
}
func (s *server) MetricsAuthMiddleware() func(http.Handler) http.Handler {
return basicauth.New(
"metrics",
map[string][]string{
viper.GetString("METRICS_USERNAME"): {
viper.GetString("METRICS_PASSWORD"),
},
},
)
}

96
internal/server/routes.go Normal file
View File

@@ -0,0 +1,96 @@
package server
import (
"net/http"
"time"
"git.eeqj.de/sneak/gohttpserver/static"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/viper"
)
func (s *server) routes() {
s.router = chi.NewRouter()
// the mux .Use() takes a http.Handler wrapper func, like most
// things that deal with "middlewares" like alice et c, and will
// call ServeHTTP on it. These middlewares applied by the mux (you
// can .Use() more than one) will be applied to every request into
// the service.
s.router.Use(middleware.Recoverer)
s.router.Use(middleware.RequestID)
s.router.Use(s.LoggingMiddleware())
// add metrics middleware only if we can serve them behind auth
if viper.GetString("METRICS_USERNAME") != "" {
s.router.Use(s.MetricsMiddleware())
}
// set up CORS headers. you'll probably want to configure that
// in middlewares.go.
s.router.Use(s.CORSMiddleware())
// CHANGEME to suit your needs, or pull from config.
// timeout for request context; your handlers must finish within
// this window:
s.router.Use(middleware.Timeout(60 * time.Second))
// this adds a sentry reporting middleware if and only if sentry is
// enabled via setting of SENTRY_DSN in env.
if s.sentryEnabled {
// Options docs at
// https://docs.sentry.io/platforms/go/guides/http/
// we set sentry to repanic so that all panics bubble up to the
// Recoverer chi middleware above.
sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true,
})
s.router.Use(sentryHandler.Handle)
}
////////////////////////////////////////////////////////////////////////
// ROUTES
// complete docs: https://github.com/go-chi/chi
////////////////////////////////////////////////////////////////////////
s.router.Get("/", s.handleIndex())
s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static))))
s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/now", s.handleNow())
})
// if you want to use a general purpose middleware (http.Handler
// wrapper) on a specific HandleFunc route, you need to take the
// .ServeHTTP of the http.Handler to get its HandleFunc, viz:
authMiddleware := s.AuthMiddleware()
s.router.Get(
"/login",
authMiddleware(s.handleLogin()).ServeHTTP,
)
// route that panics for testing
// CHANGEME remove this
s.router.Get(
"/panic",
s.handlePanic(),
)
s.router.Get(
"/.well-known/healthcheck.json",
s.handleHealthCheck(),
)
// set up authenticated /metrics route:
if viper.GetString("METRICS_USERNAME") != "" {
s.router.Group(func(r chi.Router) {
r.Use(s.MetricsAuthMiddleware())
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
})
}
}