Files
LocalAI/core/services/studio/files/versions.go
T
Ettore Di Giacinto 7efb20bd1f feat(studio): read and send large connector files by handle
For a kind that declares files.transfer, the connector manager gives the
add-on a handle and not the bytes, in both directions.

Inbound, the add-on streams the file to the host. The chat upload then
reads it from the blob store, so a 6 MB PDF reaches the chat. Outbound,
the manager checks the access of the chat to the file, gives the
process that runs the add-on only the hash and the size of the content,
and the add-on pulls the chunks. The file limit of such a kind has no
inline cap: it is the smallest of the host ceiling, the ability the kind
declares and the limit the owner set for the bot, and the sentence names
the number and the layer. A kind without transfer keeps the 1 MiB inline
cap, and its sentence says so.

Checks: the files of one message may add up to three times the limit;
the media type that the add-on states must be among the types the kind
declares; an outbound file must have a type that the kind declares and
must not start with an HTML page. Every transfer writes one audit line
with direction, binding, size, result, layer and time, and no name or
content. The binding page no longer says "larger files need streaming"
for a kind that streams.

The specs run the real Telegram and Slack add-ons, the real gateway and
the real file operations over scripted platform APIs, with a 6 MB file in
each direction.

Not in this change: a daily byte counter per binding, and a virus scan
(the table has a hook that accepts everything).

Assisted-by: Claude:claude-sonnet-5-5 go test
2026-11-03 15:54:45 +00:00

150 lines
4.5 KiB
Go

package files
import (
"context"
"errors"
"fmt"
"io"
"path"
"strings"
"time"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/workspace"
)
// ErrNoVersions means the service cannot read earlier versions of a file: it
// was built without the blob store.
var ErrNoVersions = errors.New("files: earlier versions are not available")
// BlobOpener reads a blob by its SHA-256. The blob store of the workspace is one.
type BlobOpener interface {
Open(ctx context.Context, sha string) (io.ReadSeekCloser, error)
}
// OpenBlob opens a blob of the workspace store by its SHA-256, for a caller
// that holds the hash from the host itself (a finished connector transfer).
// It checks no access: the caller must have proved the right to the content.
func (s *Service) OpenBlob(ctx context.Context, sha string) (io.ReadSeekCloser, error) {
if s == nil || s.blobs == nil {
return nil, ErrNoVersions
}
return s.blobs.Open(ctx, sha)
}
// Option configures a Service.
type Option func(*Service)
// WithBlobs lets the service read earlier versions of a file. The blobs are
// only reached through the history of a path that the user can read.
func WithBlobs(b BlobOpener) Option { return func(s *Service) { s.blobs = b } }
// FileVersion is one content a file had.
type FileVersion struct {
Rev int64
At time.Time
Kind workspace.ChangeSetKind
// ChatID is the chat that made the change, when a chat did.
ChatID string
Data []byte
}
// maxMoveHops is how many moves Versions follows back.
const maxMoveHops = 4
// Versions calls fn with the contents the file abs had, newest first, as the
// history of the user shows them, until fn returns false. It looks at most at
// limit change sets per path. A version larger than maxSize is skipped without
// reading it. When the file was moved to abs, the versions it had at its old
// path follow, so a move (a rename, or LocalAI moving a settings file into its
// folder) keeps the file's past.
func (s *Service) Versions(ctx context.Context, principal, abs string, limit int, maxSize int64, fn func(FileVersion) bool) error {
if !s.Available() {
return fmt.Errorf("%w: %s", ErrUnavailable, s.Unavailable())
}
if s.blobs == nil {
return ErrNoVersions
}
cur, before, skip := abs, int64(0), ""
for hop := 0; hop <= maxMoveHops; hop++ {
q := workspace.HistoryQuery{Path: cur, Limit: limit, BeforeRev: before}
if hop > 0 {
// The old path no longer exists: ask the history of the nearest
// folder above it that does, and keep the rows of the old path.
q.Path, q.Limit = s.liveAncestor(ctx, principal, cur), 1000
}
hist, err := s.ws.History(ctx, s.Actor(principal), q)
if err != nil {
if hop > 0 {
return nil
}
return err
}
from, fromRev, fromBlob := "", int64(0), ""
for _, cs := range hist {
for _, op := range cs.Ops {
if op.NewPath != cur || op.Kind == workspace.OpDelete {
continue
}
if op.Kind == workspace.OpMove && op.OldPath != "" && op.OldPath != cur && from == "" {
from, fromRev, fromBlob = op.OldPath, cs.Rev, op.NewBlob
}
if op.NewBlob == "" {
continue
}
if skip != "" && op.NewBlob == skip {
// The move showed this content already.
skip = ""
continue
}
skip = ""
data, err := s.readBlob(ctx, op.NewBlob, maxSize)
if errors.Is(err, workspace.ErrTooLarge) {
continue
}
if err != nil {
return err
}
if !fn(FileVersion{Rev: cs.Rev, At: cs.CreatedAt, Kind: cs.Kind, ChatID: cs.ChatID, Data: data}) {
return nil
}
}
}
if from == "" {
return nil
}
cur, before, skip = from, fromRev, fromBlob
}
return nil
}
// liveAncestor is the nearest folder above p that the user can still read,
// at most the home.
func (s *Service) liveAncestor(ctx context.Context, principal, p string) string {
home := session.HomePath(principal)
for dir := path.Dir(p); ; dir = path.Dir(dir) {
if dir == home || !strings.HasPrefix(dir, home+"/") {
return home
}
if n, err := s.ws.Stat(ctx, s.Actor(principal), dir); err == nil && n.Kind == workspace.KindDir {
return dir
}
}
}
func (s *Service) readBlob(ctx context.Context, sha string, maxSize int64) ([]byte, error) {
r, err := s.blobs.Open(ctx, sha)
if err != nil {
return nil, fmt.Errorf("files: read an earlier version: %w", err)
}
defer func() { _ = r.Close() }()
data, err := io.ReadAll(io.LimitReader(r, maxSize+1))
if err != nil {
return nil, fmt.Errorf("files: read an earlier version: %w", err)
}
if int64(len(data)) > maxSize {
return nil, workspace.ErrTooLarge
}
return data, nil
}