410a131cec
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.
453 lines
15 KiB
Go
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)
|
|
}
|
|
|