350 lines
7.9 KiB
Go
350 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/draw"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/auyer/steganography"
|
|
"golang.org/x/image/webp"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type Config struct {
|
|
Server struct {
|
|
Port string `yaml:"port"`
|
|
ImageDir string `yaml:"image_dir"`
|
|
BasePath string `yaml:"base_path"`
|
|
CacheImages bool `yaml:"cache_images"`
|
|
} `yaml:"server"`
|
|
Security struct {
|
|
AESKey string `yaml:"aes_key"` // Base64 encoded 32-byte key
|
|
} `yaml:"security"`
|
|
}
|
|
|
|
func loadConfig(filename string) error {
|
|
data, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("config file read error: %w", err)
|
|
}
|
|
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return fmt.Errorf("config parse error: %w", err)
|
|
}
|
|
|
|
// Set defaults if not specified
|
|
if cfg.Server.Port == "" {
|
|
cfg.Server.Port = "8219"
|
|
}
|
|
if cfg.Server.ImageDir == "" {
|
|
cfg.Server.ImageDir = "./images"
|
|
}
|
|
|
|
if cfg.Server.BasePath == "" {
|
|
cfg.Server.BasePath = "/"
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
decodedImageCache sync.Map // path -> *cachedImage
|
|
aesKey []byte
|
|
cfg Config
|
|
)
|
|
|
|
type cachedImage struct {
|
|
img *image.NRGBA
|
|
mimeType string
|
|
maxPayloadSize uint32
|
|
size uint32
|
|
}
|
|
|
|
func init() {
|
|
if err := loadConfig("config.yaml"); err != nil {
|
|
log.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
if cfg.Security.AESKey != "" {
|
|
var err error
|
|
aesKey, err = base64.StdEncoding.DecodeString(cfg.Security.AESKey)
|
|
if err != nil {
|
|
log.Fatalf("Invalid AES key in config: %v", err)
|
|
}
|
|
} else {
|
|
aesKey = make([]byte, 32)
|
|
if _, err := rand.Read(aesKey); err != nil {
|
|
log.Fatalf("Failed to generate AES key: %v", err)
|
|
}
|
|
log.Printf("Generated new AES key (base64): %s", base64.StdEncoding.EncodeToString(aesKey))
|
|
}
|
|
|
|
if cfg.Server.CacheImages {
|
|
if err := loadImages(); err != nil {
|
|
log.Printf("Warning: could not preload images: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadImages() error {
|
|
return filepath.WalkDir(cfg.Server.ImageDir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
switch ext {
|
|
case ".jpg", ".jpeg", ".png", ".webp":
|
|
data, err := os.ReadFile(path)
|
|
size := uint32(len(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Preload decoded image
|
|
img, mimeType, err := decodeImageData(data, ext)
|
|
if err != nil {
|
|
return fmt.Errorf("error decoding %s: %v", path, err)
|
|
}
|
|
|
|
nrgba := image.NewNRGBA(img.Bounds())
|
|
draw.Draw(nrgba, nrgba.Bounds(), img, img.Bounds().Min, draw.Src)
|
|
|
|
maxSize := steganography.MaxEncodeSize(nrgba)
|
|
maxPlaintext, err := maxPlaintextSize(maxSize)
|
|
if err != nil {
|
|
log.Printf("Skipping %s: %v", path, err)
|
|
return nil
|
|
}
|
|
|
|
decodedImageCache.Store(path, &cachedImage{
|
|
img: nrgba,
|
|
mimeType: mimeType,
|
|
maxPayloadSize: maxPlaintext,
|
|
size: size,
|
|
})
|
|
log.Printf("Cached decoded image: %s", path)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func decodeImageData(data []byte, ext string) (image.Image, string, error) {
|
|
switch ext {
|
|
case ".jpg", ".jpeg":
|
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
|
return img, "image/jpeg", err
|
|
case ".png":
|
|
img, err := png.Decode(bytes.NewReader(data))
|
|
return img, "image/png", err
|
|
case ".webp":
|
|
img, err := webp.Decode(bytes.NewReader(data))
|
|
return img, "image/webp", err
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
|
}
|
|
}
|
|
|
|
func encryptData(plaintext []byte) ([]byte, error) {
|
|
block, err := aes.NewCipher(aesKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
|
}
|
|
|
|
func maxPlaintextSize(totalOutputSize uint32) (uint32, error) {
|
|
block, err := aes.NewCipher(aesKey)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
overhead := uint32(gcm.NonceSize() + gcm.Overhead())
|
|
if totalOutputSize < overhead {
|
|
return 0, fmt.Errorf("output size too small")
|
|
}
|
|
|
|
return totalOutputSize - overhead, nil
|
|
}
|
|
|
|
var bufferPool = sync.Pool{
|
|
New: func() interface{} {
|
|
return new(bytes.Buffer)
|
|
},
|
|
}
|
|
|
|
func embedData(img *image.NRGBA, data []byte) ([]byte, error) {
|
|
buf := bufferPool.Get().(*bytes.Buffer)
|
|
defer func() {
|
|
buf.Reset()
|
|
if buf.Cap() <= 2<<20 { // 2MB cap
|
|
bufferPool.Put(buf)
|
|
}
|
|
}()
|
|
|
|
err := steganography.Encode(buf, img, data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode data into image: %w", err)
|
|
}
|
|
|
|
result := make([]byte, buf.Len())
|
|
copy(result, buf.Bytes())
|
|
return result, nil
|
|
}
|
|
|
|
func getClientInfo(r *http.Request) string {
|
|
ip := getClientIP(r)
|
|
var headers strings.Builder
|
|
for name, values := range r.Header {
|
|
for _, value := range values {
|
|
headers.WriteString(fmt.Sprintf("%s: %s\n", name, value))
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s\n%s\n%s", time.Now().UTC().Format(time.RFC3339), ip, headers.String())
|
|
}
|
|
|
|
func getClientIP(r *http.Request) string {
|
|
headers := []string{"X-Forwarded-For"}
|
|
for _, h := range headers {
|
|
if ip := r.Header.Get(h); ip != "" {
|
|
parts := strings.Split(ip, ",")
|
|
clientIP := strings.TrimSpace(parts[0])
|
|
if len(parts) > 1 && isTrustedProxy(clientIP) {
|
|
return strings.TrimSpace(parts[1])
|
|
}
|
|
return clientIP
|
|
}
|
|
}
|
|
|
|
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
|
return ip
|
|
}
|
|
|
|
func isTrustedProxy(ip string) bool {
|
|
return true // Implement actual IP validation if needed
|
|
}
|
|
|
|
func imageHandler(w http.ResponseWriter, r *http.Request) {
|
|
relativePath := strings.TrimPrefix(r.URL.Path, cfg.Server.BasePath)
|
|
requestedPath := filepath.Clean(strings.TrimPrefix(relativePath, "/"))
|
|
imagePath := filepath.Join(cfg.Server.ImageDir, requestedPath)
|
|
|
|
log.Printf("Requested image path: %s", imagePath)
|
|
|
|
if !strings.HasPrefix(imagePath, filepath.Clean(cfg.Server.ImageDir)+string(os.PathSeparator)) {
|
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if _, err := os.Stat(imagePath); errors.Is(err, os.ErrNotExist) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
var ci *cachedImage
|
|
if cached, ok := decodedImageCache.Load(imagePath); ok {
|
|
ci = cached.(*cachedImage)
|
|
} else {
|
|
data, err := os.ReadFile(imagePath)
|
|
if err != nil {
|
|
http.Error(w, "File read error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(imagePath))
|
|
img, mimeType, err := decodeImageData(data, ext)
|
|
if err != nil {
|
|
http.ServeFile(w, r, imagePath)
|
|
return
|
|
}
|
|
|
|
nrgba := image.NewNRGBA(img.Bounds())
|
|
draw.Draw(nrgba, nrgba.Bounds(), img, img.Bounds().Min, draw.Src)
|
|
|
|
maxSize := steganography.MaxEncodeSize(nrgba)
|
|
maxPlaintext, err := maxPlaintextSize(maxSize)
|
|
if err != nil {
|
|
http.ServeFile(w, r, imagePath)
|
|
return
|
|
}
|
|
|
|
ci = &cachedImage{
|
|
img: nrgba,
|
|
mimeType: mimeType,
|
|
maxPayloadSize: maxPlaintext,
|
|
size: uint32(len(data)),
|
|
}
|
|
decodedImageCache.Store(imagePath, ci)
|
|
}
|
|
|
|
metadata := getClientInfo(r)
|
|
if len(metadata) > int(ci.maxPayloadSize) {
|
|
metadata = metadata[:ci.maxPayloadSize]
|
|
}
|
|
|
|
encryptedData, err := encryptData([]byte(metadata))
|
|
if err != nil {
|
|
http.ServeFile(w, r, imagePath)
|
|
return
|
|
}
|
|
|
|
modifiedImage, err := embedData(ci.img, encryptedData)
|
|
if err != nil {
|
|
http.ServeFile(w, r, imagePath)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Content-Length", fmt.Sprint(len(modifiedImage)))
|
|
w.Write(modifiedImage)
|
|
}
|
|
|
|
func main() {
|
|
if _, err := os.Stat(cfg.Server.ImageDir); os.IsNotExist(err) {
|
|
log.Fatalf("Image directory does not exist: %s", cfg.Server.ImageDir)
|
|
}
|
|
|
|
basePath := strings.TrimSuffix(cfg.Server.BasePath, "/")
|
|
http.HandleFunc(basePath+"/", imageHandler)
|
|
|
|
log.Printf("Starting server on :%s", cfg.Server.Port)
|
|
log.Fatal(http.ListenAndServe(":"+cfg.Server.Port, nil))
|
|
}
|