mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
feat(addons): upload a zip to the owner and install it from the Add-ons API
POST /api/addons/files streams the zip to the owner in chunks over the host access call, so a frontend of a split deployment keeps no copy and the owner enforces the size limit while it receives. GET and DELETE read and remove a staged file. The install is the existing host access preview and install with sourceId local-file, so it keeps the human surface rule, the nonce and the digest binding. The install body gains a downgrade confirmation. Assisted-by: Claude:claude-sonnet-5-5 go gofmt
This commit is contained in:
15 files changed
+801
-10
No files matched your search
@@ -647,6 +647,7 @@ func (a *Application) wireHostGrantsOwner(g *gateway.Gateway) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.HostGrantsOwner = owner
|
||||
// The same service takes the zip files of an install from a file.
|
||||
g.HostGrantsOwner = owner.WithFiles(a.addonManager)
|
||||
return nil
|
||||
}
|
||||
@@ -82,6 +82,10 @@ func bodyLimitSkipper(cfg *config.ApplicationConfig) func(echo.Context) bool {
|
||||
switch {
|
||||
case r.Method == http.MethodPut && c.Path() == "/api/chats/files/content":
|
||||
return true
|
||||
case r.Method == http.MethodPost && c.Path() == "/api/addons/files":
|
||||
// An add-on zip streams to the owner in chunks, which enforces
|
||||
// the add-on size limit while it receives.
|
||||
return true
|
||||
case r.Method == http.MethodPost && c.Path() == "/api/chats/:id/messages":
|
||||
// Only the multipart form carries files; a JSON message keeps the
|
||||
// general limit.
|
||||
|
||||
@@ -236,6 +236,9 @@ var RouteFeatureRegistry = []RouteFeature{
|
||||
{"GET", "/api/addons/:id/update", FeatureAddons},
|
||||
{"POST", "/api/addons/:id/update/check", FeatureAddons},
|
||||
{"POST", "/api/addons/:id/upgrade", FeatureAddons},
|
||||
{"POST", "/api/addons/files", FeatureAddons},
|
||||
{"GET", "/api/addons/files/:fileId", FeatureAddons},
|
||||
{"DELETE", "/api/addons/files/:fileId", FeatureAddons},
|
||||
{"GET", "/api/addons/host-grants", FeatureAddons},
|
||||
{"POST", "/api/addons/host-grants/preview", FeatureAddons},
|
||||
{"POST", "/api/addons/host-grants/install", FeatureAddons},
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package addons
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/services/addons/gateway"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The file routes stage an add-on zip on the owner. Staging runs nothing and
|
||||
// changes no add-on, so an administrator's API key may use them, which lets
|
||||
// `local-ai addons install` hand a file to a running server. The install that
|
||||
// follows is the host access install: it needs the administrator's own browser
|
||||
// session, the preview nonce and the digest of the file, so no script can
|
||||
// install a staged file by itself. A file is bound to the user that uploaded
|
||||
// it and expires after an hour.
|
||||
|
||||
// UploadAddonFile stages an add-on zip file.
|
||||
// @Summary Upload an add-on zip file to install from a file (administrators)
|
||||
// @Description Administrators only (an administrator's session or API key). Multipart form with one part named file, the add-on zip made by `make addons-zip`. The bytes stream to the add-on owner, which keeps them in a private staging area for one hour, bound to the uploading user, and verifies the file: its digest manifest, its paths and sizes, its platform and its add-on manifest. Nothing is installed. Returns the file ID, the sha256 of the file, the add-on ID, version, platform and API range, the host operations the manifest asks for, how the file relates to the installed version (new, upgrade, same or downgrade) and the origin "local file, unsigned": nothing signs a local file, so the administrator chooses what to install. The install is host-grants/preview and host-grants/install with sourceId local-file and version equal to the file ID; it needs the administrator's own browser session. Refusals carry the rule that failed as the error type: bad_zip, unsafe_path, unsupported_entry, too_large, too_many_files, compression_ratio, bad_manifest, digest_mismatch, wrong_platform or bad_mode. No MCP tool maps to this route.
|
||||
// @Tags Addons
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param file formData file true "The add-on zip"
|
||||
// @Success 200 {object} addons.FileView "The verified file"
|
||||
// @Failure 400 {object} AddonErrorResponse "Not a multipart form with a file, or the file broke a rule"
|
||||
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
|
||||
// @Failure 403 {object} AddonErrorResponse "Feature or administration denied"
|
||||
// @Failure 413 {object} AddonErrorResponse "The file is over the size limit"
|
||||
// @Failure 429 {object} AddonErrorResponse "Too many staged files"
|
||||
// @Failure 503 {object} AddonErrorResponse "Addons or the owner are unavailable"
|
||||
// @Router /api/addons/files [post]
|
||||
func UploadAddonFile(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
s := resolve(opts)
|
||||
return func(c echo.Context) error {
|
||||
a, err, ok := hostGrantsAdmin(c, db, s)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if g == nil {
|
||||
return failure(c, 503, "Addons unavailable")
|
||||
}
|
||||
noteRequest(c, contract.Request{Action: "addon-file-upload"})
|
||||
mt, params, merr := mime.ParseMediaType(c.Request().Header.Get("Content-Type"))
|
||||
if merr != nil || !strings.HasPrefix(mt, "multipart/") || params["boundary"] == "" {
|
||||
return failure(c, 400, "Send the file as multipart form data")
|
||||
}
|
||||
mr, merr := c.Request().MultipartReader()
|
||||
if merr != nil {
|
||||
return failure(c, 400, "Send the file as multipart form data")
|
||||
}
|
||||
for {
|
||||
part, perr := mr.NextPart()
|
||||
if errors.Is(perr, io.EOF) {
|
||||
return failure(c, 400, "The form has no part named file")
|
||||
}
|
||||
if perr != nil {
|
||||
return failure(c, 400, "The form could not be read")
|
||||
}
|
||||
if part.FormName() != "file" {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
view, uerr := g.UploadFile(c.Request().Context(), a, part.FileName(), part)
|
||||
_ = part.Close()
|
||||
if uerr != nil {
|
||||
return hostGrantsFailure(c, uerr)
|
||||
}
|
||||
return c.JSON(200, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAddonFile reads a staged file.
|
||||
// @Summary Read a staged add-on file (administrators)
|
||||
// @Description Administrators only. The same answer as the upload, for a file of the calling user that has not expired. 404 when the file is gone or belongs to another user.
|
||||
// @Tags Addons
|
||||
// @Produce json
|
||||
// @Param fileId path string true "File ID from the upload"
|
||||
// @Success 200 {object} addons.FileView "The staged file"
|
||||
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
|
||||
// @Failure 403 {object} AddonErrorResponse "Feature or administration denied"
|
||||
// @Failure 404 {object} AddonErrorResponse "No such file"
|
||||
// @Failure 503 {object} AddonErrorResponse "Addons or the owner are unavailable"
|
||||
// @Router /api/addons/files/{fileId} [get]
|
||||
func GetAddonFile(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
s := resolve(opts)
|
||||
return func(c echo.Context) error {
|
||||
a, err, ok := hostGrantsAdmin(c, db, s)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if g == nil {
|
||||
return failure(c, 503, "Addons unavailable")
|
||||
}
|
||||
noteRequest(c, contract.Request{Action: "addon-file-read"})
|
||||
view, e := g.DescribeFile(c.Request().Context(), a, c.Param("fileId"))
|
||||
if e != nil {
|
||||
return hostGrantsFailure(c, e)
|
||||
}
|
||||
return c.JSON(200, view)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAddonFile removes a staged file.
|
||||
// @Summary Remove a staged add-on file (administrators)
|
||||
// @Description Administrators only. Removes a file of the calling user that waits for an install. 404 when the file is gone or belongs to another user.
|
||||
// @Tags Addons
|
||||
// @Produce json
|
||||
// @Param fileId path string true "File ID from the upload"
|
||||
// @Success 204 "Removed"
|
||||
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
|
||||
// @Failure 403 {object} AddonErrorResponse "Feature or administration denied"
|
||||
// @Failure 404 {object} AddonErrorResponse "No such file"
|
||||
// @Failure 503 {object} AddonErrorResponse "Addons or the owner are unavailable"
|
||||
// @Router /api/addons/files/{fileId} [delete]
|
||||
func DeleteAddonFile(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
s := resolve(opts)
|
||||
return func(c echo.Context) error {
|
||||
a, err, ok := hostGrantsAdmin(c, db, s)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if g == nil {
|
||||
return failure(c, 503, "Addons unavailable")
|
||||
}
|
||||
noteRequest(c, contract.Request{Action: "addon-file-delete"})
|
||||
if e := g.DropFile(c.Request().Context(), a, c.Param("fileId")); e != nil {
|
||||
return hostGrantsFailure(c, e)
|
||||
}
|
||||
return c.NoContent(204)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package addons
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
managerpkg "github.com/mudler/LocalAI/core/services/addons"
|
||||
"github.com/mudler/LocalAI/core/services/addons/gateway"
|
||||
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
|
||||
"github.com/mudler/LocalAI/core/services/addons/packages"
|
||||
"github.com/mudler/LocalAI/core/services/addons/uploads"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// fileOwner is the owner's file service over a real staging store.
|
||||
type fileOwner struct {
|
||||
store *uploads.Store
|
||||
max int64
|
||||
reject error
|
||||
}
|
||||
|
||||
func (f *fileOwner) FileBegin(_ context.Context, a contract.Actor, name string) (string, int64, error) {
|
||||
id, err := f.store.Begin(a.UserID, name, f.max)
|
||||
return id, f.max, err
|
||||
}
|
||||
func (f *fileOwner) FileChunk(_ context.Context, a contract.Actor, id string, off int64, p []byte) error {
|
||||
return f.store.Append(id, a.UserID, off, p)
|
||||
}
|
||||
func (f *fileOwner) FileFinish(_ context.Context, a contract.Actor, id string) (managerpkg.FileView, error) {
|
||||
e, err := f.store.Finish(id, a.UserID)
|
||||
if err == nil && f.reject != nil {
|
||||
_ = f.store.Remove(id, a.UserID)
|
||||
err = f.reject
|
||||
}
|
||||
if err != nil {
|
||||
return managerpkg.FileView{}, err
|
||||
}
|
||||
return managerpkg.FileView{FileID: e.ID, Name: e.Name, Size: e.Size, SHA256: e.SHA256, AddonID: "sample", Version: "1.0.0", Relation: managerpkg.FileNew,
|
||||
Required: []string{}, Optional: []string{}, Source: "local-file", Origin: "local file, unsigned"}, nil
|
||||
}
|
||||
func (f *fileOwner) FileAbort(_ context.Context, a contract.Actor, id string) {
|
||||
f.store.Abort(id, a.UserID)
|
||||
}
|
||||
func (f *fileOwner) FileDrop(_ context.Context, a contract.Actor, id string) error {
|
||||
return f.store.Remove(id, a.UserID)
|
||||
}
|
||||
func (f *fileOwner) FileDescribe(_ context.Context, a contract.Actor, id string) (managerpkg.FileView, error) {
|
||||
_, e, err := f.store.Open(id, a.UserID)
|
||||
return managerpkg.FileView{FileID: e.ID, SHA256: e.SHA256}, err
|
||||
}
|
||||
|
||||
var _ = Describe("add-on file routes", func() {
|
||||
const host = "192.0.2.10:8080"
|
||||
var (
|
||||
e *echo.Echo
|
||||
owner *fileOwner
|
||||
)
|
||||
BeforeEach(func() {
|
||||
store, err := hostgrants.Open(filepath.Join(GinkgoT().TempDir(), "hostgrants.json"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(store.Close)
|
||||
owner = &fileOwner{store: &uploads.Store{Root: filepath.Join(GinkgoT().TempDir(), "uploads")}, max: 1 << 20}
|
||||
svc, err := gateway.NewHostGrantsOwner(gateway.HostGrantsConfig{Store: store, Manager: &hgManager{},
|
||||
Admin: func(context.Context, contract.Actor) error { return nil }})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
svc.WithFiles(owner)
|
||||
gw := &gateway.Gateway{HostGrantsOwner: svc, Revalidate: func(context.Context, contract.Actor) error { return nil }}
|
||||
e = echo.New()
|
||||
mode := WithOwnership(auth.AddonModeLocalOwner)
|
||||
g := e.Group("/api/addons", BrowserProtection())
|
||||
g.POST("/files", UploadAddonFile(gw, nil, mode))
|
||||
g.GET("/files/:fileId", GetAddonFile(gw, nil, mode))
|
||||
g.DELETE("/files/:fileId", DeleteAddonFile(gw, nil, mode))
|
||||
registerHostGrants(e, gw)
|
||||
})
|
||||
form := func(name string, content []byte) (*bytes.Buffer, string) {
|
||||
var b bytes.Buffer
|
||||
mw := multipart.NewWriter(&b)
|
||||
if name != "" {
|
||||
part, err := mw.CreateFormFile(name, "sample.zip")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, _ = part.Write(content)
|
||||
}
|
||||
Expect(mw.Close()).To(Succeed())
|
||||
return &b, mw.FormDataContentType()
|
||||
}
|
||||
do := func(method, path string, body *bytes.Buffer, ctype string, headers map[string]string) (int, map[string]any) {
|
||||
var r *http.Request
|
||||
if body != nil {
|
||||
r = httptest.NewRequest(method, path, body)
|
||||
} else {
|
||||
r = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
r.Host = host
|
||||
if ctype != "" {
|
||||
r.Header.Set("Content-Type", ctype)
|
||||
}
|
||||
r.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for k, v := range headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
e.ServeHTTP(w, r)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out
|
||||
}
|
||||
upload := func(content []byte) (int, map[string]any) {
|
||||
b, ct := form("file", content)
|
||||
return do("POST", "/api/addons/files", b, ct, nil)
|
||||
}
|
||||
|
||||
It("stages a file and answers with its digest", func() {
|
||||
payload := bytes.Repeat([]byte("zip"), 1000)
|
||||
code, out := upload(payload)
|
||||
Expect(code).To(Equal(200), "%v", out)
|
||||
sum := sha256.Sum256(payload)
|
||||
Expect(out["sha256"]).To(Equal(hex.EncodeToString(sum[:])))
|
||||
Expect(out["origin"]).To(Equal("local file, unsigned"))
|
||||
Expect(out["source"]).To(Equal("local-file"))
|
||||
for _, k := range []string{"fileId", "name", "size", "addonId", "version", "relation", "required", "optional"} {
|
||||
Expect(out).To(HaveKey(k), k)
|
||||
}
|
||||
id := out["fileId"].(string)
|
||||
code, got := do("GET", "/api/addons/files/"+id, nil, "", nil)
|
||||
Expect(code).To(Equal(200))
|
||||
Expect(got["fileId"]).To(Equal(id))
|
||||
code, _ = do("DELETE", "/api/addons/files/"+id, nil, "", nil)
|
||||
Expect(code).To(Equal(204))
|
||||
code, got = do("GET", "/api/addons/files/"+id, nil, "", nil)
|
||||
Expect(code).To(Equal(404))
|
||||
Expect(got["error"].(map[string]any)["type"]).To(Equal("not_found"))
|
||||
})
|
||||
|
||||
It("accepts an administrator's API key to stage a file, as the command line does", func() {
|
||||
b, ct := form("file", []byte("zip"))
|
||||
code, _ := do("POST", "/api/addons/files", b, ct, map[string]string{"Sec-Fetch-Site": "", "Authorization": "Bearer key"})
|
||||
Expect(code).To(Equal(200))
|
||||
})
|
||||
|
||||
It("refuses a cross-site or unproven request that rides a session cookie", func() {
|
||||
cookie := map[string]string{"Cookie": "session=abc"}
|
||||
with := func(h map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range cookie {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range h {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
b, ct := form("file", []byte("zip"))
|
||||
code, _ := do("POST", "/api/addons/files", b, ct, with(map[string]string{"Sec-Fetch-Site": "cross-site"}))
|
||||
Expect(code).To(Equal(403))
|
||||
b, ct = form("file", []byte("zip"))
|
||||
code, _ = do("POST", "/api/addons/files", b, ct, with(map[string]string{"Sec-Fetch-Site": ""}))
|
||||
Expect(code).To(Equal(403))
|
||||
ents, _ := filepath.Glob(filepath.Join(owner.store.Root, "*"))
|
||||
Expect(ents).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("refuses a request without a file part", func() {
|
||||
b, ct := form("", nil)
|
||||
code, _ := do("POST", "/api/addons/files", b, ct, nil)
|
||||
Expect(code).To(Equal(400))
|
||||
b, ct = form("other", []byte("zip"))
|
||||
code, _ = do("POST", "/api/addons/files", b, ct, nil)
|
||||
Expect(code).To(Equal(400))
|
||||
code, _ = do("POST", "/api/addons/files", bytes.NewBufferString(`{"file":"x"}`), "application/json", nil)
|
||||
Expect(code).To(Equal(400))
|
||||
})
|
||||
|
||||
It("types a file over the limit and a file that broke a rule", func() {
|
||||
code, out := upload(bytes.Repeat([]byte("a"), (1<<20)+1))
|
||||
Expect(code).To(Equal(413))
|
||||
Expect(out["error"].(map[string]any)["type"]).To(Equal("too_large"))
|
||||
owner.reject = &packages.FileError{Code: packages.FileUnsafePath, Msg: "The zip holds an unsafe file path"}
|
||||
code, out = upload([]byte("zip"))
|
||||
Expect(code).To(Equal(400))
|
||||
Expect(out["error"].(map[string]any)["type"]).To(Equal("unsafe_path"))
|
||||
Expect(out["error"].(map[string]any)["message"]).To(Equal("The zip holds an unsafe file path"))
|
||||
})
|
||||
|
||||
It("does not let a script install a staged file: the install keeps its human surface rule", func() {
|
||||
_, out := upload([]byte("zip"))
|
||||
id := out["fileId"].(string)
|
||||
body := `{"operationId":"f1","action":"install","sourceId":"local-file","addonId":"sample","version":"` + id + `","downgrade":true,"approve":{"digest":"sha256:aa","ops":[],"revision":0,"nonce":"n"}}`
|
||||
code, res := do("POST", "/api/addons/host-grants/install", bytes.NewBufferString(body), "application/json", map[string]string{"Authorization": "Bearer key", ConfirmHeader: "sample@sha256:aa"})
|
||||
Expect(code).To(Equal(403))
|
||||
Expect(res["error"].(map[string]any)["type"]).To(Equal("not_human_surface"))
|
||||
Expect(strings.Contains(body, "downgrade")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
@@ -209,6 +209,12 @@ func operationCauseMessage(code string) string {
|
||||
return "The package could not be downloaded. Try again with a new operation ID. Nothing was installed"
|
||||
case managerpkg.CauseInstallStorage:
|
||||
return "The package could not be written to the add-on storage. Check the free space and the permissions of the add-on folder. Nothing was installed"
|
||||
case managerpkg.CauseFileMissing:
|
||||
return "The uploaded file is gone: it expired, or another user uploaded it. Upload it again. Nothing was installed"
|
||||
case managerpkg.CauseVersionNotNewer:
|
||||
return "This version of the add-on is installed already. Nothing was installed"
|
||||
case managerpkg.CauseDowngradeRefused:
|
||||
return "The file holds an older version than the installed one. Confirm the downgrade to install it. Nothing was installed"
|
||||
}
|
||||
return "The operation failed"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long.
@@ -103,6 +103,11 @@ func RegisterAddonRoutesFor(e *echo.Echo, gw *gateway.Gateway, db *gorm.DB, owne
|
||||
// only the administrator's own browser session, and the frontend forwards
|
||||
// that decision to the owner as a signed field. Never tools: no MCP tool
|
||||
// maps to them.
|
||||
// Install from a zip file: the file is staged on the owner first. The install
|
||||
// itself is host-grants/install with sourceId local-file.
|
||||
group.POST("/files", endpoints.UploadAddonFile(gw, db, mode))
|
||||
group.GET("/files/:fileId", endpoints.GetAddonFile(gw, db, mode))
|
||||
group.DELETE("/files/:fileId", endpoints.DeleteAddonFile(gw, db, mode))
|
||||
group.GET("/host-grants", endpoints.GetHostGrants(gw, db, mode))
|
||||
group.POST("/host-grants/preview", endpoints.PreviewHostGrants(gw, db, mode))
|
||||
group.POST("/host-grants/install", endpoints.InstallWithHostGrants(gw, db, mode))
|
||||
|
||||
@@ -641,7 +641,8 @@ func operationCause(e error) string {
|
||||
// operation that cross the wire.
|
||||
func IsOperationCause(code string) bool {
|
||||
switch code {
|
||||
case addons.CauseLibraryUnavailable, addons.CauseLibraryConflict, addons.CauseReleaseNotFound, addons.CausePackageFetch, addons.CauseInstallStorage:
|
||||
case addons.CauseLibraryUnavailable, addons.CauseLibraryConflict, addons.CauseReleaseNotFound, addons.CausePackageFetch, addons.CauseInstallStorage,
|
||||
addons.CauseFileMissing, addons.CauseVersionNotNewer, addons.CauseDowngradeRefused:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -823,7 +824,8 @@ func (c *Carrier) send(ctx context.Context, a contract.Actor, wire wireRequest)
|
||||
return nil, SDKRefusalError(&contract.SDKRefusal{Min: b.Min, Max: b.Max, Host: b.Host})
|
||||
case wirePackage:
|
||||
return nil, fmt.Errorf("%w: %w", addons.ErrOperationFailed, packages.ErrPackage)
|
||||
case addons.CauseLibraryUnavailable, addons.CauseLibraryConflict, addons.CauseReleaseNotFound, addons.CausePackageFetch, addons.CauseInstallStorage:
|
||||
case addons.CauseLibraryUnavailable, addons.CauseLibraryConflict, addons.CauseReleaseNotFound, addons.CausePackageFetch, addons.CauseInstallStorage,
|
||||
addons.CauseFileMissing, addons.CauseVersionNotNewer, addons.CauseDowngradeRefused:
|
||||
return nil, fmt.Errorf("%w: %w", addons.ErrOperationFailed, &addons.FailureCause{Code: b.Type})
|
||||
case wireNotPermitted:
|
||||
return nil, ErrNotPermitted
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/addons"
|
||||
"github.com/mudler/LocalAI/core/services/addons/uploads"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// The calls that move an add-on zip file to the owner. They ride the host
|
||||
// access carrier call, so a frontend forwards them like any other: signed,
|
||||
// over the pinned channel, one chunk per request. The owner keeps the bytes;
|
||||
// a frontend keeps none. They are not writes of the host access policy: the
|
||||
// install that follows is, and it keeps its human surface rule.
|
||||
const (
|
||||
HostGrantsFileBegin = "file-begin"
|
||||
HostGrantsFileChunk = "file-chunk"
|
||||
HostGrantsFileFinish = "file-finish"
|
||||
HostGrantsFileAbort = "file-abort"
|
||||
HostGrantsFileDrop = "file-drop"
|
||||
HostGrantsFileDescribe = "file-describe"
|
||||
)
|
||||
|
||||
// FileChunkBytes is the size of one chunk. Base64 makes it a third larger on
|
||||
// the wire, which stays under the 2 MiB envelope.
|
||||
const FileChunkBytes = 1 << 20
|
||||
|
||||
// FileService is what the owner needs from the add-on manager to take files.
|
||||
type FileService interface {
|
||||
FileBegin(ctx context.Context, actor contract.Actor, name string) (id string, max int64, err error)
|
||||
FileChunk(ctx context.Context, actor contract.Actor, id string, offset int64, data []byte) error
|
||||
FileFinish(ctx context.Context, actor contract.Actor, id string) (addons.FileView, error)
|
||||
FileAbort(ctx context.Context, actor contract.Actor, id string)
|
||||
FileDrop(ctx context.Context, actor contract.Actor, id string) error
|
||||
FileDescribe(ctx context.Context, actor contract.Actor, id string) (addons.FileView, error)
|
||||
}
|
||||
|
||||
func isFileOp(op string) bool {
|
||||
switch op {
|
||||
case HostGrantsFileBegin, HostGrantsFileChunk, HostGrantsFileFinish, HostGrantsFileAbort, HostGrantsFileDrop, HostGrantsFileDescribe:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func init() {
|
||||
// A lost answer to a file call is a plain failure: the upload starts again.
|
||||
for _, op := range []string{HostGrantsFileBegin, HostGrantsFileChunk, HostGrantsFileFinish, HostGrantsFileAbort, HostGrantsFileDrop, HostGrantsFileDescribe} {
|
||||
hostGrantsRead["hostgrants-"+op] = true
|
||||
}
|
||||
}
|
||||
|
||||
type fileBeginRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// FileBeginView is the answer of file-begin.
|
||||
type FileBeginView struct {
|
||||
ID string `json:"id"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
}
|
||||
|
||||
type fileChunkRequest struct {
|
||||
ID string `json:"id"`
|
||||
Offset int64 `json:"offset"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
type fileIDRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// WithFiles sets the service that takes staged files. Set it on the owner.
|
||||
func (o *HostGrantsOwner) WithFiles(f FileService) *HostGrantsOwner {
|
||||
o.files = f
|
||||
return o
|
||||
}
|
||||
|
||||
// fileCall serves one file call. The administrator check has run already.
|
||||
func (o *HostGrantsOwner) fileCall(ctx context.Context, a contract.Actor, call HostGrantsCall) (any, error) {
|
||||
if o.files == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
bad := func() (any, error) { return nil, o.invalid() }
|
||||
switch call.Op {
|
||||
case HostGrantsFileBegin:
|
||||
var r fileBeginRequest
|
||||
if !strictBody(call.Body, &r) || r.Name == "" || len(r.Name) > 512 {
|
||||
return bad()
|
||||
}
|
||||
id, max, err := o.files.FileBegin(ctx, a, r.Name)
|
||||
return FileBeginView{ID: id, MaxBytes: max}, o.fileError(err)
|
||||
case HostGrantsFileChunk:
|
||||
var r fileChunkRequest
|
||||
if !strictBody(call.Body, &r) || !uploads.ValidID(r.ID) || r.Offset < 0 || len(r.Data) == 0 || len(r.Data) > FileChunkBytes {
|
||||
return bad()
|
||||
}
|
||||
return struct{}{}, o.fileError(o.files.FileChunk(ctx, a, r.ID, r.Offset, r.Data))
|
||||
case HostGrantsFileFinish, HostGrantsFileAbort, HostGrantsFileDrop, HostGrantsFileDescribe:
|
||||
var r fileIDRequest
|
||||
if !strictBody(call.Body, &r) || !uploads.ValidID(r.ID) {
|
||||
return bad()
|
||||
}
|
||||
switch call.Op {
|
||||
case HostGrantsFileFinish:
|
||||
v, err := o.files.FileFinish(ctx, a, r.ID)
|
||||
return v, o.fileError(err)
|
||||
case HostGrantsFileDescribe:
|
||||
v, err := o.files.FileDescribe(ctx, a, r.ID)
|
||||
return v, o.fileError(err)
|
||||
case HostGrantsFileDrop:
|
||||
return struct{}{}, o.fileError(o.files.FileDrop(ctx, a, r.ID))
|
||||
}
|
||||
o.files.FileAbort(ctx, a, r.ID)
|
||||
return struct{}{}, nil
|
||||
}
|
||||
return bad()
|
||||
}
|
||||
|
||||
// fileError types the refusals of the file calls for the routes. A refusal of
|
||||
// the content keeps the code of the rule it broke.
|
||||
func (o *HostGrantsOwner) fileError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if fe, ok := addons.IsFileRefusal(err); ok {
|
||||
return hgErr(400, fe.Code, fe.Msg)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, uploads.ErrNotFound):
|
||||
return hgErr(404, HostGrantsNotFound, "No such file. It may have expired; upload it again")
|
||||
case errors.Is(err, uploads.ErrTooLarge):
|
||||
return hgErr(413, "too_large", "The file is over the size limit")
|
||||
case errors.Is(err, uploads.ErrBusy):
|
||||
return hgErr(429, "busy", "Too many files wait for an install. Remove one or wait")
|
||||
case errors.Is(err, uploads.ErrOffset):
|
||||
return hgErr(400, HostGrantsInvalidRequest, "The upload chunks arrived out of order")
|
||||
case errors.Is(err, addons.ErrNoFileInstall):
|
||||
return ErrUnavailable
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (g *Gateway) fileDo(ctx context.Context, a contract.Actor, op string, req any, out any) error {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return ErrForbidden
|
||||
}
|
||||
raw, err := g.HostGrants(ctx, a, HostGrantsCall{Op: op, Body: body})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
// UploadFile sends a zip file to the owner chunk by chunk and returns what the
|
||||
// owner made of it. In a split deployment the chunks cross the carrier; on the
|
||||
// owner they go straight to the service. Nothing is kept here. The owner
|
||||
// enforces the byte limit while it receives, so a client that sends more is cut.
|
||||
func (g *Gateway) UploadFile(ctx context.Context, a contract.Actor, name string, r io.Reader) (addons.FileView, error) {
|
||||
var begin FileBeginView
|
||||
if err := g.fileDo(ctx, a, HostGrantsFileBegin, fileBeginRequest{Name: name}, &begin); err != nil {
|
||||
return addons.FileView{}, err
|
||||
}
|
||||
abort := func() {
|
||||
// The caller's context may be the reason for the failure.
|
||||
c, cancel := context.WithTimeout(context.WithoutCancel(ctx), contract.DefaultLimits().ReadTimeout)
|
||||
defer cancel()
|
||||
_ = g.fileDo(c, a, HostGrantsFileAbort, fileIDRequest{ID: begin.ID}, nil)
|
||||
}
|
||||
buf := make([]byte, FileChunkBytes)
|
||||
var offset int64
|
||||
for {
|
||||
n, rerr := io.ReadFull(r, buf)
|
||||
if n > 0 {
|
||||
if err := g.fileDo(ctx, a, HostGrantsFileChunk, fileChunkRequest{ID: begin.ID, Offset: offset, Data: buf[:n]}, nil); err != nil {
|
||||
abort()
|
||||
return addons.FileView{}, err
|
||||
}
|
||||
offset += int64(n)
|
||||
}
|
||||
if rerr == io.EOF || rerr == io.ErrUnexpectedEOF {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
abort()
|
||||
return addons.FileView{}, rerr
|
||||
}
|
||||
}
|
||||
if offset == 0 {
|
||||
abort()
|
||||
return addons.FileView{}, hgErr(400, "bad_zip", "The file is empty")
|
||||
}
|
||||
var view addons.FileView
|
||||
if err := g.fileDo(ctx, a, HostGrantsFileFinish, fileIDRequest{ID: begin.ID}, &view); err != nil {
|
||||
abort()
|
||||
return addons.FileView{}, err
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// DescribeFile reads a staged file again.
|
||||
func (g *Gateway) DescribeFile(ctx context.Context, a contract.Actor, id string) (addons.FileView, error) {
|
||||
var view addons.FileView
|
||||
err := g.fileDo(ctx, a, HostGrantsFileDescribe, fileIDRequest{ID: id}, &view)
|
||||
return view, err
|
||||
}
|
||||
|
||||
// DropFile removes a staged file.
|
||||
func (g *Gateway) DropFile(ctx context.Context, a contract.Actor, id string) error {
|
||||
return g.fileDo(ctx, a, HostGrantsFileDrop, fileIDRequest{ID: id}, nil)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing/iotest"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/addons"
|
||||
"github.com/mudler/LocalAI/core/services/addons/packages"
|
||||
"github.com/mudler/LocalAI/core/services/addons/uploads"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// fileSim is the owner's file service over a real staging store. Finish
|
||||
// checks nothing about the zip itself: the manager tests do that.
|
||||
type fileSim struct {
|
||||
store *uploads.Store
|
||||
max int64
|
||||
aborted int
|
||||
reject error
|
||||
}
|
||||
|
||||
func (f *fileSim) FileBegin(_ context.Context, a contract.Actor, name string) (string, int64, error) {
|
||||
id, e := f.store.Begin(a.UserID, name, f.max)
|
||||
return id, f.max, e
|
||||
}
|
||||
func (f *fileSim) FileChunk(_ context.Context, a contract.Actor, id string, off int64, p []byte) error {
|
||||
return f.store.Append(id, a.UserID, off, p)
|
||||
}
|
||||
func (f *fileSim) FileFinish(_ context.Context, a contract.Actor, id string) (addons.FileView, error) {
|
||||
e, err := f.store.Finish(id, a.UserID)
|
||||
if err != nil {
|
||||
return addons.FileView{}, err
|
||||
}
|
||||
if f.reject != nil {
|
||||
_ = f.store.Remove(id, a.UserID)
|
||||
return addons.FileView{}, f.reject
|
||||
}
|
||||
return addons.FileView{FileID: e.ID, Name: e.Name, Size: e.Size, SHA256: e.SHA256, AddonID: "sample", Relation: addons.FileNew}, nil
|
||||
}
|
||||
func (f *fileSim) FileAbort(_ context.Context, a contract.Actor, id string) {
|
||||
f.aborted++
|
||||
f.store.Abort(id, a.UserID)
|
||||
}
|
||||
func (f *fileSim) FileDrop(_ context.Context, a contract.Actor, id string) error {
|
||||
return f.store.Remove(id, a.UserID)
|
||||
}
|
||||
func (f *fileSim) FileDescribe(_ context.Context, a contract.Actor, id string) (addons.FileView, error) {
|
||||
_, e, err := f.store.Open(id, a.UserID)
|
||||
if err != nil {
|
||||
return addons.FileView{}, err
|
||||
}
|
||||
return addons.FileView{FileID: e.ID, SHA256: e.SHA256, Size: e.Size}, nil
|
||||
}
|
||||
|
||||
var _ = Describe("add-on zip files reach the owner", func() {
|
||||
ok := func(context.Context, contract.Actor) error { return nil }
|
||||
alice := contract.Actor{UserID: "alice", CredentialID: "session:a", Mode: "named-user"}
|
||||
bob := contract.Actor{UserID: "bob", CredentialID: "session:b", Mode: "named-user"}
|
||||
|
||||
type rig struct {
|
||||
owner, front, other *Gateway
|
||||
sim *fileSim
|
||||
}
|
||||
build := func(max int64) rig {
|
||||
sim := &fileSim{store: &uploads.Store{Root: filepath.Join(GinkgoT().TempDir(), "uploads")}, max: max}
|
||||
fx := newHGFixture()
|
||||
fx.svc.WithFiles(sim)
|
||||
var og *Gateway
|
||||
_, cs := carrierFixture(func(c *Carrier) http.Handler {
|
||||
og = &Gateway{HostGrantsOwner: fx.svc, Revalidate: ok}
|
||||
h, e := c.OwnerHandler(og)
|
||||
Expect(e).NotTo(HaveOccurred())
|
||||
return h
|
||||
})
|
||||
return rig{owner: og, front: &Gateway{Forward: cs["front-a"], Revalidate: ok}, other: &Gateway{Forward: cs["front-b"], Revalidate: ok}, sim: sim}
|
||||
}
|
||||
random := func(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return b
|
||||
}
|
||||
|
||||
for _, topology := range []string{"on the owner", "through a frontend"} {
|
||||
Describe(topology, func() {
|
||||
gw := func(r rig) *Gateway {
|
||||
if topology == "on the owner" {
|
||||
return r.owner
|
||||
}
|
||||
return r.front
|
||||
}
|
||||
It("moves a file in chunks and keeps its digest", func() {
|
||||
r := build(64 << 20)
|
||||
// Three chunks and a bit: the last one is short.
|
||||
payload := random(3*FileChunkBytes + 12345)
|
||||
v, err := gw(r).UploadFile(context.Background(), alice, "sample.zip", bytes.NewReader(payload))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
sum := sha256.Sum256(payload)
|
||||
Expect(v.SHA256).To(Equal(hex.EncodeToString(sum[:])))
|
||||
Expect(v.Size).To(Equal(int64(len(payload))))
|
||||
p, _, err := r.sim.store.Open(v.FileID, "alice")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
onDisk, _ := os.ReadFile(p)
|
||||
Expect(onDisk).To(Equal(payload))
|
||||
})
|
||||
|
||||
It("cuts an upload that goes over the limit and keeps nothing", func() {
|
||||
r := build(int64(FileChunkBytes) + 10)
|
||||
_, err := gw(r).UploadFile(context.Background(), alice, "big.zip", bytes.NewReader(random(2*FileChunkBytes)))
|
||||
Expect(typeOf(err)).To(Equal("too_large"))
|
||||
var he *HostGrantsError
|
||||
Expect(errors.As(err, &he)).To(BeTrue())
|
||||
Expect(he.Status).To(Equal(413))
|
||||
ents, _ := os.ReadDir(r.sim.store.Root)
|
||||
Expect(ents).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("passes the rule a refused file broke to the caller", func() {
|
||||
r := build(64 << 20)
|
||||
r.sim.reject = &packages.FileError{Code: packages.FileWrongPlatform, Msg: "This file is for linux/arm64; this host is linux/amd64"}
|
||||
_, err := gw(r).UploadFile(context.Background(), alice, "x.zip", strings.NewReader("zip bytes"))
|
||||
Expect(typeOf(err)).To(Equal("wrong_platform"))
|
||||
Expect(err.Error()).To(ContainSubstring("linux/arm64"))
|
||||
ents, _ := os.ReadDir(r.sim.store.Root)
|
||||
Expect(ents).To(BeEmpty(), "a refused file is not kept")
|
||||
})
|
||||
|
||||
It("aborts when the client's stream breaks, and rejects an empty file", func() {
|
||||
r := build(64 << 20)
|
||||
broken := iotest.ErrReader(errors.New("connection reset"))
|
||||
_, err := gw(r).UploadFile(context.Background(), alice, "x.zip", broken)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(r.sim.aborted).To(Equal(1))
|
||||
_, err = gw(r).UploadFile(context.Background(), alice, "x.zip", bytes.NewReader(nil))
|
||||
Expect(typeOf(err)).To(Equal("bad_zip"))
|
||||
ents, _ := os.ReadDir(r.sim.store.Root)
|
||||
Expect(ents).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("binds a staged file to the user that uploaded it", func() {
|
||||
r := build(64 << 20)
|
||||
v, err := gw(r).UploadFile(context.Background(), alice, "x.zip", strings.NewReader("zip bytes"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = gw(r).DescribeFile(context.Background(), bob, v.FileID)
|
||||
Expect(typeOf(err)).To(Equal("not_found"))
|
||||
Expect(gw(r).DropFile(context.Background(), bob, v.FileID)).NotTo(Succeed())
|
||||
got, err := gw(r).DescribeFile(context.Background(), alice, v.FileID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got.FileID).To(Equal(v.FileID))
|
||||
Expect(gw(r).DropFile(context.Background(), alice, v.FileID)).To(Succeed())
|
||||
_, err = gw(r).DescribeFile(context.Background(), alice, v.FileID)
|
||||
Expect(typeOf(err)).To(Equal("not_found"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
It("lets a second frontend read the file the first one uploaded", func() {
|
||||
r := build(64 << 20)
|
||||
v, err := r.front.UploadFile(context.Background(), alice, "x.zip", strings.NewReader("zip bytes"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
got, err := r.other.DescribeFile(context.Background(), alice, v.FileID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got.SHA256).To(Equal(v.SHA256))
|
||||
})
|
||||
|
||||
It("needs no human mark to stage a file, and never changes the policy by it", func() {
|
||||
r := build(64 << 20)
|
||||
_, err := r.front.UploadFile(context.Background(), alice, "x.zip", strings.NewReader("zip bytes"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
// The install is another call and keeps its rule.
|
||||
_, err = r.front.HostGrants(context.Background(), alice, call(HostGrantsInstall, "i1", "", HostGrantsInstallRequest{OperationID: "i1", Action: "install", SourceID: "local-file", AddonID: "sample", Version: strings.Repeat("a", 32), Approve: HostGrantsApprove{Digest: "sha256:aa", Nonce: "n"}}))
|
||||
Expect(typeOf(err)).To(Equal("not_human_surface"))
|
||||
})
|
||||
|
||||
It("answers unavailable when the owner has no file service", func() {
|
||||
fx := newHGFixture()
|
||||
g := &Gateway{HostGrantsOwner: fx.svc, Revalidate: ok}
|
||||
_, err := g.UploadFile(context.Background(), alice, "x.zip", strings.NewReader("x"))
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,9 @@ func (c HostGrantsCall) write() bool {
|
||||
}
|
||||
|
||||
func (c HostGrantsCall) valid() bool {
|
||||
if isFileOp(c.Op) {
|
||||
return c.OperationID == ""
|
||||
}
|
||||
switch c.Op {
|
||||
case HostGrantsRead, HostGrantsPreview, HostGrantsDenyPreview:
|
||||
return c.OperationID == ""
|
||||
@@ -277,9 +280,12 @@ type HostGrantsInstallRequest struct {
|
||||
AddonID string `json:"addonId"`
|
||||
Version string `json:"version"`
|
||||
// ExpectedDigest and CandidateDigest pin an upgrade; both or neither.
|
||||
ExpectedDigest string `json:"expectedDigest,omitempty"`
|
||||
CandidateDigest string `json:"candidateDigest,omitempty"`
|
||||
Approve HostGrantsApprove `json:"approve"`
|
||||
ExpectedDigest string `json:"expectedDigest,omitempty"`
|
||||
CandidateDigest string `json:"candidateDigest,omitempty"`
|
||||
// Downgrade confirms that a local file may install an older version than
|
||||
// the installed one. Only a sourceId of local-file reads it.
|
||||
Downgrade bool `json:"downgrade,omitempty"`
|
||||
Approve HostGrantsApprove `json:"approve"`
|
||||
}
|
||||
|
||||
// HostGrantsEditRequest is the body of PUT /api/addons/host-grants/{addonId}.
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
|
||||
"github.com/mudler/LocalAI/core/services/addons/hostops"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"github.com/mudler/LocalAI/pkg/addons/zipformat"
|
||||
)
|
||||
|
||||
// nonceLife is how long a preview nonce stays valid.
|
||||
@@ -63,6 +64,7 @@ type HostGrantsConfig struct {
|
||||
type HostGrantsOwner struct {
|
||||
cfg HostGrantsConfig
|
||||
nonces nonceStore
|
||||
files FileService
|
||||
}
|
||||
|
||||
// NewHostGrantsOwner returns the owner service. A store and a manager are required.
|
||||
@@ -152,6 +154,8 @@ func (o *HostGrantsOwner) Serve(ctx context.Context, a contract.Actor, call Host
|
||||
if o.cfg.Admin == nil {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
// A staged local file is read for the user that uploaded it only.
|
||||
ctx = addons.WithFileOwner(ctx, a.UserID)
|
||||
if e := ClassifyCheck(o.cfg.Admin(ctx, a)); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
@@ -210,6 +214,8 @@ func (o *HostGrantsOwner) Serve(ctx context.Context, a contract.Actor, call Host
|
||||
return nil, hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
|
||||
}
|
||||
out, err = o.deny(ctx, a, r)
|
||||
case HostGrantsFileBegin, HostGrantsFileChunk, HostGrantsFileFinish, HostGrantsFileAbort, HostGrantsFileDrop, HostGrantsFileDescribe:
|
||||
out, err = o.fileCall(ctx, a, call)
|
||||
default:
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
@@ -550,7 +556,7 @@ func (o *HostGrantsOwner) install(ctx context.Context, a contract.Actor, r HostG
|
||||
return nil, hgErr(409, HostGrantsApprovalStale, "The approval expired or does not match; review the package again")
|
||||
}
|
||||
cmd := addons.LifecycleCommand{OperationID: r.OperationID, AddonID: r.AddonID, Action: r.Action, SourceID: r.SourceID, Version: r.Version,
|
||||
ExpectedDigest: r.ExpectedDigest, CandidateDigest: r.CandidateDigest}
|
||||
ExpectedDigest: r.ExpectedDigest, CandidateDigest: r.CandidateDigest, Downgrade: r.Downgrade && r.SourceID == zipformat.SourceID}
|
||||
st, err := o.cfg.Manager.SubmitWithApproval(ctx, a, cmd, HostApproval{Digest: r.Approve.Digest, Ops: slices.Clone(r.Approve.Ops), Revision: r.Approve.Revision, Nonce: r.Approve.Nonce})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -302,7 +302,7 @@ var assetPath = regexp.MustCompile(`^[a-zA-Z0-9_./-]+$`)
|
||||
// so an add-on with one of these IDs would have its asset URLs captured by the
|
||||
// host route of the same name. A test derives the registered segments from the
|
||||
// real routes and requires each to be listed here.
|
||||
var reservedAddonIDs = []string{"apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins", "host-grants"}
|
||||
var reservedAddonIDs = []string{"apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins", "host-grants", "files"}
|
||||
|
||||
// ReservedAddonIDs returns a copy of the reserved add-on IDs.
|
||||
func ReservedAddonIDs() []string { return append([]string(nil), reservedAddonIDs...) }
|
||||
|
||||
@@ -143,7 +143,7 @@ var _ = Describe("Manifest", func() {
|
||||
It("rejects add-on IDs that the host serves as a route segment", func() {
|
||||
// Every first path segment the host uses under /api/addons/ would
|
||||
// otherwise capture the asset URL of an add-on with that ID.
|
||||
Expect(ReservedAddonIDs()).To(ConsistOf("apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins", "host-grants"))
|
||||
Expect(ReservedAddonIDs()).To(ConsistOf("apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins", "host-grants", "files"))
|
||||
for _, id := range ReservedAddonIDs() {
|
||||
m, _ := ParseManifest(fixture())
|
||||
m.ID = id
|
||||
|
||||
Reference in new issue
Block a user