Compare commits

..

10 Commits

37 changed files with 1245 additions and 308 deletions

173
AGENTS.md Normal file
View File

@@ -0,0 +1,173 @@
# AGENTS.md - Coding Agent Guidelines for golib
## Project Overview
This is a Go library repository containing multiple independent packages:
- **cookies**: HTTP cookie utilities
- **env**: Environment variable helpers
- **ezconf**: Configuration loader with ENV parsing
- **hlog**: Logging with zerolog
- **hws**: HTTP web server
- **hwsauth**: Authentication middleware for hws
- **jwt**: JWT token generation and validation
- **tmdb**: The Movie Database API client
Each package has its own `go.mod` and can be used independently.
## Interactive Questions
All questions in plan mode should use the opencode interactive question prompter for user interaction.
## Build, Test, and Lint Commands
### Running Tests
```bash
# Test all packages from repo root
go test ./...
# Test a specific package
cd <package> && go test
# Run a single test function
cd <package> && go test -run TestFunctionName
# Run tests with verbose output
cd <package> && go test -v
# Run tests matching a pattern
cd <package> && go test -run "TestName.*"
```
### Building
```bash
# Each package is a library - no build needed
# Verify code compiles:
go build ./...
# Or for specific package:
cd <package> && go build
```
### Linting
```bash
# Use standard go tools
go vet ./...
go fmt ./...
# Check formatting without changing files
gofmt -l .
```
## Code Style Guidelines
### Package Structure
- Each package must have its own `go.mod` with module path: `git.haelnorr.com/h/golib/<package>`
- Go version should be current (1.23.4+)
- Each package should have a `doc.go` file with package documentation
### Imports
- Use standard library imports first
- Then third-party imports
- Then local imports from this repo (e.g., `git.haelnorr.com/h/golib/hlog`)
- Group imports with blank lines between groups
- Example:
```go
import (
"context"
"net/http"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"git.haelnorr.com/h/golib/hlog"
)
```
### Formatting
- Use `gofmt` standard formatting
- No tabs for alignment, use spaces inside structs
- Line length: no hard limit, but prefer readability
### Types
- Use explicit types for struct fields
- Config structs must have ENV comments (see below)
- Prefer named return values for complex functions
- Use generics where appropriate (see `hwsauth.Authenticator[T Model, TX DBTransaction]`)
### Naming Conventions
- Packages: lowercase, single word (e.g., `cookies`, `ezconf`)
- Exported functions: PascalCase (e.g., `NewServer`, `ConfigFromEnv`)
- Unexported functions: camelCase (e.g., `isValidHostname`, `waitUntilReady`)
- Test functions: `Test<FunctionName>` or `Test<FunctionName>_<Case>` (underscore for sub-cases)
- Variables: camelCase, descriptive names
- Constants: PascalCase or UPPER_CASE depending on scope
### Error Handling
- Use `github.com/pkg/errors` for error wrapping
- Wrap errors with context: `errors.Wrap(err, "context message")`
- Return errors, don't panic (except in truly exceptional cases)
- Validate inputs and return descriptive errors
- Example:
```go
if config == nil {
return nil, errors.New("Config cannot be nil")
}
```
### Configuration Pattern
- Each package with config should have a `Config` struct
- Provide `ConfigFromEnv() (*Config, error)` function
- ENV comment format for Config struct fields:
```go
type Config struct {
Host string // ENV HWS_HOST: Host to listen on (default: 127.0.0.1)
Port uint64 // ENV HWS_PORT: Port to listen on (default: 3000)
SSL bool // ENV HWS_SSL: Enable SSL (required when using production)
}
```
- Format: `// ENV ENV_NAME: Description (required <condition>) (default: <value>)`
- Include "required" only if no default
- Include "default" only if one exists
### Testing
- Use `testing` package from standard library
- Use `github.com/stretchr/testify` for assertions (`require`, `assert`)
- Table-driven tests for multiple cases:
```go
tests := []struct {
name string
input string
wantErr bool
}{
{"valid case", "input", false},
{"error case", "", true},
}
```
- Test files use `<package>_test` for black-box tests or `<package>` for white-box
- Helper functions should use `t.Helper()`
### Documentation
- All exported functions, types, and constants must have godoc comments
- Comments should start with the name being documented
- Example: `// NewServer returns a new hws.Server with the specified configuration.`
- Keep doc.go files up to date with package overview
- Follow RULES.md for README and wiki documentation
## Version Control (from RULES.md)
- Do NOT make changes to master branch
- Checkout a branch for new features
- Version numbers use git tags - do NOT change manually
- When updating docs, append branch name to version
- Changes to golib-wiki repo should use same branch name
## Testing Requirements (from RULES.md)
- All features MUST have tests
- Update existing tests when modifying features
- New features require new tests
## Documentation Requirements (from RULES.md)
- Document via: docstrings, README.md, doc.go, wiki
- README structure: Title+version, Features (NO EMOTICONS), Installation, Quick Start, Docs links, Additional info, License, Contributing, Related projects
- Wiki location: `~/projects/golib-wiki`
- Docstrings must conform to godoc standards
## License
- All modules use MIT License

View File

@@ -45,3 +45,6 @@ Do not make any changes to master. Checkout a branch to work on new features
Version numbers are specified using git tags.
Do not change version numbers. When updating documentation, append the branch name to the version number.
Changes made to the golib-wiki repo should be made under the same branch name as the changes made in this repo
4. Licencing
All modules should have an MIT License

21
cookies/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 haelnorr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

61
cookies/README.md Normal file
View File

@@ -0,0 +1,61 @@
# cookies v1.0.0
HTTP cookie utilities for Go web applications with security best practices.
## Features
- Secure cookie setting with HttpOnly flag
- Cookie deletion with proper expiration
- Pagefrom tracking for post-login redirects
- Host validation for referer-based redirects
- Full test coverage
## Installation
```bash
go get git.haelnorr.com/h/golib/cookies
```
## Quick Start
```go
package main
import (
"net/http"
"git.haelnorr.com/h/golib/cookies"
)
func handler(w http.ResponseWriter, r *http.Request) {
// Set a secure cookie
cookies.SetCookie(w, "session", "/", "abc123", 3600)
// Delete a cookie
cookies.DeleteCookie(w, "old_session", "/")
// Handle pagefrom for redirects
if r.URL.Path == "/login" {
cookies.SetPageFrom(w, r, "example.com")
}
// Check pagefrom after login
redirectTo := cookies.CheckPageFrom(w, r)
http.Redirect(w, r, redirectTo, http.StatusFound)
}
```
## Documentation
See the [wiki documentation](../golib/wiki/cookies.md) for detailed usage information and examples.
## License
MIT License
## Contributing
Please see the main golib repository for contributing guidelines.
## Related Projects
This package is part of the golib collection of utilities for Go applications and integrates well with other golib packages.

405
cookies/cookies_test.go Normal file
View File

