Files
LocalAI/core/services/addons/uploads/uploads.go
T
Ettore Di Giacinto c604f3a133 feat(addons): install, upgrade and downgrade from a staged zip file
The manager takes a staged file as a release: sourceId local-file and
the file ID as the version. It runs through the one lifecycle path, so
generations, backup, recovery and the host access approval apply as for
a library package. The approval digest is the sha256 of the zip.

The same version is refused, and an older one needs a confirmed
downgrade. Files wait in a private per-user staging area for an hour.
An add-on from a file has no library, so no update check answers for it.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt
2026-11-03 22:52:50 +00:00

333 lines
7.9 KiB
Go

// SPDX-License-Identifier: MIT
// Package uploads is the owner's staging area for add-on zip files. A file
// waits here between the upload and the install the administrator confirms.
// It is private to the owner, bound to the user that uploaded it, and
// removed after a short time, after the install and at start.
package uploads
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"regexp"
"sync"
"time"
)
var (
// ErrNotFound: no such file, or it belongs to another user, or it expired.
ErrNotFound = errors.New("staged file not found")
// ErrTooLarge: the upload went over the byte limit.
ErrTooLarge = errors.New("file is over the size limit")
// ErrBusy: too many files are waiting.
ErrBusy = errors.New("too many staged files")
// ErrOffset: a chunk did not follow the bytes received so far.
ErrOffset = errors.New("chunk out of order")
)
// Defaults of a store.
const (
DefaultTTL = time.Hour
DefaultMaxFiles = 16
)
var idRE = regexp.MustCompile(`^[0-9a-f]{32}$`)
// ValidID reports whether s has the shape of a staged file ID.
func ValidID(s string) bool { return idRE.MatchString(s) }
// Entry is a staged file.
type Entry struct {
ID string `json:"id"`
Owner string `json:"owner"`
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Created time.Time `json:"created"`
}
// Store keeps staged files under Root.
type Store struct {
Root string
TTL time.Duration
MaxFiles int
// Now is replaced by tests.
Now func() time.Time
mu sync.Mutex
open map[string]*upload
}
type upload struct {
owner, name string
f *os.File
h hash.Hash
n int64
max int64
started time.Time
}
func (s *Store) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}
func (s *Store) ttl() time.Duration {
if s.TTL > 0 {
return s.TTL
}
return DefaultTTL
}
func (s *Store) maxFiles() int {
if s.MaxFiles > 0 {
return s.MaxFiles
}
return DefaultMaxFiles
}
func (s *Store) dir() (string, error) {
if s.Root == "" || !filepath.IsAbs(s.Root) {
return "", errors.New("uploads: root must be an absolute path")
}
if err := os.MkdirAll(s.Root, 0o700); err != nil {
return "", err
}
info, err := os.Lstat(s.Root)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 {
return "", errors.New("uploads: root must be a private directory")
}
return s.Root, nil
}
func newID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
func (s *Store) zipPath(id string) string { return filepath.Join(s.Root, id+".zip") }
func (s *Store) metaPath(id string) string { return filepath.Join(s.Root, id+".json") }
// Begin opens a new upload for owner. max is the byte limit of the file.
func (s *Store) Begin(owner, name string, max int64) (string, error) {
if owner == "" || max <= 0 {
return "", ErrNotFound
}
if _, err := s.dir(); err != nil {
return "", err
}
s.Sweep()
s.mu.Lock()
defer s.mu.Unlock()
if s.open == nil {
s.open = map[string]*upload{}
}
staged, _ := filepath.Glob(filepath.Join(s.Root, "*.zip"))
if len(staged)+len(s.open) >= s.maxFiles() {
return "", ErrBusy
}
id, err := newID()
if err != nil {
return "", err
}
f, err := os.OpenFile(s.zipPath(id), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) // #nosec G304 -- the name is a random ID under the private staging root
if err != nil {
return "", err
}
s.open[id] = &upload{owner: owner, name: name, f: f, h: sha256.New(), max: max, started: s.now()}
return id, nil
}
// Append adds the next chunk. offset is the number of bytes received before it.
func (s *Store) Append(id, owner string, offset int64, p []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.open[id]
if !ok || u.owner != owner {
return ErrNotFound
}
if offset != u.n {
return ErrOffset
}
if int64(len(p)) > u.max-u.n {
s.abortLocked(id)
return ErrTooLarge
}
if _, err := u.f.Write(p); err != nil {
s.abortLocked(id)
return err
}
_, _ = u.h.Write(p)
u.n += int64(len(p))
return nil
}
// Finish closes the upload and makes it a staged file.
func (s *Store) Finish(id, owner string) (Entry, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.open[id]
if !ok || u.owner != owner {
return Entry{}, ErrNotFound
}
delete(s.open, id)
e := Entry{ID: id, Owner: owner, Name: u.name, Size: u.n, SHA256: hex.EncodeToString(u.h.Sum(nil)), Created: s.now().UTC()}
err := u.f.Sync()
if ce := u.f.Close(); err == nil {
err = ce
}
if err == nil {
var b []byte
if b, err = json.Marshal(e); err == nil {
err = os.WriteFile(s.metaPath(id), b, 0o600)
}
}
if err != nil {
_ = os.Remove(s.zipPath(id))
return Entry{}, err
}
return e, nil
}
// Abort drops an upload that is not finished. It is safe to call twice.
func (s *Store) Abort(id, owner string) {
s.mu.Lock()
defer s.mu.Unlock()
if u, ok := s.open[id]; ok && u.owner == owner {
s.abortLocked(id)
}
}
func (s *Store) abortLocked(id string) {
if u, ok := s.open[id]; ok {
_ = u.f.Close()
delete(s.open, id)
}
_ = os.Remove(s.zipPath(id))
}
// Put stores the whole of r as a staged file of owner, up to max bytes.
func (s *Store) Put(owner, name string, max int64, r io.Reader) (Entry, error) {
id, err := s.Begin(owner, name, max)
if err != nil {
return Entry{}, err
}
buf := make([]byte, 256<<10)
var off int64
for {
n, rerr := r.Read(buf)
if n > 0 {
if err = s.Append(id, owner, off, buf[:n]); err != nil {
return Entry{}, err
}
off += int64(n)
}
if rerr == io.EOF {
break
}
if rerr != nil {
s.Abort(id, owner)
return Entry{}, rerr
}
}
return s.Finish(id, owner)
}
// Open returns the path and the entry of a staged file of owner.
func (s *Store) Open(id, owner string) (string, Entry, error) {
if !ValidID(id) || owner == "" {
return "", Entry{}, ErrNotFound
}
e, err := s.read(id)
if err != nil || e.Owner != owner || s.expired(e) {
return "", Entry{}, ErrNotFound
}
return s.zipPath(id), e, nil
}
func (s *Store) read(id string) (Entry, error) {
var e Entry
b, err := os.ReadFile(s.metaPath(id)) // #nosec G304 -- the name is a validated random ID under the private staging root
if err != nil {
return e, ErrNotFound
}
if json.Unmarshal(b, &e) != nil || e.ID != id {
return Entry{}, ErrNotFound
}
return e, nil
}
func (s *Store) expired(e Entry) bool { return s.now().Sub(e.Created) > s.ttl() }
// Remove deletes a staged file of owner.
func (s *Store) Remove(id, owner string) error {
if _, _, err := s.Open(id, owner); err != nil {
return err
}
return s.removeFiles(id)
}
func (s *Store) removeFiles(id string) error {
err := os.Remove(s.zipPath(id))
_ = os.Remove(s.metaPath(id))
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove staged file: %w", err)
}
return nil
}
// Sweep removes the files that are older than the time to live and the
// uploads that never finished. With purge set it removes everything that is
// not an open upload, as the owner does at start.
func (s *Store) Sweep() { s.sweep(false) }
// Purge removes every staged file. Call it once at start.
func (s *Store) Purge() { s.sweep(true) }
func (s *Store) sweep(all bool) {
if _, err := s.dir(); err != nil {
return
}
// The lock covers the whole sweep, so a Finish in progress is never seen
// half done.
s.mu.Lock()
defer s.mu.Unlock()
for id, u := range s.open {
if all || s.now().Sub(u.started) > s.ttl() {
s.abortLocked(id)
}
}
open := map[string]bool{}
for id := range s.open {
open[id] = true
}
ents, err := os.ReadDir(s.Root)
if err != nil {
return
}
for _, de := range ents {
name := de.Name()
id := name[:len(name)-len(filepath.Ext(name))]
if !ValidID(id) || open[id] {
continue
}
e, rerr := s.read(id)
if all || rerr != nil || s.expired(e) {
_ = s.removeFiles(id)
}
}
}