[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:
2026-10-19 02:40:05 +01:00
parent 13f6d14521
commit 786b7a3b5f
18 changed files with 4307 additions and 94 deletions
File diff suppressed because it is too large Load Diff
@@ -15,18 +15,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Settings, ShieldCheck, X } from 'lucide-react' import { Settings, Smartphone, X } from 'lucide-react'
import { Dialog, Modal, ModalOverlay } from 'react-aria-components' import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
import { Button } from '@components/ui/button' import { Button } from '@components/ui/button'
import { AccountSection } from '@components/settings/account-section' import { AccountSection } from '@components/settings/account-section'
import { TrustedServicesSection } from '@components/service/trusted-services' import { AppCatalogSection } from '@components/settings/app-catalog-section'
import type { TrustLoadState } from '@lib/hooks/useTrustStore'
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
trust: TrustLoadState
onRefreshTrust: () => void
username: string username: string
onSignOut: () => void onSignOut: () => void
} }
@@ -34,8 +31,6 @@ interface SettingsDialogProps {
export function SettingsDialog({ export function SettingsDialog({
open, open,
onOpenChange, onOpenChange,
trust,
onRefreshTrust,
username, username,
onSignOut, onSignOut,
}: SettingsDialogProps) { }: SettingsDialogProps) {
@@ -58,7 +53,7 @@ export function SettingsDialog({
Settings Settings
</h2> </h2>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground"> <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Manage how this node trusts the services on its fleet. This node’s owner account, the servers it trusts, and the apps it publishes.
</p> </p>
</div> </div>
<Button <Button
@@ -75,22 +70,25 @@ export function SettingsDialog({
<div className="grid min-w-0 gap-6 px-5 py-5"> <div className="grid min-w-0 gap-6 px-5 py-5">
<AccountSection username={username} onSignOut={onSignOut} /> <AccountSection username={username} onSignOut={onSignOut} />
<section data-slot="settings-section-trusted" aria-labelledby="settings-trusted-title" className="min-w-0"> <section
data-slot="settings-section-app-catalog"
aria-labelledby="settings-app-catalog-title"
className="min-w-0"
>
<h3 <h3
id="settings-trusted-title" id="settings-app-catalog-title"
className="flex items-center gap-2 text-sm font-semibold tracking-tight text-foreground" className="flex items-center gap-2 text-sm font-semibold tracking-tight text-foreground"
> >
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" /> <Smartphone className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
Trusted services App catalog
</h3> </h3>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground"> <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Services asking to talk to the auth service. Accept authorizes a fingerprint (SSH Apps this instance publishes for people to install on their phones. Clients
authorized_keys, applied to the service fleet), reject drops a request without discover them here; each entry carries the artifact’s checksum so installs can be
authorizing it, revoke cuts off an accepted peer immediately, re-authorize restores verified.
one.
</p> </p>
<div className="mt-4 min-w-0"> <div className="mt-4 min-w-0">
<TrustedServicesSection load={trust} onRefresh={onRefreshTrust} /> <AppCatalogSection />
</div> </div>
</section> </section>
</div> </div>
+162
View File
@@ -0,0 +1,162 @@
/**
* 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/>.
*/
import { ApiError, del, get, post, put } from './client'
import type { CatalogApp, CatalogAppInput, CatalogAppPatch } from '@lib/types'
const BASE = '/api/catalog/apps'
export interface UploadOptions {
/** Byte-level progress, 0–100, reported by the browser's XHR upload stream. */
onProgress?: (percent: number) => void
signal?: AbortSignal
}
function readErrorCode(text: string): string | undefined {
try {
const body = JSON.parse(text) as { error?: { code?: unknown } }
return typeof body?.error?.code === 'string' ? body.error.code : undefined
} catch {
return undefined
}
}
function jsonField(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value)
}
/**
* Multipart over XMLHttpRequest instead of fetch: only XHR exposes the upload
* progress stream, and a 512 MiB artifact must never look frozen. Non-file
* metadata rides as flat snake_case form fields so the same handler can serve
* both the URL and the upload add paths. The uniform error envelope is decoded
* exactly the way the fetch client does it.
*/
function multipart<T>(
method: 'POST' | 'PUT',
path: string,
parts: object,
fileField: string,
file: File | null,
options: UploadOptions = {}
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open(method, path)
xhr.withCredentials = true
xhr.responseType = 'text'
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && options.onProgress) {
options.onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)))
}
}
xhr.onload = () => {
const text = typeof xhr.response === 'string' ? xhr.response : ''
if (xhr.status >= 200 && xhr.status < 300) {
options.onProgress?.(100)
try {
resolve((text ? JSON.parse(text) : undefined) as T)
} catch {
resolve(undefined as T)
}
return
}
reject(new ApiError(`Request failed (${xhr.status})`, xhr.status, readErrorCode(text)))
}
xhr.onerror = () => reject(new ApiError('Could not reach this node’s API.', 0))
xhr.onabort = () => reject(new ApiError('Upload cancelled.', 0))
options.signal?.addEventListener('abort', () => xhr.abort(), { once: true })
const form = new FormData()
for (const [key, value] of Object.entries(parts)) {
if (value === undefined || value === null || value === '') continue
form.append(key, jsonField(value))
}
if (file) {
form.append(fileField, file, file.name)
// The record-specific field name and the generic `file` alias both
// travel, so the handler can read either without a client-side guess.
if (fileField !== 'file') form.append('file', file, file.name)
}
xhr.send(form)
})
}
/** GET /api/catalog/apps — an empty catalog serializes as `null`. */
export function fetchCatalogApps(): Promise<CatalogApp[] | null> {
return get<CatalogApp[] | null>(BASE)
}
/** POST /api/catalog/apps — add from an artifact URL (hosted fetch or link-only). */
export function addCatalogApp(input: CatalogAppInput): Promise<CatalogApp> {
return post<CatalogApp>(BASE, input)
}
/**
* POST /api/catalog/apps as multipart — add with the APK supplied directly.
* The record metadata travels as snake_case fields beside the artifact bytes.
*/
export function addCatalogAppWithFile(
input: CatalogAppInput,
file: File,
options?: UploadOptions
): Promise<CatalogApp> {
return multipart<CatalogApp>('POST', BASE, input, 'artifact', file, options)
}
/** PUT /api/catalog/apps/{id} — edit metadata; `package` is immutable. */
export function updateCatalogApp(id: string, patch: CatalogAppPatch): Promise<CatalogApp> {
return put<CatalogApp>(`${BASE}/${encodeURIComponent(id)}`, patch)
}
/** DELETE /api/catalog/apps/{id} — removes the entry and any hosted bytes. */
export function deleteCatalogApp(id: string): Promise<void> {
return del<void>(`${BASE}/${encodeURIComponent(id)}`)
}
/** PUT /api/catalog/apps/{id}/artifact — replace the APK from a local file. */
export function uploadCatalogArtifact(
id: string,
file: File,
options?: UploadOptions
): Promise<CatalogApp> {
return multipart<CatalogApp>(
'PUT',
`${BASE}/${encodeURIComponent(id)}/artifact`,
{},
'artifact',
file,
options
)
}
/** PUT /api/catalog/apps/{id}/icon — replace the icon from a local image. */
export function uploadCatalogIcon(
id: string,
file: File,
options?: UploadOptions
): Promise<CatalogApp> {
return multipart<CatalogApp>(
'PUT',
`${BASE}/${encodeURIComponent(id)}/icon`,
{},
'icon',
file,
options
)
}
+15 -2
View File
@@ -17,11 +17,24 @@
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number readonly status: number
/** The uniform `{"error":{"code":…}}` code, when the body carried one. */
readonly code?: string
constructor(message: string, status: number) { constructor(message: string, status: number, code?: string) {
super(message) super(message)
this.name = 'ApiError' this.name = 'ApiError'
this.status = status this.status = status
this.code = code
}
}
/** Best-effort read of the uniform error envelope; never masks the HTTP status. */
async function readErrorCode(res: Response): Promise<string | undefined> {
try {
const body = (await res.json()) as { error?: { code?: unknown } }
return typeof body?.error?.code === 'string' ? body.error.code : undefined
} catch {
return undefined
} }
} }
@@ -67,7 +80,7 @@ async function request<T>(path: string, init?: RequestInit, retried = false): Pr
} }
} }
if (!res.ok) { if (!res.ok) {
throw new ApiError(`Request failed (${res.status})`, res.status) throw new ApiError(`Request failed (${res.status})`, res.status, await readErrorCode(res))
} }
return readJson<T>(res) return readJson<T>(res)
} }
@@ -0,0 +1,190 @@
/**
* 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/>.
*/
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import {
addCatalogApp,
addCatalogAppWithFile,
deleteCatalogApp,
fetchCatalogApps,
updateCatalogApp,
uploadCatalogArtifact,
uploadCatalogIcon,
} from '@lib/api/catalog'
import type { CatalogApp, CatalogAppInput, CatalogAppPatch } from '@lib/types'
export type CatalogLoadState =
| { kind: 'loading' }
| { kind: 'ok'; apps: CatalogApp[] }
| { kind: 'error'; message: string }
/** In-flight transfer for one row, so a 512 MiB artifact never looks frozen. */
export interface CatalogUpload {
percent: number
label: string
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error'
}
/** The catalog serializes empty as `null`; artifacts may omit optional fields. */
function normalize(rows: CatalogApp[] | null | undefined): CatalogApp[] {
if (!Array.isArray(rows)) return []
return rows
}
/**
* Owns the operator's app catalog: the single-flight listing plus add, edit,
* delete and the two byte transfers. Mutations refetch from the server so the
* console never drifts from what clients can actually install; delete is
* applied optimistically first because the row disappears in the same turn.
* Uploads report byte progress per app id and can be cancelled, and the row
* stays interactive while the transfer runs.
*/
export function useCatalogApps() {
const [state, setState] = useState<CatalogLoadState>({ kind: 'loading' })
const [uploads, setUploads] = useState<Record<string, CatalogUpload>>({})
const inFlight = useRef<Promise<void> | null>(null)
const aborts = useRef<Record<string, AbortController>>({})
const load = useCallback((silent: boolean): Promise<void> => {
// Single-flight: concurrent open/refresh/mutation calls share one request.
if (inFlight.current) return inFlight.current
const run = (async () => {
try {
const apps = normalize(await fetchCatalogApps())
setState({ kind: 'ok', apps })
} catch (error) {
setState((prev) =>
silent && prev.kind === 'ok' ? prev : { kind: 'error', message: errorMessage(error) }
)
} finally {
inFlight.current = null
}
})()
inFlight.current = run
return run
}, [])
useEffect(() => {
void load(false)
}, [load])
/** Quiet refetch — replaces live rows without flashing a loading state. */
const refresh = useCallback(() => load(true), [load])
/** Loud refetch — returns to the skeleton/error surface. */
const reload = useCallback(() => load(false), [load])
const setUpload = useCallback((id: string, upload: CatalogUpload) => {
setUploads((prev) => ({ ...prev, [id]: upload }))
}, [])
const clearUpload = useCallback((id: string) => {
setUploads((prev) => {
if (!(id in prev)) return prev
const next = { ...prev }
delete next[id]
return next
})
}, [])
const mutate = useCallback(
async (action: () => Promise<unknown>) => {
await action()
await load(true)
},
[load]
)
const add = useCallback(
(input: CatalogAppInput, file?: File | null, onProgress?: (percent: number) => void) =>
mutate(() =>
file
? addCatalogAppWithFile(input, file, { onProgress })
: addCatalogApp(input)
),
[mutate]
)
const update = useCallback(
(id: string, patch: CatalogAppPatch) => mutate(() => updateCatalogApp(id, patch)),
[mutate]
)
const remove = useCallback(
async (id: string) => {
setState((prev) =>
prev.kind === 'ok' ? { kind: 'ok', apps: prev.apps.filter((app) => app.id !== id) } : prev
)
try {
await deleteCatalogApp(id)
} catch (error) {
await load(true)
throw error
}
await load(true)
},
[load]
)
const transfer = useCallback(
async (id: string, kind: 'artifact' | 'icon', file: File) => {
const controller = new AbortController()
aborts.current[id] = controller
const label = kind === 'icon' ? 'Uploading icon' : 'Uploading artifact'
setUpload(id, { percent: 0, label })
try {
const send = kind === 'icon' ? uploadCatalogIcon : uploadCatalogArtifact
await send(id, file, {
signal: controller.signal,
onProgress: (percent) => setUpload(id, { percent, label }),
})
await load(true)
} finally {
delete aborts.current[id]
clearUpload(id)
}
},
[clearUpload, load, setUpload]
)
const uploadArtifact = useCallback(
(id: string, file: File) => transfer(id, 'artifact', file),
[transfer]
)
const uploadIcon = useCallback((id: string, file: File) => transfer(id, 'icon', file), [transfer])
const cancelUpload = useCallback((id: string) => {
aborts.current[id]?.abort()
}, [])
return {
state,
uploads,
refresh,
reload,
add,
update,
remove,
uploadArtifact,
uploadIcon,
cancelUpload,
}
}
export type CatalogController = ReturnType<typeof useCatalogApps>
+116 -33
View File
@@ -42,6 +42,11 @@ export interface Container {
role?: string role?: string
/** Infrastructure services managed by Maestro but hidden from the grid. */ /** Infrastructure services managed by Maestro but hidden from the grid. */
hidden?: boolean hidden?: boolean
/**
* Started outside Maestro (e.g. a repo's own docker-compose.debug.yaml).
* The console may stop it but must not start, uninstall or reconfigure it.
*/
external?: boolean
/** Capabilities declared by the service manifest (e.g. ["database"]). */ /** Capabilities declared by the service manifest (e.g. ["database"]). */
capabilities?: string[] capabilities?: string[]
/** Whether the service consumes the shared auth service (manifest has_auth). */ /** Whether the service consumes the shared auth service (manifest has_auth). */
@@ -50,6 +55,25 @@ export interface Container {
public_key?: string public_key?: string
/** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */ /** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */
config?: ServiceConfigDeclaration[] config?: ServiceConfigDeclaration[]
/**
* The service's Web App capability (contract rev 8.2 §16.2). Present only
* while the service advertises a usable web app; `href` is resolved
* SERVER-SIDE and is the only thing the console may open. The frontend never
* builds a URL from service strings.
*/
web?: ServiceWeb
}
/**
* The `web` block carried on `GET /api/containers` (contract §16.3/§16.4).
* `href` is always server-resolved; an entry with no valid href is omitted
* entirely rather than sent with an empty href.
*/
export interface ServiceWeb {
enabled: boolean
port?: number
url?: string
href: string
} }
/** A config field spec declared by a service manifest (Service Config & Secrets theme). */ /** A config field spec declared by a service manifest (Service Config & Secrets theme). */
@@ -82,6 +106,8 @@ export interface ServiceConfigEntry {
/** A remote catalog entry, as served by GET /api/registry. */ /** A remote catalog entry, as served by GET /api/registry. */
export interface RegistryItem { export interface RegistryItem {
name: string name: string
/** The service identity declared by the image's org.dcntrlized.service label. */
service_name?: string
version: string version: string
image: string image: string
digest?: string digest?: string
@@ -90,6 +116,12 @@ export interface RegistryItem {
author?: string author?: string
description?: string description?: string
source?: string source?: string
/**
* The source URL to render as a hyperlink — server-validated against the
* http/https allowlist (`lib/types.ValidWebBase`), empty when `source` is not
* a safe link. Never derive this from `source` in the frontend (audit N7).
*/
source_href?: string
revision?: string revision?: string
licenses?: string licenses?: string
url?: string url?: string
@@ -97,6 +129,8 @@ export interface RegistryItem {
created_at?: string created_at?: string
/** Infrastructure images (e.g. the shared database) excluded from the grid. */ /** Infrastructure images (e.g. the shared database) excluded from the grid. */
hidden?: boolean hidden?: boolean
/** The Web App capability announced by the image manifest, when known. */
web?: ServiceWeb
} }
/** Merged view model: one card per service, local state and remote catalog fused. */ /** Merged view model: one card per service, local state and remote catalog fused. */
@@ -106,6 +140,10 @@ export interface Service {
status: string status: string
origin: ContainerOrigin origin: ContainerOrigin
title?: string title?: string
/** The service identity from the dcntrlized service label (registry matching). */
serviceName?: string
/** True when the container was started outside Maestro (see Container.external). */
external?: boolean
vendor?: string vendor?: string
author?: string author?: string
description?: string description?: string
@@ -113,6 +151,8 @@ export interface Service {
/** The image reference published in the registry (for install/update). */ /** The image reference published in the registry (for install/update). */
registryImage?: string registryImage?: string
source?: string source?: string
/** Server-validated href for `source`; empty means render `source` as text. */
sourceHref?: string
registryUrl?: string registryUrl?: string
version?: string version?: string
latestVersion?: string latestVersion?: string
@@ -121,6 +161,8 @@ export interface Service {
capabilities: string[] capabilities: string[]
/** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */ /** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */
config?: ServiceConfigDeclaration[] config?: ServiceConfigDeclaration[]
/** The service's Web App capability, when advertised (see Container.web). */
web?: ServiceWeb
} }
/** Live status of the shared database infra service. */ /** Live status of the shared database infra service. */
@@ -134,38 +176,6 @@ export interface FilterOption {
hint: string hint: string
} }
/** A first-contact authorization request waiting for the instance owner (security theme M2 §3). */
export interface PeerPending {
service: string
fingerprint: string
first_seen: string
last_seen: string
}
/** An approved peer row of the auth service's trust store. */
export interface PeerAuthorized {
service: string
fingerprint: string
approved_by: string
approved_at: string
revoked_at?: string | null
hidden_at?: string | null
}
/** Combined pending + approved trust state served by GET /api/peers. */
export interface PeerTrustState {
pending: PeerPending[]
authorized: PeerAuthorized[]
}
/** The auth service's public signing key statement (GET /api/peers/attestation). */
export interface PeerAttestation {
public_key: string
fingerprint: string
kid: string
issued_at: string
}
/** The instance-owner gate state served by GET /api/auth/status (onboarding M1). */ /** The instance-owner gate state served by GET /api/auth/status (onboarding M1). */
export interface AuthStatus { export interface AuthStatus {
setup_required: boolean setup_required: boolean
@@ -200,7 +210,11 @@ export interface PublicService {
image: string image: string
} }
/** A remote instance this instance trusts (federation store row). */ /**
* A remote instance this instance trusts, as surfaced on the public profile.
* Kept for wire compatibility: rev 9 retired federation, so the array is
* always empty.
*/
export interface TrustedInstance { export interface TrustedInstance {
name: string name: string
url: string url: string
@@ -215,3 +229,72 @@ export interface InstancePublicProfile {
services?: PublicService[] | null services?: PublicService[] | null
trusted_instances?: TrustedInstance[] | null trusted_instances?: TrustedInstance[] | null
} }
/**
* How this instance serves a catalog app's artifact (app catalog A4):
* `hosted` = Maestro keeps a copy and serves it; `link` = only the checksum is
* stored and downloads redirect to the operator-supplied URL.
*/
export type CatalogArtifactKind = 'hosted' | 'link'
/** The artifact half of a catalog app record (GET /api/catalog/apps). */
export interface CatalogArtifact {
filename: string
size_bytes: number
/** Lowercase hex sha256 of the exact bytes; clients verify what they install. */
sha256: string
content_type?: string
kind: CatalogArtifactKind
/** Absolute, session-free download URL served by this instance. */
download_url: string
/** sha256 of the APK signing certificate, when the operator supplied one. */
signer_sha256?: string
}
/**
* One operator-authored app in this instance's catalog (app catalog A1/A2).
* The catalog is independent of the services the instance runs; `package` is
* immutable after creation so installs are never orphaned.
*/
export interface CatalogApp {
/** `a_` + first 12 hex of sha256(package); stable across edits. */
id: string
package: string
name: string
summary?: string
description?: string
category?: string
version_name: string
version_code?: number
min_android?: number
icon_url?: string
artifact: CatalogArtifact
source_url?: string
added_at: string
updated_at?: string
/** Owner identity; present on the owner-gated list, never on the public profile. */
added_by?: string
}
/** The writable shape of a catalog app. Only package/name/version_name are required. */
export interface CatalogAppInput {
package: string
name: string
version_name: string
/** Fetched and copied at add time unless `link_only` is set. */
artifact_url?: string
/** Skip the fetch and store only the checksum; requires `sha256`. */
link_only?: boolean
sha256?: string
summary?: string
description?: string
category?: string
version_code?: number
min_android?: number
icon_url?: string
signer_sha256?: string
source_url?: string
}
/** PUT /api/catalog/apps/{id} — package is immutable, so it never appears here. */
export type CatalogAppPatch = Omit<CatalogAppInput, 'package' | 'artifact_url' | 'link_only'>
+749
View File
@@ -0,0 +1,749 @@
// 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 serves Maestro's operator-authored app catalog
// (app-catalog theme): the owner-gated add/edit/delete/replace API and the
// public, session-free artifact/icon download endpoints. The catalog is
// independent of services and the registry (A1/A2).
package catalog
import (
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
)
// ownerFallback stamps added_by when the owner gate did not furnish a username
// (standalone wiring/tests).
const ownerFallback = "owner"
// Handler owns catalog store access and the artifact fetch for the
// /api/catalog endpoints.
type Handler struct {
store *catalog.Store
dataRoot string
client *http.Client
}
// NewHandler builds the catalog handler over the store at dataRoot. The fetch
// client follows redirects (Go default) with a 60s timeout; the 512 MiB cap is
// enforced while the copy streams to disk.
func NewHandler(dataRoot string) *Handler {
return &Handler{
store: catalog.NewStore(dataRoot),
dataRoot: dataRoot,
client: &http.Client{Timeout: 60 * time.Second},
}
}
// Apps dispatches GET (list) and POST (add) on /api/catalog/apps.
func (h *Handler) Apps(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.list(w, r)
case http.MethodPost:
h.add(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "GET or POST is required")
}
}
// App dispatches PUT (edit) and DELETE on /api/catalog/apps/{id}.
func (h *Handler) App(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
h.edit(w, r)
case http.MethodDelete:
h.remove(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "PUT or DELETE is required")
}
}
// list serves GET /api/catalog/apps — every record with absolute URLs and the
// private added_by field. An empty catalog is [] (never null).
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
apps, err := h.store.List()
if err != nil {
logger.Error("catalog: list: %v", err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
return
}
base := catalog.RequestBaseURL(r)
out := make([]catalog.App, 0, len(apps))
for _, app := range apps {
out = append(out, app.WithBase(base))
}
writeJSON(w, http.StatusOK, out)
}
// addRequest is the POST /api/catalog/apps body.
type addRequest struct {
Package string `json:"package"`
Name string `json:"name"`
Summary string `json:"summary"`
Description string `json:"description"`
Category string `json:"category"`
VersionName string `json:"version_name"`
VersionCode int `json:"version_code"`
MinAndroid int `json:"min_android"`
IconURL string `json:"icon_url"`
SourceURL string `json:"source_url"`
ArtifactURL string `json:"artifact_url"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SHA256 string `json:"sha256"`
SignerSHA256 string `json:"signer_sha256"`
SizeBytes int64 `json:"size_bytes"`
LinkOnly bool `json:"link_only"`
}
// add serves POST /api/catalog/apps — fetch-and-host an artifact from
// artifact_url, host an uploaded file (multipart `file` part), or record a
// link, then answer 201. A checksum mismatch stores nothing.
func (h *Handler) add(w http.ResponseWriter, r *http.Request) {
req, upload, uploadHeader, err := decodeAddRequest(r)
if err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", err.Error())
return
}
if upload != nil {
defer upload.Close()
}
pkg := strings.TrimSpace(req.Package)
name := strings.TrimSpace(req.Name)
version := strings.TrimSpace(req.VersionName)
if pkg == "" || name == "" || version == "" {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "package, name and version_name are required")
return
}
if strings.ContainsAny(pkg, " \t\r\n") {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "package must not contain whitespace")
return
}
// The artifact source is either the uploaded multipart file or artifact_url.
artifactURL := ""
if upload == nil {
normalized, err := normalizeArtifactURL(req.ArtifactURL)
if err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact_url must be an absolute http(s) URL")
return
}
artifactURL = normalized
}
existing, err := h.store.GetByPackage(pkg)
if err != nil {
logger.Error("catalog: package lookup: %v", err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
return
}
if existing != nil {
writeError(w, http.StatusConflict, "ERR_APP_EXISTS", "an app with that package already exists")
return
}
id := catalog.AppID(pkg)
filename := ""
if uploadHeader != nil {
filename = catalog.SafeFilename(uploadHeader.Filename)
}
if filename == "" {
filename = catalog.SafeFilename(req.Filename)
}
if filename == "" {
filename = catalog.FilenameFromURL(artifactURL)
}
if filename == "" {
filename = pkg + ".apk"
}
artifact, artifactRel, err := h.buildArtifact(id, filename, artifactURL, &req, upload, uploadHeader)
if err != nil {
writeArtifactError(w, err)
return
}
by := r.Header.Get("X-Maestro-User")
if by == "" {
by = ownerFallback
}
now := catalog.NowRFC3339()
app := catalog.App{
ID: id,
Package: pkg,
Name: name,
Summary: strings.TrimSpace(req.Summary),
Description: strings.TrimSpace(req.Description),
Category: strings.TrimSpace(req.Category),
VersionName: version,
VersionCode: req.VersionCode,
MinAndroid: req.MinAndroid,
IconURL: strings.TrimSpace(req.IconURL),
Artifact: artifact,
SourceURL: strings.TrimSpace(req.SourceURL),
AddedAt: now,
UpdatedAt: now,
AddedBy: by,
ArtifactPath: artifactRel,
}
if err := h.store.Upsert(app); err != nil {
logger.Error("catalog: persist app: %v", err)
_ = h.store.DeleteFiles(app)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
return
}
writeJSON(w, http.StatusCreated, app.WithBase(catalog.RequestBaseURL(r)))
}
// artifactError carries the HTTP mapping for a buildArtifact failure.
type artifactError struct {
status int
code string
msg string
}
func (e *artifactError) Error() string { return e.msg }
// buildArtifact hosts an uploaded file, fetches+hosts the artifact URL, or
// records a link. It returns the artifact sub-record plus the data-root-relative
// path of the hosted copy ("" for link entries). Nothing is persisted on a
// checksum mismatch or fetch failure.
func (h *Handler) buildArtifact(id, filename, artifactURL string, req *addRequest, upload multipart.File, uploadHeader *multipart.FileHeader) (catalog.Artifact, string, error) {
supplied := strings.ToLower(strings.TrimSpace(req.SHA256))
if upload != nil {
rel, size, sum, err := h.store.SaveArtifact(id, filename, upload, catalog.MaxArtifactBytes)
if err != nil {
if errors.Is(err, catalog.ErrTooLarge) {
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact exceeds the 512 MiB limit"}
}
logger.Warn("catalog: store uploaded artifact: %v", err)
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "could not store the uploaded artifact"}
}
if supplied != "" && !strings.EqualFold(supplied, sum) {
// Nothing is stored on mismatch: drop the freshly written copy.
_ = os.RemoveAll(filepath.Join(catalog.Dir(h.dataRoot), "artifacts", id))
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_CHECKSUM_MISMATCH", "sha256 does not match the uploaded artifact"}
}
contentType := strings.TrimSpace(req.ContentType)
if contentType == "" && uploadHeader != nil {
contentType = strings.TrimSpace(uploadHeader.Header.Get("Content-Type"))
}
if contentType == "" || contentType == "application/octet-stream" {
contentType = detectFileContentType(h.store, rel)
}
if contentType == "" {
contentType = catalog.DefaultArtifactContentType
}
return catalog.Artifact{
Filename: filename,
SizeBytes: size,
SHA256: sum,
ContentType: contentType,
Kind: catalog.KindHosted,
}, rel, nil
}
if req.LinkOnly {
if supplied == "" {
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "link_only requires sha256"}
}
if !isHexSHA256(supplied) {
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "sha256 must be 64 hex characters"}
}
return catalog.Artifact{
Filename: filename,
SizeBytes: req.SizeBytes,
SHA256: supplied,
ContentType: firstNonEmpty(strings.TrimSpace(req.ContentType), catalog.DefaultArtifactContentType),
Kind: catalog.KindLink,
SourceURL: artifactURL,
}, "", nil
}
resp, err := h.client.Get(artifactURL)
if err != nil {
logger.Warn("catalog: fetch %s: %v", artifactURL, err)
return catalog.Artifact{}, "", &artifactError{http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "could not fetch artifact_url"}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
logger.Warn("catalog: fetch %s: status %d", artifactURL, resp.StatusCode)
return catalog.Artifact{}, "", &artifactError{http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "artifact_url returned a non-success status"}
}
rel, size, sum, err := h.store.SaveArtifact(id, filename, resp.Body, catalog.MaxArtifactBytes)
if err != nil {
if errors.Is(err, catalog.ErrTooLarge) {
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact exceeds the 512 MiB limit"}
}
logger.Warn("catalog: store artifact: %v", err)
return catalog.Artifact{}, "", &artifactError{http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "could not store the fetched artifact"}
}
if supplied != "" && !strings.EqualFold(supplied, sum) {
// Nothing is stored on mismatch: drop the freshly written copy.
_ = os.RemoveAll(filepath.Join(catalog.Dir(h.dataRoot), "artifacts", id))
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_CHECKSUM_MISMATCH", "sha256 does not match the fetched artifact"}
}
contentType := strings.TrimSpace(req.ContentType)
if contentType == "" {
contentType = strings.TrimSpace(resp.Header.Get("Content-Type"))
}
if contentType == "" {
contentType = catalog.DefaultArtifactContentType
}
return catalog.Artifact{
Filename: filename,
SizeBytes: size,
SHA256: sum,
ContentType: contentType,
Kind: catalog.KindHosted,
}, rel, nil
}
// decodeAddRequest reads POST /api/catalog/apps from either application/json or
// multipart/form-data (flat snake_case fields + an optional file part named
// "file"), so the owner UI can publish a new app from a local file in one step.
func decodeAddRequest(r *http.Request) (addRequest, multipart.File, *multipart.FileHeader, error) {
if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
return addRequest{}, nil, nil, errors.New("invalid multipart body")
}
req := addRequest{
Package: formValue(r, "package"),
Name: formValue(r, "name"),
Summary: formValue(r, "summary"),
Description: formValue(r, "description"),
Category: formValue(r, "category"),
VersionName: formValue(r, "version_name"),
VersionCode: formInt(r, "version_code"),
MinAndroid: formInt(r, "min_android"),
IconURL: formValue(r, "icon_url"),
SourceURL: formValue(r, "source_url"),
ArtifactURL: formValue(r, "artifact_url"),
Filename: formValue(r, "filename"),
ContentType: formValue(r, "content_type"),
SHA256: formValue(r, "sha256"),
SignerSHA256: formValue(r, "signer_sha256"),
SizeBytes: formInt64(r, "size_bytes"),
LinkOnly: formBool(r, "link_only"),
}
file, header, err := r.FormFile("file")
if err != nil {
if errors.Is(err, http.ErrMissingFile) {
return req, nil, nil, nil
}
return addRequest{}, nil, nil, errors.New("could not read the file part")
}
return req, file, header, nil
}
var req addRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return addRequest{}, nil, nil, errors.New("invalid JSON body")
}
return req, nil, nil, nil
}
func formValue(r *http.Request, key string) string {
if r.MultipartForm == nil {
return ""
}
if values := r.MultipartForm.Value[key]; len(values) > 0 {
return values[0]
}
return ""
}
func formInt(r *http.Request, key string) int {
value := strings.TrimSpace(formValue(r, key))
if value == "" {
return 0
}
n, _ := strconv.Atoi(value)
return n
}
func formInt64(r *http.Request, key string) int64 {
value := strings.TrimSpace(formValue(r, key))
if value == "" {
return 0
}
n, _ := strconv.ParseInt(value, 10, 64)
return n
}
func formBool(r *http.Request, key string) bool {
switch strings.ToLower(strings.TrimSpace(formValue(r, key))) {
case "true", "1", "on", "yes":
return true
}
return false
}
// editRequest is the PUT /api/catalog/apps/{id} body. Pointers distinguish
// "absent" from "explicitly cleared".
// editRequest is the PUT /api/catalog/apps/{id} body. The editable metadata is
// a full replacement: present fields are applied and optional fields that are
// omitted/null/empty are cleared. id and package are immutable.
type editRequest struct {
Package string `json:"package"`
Name string `json:"name"`
Summary string `json:"summary"`
Description string `json:"description"`
Category string `json:"category"`
VersionName string `json:"version_name"`
VersionCode int `json:"version_code"`
MinAndroid int `json:"min_android"`
IconURL string `json:"icon_url"`
SourceURL string `json:"source_url"`
SignerSHA256 string `json:"signer_sha256"`
}
// edit serves PUT /api/catalog/apps/{id} — metadata edit. package is immutable.
func (h *Handler) edit(w http.ResponseWriter, r *http.Request) {
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 {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
return
}
var req editRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "invalid JSON body")
return
}
if req.Package != "" && strings.TrimSpace(req.Package) != app.Package {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "package is immutable")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "name must not be empty")
return
}
version := strings.TrimSpace(req.VersionName)
if version == "" {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "version_name must not be empty")
return
}
// Full replacement of the editable metadata: omitted optional fields
// decode to their zero value and therefore clear (README §Frozen request
// conventions).
updated := *app
updated.Name = name
updated.VersionName = version
updated.Summary = strings.TrimSpace(req.Summary)
updated.Description = strings.TrimSpace(req.Description)
updated.Category = strings.TrimSpace(req.Category)
updated.VersionCode = req.VersionCode
updated.MinAndroid = req.MinAndroid
updated.IconURL = strings.TrimSpace(req.IconURL)
updated.SourceURL = strings.TrimSpace(req.SourceURL)
updated.Artifact.SignerSHA256 = strings.TrimSpace(req.SignerSHA256)
updated.UpdatedAt = catalog.NowRFC3339()
if err := h.store.Upsert(updated); err != nil {
logger.Error("catalog: persist edit %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
return
}
writeJSON(w, http.StatusOK, updated.WithBase(catalog.RequestBaseURL(r)))
}
// remove serves DELETE /api/catalog/apps/{id} — drops the record, the hosted
// bytes and the uploaded icon.
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
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 {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
return
}
if err := h.store.Remove(id); err != nil {
logger.Error("catalog: remove %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
return
}
if err := h.store.DeleteFiles(*app); err != nil {
logger.Warn("catalog: delete files for %s: %v", id, err)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
}
// Artifact serves PUT /api/catalog/apps/{id}/artifact — replace hosted bytes
// from a multipart `file`, recomputing size + sha256.
func (h *Handler) Artifact(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "PUT is required")
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 {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "expected a multipart file upload")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "multipart field file is required")
return
}
defer file.Close()
filename := catalog.SafeFilename(header.Filename)
if filename == "" {
filename = catalog.SafeFilename(app.Artifact.Filename)
}
if filename == "" {
filename = app.Package + ".apk"
}
rel, size, sum, err := h.store.SaveArtifact(id, filename, file, catalog.MaxArtifactBytes)
if err != nil {
if errors.Is(err, catalog.ErrTooLarge) {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact exceeds the 512 MiB limit")
return
}
logger.Error("catalog: store uploaded artifact: %v", err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to store artifact")
return
}
updated := *app
updated.Artifact.Filename = filename
updated.Artifact.SizeBytes = size
updated.Artifact.SHA256 = sum
updated.Artifact.Kind = catalog.KindHosted
updated.Artifact.SourceURL = ""
updated.Artifact.ContentType = firstNonEmpty(
strings.TrimSpace(header.Header.Get("Content-Type")),
detectFileContentType(h.store, rel),
catalog.DefaultArtifactContentType,
)
updated.ArtifactPath = rel
updated.UpdatedAt = catalog.NowRFC3339()
if err := h.store.Upsert(updated); err != nil {
logger.Error("catalog: persist artifact %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
return
}
// Drop any previous copy that lived under a different filename.
if err := h.store.PruneArtifactDir(id, filename); err != nil {
logger.Warn("catalog: prune old artifact %s: %v", id, err)
}
writeJSON(w, http.StatusOK, updated.WithBase(catalog.RequestBaseURL(r)))
}
// Icon serves PUT /api/catalog/apps/{id}/icon — replace the uploaded icon
// (png/jpeg/webp, ≤512 KiB).
func (h *Handler) Icon(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "PUT is required")
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 {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
return
}
if err := r.ParseMultipartForm(2 << 20); err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "expected a multipart file upload")
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "multipart field file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, catalog.MaxIconBytes+1))
if err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "could not read icon upload")
return
}
if len(data) == 0 {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "icon file is empty")
return
}
if int64(len(data)) > catalog.MaxIconBytes {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "icon exceeds the 512 KiB limit")
return
}
contentType := http.DetectContentType(data)
if !allowedIconType(contentType) {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "icon must be png, jpeg or webp")
return
}
rel, err := h.store.SaveIcon(id, data)
if err != nil {
logger.Error("catalog: store icon %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to store icon")
return
}
updated := *app
updated.IconPath = rel
updated.UpdatedAt = catalog.NowRFC3339()
if err := h.store.Upsert(updated); err != nil {
logger.Error("catalog: persist icon %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
return
}
writeJSON(w, http.StatusOK, updated.WithBase(catalog.RequestBaseURL(r)))
}
// --- helpers ---
func normalizeArtifactURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("artifact_url is required")
}
u, err := url.Parse(trimmed)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("unsupported scheme %q", u.Scheme)
}
if u.Host == "" {
return "", errors.New("artifact_url must include a host")
}
return u.String(), nil
}
func isHexSHA256(s string) bool {
if len(s) != 64 {
return false
}
for _, r := range s {
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
return false
}
}
return true
}
func allowedIconType(contentType string) bool {
switch contentType {
case "image/png", "image/jpeg", "image/webp":
return true
}
return false
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
func detectFileContentType(store *catalog.Store, rel string) string {
abs, err := store.ResolveArtifact(catalog.App{ArtifactPath: rel})
if err != nil {
return ""
}
f, err := os.Open(abs)
if err != nil {
return ""
}
defer f.Close()
buf := make([]byte, 512)
n, _ := f.Read(buf)
return http.DetectContentType(buf[:n])
}
// writeArtifactError maps a buildArtifact failure onto the error envelope.
func writeArtifactError(w http.ResponseWriter, err error) {
var ae *artifactError
if errors.As(err, &ae) {
writeError(w, ae.status, ae.code, ae.msg)
return
}
writeError(w, http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "could not fetch artifact_url")
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message,omitempty"`
} `json:"error"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+515
View File
@@ -0,0 +1,515 @@
// 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 (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"os"
"path/filepath"
"strings"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
)
func newTestHandler(t *testing.T) (*Handler, string) {
t.Helper()
root := t.TempDir()
return NewHandler(root), root
}
func seedHostedApp(t *testing.T, h *Handler, pkg, content string) catalog.App {
t.Helper()
id := catalog.AppID(pkg)
rel, size, sum, err := h.store.SaveArtifact(id, "app.apk", strings.NewReader(content), catalog.MaxArtifactBytes)
if err != nil {
t.Fatalf("seed artifact: %v", err)
}
now := catalog.NowRFC3339()
app := catalog.App{
ID: id, Package: pkg, Name: "Seed", VersionName: "1.0",
AddedAt: now, UpdatedAt: now, AddedBy: "owner",
Artifact: catalog.Artifact{
Filename: "app.apk", SizeBytes: size, SHA256: sum,
Kind: catalog.KindHosted, ContentType: catalog.DefaultArtifactContentType,
},
ArtifactPath: rel,
}
if err := h.store.Upsert(app); err != nil {
t.Fatalf("seed app: %v", err)
}
return app
}
func doJSON(t *testing.T, h *Handler, method, target, body string, pathValues map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Maestro-User", "admin")
for k, v := range pathValues {
req.SetPathValue(k, v)
}
rec := httptest.NewRecorder()
switch {
case strings.HasPrefix(target, "/api/catalog/apps") && strings.HasSuffix(target, "/artifact"):
h.Artifact(rec, req)
case strings.HasPrefix(target, "/api/catalog/apps") && strings.HasSuffix(target, "/icon"):
h.Icon(rec, req)
case target == "/api/catalog/apps":
h.Apps(rec, req)
default:
h.App(rec, req)
}
return rec
}
func multipartBody(t *testing.T, field, filename, contentType string, data []byte) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
head := make(textproto.MIMEHeader)
head.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename))
head.Set("Content-Type", contentType)
part, err := mw.CreatePart(head)
if err != nil {
t.Fatalf("create part: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("write part: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
return &buf, mw.FormDataContentType()
}
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode error envelope %q: %v", rec.Body.String(), err)
}
return body.Error.Code
}
func TestAddHostedFromURL(t *testing.T) {
payload := []byte("APK-CONTENT")
sum := sha256.Sum256(payload)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
_, _ = w.Write(payload)
}))
t.Cleanup(srv.Close)
h, root := newTestHandler(t)
body := fmt.Sprintf(`{"package":"com.example.app","name":"Example","summary":"summary","version_name":"1.2.3","version_code":123,"min_android":26,"artifact_url":%q,"sha256":%q}`,
srv.URL+"/example-1.2.3.apk", hex.EncodeToString(sum[:]))
rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
var app catalog.App
if err := json.Unmarshal(rec.Body.Bytes(), &app); err != nil {
t.Fatalf("decode app: %v", err)
}
wantID := catalog.AppID("com.example.app")
if app.ID != wantID {
t.Fatalf("id = %q, want %q", app.ID, wantID)
}
if app.Artifact.Kind != catalog.KindHosted || app.Artifact.SizeBytes != int64(len(payload)) {
t.Fatalf("artifact = %#v", app.Artifact)
}
if app.Artifact.SHA256 != hex.EncodeToString(sum[:]) || app.Artifact.Filename != "example-1.2.3.apk" {
t.Fatalf("artifact = %#v", app.Artifact)
}
wantURL := "http://example.com/api/catalog/artifacts/" + wantID + "/example-1.2.3.apk"
if app.Artifact.DownloadURL != wantURL {
t.Fatalf("download_url = %q, want %q", app.Artifact.DownloadURL, wantURL)
}
if app.AddedBy != "admin" {
t.Fatalf("added_by = %q", app.AddedBy)
}
stored, err := os.ReadFile(filepath.Join(root, "catalog", "artifacts", wantID, "example-1.2.3.apk"))
if err != nil || !bytes.Equal(stored, payload) {
t.Fatalf("hosted bytes = %q, err=%v", stored, err)
}
}
func TestListIncludesAddedByAndAbsoluteURLs(t *testing.T) {
h, _ := newTestHandler(t)
seedHostedApp(t, h, "com.example.list", "bytes")
rec := doJSON(t, h, http.MethodGet, "/api/catalog/apps", "", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var apps []catalog.App
if err := json.Unmarshal(rec.Body.Bytes(), &apps); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(apps) != 1 {
t.Fatalf("apps = %#v", apps)
}
if apps[0].AddedBy != "owner" {
t.Fatalf("owner list must include added_by, got %q", apps[0].AddedBy)
}
if !strings.HasPrefix(apps[0].Artifact.DownloadURL, "http://example.com/api/catalog/artifacts/") {
t.Fatalf("download_url not absolute: %q", apps[0].Artifact.DownloadURL)
}
// Empty catalog is [] not null.
empty, _ := newTestHandler(t)
rec = doJSON(t, empty, http.MethodGet, "/api/catalog/apps", "", nil)
if strings.TrimSpace(rec.Body.String()) != "[]" {
t.Fatalf("empty catalog = %q, want []", rec.Body.String())
}
}
func TestAddChecksumMismatchStoresNothing(t *testing.T) {
payload := []byte("APK-CONTENT")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(payload)
}))
t.Cleanup(srv.Close)
h, root := newTestHandler(t)
body := fmt.Sprintf(`{"package":"com.example.bad","name":"Bad","version_name":"1.0","artifact_url":%q,"sha256":%q}`,
srv.URL+"/bad.apk", strings.Repeat("a", 64))
rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_CHECKSUM_MISMATCH" {
t.Fatalf("status = %d code = %s body %s", rec.Code, errorCode(t, rec), rec.Body.String())
}
apps, _ := h.store.List()
if len(apps) != 0 {
t.Fatalf("checksum mismatch stored a record: %#v", apps)
}
if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", catalog.AppID("com.example.bad"))); !os.IsNotExist(err) {
t.Fatalf("checksum mismatch left artifact bytes behind: %v", err)
}
}
func TestAddLinkOnly(t *testing.T) {
h, _ := newTestHandler(t)
link := "https://downloads.example.org/keepnote/app.apk"
body := fmt.Sprintf(`{"package":"com.example.link","name":"Linked","version_name":"2.0","artifact_url":%q,"link_only":true,"sha256":%q,"size_bytes":42}`,
link, strings.Repeat("b", 64))
rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
var app catalog.App
if err := json.Unmarshal(rec.Body.Bytes(), &app); err != nil {
t.Fatalf("decode: %v", err)
}
if app.Artifact.Kind != catalog.KindLink || app.Artifact.SHA256 != strings.Repeat("b", 64) || app.Artifact.SizeBytes != 42 {
t.Fatalf("artifact = %#v", app.Artifact)
}
// link_only without a sha256 is invalid.
bad := fmt.Sprintf(`{"package":"com.example.link2","name":"NoSum","version_name":"1.0","artifact_url":%q,"link_only":true}`, link)
rec = doJSON(t, h, http.MethodPost, "/api/catalog/apps", bad, nil)
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
t.Fatalf("missing sha256: status = %d code = %s", rec.Code, errorCode(t, rec))
}
}
func TestAddDuplicatePackage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("data"))
}))
t.Cleanup(srv.Close)
h, _ := newTestHandler(t)
body := fmt.Sprintf(`{"package":"com.example.dup","name":"Dup","version_name":"1.0","artifact_url":%q}`, srv.URL+"/d.apk")
if rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil); rec.Code != http.StatusCreated {
t.Fatalf("first add status = %d", rec.Code)
}
rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
if rec.Code != http.StatusConflict || errorCode(t, rec) != "ERR_APP_EXISTS" {
t.Fatalf("status = %d code = %s", rec.Code, errorCode(t, rec))
}
}
func TestAddInvalidPayloadAndFetchFailure(t *testing.T) {
h, _ := newTestHandler(t)
rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", `{}`, nil)
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
t.Fatalf("empty body: status = %d code = %s", rec.Code, errorCode(t, rec))
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
body := fmt.Sprintf(`{"package":"com.example.fetch","name":"Fetch","version_name":"1.0","artifact_url":%q}`, srv.URL+"/f.apk")
rec = doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
if rec.Code != http.StatusBadGateway || errorCode(t, rec) != "ERR_ARTIFACT_FETCH" {
t.Fatalf("fetch failure: status = %d code = %s", rec.Code, errorCode(t, rec))
}
apps, _ := h.store.List()
if len(apps) != 0 {
t.Fatalf("fetch failure stored a record: %#v", apps)
}
}
func TestEditMetadataFullReplacement(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.edit", "bytes")
if err := h.store.Upsert(func() catalog.App {
edited := app
edited.Summary = "old summary"
edited.Category = "old category"
edited.VersionCode = 9
edited.Artifact.SignerSHA256 = "signer"
return edited
}()); err != nil {
t.Fatalf("seed extra metadata: %v", err)
}
rec := doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID,
`{"name":"Edited","version_name":"1.0","summary":"new summary","category":"tools","version_code":12}`,
map[string]string{"id": app.ID})
if rec.Code != http.StatusOK {
t.Fatalf("edit status = %d body %s", rec.Code, rec.Body.String())
}
got, _ := h.store.Get(app.ID)
if got == nil || got.Name != "Edited" || got.Summary != "new summary" || got.Category != "tools" || got.VersionCode != 12 {
t.Fatalf("edit not persisted: %#v", got)
}
if got.Artifact.SHA256 != app.Artifact.SHA256 {
t.Fatalf("metadata edit must not touch the artifact: %#v", got.Artifact)
}
// Omitted optional fields are cleared (full replacement), but the hosted
// artifact and the immutable package survive.
rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID,
`{"name":"Edited","version_name":"1.0"}`, map[string]string{"id": app.ID})
if rec.Code != http.StatusOK {
t.Fatalf("clear status = %d body %s", rec.Code, rec.Body.String())
}
got, _ = h.store.Get(app.ID)
if got.Summary != "" || got.Category != "" || got.VersionCode != 0 || got.Artifact.SignerSHA256 != "" {
t.Fatalf("omitted optional metadata not cleared: %#v", got)
}
if got.Package != app.Package || got.Artifact.SHA256 != app.Artifact.SHA256 {
t.Fatalf("package/artifact must be untouched: %#v", got)
}
// A changed package is refused even with valid required fields.
rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID,
`{"package":"com.example.repackaged","name":"Edited","version_name":"1.0"}`, map[string]string{"id": app.ID})
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
t.Fatalf("immutable package: status = %d code = %s", rec.Code, errorCode(t, rec))
}
// name/version_name are required by the full replacement.
rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID, `{"summary":"only summary"}`, map[string]string{"id": app.ID})
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
t.Fatalf("missing name: status = %d code = %s", rec.Code, errorCode(t, rec))
}
rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/a_missing", `{"name":"x","version_name":"1"}`, map[string]string{"id": "a_missing"})
if rec.Code != http.StatusNotFound || errorCode(t, rec) != "ERR_NOT_FOUND" {
t.Fatalf("unknown edit: status = %d code = %s", rec.Code, errorCode(t, rec))
}
}
func TestDeleteRemovesRecordAndBytes(t *testing.T) {
h, root := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.del", "bytes")
if _, err := h.store.SaveIcon(app.ID, []byte("\x89PNG\r\n\x1a\nicon")); err != nil {
t.Fatalf("seed icon: %v", err)
}
stored, _ := h.store.Get(app.ID)
stored.IconPath = filepath.Join("icons", app.ID)
if err := h.store.Upsert(*stored); err != nil {
t.Fatalf("seed icon path: %v", err)
}
rec := doJSON(t, h, http.MethodDelete, "/api/catalog/apps/"+app.ID, "", map[string]string{"id": app.ID})
if rec.Code != http.StatusOK {
t.Fatalf("delete status = %d body %s", rec.Code, rec.Body.String())
}
if got, _ := h.store.Get(app.ID); got != nil {
t.Fatalf("record survived delete: %#v", got)
}
if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", app.ID)); !os.IsNotExist(err) {
t.Fatalf("hosted bytes survived delete: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "catalog", "icons", app.ID)); !os.IsNotExist(err) {
t.Fatalf("icon survived delete: %v", err)
}
rec = doJSON(t, h, http.MethodDelete, "/api/catalog/apps/"+app.ID, "", map[string]string{"id": app.ID})
if rec.Code != http.StatusNotFound {
t.Fatalf("second delete status = %d", rec.Code)
}
}
func TestReplaceArtifactUpload(t *testing.T) {
h, root := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.replace", "old-bytes")
payload := []byte("new-bytes-longer")
buf, contentType := multipartBody(t, "file", "new.apk", "application/vnd.android.package-archive", payload)
req := httptest.NewRequest(http.MethodPut, "/api/catalog/apps/"+app.ID+"/artifact", buf)
req.Header.Set("Content-Type", contentType)
req.SetPathValue("id", app.ID)
rec := httptest.NewRecorder()
h.Artifact(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
var updated catalog.App
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
t.Fatalf("decode: %v", err)
}
sum := sha256.Sum256(payload)
if updated.Artifact.Filename != "new.apk" || updated.Artifact.SizeBytes != int64(len(payload)) ||
updated.Artifact.SHA256 != hex.EncodeToString(sum[:]) || updated.Artifact.Kind != catalog.KindHosted {
t.Fatalf("artifact = %#v", updated.Artifact)
}
data, err := os.ReadFile(filepath.Join(root, "catalog", "artifacts", app.ID, "new.apk"))
if err != nil || !bytes.Equal(data, payload) {
t.Fatalf("uploaded bytes = %q err=%v", data, err)
}
if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", app.ID, "app.apk")); !os.IsNotExist(err) {
t.Fatalf("old artifact not pruned: %v", err)
}
}
func TestReplaceIconUpload(t *testing.T) {
h, root := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.icon", "bytes")
icon := append([]byte("\x89PNG\r\n\x1a\n"), []byte("png-body")...)
buf, contentType := multipartBody(t, "file", "icon.png", "image/png", icon)
req := httptest.NewRequest(http.MethodPut, "/api/catalog/apps/"+app.ID+"/icon", buf)
req.Header.Set("Content-Type", contentType)
req.SetPathValue("id", app.ID)
rec := httptest.NewRecorder()
h.Icon(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
got, _ := h.store.Get(app.ID)
if got == nil || got.IconPath == "" {
t.Fatalf("icon path not persisted: %#v", got)
}
if want := "http://example.com/api/catalog/icons/" + app.ID; got.WithBase("http://example.com").IconURL != want {
t.Fatalf("icon_url = %q, want %q", got.WithBase("http://example.com").IconURL, want)
}
if data, err := os.ReadFile(filepath.Join(root, "catalog", "icons", app.ID)); err != nil || !bytes.Equal(data, icon) {
t.Fatalf("icon bytes = %q err=%v", data, err)
}
// Non-image content is rejected.
buf, contentType = multipartBody(t, "file", "note.txt", "text/plain", []byte("hello"))
req = httptest.NewRequest(http.MethodPut, "/api/catalog/apps/"+app.ID+"/icon", buf)
req.Header.Set("Content-Type", contentType)
req.SetPathValue("id", app.ID)
rec = httptest.NewRecorder()
h.Icon(rec, req)
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
t.Fatalf("non-image icon: status = %d code = %s", rec.Code, errorCode(t, rec))
}
}
// multipartAdd builds a multipart POST /api/catalog/apps body with flat
// snake_case fields plus an optional file part named "file".
func multipartAdd(t *testing.T, fields map[string]string, filename string, data []byte) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for key, value := range fields {
if err := mw.WriteField(key, value); err != nil {
t.Fatalf("write field %s: %v", key, err)
}
}
if filename != "" {
part, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create file part: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("write file part: %v", err)
}
}
if err := mw.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
return &buf, mw.FormDataContentType()
}
func TestAddFromMultipartUpload(t *testing.T) {
payload := []byte("UPLOADED-APK")
sum := sha256.Sum256(payload)
h, root := newTestHandler(t)
buf, contentType := multipartAdd(t, map[string]string{
"package": "com.example.upload", "name": "Uploaded", "version_name": "3.1",
"summary": "from file", "sha256": hex.EncodeToString(sum[:]),
}, "uploaded-3.1.apk", payload)
req := httptest.NewRequest(http.MethodPost, "/api/catalog/apps", buf)
req.Header.Set("Content-Type", contentType)
req.Header.Set("X-Maestro-User", "admin")
rec := httptest.NewRecorder()
h.Apps(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
var app catalog.App
if err := json.Unmarshal(rec.Body.Bytes(), &app); err != nil {
t.Fatalf("decode: %v", err)
}
if app.Package != "com.example.upload" || app.Summary != "from file" {
t.Fatalf("metadata not applied: %#v", app)
}
if app.Artifact.Kind != catalog.KindHosted || app.Artifact.Filename != "uploaded-3.1.apk" ||
app.Artifact.SizeBytes != int64(len(payload)) || app.Artifact.SHA256 != hex.EncodeToString(sum[:]) {
t.Fatalf("artifact = %#v", app.Artifact)
}
stored, err := os.ReadFile(filepath.Join(root, "catalog", "artifacts", app.ID, "uploaded-3.1.apk"))
if err != nil || !bytes.Equal(stored, payload) {
t.Fatalf("hosted bytes = %q err=%v", stored, err)
}
// A mismatching supplied sha256 rejects the upload and stores nothing.
wrong, wrongType := multipartAdd(t, map[string]string{
"package": "com.example.upload2", "name": "Bad", "version_name": "1.0",
"sha256": strings.Repeat("e", 64),
}, "bad.apk", payload)
req = httptest.NewRequest(http.MethodPost, "/api/catalog/apps", wrong)
req.Header.Set("Content-Type", wrongType)
rec = httptest.NewRecorder()
h.Apps(rec, req)
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_CHECKSUM_MISMATCH" {
t.Fatalf("mismatch: status = %d code = %s", rec.Code, errorCode(t, rec))
}
if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", catalog.AppID("com.example.upload2"))); !os.IsNotExist(err) {
t.Fatalf("mismatched upload left bytes: %v", err)
}
}
+147
View File
@@ -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)
}
}
+156
View File
@@ -0,0 +1,156 @@
// 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 (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
)
func serveDownload(t *testing.T, h *Handler, id, filename string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/catalog/artifacts/"+id+"/"+filename, nil)
req.SetPathValue("id", id)
req.SetPathValue("filename", filename)
rec := httptest.NewRecorder()
h.Download(rec, req)
return rec
}
func TestDownloadHostedExactBytesAndHeaders(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.download", "exact-apk-bytes")
rec := serveDownload(t, h, app.ID, app.Artifact.Filename)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "exact-apk-bytes" {
t.Fatalf("body = %q", rec.Body.String())
}
if got := rec.Header().Get("Content-Length"); got != "15" {
t.Fatalf("Content-Length = %q, want 15", got)
}
if got := rec.Header().Get("X-Content-SHA256"); got != app.Artifact.SHA256 {
t.Fatalf("X-Content-SHA256 = %q, want %q", got, app.Artifact.SHA256)
}
if got := rec.Header().Get("Content-Type"); got != catalog.DefaultArtifactContentType {
t.Fatalf("Content-Type = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q", got)
}
}
func TestDownloadUnknownAndWrongFilename(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.dl2", "bytes")
if rec := serveDownload(t, h, app.ID, "other.apk"); rec.Code != http.StatusNotFound {
t.Fatalf("wrong filename status = %d", rec.Code)
}
if rec := serveDownload(t, h, "a_missing", "app.apk"); rec.Code != http.StatusNotFound {
t.Fatalf("unknown id status = %d", rec.Code)
}
}
func TestDownloadLinkRedirects(t *testing.T) {
h, _ := newTestHandler(t)
link := "https://downloads.example.org/app.apk"
app := catalog.App{
ID: catalog.AppID("com.example.redirect"), Package: "com.example.redirect", Name: "Link", VersionName: "1.0",
Artifact: catalog.Artifact{
Filename: "app.apk", SHA256: strings.Repeat("c", 64),
Kind: catalog.KindLink, SourceURL: link,
},
}
if err := h.store.Upsert(app); err != nil {
t.Fatalf("seed link: %v", err)
}
rec := serveDownload(t, h, app.ID, "app.apk")
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != link {
t.Fatalf("Location = %q, want %q", loc, link)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("redirect ACAO = %q", got)
}
}
func TestDownloadPrivacySwitchOff(t *testing.T) {
t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false")
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.private", "bytes")
if rec := serveDownload(t, h, app.ID, app.Artifact.Filename); rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 when the profile switch is off", rec.Code)
}
req := httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+app.ID, nil)
req.SetPathValue("id", app.ID)
rec := httptest.NewRecorder()
h.IconBytes(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("icon status = %d, want 404 when the profile switch is off", rec.Code)
}
}
func TestIconBytes(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.icon2", "bytes")
icon := append([]byte("\x89PNG\r\n\x1a\n"), []byte("png-body")...)
if _, err := h.store.SaveIcon(app.ID, icon); err != nil {
t.Fatalf("save icon: %v", err)
}
stored, _ := h.store.Get(app.ID)
stored.IconPath = "icons/" + app.ID
if err := h.store.Upsert(*stored); err != nil {
t.Fatalf("persist icon path: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+app.ID, nil)
req.SetPathValue("id", app.ID)
rec := httptest.NewRecorder()
h.IconBytes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(icon) {
t.Fatalf("icon body = %q", rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "image/png" {
t.Fatalf("Content-Type = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("ACAO = %q", got)
}
// App without an uploaded icon → 404.
other := seedHostedApp(t, h, "com.example.noicon", "bytes")
req = httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+other.ID, nil)
req.SetPathValue("id", other.ID)
rec = httptest.NewRecorder()
h.IconBytes(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("missing icon status = %d", rec.Code)
}
}
+45 -13
View File
@@ -25,15 +25,17 @@ import (
"sort" "sort"
"strings" "strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env" "git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/instance" "git.dcntrlized.org/Dcntrlized/Maestro/lib/instance"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
) )
// InstanceIdentity is the public face of this instance. Fingerprint is the // InstanceIdentity is the public face of this instance. Fingerprint is the
// instance CA's public-key hash — the same value a peer pins at first contact. // instance's stable app-level identity fingerprint (rev 9: with the PKI layer
// removed there is no CA, so it is derived from a persisted instance key). It
// is an unauthenticated string a consumer can still compare to detect a
// changed peer.
type InstanceIdentity struct { type InstanceIdentity struct {
Name string `json:"name"` Name string `json:"name"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
@@ -55,18 +57,31 @@ type PublicService struct {
Image string `json:"image"` Image string `json:"image"`
} }
// TrustedInstance is the retired federation trust row. Rev 9 removed the
// peer/federation trust store (no peer identity exists on a plaintext
// network), so the public profile always reports an empty list; the field is
// kept so existing consumers (the hub SPA) still see the documented shape.
type TrustedInstance struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Status string `json:"status,omitempty"`
Scopes []string `json:"scopes,omitempty"`
}
// PublicProfile is the GET /api/instance wire shape. The section pointers // PublicProfile is the GET /api/instance wire shape. The section pointers
// carry three distinct states: nil → field omitted (privacy switch off), // carry three distinct states: nil → field omitted (privacy switch off),
// pointer-to-nil → JSON null (store absent), pointer-to-empty → JSON []. // pointer-to-nil → JSON null (store absent), pointer-to-empty → JSON [].
type PublicProfile struct { type PublicProfile struct {
Instance InstanceIdentity `json:"instance"` Instance InstanceIdentity `json:"instance"`
Services *[]PublicService `json:"services,omitempty"` Services *[]PublicService `json:"services,omitempty"`
TrustedInstances *[]federation.RemoteInstance `json:"trusted_instances,omitempty"` TrustedInstances *[]TrustedInstance `json:"trusted_instances,omitempty"`
Apps *[]catalog.App `json:"apps,omitempty"`
} }
// NewPublicProfileHandler serves GET /api/instance. getServices is the cache // NewPublicProfileHandler serves GET /api/instance. getServices is the cache
// accessor (cache.GetServices self-locks); dataRoot resolves identity, CA and // accessor (cache.GetServices self-locks); dataRoot resolves identity, the
// federation stores on demand. // instance fingerprint and the catalog store on demand.
func NewPublicProfileHandler(getServices func() []types.Container, dataRoot string) http.HandlerFunc { func NewPublicProfileHandler(getServices func() []types.Container, dataRoot string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
profile := PublicProfile{ profile := PublicProfile{
@@ -75,14 +90,14 @@ func NewPublicProfileHandler(getServices func() []types.Container, dataRoot stri
if env.PublicProfile() { if env.PublicProfile() {
services := publicServices(getServices()) services := publicServices(getServices())
trusted, err := federation.List(dataRoot) // Rev 9: the peer/federation trust store is retired, so the
if err != nil { // trusted list is always empty (never nil, so the field stays an
// A malformed store must not take the profile down; // array rather than JSON null).
// report absence instead. trusted := []TrustedInstance{}
trusted = nil apps := publicApps(dataRoot, catalog.RequestBaseURL(r))
}
profile.Services = &services profile.Services = &services
profile.TrustedInstances = &trusted profile.TrustedInstances = &trusted
profile.Apps = &apps
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -99,10 +114,27 @@ func buildIdentity(dataRoot string) InstanceIdentity {
Name: id.Name, Name: id.Name,
Title: id.Title, Title: id.Title,
Description: id.Description, Description: id.Description,
Fingerprint: pki.CAFingerprint(dataRoot), Fingerprint: instance.Fingerprint(dataRoot),
} }
} }
// publicApps projects the operator-authored catalog (app-catalog theme) onto
// the public profile: metadata plus absolute download/icon URLs, with the
// private owner field stripped (A7). The result is always non-nil so an empty
// catalog serializes as [] rather than null; a malformed store degrades to []
// instead of taking the profile down.
func publicApps(dataRoot, base string) []catalog.App {
apps, err := catalog.List(dataRoot)
if err != nil || apps == nil {
apps = []catalog.App{}
}
out := make([]catalog.App, 0, len(apps))
for _, app := range apps {
out = append(out, app.WithBase(base).Public())
}
return out
}
// publicServices filters hidden infrastructure (role-labeled containers such // publicServices filters hidden infrastructure (role-labeled containers such
// as dcntrlized-db / dcntrlized-auth and anything flagged hidden) down to the // as dcntrlized-db / dcntrlized-auth and anything flagged hidden) down to the
// public field set. The result is sorted case-insensitively by service name: // public field set. The result is sorted case-insensitively by service name:
+89 -17
View File
@@ -21,9 +21,10 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/federation" "git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
) )
@@ -125,38 +126,41 @@ func TestPublicProfileIdentityFallbackAndFile(t *testing.T) {
} }
} }
func TestPublicProfileTrustedInstancesNullVsEmpty(t *testing.T) { // TestPublicProfileTrustedInstancesAlwaysEmpty covers rev 9: the federation
// trust store is retired, so trusted_instances is always an empty array (never
// null) regardless of on-disk state, and the instance fingerprint is a stable
// app-level value derived from the persisted instance key.
func TestPublicProfileTrustedInstancesAlwaysEmpty(t *testing.T) {
dataRoot := t.TempDir() dataRoot := t.TempDir()
// Store absent → JSON null (field present with null value).
body := serve(t, dataRoot, testServices()) body := serve(t, dataRoot, testServices())
if v, ok := body["trusted_instances"]; !ok || v != nil { raw, _ := json.Marshal(body["trusted_instances"])
raw, _ := json.Marshal(body) if string(raw) != "[]" {
t.Fatalf("absent federation store should yield trusted_instances:null, got %s", raw) t.Fatalf("trusted_instances should be [] after the federation removal, got %s", raw)
} }
// Present but empty → []. // A stale federation directory on disk must not resurrect the surface.
if err := os.MkdirAll(filepath.Join(dataRoot, "federation"), 0o700); err != nil { if err := os.MkdirAll(filepath.Join(dataRoot, "federation"), 0o700); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(federation.Path(dataRoot), []byte(`[]`), 0o600); err != nil { if err := os.WriteFile(filepath.Join(dataRoot, "federation", "instances.json"),
[]byte(`{"instances":[{"name":"peer-b","status":"authorized"}]}`), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
body = serve(t, dataRoot, testServices()) body = serve(t, dataRoot, testServices())
raw, _ := json.Marshal(body["trusted_instances"]) raw, _ = json.Marshal(body["trusted_instances"])
if string(raw) != "[]" { if string(raw) != "[]" {
t.Fatalf("empty store should marshal as [], got %s", raw) t.Fatalf("stale federation store leaked into the profile: %s", raw)
} }
// Populated → rows pass through. // The fingerprint is present and stable across requests.
populated := `{"instances":[{"name":"peer-b","url":"http://192.168.1.20:8080","fingerprint":"sha256:ab","status":"authorized","scopes":["read"]}]}` first := body["instance"].(map[string]any)["fingerprint"].(string)
if err := os.WriteFile(federation.Path(dataRoot), []byte(populated), 0o600); err != nil { if !strings.HasPrefix(first, "sha256:") {
t.Fatal(err) t.Fatalf("fingerprint = %q, want sha256: prefix", first)
} }
body = serve(t, dataRoot, testServices()) body = serve(t, dataRoot, testServices())
list := body["trusted_instances"].([]any) if again := body["instance"].(map[string]any)["fingerprint"].(string); again != first {
if len(list) != 1 || list[0].(map[string]any)["name"] != "peer-b" { t.Fatalf("fingerprint not stable: %q vs %q", first, again)
t.Fatalf("populated store not surfaced: %v", list)
} }
} }
@@ -174,3 +178,71 @@ func TestPublicProfilePrivacySwitchOmitsSections(t *testing.T) {
t.Error("instance identity must remain visible with the profile off") t.Error("instance identity must remain visible with the profile off")
} }
} }
func TestPublicProfileAppsSection(t *testing.T) {
dataRoot := t.TempDir()
id := catalog.AppID("com.example.profile")
app := catalog.App{
ID: id, Package: "com.example.profile", Name: "Profile App",
VersionName: "1.0", AddedAt: "2026-10-06T12:00:00Z", UpdatedAt: "2026-10-06T12:00:00Z", AddedBy: "owner",
Artifact: catalog.Artifact{
Filename: "app.apk", SizeBytes: 5, SHA256: strings.Repeat("d", 64),
Kind: catalog.KindHosted, ContentType: catalog.DefaultArtifactContentType,
},
ArtifactPath: filepath.Join("artifacts", id, "app.apk"),
IconPath: filepath.Join("icons", id),
}
if err := catalog.NewStore(dataRoot).Upsert(app); err != nil {
t.Fatalf("seed catalog: %v", err)
}
body := serve(t, dataRoot, testServices())
raw, _ := json.Marshal(body["apps"])
var apps []map[string]any
if err := json.Unmarshal(raw, &apps); err != nil {
t.Fatalf("apps not an array: %v (%s)", err, raw)
}
if len(apps) != 1 {
t.Fatalf("apps = %s", raw)
}
row := apps[0]
if row["package"] != "com.example.profile" || row["name"] != "Profile App" {
t.Fatalf("app metadata wrong: %v", row)
}
if _, leaked := row["added_by"]; leaked {
t.Fatal("public profile leaked added_by")
}
if _, leaked := row["artifact_path"]; leaked {
t.Fatal("public profile leaked the internal artifact path")
}
artifact, ok := row["artifact"].(map[string]any)
if !ok {
t.Fatalf("artifact missing: %v", row)
}
download := artifact["download_url"].(string)
if !strings.HasPrefix(download, "http://127.0.0.1:") || !strings.HasSuffix(download, "/api/catalog/artifacts/"+id+"/app.apk") {
t.Fatalf("download_url = %q", download)
}
icon := row["icon_url"].(string)
if !strings.HasSuffix(icon, "/api/catalog/icons/"+id) {
t.Fatalf("icon_url = %q", icon)
}
if artifact["sha256"] != strings.Repeat("d", 64) {
t.Fatalf("sha256 not surfaced: %v", artifact)
}
}
func TestPublicProfileAppsEmptyAndSwitchOff(t *testing.T) {
dataRoot := t.TempDir()
body := serve(t, dataRoot, testServices())
raw, _ := json.Marshal(body["apps"])
if string(raw) != "[]" {
t.Fatalf("empty catalog must marshal as [], got %s", raw)
}
t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false")
body = serve(t, dataRoot, testServices())
if _, ok := body["apps"]; ok {
t.Error("apps must be omitted when DCNTRLIZED_PUBLIC_PROFILE=false")
}
}
+165
View File
@@ -0,0 +1,165 @@
// 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)
}
+368
View File
@@ -0,0 +1,368 @@
// 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 (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
)
// Limits from the frozen contract: a hosted artifact fetch is capped at
// 512 MiB, an uploaded icon at 512 KiB.
const (
MaxArtifactBytes = int64(512) << 20
MaxIconBytes = int64(512) << 10
)
// ErrTooLarge is returned when a copied file exceeds the caller-supplied limit.
var ErrTooLarge = errors.New("catalog: file exceeds size limit")
const (
dirName = "catalog"
appsFile = "apps.json"
artifacts = "artifacts"
icons = "icons"
)
// Dir returns the catalog directory for a data root.
func Dir(dataRoot string) string {
return filepath.Join(dataRoot, dirName)
}
// Path returns the catalog index file location for a data root.
func Path(dataRoot string) string {
return filepath.Join(Dir(dataRoot), appsFile)
}
// Store is the file-backed catalog index plus the helpers that manage the
// hosted artifact/icon bytes beside it. A mutex serializes read-modify-write
// cycles so concurrent owner actions cannot lose rows.
type Store struct {
mu sync.Mutex
dataRoot string
}
// NewStore opens (without creating) the catalog under dataRoot.
func NewStore(dataRoot string) *Store {
return &Store{dataRoot: dataRoot}
}
// List reads every app in insertion order (A5: the operator's ordering is
// stable). A missing index yields a non-nil empty slice: an empty catalog
// serializes as [] and never null.
func (s *Store) List() ([]App, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.load()
}
// List is the package-level helper over the data-root-backed store.
func List(dataRoot string) ([]App, error) {
return NewStore(dataRoot).List()
}
// Get returns the app with the given id, or (nil, nil) when absent.
func (s *Store) Get(id string) (*App, error) {
apps, err := s.List()
if err != nil {
return nil, err
}
for i := range apps {
if apps[i].ID == id {
app := apps[i]
return &app, nil
}
}
return nil, nil
}
// GetByPackage returns the app owning the given package name, or (nil, nil).
func (s *Store) GetByPackage(pkg string) (*App, error) {
apps, err := s.List()
if err != nil {
return nil, err
}
for i := range apps {
if apps[i].Package == pkg {
app := apps[i]
return &app, nil
}
}
return nil, nil
}
// Upsert inserts a new app at the end (insertion order) or replaces the record
// with the same id in place.
func (s *Store) Upsert(app App) error {
s.mu.Lock()
defer s.mu.Unlock()
apps, err := s.load()
if err != nil {
return err
}
for i := range apps {
if apps[i].ID == app.ID {
apps[i] = app
return s.save(apps)
}
}
return s.save(append(apps, app))
}
// Remove drops the named record. Removing an unknown id is a no-op.
func (s *Store) Remove(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
apps, err := s.load()
if err != nil {
return err
}
kept := make([]App, 0, len(apps))
removed := false
for _, app := range apps {
if app.ID == id {
removed = true
continue
}
kept = append(kept, app)
}
if !removed {
return nil
}
return s.save(kept)
}
// SaveArtifact streams src to artifacts/<id>/<filename> (dir 0700, file 0600,
// tmp+rename), returning the data-root-relative path, byte count and sha256.
// The copy is capped at limit; exceeding it yields ErrTooLarge and leaves no
// file behind.
func (s *Store) SaveArtifact(id, filename string, src io.Reader, limit int64) (string, int64, string, error) {
name := SafeFilename(filename)
if id == "" || name == "" {
return "", 0, "", errors.New("catalog: invalid artifact id or filename")
}
dir := filepath.Join(Dir(s.dataRoot), artifacts, id)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", 0, "", fmt.Errorf("create artifact dir: %w", err)
}
final := filepath.Join(dir, name)
tmp := final + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return "", 0, "", fmt.Errorf("create artifact file: %w", err)
}
hash := sha256.New()
copied, copyErr := io.Copy(io.MultiWriter(f, hash), io.LimitReader(src, limit+1))
closeErr := f.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return "", 0, "", copyErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return "", 0, "", closeErr
}
if copied > limit {
_ = os.Remove(tmp)
return "", 0, "", ErrTooLarge
}
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
return "", 0, "", fmt.Errorf("store artifact: %w", err)
}
return filepath.Join(artifacts, id, name), copied, hex.EncodeToString(hash.Sum(nil)), nil
}
// SaveIcon writes icon bytes to icons/<id> (dir 0700, file 0600, tmp+rename)
// and returns the data-root-relative path.
func (s *Store) SaveIcon(id string, data []byte) (string, error) {
if id == "" || len(data) == 0 {
return "", errors.New("catalog: invalid icon id or empty data")
}
if int64(len(data)) > MaxIconBytes {
return "", ErrTooLarge
}
dir := filepath.Join(Dir(s.dataRoot), icons)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create icons dir: %w", err)
}
final := filepath.Join(dir, id)
tmp := final + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return "", fmt.Errorf("write icon: %w", err)
}
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("store icon: %w", err)
}
return filepath.Join(icons, id), nil
}
// PruneArtifactDir removes every file in artifacts/<id> except keep, so an
// artifact replacement does not leave the previous copy behind.
func (s *Store) PruneArtifactDir(id, keep string) error {
dir := filepath.Join(Dir(s.dataRoot), artifacts, id)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, e := range entries {
if e.Name() == keep {
continue
}
if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
return err
}
}
return nil
}
// DeleteFiles removes an app's hosted artifact directory and uploaded icon.
func (s *Store) DeleteFiles(app App) error {
if app.ID != "" {
if err := os.RemoveAll(filepath.Join(Dir(s.dataRoot), artifacts, app.ID)); err != nil {
return err
}
}
if app.IconPath != "" {
p, err := s.ResolveIcon(app)
if err == nil {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
}
}
return nil
}
// ResolveArtifact returns the absolute path of an app's hosted artifact.
func (s *Store) ResolveArtifact(app App) (string, error) {
return s.resolve(app.ArtifactPath)
}
// ResolveIcon returns the absolute path of an app's uploaded icon.
func (s *Store) ResolveIcon(app App) (string, error) {
return s.resolve(app.IconPath)
}
// resolve joins a stored relative path under the catalog dir, refusing any
// value that would escape it.
func (s *Store) resolve(rel string) (string, error) {
if rel == "" {
return "", os.ErrNotExist
}
base := Dir(s.dataRoot)
p := filepath.Clean(filepath.Join(base, rel))
if p != base && !strings.HasPrefix(p, base+string(filepath.Separator)) {
return "", fmt.Errorf("catalog: path escapes store dir: %q", rel)
}
return p, nil
}
// diskApp is the private on-disk projection of a record: it adds the fields
// that must never serialize to clients (hosted artifact/icon paths and the
// operator link target) under private keys.
type diskApp struct {
App
ArtifactPath string `json:"artifact_path,omitempty"`
IconPath string `json:"icon_path,omitempty"`
ArtifactSourceURL string `json:"artifact_source_url,omitempty"`
}
func toDisk(app App) diskApp {
return diskApp{
App: app,
ArtifactPath: app.ArtifactPath,
IconPath: app.IconPath,
ArtifactSourceURL: app.Artifact.SourceURL,
}
}
func fromDisk(app diskApp) App {
out := app.App
out.ArtifactPath = app.ArtifactPath
out.IconPath = app.IconPath
out.Artifact.SourceURL = app.ArtifactSourceURL
return out
}
// load reads and parses the index. A missing file is an empty catalog.
func (s *Store) load() ([]App, error) {
data, err := os.ReadFile(Path(s.dataRoot))
if err != nil {
if os.IsNotExist(err) {
return []App{}, nil
}
return nil, err
}
var wrapped struct {
Apps []diskApp `json:"apps"`
}
if err := json.Unmarshal(data, &wrapped); err != nil {
var direct []diskApp
if err2 := json.Unmarshal(data, &direct); err2 != nil {
return nil, fmt.Errorf("parse catalog index: %w", err)
}
wrapped.Apps = direct
}
apps := make([]App, 0, len(wrapped.Apps))
for _, app := range wrapped.Apps {
apps = append(apps, fromDisk(app))
}
return apps, nil
}
// save atomically replaces the index (dir 0700, tmp+rename, file 0600).
func (s *Store) save(apps []App) error {
disk := make([]diskApp, 0, len(apps))
for _, app := range apps {
disk = append(disk, toDisk(app))
}
payload, err := json.MarshalIndent(struct {
Apps []diskApp `json:"apps"`
}{Apps: disk}, "", " ")
if err != nil {
return fmt.Errorf("marshal catalog index: %w", err)
}
payload = append(payload, '\n')
dir := Dir(s.dataRoot)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create catalog dir: %w", err)
}
path := Path(s.dataRoot)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o600); err != nil {
return fmt.Errorf("write catalog index: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("replace catalog index: %w", err)
}
return nil
}
+251
View File
@@ -0,0 +1,251 @@
// 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 (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAppIDStable(t *testing.T) {
pkg := "org.dcntrlized.keepnote"
sum := sha256.Sum256([]byte(pkg))
want := "a_" + hex.EncodeToString(sum[:])[:12]
if got := AppID(pkg); got != want {
t.Fatalf("AppID = %q, want %q", got, want)
}
if id := AppID(pkg); id != want {
t.Fatalf("AppID not stable: %q vs %q", id, want)
}
if strings.ContainsAny(want, "ABCDEF") {
t.Fatalf("AppID must be lowercase hex: %q", want)
}
}
func TestStoreCRUDAndPermissions(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
apps, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if apps == nil || len(apps) != 0 {
t.Fatalf("empty catalog must be a non-nil empty slice, got %#v", apps)
}
first := App{ID: AppID("com.example.one"), Package: "com.example.one", Name: "One", VersionName: "1.0", Artifact: Artifact{Kind: KindLink, SHA256: strings.Repeat("a", 64)}}
second := App{ID: AppID("com.example.two"), Package: "com.example.two", Name: "Two", VersionName: "2.0"}
if err := store.Upsert(first); err != nil {
t.Fatalf("Upsert first: %v", err)
}
if err := store.Upsert(second); err != nil {
t.Fatalf("Upsert second: %v", err)
}
apps, err = store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(apps) != 2 || apps[0].Package != "com.example.one" || apps[1].Package != "com.example.two" {
t.Fatalf("insertion order not preserved: %#v", apps)
}
// Store file must be 0600 and dir 0700.
info, err := os.Stat(Path(root))
if err != nil {
t.Fatalf("stat index: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("apps.json mode = %o, want 600", info.Mode().Perm())
}
dirInfo, err := os.Stat(Dir(root))
if err != nil {
t.Fatalf("stat dir: %v", err)
}
if dirInfo.Mode().Perm() != 0o700 {
t.Fatalf("catalog dir mode = %o, want 700", dirInfo.Mode().Perm())
}
got, err := store.Get(first.ID)
if err != nil || got == nil || got.Name != "One" {
t.Fatalf("Get = %#v, %v", got, err)
}
if missing, err := store.Get("a_doesnotexist"); err != nil || missing != nil {
t.Fatalf("Get unknown = %#v, %v; want nil, nil", missing, err)
}
byPkg, err := store.GetByPackage("com.example.two")
if err != nil || byPkg == nil || byPkg.Name != "Two" {
t.Fatalf("GetByPackage = %#v, %v", byPkg, err)
}
// Upsert same id replaces in place, keeping size/order.
second.Name = "Two renamed"
if err := store.Upsert(second); err != nil {
t.Fatalf("Upsert replace: %v", err)
}
apps, _ = store.List()
if len(apps) != 2 || apps[1].Name != "Two renamed" {
t.Fatalf("in-place replace failed: %#v", apps)
}
if err := store.Remove(first.ID); err != nil {
t.Fatalf("Remove: %v", err)
}
apps, _ = store.List()
if len(apps) != 1 || apps[0].ID != second.ID {
t.Fatalf("Remove left %#v", apps)
}
if err := store.Remove("a_missing"); err != nil {
t.Fatalf("Remove unknown must be a no-op, got %v", err)
}
}
func TestSaveArtifactAndDeleteFiles(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
id := AppID("com.example.app")
rel, size, sum, err := store.SaveArtifact(id, "app-1.0.apk", strings.NewReader("APK-BYTES"), MaxArtifactBytes)
if err != nil {
t.Fatalf("SaveArtifact: %v", err)
}
if size != int64(len("APK-BYTES")) {
t.Fatalf("size = %d", size)
}
want := sha256.Sum256([]byte("APK-BYTES"))
if sum != hex.EncodeToString(want[:]) {
t.Fatalf("sha = %s", sum)
}
app := App{ID: id, ArtifactPath: rel, Artifact: Artifact{Filename: "app-1.0.apk"}}
abs, err := store.ResolveArtifact(app)
if err != nil {
t.Fatalf("ResolveArtifact: %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
t.Fatalf("read artifact: %v", err)
}
if string(data) != "APK-BYTES" {
t.Fatalf("artifact bytes = %q", data)
}
if info, _ := os.Stat(abs); info.Mode().Perm() != 0o600 {
t.Fatalf("artifact mode = %o, want 600", info.Mode().Perm())
}
iconRel, err := store.SaveIcon(id, []byte("\x89PNG\r\n\x1a\nicon"))
if err != nil {
t.Fatalf("SaveIcon: %v", err)
}
app.IconPath = iconRel
iconAbs, err := store.ResolveIcon(app)
if err != nil {
t.Fatalf("ResolveIcon: %v", err)
}
if _, err := os.Stat(iconAbs); err != nil {
t.Fatalf("icon not stored: %v", err)
}
if err := store.DeleteFiles(app); err != nil {
t.Fatalf("DeleteFiles: %v", err)
}
if _, err := os.Stat(abs); !os.IsNotExist(err) {
t.Fatalf("artifact survived DeleteFiles: %v", err)
}
if _, err := os.Stat(iconAbs); !os.IsNotExist(err) {
t.Fatalf("icon survived DeleteFiles: %v", err)
}
}
func TestSaveArtifactEnforcesLimit(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
id := AppID("com.example.big")
_, _, _, err := store.SaveArtifact(id, "big.apk", strings.NewReader("0123456789"), 4)
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("err = %v, want ErrTooLarge", err)
}
entries, _ := os.ReadDir(filepath.Join(Dir(root), "artifacts", id))
if len(entries) != 0 {
t.Fatalf("oversize artifact left files: %v", entries)
}
}
func TestSaveArtifactSanitizesTraversal(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
id := AppID("com.example.x")
// A name with nothing usable left is rejected outright.
if _, _, _, err := store.SaveArtifact(id, "..", strings.NewReader("x"), MaxArtifactBytes); err == nil {
t.Fatal("unusable filename must be rejected")
}
// A traversal attempt is reduced to a single segment inside the store.
rel, _, _, err := store.SaveArtifact(id, "../escape.apk", strings.NewReader("x"), MaxArtifactBytes)
if err != nil {
t.Fatalf("SaveArtifact: %v", err)
}
if rel != filepath.Join("artifacts", id, "escape.apk") {
t.Fatalf("rel = %q", rel)
}
if _, err := os.Stat(filepath.Join(root, "escape.apk")); !os.IsNotExist(err) {
t.Fatalf("artifact escaped the catalog dir (err=%v)", err)
}
// Stored relative paths are contained as well.
if _, err := store.resolve("../../etc/passwd"); err == nil {
t.Fatal("resolve must refuse paths escaping the catalog dir")
}
}
func TestSafeFilename(t *testing.T) {
cases := map[string]string{
"app.apk": "app.apk",
"../evil.apk": "evil.apk",
"..\\evil.apk": "evil.apk",
"/etc/passwd": "passwd",
"nested/dir/app.apk": "app.apk",
"": "",
"..": "",
"keepnote-1.4.2.apk": "keepnote-1.4.2.apk",
}
for in, want := range cases {
if got := SafeFilename(in); got != want {
t.Errorf("SafeFilename(%q) = %q, want %q", in, got, want)
}
}
}
func TestRequestBaseURL(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://internal:8080/api/instance", nil)
r.Header.Set("X-Forwarded-Proto", "https")
r.Header.Set("X-Forwarded-Host", "apps.example")
if got := RequestBaseURL(r); got != "https://apps.example" {
t.Fatalf("base = %q", got)
}
direct := httptest.NewRequest(http.MethodGet, "http://direct.example/x", nil)
if got := RequestBaseURL(direct); got != "http://direct.example" {
t.Fatalf("direct base = %q", got)
}
}
+40 -1
View File
@@ -246,8 +246,8 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
if hidden, ok := manifest.Annotations["org.dcntrlized.hidden"]; ok && hidden == "true" { if hidden, ok := manifest.Annotations["org.dcntrlized.hidden"]; ok && hidden == "true" {
item.Hidden = true item.Hidden = true
} }
item.ServiceName = serviceNameFromLabels(manifest.Annotations, nil)
} }
// 2. Fallback to Image Config Labels (where docker buildx puts them when targeting v2 registries) // 2. Fallback to Image Config Labels (where docker buildx puts them when targeting v2 registries)
if configFile, err := img.ConfigFile(); err == nil && configFile != nil { if configFile, err := img.ConfigFile(); err == nil && configFile != nil {
labels := configFile.Config.Labels labels := configFile.Config.Labels
@@ -285,6 +285,18 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
if !item.Hidden && labels["org.dcntrlized.hidden"] == "true" { if !item.Hidden && labels["org.dcntrlized.hidden"] == "true" {
item.Hidden = true item.Hidden = true
} }
if item.ServiceName == "" {
item.ServiceName = serviceNameFromLabels(nil, labels)
}
}
// A container package is a Maestro service only if its image declares the
// dcntrlized service label. Org-level packages that don't (e.g. the
// hardened runner job image dcntrlized/ci-tools) are infrastructure, not
// installable services, and must not appear in the console.
if item.ServiceName == "" {
logger.Debug("Skipping registry package %s:%s: no org.dcntrlized.service label", pkg.Name, pkg.Version)
continue
} }
// Fallback for infra images published before the hidden label existed: // Fallback for infra images published before the hidden label existed:
@@ -303,6 +315,11 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
if item.URL == "" { if item.URL == "" {
item.URL = item.Source item.URL = item.Source
} }
// Source is displayed and linked by the console. It arrives verbatim
// from the image's OCI annotations, so the href is only populated when
// the value passes the shared http/https allowlist (audit N7); the raw
// Source is still served for plain-text display.
item.SourceHref = sourceHrefFor(item.Source)
registryItems = append(registryItems, item) registryItems = append(registryItems, item)
} }
@@ -310,6 +327,17 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
return registryItems, nil return registryItems, nil
} }
// sourceHrefFor returns source when it is safe to use as a hyperlink, else "".
// It reuses the Web App chip's allowlist (types.ValidWebBase: http/https
// exactly, no userinfo, no control chars, non-empty host) rather than defining
// a second one — an invalid source is rendered as plain text, never as an href.
func sourceHrefFor(source string) string {
if source == "" || !types.ValidWebBase(source) {
return ""
}
return source
}
// ItemByName returns the registry item for the named service, or an error when // ItemByName returns the registry item for the named service, or an error when
// the registry does not carry a container package with that name. // the registry does not carry a container package with that name.
func ItemByName(ctx context.Context, name string) (*types.RegistryContainer, error) { func ItemByName(ctx context.Context, name string) (*types.RegistryContainer, error) {
@@ -325,6 +353,17 @@ func ItemByName(ctx context.Context, name string) (*types.RegistryContainer, err
return nil, fmt.Errorf("registry: service %q not found", name) return nil, fmt.Errorf("registry: service %q not found", name)
} }
// serviceNameFromLabels reads the service identity a container image declares
// via org.dcntrlized.service. The OCI manifest annotations win; docker buildx
// writes labels to the image config instead, depending on the target. An empty
// result means the package is not a Maestro service.
func serviceNameFromLabels(annotations, labels map[string]string) string {
if v := annotations["org.dcntrlized.service"]; v != "" {
return v
}
return labels["org.dcntrlized.service"]
}
// normalizeSemver expands partial version tags to the full X.Y.Z form, // normalizeSemver expands partial version tags to the full X.Y.Z form,
// preserving an optional pre-release suffix (v3 -> 3.0.0, v3.1 -> 3.1.0, // preserving an optional pre-release suffix (v3 -> 3.0.0, v3.1 -> 3.1.0,
// v3.3.3-hotfix -> 3.3.3-hotfix). // v3.3.3-hotfix -> 3.3.3-hotfix).
+108
View File
@@ -17,12 +17,94 @@ package registry
import ( import (
"context" "context"
"encoding/json"
"os" "os"
"strings" "strings"
"testing" "testing"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
) )
// TestSourceHrefAllowlist pins audit finding N7: the registry item's Source
// comes verbatim from the image's OCI annotations, so the href the console
// renders must be gated by the same http/https allowlist as the Web App chip.
// An invalid source must never yield a usable href — it is served only as
// plain display text.
func TestSourceHrefAllowlist(t *testing.T) {
valid := []string{
"https://github.com/Dcntrlized/Maestro",
"https://git.dcntrlized.org/Dcntrlized/Calendar.git",
"http://192.168.122.183:3000/Dcntrlized/KeepNote",
"https://example.com/x?a=b#frag",
"http://[::1]:8080/repo",
}
for _, raw := range valid {
if got := sourceHrefFor(raw); got != raw {
t.Errorf("valid source %q: got href %q, want it verbatim", raw, got)
}
}
invalid := []string{
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"file:///etc/passwd",
"https://user:[email protected]/repo",
"https://[email protected]/repo",
"github.com/Dcntrlized/Maestro", // relative: no scheme
"//example.com/repo", // scheme-relative
"/repo", // path only
"", // absent
" ", // whitespace only
"https://example.com/re po", // space
"https://example.com/<repo>", // angle brackets
"https://example.com/repo\njavascript:1", // newline
"https://example.com/repo\x00tail", // NUL
"https://example.com/repo\x7f", // DEL
"https:", // no host
"ftp://example.com/repo", // wrong scheme
}
for _, raw := range invalid {
if got := sourceHrefFor(raw); got != "" {
t.Errorf("invalid source %q must not become an href, got %q", raw, got)
}
}
}
// TestSourceHrefWireKey locks the console contract: the validated href rides
// as `source_href` next to the raw `source`, so the frontend can render plain
// text for an invalid value instead of an anchor.
func TestSourceHrefWireKey(t *testing.T) {
b, err := json.Marshal(types.RegistryContainer{
Name: "keepnote",
Source: "javascript:alert(1)",
SourceHref: "",
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(b), `"source":"javascript:alert(1)"`) {
t.Fatalf("raw source must stay on the wire for display: %s", b)
}
if strings.Contains(string(b), "source_href") {
t.Fatalf("an invalid source must not emit source_href: %s", b)
}
b, err = json.Marshal(types.RegistryContainer{
Name: "keepnote",
Source: "https://github.com/Dcntrlized/KeepNote",
SourceHref: "https://github.com/Dcntrlized/KeepNote",
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(b), `"source_href":"https://github.com/Dcntrlized/KeepNote"`) {
t.Fatalf("valid source must emit source_href: %s", b)
}
}
// TestItemByNameLive resolves the infrastructure services against a live // TestItemByNameLive resolves the infrastructure services against a live
// registry. It is opt-in (MAESTRO_REGISTRY_TEST=1) so the default test run // registry. It is opt-in (MAESTRO_REGISTRY_TEST=1) so the default test run
// stays offline; the dev VM and prod CI both set the Gitea env the function // stays offline; the dev VM and prod CI both set the Gitea env the function
@@ -51,6 +133,32 @@ func TestItemByNameLive(t *testing.T) {
} }
} }
// TestServiceNameFromLabels locks the "is this package a Maestro service?" rule:
// the org.dcntrlized.service label must be declared by the image (manifest
// annotations or config labels). The runner job image dcntrlized/ci-tools
// declares neither and must not surface in the console.
func TestServiceNameFromLabels(t *testing.T) {
if got := serviceNameFromLabels(map[string]string{"org.dcntrlized.service": "KeepNote"}, nil); got != "KeepNote" {
t.Fatalf("annotation: got %q", got)
}
if got := serviceNameFromLabels(nil, map[string]string{"org.dcntrlized.service": "Calendar"}); got != "Calendar" {
t.Fatalf("label: got %q", got)
}
// Annotations win when both are present.
if got := serviceNameFromLabels(
map[string]string{"org.dcntrlized.service": "KeepNote"},
map[string]string{"org.dcntrlized.service": "Calendar"},
); got != "KeepNote" {
t.Fatalf("annotation precedence: got %q", got)
}
if got := serviceNameFromLabels(nil, nil); got != "" {
t.Fatalf("no label must yield empty, got %q", got)
}
if got := serviceNameFromLabels(map[string]string{"org.opencontainers.image.title": "ci-tools"}, nil); got != "" {
t.Fatalf("unrelated labels must yield empty, got %q", got)
}
}
// TestBaseURLNeverFallsBackToPublicInDev locks the dev registry seam: with // TestBaseURLNeverFallsBackToPublicInDev locks the dev registry seam: with
// DCNTRLIZED_DEV=true Maestro must talk to the debug registry // DCNTRLIZED_DEV=true Maestro must talk to the debug registry
// (DCNTRLIZED_GITEA_URL) only — an unset dev URL yields "" (loud failure), // (DCNTRLIZED_GITEA_URL) only — an unset dev URL yields "" (loud failure),
+15
View File
@@ -18,6 +18,10 @@ package types
// RegistryContainer represents a clean API payload specifically for container registry items. // RegistryContainer represents a clean API payload specifically for container registry items.
type RegistryContainer struct { type RegistryContainer struct {
Name string `json:"name"` Name string `json:"name"`
// ServiceName is the service's declared identity, read from the image's
// org.dcntrlized.service label. A container package without it is not a
// Maestro service and is not listed.
ServiceName string `json:"service_name,omitempty"`
Version string `json:"version"` Version string `json:"version"`
Image string `json:"image"` Image string `json:"image"`
Digest string `json:"digest,omitempty"` Digest string `json:"digest,omitempty"`
@@ -26,6 +30,12 @@ type RegistryContainer struct {
Author string `json:"author,omitempty"` Author string `json:"author,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
// SourceHref is Source only when it passed the http/https allowlist
// (ValidWebBase), else "". Source is attacker-influenced (it comes verbatim
// from the image's OCI annotations), so it must never reach the DOM as an
// href on trust alone; the console renders the raw Source as plain text
// when this is empty (audit finding N7).
SourceHref string `json:"source_href,omitempty"`
Revision string `json:"revision,omitempty"` Revision string `json:"revision,omitempty"`
Licenses string `json:"licenses,omitempty"` Licenses string `json:"licenses,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
@@ -38,6 +48,11 @@ type RegistryContainer struct {
// manifest (Service Config & Secrets theme M1), persisted so installed // manifest (Service Config & Secrets theme M1), persisted so installed
// services can surface them even while stopped. // services can surface them even while stopped.
Config []ConfigItem `json:"config,omitempty"` Config []ConfigItem `json:"config,omitempty"`
// Web is the Web App capability declared by the service manifest (contract
// rev 8.2 §16.4), persisted like Config. A registry listing carries
// {enabled,port,url} with an empty Href — there is no live container to
// resolve against.
Web *WebApp `json:"web,omitempty"`
} }
type RawPackage struct { type RawPackage struct {