@@ -0,0 +1,405 @@
package cookies
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSetCookie(t *testing.T) {
tests := []struct {
name string
cookie string
path string
value string
maxAge int
expected string
}{
{
name: "basic cookie",
cookie: "test",
path: "/",
value: "value",
maxAge: 3600,
expected: "test=value; Path=/; Max-Age=3600; HttpOnly",
},
{
name: "zero max age",
cookie: "session",
path: "/api",
value: "abc123",
maxAge: 0,
expected: "session=abc123; Path=/api; HttpOnly",
},
{
name: "negative max age",
cookie: "temp",
path: "/",
value: "temp",
maxAge: -1,
expected: "temp=temp; Path=/; Max-Age=0; HttpOnly",
},
{
name: "empty value",
cookie: "empty",
path: "/",
value: "",
maxAge: 3600,
expected: "empty=; Path=/; Max-Age=3600; HttpOnly",
},
{
name: "special characters in value",
cookie: "data",
path: "/",
value: "test@123!#$%",
maxAge: 7200,
expected: "data=test@123!#$%; Path=/; Max-Age=7200; HttpOnly",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
SetCookie(w, tt.cookie, tt.path, tt.value, tt.maxAge)
headers := w.Header()["Set-Cookie"]
if len(headers) != 1 {
t.Errorf("Expected 1 Set-Cookie header, got %d", len(headers))
return
}
// Parse the cookie header to check individual components
cookieHeader := headers[0]
// Check that all expected components are present
if !strings.Contains(cookieHeader, tt.cookie+"="+tt.value) {
t.Errorf("Expected cookie name/value not found in: %s", cookieHeader)
}
if !strings.Contains(cookieHeader, "Path="+tt.path) {
t.Errorf("Expected path not found in: %s", cookieHeader)
}
if !strings.Contains(cookieHeader, "HttpOnly") {
t.Errorf("Expected HttpOnly not found in: %s", cookieHeader)
}
if tt.maxAge != 0 {
expectedMaxAge := fmt.Sprintf("Max-Age=%d", tt.maxAge)
if tt.maxAge < 0 {
expectedMaxAge = "Max-Age=0" // Go normalizes negative Max-Age to 0
}
if !strings.Contains(cookieHeader, expectedMaxAge) {
t.Errorf("Expected Max-Age not found in: %s", cookieHeader)
}
}
})
}
}
func TestDeleteCookie(t *testing.T) {
tests := []struct {
name string
cookie string
path string
expected string
}{
{
name: "basic deletion",
cookie: "test",
path: "/",
expected: "test=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; HttpOnly",
},
{
name: "delete with specific path",
cookie: "session",
path: "/api",
expected: "session=; Path=/api; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; HttpOnly",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
DeleteCookie(w, tt.cookie, tt.path)
headers := w.Header()["Set-Cookie"]
if len(headers) != 1 {
t.Errorf("Expected 1 Set-Cookie header, got %d", len(headers))
return
}
cookieHeader := headers[0]
// Check deletion-specific components
if !strings.Contains(cookieHeader, tt.cookie+"=") {
t.Errorf("Expected cookie name not found in: %s", cookieHeader)
}
if !strings.Contains(cookieHeader, "Path="+tt.path) {
t.Errorf("Expected path not found in: %s", cookieHeader)
}
if !strings.Contains(cookieHeader, "Max-Age=0") {
t.Errorf("Expected Max-Age=0 not found in: %s", cookieHeader)
}
if !strings.Contains(cookieHeader, "Expires=") {
t.Errorf("Expected Expires not found in: %s", cookieHeader)
}
if !strings.Contains(cookieHeader, "HttpOnly") {
t.Errorf("Expected HttpOnly not found in: %s", cookieHeader)
}
})
}
}
func TestCheckPageFrom(t *testing.T) {
tests := []struct {
name string
cookieValue string
cookiePath string
expectedResult string
shouldSet bool
}{
{
name: "valid pagefrom cookie",
cookieValue: "/dashboard",
cookiePath: "/",
expectedResult: "/dashboard",
shouldSet: true,
},
{
name: "no pagefrom cookie",
cookieValue: "",
cookiePath: "",
expectedResult: "/",
shouldSet: false,
},
{
name: "empty pagefrom cookie",
cookieValue: "",
cookiePath: "/",
expectedResult: "",
shouldSet: true,
},
{
name: "pagefrom with query params",
cookieValue: "/search?q=test",
cookiePath: "/",
expectedResult: "/search?q=test",
shouldSet: true,
},
{
name: "pagefrom with special path",
cookieValue: "/api/v1/users",
cookiePath: "/api",
expectedResult: "/api/v1/users",
shouldSet: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
r := &http.Request{
Header: make(http.Header),
}
if tt.shouldSet {
cookie := &http.Cookie{
Name: "pagefrom",
Value: tt.cookieValue,
Path: tt.cookiePath,
}
r.AddCookie(cookie)
}
result := CheckPageFrom(w, r)
if result != tt.expectedResult {
t.Errorf("CheckPageFrom() = %v, want %v", result, tt.expectedResult)
}
// Verify that the cookie was deleted
if tt.shouldSet {
headers := w.Header()["Set-Cookie"]
if len(headers) != 1 {
t.Errorf("Expected 1 Set-Cookie header for deletion, got %d", len(headers))
return
}
cookieHeader := headers[0]
if !strings.Contains(cookieHeader, "pagefrom=") {
t.Errorf("Expected pagefrom cookie deletion not found in: %s", cookieHeader)
}
if !strings.Contains(cookieHeader, "Max-Age=0") {
t.Errorf("Expected Max-Age=0 for deletion not found in: %s", cookieHeader)
}
}
})
}
}
func TestSetPageFrom(t *testing.T) {
tests := []struct {
name string
referer string
trustedHost string
expectedSet bool
expectedValue string
}{
{
name: "valid trusted host referer",
referer: "http://example.com/dashboard",
trustedHost: "example.com",
expectedSet: true,
expectedValue: "/dashboard",
},
{
name: "valid trusted host with https",
referer: "https://example.com/profile",
trustedHost: "example.com",
expectedSet: true,
expectedValue: "/profile",
},
{
name: "untrusted host",
referer: "http://evil.com/dashboard",
trustedHost: "example.com",
expectedSet: true,
expectedValue: "/",
},
{
name: "empty path",
referer: "http://example.com",
trustedHost: "example.com",
expectedSet: true,
expectedValue: "/",
},
{
name: "login path - should not set",
referer: "http://example.com/login",
trustedHost: "example.com",
expectedSet: false,
expectedValue: "",
},
{
name: "register path - should not set",
referer: "http://example.com/register",
trustedHost: "example.com",
expectedSet: false,
expectedValue: "",
},
{
name: "invalid referer URL",
referer: "not-a-url",
trustedHost: "example.com",
expectedSet: true,
expectedValue: "/",
},
{
name: "empty referer",
referer: "",
trustedHost: "example.com",
expectedSet: true,
expectedValue: "/",
},
{
name: "root path",
referer: "http://example.com/",
trustedHost: "example.com",
expectedSet: true,
expectedValue: "/",
},
{
name: "path with query string",
referer: "http://example.com/search?q=test",
trustedHost: "example.com",
expectedSet: true,
expectedValue: "/search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
r := &http.Request{
Header: make(http.Header),
}
if tt.referer != "" {
r.Header.Set("Referer", tt.referer)
}
SetPageFrom(w, r, tt.trustedHost)
headers := w.Header()["Set-Cookie"]
if tt.expectedSet {
if len(headers) != 1 {
t.Errorf("Expected 1 Set-Cookie header, got %d", len(headers))
return
}
cookieHeader := headers[0]
if !strings.Contains(cookieHeader, "pagefrom="+tt.expectedValue) {
t.Errorf("Expected pagefrom=%s not found in: %s", tt.expectedValue, cookieHeader)
}
} else {
if len(headers) != 0 {
t.Errorf("Expected no Set-Cookie header, got %d", len(headers))
}
}
})
}
}
func TestIntegration(t *testing.T) {
// Test the complete flow: SetPageFrom -> CheckPageFrom
t.Run("complete flow", func(t *testing.T) {
// Step 1: Set pagefrom cookie
w1 := httptest.NewRecorder()
r1 := &http.Request{
Header: make(http.Header),
}
r1.Header.Set("Referer", "http://example.com/dashboard")
SetPageFrom(w1, r1, "example.com")
// Extract the cookie from the response
headers1 := w1.Header()["Set-Cookie"]
if len(headers1) != 1 {
t.Errorf("Expected 1 Set-Cookie header, got %d", len(headers1))
return
}
// Verify the cookie was set correctly
cookieHeader := headers1[0]
if !strings.Contains(cookieHeader, "pagefrom=/dashboard") {
t.Errorf("Expected pagefrom=/dashboard not found in: %s", cookieHeader)
}
// Step 2: Check pagefrom cookie (should delete it)
w2 := httptest.NewRecorder()
r2 := &http.Request{
Header: make(http.Header),
}
r2.AddCookie(&http.Cookie{
Name: "pagefrom",
Value: "/dashboard",
Path: "/",
})
result := CheckPageFrom(w2, r2)
if result != "/dashboard" {
t.Errorf("Expected result /dashboard, got %s", result)
}
// Verify the cookie was deleted
headers2 := w2.Header()["Set-Cookie"]
if len(headers2) != 1 {
t.Errorf("Expected 1 Set-Cookie header for deletion, got %d", len(headers2))
return
}
cookieHeader2 := headers2[0]
// Check for deletion indicators (Max-Age=0 with Expires in the past)
if !(strings.Contains(cookieHeader2, "Max-Age=0") && strings.Contains(cookieHeader2, "Expires=Thu, 01 Jan 1970")) {
t.Errorf("Expected cookie deletion, got: %s", cookieHeader2)
}
})
}

26
cookies/doc.go Normal file
View File

@@ -0,0 +1,26 @@
// Package cookies provides utilities for handling HTTP cookies in Go web applications.
// It includes functions for setting secure cookies, deleting cookies, and managing
// pagefrom tracking for post-login redirects.
//
// The package follows security best practices by setting the HttpOnly flag on all
// cookies to prevent XSS attacks. The SetCookie function allows you to specify the
// name, path, value, and max-age for cookies.
//
// The pagefrom functionality helps with user experience by remembering where a user
// was before being redirected to login/register pages, then redirecting them back
// after successful authentication.
//
// Example usage:
//
// // Set a session cookie
// cookies.SetCookie(w, "session", "/", "abc123", 3600)
//
// // Delete a cookie
// cookies.DeleteCookie(w, "old_session", "/")
//
// // Handle pagefrom tracking
// cookies.SetPageFrom(w, r, "example.com")
// redirectTo := cookies.CheckPageFrom(w, r)
//
// All functions are designed to be safe and handle edge cases gracefully.
package cookies

21
env/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 haelnorr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

67
env/README.md vendored Normal file
View File

