Files
tiny-forge/internal/api/discovery.go
T
alexei.dolgolyov 410a131cec feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
  WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
  ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
  + {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
  /apps/[id] edit form onto the same components (removes the duplication). Add
  vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
  environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
  state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
  conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
  label hints; dashboard + /apps "Total workloads" count only source_kind workloads
  (drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
  empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
2026-05-29 02:09:54 +03:00

453 lines
15 KiB
Go

package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"regexp"
"strings"
"time"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/staticsite"
)
// Discovery endpoints feed the /apps/new wizard's auto-discovery and
// connection-test flow. They wrap staticsite.GitProvider so the form
// can validate a repo + token before the workload is created, browse
// repos/branches/folders without leaving the page, and warn the operator
// when an image is already in use by another workload.
//
// The endpoints are workload-agnostic on purpose — they are scoped under
// /api/discovery rather than tied to the static_sites table the cutover
// dropped. Any future Git-driven source plugin can reuse them.
// Per-request budget for outbound calls. Short enough that a malicious
// or stuck upstream cannot pin a worker for long; long enough for slow
// self-hosted Gitea instances to respond.
const discoveryTimeout = 15 * time.Second
// gitProviderRequest is the shared request body for the four Git
// discovery endpoints. Token is plaintext over HTTPS — the wizard has
// not yet persisted it, so there is nothing to decrypt server-side.
// Empty Provider triggers DetectProviderWithProbe.
type gitProviderRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
Query string `json:"query"`
}
// gitIdentRe accepts Git owner / repo identifiers as the major hosts
// (GitHub, GitLab, Gitea/Forgejo) accept them: alphanumeric plus dot,
// underscore, hyphen. Rejecting other characters at the API boundary
// prevents `..` traversal and URL injection in the provider code that
// interpolates these segments into request paths.
var gitIdentRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
// gitBranchRe is more permissive than gitIdentRe: branches may contain
// `/` (e.g. `feature/foo`) but still cannot contain `..` or control
// characters. The check below pairs this regex with an explicit `..`
// reject so a `feature/../admin` value cannot slip through.
var gitBranchRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
// validateGitIdent guards owner / repo path segments at the boundary
// so the provider code can interpolate them with fmt.Sprintf without
// risking traversal. Empty input is reported with the supplied field
// name so the error message is actionable.
func validateGitIdent(field, value string) error {
v := strings.TrimSpace(value)
if v == "" {
return &apiError{msg: field + " is required"}
}
if !gitIdentRe.MatchString(v) {
return &apiError{msg: field + " contains invalid characters"}
}
return nil
}
// validateGitBranch is the branch-shaped variant of validateGitIdent.
// Branches legitimately contain `/`; the extra `..` reject covers the
// one traversal vector the regex still admits.
func validateGitBranch(value string) error {
v := strings.TrimSpace(value)
if v == "" {
return &apiError{msg: "branch is required"}
}
if strings.Contains(v, "..") {
return &apiError{msg: "branch contains invalid sequence '..'"}
}
if !gitBranchRe.MatchString(v) {
return &apiError{msg: "branch contains invalid characters"}
}
return nil
}
// apiError is a small typed error so handlers can distinguish a
// validation failure (→ 400) from any other error (→ 500/502). The
// type lives in this file because nothing outside discovery uses it
// yet — promote to response.go if other handlers need the same shape.
type apiError struct{ msg string }
func (e *apiError) Error() string { return e.msg }
// providerType normalizes the provider string into the typed enum used
// by staticsite.NewGitProvider. Empty input falls through to provider
// auto-detection inside NewGitProvider.
func (req gitProviderRequest) providerType() staticsite.ProviderType {
switch strings.ToLower(strings.TrimSpace(req.Provider)) {
case "github":
return staticsite.ProviderGitHub
case "gitlab":
return staticsite.ProviderGitLab
case "gitea":
return staticsite.ProviderGitea
default:
return ""
}
}
// newProvider constructs the GitProvider for the request, or writes a
// 400 to w and returns nil if the inputs are invalid. BaseURL is fully
// validated here (scheme + host shape); connect-time IP filtering is
// enforced inside the safe-HTTP transport the provider receives.
func (req gitProviderRequest) newProvider(w http.ResponseWriter) staticsite.GitProvider {
if err := staticsite.ValidateBaseURL(req.BaseURL); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return nil
}
provider, err := staticsite.NewGitProvider(req.providerType(), req.BaseURL, req.AccessToken)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return nil
}
return provider
}
// upstreamError logs the detailed upstream failure server-side and
// writes a generic 502 to the client. Echoing the raw error string
// would leak any access token reflected by a misconfigured or
// attacker-controlled upstream into the response body.
func upstreamError(w http.ResponseWriter, op string, err error) {
slog.Warn("discovery upstream call failed", "op", op, "error", err)
respondError(w, http.StatusBadGateway, "upstream git provider returned an error")
}
// detectGitProviderRequest is the body for POST /api/discovery/git/detect-provider.
type detectGitProviderRequest struct {
BaseURL string `json:"base_url"`
}
// detectGitProvider probes the base URL for known Git provider API
// signatures so the wizard can auto-fill the provider dropdown.
// POST /api/discovery/git/detect-provider.
func (s *Server) detectGitProvider(w http.ResponseWriter, r *http.Request) {
var req detectGitProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if err := staticsite.ValidateBaseURL(req.BaseURL); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
provider := staticsite.DetectProviderWithProbe(ctx, req.BaseURL)
respondJSON(w, http.StatusOK, map[string]string{"provider": string(provider)})
}
// testGitConnection verifies the configured base URL + token + repo
// reach the provider successfully so the wizard can fail fast.
// POST /api/discovery/git/test-connection.
func (s *Server) testGitConnection(w http.ResponseWriter, r *http.Request) {
var req gitProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateGitIdent("repo_name", req.RepoName); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
provider := req.newProvider(w)
if provider == nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
if err := provider.TestConnection(ctx, req.RepoOwner, req.RepoName); err != nil {
upstreamError(w, "test_connection", err)
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// listGitRepos returns repositories accessible with the supplied token,
// optionally filtered by a name query.
// POST /api/discovery/git/repos.
func (s *Server) listGitRepos(w http.ResponseWriter, r *http.Request) {
var req gitProviderRequest
if !decodeJSON(w, r, &req) {
return
}
provider := req.newProvider(w)
if provider == nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
repos, err := provider.ListRepos(ctx, req.Query)
if err != nil {
upstreamError(w, "list_repos", err)
return
}
if repos == nil {
repos = []staticsite.RepoInfo{}
}
respondJSON(w, http.StatusOK, repos)
}
// listGitBranches returns the branch list for a repo.
// POST /api/discovery/git/branches.
func (s *Server) listGitBranches(w http.ResponseWriter, r *http.Request) {
var req gitProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateGitIdent("repo_name", req.RepoName); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
provider := req.newProvider(w)
if provider == nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
branches, err := provider.ListBranches(ctx, req.RepoOwner, req.RepoName)
if err != nil {
upstreamError(w, "list_branches", err)
return
}
if branches == nil {
branches = []string{}
}
respondJSON(w, http.StatusOK, branches)
}
// listGitTree returns the full directory tree for a branch so the
// wizard can render the folder picker.
// POST /api/discovery/git/tree.
func (s *Server) listGitTree(w http.ResponseWriter, r *http.Request) {
var req gitProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateGitIdent("repo_name", req.RepoName); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateGitBranch(req.Branch); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
provider := req.newProvider(w)
if provider == nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
tree, err := provider.ListTree(ctx, req.RepoOwner, req.RepoName, req.Branch)
if err != nil {
upstreamError(w, "list_tree", err)
return
}
if tree == nil {
tree = []staticsite.FolderEntry{}
}
respondJSON(w, http.StatusOK, tree)
}
// imageConflict is a slim projection of Workload, scoped to what the
// /apps/new conflict dialog needs to render.
type imageConflict struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
AppID string `json:"app_id,omitempty"`
}
// listImageConflicts finds existing image-source workloads whose
// configured image matches the supplied ref, with or without tag.
// GET /api/discovery/image/conflicts?image=<ref>.
//
// Matching mirrors the legacy quickDeploy behavior: collide on
// repository-without-tag so nginx:1.25 surfaces nginx, nginx:latest,
// and nginx:1.26 as conflicts. This is intentionally permissive — the
// wizard surfaces matches but lets the operator decide.
func (s *Server) listImageConflicts(w http.ResponseWriter, r *http.Request) {
image := strings.TrimSpace(r.URL.Query().Get("image"))
if image == "" {
respondError(w, http.StatusBadRequest, "image query parameter is required")
return
}
target := stripImageTag(image)
if target == "" {
respondError(w, http.StatusBadRequest, "image is empty after tag strip")
return
}
workloads, err := s.store.ListWorkloads("")
if err != nil {
slog.Error("list workloads for conflict check", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
conflicts := []imageConflict{}
for _, wl := range workloads {
if wl.SourceKind != "image" {
continue
}
ref := imageRefFromSourceConfig(wl.SourceConfig)
if ref == "" {
continue
}
if stripImageTag(ref) != target {
continue
}
conflicts = append(conflicts, imageConflict{
ID: wl.ID,
Name: wl.Name,
Image: ref,
AppID: wl.AppID,
})
}
respondJSON(w, http.StatusOK, conflicts)
}
// inspectImageRequest is the body for POST /api/discovery/image/inspect.
type inspectImageRequest struct {
Image string `json:"image"`
}
// inspectImageResponse mirrors the frontend InspectResult shape the
// new-app wizard pre-fills from: the first exposed port (parsed to int,
// 0 when none) and the image's HEALTHCHECK command string.
type inspectImageResponse struct {
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
}
// inspectImageMetadata inspects a LOCAL image and returns its first
// exposed port + healthcheck so the wizard can pre-fill those fields.
// POST /api/discovery/image/inspect.
//
// This inspects local images only — it does not pull. When the image is
// not present locally the docker call fails; we return a generic,
// non-leaky 400 rather than the git-specific upstreamError so a raw
// docker daemon string (which may echo the ref) never reaches the client.
func (s *Server) inspectImageMetadata(w http.ResponseWriter, r *http.Request) {
var req inspectImageRequest
if !decodeJSON(w, r, &req) {
return
}
image := strings.TrimSpace(req.Image)
if image == "" {
respondError(w, http.StatusBadRequest, "image is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
info, err := s.docker.InspectImage(ctx, image)
if err != nil {
slog.Warn("inspect image metadata failed", "error", err)
respondError(w, http.StatusBadRequest, "could not inspect image — make sure it is pulled locally and the reference is correct")
return
}
respondJSON(w, http.StatusOK, inspectImageResponse{
Port: docker.ExtractPort(info.ExposedPorts),
Healthcheck: info.Healthcheck,
})
}
// stripImageTag returns the image reference with the trailing :tag
// removed, taking care to leave a registry port (e.g. registry:5000/foo)
// intact. Digest references (image@sha256:...) are returned unchanged.
func stripImageTag(ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" {
return ""
}
// Digest reference: keep as-is so two pinned-by-digest workloads do
// not collide with each other or with tag-based refs unless the
// caller asks for exact-match (we currently don't).
if at := strings.Index(ref, "@"); at >= 0 {
return ref[:at]
}
// Strip a :tag suffix only when the colon is in the final path
// segment — earlier colons belong to a registry port.
lastSlash := strings.LastIndex(ref, "/")
tail := ref
if lastSlash >= 0 {
tail = ref[lastSlash+1:]
}
if colon := strings.LastIndex(tail, ":"); colon >= 0 {
// Only strip if the tag part looks like a tag (no slashes,
// non-empty). Otherwise leave alone. When lastSlash is -1 the
// arithmetic still yields the right cut point (-1 + 1 + colon
// == colon), so no special case is needed.
tag := tail[colon+1:]
if tag != "" && !strings.ContainsAny(tag, "/") {
return ref[:lastSlash+1+colon]
}
}
return ref
}
// imageRefFromSourceConfig extracts the "image" field from a workload's
// source_config JSON. Returns "" when the blob is missing, malformed,
// or has no image field — those workloads simply do not contribute to
// conflict detection.
func imageRefFromSourceConfig(raw string) string {
if raw == "" {
return ""
}
var cfg struct {
Image string `json:"image"`
}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return ""
}
return strings.TrimSpace(cfg.Image)
}