From 18df90b20d9e3490499a84651028a1c7952e69ef Mon Sep 17 00:00:00 2001 From: william harbert Date: Sat, 19 Jul 2025 17:54:34 -0400 Subject: [PATCH] init --- .gitignore | 5 + .vscode/launch.json | 17 +++ README.md | 42 +++++++ internal/app/app.go | 19 +++ internal/app/models/models.go | 1 + internal/app/models/models_test.go | 1 + internal/app/services/services.go | 1 + internal/app/services/services_test.go | 1 + internal/components/index.templ | 5 + internal/contextutil/context.go | 86 ++++++++++++++ internal/database/database.go | 3 + internal/database/statements.go | 1 + internal/form/form.go | 1 + internal/server/config.go | 40 +++++++ internal/server/errors.go | 31 +++++ internal/server/handlers.go | 8 ++ internal/server/helpers.go | 83 +++++++++++++ internal/server/middleware.go | 1 + internal/server/routes.go | 24 ++++ internal/server/server.go | 158 +++++++++++++++++++++++++ ui/efs.go | 7 ++ 21 files changed, 535 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 README.md create mode 100644 internal/app/app.go create mode 100644 internal/app/models/models.go create mode 100644 internal/app/models/models_test.go create mode 100644 internal/app/services/services.go create mode 100644 internal/app/services/services_test.go create mode 100644 internal/components/index.templ create mode 100644 internal/contextutil/context.go create mode 100644 internal/database/database.go create mode 100644 internal/database/statements.go create mode 100644 internal/form/form.go create mode 100644 internal/server/config.go create mode 100644 internal/server/errors.go create mode 100644 internal/server/handlers.go create mode 100644 internal/server/helpers.go create mode 100644 internal/server/middleware.go create mode 100644 internal/server/routes.go create mode 100644 internal/server/server.go create mode 100644 ui/efs.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bce5073 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/static/ +/static/* +*.db +*templ.go +*.exe diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..395fd73 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug cmd/web", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/web", + //"showLog": true, + "cwd": "${workspaceFolder}", + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2021491 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Go WebApp Template + +## Structure +my-app/ +├── cmd/ +│ └── web/ # Individual target binary. Duplicate as needed +│ ├── app1.go +│ └── app1_test.go +├── internal/ +│ ├── app/ +│ │ ├── models # Code that interacts with the the DB +│ │ └── services # Works across multiple models to provide functionality +│ ├── components/ # TEMPL files and generated source +│ ├── contextutil/ # Insert and recall values from request context +│ ├── database/ # Wraps DB source(s) +│ ├── form/ # Structs for forms with validation info +│ ├── helpers/ # Assorted helper functions +│ ├── server/ # Server implementation +│ └── pkg1/ # Additional internal use package. Duplicate as needed +│ ├── pgk1.go +│ └── pgk1_test.go +├── tls/ # TLS certificates for web service +├── ui/ + ├── static/ # Static assets to be embedded into cmd/web binary + │ ├── css + │ ├── img + │ └── js + └── efs.go + +## Use +- `git clone git.develent.net/wiharb/go-webapp-app.git [project-name]` +- `cd ./[project-name]` +- `rm -rf ./.git` +- Rename and duplicate `internal/pkg1` as needed +- `go mod init [package_name]` +- Update `./.vscode/launch.json::"program"` to cmd/web +- Reinitialize git + - `git init` + - `git add .` + - `git commit -m "init"` + - `git remote add origin http://[user]:[token]@git.develenet.net/wiharb/[repo].git` + - `git push -u origin master` diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..db612ae --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,19 @@ +package app + +import ( + "log/slog" +) + +//Contains business/DB logic + +type Application struct { + logger *slog.Logger +} + +func NewApp(logger *slog.Logger) *Application { + app := &Application{ + logger: logger, + } + + return app +} diff --git a/internal/app/models/models.go b/internal/app/models/models.go new file mode 100644 index 0000000..2640e7f --- /dev/null +++ b/internal/app/models/models.go @@ -0,0 +1 @@ +package models diff --git a/internal/app/models/models_test.go b/internal/app/models/models_test.go new file mode 100644 index 0000000..2640e7f --- /dev/null +++ b/internal/app/models/models_test.go @@ -0,0 +1 @@ +package models diff --git a/internal/app/services/services.go b/internal/app/services/services.go new file mode 100644 index 0000000..5e568ea --- /dev/null +++ b/internal/app/services/services.go @@ -0,0 +1 @@ +package services diff --git a/internal/app/services/services_test.go b/internal/app/services/services_test.go new file mode 100644 index 0000000..5e568ea --- /dev/null +++ b/internal/app/services/services_test.go @@ -0,0 +1 @@ +package services diff --git a/internal/components/index.templ b/internal/components/index.templ new file mode 100644 index 0000000..d170501 --- /dev/null +++ b/internal/components/index.templ @@ -0,0 +1,5 @@ +templ Index() { + +

TEMPL Page

+

Hello from TEMPL

+} \ No newline at end of file diff --git a/internal/contextutil/context.go b/internal/contextutil/context.go new file mode 100644 index 0000000..8543ee1 --- /dev/null +++ b/internal/contextutil/context.go @@ -0,0 +1,86 @@ +package contextutil + +//Helpers for injecting and extracting data from request context +//TODO : Make a bit more generic when a contextKey is added and magically there are functions for it + +import "context" + +type contextKey string + +const IsAuthenticatedCtxKey = contextKey("isAuthenticated") +const IsAdminCtxKey = contextKey("isAdmin") +const AuthenticateUserIDCtxKey = contextKey("authenticatedUserID") +const AuthenticatedUserNameCtxKey = contextKey("authenticatedUserName") +const CSRFTokenCtxKey = contextKey("CSRFToken") + +func IsAuth(ctx context.Context) bool { + isAuthenticate, ok := ctx.Value(IsAuthenticatedCtxKey).(bool) + if !ok { + return false + } + + return isAuthenticate +} + +func IsAdmin(ctx context.Context) bool { + isAdmin, ok := ctx.Value(IsAdminCtxKey).(bool) + if !ok { + return false + } + + return isAdmin +} + +// Extract userName from ctx +func UserName(ctx context.Context) string { + userName, ok := ctx.Value(AuthenticatedUserNameCtxKey).(string) + if !ok { + return "" + } + + return userName +} + +// Extract userID from ctx +func UserID(ctx context.Context) int { + userID, ok := ctx.Value(AuthenticateUserIDCtxKey).(int) + if !ok { + return 0 + } + + return userID +} + +func CSRFToken(ctx context.Context) string { + csrfToken, ok := ctx.Value(CSRFTokenCtxKey).(string) + if !ok { + return "" + } + + return csrfToken +} + +// Add isAuth to ctx and return +func WithIsAuth(ctx context.Context, isAuth bool) context.Context { + return context.WithValue(ctx, IsAuthenticatedCtxKey, isAuth) +} + +// Add isAdmin to ctx and return +func WithIsAdmin(ctx context.Context, isAdmin bool) context.Context { + return context.WithValue(ctx, IsAdminCtxKey, isAdmin) +} + +// Add CSRFTokent to ctx and return +func WithCSRFToken(ctx context.Context, token string) context.Context { + return context.WithValue(ctx, CSRFTokenCtxKey, token) +} + +// Add AuthUserName to ctx and return +func WithAuthUserName(ctx context.Context, userName string) context.Context { + return context.WithValue(ctx, AuthenticatedUserNameCtxKey, userName) +} + +// Add AuthUserID to ctx and return +func WithAuthUserID(ctx context.Context, userID int) context.Context { + return context.WithValue(ctx, AuthenticateUserIDCtxKey, userID) +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..2c190c3 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,3 @@ +package database + +//DB Encapsulation...possibly replaced with GORM diff --git a/internal/database/statements.go b/internal/database/statements.go new file mode 100644 index 0000000..636bab8 --- /dev/null +++ b/internal/database/statements.go @@ -0,0 +1 @@ +package database diff --git a/internal/form/form.go b/internal/form/form.go new file mode 100644 index 0000000..b50f6a4 --- /dev/null +++ b/internal/form/form.go @@ -0,0 +1 @@ +package form diff --git a/internal/server/config.go b/internal/server/config.go new file mode 100644 index 0000000..9ee3ade --- /dev/null +++ b/internal/server/config.go @@ -0,0 +1,40 @@ +package server + +import ( + "flag" + "fmt" +) + +type config struct { + baseURL string + httpAddr string + dsn string + logLevel string +} + +const ( + version = "0.0.1" + defaultBaseURL = "https://localhost" + defaultHTTPAddr = "localhost:6300" + //defaultDSN = "mysql://gopics2:gopics2@/gopics2?parseTime=true" + defaultDSN = "sqlite://gopics.db" +) + +//TODO : create load from file, load order : file -> flags + +func parseCfg() (config, error) { + var cfg config + flag.StringVar(&cfg.baseURL, "base-url", defaultBaseURL, "base URL for the application") + flag.StringVar(&cfg.httpAddr, "http-address", defaultHTTPAddr, "IP:PORT to listen on for HTTP requests") + flag.StringVar(&cfg.dsn, "dsn", defaultDSN, "MySQL data source name") + showVersion := flag.Bool("version", false, "display version and exit") + + flag.Parse() + + if *showVersion { + fmt.Printf("version: %s\n", version) + return cfg, nil + } + cfg.logLevel = "Debug" + return cfg, nil +} diff --git a/internal/server/errors.go b/internal/server/errors.go new file mode 100644 index 0000000..60d16a0 --- /dev/null +++ b/internal/server/errors.go @@ -0,0 +1,31 @@ +package server + +import ( + "net/http" +) + +func (srv *server) serverError(w http.ResponseWriter, r *http.Request, err error) { + var ( + method = r.Method + uri = r.URL.RequestURI() + trace = "" //string(debug.Stack()) + ) + + srv.logger.Error(err.Error(), "method", method, "uri", uri, "trace", trace) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) +} + +// Logs server errors silently (without generating http:50x) +func (srv *server) serverErrorSilent(w http.ResponseWriter, r *http.Request, err error) { + var ( + method = r.Method + uri = r.URL.RequestURI() + trace = "" //string(debug.Stack()) + ) + + srv.logger.Error(err.Error(), "method", method, "uri", uri, "trace", trace) +} + +func (srv *server) clientError(w http.ResponseWriter, status int) { + http.Error(w, http.StatusText(status), status) +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..94b8c8d --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,8 @@ +package server + +import ( + "net/http" +) + +func (srv *server) home(w http.ResponseWriter, r *http.Request) { +} diff --git a/internal/server/helpers.go b/internal/server/helpers.go new file mode 100644 index 0000000..f3a2408 --- /dev/null +++ b/internal/server/helpers.go @@ -0,0 +1,83 @@ +package server + +import ( + "context" + "fmt" + "net/http" + + "github.com/a-h/templ" + "github.com/go-playground/validator/v10" + + "gopics.develent.net/internal/components" +) + +// Extract form and validate fields +func (srv *server) bindAndValidate(r *http.Request, form any) (map[string]string, error) { + + err := r.ParseForm() + if err != nil { + return nil, err + } + + err = srv.formDecoder.Decode(form, r.PostForm) + if err != nil { + return nil, err + } + + err = srv.validate.Struct(form) + if err != nil { + ve, ok := err.(validator.ValidationErrors) + srv.logger.Info("BnV Validation", "ok", ok, "ve", ve) + if ok { + return mapValidationErrors(ve), nil // validation failed + } + return nil, err // something else went wrong + + } + + return nil, nil // valid +} + +// Render TEMPL pages +func (srv *server) RenderPage(w http.ResponseWriter, r *http.Request, status int, page templ.Component, title string) { + w.WriteHeader(status) + flash := srv.sessionManager.PopString(r.Context(), "flash") + components.Base(page, title, flash).Render(r.Context(), w) +} + +// Inject flash messages into context +func (srv *server) PutFlash(ctx context.Context, msg string) { + srv.sessionManager.Put(ctx, "flash", msg) +} + +// Pop flash messages from context +func (srv *server) PopFlash(ctx context.Context) string { + srv.logger.Info("Pre-Pop", "flash", srv.sessionManager.GetString(ctx, "flash")) + flash := srv.sessionManager.PopString(ctx, "flash") + srv.logger.Info("Post-Pop", "flash", srv.sessionManager.GetString(ctx, "flash")) + return flash +} + +// Map validator errors to user presentable errors +func mapValidationErrors(errs validator.ValidationErrors) map[string]string { + errors := make(map[string]string) + for _, fe := range errs { + field := fe.Field() + tag := fe.Tag() + var msg string + + switch tag { + case "required": + msg = fmt.Sprintf("%s is required", field) + case "email": + msg = "Invalid email format" + case "gte": + msg = fmt.Sprintf("%s must be %s or greater", field, fe.Param()) + default: + msg = fmt.Sprintf("%s is not valid", field) + } + errors[field] = msg + } + + return errors +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100644 index 0000000..abb4e43 --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1 @@ +package server diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..ed89b21 --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,24 @@ +package server + +import ( + "net/http" +) + +func (srv *server) routes() http.Handler { + mux := http.NewServeMux() + + fileServer := http.FileServer(http.Dir("./ui/static/")) + mux.Handle("GET /static/", http.StripPrefix("/static", fileServer)) + //mux.Handle("GET /static/", http.FileServerFS(ui.Files)) + + //Configure Middleware chains + + //Add routes and handlers to map + routes := map[string]http.Handler{} + + for path, handler := range routes { + mux.Handle(path, handler) + } + + return mux +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..7b21c8d --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,158 @@ +package server + +import ( + "context" + "crypto/tls" + "errors" + "go-template-webapp/internal/app" + "log/slog" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/alexedwards/scs/mysqlstore" + "github.com/alexedwards/scs/sqlite3store" + "github.com/alexedwards/scs/v2" + "github.com/go-playground/form/v4" + "github.com/go-playground/validator/v10" + "github.com/jmoiron/sqlx" +) + +const ( + defaultIdleTimeout = time.Minute + defaultReadTimeout = 5 * time.Second + defaultWriteTimeout = 10 * time.Second + defaultShutdownPeriod = 30 * time.Second +) + +type server struct { + logger *slog.Logger + app *app.Application + cfg config + formDecoder *form.Decoder + validate *validator.Validate + sessionManager *scs.SessionManager + wg sync.WaitGroup +} + +func NewServer(logger *slog.Logger) (server, error) { + //parse config + cfg, err := parseCfg() + if err != nil { + logger.Error(err.Error()) + return server{}, err + } + + return server{logger: logger, cfg: cfg}, nil +} + +func (srv *server) Run() error { + db, err := srv.newDBConnection() + if err != nil { + return err + } + defer db.Close() + + srv.formDecoder = form.NewDecoder() + srv.validate = validator.New(validator.WithRequiredStructEnabled()) + srv.sessionManager = newSessionManager(db.DriverName, db.DB) + srv.app = app.NewApp(srv.logger, db) + + err = srv.initApp() //Makes sure required DB fields exist. May move move DB init code here as well + if err != nil { + return err + } + + return srv.serveHTTP() +} + +func (srv *server) serveHTTP() error { + tlsConfig := &tls.Config{ + CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, + } + + httpSrv := &http.Server{ + Addr: srv.cfg.httpAddr, + Handler: srv.routes(), + ErrorLog: slog.NewLogLogger(srv.logger.Handler(), slog.LevelError), + TLSConfig: tlsConfig, + IdleTimeout: defaultIdleTimeout, + ReadTimeout: defaultReadTimeout, + WriteTimeout: defaultWriteTimeout, + } + + shutdownErrorChan := make(chan error) + + go func() { + quitChan := make(chan os.Signal, 1) + signal.Notify(quitChan, syscall.SIGINT, syscall.SIGTERM) + <-quitChan + + ctx, cancel := context.WithTimeout(context.Background(), defaultShutdownPeriod) + defer cancel() + + shutdownErrorChan <- httpSrv.Shutdown(ctx) + }() + + srv.logger.Info("starting server", slog.Group("server", "addr", httpSrv.Addr)) + + err := httpSrv.ListenAndServeTLS("./tls/cert.pem", "./tls/key.pem") + if !errors.Is(err, http.ErrServerClosed) { + return err + } + + err = <-shutdownErrorChan + if err != nil { + return err + } + + srv.logger.Info("stopped server", slog.Group("server", "addr", httpSrv.Addr)) + + srv.wg.Wait() + return nil +} + +func (srv *server) newDBConnection() (*database.DB, error) { + db := database.New(srv.logger) + + err := db.Connect(srv.cfg.dsn) + if err != nil { + return nil, err + } + + return db, nil +} + +func newSessionManager(dbDriver string, db *sqlx.DB) *scs.SessionManager { + sm := scs.New() + + if dbDriver == "mysql" { + sm.Store = mysqlstore.New(db.DB) + } else if dbDriver == "sqlite3" { + sm.Store = sqlite3store.New(db.DB) + } + sm.Lifetime = 12 * time.Hour + sm.Cookie.Secure = true + + return sm +} + +func redirectToTls(w http.ResponseWriter, r *http.Request) { + httpsUrl := "https://" + r.Host + ":6300" + r.URL.Path + if r.URL.RawQuery != "" { + httpsUrl += "?" + r.URL.RawQuery + } + http.Redirect(w, r, httpsUrl, http.StatusMovedPermanently) +} + +// check DB tables and records, init if missing +func (srv *server) initApp() error { + err := srv.app.UserService.InitDB() + if err != nil { + return nil + } + return nil +} diff --git a/ui/efs.go b/ui/efs.go new file mode 100644 index 0000000..ca16e03 --- /dev/null +++ b/ui/efs.go @@ -0,0 +1,7 @@ +package ui + +//"embed" + +//go:embed +//"static" "html" +//var Files embed.FS