@@ -0,0 +1,67 @@
# env v1.0.0
Environment variable utilities for Go applications with type safety and default values.
## Features
- Type-safe environment variable parsing
- Support for all basic Go types (string, int variants, uint variants, bool, time.Duration)
- Graceful fallback to default values
- Comprehensive boolean parsing with multiple truthy/falsy values
- Full test coverage
## Installation
```bash
go get git.haelnorr.com/h/golib/env
```
## Quick Start
```go
package main
import (
"fmt"
"time"
"git.haelnorr.com/h/golib/env"
)
func main() {
// String values
host := env.String("HOST", "localhost")
// Integer values (all sizes supported)
port := env.Int("PORT", 8080)
timeout := env.Int64("TIMEOUT_SECONDS", 30)
// Unsigned integer values
maxConnections := env.UInt("MAX_CONNECTIONS", 100)
// Boolean values (supports many formats)
debug := env.Bool("DEBUG", false)
// Duration values
requestTimeout := env.Duration("REQUEST_TIMEOUT", 30*time.Second)
fmt.Printf("Server: %s:%d\n", host, port)
fmt.Printf("Debug: %v\n", debug)
fmt.Printf("Timeout: %v\n", requestTimeout)
}
```
## Documentation
See the [wiki documentation](../golib/wiki/env.md) for detailed usage information and examples.
## License
MIT License
## Contributing
Please see the main golib repository for contributing guidelines.
## Related Projects
This package is part of the golib collection of utilities for Go applications.

18
env/doc.go vendored Normal file
View File

@@ -0,0 +1,18 @@
// Package env provides utilities for reading environment variables with type safety
// and default values. It supports common Go types including strings, integers (all sizes),
// unsigned integers (all sizes), booleans, and time.Duration values.
//
// The package follows a simple pattern where each function takes a key name and a
// default value, returning the parsed environment variable or the default if the
// variable is not set or cannot be parsed.
//
// Example usage:
//
// port := env.Int("PORT", 8080)
// debug := env.Bool("DEBUG", false)
// timeout := env.Duration("TIMEOUT", 30*time.Second)
//
// All functions gracefully handle missing environment variables by returning the
// provided default value. They also handle parsing errors by falling back to the
// default value.
package env

View File

