[feat](maestro): publish an operator-authored app catalog
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.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
)
|
||||
|
||||
// Download serves GET /api/catalog/artifacts/{id}/{filename} — the public,
|
||||
// session-free artifact surface. Hosted entries stream exact bytes with their
|
||||
// integrity headers; link entries 302 to the operator's URL. Unknown entries,
|
||||
// a mismatched filename and DCNTRLIZED_PUBLIC_PROFILE=false all answer 404.
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
if !env.PublicProfile() {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "catalog is not public")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
filename := r.PathValue("filename")
|
||||
|
||||
app, err := h.store.Get(id)
|
||||
if err != nil {
|
||||
logger.Error("catalog: get %s: %v", id, err)
|
||||
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
|
||||
return
|
||||
}
|
||||
if app == nil || app.Artifact.Filename == "" || catalog.SafeFilename(filename) != app.Artifact.Filename {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
|
||||
return
|
||||
}
|
||||
|
||||
if app.Artifact.Kind == catalog.KindLink {
|
||||
if app.Artifact.SourceURL == "" {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
http.Redirect(w, r, app.Artifact.SourceURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := h.store.ResolveArtifact(*app)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
|
||||
return
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
|
||||
return
|
||||
}
|
||||
|
||||
contentType := app.Artifact.ContentType
|
||||
if contentType == "" {
|
||||
contentType = catalog.DefaultArtifactContentType
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
|
||||
w.Header().Set("X-Content-SHA256", app.Artifact.SHA256)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := io.Copy(w, file); err != nil {
|
||||
logger.Warn("catalog: stream artifact %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// IconBytes serves GET /api/catalog/icons/{id} — uploaded icon bytes, or 404
|
||||
// when there is no uploaded icon / the catalog is private.
|
||||
func (h *Handler) IconBytes(w http.ResponseWriter, r *http.Request) {
|
||||
if !env.PublicProfile() {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "catalog is not public")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
app, err := h.store.Get(id)
|
||||
if err != nil {
|
||||
logger.Error("catalog: get %s: %v", id, err)
|
||||
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
|
||||
return
|
||||
}
|
||||
if app == nil || app.IconPath == "" {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
|
||||
return
|
||||
}
|
||||
|
||||
path, err := h.store.ResolveIcon(*app)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
|
||||
return
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
|
||||
return
|
||||
}
|
||||
|
||||
head := make([]byte, 512)
|
||||
n, _ := file.Read(head)
|
||||
contentType := http.DetectContentType(head[:n])
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
// Rewind: Read advanced the file offset.
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read icon")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := io.Copy(w, file); err != nil {
|
||||
logger.Warn("catalog: stream icon %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user