// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // Package catalog owns the instance's operator-authored app distribution: the // app records the owner publishes (Android artifacts Maestro hosts or links), // their on-disk bytes, and the public projection phones download from. It is // deliberately independent of services and the container registry (app-catalog // decisions A1/A2): nothing here is derived from installed containers. package catalog import ( "crypto/sha256" "encoding/hex" "net/http" "net/url" "path" "strings" "time" ) // Artifact kinds (A4). const ( KindHosted = "hosted" KindLink = "link" ) // DefaultArtifactContentType is Android's APK MIME type. const DefaultArtifactContentType = "application/vnd.android.package-archive" // Artifact is the artifact sub-record of an app. DownloadURL is computed per // request (absolute, points at this instance's public artifact endpoint); // SourceURL is the operator-supplied link target for kind=link and stays // internal (`json:"-"` — A7). type Artifact struct { Filename string `json:"filename"` SizeBytes int64 `json:"size_bytes"` SHA256 string `json:"sha256"` ContentType string `json:"content_type,omitempty"` Kind string `json:"kind"` DownloadURL string `json:"download_url,omitempty"` SignerSHA256 string `json:"signer_sha256,omitempty"` SourceURL string `json:"-"` } // App is one catalog record (wire shape, snake_case). AddedBy is the owner // username for the owner view and is blanked on the public projection; // ArtifactPath/IconPath are the local filesystem locations of the hosted bytes // and never serialize (A7). type App struct { ID string `json:"id"` Package string `json:"package"` Name string `json:"name"` Summary string `json:"summary,omitempty"` Description string `json:"description,omitempty"` Category string `json:"category,omitempty"` VersionName string `json:"version_name"` VersionCode int `json:"version_code,omitempty"` MinAndroid int `json:"min_android,omitempty"` IconURL string `json:"icon_url,omitempty"` Artifact Artifact `json:"artifact"` SourceURL string `json:"source_url,omitempty"` AddedAt string `json:"added_at"` UpdatedAt string `json:"updated_at"` AddedBy string `json:"added_by,omitempty"` ArtifactPath string `json:"-"` IconPath string `json:"-"` } // AppID derives the stable id for a package: "a_" + first 12 hex chars of // sha256(package). Stable across edits so a client refresh never renames an app. func AppID(pkg string) string { sum := sha256.Sum256([]byte(pkg)) return "a_" + hex.EncodeToString(sum[:])[:12] } // NowRFC3339 stamps records in UTC RFC3339, the store's timestamp format. func NowRFC3339() string { return time.Now().UTC().Format(time.RFC3339) } // Public returns the public projection of a record: identical metadata minus // the private owner field (A7). Callers still apply WithBase for the absolute // URLs. func (a App) Public() App { a.AddedBy = "" return a } // WithBase returns a copy whose icon/download URLs are absolute for the given // request base (e.g. "https://instance.example"). A hosted icon wins over an // operator-supplied external icon_url; a link artifact still advertises this // instance's redirect endpoint as its download URL. func (a App) WithBase(base string) App { out := a base = strings.TrimRight(base, "/") if a.IconPath != "" { out.IconURL = base + "/api/catalog/icons/" + a.ID } if a.Artifact.Filename != "" { out.Artifact.DownloadURL = base + "/api/catalog/artifacts/" + a.ID + "/" + url.PathEscape(a.Artifact.Filename) } return out } // RequestBaseURL derives the instance's absolute base URL from the request, // honoring a reverse proxy's X-Forwarded-Proto / X-Forwarded-Host (falling // back to the direct TLS state and Host header). One helper so every catalog // surface builds URLs identically. func RequestBaseURL(r *http.Request) string { scheme := "http" if r.TLS != nil { scheme = "https" } if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { first := strings.ToLower(strings.TrimSpace(strings.Split(proto, ",")[0])) if first == "http" || first == "https" { scheme = first } } host := r.Header.Get("X-Forwarded-Host") if host == "" { host = r.Host } else { host = strings.TrimSpace(strings.Split(host, ",")[0]) } if host == "" { return scheme + "://localhost" } return scheme + "://" + host } // SafeFilename reduces an untrusted filename (URL path or multipart header) to // a single clean path segment, or "" when nothing usable remains. It blocks // traversal before any file is written. func SafeFilename(name string) string { n := strings.TrimSpace(name) n = strings.ReplaceAll(n, "\\", "/") n = path.Base(n) if n == "" || n == "." || n == ".." || n == "/" { return "" } return n } // FilenameFromURL extracts a usable filename from an artifact URL path. func FilenameFromURL(raw string) string { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return "" } return SafeFilename(u.Path) }