mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-31 00:34:25 +00:00
staticweb package for easier static web server hosting
This commit is contained in:
439
pkg/server/staticweb/README.md
Normal file
439
pkg/server/staticweb/README.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# StaticWeb - Interface-Driven Static File Server
|
||||
|
||||
StaticWeb is a flexible, interface-driven Go package for serving static files over HTTP. It supports multiple filesystem backends (local, zip, embedded) and provides pluggable policies for caching, MIME types, and fallback strategies.
|
||||
|
||||
## Features
|
||||
|
||||
- **Router-Agnostic**: Works with any HTTP router through standard `http.Handler`
|
||||
- **Multiple Filesystem Providers**: Local directories, zip files, embedded filesystems
|
||||
- **Pluggable Policies**: Customizable cache, MIME type, and fallback strategies
|
||||
- **Thread-Safe**: Safe for concurrent use
|
||||
- **Resource Management**: Proper lifecycle management with `Close()` methods
|
||||
- **Extensible**: Easy to add new providers and policies
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/bitechdev/ResolveSpec/pkg/server/staticweb
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Serve files from a local directory:
|
||||
|
||||
```go
|
||||
import "github.com/bitechdev/ResolveSpec/pkg/server/staticweb"
|
||||
|
||||
// Create service
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
// Mount a local directory
|
||||
provider, _ := staticweb.LocalProvider("./public")
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
// Use with any router
|
||||
router.PathPrefix("/").Handler(service.Handler())
|
||||
```
|
||||
|
||||
### Single Page Application (SPA)
|
||||
|
||||
Serve an SPA with HTML fallback routing:
|
||||
|
||||
```go
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
provider, _ := staticweb.LocalProvider("./dist")
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: provider,
|
||||
FallbackStrategy: staticweb.HTMLFallback("index.html"),
|
||||
})
|
||||
|
||||
// API routes take precedence (registered first)
|
||||
router.HandleFunc("/api/users", usersHandler)
|
||||
router.HandleFunc("/api/posts", postsHandler)
|
||||
|
||||
// Static files handle all other routes
|
||||
router.PathPrefix("/").Handler(service.Handler())
|
||||
```
|
||||
|
||||
## Filesystem Providers
|
||||
|
||||
### Local Directory
|
||||
|
||||
Serve files from a local filesystem directory:
|
||||
|
||||
```go
|
||||
provider, err := staticweb.LocalProvider("/var/www/static")
|
||||
```
|
||||
|
||||
### Zip File
|
||||
|
||||
Serve files from a zip archive:
|
||||
|
||||
```go
|
||||
provider, err := staticweb.ZipProvider("./static.zip")
|
||||
```
|
||||
|
||||
### Embedded Filesystem
|
||||
|
||||
Serve files from Go's embedded filesystem:
|
||||
|
||||
```go
|
||||
//go:embed assets
|
||||
var assets embed.FS
|
||||
|
||||
// Direct embedded FS
|
||||
provider, err := staticweb.EmbedProvider(&assets, "")
|
||||
|
||||
// Or from a zip file within embedded FS
|
||||
provider, err := staticweb.EmbedProvider(&assets, "assets.zip")
|
||||
```
|
||||
|
||||
## Cache Policies
|
||||
|
||||
### Simple Cache
|
||||
|
||||
Single TTL for all files:
|
||||
|
||||
```go
|
||||
cachePolicy := staticweb.SimpleCache(3600) // 1 hour
|
||||
```
|
||||
|
||||
### Extension-Based Cache
|
||||
|
||||
Different TTLs per file type:
|
||||
|
||||
```go
|
||||
rules := map[string]int{
|
||||
".html": 3600, // 1 hour
|
||||
".js": 86400, // 1 day
|
||||
".css": 86400, // 1 day
|
||||
".png": 604800, // 1 week
|
||||
}
|
||||
|
||||
cachePolicy := staticweb.ExtensionCache(rules, 3600) // default 1 hour
|
||||
```
|
||||
|
||||
### No Cache
|
||||
|
||||
Disable caching entirely:
|
||||
|
||||
```go
|
||||
cachePolicy := staticweb.NoCache()
|
||||
```
|
||||
|
||||
## Fallback Strategies
|
||||
|
||||
### HTML Fallback
|
||||
|
||||
Serve index.html for non-asset requests (SPA routing):
|
||||
|
||||
```go
|
||||
fallback := staticweb.HTMLFallback("index.html")
|
||||
```
|
||||
|
||||
### Extension-Based Fallback
|
||||
|
||||
Skip fallback for known static assets:
|
||||
|
||||
```go
|
||||
fallback := staticweb.DefaultExtensionFallback("index.html")
|
||||
```
|
||||
|
||||
Custom extensions:
|
||||
|
||||
```go
|
||||
staticExts := []string{".js", ".css", ".png", ".jpg"}
|
||||
fallback := staticweb.ExtensionFallback(staticExts, "index.html")
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Service Configuration
|
||||
|
||||
```go
|
||||
config := &staticweb.ServiceConfig{
|
||||
DefaultCacheTime: 3600,
|
||||
DefaultMIMETypes: map[string]string{
|
||||
".webp": "image/webp",
|
||||
".wasm": "application/wasm",
|
||||
},
|
||||
}
|
||||
|
||||
service := staticweb.NewService(config)
|
||||
```
|
||||
|
||||
### Mount Configuration
|
||||
|
||||
```go
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: provider,
|
||||
CachePolicy: cachePolicy, // Optional
|
||||
MIMEResolver: mimeResolver, // Optional
|
||||
FallbackStrategy: fallbackStrategy, // Optional
|
||||
})
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Multiple Mount Points
|
||||
|
||||
Serve different directories at different URL prefixes with different policies:
|
||||
|
||||
```go
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
// Long-lived assets
|
||||
assetsProvider, _ := staticweb.LocalProvider("./assets")
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/assets",
|
||||
Provider: assetsProvider,
|
||||
CachePolicy: staticweb.SimpleCache(604800), // 1 week
|
||||
})
|
||||
|
||||
// Frequently updated HTML
|
||||
htmlProvider, _ := staticweb.LocalProvider("./public")
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: htmlProvider,
|
||||
CachePolicy: staticweb.SimpleCache(300), // 5 minutes
|
||||
})
|
||||
```
|
||||
|
||||
### Custom MIME Types
|
||||
|
||||
```go
|
||||
mimeResolver := staticweb.DefaultMIMEResolver()
|
||||
mimeResolver.RegisterMIMEType(".webp", "image/webp")
|
||||
mimeResolver.RegisterMIMEType(".wasm", "application/wasm")
|
||||
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: provider,
|
||||
MIMEResolver: mimeResolver,
|
||||
})
|
||||
```
|
||||
|
||||
### Resource Cleanup
|
||||
|
||||
Always close the service when done:
|
||||
|
||||
```go
|
||||
service := staticweb.NewService(nil)
|
||||
defer service.Close()
|
||||
|
||||
// ... mount and use service ...
|
||||
```
|
||||
|
||||
Or unmount individual mount points:
|
||||
|
||||
```go
|
||||
service.Unmount("/static")
|
||||
```
|
||||
|
||||
### Reloading/Refreshing Content
|
||||
|
||||
Reload providers to pick up changes from the underlying filesystem. This is particularly useful for zip files in development:
|
||||
|
||||
```go
|
||||
// When zip file or directory contents change
|
||||
err := service.Reload()
|
||||
if err != nil {
|
||||
log.Printf("Failed to reload: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
Providers that support reloading:
|
||||
- **ZipFSProvider**: Reopens the zip file to pick up changes
|
||||
- **LocalFSProvider**: Refreshes the directory view (automatically picks up changes)
|
||||
- **EmbedFSProvider**: Not reloadable (embedded at compile time)
|
||||
|
||||
You can also reload individual providers:
|
||||
|
||||
```go
|
||||
if reloadable, ok := provider.(staticweb.ReloadableProvider); ok {
|
||||
err := reloadable.Reload()
|
||||
if err != nil {
|
||||
log.Printf("Failed to reload: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Development Workflow Example:**
|
||||
|
||||
```go
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
provider, _ := staticweb.ZipProvider("./dist.zip")
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/app",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
// In development, reload when dist.zip is rebuilt
|
||||
go func() {
|
||||
watcher := fsnotify.NewWatcher()
|
||||
watcher.Add("./dist.zip")
|
||||
|
||||
for range watcher.Events {
|
||||
log.Println("Reloading static files...")
|
||||
if err := service.Reload(); err != nil {
|
||||
log.Printf("Reload failed: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
## Router Integration
|
||||
|
||||
### Gorilla Mux
|
||||
|
||||
```go
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/api/users", usersHandler)
|
||||
router.PathPrefix("/").Handler(service.Handler())
|
||||
```
|
||||
|
||||
### Standard http.ServeMux
|
||||
|
||||
```go
|
||||
http.Handle("/api/", apiHandler)
|
||||
http.Handle("/", service.Handler())
|
||||
```
|
||||
|
||||
### BunRouter
|
||||
|
||||
```go
|
||||
router.GET("/api/users", usersHandler)
|
||||
router.GET("/*path", bunrouter.HTTPHandlerFunc(service.Handler()))
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Interfaces
|
||||
|
||||
#### FileSystemProvider
|
||||
|
||||
Abstracts the source of files:
|
||||
|
||||
```go
|
||||
type FileSystemProvider interface {
|
||||
Open(name string) (fs.File, error)
|
||||
Close() error
|
||||
Type() string
|
||||
}
|
||||
```
|
||||
|
||||
Implementations:
|
||||
- `LocalFSProvider` - Local directories
|
||||
- `ZipFSProvider` - Zip archives
|
||||
- `EmbedFSProvider` - Embedded filesystems
|
||||
|
||||
#### CachePolicy
|
||||
|
||||
Defines caching behavior:
|
||||
|
||||
```go
|
||||
type CachePolicy interface {
|
||||
GetCacheTime(path string) int
|
||||
GetCacheHeaders(path string) map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
Implementations:
|
||||
- `SimpleCachePolicy` - Single TTL
|
||||
- `ExtensionBasedCachePolicy` - Per-extension TTL
|
||||
- `NoCachePolicy` - Disable caching
|
||||
|
||||
#### FallbackStrategy
|
||||
|
||||
Handles missing files:
|
||||
|
||||
```go
|
||||
type FallbackStrategy interface {
|
||||
ShouldFallback(path string) bool
|
||||
GetFallbackPath(path string) string
|
||||
}
|
||||
```
|
||||
|
||||
Implementations:
|
||||
- `NoFallback` - Return 404
|
||||
- `HTMLFallbackStrategy` - SPA routing
|
||||
- `ExtensionBasedFallback` - Skip known assets
|
||||
|
||||
#### MIMETypeResolver
|
||||
|
||||
Determines Content-Type:
|
||||
|
||||
```go
|
||||
type MIMETypeResolver interface {
|
||||
GetMIMEType(path string) string
|
||||
RegisterMIMEType(extension, mimeType string)
|
||||
}
|
||||
```
|
||||
|
||||
Implementations:
|
||||
- `DefaultMIMEResolver` - Common web types
|
||||
- `ConfigurableMIMEResolver` - Custom mappings
|
||||
|
||||
## Testing
|
||||
|
||||
### Mock Providers
|
||||
|
||||
```go
|
||||
import staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing"
|
||||
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("<html>test</html>"),
|
||||
"app.js": []byte("console.log('test')"),
|
||||
})
|
||||
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: provider,
|
||||
})
|
||||
```
|
||||
|
||||
### Test Helpers
|
||||
|
||||
```go
|
||||
req := httptest.NewRequest("GET", "/index.html", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
service.Handler().ServeHTTP(rec, req)
|
||||
|
||||
// Assert response
|
||||
assert.Equal(t, 200, rec.Code)
|
||||
```
|
||||
|
||||
## Future Features
|
||||
|
||||
The interface-driven design allows for easy extensibility:
|
||||
|
||||
### Planned Providers
|
||||
|
||||
- **HTTPFSProvider**: Fetch files from remote HTTP servers with local caching
|
||||
- **S3FSProvider**: Serve files from S3-compatible storage
|
||||
- **CompositeProvider**: Fallback chain across multiple providers
|
||||
- **MemoryProvider**: In-memory filesystem for testing
|
||||
|
||||
### Planned Policies
|
||||
|
||||
- **TimedCachePolicy**: Different cache times by time of day
|
||||
- **ConditionalCachePolicy**: Smart cache based on file size/type
|
||||
- **RegexFallbackStrategy**: Pattern-based routing
|
||||
|
||||
## License
|
||||
|
||||
See the main repository for license information.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! The interface-driven design makes it easy to add new providers and policies without modifying existing code.
|
||||
99
pkg/server/staticweb/config.go
Normal file
99
pkg/server/staticweb/config.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package staticweb
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/server/staticweb/policies"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/server/staticweb/providers"
|
||||
)
|
||||
|
||||
// ServiceConfig configures the static file service.
|
||||
type ServiceConfig struct {
|
||||
// DefaultCacheTime is the default cache duration in seconds.
|
||||
// Used when a mount point doesn't specify a custom CachePolicy.
|
||||
// Default: 172800 (48 hours)
|
||||
DefaultCacheTime int
|
||||
|
||||
// DefaultMIMETypes is a map of file extensions to MIME types.
|
||||
// These are added to the default MIME resolver.
|
||||
// Extensions should include the leading dot (e.g., ".webp").
|
||||
DefaultMIMETypes map[string]string
|
||||
}
|
||||
|
||||
// DefaultServiceConfig returns a ServiceConfig with sensible defaults.
|
||||
func DefaultServiceConfig() *ServiceConfig {
|
||||
return &ServiceConfig{
|
||||
DefaultCacheTime: 172800, // 48 hours
|
||||
DefaultMIMETypes: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks if the ServiceConfig is valid.
|
||||
func (c *ServiceConfig) Validate() error {
|
||||
if c.DefaultCacheTime < 0 {
|
||||
return fmt.Errorf("DefaultCacheTime cannot be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper constructor functions for providers
|
||||
|
||||
// LocalProvider creates a FileSystemProvider for a local directory.
|
||||
func LocalProvider(path string) (FileSystemProvider, error) {
|
||||
return providers.NewLocalFSProvider(path)
|
||||
}
|
||||
|
||||
// ZipProvider creates a FileSystemProvider for a zip file.
|
||||
func ZipProvider(zipPath string) (FileSystemProvider, error) {
|
||||
return providers.NewZipFSProvider(zipPath)
|
||||
}
|
||||
|
||||
// EmbedProvider creates a FileSystemProvider for an embedded filesystem.
|
||||
// If zipFile is empty, the embedded FS is used directly.
|
||||
// If zipFile is specified, it's treated as a path to a zip file within the embedded FS.
|
||||
// The embedFS parameter can be any fs.FS, but is typically *embed.FS.
|
||||
func EmbedProvider(embedFS *embed.FS, zipFile string) (FileSystemProvider, error) {
|
||||
return providers.NewEmbedFSProvider(embedFS, zipFile)
|
||||
}
|
||||
|
||||
// Policy constructor functions
|
||||
|
||||
// SimpleCache creates a simple cache policy with the given TTL in seconds.
|
||||
func SimpleCache(seconds int) CachePolicy {
|
||||
return policies.NewSimpleCachePolicy(seconds)
|
||||
}
|
||||
|
||||
// ExtensionCache creates an extension-based cache policy.
|
||||
// rules maps file extensions (with leading dot) to cache times in seconds.
|
||||
// defaultTime is used for files that don't match any rule.
|
||||
func ExtensionCache(rules map[string]int, defaultTime int) CachePolicy {
|
||||
return policies.NewExtensionBasedCachePolicy(rules, defaultTime)
|
||||
}
|
||||
|
||||
// NoCache creates a cache policy that disables all caching.
|
||||
func NoCache() CachePolicy {
|
||||
return policies.NewNoCachePolicy()
|
||||
}
|
||||
|
||||
// HTMLFallback creates a fallback strategy for SPAs that serves the given index file.
|
||||
func HTMLFallback(indexFile string) FallbackStrategy {
|
||||
return policies.NewHTMLFallbackStrategy(indexFile)
|
||||
}
|
||||
|
||||
// ExtensionFallback creates an extension-based fallback strategy.
|
||||
// staticExtensions is a list of file extensions that should NOT use fallback.
|
||||
// fallbackPath is the file to serve when fallback is triggered.
|
||||
func ExtensionFallback(staticExtensions []string, fallbackPath string) FallbackStrategy {
|
||||
return policies.NewExtensionBasedFallback(staticExtensions, fallbackPath)
|
||||
}
|
||||
|
||||
// DefaultExtensionFallback creates an extension-based fallback with common web asset extensions.
|
||||
func DefaultExtensionFallback(fallbackPath string) FallbackStrategy {
|
||||
return policies.NewDefaultExtensionBasedFallback(fallbackPath)
|
||||
}
|
||||
|
||||
// DefaultMIMEResolver creates a MIME resolver with common web file types.
|
||||
func DefaultMIMEResolver() MIMETypeResolver {
|
||||
return policies.NewDefaultMIMEResolver()
|
||||
}
|
||||
60
pkg/server/staticweb/example_reload_test.go
Normal file
60
pkg/server/staticweb/example_reload_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package staticweb_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/server/staticweb"
|
||||
staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing"
|
||||
)
|
||||
|
||||
// Example_reload demonstrates reloading content when files change.
|
||||
func Example_reload() {
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
// Create a provider
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"version.txt": []byte("v1.0.0"),
|
||||
})
|
||||
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
// Simulate updating the file
|
||||
provider.AddFile("version.txt", []byte("v2.0.0"))
|
||||
|
||||
// Reload to pick up changes (in real usage with zip files)
|
||||
err := service.Reload()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to reload: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Successfully reloaded static files")
|
||||
}
|
||||
|
||||
// Output: Successfully reloaded static files
|
||||
}
|
||||
|
||||
// Example_reloadZip demonstrates reloading a zip file provider.
|
||||
func Example_reloadZip() {
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
// In production, you would use:
|
||||
// provider, _ := staticweb.ZipProvider("./dist.zip")
|
||||
// For this example, we use a mock
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"app.js": []byte("console.log('v1')"),
|
||||
})
|
||||
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/app",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
fmt.Println("Serving from zip file")
|
||||
|
||||
// When the zip file is updated, call Reload()
|
||||
// service.Reload()
|
||||
|
||||
// Output: Serving from zip file
|
||||
}
|
||||
138
pkg/server/staticweb/example_test.go
Normal file
138
pkg/server/staticweb/example_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package staticweb_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/server/staticweb"
|
||||
staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Example_basic demonstrates serving files from a local directory.
|
||||
func Example_basic() {
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
// Using mock provider for example purposes
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("<html>test</html>"),
|
||||
})
|
||||
|
||||
_ = service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.PathPrefix("/").Handler(service.Handler())
|
||||
|
||||
fmt.Println("Serving files from ./public at /static")
|
||||
// Output: Serving files from ./public at /static
|
||||
}
|
||||
|
||||
// Example_spa demonstrates an SPA with HTML fallback routing.
|
||||
func Example_spa() {
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
// Using mock provider for example purposes
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("<html>app</html>"),
|
||||
})
|
||||
|
||||
_ = service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: provider,
|
||||
FallbackStrategy: staticweb.HTMLFallback("index.html"),
|
||||
})
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
// API routes take precedence
|
||||
router.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("users"))
|
||||
})
|
||||
|
||||
// Static files handle all other routes
|
||||
router.PathPrefix("/").Handler(service.Handler())
|
||||
|
||||
fmt.Println("SPA with fallback to index.html")
|
||||
// Output: SPA with fallback to index.html
|
||||
}
|
||||
|
||||
// Example_multiple demonstrates multiple mount points with different policies.
|
||||
func Example_multiple() {
|
||||
service := staticweb.NewService(&staticweb.ServiceConfig{
|
||||
DefaultCacheTime: 3600,
|
||||
})
|
||||
|
||||
// Assets with long cache (using mock for example)
|
||||
assetsProvider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"app.js": []byte("console.log('test')"),
|
||||
})
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/assets",
|
||||
Provider: assetsProvider,
|
||||
CachePolicy: staticweb.SimpleCache(604800), // 1 week
|
||||
})
|
||||
|
||||
// HTML with short cache (using mock for example)
|
||||
htmlProvider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("<html>test</html>"),
|
||||
})
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: htmlProvider,
|
||||
CachePolicy: staticweb.SimpleCache(300), // 5 minutes
|
||||
})
|
||||
|
||||
fmt.Println("Multiple mount points configured")
|
||||
// Output: Multiple mount points configured
|
||||
}
|
||||
|
||||
// Example_zip demonstrates serving from a zip file (concept).
|
||||
func Example_zip() {
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
// For actual usage, you would use:
|
||||
// provider, err := staticweb.ZipProvider("./static.zip")
|
||||
// For this example, we use a mock
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"file.txt": []byte("content"),
|
||||
})
|
||||
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
fmt.Println("Serving from zip file")
|
||||
// Output: Serving from zip file
|
||||
}
|
||||
|
||||
// Example_extensionCache demonstrates extension-based caching.
|
||||
func Example_extensionCache() {
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
// Using mock provider for example purposes
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("<html>test</html>"),
|
||||
"app.js": []byte("console.log('test')"),
|
||||
})
|
||||
|
||||
// Different cache times per file type
|
||||
cacheRules := map[string]int{
|
||||
".html": 3600, // 1 hour
|
||||
".js": 86400, // 1 day
|
||||
".css": 86400, // 1 day
|
||||
".png": 604800, // 1 week
|
||||
}
|
||||
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: provider,
|
||||
CachePolicy: staticweb.ExtensionCache(cacheRules, 3600), // default 1 hour
|
||||
})
|
||||
|
||||
fmt.Println("Extension-based caching configured")
|
||||
// Output: Extension-based caching configured
|
||||
}
|
||||
130
pkg/server/staticweb/interfaces.go
Normal file
130
pkg/server/staticweb/interfaces.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package staticweb
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// FileSystemProvider abstracts the source of files (local, zip, embedded, future: http, s3)
|
||||
// Implementations must be safe for concurrent use.
|
||||
type FileSystemProvider interface {
|
||||
// Open opens the named file.
|
||||
// The name is always a slash-separated path relative to the filesystem root.
|
||||
Open(name string) (fs.File, error)
|
||||
|
||||
// Close releases any resources held by the provider.
|
||||
// After Close is called, the provider should not be used.
|
||||
Close() error
|
||||
|
||||
// Type returns the provider type (e.g., "local", "zip", "embed", "http", "s3").
|
||||
// This is primarily for debugging and logging purposes.
|
||||
Type() string
|
||||
}
|
||||
|
||||
// ReloadableProvider is an optional interface that providers can implement
|
||||
// to support reloading/refreshing their content.
|
||||
// This is useful for development workflows where the underlying files may change.
|
||||
type ReloadableProvider interface {
|
||||
FileSystemProvider
|
||||
|
||||
// Reload refreshes the provider's content from the underlying source.
|
||||
// For zip files, this reopens the zip archive.
|
||||
// For local directories, this refreshes the filesystem view.
|
||||
// Returns an error if the reload fails.
|
||||
Reload() error
|
||||
}
|
||||
|
||||
// CachePolicy defines how files should be cached by browsers and proxies.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type CachePolicy interface {
|
||||
// GetCacheTime returns the cache duration in seconds for the given path.
|
||||
// A value of 0 means no caching.
|
||||
// A negative value can be used to indicate browser should revalidate.
|
||||
GetCacheTime(path string) int
|
||||
|
||||
// GetCacheHeaders returns additional cache-related HTTP headers for the given path.
|
||||
// Common headers include "Cache-Control", "Expires", "ETag", etc.
|
||||
// Returns nil if no additional headers are needed.
|
||||
GetCacheHeaders(path string) map[string]string
|
||||
}
|
||||
|
||||
// MIMETypeResolver determines the Content-Type for files.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type MIMETypeResolver interface {
|
||||
// GetMIMEType returns the MIME type for the given file path.
|
||||
// Returns empty string if the MIME type cannot be determined.
|
||||
GetMIMEType(path string) string
|
||||
|
||||
// RegisterMIMEType registers a custom MIME type for the given file extension.
|
||||
// The extension should include the leading dot (e.g., ".webp").
|
||||
RegisterMIMEType(extension, mimeType string)
|
||||
}
|
||||
|
||||
// FallbackStrategy handles requests for files that don't exist.
|
||||
// This is commonly used for Single Page Applications (SPAs) that use client-side routing.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type FallbackStrategy interface {
|
||||
// ShouldFallback determines if a fallback should be attempted for the given path.
|
||||
// Returns true if the request should be handled by fallback logic.
|
||||
ShouldFallback(path string) bool
|
||||
|
||||
// GetFallbackPath returns the path to serve instead of the originally requested path.
|
||||
// This is only called if ShouldFallback returns true.
|
||||
GetFallbackPath(path string) string
|
||||
}
|
||||
|
||||
// MountConfig configures a single mount point.
|
||||
// A mount point connects a URL prefix to a filesystem provider with optional policies.
|
||||
type MountConfig struct {
|
||||
// URLPrefix is the URL path prefix where the filesystem should be mounted.
|
||||
// Must start with "/" (e.g., "/static", "/", "/assets").
|
||||
// Requests starting with this prefix will be handled by this mount point.
|
||||
URLPrefix string
|
||||
|
||||
// Provider is the filesystem provider that supplies the files.
|
||||
// Required.
|
||||
Provider FileSystemProvider
|
||||
|
||||
// CachePolicy determines how files should be cached.
|
||||
// If nil, the service's default cache policy is used.
|
||||
CachePolicy CachePolicy
|
||||
|
||||
// MIMEResolver determines Content-Type headers for files.
|
||||
// If nil, the service's default MIME resolver is used.
|
||||
MIMEResolver MIMETypeResolver
|
||||
|
||||
// FallbackStrategy handles requests for missing files.
|
||||
// If nil, no fallback is performed and 404 responses are returned.
|
||||
FallbackStrategy FallbackStrategy
|
||||
}
|
||||
|
||||
// StaticFileService manages multiple mount points and serves static files.
|
||||
// The service is safe for concurrent use.
|
||||
type StaticFileService interface {
|
||||
// Mount adds a new mount point with the given configuration.
|
||||
// Returns an error if the URLPrefix is already mounted or if the config is invalid.
|
||||
Mount(config MountConfig) error
|
||||
|
||||
// Unmount removes the mount point at the given URL prefix.
|
||||
// Returns an error if no mount point exists at that prefix.
|
||||
// Automatically calls Close() on the provider to release resources.
|
||||
Unmount(urlPrefix string) error
|
||||
|
||||
// ListMounts returns a sorted list of all mounted URL prefixes.
|
||||
ListMounts() []string
|
||||
|
||||
// Reload reinitializes all filesystem providers.
|
||||
// This can be used to pick up changes in the underlying filesystems.
|
||||
// Not all providers may support reloading.
|
||||
Reload() error
|
||||
|
||||
// Close releases all resources held by the service and all mounted providers.
|
||||
// After Close is called, the service should not be used.
|
||||
Close() error
|
||||
|
||||
// Handler returns an http.Handler that serves static files from all mount points.
|
||||
// The handler performs longest-prefix matching to find the appropriate mount point.
|
||||
// If no mount point matches, the handler returns without writing a response,
|
||||
// allowing other handlers (like API routes) to process the request.
|
||||
Handler() http.Handler
|
||||
}
|
||||
235
pkg/server/staticweb/mount.go
Normal file
235
pkg/server/staticweb/mount.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package staticweb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mountPoint represents a mounted filesystem at a specific URL prefix.
|
||||
type mountPoint struct {
|
||||
urlPrefix string
|
||||
provider FileSystemProvider
|
||||
cachePolicy CachePolicy
|
||||
mimeResolver MIMETypeResolver
|
||||
fallbackStrategy FallbackStrategy
|
||||
fileServer http.Handler
|
||||
}
|
||||
|
||||
// newMountPoint creates a new mount point with the given configuration.
|
||||
func newMountPoint(config MountConfig, defaults *ServiceConfig) (*mountPoint, error) {
|
||||
if config.URLPrefix == "" {
|
||||
return nil, fmt.Errorf("URLPrefix cannot be empty")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(config.URLPrefix, "/") {
|
||||
return nil, fmt.Errorf("URLPrefix must start with /")
|
||||
}
|
||||
|
||||
if config.Provider == nil {
|
||||
return nil, fmt.Errorf("Provider cannot be nil")
|
||||
}
|
||||
|
||||
mp := &mountPoint{
|
||||
urlPrefix: config.URLPrefix,
|
||||
provider: config.Provider,
|
||||
cachePolicy: config.CachePolicy,
|
||||
mimeResolver: config.MIMEResolver,
|
||||
fallbackStrategy: config.FallbackStrategy,
|
||||
}
|
||||
|
||||
// Apply defaults if policies are not specified
|
||||
if mp.cachePolicy == nil && defaults != nil {
|
||||
mp.cachePolicy = defaultCachePolicy(defaults.DefaultCacheTime)
|
||||
}
|
||||
|
||||
if mp.mimeResolver == nil {
|
||||
mp.mimeResolver = defaultMIMEResolver()
|
||||
}
|
||||
|
||||
// Create an http.FileServer for serving files
|
||||
mp.fileServer = http.FileServer(http.FS(config.Provider))
|
||||
|
||||
return mp, nil
|
||||
}
|
||||
|
||||
// ServeHTTP handles HTTP requests for files in this mount point.
|
||||
func (m *mountPoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Strip the URL prefix to get the file path
|
||||
filePath := strings.TrimPrefix(r.URL.Path, m.urlPrefix)
|
||||
if filePath == "" {
|
||||
filePath = "/"
|
||||
}
|
||||
|
||||
// Clean the path
|
||||
filePath = path.Clean(filePath)
|
||||
|
||||
// Try to open the file
|
||||
file, err := m.provider.Open(strings.TrimPrefix(filePath, "/"))
|
||||
if err != nil {
|
||||
// File doesn't exist - check if we should use fallback
|
||||
if m.fallbackStrategy != nil && m.fallbackStrategy.ShouldFallback(filePath) {
|
||||
fallbackPath := m.fallbackStrategy.GetFallbackPath(filePath)
|
||||
file, err = m.provider.Open(strings.TrimPrefix(fallbackPath, "/"))
|
||||
if err == nil {
|
||||
// Successfully opened fallback file
|
||||
defer file.Close()
|
||||
m.serveFile(w, r, fallbackPath, file)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No fallback or fallback failed - return 404
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Serve the file
|
||||
m.serveFile(w, r, filePath, file)
|
||||
}
|
||||
|
||||
// serveFile serves a single file with appropriate headers.
|
||||
func (m *mountPoint) serveFile(w http.ResponseWriter, r *http.Request, filePath string, file fs.File) {
|
||||
// Get file info
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// If it's a directory, try to serve index.html
|
||||
if stat.IsDir() {
|
||||
indexPath := path.Join(filePath, "index.html")
|
||||
indexFile, err := m.provider.Open(strings.TrimPrefix(indexPath, "/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
defer indexFile.Close()
|
||||
|
||||
indexStat, err := indexFile.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filePath = indexPath
|
||||
stat = indexStat
|
||||
file = indexFile
|
||||
}
|
||||
|
||||
// Set Content-Type header using MIME resolver
|
||||
if m.mimeResolver != nil {
|
||||
if mimeType := m.mimeResolver.GetMIMEType(filePath); mimeType != "" {
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply cache policy
|
||||
if m.cachePolicy != nil {
|
||||
headers := m.cachePolicy.GetCacheHeaders(filePath)
|
||||
for key, value := range headers {
|
||||
w.Header().Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Serve the content
|
||||
if seeker, ok := file.(interface {
|
||||
io.ReadSeeker
|
||||
}); ok {
|
||||
http.ServeContent(w, r, stat.Name(), stat.ModTime(), seeker)
|
||||
} else {
|
||||
// If the file doesn't support seeking, we need to read it all into memory
|
||||
data, err := fs.ReadFile(m.provider, strings.TrimPrefix(filePath, "/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.ServeContent(w, r, stat.Name(), stat.ModTime(), strings.NewReader(string(data)))
|
||||
}
|
||||
}
|
||||
|
||||
// Close releases resources held by the mount point.
|
||||
func (m *mountPoint) Close() error {
|
||||
if m.provider != nil {
|
||||
return m.provider.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultCachePolicy creates a default simple cache policy.
|
||||
func defaultCachePolicy(cacheTime int) CachePolicy {
|
||||
// Import the policies package type - we'll need to use the concrete type
|
||||
// For now, create a simple inline implementation
|
||||
return &simpleCachePolicy{cacheTime: cacheTime}
|
||||
}
|
||||
|
||||
// simpleCachePolicy is a simple inline implementation of CachePolicy
|
||||
type simpleCachePolicy struct {
|
||||
cacheTime int
|
||||
}
|
||||
|
||||
func (p *simpleCachePolicy) GetCacheTime(path string) int {
|
||||
return p.cacheTime
|
||||
}
|
||||
|
||||
func (p *simpleCachePolicy) GetCacheHeaders(path string) map[string]string {
|
||||
if p.cacheTime <= 0 {
|
||||
return map[string]string{
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
}
|
||||
return map[string]string{
|
||||
"Cache-Control": fmt.Sprintf("public, max-age=%d", p.cacheTime),
|
||||
}
|
||||
}
|
||||
|
||||
// defaultMIMEResolver creates a default MIME resolver.
|
||||
func defaultMIMEResolver() MIMETypeResolver {
|
||||
// Import the policies package type - we'll need to use the concrete type
|
||||
// For now, create a simple inline implementation
|
||||
return &simpleMIMEResolver{
|
||||
types: map[string]string{
|
||||
".js": "application/javascript",
|
||||
".mjs": "application/javascript",
|
||||
".cjs": "application/javascript",
|
||||
".css": "text/css",
|
||||
".html": "text/html",
|
||||
".htm": "text/html",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".txt": "text/plain",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// simpleMIMEResolver is a simple inline implementation of MIMETypeResolver
|
||||
type simpleMIMEResolver struct {
|
||||
types map[string]string
|
||||
}
|
||||
|
||||
func (r *simpleMIMEResolver) GetMIMEType(filePath string) string {
|
||||
ext := strings.ToLower(path.Ext(filePath))
|
||||
if mimeType, ok := r.types[ext]; ok {
|
||||
return mimeType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *simpleMIMEResolver) RegisterMIMEType(extension, mimeType string) {
|
||||
if !strings.HasPrefix(extension, ".") {
|
||||
extension = "." + extension
|
||||
}
|
||||
r.types[strings.ToLower(extension)] = mimeType
|
||||
}
|
||||
592
pkg/server/staticweb/plan.md
Normal file
592
pkg/server/staticweb/plan.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# StaticWeb Package Interface-Driven Refactoring Plan
|
||||
|
||||
## Overview
|
||||
Refactor `pkg/server/staticweb` to be interface-driven, router-agnostic, and maintainable. This is a breaking change that replaces the existing API with a cleaner design.
|
||||
|
||||
## User Requirements
|
||||
- ✅ Work with any server (not just Gorilla mux)
|
||||
- ✅ Serve static files from zip files or directories
|
||||
- ✅ Support embedded, local filesystems
|
||||
- ✅ Interface-driven and maintainable architecture
|
||||
- ✅ Struct-based configuration
|
||||
- ✅ Breaking changes acceptable
|
||||
- 🔮 Remote HTTP/HTTPS and S3 support (future feature)
|
||||
|
||||
## Design Principles
|
||||
1. **Interface-first**: Define clear interfaces for all abstractions
|
||||
2. **Composition over inheritance**: Combine small, focused components
|
||||
3. **Router-agnostic**: Return standard `http.Handler` for universal compatibility
|
||||
4. **Configurable policies**: Extract hardcoded behavior into pluggable strategies
|
||||
5. **Resource safety**: Proper lifecycle management with `Close()` methods
|
||||
6. **Testability**: Mock-friendly interfaces with clear boundaries
|
||||
7. **Extensibility**: Easy to add new providers (HTTP, S3, etc.) in future
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Core Interfaces (pkg/server/staticweb/interfaces.go)
|
||||
|
||||
```go
|
||||
// FileSystemProvider abstracts file sources (local, zip, embedded, future: http, s3)
|
||||
type FileSystemProvider interface {
|
||||
Open(name string) (fs.File, error)
|
||||
Close() error
|
||||
Type() string
|
||||
}
|
||||
|
||||
// CachePolicy defines caching behavior
|
||||
type CachePolicy interface {
|
||||
GetCacheTime(path string) int
|
||||
GetCacheHeaders(path string) map[string]string
|
||||
}
|
||||
|
||||
// MIMETypeResolver determines content types
|
||||
type MIMETypeResolver interface {
|
||||
GetMIMEType(path string) string
|
||||
RegisterMIMEType(extension, mimeType string)
|
||||
}
|
||||
|
||||
// FallbackStrategy handles missing files
|
||||
type FallbackStrategy interface {
|
||||
ShouldFallback(path string) bool
|
||||
GetFallbackPath(path string) string
|
||||
}
|
||||
|
||||
// StaticFileService manages mount points
|
||||
type StaticFileService interface {
|
||||
Mount(config MountConfig) error
|
||||
Unmount(urlPrefix string) error
|
||||
ListMounts() []string
|
||||
Reload() error
|
||||
Close() error
|
||||
Handler() http.Handler // Router-agnostic integration
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration (pkg/server/staticweb/config.go)
|
||||
|
||||
```go
|
||||
// MountConfig configures a single mount point
|
||||
type MountConfig struct {
|
||||
URLPrefix string
|
||||
Provider FileSystemProvider
|
||||
CachePolicy CachePolicy // Optional, uses default if nil
|
||||
MIMEResolver MIMETypeResolver // Optional, uses default if nil
|
||||
FallbackStrategy FallbackStrategy // Optional, no fallback if nil
|
||||
}
|
||||
|
||||
// ServiceConfig configures the service
|
||||
type ServiceConfig struct {
|
||||
DefaultCacheTime int // Default: 48 hours
|
||||
DefaultMIMETypes map[string]string // Additional MIME types
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Create Core Interfaces
|
||||
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/interfaces.go` (NEW)
|
||||
|
||||
Define all interfaces listed above. This establishes the contract for all components.
|
||||
|
||||
### Step 2: Implement Default Policies
|
||||
**Directory**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/policies/` (NEW)
|
||||
|
||||
**File**: `policies/cache.go`
|
||||
- `SimpleCachePolicy` - Single TTL for all files
|
||||
- `ExtensionBasedCachePolicy` - Different TTL per file extension
|
||||
- `NoCachePolicy` - Disables caching
|
||||
|
||||
**File**: `policies/mime.go`
|
||||
- `DefaultMIMEResolver` - Standard web MIME types + stdlib
|
||||
- `ConfigurableMIMEResolver` - User-defined mappings
|
||||
- Migrate hardcoded MIME types from `InitMimeTypes()` (lines 60-76)
|
||||
|
||||
**File**: `policies/fallback.go`
|
||||
- `NoFallback` - Returns 404 for missing files
|
||||
- `HTMLFallbackStrategy` - SPA routing (serves index.html)
|
||||
- `ExtensionBasedFallback` - Current behavior (checks extensions)
|
||||
- Migrate logic from `StaticHTMLFallbackHandler()` (lines 241-285)
|
||||
|
||||
### Step 3: Implement FileSystem Providers
|
||||
**Directory**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/providers/` (NEW)
|
||||
|
||||
**File**: `providers/local.go`
|
||||
```go
|
||||
type LocalFSProvider struct {
|
||||
path string
|
||||
fs fs.FS
|
||||
}
|
||||
|
||||
func NewLocalFSProvider(path string) (*LocalFSProvider, error)
|
||||
func (l *LocalFSProvider) Open(name string) (fs.File, error)
|
||||
func (l *LocalFSProvider) Close() error
|
||||
func (l *LocalFSProvider) Type() string
|
||||
```
|
||||
|
||||
**File**: `providers/zip.go`
|
||||
```go
|
||||
type ZipFSProvider struct {
|
||||
zipPath string
|
||||
zipReader *zip.ReadCloser
|
||||
zipFS *zipfs.ZipFS
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewZipFSProvider(zipPath string) (*ZipFSProvider, error)
|
||||
func (z *ZipFSProvider) Open(name string) (fs.File, error)
|
||||
func (z *ZipFSProvider) Close() error
|
||||
func (z *ZipFSProvider) Type() string
|
||||
```
|
||||
- Integrates with existing `pkg/server/zipfs/zipfs.go`
|
||||
- Manages zip file lifecycle properly
|
||||
|
||||
**File**: `providers/embed.go`
|
||||
```go
|
||||
type EmbedFSProvider struct {
|
||||
embedFS *embed.FS
|
||||
zipFile string // Optional: path within embedded FS to zip file
|
||||
zipReader *zip.ReadCloser
|
||||
fs fs.FS
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewEmbedFSProvider(embedFS *embed.FS, zipFile string) (*EmbedFSProvider, error)
|
||||
func (e *EmbedFSProvider) Open(name string) (fs.File, error)
|
||||
func (e *EmbedFSProvider) Close() error
|
||||
func (e *EmbedFSProvider) Type() string
|
||||
```
|
||||
|
||||
### Step 4: Implement Mount Point
|
||||
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/mount.go` (NEW)
|
||||
|
||||
```go
|
||||
type mountPoint struct {
|
||||
urlPrefix string
|
||||
provider FileSystemProvider
|
||||
cachePolicy CachePolicy
|
||||
mimeResolver MIMETypeResolver
|
||||
fallbackStrategy FallbackStrategy
|
||||
}
|
||||
|
||||
func newMountPoint(config MountConfig, defaults *ServiceConfig) (*mountPoint, error)
|
||||
func (m *mountPoint) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
func (m *mountPoint) Close() error
|
||||
```
|
||||
|
||||
**Key behaviors**:
|
||||
- Strips URL prefix before passing to provider
|
||||
- Applies cache headers via `CachePolicy`
|
||||
- Sets Content-Type via `MIMETypeResolver`
|
||||
- Falls back via `FallbackStrategy` if file not found
|
||||
- Integrates with `http.FileServer()` for actual serving
|
||||
|
||||
### Step 5: Implement Service
|
||||
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/service.go` (NEW)
|
||||
|
||||
```go
|
||||
type service struct {
|
||||
mounts map[string]*mountPoint // urlPrefix -> mount
|
||||
config *ServiceConfig
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewService(config *ServiceConfig) StaticFileService
|
||||
func (s *service) Mount(config MountConfig) error
|
||||
func (s *service) Unmount(urlPrefix string) error
|
||||
func (s *service) ListMounts() []string
|
||||
func (s *service) Reload() error
|
||||
func (s *service) Close() error
|
||||
func (s *service) Handler() http.Handler
|
||||
```
|
||||
|
||||
**Handler Implementation**:
|
||||
- Performs longest-prefix matching to find mount point
|
||||
- Delegates to mount point's `ServeHTTP()`
|
||||
- Returns silently if no match (allows API routes to handle)
|
||||
- Thread-safe with `sync.RWMutex`
|
||||
|
||||
### Step 6: Create Configuration Helpers
|
||||
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/config.go` (NEW)
|
||||
|
||||
```go
|
||||
// Default configurations
|
||||
func DefaultServiceConfig() *ServiceConfig
|
||||
func DefaultCachePolicy() CachePolicy
|
||||
func DefaultMIMEResolver() MIMETypeResolver
|
||||
|
||||
// Helper constructors
|
||||
func LocalProvider(path string) FileSystemProvider
|
||||
func ZipProvider(zipPath string) FileSystemProvider
|
||||
func EmbedProvider(embedFS *embed.FS, zipFile string) FileSystemProvider
|
||||
|
||||
// Policy constructors
|
||||
func SimpleCache(seconds int) CachePolicy
|
||||
func ExtensionCache(rules map[string]int) CachePolicy
|
||||
func HTMLFallback(indexFile string) FallbackStrategy
|
||||
func ExtensionFallback(staticExtensions []string) FallbackStrategy
|
||||
```
|
||||
|
||||
### Step 7: Update/Remove Existing Files
|
||||
|
||||
**REMOVE**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/staticweb.go`
|
||||
- All functionality migrated to new interface-based design
|
||||
- No backward compatibility needed per user request
|
||||
|
||||
**KEEP**: `/home/hein/hein/dev/ResolveSpec/pkg/server/zipfs/zipfs.go`
|
||||
- Still used by `ZipFSProvider`
|
||||
- Already implements `fs.FS` interface correctly
|
||||
|
||||
### Step 8: Create Examples and Tests
|
||||
|
||||
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/example_test.go` (NEW)
|
||||
|
||||
```go
|
||||
func ExampleService_basic() { /* Serve local directory */ }
|
||||
func ExampleService_spa() { /* SPA with fallback */ }
|
||||
func ExampleService_multiple() { /* Multiple mount points */ }
|
||||
func ExampleService_zip() { /* Serve from zip file */ }
|
||||
func ExampleService_embedded() { /* Serve from embedded zip */ }
|
||||
```
|
||||
|
||||
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/service_test.go` (NEW)
|
||||
- Test mount/unmount operations
|
||||
- Test longest-prefix matching
|
||||
- Test concurrent access
|
||||
- Test resource cleanup
|
||||
|
||||
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/providers/providers_test.go` (NEW)
|
||||
- Test each provider implementation
|
||||
- Test resource cleanup
|
||||
- Test error handling
|
||||
|
||||
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/testing/mocks.go` (NEW)
|
||||
- `MockFileSystemProvider` - In-memory file storage
|
||||
- `MockCachePolicy` - Configurable cache behavior
|
||||
- `MockMIMEResolver` - Custom MIME mappings
|
||||
- Test helpers for common scenarios
|
||||
|
||||
### Step 9: Create Documentation
|
||||
|
||||
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/README.md` (NEW)
|
||||
|
||||
Document:
|
||||
- Quick start examples
|
||||
- Interface overview
|
||||
- Provider implementations
|
||||
- Policy customization
|
||||
- Router integration patterns
|
||||
- Migration guide from old API
|
||||
- Future features roadmap
|
||||
|
||||
## Key Improvements Over Current Implementation
|
||||
|
||||
### 1. Router-Agnostic Design
|
||||
**Before**: Coupled to Gorilla mux via `RegisterRoutes(*mux.Router)`
|
||||
**After**: Returns `http.Handler`, works with any router
|
||||
|
||||
```go
|
||||
// Works with Gorilla Mux
|
||||
muxRouter.PathPrefix("/").Handler(service.Handler())
|
||||
|
||||
// Works with standard http.ServeMux
|
||||
http.Handle("/", service.Handler())
|
||||
|
||||
// Works with any http.Handler-compatible router
|
||||
```
|
||||
|
||||
### 2. Configurable Behaviors
|
||||
**Before**: Hardcoded MIME types, cache times, file extensions
|
||||
**After**: Pluggable policies
|
||||
|
||||
```go
|
||||
// Custom cache per file type
|
||||
cachePolicy := ExtensionCache(map[string]int{
|
||||
".html": 3600, // 1 hour
|
||||
".js": 86400, // 1 day
|
||||
".css": 86400, // 1 day
|
||||
".png": 604800, // 1 week
|
||||
})
|
||||
|
||||
// Custom fallback logic
|
||||
fallback := HTMLFallback("index.html")
|
||||
|
||||
service.Mount(MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: LocalProvider("./dist"),
|
||||
CachePolicy: cachePolicy,
|
||||
FallbackStrategy: fallback,
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Better Resource Management
|
||||
**Before**: Manual zip file cleanup, easy to leak resources
|
||||
**After**: Proper lifecycle with `Close()` on all components
|
||||
|
||||
```go
|
||||
defer service.Close() // Cleans up all providers
|
||||
```
|
||||
|
||||
### 4. Testability
|
||||
**Before**: Hard to test, coupled to filesystem
|
||||
**After**: Mock providers for testing
|
||||
|
||||
```go
|
||||
mockProvider := testing.NewInMemoryProvider(map[string][]byte{
|
||||
"index.html": []byte("<html>test</html>"),
|
||||
})
|
||||
|
||||
service.Mount(MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: mockProvider,
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Extensibility
|
||||
**Before**: Need to modify code to support new file sources
|
||||
**After**: Implement `FileSystemProvider` interface
|
||||
|
||||
```go
|
||||
// Future: Add HTTP provider without changing core code
|
||||
type HTTPFSProvider struct { /* ... */ }
|
||||
func (h *HTTPFSProvider) Open(name string) (fs.File, error) { /* ... */ }
|
||||
func (h *HTTPFSProvider) Close() error { /* ... */ }
|
||||
func (h *HTTPFSProvider) Type() string { return "http" }
|
||||
```
|
||||
|
||||
## Future Features (To Implement Later)
|
||||
|
||||
### Remote HTTP/HTTPS Provider
|
||||
**File**: `providers/http.go` (FUTURE)
|
||||
|
||||
Serve static files from remote HTTP servers with local caching:
|
||||
|
||||
```go
|
||||
type HTTPFSProvider struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
cache LocalCache // Optional disk/memory cache
|
||||
cacheTTL time.Duration
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Example usage
|
||||
service.Mount(MountConfig{
|
||||
URLPrefix: "/cdn",
|
||||
Provider: HTTPProvider("https://cdn.example.com/assets"),
|
||||
})
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Fetch files from remote URLs on-demand
|
||||
- Local cache to reduce remote requests
|
||||
- Configurable TTL and cache eviction
|
||||
- HEAD request support for metadata
|
||||
- Retry logic and timeout handling
|
||||
- Support for authentication headers
|
||||
|
||||
### S3-Compatible Provider
|
||||
**File**: `providers/s3.go` (FUTURE)
|
||||
|
||||
Serve static files from S3, MinIO, or S3-compatible storage:
|
||||
|
||||
```go
|
||||
type S3FSProvider struct {
|
||||
bucket string
|
||||
prefix string
|
||||
region string
|
||||
client *s3.Client
|
||||
cache LocalCache
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Example usage
|
||||
service.Mount(MountConfig{
|
||||
URLPrefix: "/media",
|
||||
Provider: S3Provider("my-bucket", "static/", "us-east-1"),
|
||||
})
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- List and fetch objects from S3 buckets
|
||||
- Support for AWS S3, MinIO, DigitalOcean Spaces, etc.
|
||||
- IAM role or credential-based authentication
|
||||
- Optional local caching layer
|
||||
- Efficient metadata retrieval
|
||||
- Support for presigned URLs
|
||||
|
||||
### Other Future Providers
|
||||
- **GitProvider**: Serve files from Git repositories
|
||||
- **MemoryProvider**: In-memory filesystem for testing/temporary files
|
||||
- **ProxyProvider**: Proxy to another static file server
|
||||
- **CompositeProvider**: Fallback chain across multiple providers
|
||||
|
||||
### Advanced Cache Policies (FUTURE)
|
||||
- **TimedCachePolicy**: Different cache times by time of day
|
||||
- **UserAgentCachePolicy**: Cache based on client type
|
||||
- **ConditionalCachePolicy**: Smart cache based on file size/type
|
||||
- **DistributedCachePolicy**: Shared cache across service instances
|
||||
|
||||
### Advanced Fallback Strategies (FUTURE)
|
||||
- **RegexFallbackStrategy**: Pattern-based routing
|
||||
- **I18nFallbackStrategy**: Language-based file resolution
|
||||
- **VersionedFallbackStrategy**: A/B testing support
|
||||
|
||||
## Critical Files Summary
|
||||
|
||||
### Files to CREATE (in order):
|
||||
1. `pkg/server/staticweb/interfaces.go` - Core contracts
|
||||
2. `pkg/server/staticweb/config.go` - Configuration structs and helpers
|
||||
3. `pkg/server/staticweb/policies/cache.go` - Cache policy implementations
|
||||
4. `pkg/server/staticweb/policies/mime.go` - MIME resolver implementations
|
||||
5. `pkg/server/staticweb/policies/fallback.go` - Fallback strategy implementations
|
||||
6. `pkg/server/staticweb/providers/local.go` - Local directory provider
|
||||
7. `pkg/server/staticweb/providers/zip.go` - Zip file provider
|
||||
8. `pkg/server/staticweb/providers/embed.go` - Embedded filesystem provider
|
||||
9. `pkg/server/staticweb/mount.go` - Mount point implementation
|
||||
10. `pkg/server/staticweb/service.go` - Main service implementation
|
||||
11. `pkg/server/staticweb/testing/mocks.go` - Test helpers
|
||||
12. `pkg/server/staticweb/service_test.go` - Service tests
|
||||
13. `pkg/server/staticweb/providers/providers_test.go` - Provider tests
|
||||
14. `pkg/server/staticweb/example_test.go` - Example code
|
||||
15. `pkg/server/staticweb/README.md` - Documentation
|
||||
|
||||
### Files to REMOVE:
|
||||
1. `pkg/server/staticweb/staticweb.go` - Replaced by new design
|
||||
|
||||
### Files to KEEP:
|
||||
1. `pkg/server/zipfs/zipfs.go` - Used by ZipFSProvider
|
||||
|
||||
### Files for FUTURE (not in this refactoring):
|
||||
1. `pkg/server/staticweb/providers/http.go` - HTTP/HTTPS remote provider
|
||||
2. `pkg/server/staticweb/providers/s3.go` - S3-compatible storage provider
|
||||
3. `pkg/server/staticweb/providers/composite.go` - Fallback chain provider
|
||||
|
||||
## Example Usage After Refactoring
|
||||
|
||||
### Basic Static Site
|
||||
```go
|
||||
service := staticweb.NewService(nil) // Use defaults
|
||||
|
||||
err := service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: staticweb.LocalProvider("./public"),
|
||||
})
|
||||
|
||||
muxRouter.PathPrefix("/").Handler(service.Handler())
|
||||
```
|
||||
|
||||
### SPA with API Routes
|
||||
```go
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: staticweb.LocalProvider("./dist"),
|
||||
FallbackStrategy: staticweb.HTMLFallback("index.html"),
|
||||
})
|
||||
|
||||
// API routes take precedence (registered first)
|
||||
muxRouter.HandleFunc("/api/users", usersHandler)
|
||||
muxRouter.HandleFunc("/api/posts", postsHandler)
|
||||
|
||||
// Static files handle all other routes
|
||||
muxRouter.PathPrefix("/").Handler(service.Handler())
|
||||
```
|
||||
|
||||
### Multiple Mount Points with Different Policies
|
||||
```go
|
||||
service := staticweb.NewService(&staticweb.ServiceConfig{
|
||||
DefaultCacheTime: 3600,
|
||||
})
|
||||
|
||||
// Assets with long cache
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/assets",
|
||||
Provider: staticweb.LocalProvider("./assets"),
|
||||
CachePolicy: staticweb.SimpleCache(604800), // 1 week
|
||||
})
|
||||
|
||||
// HTML with short cache
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: staticweb.LocalProvider("./public"),
|
||||
CachePolicy: staticweb.SimpleCache(300), // 5 minutes
|
||||
})
|
||||
|
||||
router.PathPrefix("/").Handler(service.Handler())
|
||||
```
|
||||
|
||||
### Embedded Files from Zip
|
||||
```go
|
||||
//go:embed assets.zip
|
||||
var assetsZip embed.FS
|
||||
|
||||
service := staticweb.NewService(nil)
|
||||
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: staticweb.EmbedProvider(&assetsZip, "assets.zip"),
|
||||
})
|
||||
|
||||
router.PathPrefix("/").Handler(service.Handler())
|
||||
```
|
||||
|
||||
### Future: CDN Fallback (when HTTP provider is implemented)
|
||||
```go
|
||||
// Primary CDN with local fallback
|
||||
service.Mount(staticweb.MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: staticweb.CompositeProvider(
|
||||
staticweb.HTTPProvider("https://cdn.example.com/assets"),
|
||||
staticweb.LocalProvider("./public/assets"),
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Each provider implementation independently
|
||||
- Each policy implementation independently
|
||||
- Mount point request handling
|
||||
- Service mount/unmount operations
|
||||
|
||||
### Integration Tests
|
||||
- Full request flow through service
|
||||
- Multiple mount points
|
||||
- Longest-prefix matching
|
||||
- Resource cleanup
|
||||
|
||||
### Example Tests
|
||||
- Executable examples in `example_test.go`
|
||||
- Demonstrate common usage patterns
|
||||
|
||||
## Migration Impact
|
||||
|
||||
### Breaking Changes
|
||||
- Complete API redesign (acceptable per user)
|
||||
- Package not currently used in codebase (no migration needed)
|
||||
- New consumers will use new API from start
|
||||
|
||||
### Future Extensibility
|
||||
The interface-driven design allows future additions without breaking changes:
|
||||
- Add HTTPFSProvider by implementing `FileSystemProvider`
|
||||
- Add S3FSProvider by implementing `FileSystemProvider`
|
||||
- Add custom cache policies by implementing `CachePolicy`
|
||||
- Add custom fallback strategies by implementing `FallbackStrategy`
|
||||
|
||||
## Implementation Order
|
||||
1. Interfaces (foundation)
|
||||
2. Configuration (API surface)
|
||||
3. Policies (pluggable behavior)
|
||||
4. Providers (filesystem abstraction)
|
||||
5. Mount Point (request handling)
|
||||
6. Service (orchestration)
|
||||
7. Tests (validation)
|
||||
8. Documentation (usage)
|
||||
9. Remove old code (cleanup)
|
||||
|
||||
This order ensures each layer builds on tested, working components.
|
||||
|
||||
---
|
||||
|
||||
103
pkg/server/staticweb/policies/cache.go
Normal file
103
pkg/server/staticweb/policies/cache.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package policies
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SimpleCachePolicy implements a basic cache policy with a single TTL for all files.
|
||||
type SimpleCachePolicy struct {
|
||||
cacheTime int // Cache duration in seconds
|
||||
}
|
||||
|
||||
// NewSimpleCachePolicy creates a new SimpleCachePolicy with the given cache time in seconds.
|
||||
func NewSimpleCachePolicy(cacheTimeSeconds int) *SimpleCachePolicy {
|
||||
return &SimpleCachePolicy{
|
||||
cacheTime: cacheTimeSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCacheTime returns the cache duration for any file.
|
||||
func (p *SimpleCachePolicy) GetCacheTime(filePath string) int {
|
||||
return p.cacheTime
|
||||
}
|
||||
|
||||
// GetCacheHeaders returns the Cache-Control header for the given file.
|
||||
func (p *SimpleCachePolicy) GetCacheHeaders(filePath string) map[string]string {
|
||||
if p.cacheTime <= 0 {
|
||||
return map[string]string{
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"Cache-Control": fmt.Sprintf("public, max-age=%d", p.cacheTime),
|
||||
}
|
||||
}
|
||||
|
||||
// ExtensionBasedCachePolicy implements a cache policy that varies by file extension.
|
||||
type ExtensionBasedCachePolicy struct {
|
||||
rules map[string]int // Extension -> cache time in seconds
|
||||
defaultTime int // Default cache time for unmatched extensions
|
||||
}
|
||||
|
||||
// NewExtensionBasedCachePolicy creates a new ExtensionBasedCachePolicy.
|
||||
// rules maps file extensions (with leading dot, e.g., ".js") to cache times in seconds.
|
||||
// defaultTime is used for files that don't match any rule.
|
||||
func NewExtensionBasedCachePolicy(rules map[string]int, defaultTime int) *ExtensionBasedCachePolicy {
|
||||
return &ExtensionBasedCachePolicy{
|
||||
rules: rules,
|
||||
defaultTime: defaultTime,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCacheTime returns the cache duration based on the file extension.
|
||||
func (p *ExtensionBasedCachePolicy) GetCacheTime(filePath string) int {
|
||||
ext := strings.ToLower(path.Ext(filePath))
|
||||
if cacheTime, ok := p.rules[ext]; ok {
|
||||
return cacheTime
|
||||
}
|
||||
return p.defaultTime
|
||||
}
|
||||
|
||||
// GetCacheHeaders returns cache headers based on the file extension.
|
||||
func (p *ExtensionBasedCachePolicy) GetCacheHeaders(filePath string) map[string]string {
|
||||
cacheTime := p.GetCacheTime(filePath)
|
||||
|
||||
if cacheTime <= 0 {
|
||||
return map[string]string{
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"Cache-Control": fmt.Sprintf("public, max-age=%d", cacheTime),
|
||||
}
|
||||
}
|
||||
|
||||
// NoCachePolicy implements a cache policy that disables all caching.
|
||||
type NoCachePolicy struct{}
|
||||
|
||||
// NewNoCachePolicy creates a new NoCachePolicy.
|
||||
func NewNoCachePolicy() *NoCachePolicy {
|
||||
return &NoCachePolicy{}
|
||||
}
|
||||
|
||||
// GetCacheTime always returns 0 (no caching).
|
||||
func (p *NoCachePolicy) GetCacheTime(filePath string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetCacheHeaders returns headers that disable caching.
|
||||
func (p *NoCachePolicy) GetCacheHeaders(filePath string) map[string]string {
|
||||
return map[string]string{
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
}
|
||||
159
pkg/server/staticweb/policies/fallback.go
Normal file
159
pkg/server/staticweb/policies/fallback.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package policies
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NoFallback implements a fallback strategy that never falls back.
|
||||
// All requests for missing files will result in 404 responses.
|
||||
type NoFallback struct{}
|
||||
|
||||
// NewNoFallback creates a new NoFallback strategy.
|
||||
func NewNoFallback() *NoFallback {
|
||||
return &NoFallback{}
|
||||
}
|
||||
|
||||
// ShouldFallback always returns false.
|
||||
func (f *NoFallback) ShouldFallback(filePath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetFallbackPath returns an empty string (never called since ShouldFallback returns false).
|
||||
func (f *NoFallback) GetFallbackPath(filePath string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// HTMLFallbackStrategy implements a fallback strategy for Single Page Applications (SPAs).
|
||||
// It serves a specified HTML file (typically index.html) for non-file requests.
|
||||
type HTMLFallbackStrategy struct {
|
||||
indexFile string
|
||||
}
|
||||
|
||||
// NewHTMLFallbackStrategy creates a new HTMLFallbackStrategy.
|
||||
// indexFile is the path to the HTML file to serve (e.g., "index.html", "/index.html").
|
||||
func NewHTMLFallbackStrategy(indexFile string) *HTMLFallbackStrategy {
|
||||
return &HTMLFallbackStrategy{
|
||||
indexFile: indexFile,
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldFallback returns true for requests that don't look like static assets.
|
||||
func (f *HTMLFallbackStrategy) ShouldFallback(filePath string) bool {
|
||||
// Always fall back unless it looks like a static asset
|
||||
return !f.isStaticAsset(filePath)
|
||||
}
|
||||
|
||||
// GetFallbackPath returns the index file path.
|
||||
func (f *HTMLFallbackStrategy) GetFallbackPath(filePath string) string {
|
||||
return f.indexFile
|
||||
}
|
||||
|
||||
// isStaticAsset checks if the path looks like a static asset (has a file extension).
|
||||
func (f *HTMLFallbackStrategy) isStaticAsset(filePath string) bool {
|
||||
return path.Ext(filePath) != ""
|
||||
}
|
||||
|
||||
// ExtensionBasedFallback implements a fallback strategy that skips fallback for known static file extensions.
|
||||
// This is the behavior from the original StaticHTMLFallbackHandler.
|
||||
type ExtensionBasedFallback struct {
|
||||
staticExtensions map[string]bool
|
||||
fallbackPath string
|
||||
}
|
||||
|
||||
// NewExtensionBasedFallback creates a new ExtensionBasedFallback strategy.
|
||||
// staticExtensions is a list of file extensions (with leading dot) that should NOT use fallback.
|
||||
// fallbackPath is the file to serve when fallback is triggered.
|
||||
func NewExtensionBasedFallback(staticExtensions []string, fallbackPath string) *ExtensionBasedFallback {
|
||||
extMap := make(map[string]bool)
|
||||
for _, ext := range staticExtensions {
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
extMap[strings.ToLower(ext)] = true
|
||||
}
|
||||
|
||||
return &ExtensionBasedFallback{
|
||||
staticExtensions: extMap,
|
||||
fallbackPath: fallbackPath,
|
||||
}
|
||||
}
|
||||
|
||||
// NewDefaultExtensionBasedFallback creates an ExtensionBasedFallback with common web asset extensions.
|
||||
// This matches the behavior of the original StaticHTMLFallbackHandler.
|
||||
func NewDefaultExtensionBasedFallback(fallbackPath string) *ExtensionBasedFallback {
|
||||
return NewExtensionBasedFallback([]string{
|
||||
".js", ".css", ".png", ".svg", ".ico", ".json",
|
||||
".jpg", ".jpeg", ".gif", ".woff", ".woff2", ".ttf", ".eot",
|
||||
}, fallbackPath)
|
||||
}
|
||||
|
||||
// ShouldFallback returns true if the file path doesn't have a static asset extension.
|
||||
func (f *ExtensionBasedFallback) ShouldFallback(filePath string) bool {
|
||||
ext := strings.ToLower(path.Ext(filePath))
|
||||
|
||||
// If it's a known static extension, don't fallback
|
||||
if f.staticExtensions[ext] {
|
||||
return false
|
||||
}
|
||||
|
||||
// Otherwise, try fallback
|
||||
return true
|
||||
}
|
||||
|
||||
// GetFallbackPath returns the configured fallback path.
|
||||
func (f *ExtensionBasedFallback) GetFallbackPath(filePath string) string {
|
||||
return f.fallbackPath
|
||||
}
|
||||
|
||||
// HTMLExtensionFallback implements a fallback strategy that appends .html to paths.
|
||||
// This tries to serve {path}.html for missing files.
|
||||
type HTMLExtensionFallback struct {
|
||||
staticExtensions map[string]bool
|
||||
}
|
||||
|
||||
// NewHTMLExtensionFallback creates a new HTMLExtensionFallback strategy.
|
||||
func NewHTMLExtensionFallback(staticExtensions []string) *HTMLExtensionFallback {
|
||||
extMap := make(map[string]bool)
|
||||
for _, ext := range staticExtensions {
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
extMap[strings.ToLower(ext)] = true
|
||||
}
|
||||
|
||||
return &HTMLExtensionFallback{
|
||||
staticExtensions: extMap,
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldFallback returns true if the path doesn't have a static extension or .html.
|
||||
func (f *HTMLExtensionFallback) ShouldFallback(filePath string) bool {
|
||||
ext := strings.ToLower(path.Ext(filePath))
|
||||
|
||||
// If it's a known static extension, don't fallback
|
||||
if f.staticExtensions[ext] {
|
||||
return false
|
||||
}
|
||||
|
||||
// If it already has .html, don't fallback
|
||||
if ext == ".html" || ext == ".htm" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetFallbackPath returns the path with .html appended.
|
||||
func (f *HTMLExtensionFallback) GetFallbackPath(filePath string) string {
|
||||
cleanPath := path.Clean(filePath)
|
||||
if !strings.HasSuffix(filePath, "/") {
|
||||
cleanPath = strings.TrimRight(cleanPath, "/")
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(cleanPath), ".html") {
|
||||
return cleanPath + ".html"
|
||||
}
|
||||
|
||||
return cleanPath
|
||||
}
|
||||
245
pkg/server/staticweb/policies/mime.go
Normal file
245
pkg/server/staticweb/policies/mime.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package policies
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DefaultMIMEResolver implements a MIME type resolver using Go's standard mime package
|
||||
// and a set of common web file type mappings.
|
||||
type DefaultMIMEResolver struct {
|
||||
customTypes map[string]string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewDefaultMIMEResolver creates a new DefaultMIMEResolver with common web MIME types.
|
||||
func NewDefaultMIMEResolver() *DefaultMIMEResolver {
|
||||
resolver := &DefaultMIMEResolver{
|
||||
customTypes: make(map[string]string),
|
||||
}
|
||||
|
||||
// JavaScript & TypeScript
|
||||
resolver.RegisterMIMEType(".js", "application/javascript")
|
||||
resolver.RegisterMIMEType(".mjs", "application/javascript")
|
||||
resolver.RegisterMIMEType(".cjs", "application/javascript")
|
||||
resolver.RegisterMIMEType(".ts", "text/typescript")
|
||||
resolver.RegisterMIMEType(".tsx", "text/tsx")
|
||||
resolver.RegisterMIMEType(".jsx", "text/jsx")
|
||||
|
||||
// CSS & Styling
|
||||
resolver.RegisterMIMEType(".css", "text/css")
|
||||
resolver.RegisterMIMEType(".scss", "text/x-scss")
|
||||
resolver.RegisterMIMEType(".sass", "text/x-sass")
|
||||
resolver.RegisterMIMEType(".less", "text/x-less")
|
||||
|
||||
// HTML & XML
|
||||
resolver.RegisterMIMEType(".html", "text/html")
|
||||
resolver.RegisterMIMEType(".htm", "text/html")
|
||||
resolver.RegisterMIMEType(".xml", "application/xml")
|
||||
resolver.RegisterMIMEType(".xhtml", "application/xhtml+xml")
|
||||
|
||||
// Images - Raster
|
||||
resolver.RegisterMIMEType(".png", "image/png")
|
||||
resolver.RegisterMIMEType(".jpg", "image/jpeg")
|
||||
resolver.RegisterMIMEType(".jpeg", "image/jpeg")
|
||||
resolver.RegisterMIMEType(".gif", "image/gif")
|
||||
resolver.RegisterMIMEType(".webp", "image/webp")
|
||||
resolver.RegisterMIMEType(".avif", "image/avif")
|
||||
resolver.RegisterMIMEType(".bmp", "image/bmp")
|
||||
resolver.RegisterMIMEType(".tiff", "image/tiff")
|
||||
resolver.RegisterMIMEType(".tif", "image/tiff")
|
||||
resolver.RegisterMIMEType(".ico", "image/x-icon")
|
||||
resolver.RegisterMIMEType(".cur", "image/x-icon")
|
||||
|
||||
// Images - Vector
|
||||
resolver.RegisterMIMEType(".svg", "image/svg+xml")
|
||||
resolver.RegisterMIMEType(".svgz", "image/svg+xml")
|
||||
|
||||
// Fonts
|
||||
resolver.RegisterMIMEType(".woff", "font/woff")
|
||||
resolver.RegisterMIMEType(".woff2", "font/woff2")
|
||||
resolver.RegisterMIMEType(".ttf", "font/ttf")
|
||||
resolver.RegisterMIMEType(".otf", "font/otf")
|
||||
resolver.RegisterMIMEType(".eot", "application/vnd.ms-fontobject")
|
||||
|
||||
// Audio
|
||||
resolver.RegisterMIMEType(".mp3", "audio/mpeg")
|
||||
resolver.RegisterMIMEType(".wav", "audio/wav")
|
||||
resolver.RegisterMIMEType(".ogg", "audio/ogg")
|
||||
resolver.RegisterMIMEType(".oga", "audio/ogg")
|
||||
resolver.RegisterMIMEType(".m4a", "audio/mp4")
|
||||
resolver.RegisterMIMEType(".aac", "audio/aac")
|
||||
resolver.RegisterMIMEType(".flac", "audio/flac")
|
||||
resolver.RegisterMIMEType(".opus", "audio/opus")
|
||||
resolver.RegisterMIMEType(".weba", "audio/webm")
|
||||
|
||||
// Video
|
||||
resolver.RegisterMIMEType(".mp4", "video/mp4")
|
||||
resolver.RegisterMIMEType(".webm", "video/webm")
|
||||
resolver.RegisterMIMEType(".ogv", "video/ogg")
|
||||
resolver.RegisterMIMEType(".avi", "video/x-msvideo")
|
||||
resolver.RegisterMIMEType(".mpeg", "video/mpeg")
|
||||
resolver.RegisterMIMEType(".mpg", "video/mpeg")
|
||||
resolver.RegisterMIMEType(".mov", "video/quicktime")
|
||||
resolver.RegisterMIMEType(".wmv", "video/x-ms-wmv")
|
||||
resolver.RegisterMIMEType(".flv", "video/x-flv")
|
||||
resolver.RegisterMIMEType(".mkv", "video/x-matroska")
|
||||
resolver.RegisterMIMEType(".m4v", "video/mp4")
|
||||
|
||||
// Data & Configuration
|
||||
resolver.RegisterMIMEType(".json", "application/json")
|
||||
resolver.RegisterMIMEType(".xml", "application/xml")
|
||||
resolver.RegisterMIMEType(".yml", "application/yaml")
|
||||
resolver.RegisterMIMEType(".yaml", "application/yaml")
|
||||
resolver.RegisterMIMEType(".toml", "application/toml")
|
||||
resolver.RegisterMIMEType(".ini", "text/plain")
|
||||
resolver.RegisterMIMEType(".conf", "text/plain")
|
||||
resolver.RegisterMIMEType(".config", "text/plain")
|
||||
|
||||
// Documents
|
||||
resolver.RegisterMIMEType(".pdf", "application/pdf")
|
||||
resolver.RegisterMIMEType(".doc", "application/msword")
|
||||
resolver.RegisterMIMEType(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
||||
resolver.RegisterMIMEType(".xls", "application/vnd.ms-excel")
|
||||
resolver.RegisterMIMEType(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
resolver.RegisterMIMEType(".ppt", "application/vnd.ms-powerpoint")
|
||||
resolver.RegisterMIMEType(".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation")
|
||||
resolver.RegisterMIMEType(".odt", "application/vnd.oasis.opendocument.text")
|
||||
resolver.RegisterMIMEType(".ods", "application/vnd.oasis.opendocument.spreadsheet")
|
||||
resolver.RegisterMIMEType(".odp", "application/vnd.oasis.opendocument.presentation")
|
||||
|
||||
// Archives
|
||||
resolver.RegisterMIMEType(".zip", "application/zip")
|
||||
resolver.RegisterMIMEType(".tar", "application/x-tar")
|
||||
resolver.RegisterMIMEType(".gz", "application/gzip")
|
||||
resolver.RegisterMIMEType(".bz2", "application/x-bzip2")
|
||||
resolver.RegisterMIMEType(".7z", "application/x-7z-compressed")
|
||||
resolver.RegisterMIMEType(".rar", "application/vnd.rar")
|
||||
|
||||
// Text files
|
||||
resolver.RegisterMIMEType(".txt", "text/plain")
|
||||
resolver.RegisterMIMEType(".md", "text/markdown")
|
||||
resolver.RegisterMIMEType(".markdown", "text/markdown")
|
||||
resolver.RegisterMIMEType(".csv", "text/csv")
|
||||
resolver.RegisterMIMEType(".log", "text/plain")
|
||||
|
||||
// Source code (for syntax highlighting in browsers)
|
||||
resolver.RegisterMIMEType(".c", "text/x-c")
|
||||
resolver.RegisterMIMEType(".cpp", "text/x-c++")
|
||||
resolver.RegisterMIMEType(".h", "text/x-c")
|
||||
resolver.RegisterMIMEType(".hpp", "text/x-c++")
|
||||
resolver.RegisterMIMEType(".go", "text/x-go")
|
||||
resolver.RegisterMIMEType(".py", "text/x-python")
|
||||
resolver.RegisterMIMEType(".java", "text/x-java")
|
||||
resolver.RegisterMIMEType(".rs", "text/x-rust")
|
||||
resolver.RegisterMIMEType(".rb", "text/x-ruby")
|
||||
resolver.RegisterMIMEType(".php", "text/x-php")
|
||||
resolver.RegisterMIMEType(".sh", "text/x-shellscript")
|
||||
resolver.RegisterMIMEType(".bash", "text/x-shellscript")
|
||||
resolver.RegisterMIMEType(".sql", "text/x-sql")
|
||||
resolver.RegisterMIMEType(".template.sql", "text/plain")
|
||||
resolver.RegisterMIMEType(".upg", "text/plain")
|
||||
|
||||
// Web Assembly
|
||||
resolver.RegisterMIMEType(".wasm", "application/wasm")
|
||||
|
||||
// Manifest & Service Worker
|
||||
resolver.RegisterMIMEType(".webmanifest", "application/manifest+json")
|
||||
resolver.RegisterMIMEType(".manifest", "text/cache-manifest")
|
||||
|
||||
// 3D Models
|
||||
resolver.RegisterMIMEType(".gltf", "model/gltf+json")
|
||||
resolver.RegisterMIMEType(".glb", "model/gltf-binary")
|
||||
resolver.RegisterMIMEType(".obj", "model/obj")
|
||||
resolver.RegisterMIMEType(".stl", "model/stl")
|
||||
|
||||
// Other common web assets
|
||||
resolver.RegisterMIMEType(".map", "application/json") // Source maps
|
||||
resolver.RegisterMIMEType(".swf", "application/x-shockwave-flash")
|
||||
resolver.RegisterMIMEType(".apk", "application/vnd.android.package-archive")
|
||||
resolver.RegisterMIMEType(".dmg", "application/x-apple-diskimage")
|
||||
resolver.RegisterMIMEType(".exe", "application/x-msdownload")
|
||||
resolver.RegisterMIMEType(".iso", "application/x-iso9660-image")
|
||||
|
||||
return resolver
|
||||
}
|
||||
|
||||
// GetMIMEType returns the MIME type for the given file path.
|
||||
// It first checks custom registered types, then falls back to Go's mime.TypeByExtension.
|
||||
func (r *DefaultMIMEResolver) GetMIMEType(filePath string) string {
|
||||
ext := strings.ToLower(path.Ext(filePath))
|
||||
|
||||
// Check custom types first
|
||||
r.mu.RLock()
|
||||
if mimeType, ok := r.customTypes[ext]; ok {
|
||||
r.mu.RUnlock()
|
||||
return mimeType
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// Fall back to standard library
|
||||
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
// Return empty string if unknown
|
||||
return ""
|
||||
}
|
||||
|
||||
// RegisterMIMEType registers a custom MIME type for the given file extension.
|
||||
func (r *DefaultMIMEResolver) RegisterMIMEType(extension, mimeType string) {
|
||||
if !strings.HasPrefix(extension, ".") {
|
||||
extension = "." + extension
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.customTypes[strings.ToLower(extension)] = mimeType
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// ConfigurableMIMEResolver implements a MIME type resolver with user-defined mappings only.
|
||||
// It does not use any default mappings.
|
||||
type ConfigurableMIMEResolver struct {
|
||||
types map[string]string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewConfigurableMIMEResolver creates a new ConfigurableMIMEResolver with the given mappings.
|
||||
func NewConfigurableMIMEResolver(types map[string]string) *ConfigurableMIMEResolver {
|
||||
resolver := &ConfigurableMIMEResolver{
|
||||
types: make(map[string]string),
|
||||
}
|
||||
|
||||
for ext, mimeType := range types {
|
||||
resolver.RegisterMIMEType(ext, mimeType)
|
||||
}
|
||||
|
||||
return resolver
|
||||
}
|
||||
|
||||
// GetMIMEType returns the MIME type for the given file path.
|
||||
func (r *ConfigurableMIMEResolver) GetMIMEType(filePath string) string {
|
||||
ext := strings.ToLower(path.Ext(filePath))
|
||||
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if mimeType, ok := r.types[ext]; ok {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// RegisterMIMEType registers a MIME type for the given file extension.
|
||||
func (r *ConfigurableMIMEResolver) RegisterMIMEType(extension, mimeType string) {
|
||||
if !strings.HasPrefix(extension, ".") {
|
||||
extension = "." + extension
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.types[strings.ToLower(extension)] = mimeType
|
||||
r.mu.Unlock()
|
||||
}
|
||||
119
pkg/server/staticweb/providers/embed.go
Normal file
119
pkg/server/staticweb/providers/embed.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"sync"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/server/zipfs"
|
||||
)
|
||||
|
||||
// EmbedFSProvider serves files from an embedded filesystem.
|
||||
// It supports both direct embedded directories and embedded zip files.
|
||||
type EmbedFSProvider struct {
|
||||
embedFS *embed.FS
|
||||
zipFile string // Optional: path within embedded FS to zip file
|
||||
zipReader *zip.Reader
|
||||
fs fs.FS
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewEmbedFSProvider creates a new EmbedFSProvider.
|
||||
// If zipFile is empty, the embedded FS is used directly.
|
||||
// If zipFile is specified, it's treated as a path to a zip file within the embedded FS.
|
||||
func NewEmbedFSProvider(embedFS fs.FS, zipFile string) (*EmbedFSProvider, error) {
|
||||
if embedFS == nil {
|
||||
return nil, fmt.Errorf("embedded filesystem cannot be nil")
|
||||
}
|
||||
|
||||
// Try to cast to *embed.FS for tracking purposes
|
||||
var embedFSPtr *embed.FS
|
||||
if efs, ok := embedFS.(*embed.FS); ok {
|
||||
embedFSPtr = efs
|
||||
}
|
||||
|
||||
provider := &EmbedFSProvider{
|
||||
embedFS: embedFSPtr,
|
||||
zipFile: zipFile,
|
||||
}
|
||||
|
||||
// If zipFile is specified, open it as a zip archive
|
||||
if zipFile != "" {
|
||||
// Read the zip file from the embedded FS
|
||||
// We need to check if the FS supports ReadFile
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
if readFileFS, ok := embedFS.(interface{ ReadFile(string) ([]byte, error) }); ok {
|
||||
data, err = readFileFS.ReadFile(zipFile)
|
||||
} else {
|
||||
// Fall back to Open and reading
|
||||
file, openErr := embedFS.Open(zipFile)
|
||||
if openErr != nil {
|
||||
return nil, fmt.Errorf("failed to open embedded zip file: %w", openErr)
|
||||
}
|
||||
defer file.Close()
|
||||
data, err = io.ReadAll(file)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read embedded zip file: %w", err)
|
||||
}
|
||||
|
||||
// Create a zip reader from the data
|
||||
reader := bytes.NewReader(data)
|
||||
zipReader, err := zip.NewReader(reader, int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create zip reader: %w", err)
|
||||
}
|
||||
|
||||
provider.zipReader = zipReader
|
||||
provider.fs = zipfs.NewZipFS(zipReader)
|
||||
} else {
|
||||
// Use the embedded FS directly
|
||||
provider.fs = embedFS
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// Open opens the named file from the embedded filesystem.
|
||||
func (p *EmbedFSProvider) Open(name string) (fs.File, error) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if p.fs == nil {
|
||||
return nil, fmt.Errorf("embedded filesystem is closed")
|
||||
}
|
||||
|
||||
return p.fs.Open(name)
|
||||
}
|
||||
|
||||
// Close releases any resources held by the provider.
|
||||
// For embedded filesystems, this is mostly a no-op since Go manages the lifecycle.
|
||||
func (p *EmbedFSProvider) Close() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Clear references to allow garbage collection
|
||||
p.fs = nil
|
||||
p.zipReader = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Type returns "embed" or "embed-zip" depending on the configuration.
|
||||
func (p *EmbedFSProvider) Type() string {
|
||||
if p.zipFile != "" {
|
||||
return "embed-zip"
|
||||
}
|
||||
return "embed"
|
||||
}
|
||||
|
||||
// ZipFile returns the path to the zip file within the embedded FS, if any.
|
||||
func (p *EmbedFSProvider) ZipFile() string {
|
||||
return p.zipFile
|
||||
}
|
||||
80
pkg/server/staticweb/providers/local.go
Normal file
80
pkg/server/staticweb/providers/local.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LocalFSProvider serves files from a local directory.
|
||||
type LocalFSProvider struct {
|
||||
path string
|
||||
fs fs.FS
|
||||
}
|
||||
|
||||
// NewLocalFSProvider creates a new LocalFSProvider for the given directory path.
|
||||
// The path must be an absolute path to an existing directory.
|
||||
func NewLocalFSProvider(path string) (*LocalFSProvider, error) {
|
||||
// Validate that the path exists and is a directory
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stat directory: %w", err)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("path is not a directory: %s", path)
|
||||
}
|
||||
|
||||
// Convert to absolute path
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
return &LocalFSProvider{
|
||||
path: absPath,
|
||||
fs: os.DirFS(absPath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Open opens the named file from the local directory.
|
||||
func (p *LocalFSProvider) Open(name string) (fs.File, error) {
|
||||
return p.fs.Open(name)
|
||||
}
|
||||
|
||||
// Close releases any resources held by the provider.
|
||||
// For local filesystem, this is a no-op since os.DirFS doesn't hold resources.
|
||||
func (p *LocalFSProvider) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Type returns "local".
|
||||
func (p *LocalFSProvider) Type() string {
|
||||
return "local"
|
||||
}
|
||||
|
||||
// Path returns the absolute path to the directory being served.
|
||||
func (p *LocalFSProvider) Path() string {
|
||||
return p.path
|
||||
}
|
||||
|
||||
// Reload refreshes the filesystem view.
|
||||
// For local directories, os.DirFS automatically picks up changes,
|
||||
// so this recreates the DirFS to ensure a fresh view.
|
||||
func (p *LocalFSProvider) Reload() error {
|
||||
// Verify the directory still exists
|
||||
info, err := os.Stat(p.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat directory: %w", err)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path is no longer a directory: %s", p.path)
|
||||
}
|
||||
|
||||
// Recreate the DirFS
|
||||
p.fs = os.DirFS(p.path)
|
||||
|
||||
return nil
|
||||
}
|
||||
393
pkg/server/staticweb/providers/providers_test.go
Normal file
393
pkg/server/staticweb/providers/providers_test.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalFSProvider(t *testing.T) {
|
||||
// Create a temporary directory with test files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider, err := NewLocalFSProvider(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
defer provider.Close()
|
||||
|
||||
// Test opening a file
|
||||
file, err := provider.Open("test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read the file
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != "test content" {
|
||||
t.Errorf("Expected 'test content', got %q", string(data))
|
||||
}
|
||||
|
||||
// Test type
|
||||
if provider.Type() != "local" {
|
||||
t.Errorf("Expected type 'local', got %q", provider.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipFSProvider(t *testing.T) {
|
||||
// Create a temporary zip file
|
||||
tmpDir := t.TempDir()
|
||||
zipPath := filepath.Join(tmpDir, "test.zip")
|
||||
|
||||
// Create zip file with test content
|
||||
zipFile, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
fileWriter, err := zipWriter.Create("test.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = fileWriter.Write([]byte("zip content"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := zipFile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test the provider
|
||||
provider, err := NewZipFSProvider(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
defer provider.Close()
|
||||
|
||||
// Test opening a file
|
||||
file, err := provider.Open("test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read the file
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != "zip content" {
|
||||
t.Errorf("Expected 'zip content', got %q", string(data))
|
||||
}
|
||||
|
||||
// Test type
|
||||
if provider.Type() != "zip" {
|
||||
t.Errorf("Expected type 'zip', got %q", provider.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipFSProviderReload(t *testing.T) {
|
||||
// Create a temporary zip file
|
||||
tmpDir := t.TempDir()
|
||||
zipPath := filepath.Join(tmpDir, "test.zip")
|
||||
|
||||
// Helper to create zip with content
|
||||
createZip := func(content string) {
|
||||
zipFile, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer zipFile.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
fileWriter, err := zipWriter.Create("test.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = fileWriter.Write([]byte(content))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial zip
|
||||
createZip("original content")
|
||||
|
||||
// Test the provider
|
||||
provider, err := NewZipFSProvider(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
defer provider.Close()
|
||||
|
||||
// Read initial content
|
||||
file, err := provider.Open("test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open file: %v", err)
|
||||
}
|
||||
data, _ := io.ReadAll(file)
|
||||
file.Close()
|
||||
|
||||
if string(data) != "original content" {
|
||||
t.Errorf("Expected 'original content', got %q", string(data))
|
||||
}
|
||||
|
||||
// Update the zip file
|
||||
createZip("updated content")
|
||||
|
||||
// Reload the provider
|
||||
if err := provider.Reload(); err != nil {
|
||||
t.Fatalf("Failed to reload: %v", err)
|
||||
}
|
||||
|
||||
// Read updated content
|
||||
file, err = provider.Open("test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open file after reload: %v", err)
|
||||
}
|
||||
data, _ = io.ReadAll(file)
|
||||
file.Close()
|
||||
|
||||
if string(data) != "updated content" {
|
||||
t.Errorf("Expected 'updated content', got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSProviderReload(t *testing.T) {
|
||||
// Create a temporary directory with test files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("original"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider, err := NewLocalFSProvider(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
defer provider.Close()
|
||||
|
||||
// Read initial content
|
||||
file, err := provider.Open("test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open file: %v", err)
|
||||
}
|
||||
data, _ := io.ReadAll(file)
|
||||
file.Close()
|
||||
|
||||
if string(data) != "original" {
|
||||
t.Errorf("Expected 'original', got %q", string(data))
|
||||
}
|
||||
|
||||
// Update the file
|
||||
if err := os.WriteFile(testFile, []byte("updated"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Reload the provider
|
||||
if err := provider.Reload(); err != nil {
|
||||
t.Fatalf("Failed to reload: %v", err)
|
||||
}
|
||||
|
||||
// Read updated content
|
||||
file, err = provider.Open("test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open file after reload: %v", err)
|
||||
}
|
||||
data, _ = io.ReadAll(file)
|
||||
file.Close()
|
||||
|
||||
if string(data) != "updated" {
|
||||
t.Errorf("Expected 'updated', got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedFSProvider(t *testing.T) {
|
||||
// Test with a mock embed.FS
|
||||
mockFS := &mockEmbedFS{
|
||||
files: map[string][]byte{
|
||||
"test.txt": []byte("test content"),
|
||||
},
|
||||
}
|
||||
|
||||
provider, err := NewEmbedFSProvider(mockFS, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
defer provider.Close()
|
||||
|
||||
// Test type
|
||||
if provider.Type() != "embed" {
|
||||
t.Errorf("Expected type 'embed', got %q", provider.Type())
|
||||
}
|
||||
|
||||
// Test opening a file
|
||||
file, err := provider.Open("test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read the file
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != "test content" {
|
||||
t.Errorf("Expected 'test content', got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedFSProviderWithZip(t *testing.T) {
|
||||
// Create an embedded-like FS with a zip file
|
||||
// For simplicity, we'll use a mock embed.FS
|
||||
tmpDir := t.TempDir()
|
||||
zipPath := filepath.Join(tmpDir, "test.zip")
|
||||
|
||||
// Create zip file
|
||||
zipFile, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
fileWriter, err := zipWriter.Create("test.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = fileWriter.Write([]byte("embedded zip content"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
zipWriter.Close()
|
||||
zipFile.Close()
|
||||
|
||||
// Read the zip file
|
||||
zipData, err := os.ReadFile(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a mock embed.FS
|
||||
mockFS := &mockEmbedFS{
|
||||
files: map[string][]byte{
|
||||
"test.zip": zipData,
|
||||
},
|
||||
}
|
||||
|
||||
provider, err := NewEmbedFSProvider(mockFS, "test.zip")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
defer provider.Close()
|
||||
|
||||
// Test opening a file
|
||||
file, err := provider.Open("test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read the file
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != "embedded zip content" {
|
||||
t.Errorf("Expected 'embedded zip content', got %q", string(data))
|
||||
}
|
||||
|
||||
// Test type
|
||||
if provider.Type() != "embed-zip" {
|
||||
t.Errorf("Expected type 'embed-zip', got %q", provider.Type())
|
||||
}
|
||||
}
|
||||
|
||||
// mockEmbedFS is a mock embed.FS for testing
|
||||
type mockEmbedFS struct {
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
func (m *mockEmbedFS) Open(name string) (fs.File, error) {
|
||||
data, ok := m.files[name]
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
return &mockFile{
|
||||
name: name,
|
||||
reader: bytes.NewReader(data),
|
||||
size: int64(len(data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockEmbedFS) ReadFile(name string) ([]byte, error) {
|
||||
data, ok := m.files[name]
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type mockFile struct {
|
||||
name string
|
||||
reader *bytes.Reader
|
||||
size int64
|
||||
}
|
||||
|
||||
func (f *mockFile) Stat() (fs.FileInfo, error) {
|
||||
return &mockFileInfo{name: f.name, size: f.size}, nil
|
||||
}
|
||||
|
||||
func (f *mockFile) Read(p []byte) (int, error) {
|
||||
return f.reader.Read(p)
|
||||
}
|
||||
|
||||
func (f *mockFile) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
|
||||
func (fi *mockFileInfo) Name() string { return fi.name }
|
||||
func (fi *mockFileInfo) Size() int64 { return fi.size }
|
||||
func (fi *mockFileInfo) Mode() fs.FileMode { return 0644 }
|
||||
func (fi *mockFileInfo) ModTime() time.Time { return time.Now() }
|
||||
func (fi *mockFileInfo) IsDir() bool { return false }
|
||||
func (fi *mockFileInfo) Sys() interface{} { return nil }
|
||||
102
pkg/server/staticweb/providers/zip.go
Normal file
102
pkg/server/staticweb/providers/zip.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/server/zipfs"
|
||||
)
|
||||
|
||||
// ZipFSProvider serves files from a zip file.
|
||||
type ZipFSProvider struct {
|
||||
zipPath string
|
||||
zipReader *zip.ReadCloser
|
||||
zipFS *zipfs.ZipFS
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewZipFSProvider creates a new ZipFSProvider for the given zip file path.
|
||||
func NewZipFSProvider(zipPath string) (*ZipFSProvider, error) {
|
||||
// Convert to absolute path
|
||||
absPath, err := filepath.Abs(zipPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
// Open the zip file
|
||||
zipReader, err := zip.OpenReader(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open zip file: %w", err)
|
||||
}
|
||||
|
||||
return &ZipFSProvider{
|
||||
zipPath: absPath,
|
||||
zipReader: zipReader,
|
||||
zipFS: zipfs.NewZipFS(&zipReader.Reader),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Open opens the named file from the zip archive.
|
||||
func (p *ZipFSProvider) Open(name string) (fs.File, error) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if p.zipFS == nil {
|
||||
return nil, fmt.Errorf("zip filesystem is closed")
|
||||
}
|
||||
|
||||
return p.zipFS.Open(name)
|
||||
}
|
||||
|
||||
// Close releases resources held by the zip reader.
|
||||
func (p *ZipFSProvider) Close() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.zipReader != nil {
|
||||
err := p.zipReader.Close()
|
||||
p.zipReader = nil
|
||||
p.zipFS = nil
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Type returns "zip".
|
||||
func (p *ZipFSProvider) Type() string {
|
||||
return "zip"
|
||||
}
|
||||
|
||||
// Path returns the absolute path to the zip file being served.
|
||||
func (p *ZipFSProvider) Path() string {
|
||||
return p.zipPath
|
||||
}
|
||||
|
||||
// Reload reopens the zip file to pick up any changes.
|
||||
// This is useful in development when the zip file is updated.
|
||||
func (p *ZipFSProvider) Reload() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Close the existing zip reader if open
|
||||
if p.zipReader != nil {
|
||||
if err := p.zipReader.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close old zip reader: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reopen the zip file
|
||||
zipReader, err := zip.OpenReader(p.zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reopen zip file: %w", err)
|
||||
}
|
||||
|
||||
p.zipReader = zipReader
|
||||
p.zipFS = zipfs.NewZipFS(&zipReader.Reader)
|
||||
|
||||
return nil
|
||||
}
|
||||
189
pkg/server/staticweb/service.go
Normal file
189
pkg/server/staticweb/service.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package staticweb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// service implements the StaticFileService interface.
|
||||
type service struct {
|
||||
mounts map[string]*mountPoint // urlPrefix -> mount point
|
||||
config *ServiceConfig
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewService creates a new static file service with the given configuration.
|
||||
// If config is nil, default configuration is used.
|
||||
func NewService(config *ServiceConfig) StaticFileService {
|
||||
if config == nil {
|
||||
config = DefaultServiceConfig()
|
||||
}
|
||||
|
||||
return &service{
|
||||
mounts: make(map[string]*mountPoint),
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Mount adds a new mount point with the given configuration.
|
||||
func (s *service) Mount(config MountConfig) error {
|
||||
// Validate the config
|
||||
if err := s.validateMountConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check if the prefix is already mounted
|
||||
if _, exists := s.mounts[config.URLPrefix]; exists {
|
||||
return fmt.Errorf("mount point already exists at %s", config.URLPrefix)
|
||||
}
|
||||
|
||||
// Create the mount point
|
||||
mp, err := newMountPoint(config, s.config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create mount point: %w", err)
|
||||
}
|
||||
|
||||
// Add to the map
|
||||
s.mounts[config.URLPrefix] = mp
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unmount removes the mount point at the given URL prefix.
|
||||
func (s *service) Unmount(urlPrefix string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
mp, exists := s.mounts[urlPrefix]
|
||||
if !exists {
|
||||
return fmt.Errorf("no mount point exists at %s", urlPrefix)
|
||||
}
|
||||
|
||||
// Close the mount point to release resources
|
||||
if err := mp.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close mount point: %w", err)
|
||||
}
|
||||
|
||||
// Remove from the map
|
||||
delete(s.mounts, urlPrefix)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMounts returns a sorted list of all mounted URL prefixes.
|
||||
func (s *service) ListMounts() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
prefixes := make([]string, 0, len(s.mounts))
|
||||
for prefix := range s.mounts {
|
||||
prefixes = append(prefixes, prefix)
|
||||
}
|
||||
|
||||
sort.Strings(prefixes)
|
||||
return prefixes
|
||||
}
|
||||
|
||||
// Reload reinitializes all filesystem providers that support reloading.
|
||||
// This is useful when the underlying files have changed (e.g., zip file updated).
|
||||
// Providers that implement ReloadableProvider will be reloaded.
|
||||
func (s *service) Reload() error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var errors []error
|
||||
|
||||
// Reload all mount points that support it
|
||||
for prefix, mp := range s.mounts {
|
||||
if reloadable, ok := mp.provider.(ReloadableProvider); ok {
|
||||
if err := reloadable.Reload(); err != nil {
|
||||
errors = append(errors, fmt.Errorf("%s: %w", prefix, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return combined errors if any
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("errors while reloading providers: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases all resources held by the service.
|
||||
func (s *service) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var errors []error
|
||||
|
||||
// Close all mount points
|
||||
for prefix, mp := range s.mounts {
|
||||
if err := mp.Close(); err != nil {
|
||||
errors = append(errors, fmt.Errorf("%s: %w", prefix, err))
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the map
|
||||
s.mounts = make(map[string]*mountPoint)
|
||||
|
||||
// Return combined errors if any
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("errors while closing mount points: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler that serves static files from all mount points.
|
||||
func (s *service) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Find the best matching mount point using longest-prefix matching
|
||||
var bestMatch *mountPoint
|
||||
var bestPrefix string
|
||||
|
||||
for prefix, mp := range s.mounts {
|
||||
if strings.HasPrefix(r.URL.Path, prefix) {
|
||||
if len(prefix) > len(bestPrefix) {
|
||||
bestMatch = mp
|
||||
bestPrefix = prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no mount point matches, return without writing a response
|
||||
// This allows other handlers (like API routes) to process the request
|
||||
if bestMatch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the file from the matched mount point
|
||||
bestMatch.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// validateMountConfig validates the mount configuration.
|
||||
func (s *service) validateMountConfig(config MountConfig) error {
|
||||
if config.URLPrefix == "" {
|
||||
return fmt.Errorf("URLPrefix cannot be empty")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(config.URLPrefix, "/") {
|
||||
return fmt.Errorf("URLPrefix must start with /")
|
||||
}
|
||||
|
||||
if config.Provider == nil {
|
||||
return fmt.Errorf("Provider cannot be nil")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
257
pkg/server/staticweb/service_test.go
Normal file
257
pkg/server/staticweb/service_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package staticweb
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing"
|
||||
)
|
||||
|
||||
func TestServiceMount(t *testing.T) {
|
||||
service := NewService(nil)
|
||||
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("<html>test</html>"),
|
||||
})
|
||||
|
||||
err := service.Mount(MountConfig{
|
||||
URLPrefix: "/test",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to mount: %v", err)
|
||||
}
|
||||
|
||||
mounts := service.ListMounts()
|
||||
if len(mounts) != 1 || mounts[0] != "/test" {
|
||||
t.Errorf("Expected [/test], got %v", mounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUnmount(t *testing.T) {
|
||||
service := NewService(nil)
|
||||
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("<html>test</html>"),
|
||||
})
|
||||
|
||||
service.Mount(MountConfig{
|
||||
URLPrefix: "/test",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
err := service.Unmount("/test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmount: %v", err)
|
||||
}
|
||||
|
||||
mounts := service.ListMounts()
|
||||
if len(mounts) != 0 {
|
||||
t.Errorf("Expected empty list, got %v", mounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceHandler(t *testing.T) {
|
||||
service := NewService(nil)
|
||||
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("<html>test</html>"),
|
||||
"app.js": []byte("console.log('test')"),
|
||||
})
|
||||
|
||||
err := service.Mount(MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to mount: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedStatus int
|
||||
expectedBody string
|
||||
}{
|
||||
{
|
||||
name: "serve index.html",
|
||||
path: "/static/index.html",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: "<html>test</html>",
|
||||
},
|
||||
{
|
||||
name: "serve app.js",
|
||||
path: "/static/app.js",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: "console.log('test')",
|
||||
},
|
||||
{
|
||||
name: "non-existent file",
|
||||
path: "/static/nonexistent.html",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedBody: "",
|
||||
},
|
||||
{
|
||||
name: "non-matching prefix returns nothing",
|
||||
path: "/api/test",
|
||||
expectedStatus: http.StatusOK, // Handler returns without writing
|
||||
expectedBody: "",
|
||||
},
|
||||
}
|
||||
|
||||
handler := service.Handler()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// For non-matching prefix, handler doesn't write anything
|
||||
if tt.path == "/api/test" {
|
||||
if rec.Code != 200 || rec.Body.Len() != 0 {
|
||||
t.Errorf("Expected no response, got status %d with body length %d", rec.Code, rec.Body.Len())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if rec.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, rec.Code)
|
||||
}
|
||||
|
||||
if tt.expectedBody != "" && rec.Body.String() != tt.expectedBody {
|
||||
t.Errorf("Expected body %q, got %q", tt.expectedBody, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLongestPrefixMatching(t *testing.T) {
|
||||
service := NewService(nil)
|
||||
|
||||
// Mount at /
|
||||
provider1 := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("root"),
|
||||
})
|
||||
|
||||
// Mount at /static
|
||||
provider2 := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("static"),
|
||||
})
|
||||
|
||||
service.Mount(MountConfig{
|
||||
URLPrefix: "/",
|
||||
Provider: provider1,
|
||||
})
|
||||
|
||||
service.Mount(MountConfig{
|
||||
URLPrefix: "/static",
|
||||
Provider: provider2,
|
||||
})
|
||||
|
||||
handler := service.Handler()
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
expectedBody string
|
||||
}{
|
||||
{"/index.html", "root"},
|
||||
{"/static/index.html", "static"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if rec.Body.String() != tt.expectedBody {
|
||||
t.Errorf("Expected body %q, got %q", tt.expectedBody, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceClose(t *testing.T) {
|
||||
service := NewService(nil)
|
||||
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("<html>test</html>"),
|
||||
})
|
||||
|
||||
service.Mount(MountConfig{
|
||||
URLPrefix: "/test",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
err := service.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to close service: %v", err)
|
||||
}
|
||||
|
||||
mounts := service.ListMounts()
|
||||
if len(mounts) != 0 {
|
||||
t.Errorf("Expected empty list after close, got %v", mounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceReload(t *testing.T) {
|
||||
service := NewService(nil)
|
||||
|
||||
// Create a mock provider that supports reload
|
||||
provider := staticwebtesting.NewMockProvider(map[string][]byte{
|
||||
"index.html": []byte("original"),
|
||||
})
|
||||
|
||||
service.Mount(MountConfig{
|
||||
URLPrefix: "/test",
|
||||
Provider: provider,
|
||||
})
|
||||
|
||||
handler := service.Handler()
|
||||
|
||||
// Test initial content
|
||||
req := httptest.NewRequest("GET", "/test/index.html", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if rec.Body.String() != "original" {
|
||||
t.Errorf("Expected body 'original', got %q", rec.Body.String())
|
||||
}
|
||||
|
||||
// Update the provider's content
|
||||
provider.AddFile("index.html", []byte("updated"))
|
||||
|
||||
// The content is already updated since we're using a mock
|
||||
// In a real scenario with zip files, you'd call Reload() here
|
||||
err := service.Reload()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload service: %v", err)
|
||||
}
|
||||
|
||||
// Test updated content
|
||||
req = httptest.NewRequest("GET", "/test/index.html", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if rec.Body.String() != "updated" {
|
||||
t.Errorf("Expected body 'updated', got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
231
pkg/server/staticweb/testing/mocks.go
Normal file
231
pkg/server/staticweb/testing/mocks.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package testing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockFileSystemProvider is an in-memory filesystem provider for testing.
|
||||
type MockFileSystemProvider struct {
|
||||
files map[string][]byte
|
||||
closed bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMockProvider creates a new in-memory provider with the given files.
|
||||
// Keys should be slash-separated paths (e.g., "index.html", "assets/app.js").
|
||||
func NewMockProvider(files map[string][]byte) *MockFileSystemProvider {
|
||||
return &MockFileSystemProvider{
|
||||
files: files,
|
||||
}
|
||||
}
|
||||
|
||||
// Open opens a file from the in-memory filesystem.
|
||||
func (m *MockFileSystemProvider) Open(name string) (fs.File, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.closed {
|
||||
return nil, fmt.Errorf("provider is closed")
|
||||
}
|
||||
|
||||
// Remove leading slash if present
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
|
||||
data, ok := m.files[name]
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
return &mockFile{
|
||||
name: path.Base(name),
|
||||
data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close marks the provider as closed.
|
||||
func (m *MockFileSystemProvider) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Type returns "mock".
|
||||
func (m *MockFileSystemProvider) Type() string {
|
||||
return "mock"
|
||||
}
|
||||
|
||||
// AddFile adds a file to the in-memory filesystem.
|
||||
func (m *MockFileSystemProvider) AddFile(name string, data []byte) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
m.files[name] = data
|
||||
}
|
||||
|
||||
// RemoveFile removes a file from the in-memory filesystem.
|
||||
func (m *MockFileSystemProvider) RemoveFile(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
delete(m.files, name)
|
||||
}
|
||||
|
||||
// mockFile implements fs.File for in-memory files.
|
||||
type mockFile struct {
|
||||
name string
|
||||
data []byte
|
||||
reader *bytes.Reader
|
||||
offset int64
|
||||
}
|
||||
|
||||
func (f *mockFile) Stat() (fs.FileInfo, error) {
|
||||
return &mockFileInfo{
|
||||
name: f.name,
|
||||
size: int64(len(f.data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *mockFile) Read(p []byte) (int, error) {
|
||||
if f.reader == nil {
|
||||
f.reader = bytes.NewReader(f.data)
|
||||
if f.offset > 0 {
|
||||
f.reader.Seek(f.offset, io.SeekStart)
|
||||
}
|
||||
}
|
||||
n, err := f.reader.Read(p)
|
||||
f.offset += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (f *mockFile) Seek(offset int64, whence int) (int64, error) {
|
||||
if f.reader == nil {
|
||||
f.reader = bytes.NewReader(f.data)
|
||||
}
|
||||
pos, err := f.reader.Seek(offset, whence)
|
||||
f.offset = pos
|
||||
return pos, err
|
||||
}
|
||||
|
||||
func (f *mockFile) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockFileInfo implements fs.FileInfo.
|
||||
type mockFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
|
||||
func (fi *mockFileInfo) Name() string { return fi.name }
|
||||
func (fi *mockFileInfo) Size() int64 { return fi.size }
|
||||
func (fi *mockFileInfo) Mode() fs.FileMode { return 0644 }
|
||||
func (fi *mockFileInfo) ModTime() time.Time { return time.Now() }
|
||||
func (fi *mockFileInfo) IsDir() bool { return false }
|
||||
func (fi *mockFileInfo) Sys() interface{} { return nil }
|
||||
|
||||
// MockCachePolicy is a configurable cache policy for testing.
|
||||
type MockCachePolicy struct {
|
||||
CacheTime int
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// NewMockCachePolicy creates a new mock cache policy.
|
||||
func NewMockCachePolicy(cacheTime int) *MockCachePolicy {
|
||||
return &MockCachePolicy{
|
||||
CacheTime: cacheTime,
|
||||
Headers: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCacheTime returns the configured cache time.
|
||||
func (p *MockCachePolicy) GetCacheTime(path string) int {
|
||||
return p.CacheTime
|
||||
}
|
||||
|
||||
// GetCacheHeaders returns the configured headers.
|
||||
func (p *MockCachePolicy) GetCacheHeaders(path string) map[string]string {
|
||||
if p.Headers != nil {
|
||||
return p.Headers
|
||||
}
|
||||
return map[string]string{
|
||||
"Cache-Control": fmt.Sprintf("public, max-age=%d", p.CacheTime),
|
||||
}
|
||||
}
|
||||
|
||||
// MockMIMEResolver is a configurable MIME resolver for testing.
|
||||
type MockMIMEResolver struct {
|
||||
types map[string]string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMockMIMEResolver creates a new mock MIME resolver.
|
||||
func NewMockMIMEResolver() *MockMIMEResolver {
|
||||
return &MockMIMEResolver{
|
||||
types: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// GetMIMEType returns the MIME type for the given path.
|
||||
func (r *MockMIMEResolver) GetMIMEType(path string) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
ext := strings.ToLower(path[strings.LastIndex(path, "."):])
|
||||
if mimeType, ok := r.types[ext]; ok {
|
||||
return mimeType
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// RegisterMIMEType registers a MIME type.
|
||||
func (r *MockMIMEResolver) RegisterMIMEType(extension, mimeType string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if !strings.HasPrefix(extension, ".") {
|
||||
extension = "." + extension
|
||||
}
|
||||
r.types[strings.ToLower(extension)] = mimeType
|
||||
}
|
||||
|
||||
// MockFallbackStrategy is a configurable fallback strategy for testing.
|
||||
type MockFallbackStrategy struct {
|
||||
ShouldFallbackFunc func(path string) bool
|
||||
FallbackPathFunc func(path string) string
|
||||
}
|
||||
|
||||
// NewMockFallbackStrategy creates a new mock fallback strategy.
|
||||
func NewMockFallbackStrategy(shouldFallback func(string) bool, fallbackPath func(string) string) *MockFallbackStrategy {
|
||||
return &MockFallbackStrategy{
|
||||
ShouldFallbackFunc: shouldFallback,
|
||||
FallbackPathFunc: fallbackPath,
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldFallback returns whether fallback should be used.
|
||||
func (s *MockFallbackStrategy) ShouldFallback(path string) bool {
|
||||
if s.ShouldFallbackFunc != nil {
|
||||
return s.ShouldFallbackFunc(path)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetFallbackPath returns the fallback path.
|
||||
func (s *MockFallbackStrategy) GetFallbackPath(path string) string {
|
||||
if s.FallbackPathFunc != nil {
|
||||
return s.FallbackPathFunc(path)
|
||||
}
|
||||
return "index.html"
|
||||
}
|
||||
112
pkg/server/zipfs/zipfs.go
Normal file
112
pkg/server/zipfs/zipfs.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package zipfs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
type ZipFS struct {
|
||||
*zip.Reader
|
||||
}
|
||||
|
||||
func NewZipFS(r *zip.Reader) *ZipFS {
|
||||
return &ZipFS{r}
|
||||
}
|
||||
|
||||
func (z *ZipFS) Open(name string) (fs.File, error) {
|
||||
for _, f := range z.File {
|
||||
if f.Name == name {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ZipFile{f, rc, 0}, nil
|
||||
}
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
type ZipFile struct {
|
||||
*zip.File
|
||||
rc io.ReadCloser
|
||||
offset int64
|
||||
}
|
||||
|
||||
func (f *ZipFile) Stat() (fs.FileInfo, error) {
|
||||
if f.File != nil {
|
||||
return f.File.FileInfo(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("No file")
|
||||
}
|
||||
|
||||
func (f *ZipFile) Close() error {
|
||||
if f.rc != nil {
|
||||
return f.rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *ZipFile) Read(b []byte) (int, error) {
|
||||
if f.rc == nil {
|
||||
var err error
|
||||
f.rc, err = f.File.Open()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
n, err := f.rc.Read(b)
|
||||
f.offset += int64(n)
|
||||
if err == io.EOF {
|
||||
f.rc.Close()
|
||||
f.rc = nil
|
||||
}
|
||||
return n, err
|
||||
|
||||
}
|
||||
func (f *ZipFile) Seek(offset int64, whence int) (int64, error) {
|
||||
if f.rc != nil {
|
||||
f.rc.Close()
|
||||
f.rc = nil
|
||||
}
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
if offset < 0 {
|
||||
return 0, &fs.PathError{Op: "seek", Path: f.Name, Err: fmt.Errorf("negative position")}
|
||||
}
|
||||
f.offset = offset
|
||||
case io.SeekCurrent:
|
||||
if f.offset+offset < 0 {
|
||||
return 0, &fs.PathError{Op: "seek", Path: f.Name, Err: fmt.Errorf("negative position")}
|
||||
}
|
||||
f.offset += offset
|
||||
case io.SeekEnd:
|
||||
size := int64(f.File.UncompressedSize64)
|
||||
if size+offset < 0 {
|
||||
return 0, &fs.PathError{Op: "seek", Path: f.Name, Err: fmt.Errorf("negative position")}
|
||||
}
|
||||
f.offset = size + offset
|
||||
}
|
||||
return f.offset, nil
|
||||
}
|
||||
|
||||
/*
|
||||
func main() {
|
||||
r, err := zip.OpenReader("path/to/your.zip")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
fs := NewZipFS(&r.Reader)
|
||||
file, err := fs.Open(path.Join("path", "to", "file"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Now you can use 'file' as a fs.File
|
||||
}
|
||||
*/
|
||||
Reference in New Issue
Block a user