package attachmentstore import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "sync" "github.com/klarkxy/nekonest/relaycore" ) type Store struct { root string mu sync.RWMutex usedBytes int64 fileCount int maxBytes int64 maxFiles int } func New(root string) (*Store, error) { return NewWithLimits(root, 1<<30, 10_000) } func NewWithLimits(root string, maxBytes int64, maxFiles int) (*Store, error) { root = filepath.Clean(strings.TrimSpace(root)) if !filepath.IsAbs(root) { return nil, fmt.Errorf("attachment root must be absolute") } if err := os.MkdirAll(root, 0o700); err != nil { return nil, fmt.Errorf("create attachment root: %w", err) } if err := os.Chmod(root, 0o700); err != nil { return nil, fmt.Errorf("secure attachment root: %w", err) } info, err := os.Lstat(root) if err != nil { return nil, err } if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return nil, fmt.Errorf("attachment root must be a real directory") } if maxBytes <= 0 || maxFiles <= 0 { return nil, fmt.Errorf("attachment limits must be positive") } store := &Store{root: root, maxBytes: maxBytes, maxFiles: maxFiles} entries, err := os.ReadDir(root) if err != nil { return nil, err } for _, entry := range entries { if entry.Type()&os.ModeSymlink != 0 { return nil, fmt.Errorf("attachment store contains a symbolic link") } if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".bin") { continue } info, err := entry.Info() if err != nil || !info.Mode().IsRegular() { return nil, fmt.Errorf("inspect attachment payload %q", entry.Name()) } store.usedBytes += info.Size() store.fileCount++ } if store.usedBytes > maxBytes || store.fileCount > maxFiles { return nil, fmt.Errorf("attachment store already exceeds configured quota") } return store, nil } func validID(id string) bool { if len(id) != 32 { return false } for _, char := range id { if !strings.ContainsRune("0123456789abcdef", char) { return false } } return true } func (s *Store) paths(id string) (string, string, error) { if !validID(id) { return "", "", fmt.Errorf("invalid attachment id") } payload := filepath.Join(s.root, id+".bin") metadata := filepath.Join(s.root, id+".json") if filepath.Dir(payload) != s.root || filepath.Dir(metadata) != s.root { return "", "", fmt.Errorf("attachment path escaped root") } return payload, metadata, nil } func (s *Store) Put(ctx context.Context, attachment relaycore.Attachment, body []byte) error { if err := ctx.Err(); err != nil { return err } s.mu.Lock() defer s.mu.Unlock() if int64(len(body)) > s.maxBytes-s.usedBytes || s.fileCount >= s.maxFiles { return fmt.Errorf("attachment quota exceeded") } payloadPath, metadataPath, err := s.paths(attachment.ID) if err != nil { return err } metadata, err := json.Marshal(attachment) if err != nil { return err } payload, err := os.OpenFile(payloadPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return fmt.Errorf("create attachment payload: %w", err) } removePayload := true defer func() { _ = payload.Close() if removePayload { _ = os.Remove(payloadPath) } }() if _, err := payload.Write(body); err != nil { return fmt.Errorf("write attachment payload: %w", err) } if err := payload.Sync(); err != nil { return fmt.Errorf("sync attachment payload: %w", err) } if err := payload.Close(); err != nil { return fmt.Errorf("close attachment payload: %w", err) } meta, err := os.OpenFile(metadataPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return fmt.Errorf("create attachment metadata: %w", err) } if _, err := meta.Write(metadata); err != nil { _ = meta.Close() _ = os.Remove(metadataPath) return fmt.Errorf("write attachment metadata: %w", err) } if err := meta.Sync(); err != nil { _ = meta.Close() _ = os.Remove(metadataPath) return fmt.Errorf("sync attachment metadata: %w", err) } if err := meta.Close(); err != nil { _ = os.Remove(metadataPath) return fmt.Errorf("close attachment metadata: %w", err) } removePayload = false s.usedBytes += int64(len(body)) s.fileCount++ return nil } func rejectLink(path string) error { info, err := os.Lstat(path) if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { return fmt.Errorf("attachment artifact is not a regular file") } return nil } func (s *Store) Get(ctx context.Context, id string) (relaycore.Attachment, io.ReadCloser, error) { if err := ctx.Err(); err != nil { return relaycore.Attachment{}, nil, err } s.mu.RLock() defer s.mu.RUnlock() payloadPath, metadataPath, err := s.paths(id) if err != nil { return relaycore.Attachment{}, nil, err } if err := rejectLink(metadataPath); err != nil { return relaycore.Attachment{}, nil, err } metadata, err := os.ReadFile(metadataPath) if err != nil { return relaycore.Attachment{}, nil, err } var attachment relaycore.Attachment if err := json.Unmarshal(metadata, &attachment); err != nil || attachment.ID != id { return relaycore.Attachment{}, nil, fmt.Errorf("invalid attachment metadata") } if err := rejectLink(payloadPath); err != nil { return relaycore.Attachment{}, nil, err } body, err := os.Open(payloadPath) if err != nil { return relaycore.Attachment{}, nil, err } return attachment, body, nil } func (s *Store) Delete(_ context.Context, id string) error { s.mu.Lock() defer s.mu.Unlock() payloadPath, metadataPath, err := s.paths(id) if err != nil { return err } var size int64 existed := false if info, statErr := os.Lstat(payloadPath); statErr == nil && info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 { size = info.Size() existed = true } var joined error for _, path := range []string{metadataPath, payloadPath} { if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { joined = errors.Join(joined, err) } } if joined == nil && existed { s.usedBytes -= size if s.usedBytes < 0 { s.usedBytes = 0 } if s.fileCount > 0 { s.fileCount-- } } return joined }