Compare commits
1 Commits
hwsauth/v0
...
hws/v0.4.4
| Author | SHA1 | Date | |
|---|---|---|---|
| cd29f11296 |
173
AGENTS.md
Normal file
173
AGENTS.md
Normal 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
|
||||||
3
RULES.md
3
RULES.md
@@ -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.
|
Version numbers are specified using git tags.
|
||||||
Do not change version numbers. When updating documentation, append the branch name to the version number.
|
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
|
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
21
cookies/LICENSE
Normal 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
61
cookies/README.md
Normal 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
405
cookies/cookies_test.go
Normal 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
26
cookies/doc.go
Normal 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
21
env/LICENSE
vendored
Normal 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
67
env/README.md
vendored
Normal 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
18
env/doc.go
vendored
Normal 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
|
||||||
@@ -14,6 +14,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // 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-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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/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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
|||||||
@@ -6,24 +6,25 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hlog"
|
"git.haelnorr.com/h/golib/hlog"
|
||||||
|
"github.com/gobwas/glob"
|
||||||
)
|
)
|
||||||
|
|
||||||
type logger struct {
|
type logger struct {
|
||||||
logger *hlog.Logger
|
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) {
|
func (s *Server) LogError(err HWSError) {
|
||||||
if s.logger == nil {
|
if s.logger == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch err.Level {
|
switch err.Level {
|
||||||
case ErrorDEBUG:
|
case ErrorDEBUG:
|
||||||
s.logger.logger.Debug().Msg(err.Message)
|
s.logger.logger.Debug().Err(err.Error).Msg(err.Message)
|
||||||
return
|
return
|
||||||
case ErrorINFO:
|
case ErrorINFO:
|
||||||
s.logger.logger.Info().Msg(err.Message)
|
s.logger.logger.Info().Err(err.Error).Msg(err.Message)
|
||||||
return
|
return
|
||||||
case ErrorWARN:
|
case ErrorWARN:
|
||||||
s.logger.logger.Warn().Err(err.Error).Msg(err.Message)
|
s.logger.logger.Warn().Err(err.Error).Msg(err.Message)
|
||||||
@@ -53,10 +54,10 @@ func (server *Server) LogFatal(err error) {
|
|||||||
server.logger.logger.Fatal().Err(err).Msg("A fatal error has occured")
|
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.
|
// AddLogger adds a logger to the server to use for request logging.
|
||||||
func (server *Server) AddLogger(hlogger *hlog.Logger) error {
|
func (server *Server) AddLogger(hlogger *hlog.Logger) error {
|
||||||
if hlogger == nil {
|
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{
|
server.logger = &logger{
|
||||||
logger: hlogger,
|
logger: hlogger,
|
||||||
@@ -64,7 +65,7 @@ func (server *Server) AddLogger(hlogger *hlog.Logger) error {
|
|||||||
return nil
|
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
|
// 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
|
// Useful for ignoring requests to CSS files or favicons
|
||||||
func (server *Server) LoggerIgnorePaths(paths ...string) error {
|
func (server *Server) LoggerIgnorePaths(paths ...string) error {
|
||||||
@@ -76,9 +77,22 @@ func (server *Server) LoggerIgnorePaths(paths ...string) error {
|
|||||||
u.RawQuery == "" &&
|
u.RawQuery == "" &&
|
||||||
u.Fragment == ""
|
u.Fragment == ""
|
||||||
if !valid {
|
if !valid {
|
||||||
return fmt.Errorf("Invalid path: '%s'", path)
|
return fmt.Errorf("invalid path: '%s'", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
server.logger.ignoredPaths = paths
|
server.logger.ignoredPaths = prepareGlobs(paths)
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package hws
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gobwas/glob"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Middleware to add logs to console with details of the request
|
// 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)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if slices.Contains(logger.ignoredPaths, r.URL.Path) {
|
if globTest(r.URL.Path, logger.ignoredPaths) {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -36,3 +37,12 @@ func logging(next http.Handler, logger *logger) http.Handler {
|
|||||||
Msg("Served")
|
Msg("Served")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func globTest(testPath string, globs []glob.Glob) bool {
|
||||||
|
for _, g := range globs {
|
||||||
|
if g.Match(testPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user