mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 20:43:27 -05:00
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
333 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|