An instance distributes installable Android apps independently of the services it runs. Add <dataRoot>/catalog/apps.json (owner-authored, atomic, 0600) with hosted-or-linked artifacts, SHA-256 and optional icon; expose the owner-gated add/edit/delete/replace API plus session-free artifact and icon downloads, and add the apps section to the public profile. The catalog is deliberately independent of services and the registry, and registry listings gain the declared service identity and a validated source href so cards can be paired correctly.
166 lines
5.6 KiB
Go
166 lines
5.6 KiB
Go
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
// 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)
|
|
}
|