package bodycache
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const (
DefaultBodyMaxMB = 256
DefaultMemoryThresholdMB = 16
DefaultTmpDir = "./cache"
DefaultTmpCleanupHours = 24
TmpFilePrefix = "octopus-images-"
)
const (
envBodyMaxMB = "OCTOPUS_IMAGES_BODY_MAX_MB"
envMemoryThresholdMB = "OCTOPUS_IMAGES_BODY_MEMORY_THRESHOLD_MB"
envTmpDir = "OCTOPUS_IMAGES_BODY_TMP_DIR"
envTmpCleanupHours = "OCTOPUS_IMAGES_BODY_TMP_CLEANUP_HOURS"
bytesPerMB int64 = 1024 * 1024
)
type BodyTooLargeError struct {
MaxBytes int64
ActualBytes int64
}
func (e *BodyTooLargeError) Error() string {
return fmt.Sprintf("images body too large: max=%d actual=%d", e.MaxBytes, e.ActualBytes)
}
type BodyCache struct {
mu sync.Mutex
closed bool
size int64
mem []byte
tmpPath string
}
func New(r io.ReadCloser) (*BodyCache, error) {
if r == nil {
return nil, errors.New("nil body reader")
}
defer r.Close()
maxBytes := BodyMaxBytesFromEnv()
thresholdBytes := MemoryThresholdBytesFromEnv()
tmpDir := TmpDirFromEnv()
limited := io.LimitReader(r, maxBytes+1)
sw := &spillWriter{
thresholdBytes: thresholdBytes,
tmpDir: tmpDir,
prefix: TmpFilePrefix,
}
n, err := io.Copy(sw, limited)
if err != nil {
_ = sw.Close()
return nil, err
}
if n > maxBytes {
_ = sw.Close()
return nil, &BodyTooLargeError{
MaxBytes: maxBytes,
ActualBytes: n,
}
}
bc := &BodyCache{
size: n,
mem: sw.Bytes(),
tmpPath: sw.TmpPath(),
}
if cerr := sw.Close(); cerr != nil {
_ = bc.Close()
return nil, cerr
}
return bc, nil
}
func (b *BodyCache) NewReader() (io.ReadCloser, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return nil, errors.New("body cache closed")
}
if b.tmpPath != "" {
f, err := os.Open(b.tmpPath)
if err != nil {
return nil, err
}
return f, nil
}
return io.NopCloser(bytes.NewReader(b.mem)), nil
}
func (b *BodyCache) Size() int64 {
b.mu.Lock()
defer b.mu.Unlock()
return b.size
}
func (b *BodyCache) IsFile() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.tmpPath != ""
}
func (b *BodyCache) TmpPath() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.tmpPath
}
func (b *BodyCache) Close() error {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return nil
}
b.closed = true
tmpPath := b.tmpPath
b.tmpPath = ""
b.mem = nil
b.size = 0
b.mu.Unlock()
if tmpPath != "" {
if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func CleanupOldTmpFiles(dir string, prefix string, olderThan time.Duration) error {
if strings.TrimSpace(dir) == "" {
return errors.New("empty dir")
}
if olderThan <= 0 {
return errors.New("olderThan must be positive")
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
deadline := time.Now().Add(-olderThan)
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, prefix) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(deadline) {
_ = os.Remove(filepath.Join(dir, name))
}
}
return nil
}
func BodyMaxBytesFromEnv() int64 {
mb := envInt(envBodyMaxMB, DefaultBodyMaxMB)
if mb <= 0 {
mb = DefaultBodyMaxMB
}
return int64(mb) * bytesPerMB
}
func MemoryThresholdBytesFromEnv() int64 {
mb := envInt(envMemoryThresholdMB, DefaultMemoryThresholdMB)
if mb <= 0 {
mb = DefaultMemoryThresholdMB
}
return int64(mb) * bytesPerMB
}
func TmpDirFromEnv() string {
if v := strings.TrimSpace(os.Getenv(envTmpDir)); v != "" {
return v
}
return DefaultTmpDir
}
func TmpCleanupOlderThanFromEnv() time.Duration {
h := envInt(envTmpCleanupHours, DefaultTmpCleanupHours)
if h <= 0 {
h = DefaultTmpCleanupHours
}
return time.Duration(h) * time.Hour
}
func envInt(name string, def int) int {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return def
}
v, err := strconv.Atoi(raw)
if err != nil {
return def
}
return v
}
type spillWriter struct {
thresholdBytes int64
tmpDir string
prefix string
size int64
buf bytes.Buffer
f *os.File
tmpPath string
}
func (w *spillWriter) Write(p []byte) (int, error) {
if w.f == nil && w.size+int64(len(p)) > w.thresholdBytes {
if err := w.ensureFile(); err != nil {
return 0, err
}
}
var n int
var err error
if w.f != nil {
n, err = w.f.Write(p)
} else {
n, err = w.buf.Write(p)
}
w.size += int64(n)
return n, err
}
func (w *spillWriter) ensureFile() error {
dir := w.tmpDir
if strings.TrimSpace(dir) == "" {
dir = DefaultTmpDir
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
f, err := os.CreateTemp(dir, w.prefix+"*")
if err != nil {
return err
}
w.f = f
w.tmpPath = f.Name()
if w.buf.Len() > 0 {
if _, err := w.f.Write(w.buf.Bytes()); err != nil {
_ = w.f.Close()
_ = os.Remove(w.tmpPath)
w.f = nil
w.tmpPath = ""
return err
}
w.buf.Reset()
}
return nil
}
func (w *spillWriter) Bytes() []byte {
if w.f != nil {
return nil
}
out := make([]byte, w.buf.Len())
copy(out, w.buf.Bytes())
return out
}
func (w *spillWriter) TmpPath() string {
return w.tmpPath
}
func (w *spillWriter) Close() error {
if w.f == nil {
return nil
}
err := w.f.Close()
w.f = nil
return err
}