mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-18 05:33:09 -04:00
* fix(http): close 0.0.0.0/[::] SSRF bypass in /api/cors-proxy The CORS proxy carried its own private-network blocklist (RFC 1918 + a handful of IPv6 ranges) instead of using the same classification as pkg/utils/urlfetch.go. The hand-rolled list missed 0.0.0.0/8 and ::/128, both of which Linux routes to localhost — so any user with FeatureMCP (default-on for new users) could reach LocalAI's own listener and any other service bound to 0.0.0.0:port via: GET /api/cors-proxy?url=http://0.0.0.0:8080/... GET /api/cors-proxy?url=http://[::]:8080/... Replace the custom check with utils.IsPublicIP (Go stdlib IsLoopback / IsLinkLocalUnicast / IsPrivate / IsUnspecified, plus IPv4-mapped IPv6 unmasking) and add an upfront hostname rejection for localhost, *.local, and the cloud metadata aliases so split-horizon DNS can't paper over the IP check. The IP-pinning DialContext is unchanged: the validated IP from the single resolution is reused for the connection, so DNS rebinding still cannot swap a public answer for a private one between validate and dial. Regression tests cover 0.0.0.0, 0.0.0.0:PORT, [::], ::ffff:127.0.0.1, ::ffff:10.0.0.1, file://, gopher://, ftp://, localhost, 127.0.0.1, 10.0.0.1, 169.254.169.254, metadata.google.internal. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(downloader): verify SHA before promoting temp file to final path DownloadFileWithContext renamed the .partial file to its final name *before* checking the streamed SHA, so a hash mismatch returned an error but left the tampered file at filePath. Subsequent code that operated on filePath (a backend launcher, a YAML loader, a re-download that finds the file already present and skips) would consume the attacker-supplied bytes. Reorder: verify the streamed hash first, remove the .partial on mismatch, then rename. The streamed hash is computed during io.Copy so no second read is needed. While here, raise the empty-SHA case from a Debug log to a Warn so "this download had no integrity check" is visible at the default log level. Backend installs currently pass through with no digest; the warning makes that footprint observable without changing behaviour. Regression test asserts os.IsNotExist on the destination after a deliberate SHA mismatch. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(auth): require email_verified for OIDC admin promotion extractOIDCUserInfo read the ID token's "email" claim but never inspected "email_verified". With LOCALAI_ADMIN_EMAIL set, an attacker who could register on the configured OIDC IdP under that email (some IdPs accept self-supplied unverified emails) inherited admin role: - first login: AssignRole(tx, email, adminEmail) → RoleAdmin - re-login: MaybePromote(db, user, adminEmail) → flip to RoleAdmin Add EmailVerified to oauthUserInfo, parse email_verified from the OIDC claims (default false on absence so an IdP that omits the claim cannot short-circuit the gate), and substitute "" for the role-decision email when verified=false via emailForRoleDecision. The user record still stores the unverified email for display. GitHub's path defaults EmailVerified=true: GitHub only returns a public profile email after verification, and fetchGitHubPrimaryEmail explicitly filters to Verified=true. Regression tests cover both the helper contract and integration with AssignRole, including the bootstrap "first user" branch that would otherwise mask the gate. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(cli): refuse public bind when no auth backend is configured When neither an auth DB nor a static API key is set, the auth middleware passes every request through. That is fine for a developer laptop, a home LAN, or a Tailnet — the network itself is the trust boundary. It is not fine on a public IP, where every model install, settings change, and admin endpoint becomes reachable from the internet. Refuse to start in that exact configuration. Loopback, RFC 1918, RFC 4193 ULA, link-local, and RFC 6598 CGNAT (Tailscale's default range) all count as trusted; wildcard binds (`:port`, `0.0.0.0`, `[::]`) are accepted only when every host interface is in one of those ranges. Hostnames are resolved and treated as trusted only when every answer is. A new --allow-insecure-public-bind / LOCALAI_ALLOW_INSECURE_PUBLIC_BIND flag opts out for deployments that gate access externally (a reverse proxy enforcing auth, a mesh ACL, etc.). The error message lists this plus the three constructive alternatives (bind a private interface, enable --auth, set --api-keys). The interface enumeration goes through a package-level interfaceAddrsFn var so tests can simulate cloud-VM, home-LAN, Tailscale-only, and enumeration-failure topologies without poking at the real network stack. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * test(http): regression-test the localai_assistant admin gate ChatEndpoint already rejects metadata.localai_assistant=true from a non-admin caller, but the gate was open-coded inline with no direct test coverage. The chat route is FeatureChat-gated (default-on), and the assistant's in-process MCP server can install/delete models and edit configs — the wrong handler change would silently turn the LLM into a confused deputy. Extract the gate into requireAssistantAccess(c, authEnabled) and pin its behaviour: auth disabled is a no-op, unauthenticated is 403, RoleUser is 403, RoleAdmin and the synthetic legacy-key admin are admitted. No behaviour change in the production path. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * test(http): assert every API route is auth-classified The auth middleware classifies path prefixes (/api/, /v1/, /models/, etc.) as protected and treats anything else as a static-asset passthrough. A new endpoint shipped under a brand-new prefix — or a new path that simply isn't on the prefix allowlist — would be reachable anonymously. Walk every route registered by API() with auth enabled and a fresh in-memory database (no users, no keys), and assert each API-prefixed route returns 401 / 404 / 405 to an anonymous request. Public surfaces (/api/auth/*, /api/branding, /api/node/* token-authenticated routes, /healthz, branding asset server, generated-content server, static assets) are explicit allowlist entries with comments justifying them. Build-tagged 'auth' so it runs against the SQLite-backed auth DB (matches the existing auth suite). Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * test(http): pin agent endpoint per-user isolation contract agents.go's getUserID / effectiveUserID / canImpersonateUser / wantsAllUsers helpers are the single trust boundary for cross-user access on agent, agent-jobs, collections, and skills routes. A regression there is the difference between "regular user reads their own data" and "regular user reads anyone's data via ?user_id=victim". Lock in the contract: - effectiveUserID ignores ?user_id= for unauthenticated and RoleUser - effectiveUserID honours it for RoleAdmin and ProviderAgentWorker - wantsAllUsers requires admin AND the literal "true" string - canImpersonateUser is admin OR agent-worker, never plain RoleUser No production change — this commit only adds tests. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(downloader): drop redundant stat in removePartialFile The stat-then-remove pattern is a TOCTOU window and a wasted syscall — os.Remove already returns ErrNotExist for the missing-file case, so trust that and treat it as a no-op. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(http): redact secrets from trace buffer and distribution-token logs The /api/traces buffer captured Authorization, Cookie, Set-Cookie, and API-key headers verbatim from every request when tracing was enabled. The endpoint is admin-only but the buffer is reachable via any heap-style introspection and the captured tokens otherwise outlive the request. Strip those header values at capture time. Body redaction is left to a follow-up — the prompts are usually the operator's own and JSON-walking is invasive. Distribution tokens were also logged in plaintext from core/explorer/discovery.go; logs forward to syslog/journald and outlive the token. Redact those to a short prefix/suffix instead. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(auth): rate-limit OAuth callbacks separately from password endpoints The shared 5/min/IP limit on auth endpoints is right for password-style flows but too tight for OAuth callbacks: corporate SSO funnels many real users through one outbound IP and would trip the limit. Add a separate 60/min/IP limiter for /api/auth/{github,oidc}/callback so callbacks are bounded against floods without breaking shared-IP deployments. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(gallery): verify backend tarball sha256 when set in gallery entry GalleryBackend gained an optional sha256 field; the install path now threads it through to the existing downloader hash-verify (which already streams, verifies, and rolls back on mismatch). Galleries without sha256 keep working; the empty-SHA path still emits the existing "downloading without integrity check" warning. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * test(http): pin CSRF coverage on multipart endpoints The CSRF middleware in app.go is global (e.Use) so it covers every multipart upload route — branding assets, fine-tune datasets, audio transforms, agent collections. Pin that contract: cross-site multipart POSTs are rejected; same-origin / same-site / API-key clients are not. Also pins the SameSite=Lax fallback path the skipper relies on when Sec-Fetch-Site is absent. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(http): XSS hardening — CSP headers, safe href, base-href escape, SVG sandbox Several closely related XSS-prevention changes spanning the SPA shell, the React UI, and the branding asset server: - New SecurityHeaders middleware sets CSP, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy on every response. The CSP keeps script-src permissive because the Vite bundle relies on inline + eval'd scripts; tightening that requires moving to a nonce-based policy. - The <base href> injection in the SPA shell escaped attacker-controllable Host / X-Forwarded-Host headers — a single quote in the host header broke out of the attribute. Pass through SecureBaseHref (html.EscapeString). - Three React sinks rendering untrusted content via dangerouslySetInnerHTML switch to text-node rendering with whiteSpace: pre-wrap: user message bodies in Chat.jsx and AgentChat.jsx, and the agent activity log in AgentChat.jsx. The hand-rolled escape on the agent user-message variant is replaced by the same plain-text path. - New safeHref util collapses non-allowlisted URI schemes (most importantly javascript:) to '#'. Applied to gallery `<a href={url}>` links in Models / Backends / Manage and to canvas artifact links — these come from gallery JSON or assistant tool calls and must be treated as untrusted. - The branding asset server attaches a sandbox CSP plus same-origin CORP to .svg responses. The React UI loads logos via <img>, but the same URL is also reachable via direct navigation; this prevents script execution if a hostile SVG slipped past upload validation. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(http): bound HTTP server with read-header and idle timeouts A net/http server with no timeouts is trivially Slowloris-able and leaks idle keep-alive connections. Set ReadHeaderTimeout (30s) to plug the slow-headers attack and IdleTimeout (120s) to cap keep-alive sockets. ReadTimeout and WriteTimeout stay at 0 because request bodies can be multi-GB model uploads and SSE / chat completions stream for many minutes; operators who need tighter per-request bounds should terminate slow clients at a reverse proxy. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * test(auth): pin PUT /api/auth/profile field-tampering contract The handler uses an explicit local body struct (only name and avatar_url) plus a gorm Updates(map) with a column allowlist, so an attacker posting {"role":"admin","email":"...","password_hash":"..."} can't mass-assign those fields. Lock that down with a regression test so a future "let's just c.Bind(&user)" refactor breaks loudly. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(services): strip directory components from multipart upload filenames UploadDataset and UploadToCollectionForUser took the raw multipart file.Filename and joined it into a destination path. The fine-tune upload was incidentally safe because of a UUID prefix that fused any leading '..' to a literal segment, but the protection is fragile. UploadToCollectionForUser handed the filename to a vendored backend without sanitising at all. Strip to filepath.Base at both boundaries and reject the trivial unsafe values ("", ".", "..", "/"). Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(react-ui): validate persisted MCP server entries on load localStorage is shared across same-origin pages; an XSS that lands once can poison persisted MCP server config to attempt header injection or to feed a non-http URL into the fetch path on subsequent loads. Validate every entry: types must match, URL must parse with http(s) scheme, header keys/values must be control-char-free. Drop anything that doesn't fit. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(http): close X-Forwarded-Prefix open redirect The reverse-proxy support concatenated X-Forwarded-Prefix into the redirect target without validation, so a forged header value of "//evil.com" turned the SPA-shell redirect helper at /, /browse, and /browse/* into a 301 to //evil.com/app. The path-strip middleware had the same shape on its prefix-trailing-slash redirect. Add SafeForwardedPrefix at the middleware boundary: must start with a single '/', no protocol-relative '//' opener, no scheme, no backslash, no control characters. Apply at both consumers; misconfig trips the validator and the header is dropped. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(http): refuse wildcard CORS when LOCALAI_CORS=true with empty allowlist When LOCALAI_CORS=true but LOCALAI_CORS_ALLOW_ORIGINS was empty, Echo's CORSWithConfig saw an empty allow-list and fell back to its default AllowOrigins=["*"]. An operator who flipped the strict-CORS feature flag without populating the list got the opposite of what they asked for. Echo never sets Allow-Credentials: true so this isn't directly exploitable (cookies aren't sent under wildcard CORS), but the misconfiguration trap is worth closing. Skip the registration and warn. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(auth): zxcvbn password strength check with user-acknowledged override The previous policy was len < 8, which let through "Password1" and the rest of the credential-stuffing corpus. LocalAI has no second factor yet, so the bar needs to sit higher. Add ValidatePasswordStrength using github.com/timbutler/zxcvbn (an actively-maintained fork of the trustelem port; v1.0.4, April 2024): - min 12 chars, max 72 (bcrypt's truncation point) - reject NUL bytes (some bcrypt callers truncate at the first NUL) - require zxcvbn score >= 3 ("safely unguessable, ~10^8 guesses to break"); the hint list ["localai", "local-ai", "admin"] penalises passwords built from the app's own branding zxcvbn produces false positives sometimes (a strong-looking password that happens to match a dictionary word) and operators occasionally need to set a known-weak password (kiosk demos, CI rigs). Add an acknowledgement path: PasswordPolicy{AllowWeak: true} skips the entropy check while still enforcing the hard rules. The structured PasswordErrorResponse marks weak-password rejections as Overridable so the UI can surface a "use this anyway" checkbox. Wired through register, self-service password change, and admin password reset on both the server and the React UI. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(react-ui): drop HTML5 minLength on new-password inputs minLength={12} on the new-password input let the browser block the form submit silently before any JS or network call ran. The browser focused the field, showed a brief native tooltip, and that was that — no toast, no fetch, no clue. Reproducible by typing fewer than 12 chars on the second password change of a session. The JS-level length check in handleSubmit already shows a toast and the server rejects with a structured error, so the HTML5 attribute was redundant defence anyway. Drop it. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(react-ui): bundle Geist fonts locally instead of fetching from Google The new CSP correctly refused to apply styles from fonts.googleapis.com because style-src is locked to 'self' and 'unsafe-inline'. Loosening the CSP would defeat its purpose; the right fix is to stop reaching out to a third-party CDN for fonts on every page load. Add @fontsource-variable/geist and @fontsource-variable/geist-mono as npm deps and import them once at boot. Drop the <link rel="preconnect"> and external stylesheet from index.html. Side benefit: no third-party tracking via Referer / IP on every UI load, no failure mode when offline / behind a captive portal. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(react-ui): refresh i18n strings to reflect 12-char password minimum The translations still said "at least 8 characters" everywhere — the client-side toast on a too-short password change told the user the wrong floor. Update tooShort and newPasswordPlaceholder / newPasswordDescription across all five locales (en, es, it, de, zh-CN) to match the real ValidatePasswordStrength rule. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(auth): make password length-floor overridable like the entropy check The 12-char minimum was a policy choice, not a technical invariant — only "non-empty", "<= 72 bytes", and "no NUL bytes" are real bcrypt constraints. Treating length-12 as a hard rule was inconsistent with the entropy check (already overridable) and friction for use cases where the account is just a name on a session, not a security boundary (single-user kiosk, CI rig, lab demo). Restructure ValidatePasswordStrength: - Hard rules (always enforced): non-empty, <= MaxPasswordLength, no NUL byte - Policy rules (skipped when AllowWeak=true): length >= 12, zxcvbn score >= 3 PasswordError now marks password_too_short as Overridable too. The React forms generalised from `error_code === 'password_too_weak'` to `overridable === true`, and the JS-side preflight length checks were removed (server is source of truth, returns the same checkbox flow). Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
641 lines
20 KiB
Go
641 lines
20 KiB
Go
package downloader
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/go-containerregistry/pkg/v1/tarball"
|
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
|
|
|
"github.com/mudler/LocalAI/pkg/oci"
|
|
"github.com/mudler/LocalAI/pkg/utils"
|
|
"github.com/mudler/LocalAI/pkg/xio"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
const (
|
|
HuggingFacePrefix = "huggingface://"
|
|
HuggingFacePrefix1 = "hf://"
|
|
HuggingFacePrefix2 = "hf.co/"
|
|
OCIPrefix = "oci://"
|
|
OCIFilePrefix = "ocifile://"
|
|
OllamaPrefix = "ollama://"
|
|
HTTPPrefix = "http://"
|
|
HTTPSPrefix = "https://"
|
|
GithubURI = "github:"
|
|
GithubURI2 = "github://"
|
|
LocalPrefix = "file://"
|
|
)
|
|
|
|
type URI string
|
|
|
|
// HF_ENDPOINT is the HuggingFace endpoint, can be overridden by setting the HF_ENDPOINT environment variable.
|
|
var HF_ENDPOINT string = loadConfig()
|
|
|
|
// loadConfig returns the HuggingFace endpoint URL.
|
|
// It supports the following environment variables in order of precedence:
|
|
// 1. HF_MIRROR - if set, uses this as the mirror URL (takes precedence over HF_ENDPOINT)
|
|
// 2. HF_ENDPOINT - if set, uses this as the endpoint
|
|
// 3. Default: https://huggingface.co
|
|
//
|
|
// HF_MIRROR supports both full URLs (https://hf-mirror.com) and simple hostnames (hf-mirror.com).
|
|
// If no scheme is provided, https:// is automatically added.
|
|
func loadConfig() string {
|
|
// Check for HF_MIRROR first (takes precedence)
|
|
HF_MIRROR := os.Getenv("HF_MIRROR")
|
|
if HF_MIRROR == "" {
|
|
HF_MIRROR = os.Getenv("HF")
|
|
}
|
|
if HF_MIRROR != "" {
|
|
// Normalize the mirror URL - add https:// if no scheme
|
|
if !strings.HasPrefix(HF_MIRROR, "http://") && !strings.HasPrefix(HF_MIRROR, "https://") {
|
|
HF_MIRROR = "https://" + HF_MIRROR
|
|
}
|
|
return HF_MIRROR
|
|
}
|
|
|
|
// Fall back to HF_ENDPOINT
|
|
HF_ENDPOINT := os.Getenv("HF_ENDPOINT")
|
|
if HF_ENDPOINT == "" {
|
|
HF_ENDPOINT = "https://huggingface.co"
|
|
}
|
|
return HF_ENDPOINT
|
|
}
|
|
|
|
func (uri URI) ReadWithCallback(basePath string, f func(url string, i []byte) error) error {
|
|
return uri.ReadWithAuthorizationAndCallback(context.Background(), basePath, "", f)
|
|
}
|
|
|
|
func (uri URI) ReadWithAuthorizationAndCallback(ctx context.Context, basePath string, authorization string, f func(url string, i []byte) error) error {
|
|
url := uri.ResolveURL()
|
|
|
|
if strings.HasPrefix(string(uri), LocalPrefix) {
|
|
// checks if the file is symbolic, and resolve if so - otherwise, this function returns the path unmodified.
|
|
resolvedFile, err := filepath.EvalSymlinks(url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resolvedBasePath, err := filepath.EvalSymlinks(basePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Check if the local file is rooted in basePath
|
|
err = utils.InTrustedRoot(resolvedFile, resolvedBasePath)
|
|
if err != nil {
|
|
xlog.Debug("downloader.GetURI blocked an attempt to ready a file url outside of basePath", "resolvedFile", resolvedFile, "basePath", basePath)
|
|
return err
|
|
}
|
|
// Read the response body
|
|
body, err := os.ReadFile(resolvedFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Unmarshal YAML data into a struct
|
|
return f(url, body)
|
|
}
|
|
|
|
// Send a GET request to the URL
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if authorization != "" {
|
|
req.Header.Add("Authorization", authorization)
|
|
}
|
|
|
|
response, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
// Read the response body
|
|
body, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Unmarshal YAML data into a struct
|
|
return f(url, body)
|
|
}
|
|
|
|
func (u URI) FilenameFromUrl() (string, error) {
|
|
if f := filenameFromUrl(string(u)); f != "" {
|
|
return f, nil
|
|
}
|
|
|
|
f := utils.MD5(string(u))
|
|
if strings.HasSuffix(string(u), ".yaml") || strings.HasSuffix(string(u), ".yml") {
|
|
f = f + ".yaml"
|
|
}
|
|
|
|
return f, nil
|
|
}
|
|
|
|
func filenameFromUrl(urlstr string) string {
|
|
// strip anything after @
|
|
if strings.Contains(urlstr, "@") {
|
|
urlstr = strings.Split(urlstr, "@")[0]
|
|
}
|
|
|
|
u, err := url.Parse(urlstr)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
x, err := url.QueryUnescape(u.EscapedPath())
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Base(x)
|
|
}
|
|
|
|
func (u URI) LooksLikeURL() bool {
|
|
return strings.HasPrefix(string(u), HTTPPrefix) ||
|
|
strings.HasPrefix(string(u), HTTPSPrefix) ||
|
|
strings.HasPrefix(string(u), HuggingFacePrefix) ||
|
|
strings.HasPrefix(string(u), HuggingFacePrefix1) ||
|
|
strings.HasPrefix(string(u), HuggingFacePrefix2) ||
|
|
strings.HasPrefix(string(u), GithubURI) ||
|
|
strings.HasPrefix(string(u), OllamaPrefix) ||
|
|
strings.HasPrefix(string(u), OCIPrefix) ||
|
|
strings.HasPrefix(string(u), GithubURI2)
|
|
}
|
|
|
|
func (u URI) LooksLikeHTTPURL() bool {
|
|
return strings.HasPrefix(string(u), HTTPPrefix) ||
|
|
strings.HasPrefix(string(u), HTTPSPrefix)
|
|
}
|
|
|
|
func (u URI) LooksLikeDir() bool {
|
|
f, err := os.Stat(string(u))
|
|
return err == nil && f.IsDir()
|
|
}
|
|
|
|
func (s URI) LooksLikeOCI() bool {
|
|
return strings.HasPrefix(string(s), "quay.io") ||
|
|
strings.HasPrefix(string(s), OCIPrefix) ||
|
|
strings.HasPrefix(string(s), OllamaPrefix) ||
|
|
strings.HasPrefix(string(s), OCIFilePrefix) ||
|
|
strings.HasPrefix(string(s), "ghcr.io") ||
|
|
strings.HasPrefix(string(s), "docker.io")
|
|
}
|
|
|
|
func (s URI) LooksLikeOCIFile() bool {
|
|
return strings.HasPrefix(string(s), OCIFilePrefix)
|
|
}
|
|
|
|
func (s URI) ResolveURL() string {
|
|
switch {
|
|
case strings.HasPrefix(string(s), LocalPrefix):
|
|
return strings.TrimPrefix(string(s), LocalPrefix)
|
|
case strings.HasPrefix(string(s), GithubURI2):
|
|
repository := strings.Replace(string(s), GithubURI2, "", 1)
|
|
|
|
repoParts := strings.Split(repository, "@")
|
|
branch := "main"
|
|
|
|
if len(repoParts) > 1 {
|
|
branch = repoParts[1]
|
|
}
|
|
|
|
repoPath := strings.Split(repoParts[0], "/")
|
|
org := repoPath[0]
|
|
project := repoPath[1]
|
|
projectPath := strings.Join(repoPath[2:], "/")
|
|
|
|
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, project, branch, projectPath)
|
|
case strings.HasPrefix(string(s), GithubURI):
|
|
parts := strings.Split(string(s), ":")
|
|
repoParts := strings.Split(parts[1], "@")
|
|
branch := "main"
|
|
|
|
if len(repoParts) > 1 {
|
|
branch = repoParts[1]
|
|
}
|
|
|
|
repoPath := strings.Split(repoParts[0], "/")
|
|
org := repoPath[0]
|
|
project := repoPath[1]
|
|
projectPath := strings.Join(repoPath[2:], "/")
|
|
|
|
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, project, branch, projectPath)
|
|
case strings.HasPrefix(string(s), HuggingFacePrefix) || strings.HasPrefix(string(s), HuggingFacePrefix1) || strings.HasPrefix(string(s), HuggingFacePrefix2):
|
|
repository := strings.Replace(string(s), HuggingFacePrefix, "", 1)
|
|
repository = strings.Replace(repository, HuggingFacePrefix1, "", 1)
|
|
repository = strings.Replace(repository, HuggingFacePrefix2, "", 1)
|
|
// convert repository to a full URL.
|
|
// e.g. TheBloke/Mixtral-8x7B-v0.1-GGUF/mixtral-8x7b-v0.1.Q2_K.gguf@main -> https://huggingface.co/TheBloke/Mixtral-8x7B-v0.1-GGUF/resolve/main/mixtral-8x7b-v0.1.Q2_K.gguf
|
|
|
|
repoPieces := strings.Split(repository, "/")
|
|
repoID := strings.Split(repository, "@")
|
|
if len(repoPieces) < 3 {
|
|
return string(s)
|
|
}
|
|
|
|
owner := repoPieces[0]
|
|
repo := repoPieces[1]
|
|
|
|
branch := "main"
|
|
filepath := strings.Join(repoPieces[2:], "/")
|
|
|
|
if len(repoID) > 1 {
|
|
if strings.Contains(repo, "@") {
|
|
branch = repoID[1]
|
|
}
|
|
if strings.Contains(filepath, "@") {
|
|
filepath = repoID[2]
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%s/%s/%s/resolve/%s/%s", HF_ENDPOINT, owner, repo, branch, filepath)
|
|
}
|
|
|
|
// If a HuggingFace mirror is configured, rewrite direct https://huggingface.co/ URLs
|
|
// to use the mirror. This ensures gallery entries with hardcoded URLs also benefit
|
|
// from the mirror setting.
|
|
if HF_ENDPOINT != "https://huggingface.co" && strings.HasPrefix(string(s), "https://huggingface.co/") {
|
|
return HF_ENDPOINT + strings.TrimPrefix(string(s), "https://huggingface.co")
|
|
}
|
|
|
|
return string(s)
|
|
}
|
|
|
|
func removePartialFile(tmpFilePath string) error {
|
|
xlog.Debug("Removing temporary file", "file", tmpFilePath)
|
|
if err := os.Remove(tmpFilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
err1 := fmt.Errorf("failed to remove temporary download file %s: %v", tmpFilePath, err)
|
|
xlog.Warn("failed to remove temporary download file", "error", err1)
|
|
return err1
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func calculateHashForPartialFile(file *os.File) (hash.Hash, error) {
|
|
hash := sha256.New()
|
|
_, err := io.Copy(hash, file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return hash, nil
|
|
}
|
|
|
|
func (uri URI) checkSeverSupportsRangeHeader() (bool, error) {
|
|
url := uri.ResolveURL()
|
|
resp, err := http.Head(url)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
return resp.Header.Get("Accept-Ranges") == "bytes", nil
|
|
}
|
|
|
|
// ContentLength returns the size in bytes of the resource at the URI.
|
|
// For file:// it uses os.Stat on the resolved path; for HTTP/HTTPS it uses HEAD
|
|
// and optionally a Range request if Content-Length is missing.
|
|
func (u URI) ContentLength(ctx context.Context) (int64, error) {
|
|
urlStr := u.ResolveURL()
|
|
if strings.HasPrefix(string(u), LocalPrefix) {
|
|
info, err := os.Stat(urlStr)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return info.Size(), nil
|
|
}
|
|
if !strings.HasPrefix(urlStr, HTTPPrefix) && !strings.HasPrefix(urlStr, HTTPSPrefix) {
|
|
return 0, fmt.Errorf("unsupported URI scheme for ContentLength: %s", string(u))
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "HEAD", urlStr, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return 0, fmt.Errorf("HEAD %s: status %d", urlStr, resp.StatusCode)
|
|
}
|
|
if resp.ContentLength >= 0 {
|
|
return resp.ContentLength, nil
|
|
}
|
|
if resp.Header.Get("Accept-Ranges") != "bytes" {
|
|
return 0, fmt.Errorf("HEAD %s: no Content-Length and server does not support Range", urlStr)
|
|
}
|
|
req2, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req2.Header.Set("Range", "bytes=0-0")
|
|
resp2, err := http.DefaultClient.Do(req2)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp2.Body.Close()
|
|
if resp2.StatusCode != http.StatusPartialContent && resp2.StatusCode != http.StatusOK {
|
|
return 0, fmt.Errorf("Range request %s: status %d", urlStr, resp2.StatusCode)
|
|
}
|
|
cr := resp2.Header.Get("Content-Range")
|
|
// Content-Range: bytes 0-0/12345
|
|
if cr == "" {
|
|
return 0, fmt.Errorf("Range request %s: no Content-Range header", urlStr)
|
|
}
|
|
parts := strings.Split(cr, "/")
|
|
if len(parts) != 2 {
|
|
return 0, fmt.Errorf("invalid Content-Range: %s", cr)
|
|
}
|
|
size, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
|
if err != nil || size < 0 {
|
|
return 0, fmt.Errorf("invalid Content-Range total length: %s", parts[1])
|
|
}
|
|
return size, nil
|
|
}
|
|
|
|
func (uri URI) DownloadFile(filePath, sha string, fileN, total int, downloadStatus func(string, string, string, float64)) error {
|
|
return uri.DownloadFileWithContext(context.Background(), filePath, sha, fileN, total, downloadStatus)
|
|
}
|
|
|
|
func (uri URI) DownloadFileWithContext(ctx context.Context, filePath, sha string, fileN, total int, downloadStatus func(string, string, string, float64)) error {
|
|
url := uri.ResolveURL()
|
|
if uri.LooksLikeOCI() {
|
|
|
|
// Only Ollama wants to download to the file, for the rest, we want to download to the directory
|
|
// so we check if filepath has any extension, otherwise we assume it's a directory.
|
|
// Caveat: `filepath.Ext` treats any dot-suffix as an extension, so paths like
|
|
// `backends/local-store.upgrade-tmp` (the tmp dir created by gallery.UpgradeBackend)
|
|
// look like a "file" to this heuristic and get rewritten to their parent — which
|
|
// then unpacks the image at `backends/` top level and clobbers the real install
|
|
// with a flat-layout file. Guard against that by short-circuiting when the caller
|
|
// has already created the target as a directory: OCI destinations are always dirs
|
|
// in that case, regardless of what their suffix looks like.
|
|
if !strings.HasPrefix(url, OllamaPrefix) {
|
|
if fi, statErr := os.Stat(filePath); statErr == nil && fi.IsDir() {
|
|
// Existing directory — use as-is.
|
|
} else if filepath.Ext(filePath) != "" {
|
|
filePath = filepath.Dir(filePath)
|
|
}
|
|
}
|
|
|
|
progressStatus := func(desc ocispec.Descriptor) io.Writer {
|
|
return &progressWriter{
|
|
fileName: filePath,
|
|
total: desc.Size,
|
|
hash: sha256.New(),
|
|
fileNo: fileN,
|
|
totalFiles: total,
|
|
downloadStatus: downloadStatus,
|
|
}
|
|
}
|
|
|
|
if url, ok := strings.CutPrefix(url, OllamaPrefix); ok {
|
|
return oci.OllamaFetchModel(ctx, url, filePath, progressStatus)
|
|
}
|
|
|
|
if url, ok := strings.CutPrefix(url, OCIFilePrefix); ok {
|
|
// Open the tarball
|
|
img, err := tarball.ImageFromPath(url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open tarball: %s", err.Error())
|
|
}
|
|
|
|
return oci.ExtractOCIImage(ctx, img, url, filePath, downloadStatus)
|
|
}
|
|
|
|
url = strings.TrimPrefix(url, OCIPrefix)
|
|
img, err := oci.GetImage(url, "", nil, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get image %q: %v", url, err)
|
|
}
|
|
|
|
return oci.ExtractOCIImage(ctx, img, url, filePath, downloadStatus)
|
|
}
|
|
|
|
// Check for cancellation before starting
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Check if the file already exists
|
|
fi, err := os.Stat(filePath)
|
|
if err == nil {
|
|
// Directories don't count as cached downloads (e.g. empty dirs left
|
|
// by failed OCI extractions). Only skip for regular files.
|
|
if fi.IsDir() {
|
|
xlog.Debug("[downloader] Path is a directory, not treating as cached download", "filePath", filePath)
|
|
} else {
|
|
xlog.Debug("[downloader] File already exists", "filePath", filePath)
|
|
// File exists, check SHA
|
|
if sha != "" {
|
|
// Verify SHA
|
|
calculatedSHA, err := CalculateSHA(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to calculate SHA for file %q: %v", filePath, err)
|
|
}
|
|
if calculatedSHA == sha {
|
|
// SHA matches, skip downloading
|
|
xlog.Debug("File already exists and matches the SHA. Skipping download", "file", filePath)
|
|
return nil
|
|
}
|
|
// SHA doesn't match, delete the file and download again
|
|
err = os.Remove(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove existing file %q: %v", filePath, err)
|
|
}
|
|
xlog.Debug("Removed file (SHA doesn't match)", "file", filePath)
|
|
} else {
|
|
// SHA is missing, skip downloading
|
|
xlog.Debug("File already exists. Skipping download", "file", filePath)
|
|
return nil
|
|
}
|
|
}
|
|
} else if !os.IsNotExist(err) || !URI(url).LooksLikeHTTPURL() {
|
|
// Error occurred while checking file existence
|
|
return fmt.Errorf("could not fetch %q: local file does not exist (%v) and %q is not a recognized downloadable URL (supported schemes: %s)", filePath, err, url, strings.Join([]string{HTTPPrefix, HTTPSPrefix, LocalPrefix, HuggingFacePrefix, HuggingFacePrefix1, OllamaPrefix, OCIPrefix, OCIFilePrefix, GithubURI2}, ", "))
|
|
}
|
|
|
|
xlog.Info("Downloading", "url", url)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request for %q: %v", filePath, err)
|
|
}
|
|
|
|
// save partial download to dedicated file
|
|
tmpFilePath := filePath + ".partial"
|
|
tmpFileInfo, err := os.Stat(tmpFilePath)
|
|
if err == nil && uri.LooksLikeHTTPURL() {
|
|
support, err := uri.checkSeverSupportsRangeHeader()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if uri server supports range header: %v", err)
|
|
}
|
|
if support {
|
|
startPos := tmpFileInfo.Size()
|
|
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", startPos))
|
|
} else {
|
|
err := removePartialFile(tmpFilePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("failed to check file %q existence: %v", filePath, err)
|
|
}
|
|
|
|
var source io.ReadCloser
|
|
var contentLength int64
|
|
if _, e := os.Stat(uri.ResolveURL()); strings.HasPrefix(string(uri), LocalPrefix) || e == nil {
|
|
file, err := os.Open(uri.ResolveURL())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open file %q: %v", uri.ResolveURL(), err)
|
|
}
|
|
l, err := file.Stat()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get file size %q: %v", uri.ResolveURL(), err)
|
|
}
|
|
source = file
|
|
contentLength = l.Size()
|
|
} else {
|
|
// Start the request
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
// Check if error is due to context cancellation
|
|
if errors.Is(err, context.Canceled) {
|
|
// Clean up partial file on cancellation
|
|
removePartialFile(tmpFilePath)
|
|
return err
|
|
}
|
|
return fmt.Errorf("failed to download file %q: %v", filePath, err)
|
|
}
|
|
//defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return fmt.Errorf("failed to download url %q, invalid status code %d", url, resp.StatusCode)
|
|
}
|
|
source = resp.Body
|
|
contentLength = resp.ContentLength
|
|
}
|
|
defer source.Close()
|
|
|
|
// Create parent directory
|
|
err = os.MkdirAll(filepath.Dir(filePath), 0750)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create parent directory for file %q: %v", filePath, err)
|
|
}
|
|
|
|
// Create and write file
|
|
outFile, err := os.OpenFile(tmpFilePath, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create / open file %q: %v", tmpFilePath, err)
|
|
}
|
|
defer outFile.Close()
|
|
hash, err := calculateHashForPartialFile(outFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to calculate hash for partial file")
|
|
}
|
|
progress := &progressWriter{
|
|
fileName: tmpFilePath,
|
|
total: contentLength,
|
|
hash: hash,
|
|
fileNo: fileN,
|
|
totalFiles: total,
|
|
downloadStatus: downloadStatus,
|
|
ctx: ctx,
|
|
}
|
|
|
|
_, err = xio.Copy(ctx, io.MultiWriter(outFile, progress), source)
|
|
if err != nil {
|
|
// Check if error is due to context cancellation
|
|
if errors.Is(err, context.Canceled) {
|
|
// Clean up partial file on cancellation
|
|
removePartialFile(tmpFilePath)
|
|
return err
|
|
}
|
|
return fmt.Errorf("failed to write file %q: %v", filePath, err)
|
|
}
|
|
|
|
// Check for cancellation before finalizing
|
|
select {
|
|
case <-ctx.Done():
|
|
removePartialFile(tmpFilePath)
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Invariant: verify the streamed hash before promoting the temp file to
|
|
// the final path. Renaming first would leave tampered content reachable
|
|
// to subsequent readers even though we return an error.
|
|
if sha != "" {
|
|
calculatedSHA := fmt.Sprintf("%x", progress.hash.Sum(nil))
|
|
if calculatedSHA != sha {
|
|
xlog.Debug("SHA mismatch for file", "file", filePath, "calculated", calculatedSHA, "metadata", sha)
|
|
_ = removePartialFile(tmpFilePath)
|
|
return fmt.Errorf("SHA mismatch for file %q ( calculated: %s != metadata: %s )", filePath, calculatedSHA, sha)
|
|
}
|
|
} else {
|
|
// Visible at the default log level so missing-digest configs are
|
|
// noticed; silent acceptance was the historical bug.
|
|
xlog.Warn("downloading without integrity check — supplied SHA is empty",
|
|
"file", filePath,
|
|
"url", url,
|
|
)
|
|
}
|
|
|
|
err = os.Rename(tmpFilePath, filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to rename temporary file %s -> %s: %v", tmpFilePath, filePath, err)
|
|
}
|
|
|
|
xlog.Info("File downloaded and verified", "file", filePath)
|
|
if utils.IsArchive(filePath) {
|
|
basePath := filepath.Dir(filePath)
|
|
xlog.Info("File is an archive, uncompressing", "file", filePath, "basePath", basePath)
|
|
if err := utils.ExtractArchive(filePath, basePath); err != nil {
|
|
xlog.Debug("Failed decompressing", "file", filePath, "error", err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func formatBytes(bytes int64) string {
|
|
const unit = 1024
|
|
if bytes < unit {
|
|
return strconv.FormatInt(bytes, 10) + " B"
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
func CalculateSHA(filePath string) (string, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer file.Close()
|
|
|
|
hash := sha256.New()
|
|
if _, err := io.Copy(hash, file); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
|
}
|