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=. // // 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) }