@@ -13,12 +13,12 @@ import (
func Test_ConfigFromEnv(t *testing.T) {
t.Run("Default values when no env vars set", func(t *testing.T) {
// Clear any existing env vars
os.Unsetenv("HWS_HOST")
os.Unsetenv("HWS_PORT")
os.Unsetenv("HWS_GZIP")
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
os.Unsetenv("HWS_WRITE_TIMEOUT")
os.Unsetenv("HWS_IDLE_TIMEOUT")
_ = os.Unsetenv("HWS_HOST")
_ = os.Unsetenv("HWS_PORT")
_ = os.Unsetenv("HWS_GZIP")
_ = os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
_ = os.Unsetenv("HWS_WRITE_TIMEOUT")
_ = os.Unsetenv("HWS_IDLE_TIMEOUT")
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
@@ -33,8 +33,10 @@ func Test_ConfigFromEnv(t *testing.T) {
})
t.Run("Custom host", func(t *testing.T) {
os.Setenv("HWS_HOST", "192.168.1.1")
defer os.Unsetenv("HWS_HOST")
_ = os.Setenv("HWS_HOST", "192.168.1.1")
defer func() {
_ = os.Unsetenv("HWS_HOST")
}()
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
@@ -42,8 +44,10 @@ func Test_ConfigFromEnv(t *testing.T) {
})
t.Run("Custom port", func(t *testing.T) {
os.Setenv("HWS_PORT", "8080")
defer os.Unsetenv("HWS_PORT")
_ = os.Setenv("HWS_PORT", "8080")
defer func() {
_ = os.Unsetenv("HWS_PORT")
}()
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
@@ -51,8 +55,10 @@ func Test_ConfigFromEnv(t *testing.T) {
})
t.Run("GZIP enabled", func(t *testing.T) {
os.Setenv("HWS_GZIP", "true")
defer os.Unsetenv("HWS_GZIP")
_ = os.Setenv("HWS_GZIP", "true")
defer func() {
_ = os.Unsetenv("HWS_GZIP")
}()
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
@@ -60,12 +66,14 @@ func Test_ConfigFromEnv(t *testing.T) {
})
t.Run("Custom timeouts", func(t *testing.T) {
os.Setenv("HWS_READ_HEADER_TIMEOUT", "5")
os.Setenv("HWS_WRITE_TIMEOUT", "30")
os.Setenv("HWS_IDLE_TIMEOUT", "300")
defer os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
defer os.Unsetenv("HWS_WRITE_TIMEOUT")
defer os.Unsetenv("HWS_IDLE_TIMEOUT")
_ = os.Setenv("HWS_READ_HEADER_TIMEOUT", "5")
_ = os.Setenv("HWS_WRITE_TIMEOUT", "30")
_ = os.Setenv("HWS_IDLE_TIMEOUT", "300")
defer func() {
_ = os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
_ = os.Unsetenv("HWS_WRITE_TIMEOUT")
_ = os.Unsetenv("HWS_IDLE_TIMEOUT")
}()
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
@@ -75,19 +83,19 @@ func Test_ConfigFromEnv(t *testing.T) {
})
t.Run("All custom values", func(t *testing.T) {
os.Setenv("HWS_HOST", "0.0.0.0")
os.Setenv("HWS_PORT", "9000")
os.Setenv("HWS_GZIP", "true")
os.Setenv("HWS_READ_HEADER_TIMEOUT", "3")
os.Setenv("HWS_WRITE_TIMEOUT", "15")
os.Setenv("HWS_IDLE_TIMEOUT", "180")
_ = os.Setenv("HWS_HOST", "0.0.0.0")
_ = os.Setenv("HWS_PORT", "9000")
_ = os.Setenv("HWS_GZIP", "true")
_ = os.Setenv("HWS_READ_HEADER_TIMEOUT", "3")
_ = os.Setenv("HWS_WRITE_TIMEOUT", "15")
_ = os.Setenv("HWS_IDLE_TIMEOUT", "180")
defer func() {
os.Unsetenv("HWS_HOST")
os.Unsetenv("HWS_PORT")
os.Unsetenv("HWS_GZIP")
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
os.Unsetenv("HWS_WRITE_TIMEOUT")
os.Unsetenv("HWS_IDLE_TIMEOUT")
_ = os.Unsetenv("HWS_HOST")
_ = os.Unsetenv("HWS_PORT")
_ = os.Unsetenv("HWS_GZIP")
_ = os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
_ = os.Unsetenv("HWS_WRITE_TIMEOUT")
_ = os.Unsetenv("HWS_IDLE_TIMEOUT")
}()
config, err := hws.ConfigFromEnv()

View File

@@ -9,7 +9,7 @@ import (
"github.com/pkg/errors"
)
// Error to use with Server.ThrowError
// HWSError wraps an error with other information for use with HWS features
type HWSError struct {
StatusCode int // HTTP Status code
Message string // Error message
@@ -41,7 +41,7 @@ type ErrorPage interface {
}
// AddErrorPage registers a handler that returns an ErrorPage
func (server *Server) AddErrorPage(pageFunc ErrorPageFunc) error {
func (s *Server) AddErrorPage(pageFunc ErrorPageFunc) error {
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
page, err := pageFunc(HWSError{StatusCode: http.StatusInternalServerError})
@@ -56,7 +56,7 @@ func (server *Server) AddErrorPage(pageFunc ErrorPageFunc) error {
return errors.New("Render method of the error page did not write anything to the response writer")
}
server.errorPage = pageFunc
s.errorPage = pageFunc
return nil
}
@@ -64,7 +64,19 @@ func (server *Server) AddErrorPage(pageFunc ErrorPageFunc) error {
// the error with the level specified by the HWSError.
// If HWSError.RenderErrorPage is true, the error page will be rendered to the ResponseWriter
// and the request chain should be terminated.
func (server *Server) ThrowError(w http.ResponseWriter, r *http.Request, error HWSError) error {
func (s *Server) ThrowError(w http.ResponseWriter, r *http.Request, error HWSError) {
err := s.throwError(w, r, error)
if err != nil {
s.LogError(error)
s.LogError(HWSError{
Message: "Error occured during throwError",
Error: errors.Wrap(err, "s.throwError"),
Level: ErrorERROR,
})
}
}
func (s *Server) throwError(w http.ResponseWriter, r *http.Request, error HWSError) error {
if error.StatusCode <= 0 {
return errors.New("HWSError.StatusCode cannot be 0.")
}
@@ -77,32 +89,27 @@ func (server *Server) ThrowError(w http.ResponseWriter, r *http.Request, error H
if r == nil {
return errors.New("Request cannot be nil")
}
if !server.IsReady() {
if !s.IsReady() {
return errors.New("ThrowError called before server started")
}
w.WriteHeader(error.StatusCode)
server.LogError(error)
if server.errorPage == nil {
server.LogError(HWSError{Message: "No error page provided", Error: nil, Level: ErrorDEBUG})
s.LogError(error)
if s.errorPage == nil {
s.LogError(HWSError{Message: "No error page provided", Error: nil, Level: ErrorDEBUG})
return nil
}
if error.RenderErrorPage {
server.LogError(HWSError{Message: "Error page rendering", Error: nil, Level: ErrorDEBUG})
errPage, err := server.errorPage(error)
s.LogError(HWSError{Message: "Error page rendering", Error: nil, Level: ErrorDEBUG})
errPage, err := s.errorPage(error)
if err != nil {
server.LogError(HWSError{Message: "Failed to get a valid error page", Error: err})
s.LogError(HWSError{Message: "Failed to get a valid error page", Error: err})
}
err = errPage.Render(r.Context(), w)
if err != nil {
server.LogError(HWSError{Message: "Failed to render error page", Error: err})
s.LogError(HWSError{Message: "Failed to render error page", Error: err})
}
} else {
server.LogError(HWSError{Message: "Error page specified not to render", Error: nil, Level: ErrorDEBUG})
s.LogError(HWSError{Message: "Error page specified not to render", Error: nil, Level: ErrorDEBUG})
}
return nil
}
func (server *Server) ThrowFatal(w http.ResponseWriter, err error) {
w.WriteHeader(http.StatusInternalServerError)
server.LogFatal(err)
}

View File

@@ -14,22 +14,26 @@ import (
"github.com/stretchr/testify/require"
)
type goodPage struct{}
type badPage struct{}
type (
goodPage struct{}
badPage struct{}
)
func goodRender(error hws.HWSError) (hws.ErrorPage, error) {
return goodPage{}, nil
}
func badRender1(error hws.HWSError) (hws.ErrorPage, error) {
return badPage{}, nil
}
func badRender2(error hws.HWSError) (hws.ErrorPage, error) {
return nil, errors.New("I'm an error")
}
func (g goodPage) Render(ctx context.Context, w io.Writer) error {
w.Write([]byte("Test write to ResponseWriter"))
return nil
_, err := w.Write([]byte("Test write to ResponseWriter"))
return err
}
func (b badPage) Render(ctx context.Context, w io.Writer) error {
@@ -85,40 +89,42 @@ func Test_ThrowError(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
t.Run("Server not started", func(t *testing.T) {
err := server.ThrowError(rr, req, hws.HWSError{
buf.Reset()
server.ThrowError(rr, req, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Error",
Error: errors.New("Error"),
})
assert.Error(t, err)
// ThrowError logs errors internally when validation fails
output := buf.String()
assert.Contains(t, output, "ThrowError called before server started")
})
startTestServer(t, server)
defer server.Shutdown(t.Context())
tests := []struct {
name string
request *http.Request
error hws.HWSError
valid bool
expectLogItem string
}{
{
name: "No HWSError.Status code",
request: nil,
error: hws.HWSError{},
valid: false,
expectLogItem: "HWSError.StatusCode cannot be 0",
},
{
name: "Negative HWSError.Status code",
request: nil,
error: hws.HWSError{StatusCode: -1},
valid: false,
expectLogItem: "HWSError.StatusCode cannot be 0",
},
{
name: "No HWSError.Message",
request: nil,
error: hws.HWSError{StatusCode: http.StatusInternalServerError},
valid: false,
expectLogItem: "HWSError.Message cannot be empty",
},
{
name: "No HWSError.Error",
@@ -127,7 +133,7 @@ func Test_ThrowError(t *testing.T) {
StatusCode: http.StatusInternalServerError,
Message: "An error occured",
},
valid: false,
expectLogItem: "HWSError.Error cannot be nil",
},
{
name: "No request provided",
@@ -137,7 +143,7 @@ func Test_ThrowError(t *testing.T) {
Message: "An error occured",
Error: errors.New("Error"),
},
valid: false,
expectLogItem: "Request cannot be nil",
},
{
name: "Valid",
@@ -147,106 +153,92 @@ func Test_ThrowError(t *testing.T) {
Message: "An error occured",
Error: errors.New("Error"),
},
valid: true,
expectLogItem: "An error occured",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf.Reset()
rr := httptest.NewRecorder()
err := server.ThrowError(rr, tt.request, tt.error)
if tt.valid {
assert.NoError(t, err)
} else {
t.Log(err)
assert.Error(t, err)
}
server.ThrowError(rr, tt.request, tt.error)
// ThrowError no longer returns errors; check logs instead
output := buf.String()
assert.Contains(t, output, tt.expectLogItem)
})
}
t.Run("Log level set correctly", func(t *testing.T) {
buf.Reset()
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
err := server.ThrowError(rr, req, hws.HWSError{
server.ThrowError(rr, req, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "An error occured",
Error: errors.New("Error"),
Level: hws.ErrorWARN,
})
assert.NoError(t, err)
_, err = buf.ReadString([]byte(" ")[0])
_, err := buf.ReadString([]byte(" ")[0])
require.NoError(t, err)
loglvl, err := buf.ReadString([]byte(" ")[0])
assert.NoError(t, err)
if loglvl != "\x1b[33mWRN\x1b[0m " {
err = errors.New("Log level not set correctly")
}
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, "\x1b[33mWRN\x1b[0m ", loglvl, "Log level should be WRN for ErrorWARN")
buf.Reset()
err = server.ThrowError(rr, req, hws.HWSError{
server.ThrowError(rr, req, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "An error occured",
Error: errors.New("Error"),
})
assert.NoError(t, err)
_, err = buf.ReadString([]byte(" ")[0])
require.NoError(t, err)
loglvl, err = buf.ReadString([]byte(" ")[0])
assert.NoError(t, err)
if loglvl != "\x1b[31mERR\x1b[0m " {
err = errors.New("Log level not set correctly")
}
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, "\x1b[31mERR\x1b[0m ", loglvl, "Log level should be ERR when no level specified")
})
t.Run("Error page doesnt render if no error page set", func(t *testing.T) {
// Must be run before adding the error page to the test server
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
err := server.ThrowError(rr, req, hws.HWSError{
server.ThrowError(rr, req, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "An error occured",
Error: errors.New("Error"),
RenderErrorPage: true,
})
assert.NoError(t, err)
body := rr.Body.String()
if body != "" {
assert.Error(t, nil)
}
assert.Empty(t, body, "Error page should not render when no error page is set")
})
t.Run("Error page renders", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
// Adding the error page will carry over to all future tests and cant be undone
server.AddErrorPage(goodRender)
err := server.ThrowError(rr, req, hws.HWSError{
err := server.AddErrorPage(goodRender)
require.NoError(t, err)
server.ThrowError(rr, req, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "An error occured",
Error: errors.New("Error"),
RenderErrorPage: true,
})
assert.NoError(t, err)
body := rr.Body.String()
if body == "" {
assert.Error(t, nil)
}
assert.NotEmpty(t, body, "Error page should render when RenderErrorPage is true")
})
t.Run("Error page doesnt render if no told to render", func(t *testing.T) {
t.Run("Error page doesnt render if not told to render", func(t *testing.T) {
// Error page already added to server
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
err := server.ThrowError(rr, req, hws.HWSError{
server.ThrowError(rr, req, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "An error occured",
Error: errors.New("Error"),
})
assert.NoError(t, err)
body := rr.Body.String()
if body != "" {
assert.Error(t, nil)
}
assert.Empty(t, body, "Error page should not render when RenderErrorPage is false")
})
server.Shutdown(t.Context())
err := server.Shutdown(t.Context())
require.NoError(t, err)
t.Run("Doesn't error if no logger added to server", func(t *testing.T) {
t.Run("Doesn't panic if no logger added to server", func(t *testing.T) {
server, err := hws.NewServer(&hws.Config{
Host: "127.0.0.1",
Port: randomPort(),
@@ -261,13 +253,18 @@ func Test_ThrowError(t *testing.T) {
err = server.Start(t.Context())
require.NoError(t, err)
<-server.Ready()
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
err = server.ThrowError(rr, req, hws.HWSError{
// Should not panic when no logger is present
assert.NotPanics(t, func() {
server.ThrowError(rr, req, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "An error occured",
Error: errors.New("Error"),
})
assert.NoError(t, err)
}, "ThrowError should not panic when no logger is present")
err = server.Shutdown(t.Context())
require.NoError(t, err)
})
}

View File

@@ -13,8 +13,8 @@ func (e EZConfIntegration) PackagePath() string {
}
// ConfigFunc returns the ConfigFromEnv function for ezconf
func (e EZConfIntegration) ConfigFunc() func() (interface{}, error) {
return func() (interface{}, error) {
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
return func() (any, error) {
return ConfigFromEnv()
}
}

View File

@@ -14,6 +14,7 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/gobwas/glob v0.2.3
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect

View File

@@ -9,6 +9,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=

View File

@@ -6,14 +6,15 @@ import (
"net/url"
"git.haelnorr.com/h/golib/hlog"
"github.com/gobwas/glob"
)
type logger struct {
logger *hlog.Logger
ignoredPaths []string
ignoredPaths []glob.Glob
}
// TODO: add tests to make sure all the fields are correctly set
// LogError uses the attached logger to log a HWSError
func (s *Server) LogError(err HWSError) {
if s.logger == nil {
return
@@ -29,45 +30,34 @@ func (s *Server) LogError(err HWSError) {
s.logger.logger.Warn().Err(err.Error).Msg(err.Message)
return
case ErrorERROR:
s.logger.logger.Error().Err(err.Error).Msg(err.Message)
s.logger.logger.Error().Str("stacktrace", fmt.Sprintf("%+v", err.Error)).Err(err.Error).Msg(err.Message)
return
case ErrorFATAL:
s.logger.logger.Fatal().Err(err.Error).Msg(err.Message)
s.logger.logger.Fatal().Str("stacktrace", fmt.Sprintf("%+v", err.Error)).Err(err.Error).Msg(err.Message)
return
case ErrorPANIC:
s.logger.logger.Panic().Err(err.Error).Msg(err.Message)
s.logger.logger.Panic().Str("stacktrace", fmt.Sprintf("%+v", err.Error)).Err(err.Error).Msg(err.Message)
return
default:
s.logger.logger.Error().Err(err.Error).Msg(err.Message)
s.logger.logger.Error().Str("stacktrace", fmt.Sprintf("%+v", err.Error)).Err(err.Error).Msg(err.Message)
}
}
func (server *Server) LogFatal(err error) {
if err == nil {
err = errors.New("LogFatal was called with a nil error")
}
if server.logger == nil {
fmt.Printf("FATAL - %s: %s", "A fatal error has occured", err.Error())
return
}
server.logger.logger.Fatal().Err(err).Msg("A fatal error has occured")
}
// Server.AddLogger adds a logger to the server to use for request logging.
func (server *Server) AddLogger(hlogger *hlog.Logger) error {
// AddLogger adds a logger to the server to use for request logging.
func (s *Server) AddLogger(hlogger *hlog.Logger) error {
if hlogger == nil {
return errors.New("Unable to add logger, no logger provided")
return errors.New("unable to add logger, no logger provided")
}
server.logger = &logger{
s.logger = &logger{
logger: hlogger,
}
return nil
}
// Server.LoggerIgnorePaths sets a list of URL paths to ignore logging for.
// LoggerIgnorePaths sets a list of URL paths to ignore logging for.
// Path should match the url.URL.Path field, see https://pkg.go.dev/net/url#URL
// Useful for ignoring requests to CSS files or favicons
func (server *Server) LoggerIgnorePaths(paths ...string) error {
func (s *Server) LoggerIgnorePaths(paths ...string) error {
for _, path := range paths {
u, err := url.Parse(path)
valid := err == nil &&
@@ -76,9 +66,22 @@ func (server *Server) LoggerIgnorePaths(paths ...string) error {
u.RawQuery == "" &&
u.Fragment == ""
if !valid {
return fmt.Errorf("Invalid path: '%s'", path)
return fmt.Errorf("invalid path: '%s'", path)
}
}
server.logger.ignoredPaths = paths
s.logger.ignoredPaths = prepareGlobs(paths)
return nil
}
func prepareGlobs(paths []string) []glob.Glob {
compiledGlobs := make([]glob.Glob, 0, len(paths))
for _, pattern := range paths {
g, err := glob.Compile(pattern)
if err != nil {
// If pattern fails to compile, skip it
continue
}
compiledGlobs = append(compiledGlobs, g)
}
return compiledGlobs
}

View File

@@ -197,7 +197,7 @@ func Test_LoggerIgnorePaths(t *testing.T) {
err := server.LoggerIgnorePaths("http://example.com/path")
assert.Error(t, err)
assert.Contains(t, err.Error(), "Invalid path")
assert.Contains(t, err.Error(), "invalid path")
})
t.Run("Invalid path with host", func(t *testing.T) {
@@ -207,7 +207,7 @@ func Test_LoggerIgnorePaths(t *testing.T) {
err := server.LoggerIgnorePaths("//example.com/path")
assert.Error(t, err)
if err != nil {
assert.Contains(t, err.Error(), "Invalid path")
assert.Contains(t, err.Error(), "invalid path")
}
})
@@ -217,7 +217,7 @@ func Test_LoggerIgnorePaths(t *testing.T) {
err := server.LoggerIgnorePaths("/path?query=value")
assert.Error(t, err)
assert.Contains(t, err.Error(), "Invalid path")
assert.Contains(t, err.Error(), "invalid path")
})
t.Run("Invalid path with fragment", func(t *testing.T) {
@@ -226,7 +226,7 @@ func Test_LoggerIgnorePaths(t *testing.T) {
err := server.LoggerIgnorePaths("/path#fragment")
assert.Error(t, err)
assert.Contains(t, err.Error(), "Invalid path")
assert.Contains(t, err.Error(), "invalid path")
})
t.Run("Valid paths", func(t *testing.T) {

View File

@@ -5,35 +5,37 @@ import (
"net/http"
)
type Middleware func(h http.Handler) http.Handler
type MiddlewareFunc func(w http.ResponseWriter, r *http.Request) (*http.Request, *HWSError)
type (
Middleware func(h http.Handler) http.Handler
MiddlewareFunc func(w http.ResponseWriter, r *http.Request) (*http.Request, *HWSError)
)
// Server.AddMiddleware registers all the middleware.
// AddMiddleware registers all the middleware.
// Middleware will be run in the order that they are provided.
// Can only be called once
func (server *Server) AddMiddleware(middleware ...Middleware) error {
if !server.routes {
func (s *Server) AddMiddleware(middleware ...Middleware) error {
if !s.routes {
return errors.New("Server.AddRoutes must be called before Server.AddMiddleware")
}
if server.middleware {
if s.middleware {
return errors.New("Server.AddMiddleware already called")
}
// RUN LOGGING MIDDLEWARE FIRST
server.server.Handler = logging(server.server.Handler, server.logger)
s.server.Handler = logging(s.server.Handler, s.logger)
// LOOP PROVIDED MIDDLEWARE IN REVERSE order
for i := len(middleware); i > 0; i-- {
server.server.Handler = middleware[i-1](server.server.Handler)
s.server.Handler = middleware[i-1](s.server.Handler)
}
// RUN GZIP
if server.GZIP {
server.server.Handler = addgzip(server.server.Handler)
if s.GZIP {
s.server.Handler = addgzip(s.server.Handler)
}
// RUN TIMER MIDDLEWARE LAST
server.server.Handler = startTimer(server.server.Handler)
s.server.Handler = startTimer(s.server.Handler)
server.middleware = true
s.middleware = true
return nil
}
@@ -43,17 +45,19 @@ func (server *Server) AddMiddleware(middleware ...Middleware) error {
// and returns a new request and optional HWSError.
// If a HWSError is returned, server.ThrowError will be called.
// If HWSError.RenderErrorPage is true, the request chain will be terminated and the error page rendered
func (server *Server) NewMiddleware(
func (s *Server) NewMiddleware(
middlewareFunc MiddlewareFunc,
) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
newReq, herr := middlewareFunc(w, r)
if herr != nil {
server.ThrowError(w, r, *herr)
s.ThrowError(w, r, *herr)
if herr.RenderErrorPage {
return
}
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, newReq)
})

View File

@@ -2,8 +2,9 @@ package hws
import (
"net/http"
"slices"
"time"
"github.com/gobwas/glob"
)
// Middleware to add logs to console with details of the request
@@ -13,7 +14,7 @@ func logging(next http.Handler, logger *logger) http.Handler {
next.ServeHTTP(w, r)
return
}
if slices.Contains(logger.ignoredPaths, r.URL.Path) {
if globTest(r.URL.Path, logger.ignoredPaths) {
next.ServeHTTP(w, r)
return
}
@@ -36,3 +37,12 @@ func logging(next http.Handler, logger *logger) http.Handler {
Msg("Served")
})
}
func globTest(testPath string, globs []glob.Glob) bool {
for _, g := range globs {
if g.Match(testPath) {
return true
}
}
return false
}

View File

@@ -18,16 +18,24 @@ func startTimer(next http.Handler) http.Handler {
)
}
type contextKey string
func (c contextKey) String() string {
return "hws context key " + string(c)
}
var requestTimerCtxKey = contextKey("request-timer")
// Set the start time of the request
func setStart(ctx context.Context, time time.Time) context.Context {
return context.WithValue(ctx, "hws context key request-timer", time)
return context.WithValue(ctx, requestTimerCtxKey, time)
}
// Get the start time of the request
func getStartTime(ctx context.Context) (time.Time, error) {
start, ok := ctx.Value("hws context key request-timer").(time.Time)
start, ok := ctx.Value(requestTimerCtxKey).(time.Time)
if !ok {
return time.Time{}, errors.New("Failed to get start time of request")
return time.Time{}, errors.New("failed to get start time of request")
}
return start, nil
}

View File

@@ -100,7 +100,7 @@ func (s *Server) NotifySub(nt notify.Notification) {
}
_, exists := s.notifier.clients.getClient(nt.Target)
if !exists {
err := fmt.Errorf("Tried to notify subscriber that doesn't exist - subID: %s", nt.Target)
err := fmt.Errorf("tried to notify subscriber that doesn't exist - subID: %s", nt.Target)
s.LogError(HWSError{Level: ErrorWARN, Message: "Failed to notify", Error: err})
return
}
@@ -119,7 +119,7 @@ func (s *Server) NotifyID(nt notify.Notification, altID string) {
clients, exists := s.notifier.clients.clientsIDMap[altID]
s.notifier.clients.lock.RUnlock()
if !exists {
err := fmt.Errorf("Tried to notify client group that doesn't exist - altID: %s", altID)
err := fmt.Errorf("tried to notify client group that doesn't exist - altID: %s", altID)
s.LogError(HWSError{Level: ErrorWARN, Message: "Failed to notify", Error: err})
return
}

View File

@@ -17,6 +17,7 @@ func newTestServerWithNotifier(t *testing.T) *Server {
cfg := &Config{
Host: "127.0.0.1",
Port: 0,
ShutdownDelay: 0, // No delay for tests
}
server, err := NewServer(cfg)
@@ -359,7 +360,7 @@ func Test_ActiveClientStaysAlive(t *testing.T) {
done := make(chan bool)
go func() {
for i := 0; i < 3; i++ {
for range 3 {
<-ticker.C
server.NotifySub(notify.Notification{
Target: client.sub.ID,
@@ -460,7 +461,7 @@ func Test_SlowConsumerTolerance(t *testing.T) {
defer close(stop)
// Send 10 notifications quickly (buffer is 10)
for i := 0; i < 10; i++ {
for range 10 {
server.NotifySub(notify.Notification{
Target: client.sub.ID,
Message: "Burst message",
@@ -468,7 +469,7 @@ func Test_SlowConsumerTolerance(t *testing.T) {
}
// Client should receive all 10
for i := 0; i < 10; i++ {
for i := range 10 {
select {
case <-notifications:
// Received
@@ -487,7 +488,7 @@ func Test_SingleTimeoutRecovery(t *testing.T) {
defer close(stop)
// Fill buffer completely (buffer is 10)
for i := 0; i < 10; i++ {
for range 10 {
server.NotifySub(notify.Notification{
Target: client.sub.ID,
Message: "Fill buffer",
@@ -500,15 +501,15 @@ func Test_SingleTimeoutRecovery(t *testing.T) {
Message: "Timeout message",
})
// Wait for timeout
time.Sleep(6 * time.Second)
// Wait for timeout (5s timeout + small buffer)
time.Sleep(5100 * time.Millisecond)
// Check failure count (should be 1)
fails := atomic.LoadInt32(&client.consecutiveFails)
require.Equal(t, int32(1), fails, "Should have 1 timeout")
// Now read all buffered messages
for i := 0; i < 10; i++ {
for range 10 {
<-notifications
}
@@ -538,7 +539,7 @@ func Test_ConsecutiveFailureDisconnect(t *testing.T) {
defer close(stop)
// Fill buffer and never read to cause 5 consecutive timeouts
for i := 0; i < 20; i++ {
for range 20 {
server.NotifySub(notify.Notification{
Target: client.sub.ID,
Message: "Timeout message",
@@ -684,7 +685,7 @@ func Test_ConcurrentSubscriptions(t *testing.T) {
var wg sync.WaitGroup
clients := make([]*Client, 100)
for i := 0; i < 100; i++ {
for i := range 100 {
wg.Add(1)
go func(index int) {
defer wg.Done()
@@ -716,7 +717,7 @@ func Test_ConcurrentNotifications(t *testing.T) {
messageCount := 50
// Send from multiple goroutines
for i := 0; i < messageCount; i++ {
for i := range messageCount {
wg.Add(1)
go func(index int) {
defer wg.Done()
@@ -733,7 +734,7 @@ func Test_ConcurrentNotifications(t *testing.T) {
// This is expected behavior - we're testing thread safety, not guaranteed delivery
// Just verify we receive at least some messages without panicking or deadlocking
received := 0
timeout := time.After(2 * time.Second)
timeout := time.After(500 * time.Millisecond)
for received < messageCount {
select {
case <-notifications:
@@ -751,7 +752,7 @@ func Test_ConcurrentCleanup(t *testing.T) {
server := newTestServerWithNotifier(t)
// Create some clients
for i := 0; i < 10; i++ {
for i := range 10 {
client, _ := server.GetClient("", "")
// Set some to be old
if i%2 == 0 {
@@ -790,16 +791,14 @@ func Test_NoRaceConditions(t *testing.T) {
var wg sync.WaitGroup
// Create a few clients and read from them
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for range 5 {
wg.Go(func() {
client, _ := server.GetClient("", "")
notifications, stop := client.Listen()
defer close(stop)
// Actively read messages
timeout := time.After(2 * time.Second)
timeout := time.After(200 * time.Millisecond)
for {
select {
case <-notifications:
@@ -808,21 +807,18 @@ func Test_NoRaceConditions(t *testing.T) {
return
}
}
}()
})
}
// Send a few notifications
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 20; j++ {
wg.Go(func() {
for range 10 {
server.NotifyAll(notify.Notification{
Message: "Stress test",
})
time.Sleep(50 * time.Millisecond)
time.Sleep(10 * time.Millisecond)
}
}()
})
wg.Wait()
}
@@ -948,7 +944,7 @@ func Test_ListenSignature(t *testing.T) {
require.NotNil(t, stop)
// notifications should be receive-only
_, ok := interface{}(notifications).(<-chan notify.Notification)
_, ok := any(notifications).(<-chan notify.Notification)
require.True(t, ok, "notifications should be receive-only channel")
// stop should be closeable
@@ -964,7 +960,7 @@ func Test_BufferSize(t *testing.T) {
defer close(stop)
// Send 10 messages without reading (buffer size is 10)
for i := 0; i < 10; i++ {
for range 10 {
server.NotifySub(notify.Notification{
Target: client.sub.ID,
Message: "Buffered",
@@ -975,7 +971,7 @@ func Test_BufferSize(t *testing.T) {
time.Sleep(100 * time.Millisecond)
// Read all 10
for i := 0; i < 10; i++ {
for i := range 10 {
select {
case <-notifications:
// Success

View File

@@ -30,13 +30,13 @@ const (
MethodPATCH Method = "PATCH"
)
// Server.AddRoutes registers the page handlers for the server.
// AddRoutes registers the page handlers for the server.
// At least one route must be provided.
// If any route patterns (path + method) are defined multiple times, the first
// instance will be added and any additional conflicts will be discarded.
func (server *Server) AddRoutes(routes ...Route) error {
func (s *Server) AddRoutes(routes ...Route) error {
if len(routes) == 0 {
return errors.New("No routes provided")
return errors.New("no routes provided")
}
patterns := []string{}
mux := http.NewServeMux()
@@ -47,10 +47,10 @@ func (server *Server) AddRoutes(routes ...Route) error {
}
for _, method := range route.Methods {
if !validMethod(method) {
return fmt.Errorf("Invalid method %s for path %s", method, route.Path)
return fmt.Errorf("invalid method %s for path %s", method, route.Path)
}
if route.Handler == nil {
return fmt.Errorf("No handler provided for %s %s", method, route.Path)
return fmt.Errorf("no handler provided for %s %s", method, route.Path)
}
pattern := fmt.Sprintf("%s %s", method, route.Path)
if slices.Contains(patterns, pattern) {
@@ -61,8 +61,8 @@ func (server *Server) AddRoutes(routes ...Route) error {
}
}
server.server.Handler = mux
server.routes = true
s.server.Handler = mux
s.routes = true
return nil
}

View File

@@ -18,7 +18,7 @@ func Test_AddRoutes(t *testing.T) {
server := createTestServer(t, &buf)
err := server.AddRoutes()
assert.Error(t, err)
assert.Contains(t, err.Error(), "No routes provided")
assert.Contains(t, err.Error(), "no routes provided")
})
t.Run("Single valid route", func(t *testing.T) {
@@ -58,7 +58,7 @@ func Test_AddRoutes(t *testing.T) {
Handler: handler,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "Invalid method")
assert.Contains(t, err.Error(), "invalid method")
})
t.Run("No handler provided", func(t *testing.T) {
@@ -69,7 +69,7 @@ func Test_AddRoutes(t *testing.T) {
Handler: nil,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "No handler provided")
assert.Contains(t, err.Error(), "no handler provided")
})
t.Run("All HTTP methods are valid", func(t *testing.T) {
@@ -203,7 +203,7 @@ func Test_AddRoutes_MultipleMethods(t *testing.T) {
Handler: handler,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "Invalid method")
assert.Contains(t, err.Error(), "invalid method")
})
t.Run("Empty Methods slice falls back to Method field", func(t *testing.T) {

View File

@@ -26,14 +26,14 @@ type Server struct {
}
// Ready returns a channel that is closed when the server is started
func (server *Server) Ready() <-chan struct{} {
return server.ready
func (s *Server) Ready() <-chan struct{} {
return s.ready
}
// IsReady checks if the server is running
func (server *Server) IsReady() bool {
func (s *Server) IsReady() bool {
select {
case <-server.ready:
case <-s.ready:
return true
default:
return false
@@ -41,13 +41,13 @@ func (server *Server) IsReady() bool {
}
// Addr returns the server's network address
func (server *Server) Addr() string {
return server.server.Addr
func (s *Server) Addr() string {
return s.server.Addr
}
// Handler returns the server's HTTP handler for testing purposes
func (server *Server) Handler() http.Handler {
return server.server.Handler
func (s *Server) Handler() http.Handler {
return s.server.Handler
}
// NewServer returns a new hws.Server with the specified configuration.
@@ -75,7 +75,7 @@ func NewServer(config *Config) (*Server, error) {
valid := isValidHostname(config.Host)
if !valid {
return nil, fmt.Errorf("Hostname '%s' is not valid", config.Host)
return nil, fmt.Errorf("hostname '%s' is not valid", config.Host)
}
httpServer := &http.Server{
@@ -95,62 +95,64 @@ func NewServer(config *Config) (*Server, error) {
return server, nil
}
func (server *Server) Start(ctx context.Context) error {
func (s *Server) Start(ctx context.Context) error {
if ctx == nil {
return errors.New("Context cannot be nil")
}
if !server.routes {
if !s.routes {
return errors.New("Server.AddRoutes must be run before starting the server")
}
if !server.middleware {
err := server.AddMiddleware()
if !s.middleware {
err := s.AddMiddleware()
if err != nil {
return errors.Wrap(err, "server.AddMiddleware")
}
}
server.startNotifier()
s.startNotifier()
go func() {
if server.logger == nil {
fmt.Printf("Listening for requests on %s", server.server.Addr)
if s.logger == nil {
fmt.Printf("Listening for requests on %s", s.server.Addr)
} else {
server.logger.logger.Info().Str("address", server.server.Addr).Msg("Listening for requests")
s.logger.logger.Info().Str("address", s.server.Addr).Msg("Listening for requests")
}
if err := server.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
if server.logger == nil {
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
if s.logger == nil {
fmt.Printf("Server encountered a fatal error: %s", err.Error())
} else {
server.LogError(HWSError{Error: err, Message: "Server encountered a fatal error"})
s.LogError(HWSError{Error: err, Message: "Server encountered a fatal error"})
}
}
}()
server.waitUntilReady(ctx)
s.waitUntilReady(ctx)
return nil
}
func (server *Server) Shutdown(ctx context.Context) error {
server.logger.logger.Debug().Dur("shutdown_delay", server.shutdowndelay).Msg("HWS Server shutting down")
server.NotifyAll(notify.Notification{
func (s *Server) Shutdown(ctx context.Context) error {
if s.logger != nil {
s.logger.logger.Debug().Dur("shutdown_delay", s.shutdowndelay).Msg("HWS Server shutting down")
}
s.NotifyAll(notify.Notification{
Title: "Shutting down",
Message: fmt.Sprintf("Server is shutting down in %v", server.shutdowndelay),
Message: fmt.Sprintf("Server is shutting down in %v", s.shutdowndelay),
Level: LevelShutdown,
})
<-time.NewTimer(server.shutdowndelay).C
if !server.IsReady() {
<-time.NewTimer(s.shutdowndelay).C
if !s.IsReady() {
return errors.New("Server isn't running")
}
if ctx == nil {
return errors.New("Context cannot be nil")
}
err := server.server.Shutdown(ctx)
err := s.server.Shutdown(ctx)
if err != nil {
return errors.Wrap(err, "Failed to shutdown the server gracefully")
}
server.closeNotifier()
server.ready = make(chan struct{})
s.closeNotifier()
s.ready = make(chan struct{})
return nil
}
@@ -168,7 +170,7 @@ func isValidHostname(host string) bool {
return false
}
func (server *Server) waitUntilReady(ctx context.Context) error {
func (s *Server) waitUntilReady(ctx context.Context) error {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
@@ -180,14 +182,14 @@ func (server *Server) waitUntilReady(ctx context.Context) error {
return ctx.Err()
case <-ticker.C:
resp, err := http.Get("http://" + server.server.Addr + "/healthz")
resp, err := http.Get("http://" + s.server.Addr + "/healthz")
if err != nil {
continue // not accepting yet
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
closeOnce.Do(func() { close(server.ready) })
closeOnce.Do(func() { close(s.ready) })
return nil
}
}

View File

@@ -28,6 +28,7 @@ func createTestServer(t *testing.T, w io.Writer) *hws.Server {
server, err := hws.NewServer(&hws.Config{
Host: "127.0.0.1",
Port: randomPort(),
ShutdownDelay: 0, // No delay for tests
})
require.NoError(t, err)

View File

@@ -9,6 +9,7 @@ import (
"git.haelnorr.com/h/golib/hlog"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/jwt"
"github.com/gobwas/glob"
"github.com/pkg/errors"
)
@@ -16,7 +17,7 @@ type Authenticator[T Model, TX DBTransaction] struct {
tokenGenerator *jwt.TokenGenerator
load LoadFunc[T, TX]
beginTx BeginTX
ignoredPaths []string
ignoredPaths []glob.Glob
logger *hlog.Logger
server *hws.Server
errorPage hws.ErrorPageFunc

View File

@@ -13,8 +13,8 @@ func (e EZConfIntegration) PackagePath() string {
}
// ConfigFunc returns the ConfigFromEnv function for ezconf
func (e EZConfIntegration) ConfigFunc() func() (interface{}, error) {
return func() (interface{}, error) {
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
return func() (any, error) {
return ConfigFromEnv()
}
}

View File

@@ -6,16 +6,19 @@ require (
git.haelnorr.com/h/golib/cookies v0.9.0
git.haelnorr.com/h/golib/env v0.9.1
git.haelnorr.com/h/golib/hlog v0.10.4
git.haelnorr.com/h/golib/hws v0.3.0
git.haelnorr.com/h/golib/hws v0.5.0
git.haelnorr.com/h/golib/jwt v0.10.1
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.11.1
)
require git.haelnorr.com/h/golib/notify v0.1.0 // indirect
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/gobwas/glob v0.2.3
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect

View File

@@ -4,10 +4,12 @@ git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjo
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ=
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
git.haelnorr.com/h/golib/hws v0.3.0 h1:/YGzxd3sRR3DFU6qVZxpJMKV3W2wCONqZKYUDIercCo=
git.haelnorr.com/h/golib/hws v0.3.0/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo=
git.haelnorr.com/h/golib/hws v0.5.0 h1:0CSv2f+dm/KzB/o5o6uXCyvN74iBdMTImhkyAZzU52c=
git.haelnorr.com/h/golib/hws v0.5.0/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM=
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
git.haelnorr.com/h/golib/notify v0.1.0 h1:xdf6zd21F6n+SuGTeJiuLNMf6zFXMvwpKD0gmNq8N10=
git.haelnorr.com/h/golib/notify v0.1.0/go.mod h1:ARqaRmCYb8LMURhDM75sG+qX+YpqXmUVeAtacwjHjBc=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
@@ -15,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=

View File

@@ -23,8 +23,7 @@ func (tm TestModel) GetID() int {
return tm.ID
}
type TestTransaction struct {
}
type TestTransaction struct{}
func (tt *TestTransaction) Exec(query string, args ...any) (sql.Result, error) {
return nil, nil
@@ -137,8 +136,10 @@ func TestCurrentModel(t *testing.T) {
func TestConfigFromEnv_MissingSecretKey(t *testing.T) {
// Clear environment variables
originalSecret := os.Getenv("HWSAUTH_SECRET_KEY")
os.Setenv("HWSAUTH_SECRET_KEY", "")
defer os.Setenv("HWSAUTH_SECRET_KEY", originalSecret)
_ = os.Setenv("HWSAUTH_SECRET_KEY", "")
defer func() {
_ = os.Setenv("HWSAUTH_SECRET_KEY", originalSecret)
}()
_, err := ConfigFromEnv()
assert.Error(t, err)
@@ -327,7 +328,9 @@ func TestNewAuthenticator_SSLWithoutTrustedHost(t *testing.T) {
db, _, err := createMockDB()
require.NoError(t, err)
defer db.Close()
defer func() {
_ = db.Close()
}()
auth, err := NewAuthenticator(
cfg,
@@ -409,7 +412,9 @@ func TestGetAuthenticatedUser_NoTokens(t *testing.T) {
db, _, err := createMockDB()
require.NoError(t, err)
defer db.Close()
defer func() {
_ = db.Close()
}()
auth, err := NewAuthenticator(
cfg,
@@ -454,7 +459,9 @@ func TestLogin_BasicFunctionality(t *testing.T) {
db, _, err := createMockDB()
require.NoError(t, err)
defer db.Close()
defer func() {
_ = db.Close()
}()
auth, err := NewAuthenticator(
cfg,
@@ -476,6 +483,7 @@ func TestLogin_BasicFunctionality(t *testing.T) {
// This test mainly checks that the function doesn't panic and has right call signature
// The actual JWT functionality is tested in jwt package itself
assert.NotPanics(t, func() {
auth.Login(w, r, user, rememberMe)
err := auth.Login(w, r, user, rememberMe)
require.NoError(t, err)
})
}

View File

@@ -3,6 +3,8 @@ package hwsauth
import (
"fmt"
"net/url"
"github.com/gobwas/glob"
)
// IgnorePaths excludes specified paths from authentication middleware.
@@ -22,9 +24,22 @@ func (auth *Authenticator[T, TX]) IgnorePaths(paths ...string) error {
u.RawQuery == "" &&
u.Fragment == ""
if !valid {
return fmt.Errorf("Invalid path: '%s'", path)
return fmt.Errorf("invalid path: '%s'", path)
}
}
auth.ignoredPaths = paths
auth.ignoredPaths = prepareGlobs(paths)
return nil
}
func prepareGlobs(paths []string) []glob.Glob {
compiledGlobs := make([]glob.Glob, 0, len(paths))
for _, pattern := range paths {
g, err := glob.Compile(pattern)
if err != nil {
// If pattern fails to compile, skip it
continue
}
compiledGlobs = append(compiledGlobs, g)
}
return compiledGlobs
}

View File

@@ -33,14 +33,18 @@ func (auth *Authenticator[T, TX]) Logout(tx TX, w http.ResponseWriter, r *http.R
if err != nil {
return errors.Wrap(err, "auth.getTokens")
}
if aT != nil {
err = aT.Revoke(jwt.DBTransaction(tx))
if err != nil {
return errors.Wrap(err, "aT.Revoke")
}
}
if rT != nil {
err = rT.Revoke(jwt.DBTransaction(tx))
if err != nil {
return errors.Wrap(err, "rT.Revoke")
}
}
cookies.DeleteCookie(w, "access", "/")
cookies.DeleteCookie(w, "refresh", "/")
return nil

View File

@@ -2,10 +2,12 @@ package hwsauth
import (
"context"
"git.haelnorr.com/h/golib/hws"
"net/http"
"slices"
"time"
"git.haelnorr.com/h/golib/hws"
"github.com/gobwas/glob"
"github.com/pkg/errors"
)
// Authenticate returns the main authentication middleware.
@@ -14,14 +16,22 @@ import (
//
// Example:
//
// server.AddMiddleware(auth.Authenticate())
func (auth *Authenticator[T, TX]) Authenticate() hws.Middleware {
return auth.server.NewMiddleware(auth.authenticate())
// server.AddMiddleware(auth.Authenticate(nil))
//
// If extraCheck is provided, it will run just before the user is added to the context,
// and the return will determine if the user will be added, or the request passed on
// without the user.
func (auth *Authenticator[T, TX]) Authenticate(
extraCheck func(ctx context.Context, model T, tx TX, w http.ResponseWriter, r *http.Request) (bool, *hws.HWSError),
) hws.Middleware {
return auth.server.NewMiddleware(auth.authenticate(extraCheck))
}
func (auth *Authenticator[T, TX]) authenticate() hws.MiddlewareFunc {
func (auth *Authenticator[T, TX]) authenticate(
extraCheck func(ctx context.Context, model T, tx TX, w http.ResponseWriter, r *http.Request) (bool, *hws.HWSError),
) hws.MiddlewareFunc {
return func(w http.ResponseWriter, r *http.Request) (*http.Request, *hws.HWSError) {
if slices.Contains(auth.ignoredPaths, r.URL.Path) {
if globTest(r.URL.Path, auth.ignoredPaths) {
return r, nil
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
@@ -30,25 +40,70 @@ func (auth *Authenticator[T, TX]) authenticate() hws.MiddlewareFunc {
// Start the transaction
tx, err := auth.beginTx(ctx)
if err != nil {
return nil, &hws.HWSError{Message: "Unable to start transaction", StatusCode: http.StatusServiceUnavailable, Error: err}
return nil, &hws.HWSError{
Message: "Unable to start transaction",
StatusCode: http.StatusServiceUnavailable,
Error: errors.Wrap(err, "auth.beginTx"),
}
}
defer func() {
_ = tx.Rollback()
}()
// Type assert to TX - safe because user's beginTx should return their TX type
txTyped, ok := tx.(TX)
if !ok {
return nil, &hws.HWSError{Message: "Transaction type mismatch", StatusCode: http.StatusInternalServerError, Error: err}
return nil, &hws.HWSError{
Message: "Transaction type mismatch",
StatusCode: http.StatusInternalServerError,
Error: errors.Wrap(err, "TX type not ok"),
}
}
model, err := auth.getAuthenticatedUser(txTyped, w, r)
if err != nil {
tx.Rollback()
rberr := tx.Rollback()
if rberr != nil {
return nil, &hws.HWSError{
Message: "Failed rolling back after error",
StatusCode: http.StatusInternalServerError,
Error: errors.Wrap(err, "tx.Rollback"),
}
}
auth.logger.Debug().
Str("remote_addr", r.RemoteAddr).
Err(err).
Msg("Failed to authenticate user")
return r, nil
}
tx.Commit()
var check bool
if extraCheck != nil {
var err *hws.HWSError
check, err = extraCheck(ctx, model.model, txTyped, w, r)
if err != nil {
return nil, err
}
}
err = tx.Commit()
if err != nil {
return nil, &hws.HWSError{
Message: "Failed to commit transaction",
StatusCode: http.StatusInternalServerError,
Error: errors.Wrap(err, "tx.Commit"),
}
}
authContext := setAuthenticatedModel(r.Context(), model)
newReq := r.WithContext(authContext)
if extraCheck == nil || check {
return newReq, nil
}
return r, nil
}
}
func globTest(testPath string, globs []glob.Glob) bool {
for _, g := range globs {
if g.Match(testPath) {
return true
}
}
return false
}

View File

@@ -39,9 +39,17 @@ type ContextLoader[T Model] func(ctx context.Context) T
// }
type LoadFunc[T Model, TX DBTransaction] func(ctx context.Context, tx TX, id int) (T, error)
type contextKey string
func (c contextKey) String() string {
return "hwsauth context key" + string(c)
}
var authenticatedModelContextKey = contextKey("authenticated-model")
// Return a new context with the user added in
func setAuthenticatedModel[T Model](ctx context.Context, m authenticatedModel[T]) context.Context {
return context.WithValue(ctx, "hwsauth context key authenticated-model", m)
return context.WithValue(ctx, authenticatedModelContextKey, m)
}
// Retrieve a user from the given context. Returns nil if not set
@@ -53,7 +61,7 @@ func getAuthorizedModel[T Model](ctx context.Context) (model authenticatedModel[
model = authenticatedModel[T]{}
}
}()
model, cok := ctx.Value("hwsauth context key authenticated-model").(authenticatedModel[T])
model, cok := ctx.Value(authenticatedModelContextKey).(authenticatedModel[T])
if !cok {
return authenticatedModel[T]{}, false
}

View File

@@ -19,15 +19,12 @@ func (auth *Authenticator[T, TX]) LoginReq(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, ok := getAuthorizedModel[T](r.Context())
if !ok {
err := auth.server.ThrowError(w, r, hws.HWSError{
auth.server.ThrowError(w, r, hws.HWSError{
Error: errors.New("Login required"),
Message: "Please login to view this page",
StatusCode: http.StatusUnauthorized,
RenderErrorPage: true,
})
if err != nil {
auth.server.ThrowFatal(w, err)
}
return
}
next.ServeHTTP(w, r)
@@ -66,15 +63,12 @@ func (auth *Authenticator[T, TX]) FreshReq(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
model, ok := getAuthorizedModel[T](r.Context())
if !ok {
err := auth.server.ThrowError(w, r, hws.HWSError{
auth.server.ThrowError(w, r, hws.HWSError{
Error: errors.New("Login required"),
Message: "Please login to view this page",
StatusCode: http.StatusUnauthorized,
RenderErrorPage: true,
})
if err != nil {
auth.server.ThrowFatal(w, err)
}
return
}
isFresh := time.Now().Before(time.Unix(model.fresh, 0))

View File

@@ -34,7 +34,7 @@ func (auth *Authenticator[T, TX]) RefreshAuthTokens(tx TX, w http.ResponseWriter
rememberMe := map[string]bool{
"session": false,
"exp": true,
}[aT.TTL]
}[rT.TTL]
// issue new tokens for the user
err = jwt.SetTokenCookies(w, r, auth.tokenGenerator, rT.SUB, true, rememberMe, auth.SSL)
if err != nil {
@@ -55,14 +55,21 @@ func (auth *Authenticator[T, TX]) getTokens(
) (*jwt.AccessToken, *jwt.RefreshToken, error) {
// get the existing tokens from the cookies
atStr, rtStr := jwt.GetTokenCookies(r)
aT, err := auth.tokenGenerator.ValidateAccess(jwt.DBTransaction(tx), atStr)
var aT *jwt.AccessToken
var rT *jwt.RefreshToken
var err error
if atStr != "" {
aT, err = auth.tokenGenerator.ValidateAccess(jwt.DBTransaction(tx), atStr)
if err != nil {
return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateAccess")
}
rT, err := auth.tokenGenerator.ValidateRefresh(jwt.DBTransaction(tx), rtStr)
}
if rtStr != "" {
rT, err = auth.tokenGenerator.ValidateRefresh(jwt.DBTransaction(tx), rtStr)
if err != nil {
return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateRefresh")
}
}
return aT, rT, nil
}
@@ -72,13 +79,17 @@ func revokeTokenPair(
aT *jwt.AccessToken,
rT *jwt.RefreshToken,
) error {
if aT != nil {
err := aT.Revoke(tx)
if err != nil {
return errors.Wrap(err, "aT.Revoke")
}
err = rT.Revoke(tx)
}
if rT != nil {
err := rT.Revoke(tx)
if err != nil {
return errors.Wrap(err, "rT.Revoke")
}
}
return nil
}