blumenjs 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "fmt"
8
+ "image"
9
+ "image/jpeg"
10
+ "image/png"
11
+ "log"
12
+ "net/http"
13
+ "os"
14
+ "path/filepath"
15
+ "strconv"
16
+ "strings"
17
+
18
+ "golang.org/x/image/draw"
19
+ )
20
+
21
+ const (
22
+ imageCacheDir = ".blumen/image-cache"
23
+ maxImageWidth = 4096
24
+ defaultQuality = 80
25
+ )
26
+
27
+ func init() {
28
+ // Ensure cache directory exists
29
+ os.MkdirAll(imageCacheDir, 0755)
30
+ }
31
+
32
+ // ImageHandler serves optimized images with on-the-fly resizing and caching.
33
+ // URL: /_blumen/image?src=/static/hero.jpg&w=800&q=80&blur=1
34
+ func ImageHandler() http.HandlerFunc {
35
+ return func(w http.ResponseWriter, r *http.Request) {
36
+ if r.Method != http.MethodGet {
37
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
38
+ return
39
+ }
40
+
41
+ q := r.URL.Query()
42
+ src := q.Get("src")
43
+ widthStr := q.Get("w")
44
+ qualityStr := q.Get("q")
45
+ isBlur := q.Get("blur") == "1"
46
+
47
+ if src == "" {
48
+ http.Error(w, "Missing 'src' parameter", http.StatusBadRequest)
49
+ return
50
+ }
51
+
52
+ // Parse width
53
+ targetWidth := 0
54
+ if widthStr != "" {
55
+ var err error
56
+ targetWidth, err = strconv.Atoi(widthStr)
57
+ if err != nil || targetWidth <= 0 || targetWidth > maxImageWidth {
58
+ http.Error(w, "Invalid 'w' parameter", http.StatusBadRequest)
59
+ return
60
+ }
61
+ }
62
+
63
+ // Parse quality
64
+ quality := defaultQuality
65
+ if qualityStr != "" {
66
+ var err error
67
+ quality, err = strconv.Atoi(qualityStr)
68
+ if err != nil || quality < 1 || quality > 100 {
69
+ quality = defaultQuality
70
+ }
71
+ }
72
+
73
+ // Security: resolve the source path and ensure it's within the project
74
+ cleanSrc := strings.TrimPrefix(src, "/")
75
+ absPath, err := filepath.Abs(cleanSrc)
76
+ if err != nil {
77
+ http.Error(w, "Invalid path", http.StatusBadRequest)
78
+ return
79
+ }
80
+
81
+ // Ensure path is within the project directory
82
+ cwd, _ := os.Getwd()
83
+ if !strings.HasPrefix(absPath, cwd) {
84
+ http.Error(w, "Access denied", http.StatusForbidden)
85
+ return
86
+ }
87
+
88
+ // Check if file exists
89
+ if _, err := os.Stat(absPath); os.IsNotExist(err) {
90
+ http.Error(w, "Image not found", http.StatusNotFound)
91
+ return
92
+ }
93
+
94
+ // Generate cache key
95
+ cacheKey := generateCacheKey(src, targetWidth, quality, isBlur)
96
+ cachePath := filepath.Join(imageCacheDir, cacheKey+".jpg")
97
+
98
+ // Check disk cache
99
+ if cachedData, err := os.ReadFile(cachePath); err == nil {
100
+ serveCachedImage(w, cachedData)
101
+ return
102
+ }
103
+
104
+ // Process the image
105
+ processedData, err := processImage(absPath, targetWidth, quality, isBlur)
106
+ if err != nil {
107
+ log.Printf("Image processing error: %v", err)
108
+ http.ServeFile(w, r, absPath)
109
+ return
110
+ }
111
+
112
+ // Write to disk cache
113
+ os.WriteFile(cachePath, processedData, 0644)
114
+
115
+ // Serve the processed image
116
+ serveCachedImage(w, processedData)
117
+ }
118
+ }
119
+
120
+ func generateCacheKey(src string, width, quality int, blur bool) string {
121
+ data := fmt.Sprintf("%s-%d-%d-%v", src, width, quality, blur)
122
+ hash := sha256.Sum256([]byte(data))
123
+ return hex.EncodeToString(hash[:16])
124
+ }
125
+
126
+ func serveCachedImage(w http.ResponseWriter, data []byte) {
127
+ w.Header().Set("Content-Type", "image/jpeg")
128
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
129
+ w.Header().Set("X-Blumen-Image", "optimized")
130
+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))
131
+ w.WriteHeader(http.StatusOK)
132
+ w.Write(data)
133
+ }
134
+
135
+ func processImage(filePath string, targetWidth, quality int, blur bool) ([]byte, error) {
136
+ file, err := os.Open(filePath)
137
+ if err != nil {
138
+ return nil, fmt.Errorf("open image: %w", err)
139
+ }
140
+ defer file.Close()
141
+
142
+ img, format, err := image.Decode(file)
143
+ if err != nil {
144
+ return nil, fmt.Errorf("decode image (%s): %w", format, err)
145
+ }
146
+
147
+ bounds := img.Bounds()
148
+ origWidth := bounds.Dx()
149
+ origHeight := bounds.Dy()
150
+
151
+ // Calculate target dimensions maintaining aspect ratio
152
+ newWidth := origWidth
153
+ newHeight := origHeight
154
+
155
+ if targetWidth > 0 && targetWidth < origWidth {
156
+ ratio := float64(targetWidth) / float64(origWidth)
157
+ newWidth = targetWidth
158
+ newHeight = int(float64(origHeight) * ratio)
159
+ }
160
+
161
+ // Resize using high-quality bilinear interpolation
162
+ resized := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
163
+ draw.BiLinear.Scale(resized, resized.Bounds(), img, bounds, draw.Over, nil)
164
+
165
+ // For blur placeholders, downscale aggressively
166
+ var finalImg image.Image = resized
167
+ if blur {
168
+ blurWidth := 20
169
+ if newWidth < blurWidth {
170
+ blurWidth = newWidth
171
+ }
172
+ blurHeight := int(float64(newHeight) * (float64(blurWidth) / float64(newWidth)))
173
+ if blurHeight < 1 {
174
+ blurHeight = 1
175
+ }
176
+ blurred := image.NewRGBA(image.Rect(0, 0, blurWidth, blurHeight))
177
+ draw.BiLinear.Scale(blurred, blurred.Bounds(), resized, resized.Bounds(), draw.Over, nil)
178
+ finalImg = blurred
179
+ }
180
+
181
+ // Encode to JPEG
182
+ var buf bytes.Buffer
183
+
184
+ jpegQuality := quality
185
+ if blur {
186
+ jpegQuality = 30
187
+ }
188
+
189
+ err = jpeg.Encode(&buf, finalImg, &jpeg.Options{Quality: jpegQuality})
190
+ if err != nil {
191
+ return nil, fmt.Errorf("encode jpeg: %w", err)
192
+ }
193
+
194
+ return buf.Bytes(), nil
195
+ }
196
+
197
+ // Ensure PNG decoder is registered
198
+ func init() {
199
+ _ = png.Decode
200
+ }