package main
import (
"context"
"fmt"
"os"
"strings"
_ "github.com/sphireinc/foundry/internal/commands/imports"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/diag"
_ "github.com/sphireinc/foundry/internal/generated"
"github.com/sphireinc/foundry/internal/logx"
adminhttp "github.com/sphireinc/foundry/internal/admin/http"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/ops"
"github.com/sphireinc/foundry/internal/platformapi"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/renderer"
"github.com/sphireinc/foundry/internal/router"
"github.com/sphireinc/foundry/internal/server"
"github.com/sphireinc/foundry/internal/site"
"github.com/sphireinc/foundry/internal/theme"
)
func main() {
logx.InitFromEnv()
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
args, loadOpts, err := extractGlobalConfigFlags(os.Args)
if err != nil {
exitWithError(diag.Wrap(diag.KindUsage, "parse global flags", err))
}
if handled := handleConfigFreeCLI(args); handled {
return
}
cfg, err := config.LoadWithOptions(consts.ConfigFilePath, loadOpts)
if err != nil {
exitWithError(diag.Wrap(diag.KindConfig, "load config", err))
}
handleConfigBoundCLI(cfg, args)
pluginManager, err := plugins.NewManager(cfg.PluginsDir, cfg.Plugins.Enabled)
if err != nil {
exitWithError(diag.Wrap(diag.KindPlugin, "load plugins", err))
}
if err := pluginManager.OnConfigLoaded(cfg); err != nil {
exitWithError(diag.Wrap(diag.KindPlugin, "run plugin config hooks", err))
}
routeResolver := router.NewResolver(cfg)
themeManager := theme.NewManager(cfg.ThemesDir, cfg.Theme)
rendererEngine := renderer.New(cfg, themeManager, pluginManager)
ctx := context.Background()
switch args[1] {
case "build":
buildOpts, err := parseBuildFlags(args[2:])
if err != nil {
exitWithError(diag.Wrap(diag.KindUsage, "parse build flags", err))
}
if buildOpts.preview {
cfg.Build.IncludeDrafts = true
}
if err := pluginManager.OnBuildStarted(); err != nil {
exitWithError(diag.Wrap(diag.KindBuild, "run build start hooks", err))
}
graph, err := site.LoadGraphWithManager(ctx, cfg, pluginManager, cfg.Build.IncludeDrafts)
if err != nil {
exitWithError(err)
}
stats, err := rendererEngine.BuildWithStats(ctx, graph)
if err != nil {
exitWithError(diag.Wrap(diag.KindBuild, "build site", err))
}
if err := ops.WritePreviewManifest(cfg, graph, loadOpts.Target, buildOpts.preview); err != nil {
exitWithError(diag.Wrap(diag.KindBuild, "write preview manifest", err))
}
if err := ops.WriteBuildReport(cfg, graph, loadOpts.Target, buildOpts.preview, stats); err != nil {
exitWithError(diag.Wrap(diag.KindBuild, "write build report", err))
}
if err := pluginManager.OnBuildCompleted(graph); err != nil {
exitWithError(diag.Wrap(diag.KindBuild, "run build completed hooks", err))
}
cliout.Successf("build complete")
case "serve":
serveDebug, err := parseServeDebugFlag(args[2:])
if err != nil {
exitWithError(diag.Wrap(diag.KindUsage, "parse serve flags", err))
}
loader := content.NewLoader(cfg, pluginManager, false)
hooks := adminhttp.NewHooks(cfg, platformapi.NewHooks(cfg, pluginManager))
srv := server.New(cfg, loader, routeResolver, rendererEngine, hooks, false, server.WithDebugMode(serveDebug))
if err := srv.ListenAndServe(ctx); err != nil {
exitWithError(diag.Wrap(diag.KindServe, "serve site", err))
}
case "serve-preview":
serveDebug, err := parseServeDebugFlag(args[2:])
if err != nil {
exitWithError(diag.Wrap(diag.KindUsage, "parse serve-preview flags", err))
}
loader := content.NewLoader(cfg, pluginManager, true)
hooks := adminhttp.NewHooks(cfg, platformapi.NewHooks(cfg, pluginManager))
srv := server.New(cfg, loader, routeResolver, rendererEngine, hooks, true, server.WithDebugMode(serveDebug))
if err := srv.ListenAndServe(ctx); err != nil {
exitWithError(diag.Wrap(diag.KindServe, "serve preview site", err))
}
default:
exitWithError(diag.New(diag.KindUsage, fmt.Sprintf("unknown command: %s", os.Args[1])))
}
}
func handleConfigFreeCLI(args []string) bool {
cmd, ok := registry.Lookup(args)
if !ok {
return false
}
if cmd.RequiresConfig() {
return false
}
if err := cmd.Run(nil, args); err != nil {
exitWithError(diag.Wrap(diag.KindUsage, "run command", err))
}
return true
}
func handleConfigBoundCLI(cfg *config.Config, args []string) {
cmd, ok := registry.Lookup(args)
if !ok {
return
}
if !cmd.RequiresConfig() {
return
}
if err := cmd.Run(cfg, args); err != nil {
exitWithError(err)
}
os.Exit(0)
}
func printUsage() {
cliout.Println(registry.Usage())
}
func parseServeDebugFlag(args []string) (bool, error) {
debug := false
for _, arg := range args {
switch strings.TrimSpace(arg) {
case "":
continue
case "--debug":
debug = true
default:
return false, fmt.Errorf("unknown serve flag: %s", arg)
}
}
return debug, nil
}
type buildFlags struct {
preview bool
}
func parseBuildFlags(args []string) (buildFlags, error) {
var flags buildFlags
for _, arg := range args {
switch strings.TrimSpace(arg) {
case "":
continue
case "--preview":
flags.preview = true
default:
return buildFlags{}, fmt.Errorf("unknown build flag: %s", arg)
}
}
return flags, nil
}
func extractGlobalConfigFlags(args []string) ([]string, config.LoadOptions, error) {
if len(args) == 0 {
return nil, config.LoadOptions{}, nil
}
filtered := []string{args[0]}
var opts config.LoadOptions
for i := 1; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--env":
if i+1 >= len(args) {
return nil, config.LoadOptions{}, fmt.Errorf("--env requires a value")
}
opts.Environment = strings.TrimSpace(args[i+1])
i++
case strings.HasPrefix(arg, "--env="):
opts.Environment = strings.TrimSpace(strings.TrimPrefix(arg, "--env="))
case arg == "--target":
if i+1 >= len(args) {
return nil, config.LoadOptions{}, fmt.Errorf("--target requires a value")
}
opts.Target = strings.TrimSpace(args[i+1])
i++
case strings.HasPrefix(arg, "--target="):
opts.Target = strings.TrimSpace(strings.TrimPrefix(arg, "--target="))
case arg == "--config-overlay":
if i+1 >= len(args) {
return nil, config.LoadOptions{}, fmt.Errorf("--config-overlay requires a value")
}
opts.OverlayPaths = append(opts.OverlayPaths, strings.TrimSpace(args[i+1]))
i++
case strings.HasPrefix(arg, "--config-overlay="):
opts.OverlayPaths = append(opts.OverlayPaths, strings.TrimSpace(strings.TrimPrefix(arg, "--config-overlay=")))
default:
filtered = append(filtered, args[i])
}
}
return filtered, opts, nil
}
func exitWithError(err error) {
if err == nil {
return
}
logx.Error("command failed", "kind", diag.KindOf(err), "error", err)
cliout.Stderr(cliout.Fail(diag.Present(err)))
os.Exit(diag.ExitCode(err))
}
package main
import (
"fmt"
"os"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/diag"
"github.com/sphireinc/foundry/internal/logx"
"github.com/sphireinc/foundry/internal/plugins"
)
func main() {
logx.InitFromEnv()
project := plugins.NewProject(
consts.ConfigFilePath,
consts.PluginsDir,
consts.GeneratedPluginsFile,
plugins.DefaultSyncModulePath,
)
if err := project.Sync(); err != nil {
err = diag.Wrap(diag.KindPlugin, "sync plugins", err)
logx.Error("plugin sync failed", "kind", diag.KindOf(err), "error", err)
_, _ = fmt.Fprintln(os.Stderr, diag.Present(err))
os.Exit(diag.ExitCode(err))
}
}
package audit
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/config"
)
func Log(cfg *config.Config, entry admintypes.AuditEntry) (logErr error) {
if cfg == nil {
return nil
}
if entry.Timestamp.IsZero() {
entry.Timestamp = time.Now().UTC()
}
path := filePath(cfg)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); logErr == nil && cerr != nil {
logErr = cerr
}
}()
enc := json.NewEncoder(f)
logErr = enc.Encode(entry)
return
}
func List(cfg *config.Config, limit int) ([]admintypes.AuditEntry, error) {
if cfg == nil {
return nil, nil
}
path := filePath(cfg)
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return []admintypes.AuditEntry{}, nil
}
return nil, err
}
defer f.Close()
items := make([]admintypes.AuditEntry, 0)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var entry admintypes.AuditEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
items = append(items, entry)
}
if err := scanner.Err(); err != nil {
return nil, err
}
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
items[left], items[right] = items[right], items[left]
}
if limit > 0 && len(items) > limit {
items = items[:limit]
}
return items, nil
}
func filePath(cfg *config.Config) string {
if cfg == nil {
return filepath.Join("data", "admin", "audit.jsonl")
}
return filepath.Join(cfg.DataDir, "admin", "audit.jsonl")
}
package auth
import (
"crypto/subtle"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/sphireinc/foundry/internal/admin/users"
"github.com/sphireinc/foundry/internal/config"
)
type Middleware struct {
cfg *config.Config
sessions *SessionManager
loginThrottler *loginThrottler
}
func New(cfg *config.Config) *Middleware {
ttl := 30 * time.Minute
idleTimeout := time.Duration(0)
maxAge := time.Duration(0)
sessionStorePath := ""
sessionSecret := ""
singleSessionPerUser := false
if cfg != nil && cfg.Admin.SessionTTLMinutes > 0 {
ttl = time.Duration(cfg.Admin.SessionTTLMinutes) * time.Minute
}
if cfg != nil {
if cfg.Admin.SessionIdleTimeoutMinutes > 0 {
idleTimeout = time.Duration(cfg.Admin.SessionIdleTimeoutMinutes) * time.Minute
}
if cfg.Admin.SessionMaxAgeMinutes > 0 {
maxAge = time.Duration(cfg.Admin.SessionMaxAgeMinutes) * time.Minute
}
sessionStorePath = cfg.Admin.SessionStoreFile
sessionSecret = cfg.Admin.SessionSecret
singleSessionPerUser = cfg.Admin.SingleSessionPerUser
}
return &Middleware{
cfg: cfg,
sessions: NewSessionManager(sessionStorePath, ttl, idleTimeout, maxAge, sessionSecret, singleSessionPerUser),
loginThrottler: newLoginThrottler(),
}
}
type Identity struct {
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
MFAComplete bool `json:"mfa_complete,omitempty"`
CSRFToken string `json:"csrf_token,omitempty"`
}
func (m *Middleware) Authorize(r *http.Request) error {
_, _, err := m.authorizeRequest(r)
return err
}
func (m *Middleware) Authenticate(w http.ResponseWriter, r *http.Request) (*Identity, error) {
identity, sessionToken, err := m.authorizeRequest(r)
if err != nil {
return nil, err
}
if w != nil && sessionToken != "" {
m.setSessionCookie(w, r, sessionToken)
}
return identity, nil
}
func (m *Middleware) Login(w http.ResponseWriter, r *http.Request, username, password, totpCode string) (*Identity, error) {
if err := m.checkAccess(r); err != nil {
return nil, err
}
if err := m.loginThrottler.Allow(r, username, time.Now()); err != nil {
return nil, err
}
user, err := users.Find(m.cfg.Admin.UsersFile, username)
if err != nil {
m.loginThrottler.Failure(r, username, time.Now())
return nil, fmt.Errorf("invalid username or password")
}
plainTOTPSecret := ""
migratedTOTPSecret := ""
ok, upgradedHash, err := users.VerifyPasswordWithUpgrade(user.PasswordHash, password)
if err != nil || user.Disabled || !ok {
m.loginThrottler.Failure(r, username, time.Now())
return nil, fmt.Errorf("invalid username or password")
}
if upgradedHash != "" {
if err := users.UpdatePasswordHash(m.cfg.Admin.UsersFile, user.Username, upgradedHash); err == nil {
user.PasswordHash = upgradedHash
}
}
if user.TOTPEnabled {
plainTOTPSecret, migratedTOTPSecret, err = m.decodeTOTPSecret(user.TOTPSecret)
if err != nil {
m.loginThrottler.Failure(r, username, time.Now())
return nil, fmt.Errorf("two-factor authentication is not available")
}
if !VerifyTOTP(plainTOTPSecret, totpCode, time.Now()) {
m.loginThrottler.Failure(r, username, time.Now())
return nil, fmt.Errorf("two-factor authentication code is required")
}
if migratedTOTPSecret != "" {
_ = users.UpdateTOTPSecret(m.cfg.Admin.UsersFile, user.Username, migratedTOTPSecret)
user.TOTPSecret = migratedTOTPSecret
}
}
m.loginThrottler.Success(r, username)
identity := Identity{
Username: user.Username,
Name: user.Name,
Email: user.Email,
Role: normalizeRole(user.Role),
Capabilities: capabilitiesFor(user.Role, user.Capabilities),
MFAComplete: !user.TOTPEnabled || VerifyTOTP(plainTOTPSecret, totpCode, time.Now()),
}
session, err := m.sessions.Issue(identity, SessionIssueMeta{
RemoteAddr: requestRemoteAddr(r),
UserAgent: requestUserAgent(r),
}, time.Now())
if err != nil {
return nil, err
}
identity.CSRFToken = session.CSRFToken
m.setSessionCookie(w, r, session.Token)
return &identity, nil
}
func (m *Middleware) Logout(w http.ResponseWriter, r *http.Request) error {
if err := m.checkAccess(r); err != nil {
return err
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
m.sessions.Revoke(strings.TrimSpace(cookie.Value))
}
m.clearSessionCookie(w, r)
return nil
}
func (m *Middleware) SessionTTL() time.Duration {
if m == nil || m.sessions == nil {
return 30 * time.Minute
}
return m.sessions.TTL()
}
func (m *Middleware) RevokeUserSessions(username string) int {
if m == nil || m.sessions == nil {
return 0
}
return m.sessions.RevokeUser(username)
}
func (m *Middleware) RevokeAllSessions() int {
if m == nil || m.sessions == nil {
return 0
}
return m.sessions.RevokeAll()
}
func (m *Middleware) RevokeSessionID(id string) bool {
if m == nil || m.sessions == nil {
return false
}
return m.sessions.RevokeSessionID(id)
}
func (m *Middleware) ListSessions(username string, r *http.Request) []SessionSummary {
if m == nil || m.sessions == nil {
return nil
}
return m.sessions.List(username, extractSessionToken(r), time.Now())
}
func (m *Middleware) authorizeRequest(r *http.Request) (*Identity, string, error) {
if m == nil || m.cfg == nil {
return nil, "", nil
}
if err := m.checkAccess(r); err != nil {
return nil, "", err
}
if sessionToken := extractSessionToken(r); sessionToken != "" {
session, ok, reason := m.sessions.AuthenticateDetailed(sessionToken, time.Now())
if ok {
return &Identity{
Username: session.Username,
Name: session.Name,
Email: session.Email,
Role: normalizeRole(session.Role),
Capabilities: append([]string(nil), session.Capabilities...),
MFAComplete: session.MFAComplete,
CSRFToken: session.CSRFToken,
}, session.Token, nil
}
switch strings.TrimSpace(reason) {
case "inactivity":
return nil, "", fmt.Errorf("admin session expired due to inactivity")
case "maximum lifetime":
return nil, "", fmt.Errorf("admin session expired after reaching maximum lifetime")
default:
return nil, "", fmt.Errorf("admin session expired")
}
}
token := strings.TrimSpace(m.cfg.Admin.AccessToken)
if token != "" && tokensEqual(extractAccessToken(r), token) {
return &Identity{
Username: "api-token",
Name: "API Token",
Role: "admin",
Capabilities: capabilitiesFor("admin", nil),
MFAComplete: true,
}, "", nil
}
if strings.TrimSpace(extractAccessToken(r)) != "" {
return nil, "", fmt.Errorf("invalid admin access token")
}
return nil, "", fmt.Errorf("admin login is required")
}
func (m *Middleware) checkAccess(r *http.Request) error {
if m == nil || m.cfg == nil {
return nil
}
if !m.cfg.Admin.Enabled {
return fmt.Errorf("admin is disabled")
}
if m.cfg.Admin.LocalOnly && !isLocalRequest(r) {
return fmt.Errorf("admin is restricted to local requests")
}
return nil
}
func isLocalRequest(r *http.Request) bool {
if r == nil {
return false
}
if hasProxyHeaders(r) {
return false
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil {
host = strings.TrimSpace(r.RemoteAddr)
}
ip := net.ParseIP(host)
if ip == nil {
return host == "localhost"
}
return ip.IsLoopback()
}
func hasProxyHeaders(r *http.Request) bool {
for _, name := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP"} {
if strings.TrimSpace(r.Header.Get(name)) != "" {
return true
}
}
return false
}
func extractAccessToken(r *http.Request) string {
if r == nil {
return ""
}
if token := strings.TrimSpace(r.Header.Get("X-Foundry-Admin-Token")); token != "" {
return token
}
authz := strings.TrimSpace(r.Header.Get("Authorization"))
const bearerPrefix = "Bearer "
if strings.HasPrefix(authz, bearerPrefix) {
return strings.TrimSpace(authz[len(bearerPrefix):])
}
return ""
}
func extractSessionToken(r *http.Request) string {
if r == nil {
return ""
}
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return ""
}
return strings.TrimSpace(cookie.Value)
}
func requestRemoteAddr(r *http.Request) string {
if r == nil {
return ""
}
return strings.TrimSpace(r.RemoteAddr)
}
func requestUserAgent(r *http.Request) string {
if r == nil {
return ""
}
return strings.TrimSpace(r.UserAgent())
}
func tokensEqual(got, want string) bool {
if len(got) != len(want) {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
package auth
import "context"
type identityContextKey struct{}
func withIdentity(ctx context.Context, identity *Identity) context.Context {
if ctx == nil || identity == nil {
return ctx
}
return context.WithValue(ctx, identityContextKey{}, identity)
}
func IdentityFromContext(ctx context.Context) (*Identity, bool) {
if ctx == nil {
return nil, false
}
identity, ok := ctx.Value(identityContextKey{}).(*Identity)
return identity, ok && identity != nil
}
package auth
import (
"net/http"
"strings"
)
const sessionCookieName = "foundry_admin_session"
func (m *Middleware) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
if w == nil || token == "" {
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: m.adminPath(),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: requestIsHTTPS(r),
MaxAge: int(m.SessionTTL().Seconds()),
})
}
func (m *Middleware) clearSessionCookie(w http.ResponseWriter, r *http.Request) {
if w == nil {
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: m.adminPath(),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: requestIsHTTPS(r),
MaxAge: -1,
})
}
func requestIsHTTPS(r *http.Request) bool {
if r == nil {
return false
}
if r.TLS != nil {
return true
}
if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") {
return true
}
return strings.Contains(strings.ToLower(r.Header.Get("Forwarded")), "proto=https")
}
func (m *Middleware) adminPath() string {
if m == nil || m.cfg == nil {
return "/__admin"
}
return m.cfg.AdminPath()
}
package auth
import (
"net/http"
"strings"
)
func (m *Middleware) Wrap(next http.Handler) http.Handler {
return m.WrapCapability(next, "")
}
func (m *Middleware) WrapRole(next http.Handler, requiredRole string) http.Handler {
return m.WrapCapability(next, roleCapabilityRequirement(requiredRole))
}
func (m *Middleware) WrapCapability(next http.Handler, requiredCapability string) http.Handler {
if next == nil {
return http.NotFoundHandler()
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, err := m.Authenticate(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if err := m.enforceCSRF(identity, r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !capabilityAllowed(identity.Capabilities, requiredCapability) {
http.Error(w, "insufficient admin capabilities", http.StatusForbidden)
return
}
next.ServeHTTP(w, r.WithContext(withIdentity(r.Context(), identity)))
})
}
func (m *Middleware) enforceCSRF(identity *Identity, r *http.Request) error {
if r == nil || identity == nil {
return nil
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return nil
}
if extractSessionToken(r) == "" || extractAccessToken(r) != "" {
return nil
}
if tokensEqual(strings.TrimSpace(r.Header.Get("X-Foundry-CSRF-Token")), identity.CSRFToken) {
return nil
}
return errCSRFRequired
}
func roleCapabilityRequirement(role string) string {
switch normalizeRole(role) {
case "admin":
return "users.manage"
case "editor":
return "documents.read"
default:
return ""
}
}
var errCSRFRequired = &csrfError{"csrf token is required"}
type csrfError struct{ message string }
func (e *csrfError) Error() string { return e.message }
package auth
import (
"fmt"
"strings"
"unicode"
"github.com/sphireinc/foundry/internal/config"
)
func ValidatePassword(cfg *config.Config, password string) error {
password = strings.TrimSpace(password)
minLength := 12
if cfg != nil && cfg.Admin.PasswordMinLength > 0 {
minLength = cfg.Admin.PasswordMinLength
}
if len(password) < minLength {
return fmt.Errorf("password must be at least %d characters", minLength)
}
var hasUpper, hasLower, hasDigit, hasSpecial bool
for _, ch := range password {
switch {
case unicode.IsUpper(ch):
hasUpper = true
case unicode.IsLower(ch):
hasLower = true
case unicode.IsDigit(ch):
hasDigit = true
case unicode.IsPunct(ch) || unicode.IsSymbol(ch):
hasSpecial = true
}
}
if !hasUpper || !hasLower || !hasDigit || !hasSpecial {
return fmt.Errorf("password must include upper, lower, number, and special characters")
}
return nil
}
package auth
import "strings"
const capabilityAll = "*"
var roleCapabilities = map[string][]string{
"admin": {
capabilityAll,
},
"editor": {
"dashboard.read",
"documents.read",
"documents.create",
"documents.write",
"documents.review",
"documents.history",
"documents.lifecycle",
"documents.diff",
"media.read",
"media.write",
"media.lifecycle",
"audit.read",
},
"author": {
"dashboard.read",
"documents.read.own",
"documents.create",
"documents.write.own",
"documents.history.own",
"documents.lifecycle.own",
"documents.diff.own",
"media.read",
"media.write",
},
"reviewer": {
"dashboard.read",
"documents.read",
"documents.review",
"documents.history",
"documents.diff",
"media.read",
"audit.read",
},
}
func normalizeRole(role string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case "admin":
return "admin"
case "editor":
return "editor"
case "author":
return "author"
case "reviewer":
return "reviewer"
default:
return ""
}
}
func normalizeCapabilities(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func capabilitiesFor(role string, custom []string) []string {
role = normalizeRole(role)
base := append([]string(nil), roleCapabilities[role]...)
base = append(base, normalizeCapabilities(custom)...)
return normalizeCapabilities(base)
}
func capabilityAllowed(actual []string, required string) bool {
required = strings.TrimSpace(strings.ToLower(required))
if required == "" {
return true
}
normalized := normalizeCapabilities(actual)
for _, cap := range normalized {
if cap == capabilityAll || cap == required {
return true
}
if strings.HasSuffix(required, ".own") {
if cap == strings.TrimSuffix(required, ".own") {
return true
}
continue
}
if cap == required+".own" {
return true
}
}
return false
}
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
type Session struct {
ID string `yaml:"id,omitempty"`
Token string `yaml:"-"`
TokenHash string `yaml:"token_hash,omitempty"`
LegacyToken string `yaml:"token,omitempty"`
CSRFToken string `yaml:"csrf_token"`
Username string `yaml:"username"`
Name string `yaml:"name"`
Email string `yaml:"email"`
Role string `yaml:"role"`
Capabilities []string `yaml:"capabilities,omitempty"`
MFAComplete bool `yaml:"mfa_complete,omitempty"`
RemoteAddr string `yaml:"remote_addr,omitempty"`
UserAgent string `yaml:"user_agent,omitempty"`
IssuedAt time.Time `yaml:"issued_at"`
LastSeen time.Time `yaml:"last_seen"`
ExpiresAt time.Time `yaml:"expires_at"`
}
type SessionIssueMeta struct {
RemoteAddr string
UserAgent string
}
type SessionSummary struct {
ID string
Username string
Name string
Email string
Role string
MFAComplete bool
RemoteAddr string
UserAgent string
IssuedAt time.Time
LastSeen time.Time
ExpiresAt time.Time
Current bool
}
type sessionFile struct {
Sessions []Session `yaml:"sessions"`
}
type SessionManager struct {
path string
secret string
ttl time.Duration
idleTimeout time.Duration
maxAge time.Duration
singleSessionUser bool
mu sync.Mutex
sessions map[string]Session
}
func NewSessionManager(path string, ttl, idleTimeout, maxAge time.Duration, secret string, singleSessionUser bool) *SessionManager {
if ttl <= 0 {
ttl = 30 * time.Minute
}
m := &SessionManager{
path: path,
secret: stringsTrimSpace(secret),
ttl: ttl,
idleTimeout: idleTimeout,
maxAge: maxAge,
singleSessionUser: singleSessionUser,
sessions: make(map[string]Session),
}
_ = m.load()
return m
}
func (m *SessionManager) Issue(identity Identity, meta SessionIssueMeta, now time.Time) (Session, error) {
token, err := randomToken()
if err != nil {
return Session{}, err
}
csrfToken, err := randomToken()
if err != nil {
return Session{}, err
}
session := Session{
ID: sessionID(now),
Token: token,
TokenHash: m.hashToken(token),
CSRFToken: csrfToken,
Username: identity.Username,
Name: identity.Name,
Email: identity.Email,
Role: identity.Role,
Capabilities: append([]string(nil), identity.Capabilities...),
MFAComplete: identity.MFAComplete,
RemoteAddr: normalizeRemoteAddr(meta.RemoteAddr),
UserAgent: normalizeUserAgent(meta.UserAgent),
IssuedAt: now,
LastSeen: now,
ExpiresAt: now.Add(m.ttl),
}
m.mu.Lock()
defer m.mu.Unlock()
if m.singleSessionUser {
for tokenHash, existing := range m.sessions {
if normalizeUsername(existing.Username) == normalizeUsername(session.Username) {
delete(m.sessions, tokenHash)
}
}
}
session.ExpiresAt = m.nextExpiryLocked(session, now)
m.sessions[session.TokenHash] = session
if err := m.saveLocked(); err != nil {
delete(m.sessions, session.TokenHash)
return Session{}, err
}
return session, nil
}
func (m *SessionManager) Authenticate(token string, now time.Time) (Session, bool) {
session, ok, _ := m.authenticateDetailed(token, now)
return session, ok
}
func (m *SessionManager) AuthenticateDetailed(token string, now time.Time) (Session, bool, string) {
return m.authenticateDetailed(token, now)
}
func (m *SessionManager) authenticateDetailed(token string, now time.Time) (Session, bool, string) {
m.mu.Lock()
defer m.mu.Unlock()
sessionHash := m.hashToken(token)
session, ok := m.sessions[sessionHash]
if !ok {
return Session{}, false, ""
}
if m.maxAge > 0 && !session.IssuedAt.IsZero() && now.Sub(session.IssuedAt) > m.maxAge {
delete(m.sessions, sessionHash)
_ = m.saveLocked()
return Session{}, false, "maximum lifetime"
}
lastSeen := session.LastSeen
if lastSeen.IsZero() {
lastSeen = session.IssuedAt
}
if m.idleTimeout > 0 && !lastSeen.IsZero() && now.Sub(lastSeen) > m.idleTimeout {
delete(m.sessions, sessionHash)
_ = m.saveLocked()
return Session{}, false, "inactivity"
}
if now.After(session.ExpiresAt) {
delete(m.sessions, sessionHash)
_ = m.saveLocked()
return Session{}, false, "expiration"
}
session.LastSeen = now
session.ExpiresAt = m.nextExpiryLocked(session, now)
session.Token = token
session.TokenHash = sessionHash
m.sessions[sessionHash] = session
_ = m.saveLocked()
return session, true, ""
}
func (m *SessionManager) Revoke(token string) {
if token == "" {
return
}
m.mu.Lock()
delete(m.sessions, m.hashToken(token))
_ = m.saveLocked()
m.mu.Unlock()
}
func (m *SessionManager) RevokeUser(username string) int {
username = normalizeUsername(username)
if username == "" {
return 0
}
m.mu.Lock()
defer m.mu.Unlock()
count := 0
for token, session := range m.sessions {
if normalizeUsername(session.Username) == username {
delete(m.sessions, token)
count++
}
}
if count > 0 {
_ = m.saveLocked()
}
return count
}
func (m *SessionManager) RevokeAll() int {
m.mu.Lock()
defer m.mu.Unlock()
count := len(m.sessions)
m.sessions = make(map[string]Session)
_ = m.saveLocked()
return count
}
func (m *SessionManager) RevokeSessionID(id string) bool {
id = stringsTrimSpace(id)
if id == "" {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
for tokenHash, session := range m.sessions {
if stringsTrimSpace(session.ID) == id {
delete(m.sessions, tokenHash)
_ = m.saveLocked()
return true
}
}
return false
}
func (m *SessionManager) List(username, currentToken string, now time.Time) []SessionSummary {
currentHash := m.hashToken(currentToken)
username = normalizeUsername(username)
m.mu.Lock()
defer m.mu.Unlock()
dirty := false
out := make([]SessionSummary, 0, len(m.sessions))
for tokenHash, session := range m.sessions {
if now.After(session.ExpiresAt) {
delete(m.sessions, tokenHash)
dirty = true
continue
}
if username != "" && normalizeUsername(session.Username) != username {
continue
}
if stringsTrimSpace(session.ID) == "" {
session.ID = legacySessionID(tokenHash)
m.sessions[tokenHash] = session
dirty = true
}
out = append(out, SessionSummary{
ID: session.ID,
Username: session.Username,
Name: session.Name,
Email: session.Email,
Role: session.Role,
MFAComplete: session.MFAComplete,
RemoteAddr: session.RemoteAddr,
UserAgent: session.UserAgent,
IssuedAt: session.IssuedAt,
LastSeen: session.LastSeen,
ExpiresAt: session.ExpiresAt,
Current: tokenHash == currentHash && currentHash != "",
})
}
if dirty {
_ = m.saveLocked()
}
sort.Slice(out, func(i, j int) bool {
if out[i].Current != out[j].Current {
return out[i].Current
}
return out[i].LastSeen.After(out[j].LastSeen)
})
return out
}
func (m *SessionManager) TTL() time.Duration {
return m.ttl
}
func (m *SessionManager) IdleTimeout() time.Duration {
return m.idleTimeout
}
func (m *SessionManager) MaxAge() time.Duration {
return m.maxAge
}
func (m *SessionManager) load() error {
if stringsTrimSpace(m.path) == "" {
return nil
}
body, err := os.ReadFile(m.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var file sessionFile
if err := yaml.Unmarshal(body, &file); err != nil {
return err
}
now := time.Now()
m.mu.Lock()
defer m.mu.Unlock()
for _, session := range file.Sessions {
tokenHash := stringsTrimSpace(session.TokenHash)
if tokenHash == "" && stringsTrimSpace(session.LegacyToken) != "" {
tokenHash = m.hashToken(session.LegacyToken)
}
if tokenHash == "" || now.After(session.ExpiresAt) {
continue
}
if m.maxAge > 0 && !session.IssuedAt.IsZero() && now.Sub(session.IssuedAt) > m.maxAge {
continue
}
lastSeen := session.LastSeen
if lastSeen.IsZero() {
lastSeen = session.IssuedAt
}
if m.idleTimeout > 0 && !lastSeen.IsZero() && now.Sub(lastSeen) > m.idleTimeout {
continue
}
if stringsTrimSpace(session.ID) == "" {
session.ID = legacySessionID(tokenHash)
}
session.RemoteAddr = normalizeRemoteAddr(session.RemoteAddr)
session.UserAgent = normalizeUserAgent(session.UserAgent)
session.Token = ""
session.LegacyToken = ""
session.TokenHash = tokenHash
m.sessions[tokenHash] = session
}
return nil
}
func (m *SessionManager) saveLocked() error {
if stringsTrimSpace(m.path) == "" {
return nil
}
sessions := make([]Session, 0, len(m.sessions))
for _, session := range m.sessions {
session.Token = ""
session.LegacyToken = ""
sessions = append(sessions, session)
}
body, err := yaml.Marshal(sessionFile{Sessions: sessions})
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(m.path), 0o755); err != nil {
return err
}
return os.WriteFile(m.path, body, 0o600)
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func sessionID(now time.Time) string {
buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("sess-%d", now.UnixNano())
}
return "sess-" + strconv.FormatInt(now.Unix(), 36) + "-" + base64.RawURLEncoding.EncodeToString(buf)
}
func legacySessionID(tokenHash string) string {
tokenHash = stringsTrimSpace(tokenHash)
if tokenHash == "" {
return ""
}
if len(tokenHash) > 16 {
tokenHash = tokenHash[:16]
}
return "sess-legacy-" + tokenHash
}
func (m *SessionManager) hashToken(token string) string {
token = stringsTrimSpace(token)
if token == "" {
return ""
}
if m == nil || stringsTrimSpace(m.secret) == "" {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
mac := hmac.New(sha256.New, []byte(m.secret))
_, _ = mac.Write([]byte(token))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (m *SessionManager) nextExpiryLocked(session Session, now time.Time) time.Time {
candidates := make([]time.Time, 0, 3)
if m.ttl > 0 {
candidates = append(candidates, now.Add(m.ttl))
}
if m.idleTimeout > 0 {
candidates = append(candidates, now.Add(m.idleTimeout))
}
if m.maxAge > 0 && !session.IssuedAt.IsZero() {
candidates = append(candidates, session.IssuedAt.Add(m.maxAge))
}
if len(candidates) == 0 {
return now
}
earliest := candidates[0]
for _, candidate := range candidates[1:] {
if candidate.Before(earliest) {
earliest = candidate
}
}
return earliest
}
func normalizeUsername(value string) string {
return stringsTrimSpaceLower(value)
}
func stringsTrimSpace(value string) string {
return strings.TrimSpace(value)
}
func stringsTrimSpaceLower(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeRemoteAddr(value string) string {
value = stringsTrimSpace(value)
if value == "" {
return ""
}
if host, _, err := net.SplitHostPort(value); err == nil && stringsTrimSpace(host) != "" {
return stringsTrimSpace(host)
}
return value
}
func normalizeUserAgent(value string) string {
value = strings.Join(strings.Fields(stringsTrimSpace(value)), " ")
if len(value) > 200 {
return value[:200]
}
return value
}
package auth
import (
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
)
const (
loginMaxFailures = 5
loginFailureWindow = 10 * time.Minute
loginLockDuration = 15 * time.Minute
)
type loginAttempt struct {
failures int
firstFailed time.Time
lockedUntil time.Time
}
type loginThrottler struct {
mu sync.Mutex
attempts map[string]loginAttempt
}
func newLoginThrottler() *loginThrottler {
return &loginThrottler{attempts: make(map[string]loginAttempt)}
}
func (t *loginThrottler) Allow(r *http.Request, username string, now time.Time) error {
if t == nil {
return nil
}
key := loginAttemptKey(r, username)
t.mu.Lock()
defer t.mu.Unlock()
attempt := t.attempts[key]
if attempt.lockedUntil.After(now) {
return fmt.Errorf("too many login attempts; try again later")
}
if !attempt.firstFailed.IsZero() && now.Sub(attempt.firstFailed) > loginFailureWindow {
delete(t.attempts, key)
}
return nil
}
func (t *loginThrottler) Failure(r *http.Request, username string, now time.Time) {
if t == nil {
return
}
key := loginAttemptKey(r, username)
t.mu.Lock()
defer t.mu.Unlock()
attempt := t.attempts[key]
if attempt.firstFailed.IsZero() || now.Sub(attempt.firstFailed) > loginFailureWindow {
attempt = loginAttempt{firstFailed: now}
}
attempt.failures++
if attempt.failures >= loginMaxFailures {
attempt.lockedUntil = now.Add(loginLockDuration)
}
t.attempts[key] = attempt
}
func (t *loginThrottler) Success(r *http.Request, username string) {
if t == nil {
return
}
key := loginAttemptKey(r, username)
t.mu.Lock()
delete(t.attempts, key)
t.mu.Unlock()
}
func loginAttemptKey(r *http.Request, username string) string {
return requestIP(r) + "|" + strings.ToLower(strings.TrimSpace(username))
}
func requestIP(r *http.Request) string {
if r == nil {
return ""
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"fmt"
"net/url"
"strconv"
"strings"
"time"
)
func GenerateTOTPSecret() (string, error) {
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return strings.TrimRight(base32.StdEncoding.EncodeToString(buf), "="), nil
}
func VerifyTOTP(secret, code string, now time.Time) bool {
secret = strings.TrimSpace(secret)
code = normalizeTOTPCode(code)
if secret == "" || code == "" {
return false
}
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
if err != nil {
return false
}
for offset := -1; offset <= 1; offset++ {
if totpCodeForCounter(key, counterForTime(now.Add(time.Duration(offset)*30*time.Second))) == code {
return true
}
}
return false
}
func TOTPProvisioningURI(issuer, username, secret string) string {
issuer = strings.TrimSpace(issuer)
username = strings.TrimSpace(username)
issuer = firstNonEmptyString(issuer, "Foundry")
label := url.QueryEscape(issuer) + ":" + url.QueryEscape(username)
return "otpauth://totp/" + label + "?secret=" + url.QueryEscape(secret) + "&issuer=" + url.QueryEscape(issuer)
}
func normalizeTOTPCode(value string) string {
value = strings.ReplaceAll(strings.TrimSpace(value), " ", "")
if len(value) != 6 {
return ""
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return ""
}
}
return value
}
func counterForTime(now time.Time) uint64 {
return uint64(now.UTC().Unix() / 30)
}
func totpCodeForCounter(key []byte, counter uint64) string {
msg := []byte{
byte(counter >> 56), byte(counter >> 48), byte(counter >> 40), byte(counter >> 32),
byte(counter >> 24), byte(counter >> 16), byte(counter >> 8), byte(counter),
}
mac := hmac.New(sha1.New, key)
_, _ = mac.Write(msg)
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
binary := (int(sum[offset])&0x7f)<<24 | int(sum[offset+1])<<16 | int(sum[offset+2])<<8 | int(sum[offset+3])
return fmt.Sprintf("%06s", strconv.Itoa(binary%1000000))
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
package auth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"strings"
)
const totpSecretPrefix = "enc:v1:"
func (m *Middleware) requireTOTPSecretKey() ([]byte, error) {
if m == nil || m.cfg == nil {
return nil, fmt.Errorf("admin auth is not configured")
}
raw := strings.TrimSpace(m.cfg.Admin.TOTPSecretKey)
if raw == "" {
return nil, fmt.Errorf("TOTP secret encryption key is not configured")
}
key, err := decodeBase64Key(raw)
if err != nil {
return nil, fmt.Errorf("decode TOTP secret key: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("TOTP secret encryption key must decode to 32 bytes")
}
return key, nil
}
func (m *Middleware) encryptTOTPSecret(secret string) (string, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return "", nil
}
key, err := m.requireTOTPSecretKey()
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(secret), nil)
return totpSecretPrefix + base64.RawStdEncoding.EncodeToString(ciphertext), nil
}
func (m *Middleware) decodeTOTPSecret(stored string) (plain string, migrated string, err error) {
stored = strings.TrimSpace(stored)
if stored == "" {
return "", "", nil
}
if !strings.HasPrefix(stored, totpSecretPrefix) {
if strings.TrimSpace(m.cfg.Admin.TOTPSecretKey) != "" {
migrated, err = m.encryptTOTPSecret(stored)
if err != nil {
return "", "", err
}
}
return stored, migrated, nil
}
key, err := m.requireTOTPSecretKey()
if err != nil {
return "", "", err
}
body, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, totpSecretPrefix))
if err != nil {
return "", "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", err
}
if len(body) < gcm.NonceSize() {
return "", "", fmt.Errorf("invalid encrypted TOTP secret")
}
nonce := body[:gcm.NonceSize()]
ciphertext := body[gcm.NonceSize():]
plainBytes, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", "", err
}
return strings.TrimSpace(string(plainBytes)), "", nil
}
func decodeBase64Key(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("key cannot be empty")
}
if key, err := base64.RawStdEncoding.DecodeString(raw); err == nil {
return key, nil
}
return base64.StdEncoding.DecodeString(raw)
}
package auth
import (
"crypto/subtle"
"fmt"
"strings"
"time"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/admin/users"
)
func (m *Middleware) StartPasswordReset(identity *Identity, username string) (*types.PasswordResetStartResponse, error) {
if m == nil || m.cfg == nil {
return nil, fmt.Errorf("admin auth is not configured")
}
username = strings.TrimSpace(username)
if username == "" {
return nil, fmt.Errorf("username is required")
}
if identity == nil {
return nil, fmt.Errorf("admin identity is required")
}
if !strings.EqualFold(identity.Username, username) && !capabilityAllowed(identity.Capabilities, "users.manage") {
return nil, fmt.Errorf("password reset is not allowed for this user")
}
all, err := users.Load(m.cfg.Admin.UsersFile)
if err != nil {
return nil, err
}
token, err := randomToken()
if err != nil {
return nil, err
}
tokenHash, err := users.HashPassword(token)
if err != nil {
return nil, err
}
expiresAt := time.Now().UTC().Add(time.Duration(m.cfg.Admin.PasswordResetTTL) * time.Minute)
found := false
for i := range all {
if strings.EqualFold(all[i].Username, username) {
all[i].ResetTokenHash = tokenHash
all[i].ResetTokenExpires = expiresAt
found = true
break
}
}
if !found {
return nil, fmt.Errorf("user not found: %s", username)
}
if err := users.Save(m.cfg.Admin.UsersFile, all); err != nil {
return nil, err
}
return &types.PasswordResetStartResponse{
Username: username,
ResetToken: token,
ExpiresIn: int(time.Until(expiresAt).Seconds()),
}, nil
}
func (m *Middleware) CompletePasswordReset(req types.PasswordResetCompleteRequest) error {
if m == nil || m.cfg == nil {
return fmt.Errorf("admin auth is not configured")
}
if err := ValidatePassword(m.cfg, req.NewPassword); err != nil {
return err
}
all, err := users.Load(m.cfg.Admin.UsersFile)
if err != nil {
return err
}
username := strings.TrimSpace(req.Username)
found := false
now := time.Now().UTC()
for i := range all {
if !strings.EqualFold(all[i].Username, username) {
continue
}
if all[i].ResetTokenHash == "" || now.After(all[i].ResetTokenExpires) || !users.VerifyPassword(all[i].ResetTokenHash, req.ResetToken) {
return fmt.Errorf("invalid or expired reset token")
}
plainSecret, migratedSecret, err := m.decodeTOTPSecret(all[i].TOTPSecret)
if err != nil {
return fmt.Errorf("two-factor authentication is not available")
}
if all[i].TOTPEnabled && !VerifyTOTP(plainSecret, req.TOTPCode, now) {
return fmt.Errorf("valid two-factor code is required")
}
hash, err := users.HashPassword(req.NewPassword)
if err != nil {
return err
}
all[i].PasswordHash = hash
if migratedSecret != "" {
all[i].TOTPSecret = migratedSecret
}
all[i].ResetTokenHash = ""
all[i].ResetTokenExpires = time.Time{}
found = true
break
}
if !found {
return fmt.Errorf("user not found: %s", username)
}
if err := users.Save(m.cfg.Admin.UsersFile, all); err != nil {
return err
}
m.RevokeUserSessions(username)
return nil
}
func (m *Middleware) SetupTOTP(identity *Identity, username string) (*types.TOTPSetupResponse, error) {
if m == nil || m.cfg == nil {
return nil, fmt.Errorf("admin auth is not configured")
}
username = resolveTargetUsername(identity, username)
if username == "" {
return nil, fmt.Errorf("username is required")
}
if err := allowSameUserOrAdmin(identity, username); err != nil {
return nil, err
}
all, err := users.Load(m.cfg.Admin.UsersFile)
if err != nil {
return nil, err
}
secret, err := GenerateTOTPSecret()
if err != nil {
return nil, err
}
encryptedSecret, err := m.encryptTOTPSecret(secret)
if err != nil {
return nil, err
}
found := false
for i := range all {
if strings.EqualFold(all[i].Username, username) {
all[i].TOTPSecret = encryptedSecret
all[i].TOTPEnabled = false
found = true
break
}
}
if !found {
return nil, fmt.Errorf("user not found: %s", username)
}
if err := users.Save(m.cfg.Admin.UsersFile, all); err != nil {
return nil, err
}
return &types.TOTPSetupResponse{
Username: username,
Secret: secret,
ProvisioningURI: TOTPProvisioningURI(m.cfg.Admin.TOTPIssuer, username, secret),
}, nil
}
func (m *Middleware) EnableTOTP(identity *Identity, username, code string) error {
username = resolveTargetUsername(identity, username)
if username == "" {
return fmt.Errorf("username is required")
}
if err := allowSameUserOrAdmin(identity, username); err != nil {
return err
}
all, err := users.Load(m.cfg.Admin.UsersFile)
if err != nil {
return err
}
found := false
for i := range all {
if !strings.EqualFold(all[i].Username, username) {
continue
}
if all[i].TOTPSecret == "" {
return fmt.Errorf("two-factor authentication has not been set up")
}
plainSecret, migratedSecret, err := m.decodeTOTPSecret(all[i].TOTPSecret)
if err != nil {
return fmt.Errorf("two-factor authentication is not available")
}
if !VerifyTOTP(plainSecret, code, time.Now()) {
return fmt.Errorf("invalid two-factor code")
}
if migratedSecret != "" {
all[i].TOTPSecret = migratedSecret
}
all[i].TOTPEnabled = true
found = true
break
}
if !found {
return fmt.Errorf("user not found: %s", username)
}
if err := users.Save(m.cfg.Admin.UsersFile, all); err != nil {
return err
}
m.RevokeUserSessions(username)
return nil
}
func (m *Middleware) DisableTOTP(identity *Identity, username string) error {
username = resolveTargetUsername(identity, username)
if username == "" {
return fmt.Errorf("username is required")
}
if err := allowSameUserOrAdmin(identity, username); err != nil {
return err
}
all, err := users.Load(m.cfg.Admin.UsersFile)
if err != nil {
return err
}
found := false
for i := range all {
if strings.EqualFold(all[i].Username, username) {
all[i].TOTPEnabled = false
all[i].TOTPSecret = ""
found = true
break
}
}
if !found {
return fmt.Errorf("user not found: %s", username)
}
m.RevokeUserSessions(username)
return users.Save(m.cfg.Admin.UsersFile, all)
}
func allowSameUserOrAdmin(identity *Identity, username string) error {
if identity == nil {
return fmt.Errorf("admin identity is required")
}
if strings.EqualFold(identity.Username, username) || capabilityAllowed(identity.Capabilities, "users.manage") {
return nil
}
return fmt.Errorf("operation is not allowed for this user")
}
func resolveTargetUsername(identity *Identity, username string) string {
username = strings.TrimSpace(username)
if username != "" {
return username
}
if identity == nil {
return ""
}
return identity.Username
}
func sameToken(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
package httpadmin
import (
"net/http"
"strconv"
"strings"
adminaudit "github.com/sphireinc/foundry/internal/admin/audit"
adminauth "github.com/sphireinc/foundry/internal/admin/auth"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/plugins"
)
func (r *Router) logAudit(reqActor, action, outcome, target string, reqMetadata map[string]string) {
if r == nil || r.cfg == nil {
return
}
entry := admintypes.AuditEntry{
Action: strings.TrimSpace(action),
Outcome: strings.TrimSpace(outcome),
Target: strings.TrimSpace(target),
Metadata: reqMetadata,
}
if reqActor != "" {
entry.Actor = reqActor
}
_ = adminaudit.Log(r.cfg, entry)
}
func (r *Router) handleAudit(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
items, err := adminaudit.List(r.cfg, 200)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (r *Router) logAuditRequest(req *http.Request, action, outcome, target string, metadata map[string]string) {
if r == nil || r.cfg == nil {
return
}
entry := admintypes.AuditEntry{
Action: strings.TrimSpace(action),
Outcome: strings.TrimSpace(outcome),
Target: strings.TrimSpace(target),
RemoteAddr: strings.TrimSpace(req.RemoteAddr),
Metadata: metadata,
}
if identity, ok := adminauth.IdentityFromContext(req.Context()); ok {
entry.Actor = strings.TrimSpace(firstNonEmpty(identity.Name, identity.Username))
entry.ActorRole = strings.TrimSpace(identity.Role)
}
_ = adminaudit.Log(r.cfg, entry)
}
func (r *Router) logPluginSecurityAudit(req *http.Request, action, pluginName string) {
if r == nil || r.cfg == nil {
return
}
meta, err := plugins.LoadMetadata(r.cfg.PluginsDir, pluginName)
if err != nil {
return
}
report := plugins.AnalyzeInstalled(meta)
metadata := map[string]string{
"risk_tier": report.RiskTier,
"requires_approval": auditBoolString(report.RequiresApproval),
"mismatch_count": stringInt(len(report.Mismatches)),
"runtime_mode": report.Runtime.Mode,
"runtime_host": report.Effective.RuntimeHost,
"runtime_supported": auditBoolString(report.Effective.RuntimeSupported),
}
logMeta := metadata
entry := admintypes.AuditEntry{
Action: strings.TrimSpace(action),
Outcome: "success",
Target: strings.TrimSpace(pluginName),
RemoteAddr: strings.TrimSpace(req.RemoteAddr),
Metadata: logMeta,
}
if identity, ok := adminauth.IdentityFromContext(req.Context()); ok {
entry.Actor = strings.TrimSpace(firstNonEmpty(identity.Name, identity.Username))
entry.ActorRole = strings.TrimSpace(identity.Role)
}
_ = adminaudit.Log(r.cfg, entry)
if len(report.Mismatches) > 0 {
entry.Action = "plugin.security.mismatch"
_ = adminaudit.Log(r.cfg, entry)
}
}
func auditBoolString(v bool) string {
if v {
return "true"
}
return "false"
}
func stringInt(v int) string {
return strconv.Itoa(v)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
package httpadmin
import (
"net/http"
"strconv"
"strings"
"time"
adminauth "github.com/sphireinc/foundry/internal/admin/auth"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
)
// registerAuthRoutes returns the authentication and session-management routes
// for the admin API.
func registerAuthRoutes(r *Router) []routeDef {
return []routeDef{
{
pattern: r.routePath("/api/login"),
handler: http.HandlerFunc(r.handleLogin),
public: true,
},
{
pattern: r.routePath("/api/logout"),
handler: http.HandlerFunc(r.handleLogout),
public: true,
},
{
pattern: r.routePath("/api/session"),
handler: http.HandlerFunc(r.handleSession),
},
{
pattern: r.routePath("/api/sessions"),
handler: http.HandlerFunc(r.handleSessions),
capability: "users.manage",
},
{
pattern: r.routePath("/api/sessions/revoke"),
handler: http.HandlerFunc(r.handleSessionRevoke),
capability: "users.manage",
},
{
pattern: r.routePath("/api/password-reset/start"),
handler: http.HandlerFunc(r.handlePasswordResetStart),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/password-reset/complete"),
handler: http.HandlerFunc(r.handlePasswordResetComplete),
public: true,
},
{
pattern: r.routePath("/api/totp/setup"),
handler: http.HandlerFunc(r.handleTOTPSetup),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/totp/enable"),
handler: http.HandlerFunc(r.handleTOTPEnable),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/totp/disable"),
handler: http.HandlerFunc(r.handleTOTPDisable),
capability: "dashboard.read",
},
}
}
func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.LoginRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
identity, err := r.auth.Login(w, req, strings.TrimSpace(body.Username), body.Password, body.TOTPCode)
if err != nil {
r.logAudit(strings.TrimSpace(body.Username), "login", "failure", strings.TrimSpace(body.Username), map[string]string{"error": err.Error()})
writeJSONError(w, http.StatusForbidden, err)
return
}
r.logAudit(firstNonEmpty(identity.Name, identity.Username), "login", "success", identity.Username, nil)
writeJSON(w, http.StatusOK, admintypes.SessionResponse{
Authenticated: true,
Username: identity.Username,
Name: identity.Name,
Email: identity.Email,
Role: identity.Role,
Capabilities: identity.Capabilities,
MFAComplete: identity.MFAComplete,
CSRFToken: identity.CSRFToken,
TTLSeconds: int(r.auth.SessionTTL().Seconds()),
})
}
func (r *Router) handleLogout(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.auth.Logout(w, req); err != nil {
r.logAuditRequest(req, "logout", "failure", "", map[string]string{"error": err.Error()})
writeJSONError(w, http.StatusForbidden, err)
return
}
r.logAuditRequest(req, "logout", "success", "", nil)
writeJSON(w, http.StatusOK, admintypes.SessionResponse{})
}
func (r *Router) handleSession(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
identity, ok := adminauth.IdentityFromContext(req.Context())
if !ok {
writeJSONErrorMessage(w, http.StatusForbidden, "admin login is required")
return
}
writeJSON(w, http.StatusOK, admintypes.SessionResponse{
Authenticated: true,
Username: identity.Username,
Name: identity.Name,
Email: identity.Email,
Role: identity.Role,
Capabilities: identity.Capabilities,
MFAComplete: identity.MFAComplete,
CSRFToken: identity.CSRFToken,
TTLSeconds: int(r.auth.SessionTTL().Seconds()),
})
}
func (r *Router) handleSessionRevoke(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.SessionRevokeRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
revoked := 0
if body.All {
revoked = r.auth.RevokeAllSessions()
} else if strings.TrimSpace(body.SessionID) != "" {
if r.auth.RevokeSessionID(strings.TrimSpace(body.SessionID)) {
revoked = 1
}
} else {
revoked = r.auth.RevokeUserSessions(strings.TrimSpace(body.Username))
}
targetScope := "user"
if body.All {
targetScope = "all"
} else if strings.TrimSpace(body.SessionID) != "" {
targetScope = "session"
}
r.logAuditRequest(req, "session.revoke", "success", strings.TrimSpace(body.Username), map[string]string{
"revoked": strconv.Itoa(revoked),
"session_id": strings.TrimSpace(body.SessionID),
"target_scope": targetScope,
})
writeJSON(w, http.StatusOK, admintypes.SessionRevokeResponse{Revoked: revoked})
}
func (r *Router) handleSessions(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
records := r.auth.ListSessions(strings.TrimSpace(req.URL.Query().Get("username")), req)
out := make([]admintypes.SessionRecord, 0, len(records))
for _, session := range records {
out = append(out, admintypes.SessionRecord{
ID: session.ID,
Username: session.Username,
Name: session.Name,
Email: session.Email,
Role: session.Role,
MFAComplete: session.MFAComplete,
RemoteAddr: session.RemoteAddr,
UserAgent: session.UserAgent,
IssuedAt: session.IssuedAt.UTC().Format(time.RFC3339),
LastSeen: session.LastSeen.UTC().Format(time.RFC3339),
ExpiresAt: session.ExpiresAt.UTC().Format(time.RFC3339),
Current: session.Current,
})
}
writeJSON(w, http.StatusOK, out)
}
func (r *Router) handlePasswordResetStart(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.PasswordResetStartRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
identity, _ := adminauth.IdentityFromContext(req.Context())
resp, err := r.auth.StartPasswordReset(identity, body.Username)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "password_reset.start", "success", body.Username, nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handlePasswordResetComplete(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.PasswordResetCompleteRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
if err := r.auth.CompletePasswordReset(body); err != nil {
r.logAudit(strings.TrimSpace(body.Username), "password_reset.complete", "failure", body.Username, map[string]string{"error": err.Error()})
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAudit(strings.TrimSpace(body.Username), "password_reset.complete", "success", body.Username, map[string]string{"sessions_revoked": "true"})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (r *Router) handleTOTPSetup(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.TOTPSetupRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
identity, _ := adminauth.IdentityFromContext(req.Context())
resp, err := r.auth.SetupTOTP(identity, body.Username)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "totp.setup", "success", resp.Username, nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleTOTPEnable(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.TOTPEnableRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
identity, _ := adminauth.IdentityFromContext(req.Context())
if err := r.auth.EnableTOTP(identity, body.Username, body.Code); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "totp.enable", "success", body.Username, map[string]string{"sessions_revoked": "true"})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (r *Router) handleTOTPDisable(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.TOTPDisableRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
identity, _ := adminauth.IdentityFromContext(req.Context())
if err := r.auth.DisableTOTP(identity, body.Username); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "totp.disable", "success", body.Username, map[string]string{"sessions_revoked": "true"})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
package httpadmin
import (
"fmt"
"net/http"
"path/filepath"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/safepath"
)
func (r *Router) handleBackups(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
items, err := r.service.ListBackups(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (r *Router) handleCreateBackup(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.BackupCreateRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
record, err := r.service.CreateBackup(req.Context(), body.Name)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "backup.create", "success", record.Name, nil)
writeJSON(w, http.StatusOK, record)
}
func (r *Router) handleRestoreBackup(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.BackupRestoreRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
record, err := r.service.RestoreBackup(req.Context(), body.Name)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "backup.restore", "success", record.Name, nil)
writeJSON(w, http.StatusOK, record)
}
func (r *Router) handleDownloadBackup(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
target, err := r.service.BackupPath(req.URL.Query().Get("name"))
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
ok, err := safepath.IsWithinRoot(r.cfg.Backup.Dir, target)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
if !ok {
writeJSONError(w, http.StatusBadRequest, fmt.Errorf("backup path is outside backup root"))
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(target)+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, req, target)
}
func (r *Router) handleGitBackups(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
items, err := r.service.ListGitBackupSnapshots(req.Context(), 20)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (r *Router) handleCreateGitBackup(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.BackupGitSnapshotRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
record, err := r.service.CreateGitBackupSnapshot(req.Context(), body.Message, body.Push)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "backup.git_snapshot.create", "success", record.Revision, nil)
writeJSON(w, http.StatusOK, record)
}
package httpadmin
import (
"net/http"
"net/http/pprof"
"strings"
)
// registerDebugRoutes returns optional runtime-debug and pprof routes when
// admin.debug.pprof is enabled.
func registerDebugRoutes(r *Router) []routeDef {
if r == nil || r.cfg == nil || !r.cfg.Admin.Debug.Pprof {
return nil
}
return []routeDef{
{
pattern: r.routePath("/api/debug/runtime"),
handler: http.HandlerFunc(r.handleRuntimeStatus),
capability: "debug.read",
},
{
pattern: r.routePath("/api/debug/validate"),
handler: http.HandlerFunc(r.handleSiteValidation),
capability: "debug.read",
},
{
pattern: r.routePath("/debug/pprof/"),
handler: http.HandlerFunc(r.handlePprofIndex),
capability: "debug.read",
},
{
pattern: r.routePath("/debug/pprof/cmdline"),
handler: http.HandlerFunc(r.handlePprofCmdline),
capability: "debug.read",
},
{
pattern: r.routePath("/debug/pprof/profile"),
handler: http.HandlerFunc(r.handlePprofProfile),
capability: "debug.read",
},
{
pattern: r.routePath("/debug/pprof/symbol"),
handler: http.HandlerFunc(r.handlePprofSymbol),
capability: "debug.read",
},
{
pattern: r.routePath("/debug/pprof/trace"),
handler: http.HandlerFunc(r.handlePprofTrace),
capability: "debug.read",
},
}
}
func (r *Router) handleRuntimeStatus(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
status, err := r.service.GetRuntimeStatus(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (r *Router) handleSiteValidation(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
status, err := r.service.ValidateSite(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (r *Router) handlePprofIndex(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !strings.HasPrefix(req.URL.Path, r.routePath("/debug/pprof/")) {
http.NotFound(w, req)
return
}
pprof.Index(w, req)
}
func (r *Router) handlePprofCmdline(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
pprof.Cmdline(w, req)
}
func (r *Router) handlePprofProfile(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
pprof.Profile(w, req)
}
func (r *Router) handlePprofSymbol(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
pprof.Symbol(w, req)
}
func (r *Router) handlePprofTrace(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
pprof.Trace(w, req)
}
package httpadmin
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
adminauth "github.com/sphireinc/foundry/internal/admin/auth"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
)
const adminMediaUploadLimit = 256 << 20
// registerDocumentRoutes returns the document-editor and media-library route
// group for the admin API.
func registerDocumentRoutes(r *Router) []routeDef {
return []routeDef{
{
pattern: r.routePath("/api/documents"),
handler: http.HandlerFunc(r.handleDocuments),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/document"),
handler: http.HandlerFunc(r.handleDocument),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/create"),
handler: http.HandlerFunc(r.handleCreateDocument),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/save"),
handler: http.HandlerFunc(r.handleSaveDocument),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/lock"),
handler: http.HandlerFunc(r.handleDocumentLock),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/lock/heartbeat"),
handler: http.HandlerFunc(r.handleDocumentLockHeartbeat),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/unlock"),
handler: http.HandlerFunc(r.handleDocumentUnlock),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/history"),
handler: http.HandlerFunc(r.handleDocumentHistory),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/trash"),
handler: http.HandlerFunc(r.handleDocumentTrash),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/restore"),
handler: http.HandlerFunc(r.handleRestoreDocument),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/purge"),
handler: http.HandlerFunc(r.handlePurgeDocument),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/diff"),
handler: http.HandlerFunc(r.handleDocumentDiff),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/status"),
handler: http.HandlerFunc(r.handleDocumentStatus),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/delete"),
handler: http.HandlerFunc(r.handleDeleteDocument),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/documents/preview"),
handler: http.HandlerFunc(r.handlePreviewDocument),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/media"),
handler: http.HandlerFunc(r.handleMedia),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/media/detail"),
handler: http.HandlerFunc(r.handleMediaDetail),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/media/history"),
handler: http.HandlerFunc(r.handleMediaHistory),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/media/trash"),
handler: http.HandlerFunc(r.handleMediaTrash),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/media/upload"),
handler: http.HandlerFunc(r.handleMediaUpload),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/media/replace"),
handler: http.HandlerFunc(r.handleMediaReplace),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/media/metadata"),
handler: http.HandlerFunc(r.handleMediaMetadata),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/media/delete"),
handler: http.HandlerFunc(r.handleMediaDelete),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/media/restore"),
handler: http.HandlerFunc(r.handleMediaRestore),
capability: "dashboard.read",
},
{
pattern: r.routePath("/api/media/purge"),
handler: http.HandlerFunc(r.handleMediaPurge),
capability: "dashboard.read",
},
}
}
func (r *Router) handleDocuments(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
opts := admintypes.DocumentListOptions{
IncludeDrafts: truthy(req.URL.Query().Get("include_drafts")),
Type: strings.TrimSpace(req.URL.Query().Get("type")),
Lang: strings.TrimSpace(req.URL.Query().Get("lang")),
Query: strings.TrimSpace(req.URL.Query().Get("q")),
}
docs, err := r.service.ListDocuments(req.Context(), opts)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, docs)
}
func (r *Router) handleDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(req.URL.Query().Get("id"))
if id == "" {
writeJSONErrorMessage(w, http.StatusBadRequest, "missing required query parameter: id")
return
}
includeDrafts := truthy(req.URL.Query().Get("include_drafts"))
doc, err := r.service.GetDocument(req.Context(), id, includeDrafts)
if err != nil {
writeJSONError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, doc)
}
func (r *Router) handleSaveDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentSaveRequest
if err := decodeJSONBody(w, req, largeJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
body.Actor = actorLabel(req)
body.Username = actorUsername(req)
resp, err := r.service.SaveDocument(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "document.save", "success", resp.SourcePath, nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleDocumentLock(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentLockRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.AcquireDocumentLock(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleDocumentLockHeartbeat(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentLockRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.HeartbeatDocumentLock(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleDocumentUnlock(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentLockRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
if err := r.service.ReleaseDocumentLock(req.Context(), body); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, admintypes.DocumentLockResponse{})
}
func (r *Router) handleDocumentHistory(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
sourcePath := strings.TrimSpace(req.URL.Query().Get("source_path"))
if sourcePath == "" {
writeJSONErrorMessage(w, http.StatusBadRequest, "missing required query parameter: source_path")
return
}
resp, err := r.service.GetDocumentHistory(req.Context(), sourcePath)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleDocumentTrash(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := r.service.ListDocumentTrash(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleRestoreDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentLifecycleRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.RestoreDocument(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "document.restore", "success", firstNonEmpty(resp.RestoredPath, resp.Path), nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handlePurgeDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentLifecycleRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.PurgeDocument(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "document.purge", "success", resp.Path, nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleDocumentDiff(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentDiffRequest
if err := decodeJSONBody(w, req, mediumJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.DiffDocument(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleCreateDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentCreateRequest
if err := decodeJSONBody(w, req, mediumJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.CreateDocument(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "document.create", "success", resp.SourcePath, map[string]string{"kind": resp.Kind})
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handlePreviewDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentPreviewRequest
if err := decodeJSONBody(w, req, largeJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.PreviewDocument(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleDocumentStatus(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentStatusRequest
if err := decodeJSONBody(w, req, mediumJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.UpdateDocumentStatus(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "document.status", "success", resp.SourcePath, map[string]string{"status": resp.Status})
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleDeleteDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.DocumentDeleteRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.DeleteDocument(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "document.delete", "success", resp.SourcePath, map[string]string{"trash_path": resp.TrashPath})
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleMedia(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
items, err := r.service.ListMedia(req.Context(), strings.TrimSpace(req.URL.Query().Get("q")))
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (r *Router) handleMediaDetail(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
reference := strings.TrimSpace(req.URL.Query().Get("reference"))
if reference == "" {
writeJSONErrorMessage(w, http.StatusBadRequest, "missing required query parameter: reference")
return
}
item, err := r.service.GetMediaDetail(req.Context(), reference)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, item)
}
func (r *Router) handleMediaHistory(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
identifier := strings.TrimSpace(req.URL.Query().Get("reference"))
if identifier == "" {
identifier = strings.TrimSpace(req.URL.Query().Get("path"))
}
if identifier == "" {
writeJSONErrorMessage(w, http.StatusBadRequest, "missing required query parameter: reference or path")
return
}
resp, err := r.service.GetMediaHistory(req.Context(), identifier)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleMediaTrash(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := r.service.ListMediaTrash(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleMediaUpload(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
req.Body = http.MaxBytesReader(w, req.Body, adminMultipartMaxBody)
if err := req.ParseMultipartForm(adminMediaUploadLimit); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeJSONErrorMessage(w, http.StatusRequestEntityTooLarge, "media upload exceeds allowed size")
return
}
writeJSONError(w, http.StatusBadRequest, err)
return
}
file, header, err := req.FormFile("file")
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
defer file.Close()
body, err := io.ReadAll(io.LimitReader(file, adminMediaUploadLimit+1))
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
resp, err := r.service.SaveMedia(
req.Context(),
req.FormValue("collection"),
req.FormValue("dir"),
header.Filename,
header.Header.Get("Content-Type"),
body,
)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "media.upload", "success", resp.Reference, map[string]string{"created": boolString(resp.Created)})
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleMediaReplace(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
req.Body = http.MaxBytesReader(w, req.Body, adminMultipartMaxBody)
if err := req.ParseMultipartForm(adminMediaUploadLimit); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeJSONErrorMessage(w, http.StatusRequestEntityTooLarge, "media upload exceeds allowed size")
return
}
writeJSONError(w, http.StatusBadRequest, err)
return
}
reference := strings.TrimSpace(req.FormValue("reference"))
if reference == "" {
writeJSONErrorMessage(w, http.StatusBadRequest, "reference is required")
return
}
file, header, err := req.FormFile("file")
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
defer file.Close()
body, err := io.ReadAll(io.LimitReader(file, adminMediaUploadLimit+1))
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
resp, err := r.service.ReplaceMedia(req.Context(), reference, header.Header.Get("Content-Type"), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "media.replace", "success", resp.Reference, nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleMediaDelete(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.MediaDeleteRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
if err := r.service.DeleteMedia(req.Context(), body.Reference); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "media.delete", "success", body.Reference, nil)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (r *Router) handleMediaRestore(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.MediaLifecycleRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.RestoreMedia(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "media.restore", "success", firstNonEmpty(resp.RestoredPath, resp.Path), nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleMediaPurge(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.MediaLifecycleRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.PurgeMedia(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "media.purge", "success", resp.Path, nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleMediaMetadata(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.MediaMetadataSaveRequest
if err := decodeJSONBody(w, req, mediumJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
body.Actor = actorLabel(req)
resp, err := r.service.SaveMediaMetadata(req.Context(), body.Reference, body.Metadata, body.VersionComment, body.Actor)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "media.metadata", "success", body.Reference, nil)
writeJSON(w, http.StatusOK, resp)
}
func actorLabel(req *http.Request) string {
identity, ok := adminauth.IdentityFromContext(req.Context())
if !ok {
return ""
}
if name := strings.TrimSpace(identity.Name); name != "" {
return name
}
return strings.TrimSpace(identity.Username)
}
func actorUsername(req *http.Request) string {
identity, ok := adminauth.IdentityFromContext(req.Context())
if !ok {
return ""
}
return strings.TrimSpace(identity.Username)
}
func truthy(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func boolString(v bool) string {
if v {
return "true"
}
return "false"
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONError(w http.ResponseWriter, status int, err error) {
writeJSONErrorMessage(w, status, err.Error())
}
func writeJSONErrorMessage(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{
"error": msg,
})
}
package httpadmin
import (
"net/http"
"github.com/sphireinc/foundry/internal/logx"
)
func (r *Router) handleUpdateStatus(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := r.service.CheckForUpdates(req.Context())
if err != nil {
writeJSONError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleOperationsStatus(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := r.service.GetOperationsStatus(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleOperationsLogs(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := r.service.ReadOperationsLog(req.Context(), 120)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleOperationsValidate(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
status, err := r.service.ValidateSite(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (r *Router) handleOperationsClearCache(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.service.ClearOperationalCaches(req.Context()); err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
r.logAuditRequest(req, "operations.cache.clear", "success", "graph-cache", nil)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (r *Router) handleOperationsRebuild(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.service.RebuildSite(req.Context()); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "operations.rebuild", "success", "build", nil)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (r *Router) handleApplyUpdate(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
logx.Info("admin api update apply started", "path", req.URL.Path)
resp, err := r.service.ApplyUpdate(req.Context())
if err != nil {
logx.Error("admin api update apply failed", "path", req.URL.Path, "error", err)
writeJSONError(w, http.StatusBadRequest, err)
return
}
logx.Info("admin api update apply accepted", "latest_version", resp.LatestVersion, "install_mode", resp.InstallMode, "apply_supported", resp.ApplySupported)
r.logAuditRequest(req, "system.update.apply", "success", resp.LatestVersion, nil)
writeJSON(w, http.StatusAccepted, resp)
}
package httpadmin
import (
"net/http"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
)
func (r *Router) handlePlugins(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
items, err := r.service.ListPlugins(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (r *Router) handleEnablePlugin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.PluginToggleRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
if err := r.service.EnablePlugin(req.Context(), body.Name, body.ApproveRisk, body.AcknowledgeMismatches); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
metadata := map[string]string{}
if body.ApproveRisk {
metadata["approve_risk"] = "true"
}
if body.AcknowledgeMismatches {
metadata["acknowledge_mismatches"] = "true"
}
r.logAuditRequest(req, "plugin.enable", "success", body.Name, metadata)
if body.ApproveRisk || body.AcknowledgeMismatches {
r.logPluginSecurityAudit(req, "plugin.enable.approved", body.Name)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (r *Router) handleInstallPlugin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.PluginInstallRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
record, err := r.service.InstallPlugin(req.Context(), body.URL, body.Name, body.ApproveRisk, body.AcknowledgeMismatches)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
metadata := map[string]string{}
if body.ApproveRisk {
metadata["approve_risk"] = "true"
}
if body.AcknowledgeMismatches {
metadata["acknowledge_mismatches"] = "true"
}
r.logAuditRequest(req, "plugin.install", "success", record.Name, metadata)
if body.ApproveRisk || body.AcknowledgeMismatches {
r.logPluginSecurityAudit(req, "plugin.install.approved", record.Name)
}
writeJSON(w, http.StatusOK, record)
}
func (r *Router) handleValidatePlugin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.PluginToggleRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
record, err := r.service.ValidatePlugin(req.Context(), body.Name)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "plugin.validate", "success", record.Name, nil)
writeJSON(w, http.StatusOK, record)
}
func (r *Router) handleUpdatePlugin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.PluginToggleRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
record, err := r.service.UpdatePlugin(req.Context(), body.Name, body.ApproveRisk, body.AcknowledgeMismatches)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
metadata := map[string]string{}
if body.ApproveRisk {
metadata["approve_risk"] = "true"
}
if body.AcknowledgeMismatches {
metadata["acknowledge_mismatches"] = "true"
}
r.logAuditRequest(req, "plugin.update", "success", record.Name, metadata)
if body.ApproveRisk || body.AcknowledgeMismatches {
r.logPluginSecurityAudit(req, "plugin.update.approved", record.Name)
}
writeJSON(w, http.StatusOK, record)
}
func (r *Router) handleRollbackPlugin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.PluginToggleRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
record, err := r.service.RollbackPlugin(req.Context(), body.Name)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "plugin.rollback", "success", record.Name, nil)
writeJSON(w, http.StatusOK, record)
}
func (r *Router) handleDisablePlugin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.PluginToggleRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
if err := r.service.DisablePlugin(req.Context(), body.Name); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "plugin.disable", "success", body.Name, nil)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
package httpadmin
import (
"net/http"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
)
func (r *Router) handleCustomFieldsDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := r.service.LoadCustomFieldsDocument(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleSaveCustomFieldsDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.CustomFieldsSaveRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.SaveCustomFieldsDocument(req.Context(), body.Raw, body.Values)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "custom_fields.save", "success", resp.Path, nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleExtensions(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
registry, err := r.service.ListAdminExtensions(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, registry)
}
func (r *Router) handleSettingsSections(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
sections, err := r.service.ListSettingsSections(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, sections)
}
func (r *Router) handleSettingsForm(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := r.service.LoadSettingsForm(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleSaveSettingsForm(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.SettingsFormSaveRequest
if err := decodeJSONBody(w, req, configJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.SaveSettingsForm(req.Context(), body.Value)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "settings.form.save", "success", resp.Path, nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleConfigDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := r.service.LoadConfigDocument(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleSaveConfigDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.ConfigSaveRequest
if err := decodeJSONBody(w, req, configJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.SaveConfigDocument(req.Context(), body.Raw)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "config.save", "success", resp.Path, nil)
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleCustomCSSDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := r.service.LoadCustomCSSDocument(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleSaveCustomCSSDocument(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.CustomCSSSaveRequest
if err := decodeJSONBody(w, req, configJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
resp, err := r.service.SaveCustomCSSDocument(req.Context(), body.Raw)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "settings.custom_css.save", "success", resp.Path, nil)
writeJSON(w, http.StatusOK, resp)
}
package httpadmin
import (
"net/http"
adminauth "github.com/sphireinc/foundry/internal/admin/auth"
"github.com/sphireinc/foundry/internal/admin/service"
"github.com/sphireinc/foundry/internal/admin/types"
)
// registerStatusRoutes returns the status and capability-discovery routes used
// by admin frontends and the Admin SDK.
func registerStatusRoutes(r *Router) []routeDef {
return []routeDef{
{
pattern: r.routePath("/api/status"),
handler: http.HandlerFunc(r.handleStatus),
},
{
pattern: r.routePath("/api/capabilities"),
handler: http.HandlerFunc(r.handleCapabilities),
},
}
}
func (r *Router) handleStatus(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
status, err := r.service.GetSystemStatus(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (r *Router) handleCapabilities(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var identity *adminauth.Identity
if current, ok := adminauth.IdentityFromContext(req.Context()); ok {
identity = current
}
resp := types.CapabilityResponse{
SDKVersion: "v1",
Modules: map[string]bool{
"session": true,
"status": true,
"documents": true,
"media": true,
"settings": true,
"settings_sections": true,
"users": true,
"themes": true,
"plugins": true,
"audit": true,
"debug": r != nil && r.cfg != nil && r.cfg.Admin.Debug.Pprof,
"extensions": true,
"sync": false,
},
Features: map[string]bool{
"history": true,
"trash": true,
"diff": true,
"document_locks": true,
"workflow": true,
"structured_editing": true,
"plugin_admin_registry": true,
"settings_sections": true,
"pprof": r != nil && r.cfg != nil && r.cfg.Admin.Debug.Pprof,
"sync": false,
"storage": false,
},
}
if identity != nil {
resp.Capabilities = append([]string(nil), identity.Capabilities...)
resp.Identity = &types.SessionResponse{
Authenticated: true,
Username: identity.Username,
Name: identity.Name,
Email: identity.Email,
Role: identity.Role,
Capabilities: append([]string(nil), identity.Capabilities...),
MFAComplete: identity.MFAComplete,
}
}
writeJSON(w, http.StatusOK, resp)
}
var _ service.StatusProvider
package httpadmin
import (
"net/http"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
)
func (r *Router) handleThemes(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
items, err := r.service.ListThemes(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (r *Router) handleThemeSwitch(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.ThemeSwitchRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
var err error
if body.Kind == "admin" {
err = r.service.SwitchAdminTheme(req.Context(), body.Name)
} else {
err = r.service.SwitchTheme(req.Context(), body.Name)
}
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
target := body.Name
if body.Kind == "admin" {
target = "admin:" + body.Name
}
r.logAuditRequest(req, "theme.switch", "success", target, nil)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (r *Router) handleInstallTheme(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.ThemeInstallRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
record, err := r.service.InstallTheme(req.Context(), body.URL, body.Name, body.Kind)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
target := record.Name
if record.Kind == "admin" {
target = "admin:" + record.Name
}
r.logAuditRequest(req, "theme.install", "success", target, nil)
writeJSON(w, http.StatusOK, record)
}
func (r *Router) handleValidateTheme(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.ThemeSwitchRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
record, err := r.service.ValidateTheme(req.Context(), body.Name, body.Kind)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
target := body.Name
if body.Kind == "admin" {
target = "admin:" + body.Name
}
r.logAuditRequest(req, "theme.validate", "success", target, nil)
writeJSON(w, http.StatusOK, record)
}
package httpadmin
import (
"net/http"
"strings"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/admin/users"
)
func (r *Router) handleUsers(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
items, err := r.service.ListUsers(req.Context())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (r *Router) handleSaveUser(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.UserSaveRequest
if err := decodeJSONBody(w, req, mediumJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
var existing *users.User
if all, loadErr := users.Load(r.cfg.Admin.UsersFile); loadErr == nil {
for i := range all {
if strings.EqualFold(strings.TrimSpace(all[i].Username), strings.TrimSpace(body.Username)) {
copyUser := all[i]
existing = ©User
break
}
}
}
user, err := r.service.SaveUser(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
metadata := map[string]string{
"new_user": boolString(existing == nil),
}
if strings.TrimSpace(body.Password) != "" {
metadata["password_changed"] = "true"
metadata["sessions_revoked"] = "true"
}
if existing != nil {
if strings.TrimSpace(existing.Role) != strings.TrimSpace(user.Role) {
metadata["role_changed"] = "true"
metadata["sessions_revoked"] = "true"
}
if existing.Disabled != user.Disabled {
metadata["disabled_changed"] = "true"
metadata["sessions_revoked"] = "true"
}
if !stringSliceSetEqual(existing.Capabilities, user.Capabilities) {
metadata["capabilities_changed"] = "true"
metadata["sessions_revoked"] = "true"
}
}
r.logAuditRequest(req, "user.save", "success", user.Username, metadata)
writeJSON(w, http.StatusOK, user)
}
func (r *Router) handleDeleteUser(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body admintypes.UserDeleteRequest
if err := decodeJSONBody(w, req, smallJSONBodyLimit, &body); err != nil {
if !writeRequestBodyError(w, err) {
writeJSONError(w, http.StatusBadRequest, err)
}
return
}
if err := r.service.DeleteUser(req.Context(), body.Username); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "user.delete", "success", body.Username, map[string]string{"sessions_revoked": "true"})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func stringSliceSetEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
set := make(map[string]int, len(a))
for _, item := range a {
set[strings.TrimSpace(item)]++
}
for _, item := range b {
trimmed := strings.TrimSpace(item)
if set[trimmed] == 0 {
return false
}
set[trimmed]--
}
for _, count := range set {
if count != 0 {
return false
}
}
return true
}
package httpadmin
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
)
const (
smallJSONBodyLimit int64 = 64 << 10
mediumJSONBodyLimit int64 = 1 << 20
largeJSONBodyLimit int64 = 8 << 20
configJSONBodyLimit int64 = 2 << 20
adminMultipartMaxBody int64 = adminMediaUploadLimit + (1 << 20)
)
type requestBodyError struct {
status int
message string
}
func (e *requestBodyError) Error() string {
return e.message
}
func decodeJSONBody(w http.ResponseWriter, req *http.Request, limit int64, dst any) error {
if req == nil || req.Body == nil {
return &requestBodyError{status: http.StatusBadRequest, message: "request body is required"}
}
if limit > 0 {
req.Body = http.MaxBytesReader(w, req.Body, limit)
}
dec := json.NewDecoder(req.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var maxErr *http.MaxBytesError
switch {
case errors.As(err, &maxErr):
return &requestBodyError{status: http.StatusRequestEntityTooLarge, message: fmt.Sprintf("request body exceeds %d bytes", limit)}
case errors.Is(err, io.EOF):
return &requestBodyError{status: http.StatusBadRequest, message: "request body is required"}
default:
return &requestBodyError{status: http.StatusBadRequest, message: "invalid JSON request body"}
}
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return &requestBodyError{status: http.StatusBadRequest, message: "request body must contain a single JSON object"}
}
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return &requestBodyError{status: http.StatusRequestEntityTooLarge, message: fmt.Sprintf("request body exceeds %d bytes", limit)}
}
return &requestBodyError{status: http.StatusBadRequest, message: "invalid JSON request body"}
}
return nil
}
func writeRequestBodyError(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
var bodyErr *requestBodyError
if !errors.As(err, &bodyErr) {
return false
}
writeJSONErrorMessage(w, bodyErr.status, bodyErr.message)
return true
}
package httpadmin
import (
"net/http"
"os"
"path/filepath"
"strings"
adminauth "github.com/sphireinc/foundry/internal/admin/auth"
"github.com/sphireinc/foundry/internal/admin/service"
adminui "github.com/sphireinc/foundry/internal/admin/ui"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/safepath"
"github.com/sphireinc/foundry/internal/server"
)
// routeDef describes one admin HTTP route and the auth/capability wrapper it
// should receive when mounted.
type routeDef struct {
pattern string
handler http.Handler
public bool
capability string
}
// Registrar returns a cohesive group of admin routes, such as auth, documents,
// management, or debug.
type Registrar func(*Router) []routeDef
// Router owns the authenticated admin HTTP surface and the theme-backed admin
// shell.
type Router struct {
cfg *config.Config
service *service.Service
auth *adminauth.Middleware
ui *adminui.Manager
registrars []Registrar
}
// New constructs the admin router and registers the built-in route groups.
func New(cfg *config.Config, svc *service.Service) *Router {
r := &Router{
cfg: cfg,
service: svc,
auth: adminauth.New(cfg),
ui: adminui.NewManager(cfg),
registrars: make([]Registrar, 0),
}
r.RegisterRegistrar(registerAuthRoutes)
r.RegisterRegistrar(registerStatusRoutes)
r.RegisterRegistrar(registerDocumentRoutes)
r.RegisterRegistrar(registerManagementRoutes)
r.RegisterRegistrar(registerDebugRoutes)
return r
}
// NewHooks composes the admin router into the preview-server hook chain when
// admin is enabled.
func NewHooks(cfg *config.Config, base server.Hooks, opts ...service.Option) server.Hooks {
if cfg == nil || !cfg.Admin.Enabled {
if base == nil {
return hookBase{}
}
return base
}
if metadata := pluginMetadataProvider(base); metadata != nil {
opts = append(opts, service.WithPluginMetadata(metadata))
}
svc := service.New(cfg, opts...)
router := New(cfg, svc)
return WrapHooks(base, router)
}
func pluginMetadataProvider(h any) func() map[string]plugins.Metadata {
for h != nil {
if pm, ok := h.(interface {
Metadata() map[string]plugins.Metadata
}); ok {
return pm.Metadata
}
unwrap, ok := h.(interface {
UnwrapHooks() any
})
if !ok {
return nil
}
h = unwrap.UnwrapHooks()
}
return nil
}
// RegisterRegistrar appends an admin route group to the router.
func (r *Router) RegisterRegistrar(reg Registrar) {
if reg == nil {
return
}
r.registrars = append(r.registrars, reg)
}
// RegisterRoutes mounts the admin shell, admin theme assets, plugin extension
// assets, and all registered admin route groups.
func (r *Router) RegisterRoutes(mux *http.ServeMux) {
if r == nil || mux == nil || r.cfg == nil || !r.cfg.Admin.Enabled {
return
}
mux.Handle(r.adminBasePath(), http.HandlerFunc(r.handleIndex))
mux.Handle(r.adminBasePath()+"/", http.HandlerFunc(r.handleIndex))
mux.Handle(r.themeBasePath()+"/", http.StripPrefix(r.themeBasePath()+"/", r.ui.AssetHandler()))
mux.Handle(r.extensionAssetBasePath()+"/", r.auth.Wrap(http.HandlerFunc(r.handlePluginExtensionAsset)))
for _, reg := range r.registrars {
for _, rd := range reg(r) {
if rd.public {
mux.Handle(rd.pattern, rd.handler)
continue
}
if strings.TrimSpace(rd.capability) != "" {
mux.Handle(rd.pattern, r.auth.WrapCapability(rd.handler, rd.capability))
continue
}
mux.Handle(rd.pattern, r.auth.Wrap(rd.handler))
}
}
}
// handleIndex serves the admin shell for non-API admin routes.
func (r *Router) handleIndex(w http.ResponseWriter, req *http.Request) {
if !strings.HasPrefix(req.URL.Path, r.adminBasePath()) ||
strings.HasPrefix(req.URL.Path, r.apiBasePath()+"/") ||
strings.HasPrefix(req.URL.Path, r.themeBasePath()+"/") ||
strings.HasPrefix(req.URL.Path, r.routePath("/debug/pprof/")) {
http.NotFound(w, req)
return
}
body, err := r.ui.RenderIndex()
if err != nil {
http.Error(w, "admin UI render failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'")
_, _ = w.Write(body)
}
func (r *Router) adminBasePath() string {
if r == nil || r.cfg == nil {
return "/__admin"
}
return r.cfg.AdminPath()
}
func (r *Router) apiBasePath() string {
return r.adminBasePath() + "/api"
}
func (r *Router) themeBasePath() string {
return r.adminBasePath() + "/theme"
}
func (r *Router) extensionAssetBasePath() string {
return r.adminBasePath() + "/extensions"
}
func (r *Router) routePath(suffix string) string {
suffix = strings.TrimSpace(suffix)
if suffix == "" || suffix == "/" {
return r.adminBasePath()
}
if !strings.HasPrefix(suffix, "/") {
suffix = "/" + suffix
}
return r.adminBasePath() + suffix
}
// handlePluginExtensionAsset serves plugin-declared admin JS/CSS bundles after
// verifying the requested asset is part of the plugin's published admin
// contract.
func (r *Router) handlePluginExtensionAsset(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
rel := strings.TrimPrefix(req.URL.Path, r.extensionAssetBasePath()+"/")
parts := strings.SplitN(rel, "/", 2)
if len(parts) != 2 {
http.NotFound(w, req)
return
}
pluginName, err := safepath.ValidatePathComponent("plugin name", parts[0])
if err != nil {
http.NotFound(w, req)
return
}
assetPath, err := plugins.NormalizeAdminAssetPath(parts[1])
if err != nil {
http.NotFound(w, req)
return
}
if r.service == nil || !r.service.AllowsAdminAsset(pluginName, assetPath) {
http.NotFound(w, req)
return
}
root := filepath.Join(r.cfg.PluginsDir, pluginName)
target, err := safepath.ResolveRelativeUnderRoot(root, assetPath)
if err != nil {
http.NotFound(w, req)
return
}
info, err := os.Stat(target)
if err != nil || info.IsDir() {
http.NotFound(w, req)
return
}
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, req, target)
}
// WrapHooks combines an existing preview-server hook set with the admin router.
func WrapHooks(base server.Hooks, admin *Router) server.Hooks {
if base == nil {
base = hookBase{}
}
if admin == nil {
return base
}
return hookSet{
base: base,
admin: admin,
}
}
type hookSet struct {
base server.Hooks
admin *Router
}
func (h hookSet) RegisterRoutes(mux *http.ServeMux) {
h.base.RegisterRoutes(mux)
h.admin.RegisterRoutes(mux)
}
func (h hookSet) OnServerStarted(addr string) error {
return h.base.OnServerStarted(addr)
}
func (h hookSet) OnRoutesAssigned(graph *content.SiteGraph) error {
return h.base.OnRoutesAssigned(graph)
}
func (h hookSet) OnAssetsBuilding(cfg *config.Config) error {
return h.base.OnAssetsBuilding(cfg)
}
type hookBase struct{}
func (hookBase) RegisterRoutes(_ *http.ServeMux) {}
func (hookBase) OnServerStarted(_ string) error { return nil }
func (hookBase) OnRoutesAssigned(_ *content.SiteGraph) error { return nil }
func (hookBase) OnAssetsBuilding(_ *config.Config) error { return nil }
package httpadmin
import "net/http"
// registerManagementRoutes returns admin configuration, users, themes, plugins,
// extensions, and audit routes.
func registerManagementRoutes(r *Router) []routeDef {
routes := make([]routeDef, 0, 32)
routes = append(routes, backupRoutes(r)...)
routes = append(routes, operationsRoutes(r)...)
routes = append(routes, settingsRoutes(r)...)
routes = append(routes, userRoutes(r)...)
routes = append(routes, themeRoutes(r)...)
routes = append(routes, pluginRoutes(r)...)
routes = append(routes, auditRoutes(r)...)
return routes
}
func backupRoutes(r *Router) []routeDef {
return []routeDef{
{pattern: r.routePath("/api/backups"), handler: http.HandlerFunc(r.handleBackups), capability: "config.manage"},
{pattern: r.routePath("/api/backups/create"), handler: http.HandlerFunc(r.handleCreateBackup), capability: "config.manage"},
{pattern: r.routePath("/api/backups/restore"), handler: http.HandlerFunc(r.handleRestoreBackup), capability: "config.manage"},
{pattern: r.routePath("/api/backups/download"), handler: http.HandlerFunc(r.handleDownloadBackup), capability: "config.manage"},
{pattern: r.routePath("/api/backups/git"), handler: http.HandlerFunc(r.handleGitBackups), capability: "config.manage"},
{pattern: r.routePath("/api/backups/git/create"), handler: http.HandlerFunc(r.handleCreateGitBackup), capability: "config.manage"},
}
}
func operationsRoutes(r *Router) []routeDef {
return []routeDef{
{pattern: r.routePath("/api/operations"), handler: http.HandlerFunc(r.handleOperationsStatus), capability: "dashboard.read"},
{pattern: r.routePath("/api/operations/logs"), handler: http.HandlerFunc(r.handleOperationsLogs), capability: "dashboard.read"},
{pattern: r.routePath("/api/operations/validate"), handler: http.HandlerFunc(r.handleOperationsValidate), capability: "dashboard.read"},
{pattern: r.routePath("/api/operations/cache/clear"), handler: http.HandlerFunc(r.handleOperationsClearCache), capability: "config.manage"},
{pattern: r.routePath("/api/operations/rebuild"), handler: http.HandlerFunc(r.handleOperationsRebuild), capability: "config.manage"},
{pattern: r.routePath("/api/update"), handler: http.HandlerFunc(r.handleUpdateStatus), capability: "dashboard.read"},
{pattern: r.routePath("/api/update/apply"), handler: http.HandlerFunc(r.handleApplyUpdate), capability: "config.manage"},
}
}
func settingsRoutes(r *Router) []routeDef {
return []routeDef{
{pattern: r.routePath("/api/extensions"), handler: http.HandlerFunc(r.handleExtensions), capability: "dashboard.read"},
{pattern: r.routePath("/api/settings/sections"), handler: http.HandlerFunc(r.handleSettingsSections), capability: "dashboard.read"},
{pattern: r.routePath("/api/settings/form"), handler: http.HandlerFunc(r.handleSettingsForm), capability: "config.manage"},
{pattern: r.routePath("/api/settings/form/save"), handler: http.HandlerFunc(r.handleSaveSettingsForm), capability: "config.manage"},
{pattern: r.routePath("/api/settings/custom-css"), handler: http.HandlerFunc(r.handleCustomCSSDocument), capability: "config.manage"},
{pattern: r.routePath("/api/settings/custom-css/save"), handler: http.HandlerFunc(r.handleSaveCustomCSSDocument), capability: "config.manage"},
{pattern: r.routePath("/api/custom-fields"), handler: http.HandlerFunc(r.handleCustomFieldsDocument), capability: "documents.read"},
{pattern: r.routePath("/api/custom-fields/save"), handler: http.HandlerFunc(r.handleSaveCustomFieldsDocument), capability: "config.manage"},
{pattern: r.routePath("/api/config"), handler: http.HandlerFunc(r.handleConfigDocument), capability: "config.manage"},
{pattern: r.routePath("/api/config/save"), handler: http.HandlerFunc(r.handleSaveConfigDocument), capability: "config.manage"},
}
}
func userRoutes(r *Router) []routeDef {
return []routeDef{
{pattern: r.routePath("/api/users"), handler: http.HandlerFunc(r.handleUsers), capability: "users.manage"},
{pattern: r.routePath("/api/users/save"), handler: http.HandlerFunc(r.handleSaveUser), capability: "users.manage"},
{pattern: r.routePath("/api/users/delete"), handler: http.HandlerFunc(r.handleDeleteUser), capability: "users.manage"},
}
}
func themeRoutes(r *Router) []routeDef {
return []routeDef{
{pattern: r.routePath("/api/themes"), handler: http.HandlerFunc(r.handleThemes), capability: "themes.manage"},
{pattern: r.routePath("/api/themes/install"), handler: http.HandlerFunc(r.handleInstallTheme), capability: "themes.manage"},
{pattern: r.routePath("/api/themes/validate"), handler: http.HandlerFunc(r.handleValidateTheme), capability: "themes.manage"},
{pattern: r.routePath("/api/themes/switch"), handler: http.HandlerFunc(r.handleThemeSwitch), capability: "themes.manage"},
}
}
func pluginRoutes(r *Router) []routeDef {
return []routeDef{
{pattern: r.routePath("/api/plugins"), handler: http.HandlerFunc(r.handlePlugins), capability: "plugins.manage"},
{pattern: r.routePath("/api/plugins/validate"), handler: http.HandlerFunc(r.handleValidatePlugin), capability: "plugins.manage"},
{pattern: r.routePath("/api/plugins/install"), handler: http.HandlerFunc(r.handleInstallPlugin), capability: "plugins.manage"},
{pattern: r.routePath("/api/plugins/update"), handler: http.HandlerFunc(r.handleUpdatePlugin), capability: "plugins.manage"},
{pattern: r.routePath("/api/plugins/rollback"), handler: http.HandlerFunc(r.handleRollbackPlugin), capability: "plugins.manage"},
{pattern: r.routePath("/api/plugins/enable"), handler: http.HandlerFunc(r.handleEnablePlugin), capability: "plugins.manage"},
{pattern: r.routePath("/api/plugins/disable"), handler: http.HandlerFunc(r.handleDisablePlugin), capability: "plugins.manage"},
}
}
func auditRoutes(r *Router) []routeDef {
return []routeDef{
{pattern: r.routePath("/api/audit"), handler: http.HandlerFunc(r.handleAudit), capability: "audit.read"},
}
}
package service
import (
"context"
"fmt"
"strings"
adminauth "github.com/sphireinc/foundry/internal/admin/auth"
"github.com/sphireinc/foundry/internal/content"
)
func currentIdentity(ctx context.Context) (*adminauth.Identity, bool) {
return adminauth.IdentityFromContext(ctx)
}
func requireCapability(ctx context.Context, capability string) error {
identity, ok := currentIdentity(ctx)
if !ok {
return nil
}
if !adminauthCapabilityAllowed(identity, capability) {
return fmt.Errorf("insufficient capability: %s", capability)
}
return nil
}
func adminauthCapabilityAllowed(identity *adminauth.Identity, capability string) bool {
if identity == nil {
return false
}
for _, candidate := range identity.Capabilities {
candidate = strings.TrimSpace(strings.ToLower(candidate))
capability = strings.TrimSpace(strings.ToLower(capability))
if candidate == "*" || candidate == capability {
return true
}
if strings.HasSuffix(capability, ".own") && candidate == strings.TrimSuffix(capability, ".own") {
return true
}
if candidate == capability+".own" {
return true
}
}
return false
}
func documentOwnerFromParams(params map[string]any) string {
if params == nil {
return ""
}
if value, ok := params["owner"].(string); ok {
return strings.TrimSpace(value)
}
return ""
}
func documentOwnerFromFrontMatter(fm *content.FrontMatter) string {
if fm == nil {
return ""
}
if value := strings.TrimSpace(fm.Author); value != "" {
return value
}
return documentOwnerFromParams(fm.Params)
}
func documentOwner(doc *content.Document) string {
if doc == nil {
return ""
}
if value := strings.TrimSpace(doc.Author); value != "" {
return value
}
return documentOwnerFromParams(doc.Params)
}
func canAccessDocument(identity *adminauth.Identity, doc *content.Document) bool {
if identity == nil || doc == nil {
return false
}
if adminauthCapabilityAllowed(identity, "documents.read") {
return true
}
if adminauthCapabilityAllowed(identity, "documents.read.own") {
return strings.EqualFold(documentOwner(doc), identity.Username)
}
return false
}
func canMutateDocument(identity *adminauth.Identity, owner string) bool {
if identity == nil {
return false
}
if adminauthCapabilityAllowed(identity, "documents.write") || adminauthCapabilityAllowed(identity, "documents.review") || adminauthCapabilityAllowed(identity, "documents.lifecycle") {
return true
}
if adminauthCapabilityAllowed(identity, "documents.write.own") || adminauthCapabilityAllowed(identity, "documents.lifecycle.own") {
return owner != "" && strings.EqualFold(owner, identity.Username)
}
return false
}
package service
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/backup"
"github.com/sphireinc/foundry/internal/safepath"
)
func (s *Service) ListBackups(ctx context.Context) ([]types.BackupRecord, error) {
_ = ctx
items, err := backup.List(s.cfg.Backup.Dir)
if err != nil {
return nil, err
}
out := make([]types.BackupRecord, 0, len(items))
for _, item := range items {
out = append(out, backupRecord(item))
}
return out, nil
}
func (s *Service) CreateBackup(ctx context.Context, name string) (*types.BackupRecord, error) {
_ = ctx
name = strings.TrimSpace(name)
var (
snapshot *backup.Snapshot
err error
)
if name == "" {
snapshot, err = backup.CreateManagedSnapshot(s.cfg)
} else {
validatedName, validateErr := validateBackupName(name)
if validateErr != nil {
return nil, validateErr
}
target, validateErr := safepath.ResolveRelativeUnderRoot(s.cfg.Backup.Dir, validatedName)
if validateErr != nil {
return nil, validateErr
}
snapshot, err = backup.CreateZipSnapshot(s.cfg, target)
}
if err != nil {
return nil, err
}
record := backupRecord(*snapshot)
return &record, nil
}
func (s *Service) RestoreBackup(ctx context.Context, name string) (*types.BackupRecord, error) {
_ = ctx
validatedName, err := validateBackupName(name)
if err != nil {
return nil, err
}
target, err := safepath.ResolveRelativeUnderRoot(s.cfg.Backup.Dir, validatedName)
if err != nil {
return nil, err
}
if err := backup.RestoreZipSnapshot(s.cfg, target); err != nil {
return nil, err
}
s.invalidateGraphCache()
info, err := os.Stat(target)
if err != nil {
return nil, err
}
return &types.BackupRecord{
Name: filepath.Base(target),
Path: target,
SizeBytes: info.Size(),
CreatedAt: info.ModTime().UTC().Format(time.RFC3339),
}, nil
}
func (s *Service) BackupPath(name string) (string, error) {
validatedName, err := validateBackupName(name)
if err != nil {
return "", err
}
target, err := safepath.ResolveRelativeUnderRoot(s.cfg.Backup.Dir, validatedName)
if err != nil {
return "", err
}
if !backup.PathIsUnderBackupRoot(s.cfg, target) {
return "", fmt.Errorf("backup path is outside backup root")
}
if _, err := os.Stat(target); err != nil {
return "", err
}
return target, nil
}
func (s *Service) CreateGitBackupSnapshot(ctx context.Context, message string, push bool) (*types.BackupGitSnapshotRecord, error) {
_ = ctx
snapshot, err := backup.CreateGitSnapshot(s.cfg, message, push)
if err != nil {
return nil, err
}
return &types.BackupGitSnapshotRecord{
RepoDir: snapshot.RepoDir,
Revision: snapshot.Revision,
CreatedAt: snapshot.CreatedAt.UTC().Format(time.RFC3339),
Message: snapshot.Message,
Changed: snapshot.Changed,
Pushed: snapshot.Pushed,
RemoteURL: snapshot.RemoteURL,
Branch: snapshot.Branch,
}, nil
}
func (s *Service) ListGitBackupSnapshots(ctx context.Context, limit int) ([]types.BackupGitSnapshotRecord, error) {
_ = ctx
items, err := backup.ListGitSnapshots(s.cfg, limit)
if err != nil {
return nil, err
}
out := make([]types.BackupGitSnapshotRecord, 0, len(items))
for _, item := range items {
out = append(out, types.BackupGitSnapshotRecord{
RepoDir: item.RepoDir,
Revision: item.Revision,
CreatedAt: item.CreatedAt.UTC().Format(time.RFC3339),
Message: item.Message,
Changed: item.Changed,
Pushed: item.Pushed,
RemoteURL: item.RemoteURL,
Branch: item.Branch,
})
}
return out, nil
}
func backupRecord(item backup.Snapshot) types.BackupRecord {
return types.BackupRecord{
Name: filepath.Base(item.Path),
Path: item.Path,
SizeBytes: item.SizeBytes,
CreatedAt: item.CreatedAt.UTC().Format(time.RFC3339),
}
}
func validateBackupName(name string) (string, error) {
validated, err := safepath.ValidatePathComponent("backup name", strings.TrimSpace(name))
if err != nil {
return "", err
}
return validated, nil
}
package service
import (
"github.com/sphireinc/foundry/internal/admin/types"
adminui "github.com/sphireinc/foundry/internal/admin/ui"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/theme"
)
func toValidationDiagnostics(in []theme.ValidationDiagnostic) []types.ValidationDiagnostic {
out := make([]types.ValidationDiagnostic, 0, len(in))
for _, diagnostic := range in {
out = append(out, types.ValidationDiagnostic{
Severity: diagnostic.Severity,
Path: diagnostic.Path,
Message: diagnostic.Message,
})
}
return out
}
func toAdminThemeDiagnostics(in []adminui.Diagnostic) []types.ValidationDiagnostic {
out := make([]types.ValidationDiagnostic, 0, len(in))
for _, diagnostic := range in {
out = append(out, types.ValidationDiagnostic{
Severity: diagnostic.Severity,
Path: diagnostic.Path,
Message: diagnostic.Message,
})
}
return out
}
func toPluginDiagnostics(in []plugins.ValidationDiagnostic) []types.ValidationDiagnostic {
out := make([]types.ValidationDiagnostic, 0, len(in))
for _, diagnostic := range in {
out = append(out, types.ValidationDiagnostic{
Severity: diagnostic.Severity,
Path: diagnostic.Path,
Message: diagnostic.Message,
})
}
return out
}
func toPluginDependencies(in []plugins.Dependency) []types.PluginDependency {
out := make([]types.PluginDependency, 0, len(in))
for _, dep := range in {
out = append(out, types.PluginDependency{
Name: dep.Name,
Version: dep.Version,
Optional: dep.Optional,
})
}
return out
}
//go:build darwin || linux
package service
import (
"syscall"
"time"
)
func processCPUTime() (time.Duration, time.Duration) {
var usage syscall.Rusage
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &usage); err != nil {
return 0, 0
}
return timevalToDuration(usage.Utime), timevalToDuration(usage.Stime)
}
func timevalToDuration(tv syscall.Timeval) time.Duration {
return time.Duration(tv.Sec)*time.Second + time.Duration(tv.Usec)*time.Microsecond
}
package service
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/fields"
"github.com/sphireinc/foundry/internal/i18n"
"github.com/sphireinc/foundry/internal/lifecycle"
"github.com/sphireinc/foundry/internal/markup"
"github.com/sphireinc/foundry/internal/safepath"
"github.com/sphireinc/foundry/internal/theme"
"gopkg.in/yaml.v3"
)
func (s *Service) ListDocuments(ctx context.Context, opts types.DocumentListOptions) ([]types.DocumentSummary, error) {
graph, err := s.load(ctx, opts.IncludeDrafts)
if err != nil {
return nil, err
}
rows := make([]types.DocumentSummary, 0, len(graph.Documents))
query := strings.ToLower(strings.TrimSpace(opts.Query))
for _, doc := range graph.Documents {
if identity, ok := currentIdentity(ctx); ok && !canAccessDocument(identity, doc) {
continue
}
if !opts.IncludeDrafts && doc.Draft {
continue
}
if opts.Type != "" && doc.Type != opts.Type {
continue
}
if opts.Lang != "" && doc.Lang != opts.Lang {
continue
}
if query != "" && !matchesDocumentQuery(doc, query) {
continue
}
rows = append(rows, toSummary(doc))
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Type != rows[j].Type {
return rows[i].Type < rows[j].Type
}
if rows[i].Lang != rows[j].Lang {
return rows[i].Lang < rows[j].Lang
}
if rows[i].URL != rows[j].URL {
return rows[i].URL < rows[j].URL
}
return rows[i].SourcePath < rows[j].SourcePath
})
return rows, nil
}
func (s *Service) GetDocument(ctx context.Context, idOrPath string, includeDrafts bool) (*types.DocumentDetail, error) {
graph, err := s.load(ctx, includeDrafts)
if err != nil {
return nil, err
}
idOrPath = strings.TrimSpace(idOrPath)
if idOrPath == "" {
return nil, fmt.Errorf("document id or path is required")
}
for _, doc := range graph.Documents {
if doc.ID == idOrPath || doc.SourcePath == idOrPath || displayDocumentPath(doc.SourcePath, s.cfg.ContentDir) == idOrPath || doc.URL == idOrPath {
identity, ok := currentIdentity(ctx)
if ok && !canAccessDocument(identity, doc) {
return nil, fmt.Errorf("document access denied")
}
detail, err := s.toDetail(ctx, doc)
if err != nil {
return nil, err
}
return &detail, nil
}
}
return nil, fmt.Errorf("document not found: %s", idOrPath)
}
func (s *Service) SaveDocument(ctx context.Context, req types.DocumentSaveRequest) (*types.DocumentSaveResponse, error) {
sourcePath, err := s.resolveContentPath(req.SourcePath)
if err != nil {
return nil, err
}
if strings.TrimSpace(req.Raw) == "" {
return nil, fmt.Errorf("raw document body is required")
}
if err := s.ensureDocumentLock(ctx, req.SourcePath, req.LockToken); err != nil {
return nil, err
}
fm, body, err := content.ParseDocument([]byte(req.Raw))
if err != nil {
return nil, err
}
if fm.Params == nil {
fm.Params = make(map[string]any)
}
defs := s.fieldDefinitionsForDocument(sourcePath, fm)
if req.Fields != nil {
fm.Fields = fields.PruneToDefinitions(fields.Normalize(req.Fields), defs)
} else {
fm.Fields = fields.PruneToDefinitions(fields.Normalize(fm.Fields), defs)
}
fm.Fields = fields.ApplyDefaults(fields.Normalize(fm.Fields), defs)
if errs := fields.Validate(fm.Fields, defs, false); len(errs) > 0 {
return nil, errs[0]
}
actorUsername := strings.TrimSpace(req.Username)
if identity, ok := currentIdentity(ctx); ok {
if actorUsername == "" {
actorUsername = identity.Username
}
owner := documentOwnerFromFrontMatter(fm)
if owner == "" && actorUsername != "" {
fm.Author = actorUsername
owner = actorUsername
}
if !canMutateDocument(identity, owner) {
return nil, fmt.Errorf("document access denied")
}
fm.LastEditor = actorUsername
} else if actorUsername != "" {
if strings.TrimSpace(fm.Author) == "" {
fm.Author = actorUsername
}
fm.LastEditor = actorUsername
}
if fm.Author == "" {
fm.Author = documentOwnerFromFrontMatter(fm)
}
if fm.CreatedAt == nil {
nowCreated := time.Now().UTC()
fm.CreatedAt = &nowCreated
}
nowUpdated := time.Now().UTC()
fm.UpdatedAt = &nowUpdated
if content.WorkflowFromFrontMatter(fm, nowUpdated).Status == "" {
content.ApplyWorkflowToFrontMatter(fm, "draft", nil, nil, "")
}
renderedRaw, err := marshalDocument(fm, body)
if err != nil {
return nil, err
}
created := false
now := time.Now()
if _, err := s.fs.Stat(sourcePath); err != nil {
if err := requireCapability(ctx, "documents.create"); err != nil {
return nil, err
}
created = true
} else {
existingRaw, err := s.fs.ReadFile(sourcePath)
if err != nil {
return nil, err
}
existingFM, _, err := content.ParseDocument(existingRaw)
if err != nil {
return nil, err
}
if identity, ok := currentIdentity(ctx); ok && !canMutateDocument(identity, documentOwnerFromFrontMatter(existingFM)) {
return nil, fmt.Errorf("document access denied")
}
if strings.TrimSpace(req.VersionComment) != "" || strings.TrimSpace(req.Actor) != "" {
if err := s.snapshotDocumentVersion(sourcePath, now, req.VersionComment, req.Actor); err != nil {
return nil, err
}
} else if err := s.versionFile(sourcePath, now); err != nil {
return nil, err
}
}
if err := s.fs.MkdirAll(filepath.Dir(sourcePath), 0o755); err != nil {
return nil, err
}
if err := s.fs.WriteFile(sourcePath, renderedRaw, 0o644); err != nil {
return nil, err
}
s.invalidateGraphCache()
return &types.DocumentSaveResponse{
SourcePath: displayDocumentPath(sourcePath, s.cfg.ContentDir),
Size: int64(len(renderedRaw)),
Created: created,
Raw: string(renderedRaw),
}, nil
}
func (s *Service) CreateDocument(ctx context.Context, req types.DocumentCreateRequest) (*types.DocumentCreateResponse, error) {
if err := requireCapability(ctx, "documents.create"); err != nil {
return nil, err
}
kind := normalizeDocumentKind(req.Kind)
if kind == "" {
return nil, fmt.Errorf("document kind must be page or post")
}
slug := sanitizeDocumentSlug(req.Slug)
if slug == "" {
return nil, fmt.Errorf("document slug is required")
}
body, err := content.BuildNewContentWithOptions(s.cfg, content.NewContentOptions{
Kind: kind,
Slug: slug,
Archetype: strings.TrimSpace(req.Archetype),
Lang: strings.TrimSpace(req.Lang),
})
if err != nil {
return nil, err
}
lang := normalizeDocumentLang(req.Lang, s.cfg.DefaultLang)
relPath := s.newDocumentRelativePath(kind, lang, slug)
sourcePath, err := s.resolveContentPath(relPath)
if err != nil {
return nil, err
}
if _, err := s.fs.Stat(sourcePath); err == nil {
return nil, fmt.Errorf("document already exists: %s", filepath.ToSlash(relPath))
} else if !os.IsNotExist(err) {
return nil, err
}
if err := s.fs.MkdirAll(filepath.Dir(sourcePath), 0o755); err != nil {
return nil, err
}
actorUsername := ""
if identity, ok := currentIdentity(ctx); ok && identity.Username != "" {
actorUsername = identity.Username
}
if actorUsername != "" {
fm, contentBody, err := content.ParseDocument([]byte(body))
if err == nil {
if fm.Params == nil {
fm.Params = make(map[string]any)
}
if documentOwnerFromFrontMatter(fm) == "" {
fm.Author = actorUsername
}
fm.LastEditor = actorUsername
now := time.Now().UTC()
if fm.CreatedAt == nil {
fm.CreatedAt = &now
}
fm.UpdatedAt = &now
content.ApplyWorkflowToFrontMatter(fm, "draft", nil, nil, "")
defs := s.fieldDefinitionsForDocument(sourcePath, fm)
fm.Fields = fields.ApplyDefaults(fields.Normalize(fm.Fields), defs)
if rendered, err := marshalDocument(fm, contentBody); err == nil {
body = string(rendered)
}
}
}
if err := s.fs.WriteFile(sourcePath, []byte(body), 0o644); err != nil {
return nil, err
}
s.invalidateGraphCache()
return &types.DocumentCreateResponse{
Kind: kind,
Slug: slug,
Lang: lang,
Archetype: strings.TrimSpace(req.Archetype),
SourcePath: displayDocumentPath(sourcePath, s.cfg.ContentDir),
Created: true,
Raw: body,
}, nil
}
func (s *Service) UpdateDocumentStatus(ctx context.Context, req types.DocumentStatusRequest) (*types.DocumentStatusResponse, error) {
sourcePath, err := s.resolveContentPath(req.SourcePath)
if err != nil {
return nil, err
}
raw, err := s.fs.ReadFile(sourcePath)
if err != nil {
return nil, err
}
fm, body, err := content.ParseDocument(raw)
if err != nil {
return nil, err
}
if identity, ok := currentIdentity(ctx); ok && !canMutateDocument(identity, documentOwnerFromFrontMatter(fm)) {
return nil, fmt.Errorf("document access denied")
}
if err := s.ensureDocumentLock(ctx, req.SourcePath, req.LockToken); err != nil {
return nil, err
}
status := normalizeDocumentStatus(req.Status)
if status == "" {
return nil, fmt.Errorf("document status must be draft, in_review, scheduled, published, or archived")
}
scheduledPublishAt, err := parseOptionalTime(req.ScheduledPublishAt)
if err != nil {
return nil, fmt.Errorf("scheduled publish time: %w", err)
}
scheduledUnpublishAt, err := parseOptionalTime(req.ScheduledUnpublishAt)
if err != nil {
return nil, fmt.Errorf("scheduled unpublish time: %w", err)
}
if status == "scheduled" && scheduledPublishAt == nil && scheduledUnpublishAt == nil {
return nil, fmt.Errorf("scheduled status requires scheduled publish or unpublish time")
}
if fm.Params == nil {
fm.Params = make(map[string]any)
}
if identity, ok := currentIdentity(ctx); ok && identity.Username != "" {
fm.LastEditor = identity.Username
if fm.Author == "" {
fm.Author = documentOwnerFromFrontMatter(fm)
}
}
now := time.Now().UTC()
if fm.CreatedAt == nil {
fm.CreatedAt = &now
}
fm.UpdatedAt = &now
content.ApplyWorkflowToFrontMatter(fm, status, scheduledPublishAt, scheduledUnpublishAt, req.EditorialNote)
rendered, err := marshalDocument(fm, body)
if err != nil {
return nil, err
}
if err := s.fs.WriteFile(sourcePath, rendered, 0o644); err != nil {
return nil, err
}
s.invalidateGraphCache()
return &types.DocumentStatusResponse{
SourcePath: displayDocumentPath(sourcePath, s.cfg.ContentDir),
Status: status,
Draft: fm.Draft,
Archived: documentArchivedFromParams(fm.Params),
ScheduledPublishAt: scheduledPublishAt,
ScheduledUnpublishAt: scheduledUnpublishAt,
EditorialNote: strings.TrimSpace(req.EditorialNote),
}, nil
}
func (s *Service) DeleteDocument(ctx context.Context, req types.DocumentDeleteRequest) (*types.DocumentDeleteResponse, error) {
sourcePath, err := s.resolveContentPath(req.SourcePath)
if err != nil {
return nil, err
}
if _, err := s.fs.Stat(sourcePath); err != nil {
return nil, err
}
if err := s.ensureDocumentLock(ctx, req.SourcePath, req.LockToken); err != nil {
return nil, err
}
if raw, err := s.fs.ReadFile(sourcePath); err == nil {
if fm, _, err := content.ParseDocument(raw); err == nil {
if identity, ok := currentIdentity(ctx); ok && !canMutateDocument(identity, documentOwnerFromFrontMatter(fm)) {
return nil, fmt.Errorf("document access denied")
}
}
}
trashPath, err := s.trashFile(sourcePath, time.Now())
if err != nil {
return nil, err
}
s.invalidateGraphCache()
return &types.DocumentDeleteResponse{
SourcePath: displayDocumentPath(sourcePath, s.cfg.ContentDir),
TrashPath: displayDocumentPath(trashPath, s.cfg.ContentDir),
Operation: "soft_delete",
}, nil
}
func (s *Service) PreviewDocument(ctx context.Context, req types.DocumentPreviewRequest) (*types.DocumentPreviewResponse, error) {
raw := req.Raw
if strings.TrimSpace(raw) == "" && strings.TrimSpace(req.SourcePath) != "" {
sourcePath, err := s.resolveContentPath(req.SourcePath)
if err != nil {
return nil, err
}
b, err := s.fs.ReadFile(sourcePath)
if err != nil {
return nil, err
}
if identity, ok := currentIdentity(ctx); ok {
if fm, _, err := content.ParseDocument(b); err == nil && !canMutateDocument(identity, documentOwnerFromFrontMatter(fm)) && !adminauthCapabilityAllowed(identity, "documents.read") && !adminauthCapabilityAllowed(identity, "documents.read.own") {
return nil, fmt.Errorf("document access denied")
}
}
raw = string(b)
}
if strings.TrimSpace(raw) == "" {
return nil, fmt.Errorf("preview requires raw content or source_path")
}
fm, body, err := content.ParseDocument([]byte(raw))
if err != nil {
return nil, err
}
defs := documentFieldDefinitionsForManifest(s.activeThemeManifest(), strings.TrimSpace(req.SourcePath), s.cfg, fm.Layout, fm.Slug)
if req.Fields != nil {
fm.Fields = fields.PruneToDefinitions(fields.Normalize(req.Fields), defs)
} else {
fm.Fields = fields.PruneToDefinitions(fields.Normalize(fm.Fields), defs)
}
fm.Fields = fields.ApplyDefaults(fields.Normalize(fm.Fields), defs)
fieldErrors := make([]string, 0)
for _, err := range fields.Validate(fm.Fields, defs, false) {
fieldErrors = append(fieldErrors, err.Error())
}
htmlBody, err := markup.MarkdownToHTML(body, s.cfg.Security.AllowUnsafeHTML)
if err != nil {
return nil, err
}
title := strings.TrimSpace(fm.Title)
if title == "" {
title = strings.TrimSpace(fm.Slug)
}
workflow := content.WorkflowFromFrontMatter(fm, time.Now().UTC())
return &types.DocumentPreviewResponse{
Title: title,
Slug: fm.Slug,
Layout: fm.Layout,
Summary: fm.Summary,
Status: workflow.Status,
Draft: fm.Draft,
Archived: workflow.Archived,
Date: fm.Date,
CreatedAt: fm.CreatedAt,
UpdatedAt: fm.UpdatedAt,
Author: strings.TrimSpace(fm.Author),
LastEditor: strings.TrimSpace(fm.LastEditor),
HTML: string(htmlBody),
WordCount: countWords(body),
FieldErrors: fieldErrors,
}, nil
}
func matchesDocumentQuery(doc *content.Document, query string) bool {
candidates := []string{
doc.ID,
doc.Title,
doc.Slug,
doc.URL,
doc.SourcePath,
doc.Type,
doc.Lang,
doc.Summary,
}
for _, c := range candidates {
if strings.Contains(strings.ToLower(c), query) {
return true
}
}
return false
}
func toSummary(doc *content.Document) types.DocumentSummary {
status := strings.TrimSpace(doc.Status)
if stored := storedWorkflowStatus(doc.Params); stored != "" {
status = stored
}
return types.DocumentSummary{
ID: doc.ID,
Type: doc.Type,
Lang: doc.Lang,
Status: status,
Title: doc.Title,
Slug: doc.Slug,
URL: doc.URL,
Layout: doc.Layout,
SourcePath: displayDocumentPath(doc.SourcePath, ""),
Summary: doc.Summary,
Draft: doc.Draft,
Archived: documentArchivedFromParams(doc.Params),
Date: doc.Date,
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
Author: doc.Author,
LastEditor: doc.LastEditor,
Taxonomies: doc.Taxonomies,
}
}
func storedWorkflowStatus(params map[string]any) string {
if len(params) == 0 {
return ""
}
value, ok := params["workflow"]
if !ok {
return ""
}
raw, ok := value.(string)
if !ok {
return ""
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "draft", "in_review", "scheduled", "published", "archived":
return strings.ToLower(strings.TrimSpace(raw))
default:
return ""
}
}
func (s *Service) toDetail(ctx context.Context, doc *content.Document) (types.DocumentDetail, error) {
raw, err := s.fs.ReadFile(doc.SourcePath)
if err != nil {
return types.DocumentDetail{}, err
}
fm, _, err := content.ParseDocument(raw)
if err != nil {
return types.DocumentDetail{}, err
}
lock, err := s.DocumentLock(ctx, displayDocumentPath(doc.SourcePath, s.cfg.ContentDir))
if err != nil {
lock = nil
}
contracts := documentContractsForManifest(s.activeThemeManifest(), doc.SourcePath, s.cfg, doc.Layout, doc.Slug)
contractKeys, contractTitles := documentContractMetadata(contracts)
defs := theme.ApplicableDocumentFieldDefinitions(s.activeThemeManifest(), doc.Type, doc.Layout, doc.Slug)
return types.DocumentDetail{
DocumentSummary: toSummary(doc),
RawBody: string(raw),
HTMLBody: string(doc.HTMLBody),
Params: doc.Params,
Fields: fields.ApplyDefaults(fields.Normalize(fm.Fields), defs),
FieldSchema: toFieldSchema(defs),
FieldContractKeys: contractKeys,
FieldContractTitles: contractTitles,
Lock: lock,
}, nil
}
func countWords(s string) int {
return len(strings.Fields(s))
}
func documentArchivedFromParams(params map[string]any) bool {
if len(params) == 0 {
return false
}
value, ok := params["archived"]
if !ok {
return false
}
switch typed := value.(type) {
case bool:
return typed
case string:
return strings.EqualFold(strings.TrimSpace(typed), "true")
default:
return false
}
}
func normalizeDocumentKind(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "page", "post":
return strings.ToLower(strings.TrimSpace(kind))
default:
return ""
}
}
func normalizeDocumentStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "published", "draft", "archived", "in_review", "scheduled":
return strings.ToLower(strings.TrimSpace(status))
default:
return ""
}
}
func documentKindFromSourcePath(path string, cfg *config.Config) string {
normalized := filepath.ToSlash(strings.TrimSpace(path))
pagesDir := filepath.ToSlash(filepath.Join(strings.TrimSpace(cfg.ContentDir), strings.TrimSpace(cfg.Content.PagesDir)))
postsDir := filepath.ToSlash(filepath.Join(strings.TrimSpace(cfg.ContentDir), strings.TrimSpace(cfg.Content.PostsDir)))
switch {
case normalized == postsDir || strings.HasPrefix(normalized, postsDir+"/"):
return "post"
case normalized == filepath.ToSlash(strings.TrimSpace(cfg.Content.PostsDir)) || strings.HasPrefix(normalized, filepath.ToSlash(strings.TrimSpace(cfg.Content.PostsDir))+"/"):
return "post"
case normalized == pagesDir || strings.HasPrefix(normalized, pagesDir+"/"):
return "page"
case normalized == filepath.ToSlash(strings.TrimSpace(cfg.Content.PagesDir)) || strings.HasPrefix(normalized, filepath.ToSlash(strings.TrimSpace(cfg.Content.PagesDir))+"/"):
return "page"
default:
if strings.Contains(normalized, "/posts/") {
return "post"
}
return "page"
}
}
func parseOptionalTime(raw string) (*time.Time, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
if parsed, err := time.Parse(time.RFC3339, trimmed); err == nil {
value := parsed.UTC()
return &value, nil
}
if parsed, err := time.Parse("2006-01-02 15:04", trimmed); err == nil {
value := parsed.UTC()
return &value, nil
}
if parsed, err := time.Parse("2006-01-02", trimmed); err == nil {
value := parsed.UTC()
return &value, nil
}
return nil, fmt.Errorf("must be RFC3339 or YYYY-MM-DD[ HH:MM]")
}
func toFieldSchema(defs []fields.Definition) []types.FieldSchema {
if len(defs) == 0 {
return nil
}
result := make([]types.FieldSchema, 0, len(defs))
for _, def := range defs {
entry := types.FieldSchema{
Name: def.Name,
Label: def.Label,
Type: def.Type,
Required: def.Required,
Default: def.Default,
Enum: append([]string{}, def.Enum...),
Help: def.Help,
Placeholder: def.Placeholder,
}
if len(def.Fields) > 0 {
entry.Fields = toFieldSchema(def.Fields)
}
if def.Item != nil {
item := toFieldSchema([]fields.Definition{*def.Item})
if len(item) == 1 {
entry.Item = &item[0]
}
}
result = append(result, entry)
}
return result
}
func sanitizeDocumentSlug(slug string) string {
slug = strings.ToLower(strings.TrimSpace(slug))
if slug == "" {
return ""
}
var b strings.Builder
lastDash := false
for _, r := range slug {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
case r == '-' || r == '_' || r == ' ' || r == '/':
if b.Len() > 0 && !lastDash {
b.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(b.String(), "-")
}
func (s *Service) newDocumentRelativePath(kind, lang, slug string) string {
root := s.cfg.Content.PagesDir
if kind == "post" {
root = s.cfg.Content.PostsDir
}
if lang != "" && lang != s.cfg.DefaultLang {
return filepath.ToSlash(filepath.Join(root, lang, slug+".md"))
}
return filepath.ToSlash(filepath.Join(root, slug+".md"))
}
func (s *Service) fieldDefinitionsForDocument(sourcePath string, fm *content.FrontMatter) []fields.Definition {
kind := documentKindFromSourcePath(sourcePath, s.cfg)
layout := documentLayoutForKind(kind, fm, s.cfg)
slug := documentSlugForPath(sourcePath, fm)
defs := theme.DocumentFieldDefinitions(s.cfg.ThemesDir, s.cfg.Theme, kind, layout, slug)
if len(defs) > 0 {
return defs
}
return fields.DefinitionsFor(s.cfg, kind)
}
func documentLayoutForKind(kind string, fm *content.FrontMatter, cfg *config.Config) string {
if fm != nil && strings.TrimSpace(fm.Layout) != "" {
return strings.TrimSpace(fm.Layout)
}
if kind == "post" {
return strings.TrimSpace(cfg.Content.DefaultLayoutPost)
}
return strings.TrimSpace(cfg.Content.DefaultLayoutPage)
}
func documentSlugForPath(sourcePath string, fm *content.FrontMatter) string {
if fm != nil && strings.TrimSpace(fm.Slug) != "" {
return strings.TrimSpace(fm.Slug)
}
base := filepath.Base(strings.TrimSpace(sourcePath))
return strings.TrimSuffix(base, filepath.Ext(base))
}
func normalizeDocumentLang(lang, fallback string) string {
lang = strings.TrimSpace(lang)
if lang == "" {
return fallback
}
lang = i18n.NormalizeTag(lang)
if !i18n.IsValidTag(lang) {
return fallback
}
return lang
}
func displayDocumentPath(path, contentRoot string) string {
path = filepath.ToSlash(strings.TrimSpace(path))
if path == "" {
return ""
}
if contentRoot != "" {
root, err := filepath.Abs(strings.TrimSpace(contentRoot))
if err == nil && root != "" {
root = filepath.ToSlash(root)
if rel, err := filepath.Rel(root, filepath.FromSlash(path)); err == nil && rel != ".." && !strings.HasPrefix(rel, "../") {
return filepath.ToSlash(filepath.Join(filepath.Base(root), rel))
}
}
}
if idx := strings.Index(path, "/content/"); idx >= 0 {
return path[idx+1:]
}
if strings.HasPrefix(path, "content/") {
return path
}
return path
}
func marshalDocument(fm *content.FrontMatter, body string) ([]byte, error) {
payload, err := yaml.Marshal(fm)
if err != nil {
return nil, err
}
body = strings.TrimLeft(body, "\n")
rendered := "---\n" + string(payload) + "---\n\n" + body
return []byte(rendered), nil
}
func (s *Service) resolveContentPath(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", fmt.Errorf("source path is required")
}
contentRoot, err := filepath.Abs(s.cfg.ContentDir)
if err != nil {
return "", err
}
var full string
if filepath.IsAbs(path) {
full = filepath.Clean(path)
} else {
clean := filepath.Clean(path)
contentDirSlash := filepath.ToSlash(s.cfg.ContentDir)
contentBase := filepath.Base(s.cfg.ContentDir)
cleanSlash := filepath.ToSlash(clean)
switch {
case strings.HasPrefix(cleanSlash, contentDirSlash+"/") || clean == s.cfg.ContentDir:
full = clean
case strings.HasPrefix(cleanSlash, contentBase+"/") || clean == contentBase:
full = filepath.Join(filepath.Dir(s.cfg.ContentDir), clean)
default:
full = filepath.Join(s.cfg.ContentDir, clean)
}
}
full, err = filepath.Abs(full)
if err != nil {
return "", err
}
rootWithSep := contentRoot + string(filepath.Separator)
if full != contentRoot && !strings.HasPrefix(full, rootWithSep) {
return "", fmt.Errorf("source path must be inside %s", s.cfg.ContentDir)
}
if filepath.Ext(full) != ".md" {
return "", fmt.Errorf("source path must point to a markdown file")
}
if lifecycle.IsDerivedPath(full) {
return "", fmt.Errorf("source path must point to a current markdown file")
}
if err := ensureNoSymlinkEscape(contentRoot, full); err != nil {
return "", err
}
return full, nil
}
func ensureNoSymlinkEscape(root, target string) error {
rootAbs, err := filepath.Abs(root)
if err != nil {
return err
}
targetAbs, err := filepath.Abs(target)
if err != nil {
return err
}
rel, err := filepath.Rel(rootAbs, targetAbs)
if err != nil {
return err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("source path must be inside %s", root)
}
current := rootAbs
if rel == "." {
return nil
}
for _, part := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
ok, err := safepath.IsWithinRoot(rootAbs, current)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("source path must be inside %s", root)
}
continue
}
return err
}
if info.Mode()&os.ModeSymlink == 0 {
continue
}
resolved, err := filepath.EvalSymlinks(current)
if err != nil {
return err
}
ok, err := safepath.IsWithinRoot(rootAbs, resolved)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("source path must stay inside %s after resolving symlinks", root)
}
}
return nil
}
package service
import (
"path/filepath"
"strings"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/theme"
)
func (s *Service) activeThemeManifest() *theme.Manifest {
if s == nil || s.cfg == nil {
return nil
}
manifest, err := theme.LoadManifest(s.cfg.ThemesDir, s.cfg.Theme)
if err != nil {
return nil
}
return manifest
}
func documentFieldDefinitionsForManifest(manifest *theme.Manifest, sourcePath string, cfg *config.Config, layout, slug string) []config.FieldDefinition {
return theme.ApplicableDocumentFieldDefinitions(manifest, documentKindFromSourcePath(sourcePath, cfg), layout, slug)
}
func documentContractsForManifest(manifest *theme.Manifest, sourcePath string, cfg *config.Config, layout, slug string) []theme.FieldContract {
return theme.ApplicableDocumentFieldContracts(manifest, documentKindFromSourcePath(sourcePath, cfg), layout, slug)
}
func sharedFieldContractsForManifest(manifest *theme.Manifest) []admintypes.SharedFieldContract {
contracts := theme.SharedFieldContracts(manifest)
if len(contracts) == 0 {
return nil
}
out := make([]admintypes.SharedFieldContract, 0, len(contracts))
for _, contract := range contracts {
out = append(out, admintypes.SharedFieldContract{
Key: strings.TrimSpace(contract.Target.Key),
Title: strings.TrimSpace(contract.Title),
Description: strings.TrimSpace(contract.Description),
Fields: toFieldSchema(contract.Fields),
})
}
return out
}
func documentContractMetadata(contracts []theme.FieldContract) ([]string, []string) {
if len(contracts) == 0 {
return nil, nil
}
keys := make([]string, 0, len(contracts))
titles := make([]string, 0, len(contracts))
for _, contract := range contracts {
key := strings.TrimSpace(contract.Key)
if key != "" {
keys = append(keys, key)
}
title := strings.TrimSpace(contract.Title)
if title == "" {
title = strings.TrimSpace(contract.Key)
}
if title != "" {
titles = append(titles, title)
}
}
if len(keys) == 0 {
keys = nil
}
if len(titles) == 0 {
titles = nil
}
return keys, titles
}
func displayCustomFieldsPath(path string) string {
path = filepath.ToSlash(strings.TrimSpace(path))
if path == "" {
return ""
}
if idx := strings.Index(path, "/content/"); idx >= 0 {
return path[idx+1:]
}
if strings.HasPrefix(path, "content/") {
return path
}
return path
}
package service
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/lifecycle"
"github.com/sphireinc/foundry/internal/media"
"gopkg.in/yaml.v3"
)
func (s *Service) GetDocumentHistory(ctx context.Context, sourcePath string) (*types.DocumentHistoryResponse, error) {
if err := requireCapability(ctx, "documents.history"); err != nil {
return nil, err
}
fullPath, originalPath, _, err := s.resolveDocumentLifecyclePath(sourcePath)
if err != nil {
return nil, err
}
entries, err := s.listDocumentLifecycleEntries(originalPath)
if err != nil {
return nil, err
}
respPath := displayDocumentPath(originalPath, s.cfg.ContentDir)
if lifecycle.IsDerivedPath(fullPath) {
respPath = displayDocumentPath(fullPath, s.cfg.ContentDir)
}
return &types.DocumentHistoryResponse{
SourcePath: respPath,
Entries: entries,
}, nil
}
func (s *Service) ListDocumentTrash(ctx context.Context) ([]types.DocumentHistoryEntry, error) {
if err := requireCapability(ctx, "documents.history"); err != nil {
return nil, err
}
entries := make([]types.DocumentHistoryEntry, 0)
err := s.walkDir(s.cfg.ContentDir, func(path string, info os.DirEntry) error {
if info.IsDir() {
return nil
}
if filepath.Ext(path) != ".md" || !lifecycle.IsTrashPath(path) {
return nil
}
entry, err := s.documentHistoryEntry(path)
if err != nil {
return err
}
entries = append(entries, entry)
return nil
})
if err != nil {
return nil, err
}
sortDocumentHistoryEntries(entries)
return entries, nil
}
func (s *Service) RestoreDocument(ctx context.Context, req types.DocumentLifecycleRequest) (*types.DocumentLifecycleResponse, error) {
if err := requireCapability(ctx, "documents.lifecycle"); err != nil {
return nil, err
}
path, originalPath, state, err := s.resolveDocumentLifecyclePath(req.Path)
if err != nil {
return nil, err
}
if state == lifecycle.StateCurrent {
return nil, fmt.Errorf("restore requires a versioned or trashed document")
}
if _, err := s.fs.Stat(originalPath); err == nil {
if err := s.versionFile(originalPath, time.Now()); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
if err := s.fs.Rename(path, originalPath); err != nil {
return nil, err
}
s.invalidateGraphCache()
return &types.DocumentLifecycleResponse{
Path: displayDocumentPath(path, s.cfg.ContentDir),
RestoredPath: displayDocumentPath(originalPath, s.cfg.ContentDir),
Operation: "restore",
}, nil
}
func (s *Service) PurgeDocument(ctx context.Context, req types.DocumentLifecycleRequest) (*types.DocumentLifecycleResponse, error) {
if err := requireCapability(ctx, "documents.lifecycle"); err != nil {
return nil, err
}
path, _, state, err := s.resolveDocumentLifecyclePath(req.Path)
if err != nil {
return nil, err
}
if state == lifecycle.StateCurrent {
return nil, fmt.Errorf("purge requires a versioned or trashed document")
}
if err := s.fs.Remove(path); err != nil {
return nil, err
}
s.invalidateGraphCache()
return &types.DocumentLifecycleResponse{
Path: displayDocumentPath(path, s.cfg.ContentDir),
Operation: "purge",
}, nil
}
func (s *Service) DiffDocument(ctx context.Context, req types.DocumentDiffRequest) (*types.DocumentDiffResponse, error) {
if err := requireCapability(ctx, "documents.diff"); err != nil {
return nil, err
}
leftPath, _, _, err := s.resolveDocumentLifecyclePath(req.LeftPath)
if err != nil {
return nil, err
}
rightPath, _, _, err := s.resolveDocumentLifecyclePath(req.RightPath)
if err != nil {
return nil, err
}
leftBody, err := s.fs.ReadFile(leftPath)
if err != nil {
return nil, err
}
rightBody, err := s.fs.ReadFile(rightPath)
if err != nil {
return nil, err
}
return &types.DocumentDiffResponse{
LeftPath: displayDocumentPath(leftPath, s.cfg.ContentDir),
RightPath: displayDocumentPath(rightPath, s.cfg.ContentDir),
LeftRaw: string(leftBody),
RightRaw: string(rightBody),
Diff: buildUnifiedLineDiff(leftPath, leftBody, rightPath, rightBody),
}, nil
}
func (s *Service) GetMediaHistory(ctx context.Context, identifier string) (*types.MediaHistoryResponse, error) {
if err := requireCapability(ctx, "media.read"); err != nil {
return nil, err
}
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return nil, fmt.Errorf("media identifier is required")
}
var (
currentPath string
reference string
err error
)
if strings.HasPrefix(identifier, media.ReferenceScheme) {
_, currentPath, err = s.resolveMediaItem(identifier)
if err != nil {
return nil, err
}
reference = identifier
} else {
_, originalPath, _, err := s.resolveMediaLifecyclePath(identifier)
if err != nil {
return nil, err
}
currentPath = originalPath
_, reference, _, err = s.mediaReferenceInfoForPath(currentPath)
if err != nil {
return nil, err
}
}
entries, err := s.listMediaLifecycleEntries(currentPath)
if err != nil {
return nil, err
}
return &types.MediaHistoryResponse{
Reference: reference,
Path: displayDocumentPath(currentPath, s.cfg.ContentDir),
Entries: entries,
}, nil
}
func (s *Service) ListMediaTrash(ctx context.Context) ([]types.MediaHistoryEntry, error) {
if err := requireCapability(ctx, "media.read"); err != nil {
return nil, err
}
entries := make([]types.MediaHistoryEntry, 0)
for _, collection := range []string{"images", "videos", "audio", "documents", "uploads", "assets"} {
root, err := s.mediaRoot(collection)
if err != nil {
return nil, err
}
if _, err := s.fs.Stat(root); err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
err = s.walkDir(root, func(path string, info os.DirEntry) error {
if info.IsDir() {
return nil
}
if isMediaMetadataFile(path) || !lifecycle.IsTrashPath(path) {
return nil
}
entry, err := s.mediaHistoryEntry(path)
if err != nil {
return nil
}
entries = append(entries, entry)
return nil
})
if err != nil {
return nil, err
}
}
sortMediaHistoryEntries(entries)
return entries, nil
}
func (s *Service) RestoreMedia(ctx context.Context, req types.MediaLifecycleRequest) (*types.MediaLifecycleResponse, error) {
if err := requireCapability(ctx, "media.lifecycle"); err != nil {
return nil, err
}
path, originalPath, state, err := s.resolveMediaLifecyclePath(req.Path)
if err != nil {
return nil, err
}
if state == lifecycle.StateCurrent {
return nil, fmt.Errorf("restore requires a versioned or trashed media file")
}
now := time.Now()
if isMediaMetadataFile(path) {
if _, err := s.fs.Stat(originalPath); err == nil {
if err := s.snapshotMediaMetadataVersion(strings.TrimSuffix(originalPath, ".meta.yaml"), now, "", ""); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
if err := s.fs.Rename(path, originalPath); err != nil {
return nil, err
}
return &types.MediaLifecycleResponse{
Path: displayDocumentPath(path, s.cfg.ContentDir),
RestoredPath: displayDocumentPath(originalPath, s.cfg.ContentDir),
Operation: "restore",
}, nil
}
if _, err := s.fs.Stat(originalPath); err == nil {
if err := s.versionFile(originalPath, now); err != nil {
return nil, err
}
if err := s.versionMediaMetadataForPrimary(originalPath, now); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
if err := s.fs.Rename(path, originalPath); err != nil {
return nil, err
}
if err := s.restoreMediaMetadata(path, originalPath); err != nil {
return nil, err
}
return &types.MediaLifecycleResponse{
Path: displayDocumentPath(path, s.cfg.ContentDir),
RestoredPath: displayDocumentPath(originalPath, s.cfg.ContentDir),
Operation: "restore",
}, nil
}
func (s *Service) PurgeMedia(ctx context.Context, req types.MediaLifecycleRequest) (*types.MediaLifecycleResponse, error) {
if err := requireCapability(ctx, "media.lifecycle"); err != nil {
return nil, err
}
path, _, state, err := s.resolveMediaLifecyclePath(req.Path)
if err != nil {
return nil, err
}
if state == lifecycle.StateCurrent {
return nil, fmt.Errorf("purge requires a versioned or trashed media file")
}
if err := s.fs.Remove(path); err != nil {
return nil, err
}
if !isMediaMetadataFile(path) {
sidecar := mediaMetadataPath(path)
if err := s.fs.Remove(sidecar); err != nil && !os.IsNotExist(err) {
return nil, err
}
}
return &types.MediaLifecycleResponse{
Path: displayDocumentPath(path, s.cfg.ContentDir),
Operation: "purge",
}, nil
}
func (s *Service) listDocumentLifecycleEntries(originalPath string) ([]types.DocumentHistoryEntry, error) {
dir := filepath.Dir(originalPath)
entries, err := s.fs.ReadDir(dir)
if err != nil {
return nil, err
}
out := make([]types.DocumentHistoryEntry, 0, len(entries)+1)
if _, err := s.fs.Stat(originalPath); err == nil {
entry, err := s.documentHistoryEntry(originalPath)
if err != nil {
return nil, err
}
out = append(out, entry)
} else if !os.IsNotExist(err) {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
fullPath := filepath.Join(dir, entry.Name())
parsedOriginal, _, _, ok := lifecycle.ParsePathDetails(fullPath)
if !ok || filepath.Clean(parsedOriginal) != filepath.Clean(originalPath) {
continue
}
historyEntry, err := s.documentHistoryEntry(fullPath)
if err != nil {
return nil, err
}
out = append(out, historyEntry)
}
sortDocumentHistoryEntries(out)
return out, nil
}
func (s *Service) documentHistoryEntry(path string) (types.DocumentHistoryEntry, error) {
info, err := s.fs.Stat(path)
if err != nil {
return types.DocumentHistoryEntry{}, err
}
body, err := s.fs.ReadFile(path)
if err != nil {
return types.DocumentHistoryEntry{}, err
}
fm, _, err := content.ParseDocument(body)
if err != nil {
return types.DocumentHistoryEntry{}, err
}
originalPath, state, ts, ok := lifecycle.ParsePathDetails(path)
if !ok {
originalPath = path
state = lifecycle.StateCurrent
}
var timestamp *time.Time
if !ts.IsZero() {
timestamp = &ts
}
workflow := content.WorkflowFromFrontMatter(fm, time.Now().UTC())
return types.DocumentHistoryEntry{
Path: displayDocumentPath(path, s.cfg.ContentDir),
OriginalPath: displayDocumentPath(originalPath, s.cfg.ContentDir),
State: toLifecycleState(state),
Timestamp: timestamp,
VersionComment: versionCommentFromFrontMatter(fm),
Actor: versionActorFromFrontMatter(fm),
Status: workflow.Status,
Title: strings.TrimSpace(fm.Title),
Slug: strings.TrimSpace(fm.Slug),
Layout: strings.TrimSpace(fm.Layout),
Summary: strings.TrimSpace(fm.Summary),
Draft: fm.Draft,
Archived: documentArchivedFromParams(fm.Params),
Lang: documentLangFromFrontMatter(fm, s.cfg.DefaultLang),
Author: strings.TrimSpace(fm.Author),
LastEditor: strings.TrimSpace(fm.LastEditor),
CreatedAt: fm.CreatedAt,
UpdatedAt: fm.UpdatedAt,
Size: info.Size(),
}, nil
}
func (s *Service) listMediaLifecycleEntries(originalPath string) ([]types.MediaHistoryEntry, error) {
dir := filepath.Dir(originalPath)
entries, err := s.fs.ReadDir(dir)
if err != nil {
return nil, err
}
out := make([]types.MediaHistoryEntry, 0, len(entries)+1)
if _, err := s.fs.Stat(originalPath); err == nil {
entry, err := s.mediaHistoryEntry(originalPath)
if err != nil {
return nil, err
}
out = append(out, entry)
} else if !os.IsNotExist(err) {
return nil, err
}
currentMetadataPath := mediaMetadataPath(originalPath)
if _, err := s.fs.Stat(currentMetadataPath); err == nil {
entry, err := s.mediaHistoryEntry(currentMetadataPath)
if err != nil {
return nil, err
}
out = append(out, entry)
} else if !os.IsNotExist(err) {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
fullPath := filepath.Join(dir, entry.Name())
parsedOriginal, _, _, ok := lifecycle.ParsePathDetails(fullPath)
if !ok {
continue
}
if filepath.Clean(parsedOriginal) != filepath.Clean(originalPath) && filepath.Clean(parsedOriginal) != filepath.Clean(currentMetadataPath) {
continue
}
historyEntry, err := s.mediaHistoryEntry(fullPath)
if err != nil {
return nil, err
}
out = append(out, historyEntry)
}
sortMediaHistoryEntries(out)
return out, nil
}
func (s *Service) mediaHistoryEntry(path string) (types.MediaHistoryEntry, error) {
if isMediaMetadataFile(path) {
return s.mediaMetadataHistoryEntry(path)
}
info, err := s.fs.Stat(path)
if err != nil {
return types.MediaHistoryEntry{}, err
}
originalPath, state, ts, ok := lifecycle.ParsePathDetails(path)
if !ok {
originalPath = path
state = lifecycle.StateCurrent
}
metadata, versionComment, actor, err := s.loadMediaHistoryMetadata(path)
if err != nil {
// History/trash views should remain usable even when an optional sidecar is
// missing or malformed.
metadata = types.MediaMetadata{}
versionComment = ""
actor = ""
}
ref, currentRef, resolved, err := s.mediaHistoryReferenceInfo(path, originalPath, state)
if err != nil {
return types.MediaHistoryEntry{}, err
}
var timestamp *time.Time
if !ts.IsZero() {
timestamp = &ts
}
return types.MediaHistoryEntry{
Collection: resolved.Collection,
Path: displayDocumentPath(path, s.cfg.ContentDir),
OriginalPath: displayDocumentPath(originalPath, s.cfg.ContentDir),
Reference: ref,
CurrentReference: currentRef,
Name: filepath.Base(path),
PublicURL: resolved.PublicURL,
Kind: string(resolved.Kind),
Size: info.Size(),
State: toLifecycleState(state),
Timestamp: timestamp,
VersionComment: versionComment,
Actor: actor,
Metadata: metadata,
}, nil
}
func (s *Service) mediaMetadataHistoryEntry(path string) (types.MediaHistoryEntry, error) {
info, err := s.fs.Stat(path)
if err != nil {
return types.MediaHistoryEntry{}, err
}
body, err := s.fs.ReadFile(path)
if err != nil {
return types.MediaHistoryEntry{}, err
}
var doc mediaMetadataDocument
if err := yaml.Unmarshal(body, &doc); err != nil {
return types.MediaHistoryEntry{}, err
}
originalPath, state, ts, ok := lifecycle.ParsePathDetails(path)
if !ok {
originalPath = path
state = lifecycle.StateCurrent
}
var timestamp *time.Time
if !ts.IsZero() {
timestamp = &ts
}
primaryOriginal := strings.TrimSuffix(originalPath, ".meta.yaml")
_, currentRef, resolved, err := s.mediaHistoryReferenceInfo(primaryOriginal, primaryOriginal, lifecycle.StateCurrent)
if err != nil {
return types.MediaHistoryEntry{}, err
}
return types.MediaHistoryEntry{
Collection: resolved.Collection,
Path: displayDocumentPath(path, s.cfg.ContentDir),
OriginalPath: displayDocumentPath(originalPath, s.cfg.ContentDir),
CurrentReference: currentRef,
Name: filepath.Base(primaryOriginal) + " metadata",
Kind: "metadata",
Size: info.Size(),
State: toLifecycleState(state),
Timestamp: timestamp,
VersionComment: strings.TrimSpace(doc.VersionComment),
Actor: strings.TrimSpace(doc.VersionActor),
MetadataOnly: true,
Metadata: normalizeMediaMetadata(doc.MediaMetadata),
}, nil
}
func (s *Service) resolveDocumentLifecyclePath(path string) (string, string, lifecycle.State, error) {
fullPath, err := s.resolveContentPathAllowDerived(path)
if err != nil {
return "", "", "", err
}
originalPath, state, ok := lifecycle.ParsePath(fullPath)
if !ok {
originalPath = fullPath
state = lifecycle.StateCurrent
}
return fullPath, originalPath, state, nil
}
func (s *Service) resolveContentPathAllowDerived(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", fmt.Errorf("source path is required")
}
contentRoot, err := filepath.Abs(s.cfg.ContentDir)
if err != nil {
return "", err
}
var full string
if filepath.IsAbs(path) {
full = filepath.Clean(path)
} else {
clean := filepath.Clean(path)
contentDirSlash := filepath.ToSlash(s.cfg.ContentDir)
contentBase := filepath.Base(s.cfg.ContentDir)
cleanSlash := filepath.ToSlash(clean)
switch {
case strings.HasPrefix(cleanSlash, contentDirSlash+"/") || clean == s.cfg.ContentDir:
full = clean
case strings.HasPrefix(cleanSlash, contentBase+"/") || clean == contentBase:
full = filepath.Join(filepath.Dir(s.cfg.ContentDir), clean)
default:
full = filepath.Join(s.cfg.ContentDir, clean)
}
}
full, err = filepath.Abs(full)
if err != nil {
return "", err
}
rootWithSep := contentRoot + string(filepath.Separator)
if full != contentRoot && !strings.HasPrefix(full, rootWithSep) {
return "", fmt.Errorf("source path must be inside %s", s.cfg.ContentDir)
}
if filepath.Ext(full) != ".md" {
return "", fmt.Errorf("source path must point to a markdown file")
}
if err := ensureNoSymlinkEscape(contentRoot, full); err != nil {
return "", err
}
return full, nil
}
func (s *Service) resolveMediaLifecyclePath(path string) (string, string, lifecycle.State, error) {
fullPath, err := s.resolveMediaPathAllowDerived(path)
if err != nil {
return "", "", "", err
}
originalPath, state, ok := lifecycle.ParsePath(fullPath)
if !ok {
originalPath = fullPath
state = lifecycle.StateCurrent
}
return fullPath, originalPath, state, nil
}
func (s *Service) resolveMediaPathAllowDerived(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", fmt.Errorf("media path is required")
}
absPath := path
if !filepath.IsAbs(absPath) {
clean := filepath.Clean(path)
contentDirSlash := filepath.ToSlash(s.cfg.ContentDir)
contentBase := filepath.Base(s.cfg.ContentDir)
cleanSlash := filepath.ToSlash(clean)
switch {
case strings.HasPrefix(cleanSlash, contentDirSlash+"/") || clean == s.cfg.ContentDir:
absPath = clean
case strings.HasPrefix(cleanSlash, contentBase+"/") || clean == contentBase:
absPath = filepath.Join(filepath.Dir(s.cfg.ContentDir), clean)
default:
absPath = filepath.Join(s.cfg.ContentDir, clean)
}
}
absPath, err := filepath.Abs(absPath)
if err != nil {
return "", err
}
for _, collection := range []string{"images", "videos", "audio", "documents", "uploads", "assets"} {
root, err := s.mediaRoot(collection)
if err != nil {
return "", err
}
root, err = filepath.Abs(root)
if err != nil {
return "", err
}
rootWithSep := root + string(filepath.Separator)
if absPath == root || strings.HasPrefix(absPath, rootWithSep) {
if err := ensureNoSymlinkEscape(root, absPath); err != nil {
return "", err
}
return absPath, nil
}
}
return "", fmt.Errorf("media path must be inside a media collection root")
}
func (s *Service) mediaReferenceInfoForPath(fullPath string) (string, string, media.Reference, error) {
absFullPath, err := filepath.Abs(fullPath)
if err != nil {
return "", "", media.Reference{}, err
}
for _, collection := range []string{"images", "videos", "audio", "documents", "uploads", "assets"} {
root, err := s.mediaRoot(collection)
if err != nil {
return "", "", media.Reference{}, err
}
root, err = filepath.Abs(root)
if err != nil {
return "", "", media.Reference{}, err
}
rel, err := filepath.Rel(root, absFullPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
continue
}
originalRel := filepath.ToSlash(rel)
if original, _, ok := lifecycle.ParsePath(absFullPath); ok {
if originalRelValue, err := filepath.Rel(root, original); err == nil {
originalRel = filepath.ToSlash(originalRelValue)
}
}
relSlash := filepath.ToSlash(rel)
reference := media.ReferenceScheme + collection + "/" + relSlash
currentReference := media.ReferenceScheme + collection + "/" + originalRel
resolved := media.Reference{
Collection: collection,
Path: relSlash,
PublicURL: "/" + collection + "/" + relSlash,
Kind: media.DetectKind(relSlash),
}
return reference, currentReference, resolved, nil
}
return "", "", media.Reference{}, fmt.Errorf("media path must stay inside a media collection root")
}
func (s *Service) mediaHistoryReferenceInfo(path, originalPath string, state lifecycle.State) (string, string, media.Reference, error) {
_, currentReference, currentResolved, err := s.mediaReferenceInfoForPath(originalPath)
if err != nil {
return "", "", media.Reference{}, err
}
if state == lifecycle.StateCurrent {
return currentReference, currentReference, currentResolved, nil
}
reference, _, derivedResolved, err := s.mediaReferenceInfoForPath(path)
if err != nil {
return currentReference, currentReference, currentResolved, nil
}
return reference, currentReference, derivedResolved, nil
}
func (s *Service) restoreMediaMetadata(oldPrimaryPath, restoredPrimaryPath string) error {
sidecar := mediaMetadataPath(oldPrimaryPath)
if _, err := s.fs.Stat(sidecar); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return s.fs.Rename(sidecar, mediaMetadataPath(restoredPrimaryPath))
}
func (s *Service) walkDir(root string, visit func(path string, info os.DirEntry) error) error {
entries, err := s.fs.ReadDir(root)
if err != nil {
return err
}
for _, entry := range entries {
fullPath := filepath.Join(root, entry.Name())
if entry.IsDir() {
if err := visit(fullPath, entry); err != nil {
return err
}
if err := s.walkDir(fullPath, visit); err != nil {
return err
}
continue
}
if err := visit(fullPath, entry); err != nil {
return err
}
}
return nil
}
func sortDocumentHistoryEntries(entries []types.DocumentHistoryEntry) {
sort.Slice(entries, func(i, j int) bool {
if entries[i].State != entries[j].State {
if entries[i].State == types.LifecycleStateCurrent {
return true
}
if entries[j].State == types.LifecycleStateCurrent {
return false
}
}
leftTS := time.Time{}
rightTS := time.Time{}
if entries[i].Timestamp != nil {
leftTS = *entries[i].Timestamp
}
if entries[j].Timestamp != nil {
rightTS = *entries[j].Timestamp
}
if !leftTS.Equal(rightTS) {
return leftTS.After(rightTS)
}
return entries[i].Path < entries[j].Path
})
}
func sortMediaHistoryEntries(entries []types.MediaHistoryEntry) {
sort.Slice(entries, func(i, j int) bool {
if entries[i].State != entries[j].State {
if entries[i].State == types.LifecycleStateCurrent {
return true
}
if entries[j].State == types.LifecycleStateCurrent {
return false
}
}
leftTS := time.Time{}
rightTS := time.Time{}
if entries[i].Timestamp != nil {
leftTS = *entries[i].Timestamp
}
if entries[j].Timestamp != nil {
rightTS = *entries[j].Timestamp
}
if !leftTS.Equal(rightTS) {
return leftTS.After(rightTS)
}
return entries[i].Path < entries[j].Path
})
}
func toLifecycleState(state lifecycle.State) types.LifecycleState {
switch state {
case lifecycle.StateVersion:
return types.LifecycleStateVersion
case lifecycle.StateTrash:
return types.LifecycleStateTrash
default:
return types.LifecycleStateCurrent
}
}
func documentLangFromFrontMatter(fm *content.FrontMatter, fallback string) string {
if fm == nil || fm.Params == nil {
return fallback
}
if value, ok := fm.Params["lang"].(string); ok {
return normalizeDocumentLang(value, fallback)
}
return fallback
}
func versionCommentFromFrontMatter(fm *content.FrontMatter) string {
if fm == nil || fm.Params == nil {
return ""
}
if value, ok := fm.Params["version_comment"].(string); ok {
return strings.TrimSpace(value)
}
return ""
}
func versionActorFromFrontMatter(fm *content.FrontMatter) string {
if fm == nil || fm.Params == nil {
return ""
}
if value, ok := fm.Params["version_actor"].(string); ok {
return strings.TrimSpace(value)
}
return ""
}
func buildUnifiedLineDiff(leftPath string, leftBody []byte, rightPath string, rightBody []byte) string {
leftLines := splitLinesForDiff(leftBody)
rightLines := splitLinesForDiff(rightBody)
matches := buildLineLCS(leftLines, rightLines)
var buf bytes.Buffer
_, _ = fmt.Fprintf(&buf, "--- %s\n", filepath.ToSlash(leftPath))
_, err := fmt.Fprintf(&buf, "+++ %s\n", filepath.ToSlash(rightPath))
if err != nil {
// TODO Handle this error at some point, even if redundant
}
for _, line := range matches {
buf.WriteString(line.prefix)
buf.WriteString(line.text)
buf.WriteByte('\n')
}
return strings.TrimRight(buf.String(), "\n")
}
func (s *Service) loadMediaHistoryMetadata(path string) (types.MediaMetadata, string, string, error) {
body, err := s.fs.ReadFile(mediaMetadataPath(path))
if err != nil {
if os.IsNotExist(err) {
return types.MediaMetadata{}, "", "", nil
}
return types.MediaMetadata{}, "", "", err
}
var doc mediaMetadataDocument
if err := yaml.Unmarshal(body, &doc); err != nil {
return types.MediaMetadata{}, "", "", err
}
return normalizeMediaMetadata(doc.MediaMetadata), strings.TrimSpace(doc.VersionComment), strings.TrimSpace(doc.VersionActor), nil
}
type diffLine struct {
prefix string
text string
}
func buildLineLCS(left, right []string) []diffLine {
dp := make([][]int, len(left)+1)
for i := range dp {
dp[i] = make([]int, len(right)+1)
}
for i := len(left) - 1; i >= 0; i-- {
for j := len(right) - 1; j >= 0; j-- {
if left[i] == right[j] {
dp[i][j] = dp[i+1][j+1] + 1
} else if dp[i+1][j] >= dp[i][j+1] {
dp[i][j] = dp[i+1][j]
} else {
dp[i][j] = dp[i][j+1]
}
}
}
out := make([]diffLine, 0, len(left)+len(right))
i, j := 0, 0
for i < len(left) && j < len(right) {
if left[i] == right[j] {
out = append(out, diffLine{prefix: " ", text: left[i]})
i++
j++
continue
}
if dp[i+1][j] >= dp[i][j+1] {
out = append(out, diffLine{prefix: "-", text: left[i]})
i++
} else {
out = append(out, diffLine{prefix: "+", text: right[j]})
j++
}
}
for i < len(left) {
out = append(out, diffLine{prefix: "-", text: left[i]})
i++
}
for j < len(right) {
out = append(out, diffLine{prefix: "+", text: right[j]})
j++
}
return out
}
func splitLinesForDiff(body []byte) []string {
s := strings.ReplaceAll(string(body), "\r\n", "\n")
s = strings.TrimSuffix(s, "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
func (s *Service) mediaUsage(reference string) ([]types.DocumentSummary, error) {
graph, err := s.load(context.Background(), true)
if err != nil {
return nil, err
}
out := make([]types.DocumentSummary, 0)
for _, doc := range graph.Documents {
raw, err := s.fs.ReadFile(doc.SourcePath)
if err != nil {
return nil, err
}
if strings.Contains(string(raw), reference) {
out = append(out, toSummary(doc))
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Type != out[j].Type {
return out[i].Type < out[j].Type
}
if out[i].Lang != out[j].Lang {
return out[i].Lang < out[j].Lang
}
return out[i].SourcePath < out[j].SourcePath
})
return out, nil
}
package service
import (
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/lifecycle"
)
func (s *Service) versionFile(path string, now time.Time) error {
if err := lifecycle.ValidateCurrentPath(path); err != nil {
return err
}
if _, err := s.fs.Stat(path); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
versionPath, err := s.uniqueDerivedPath(func(ts time.Time) string {
return lifecycle.BuildVersionPath(path, ts)
}, now)
if err != nil {
return err
}
if err := s.fs.Rename(path, versionPath); err != nil {
return err
}
return s.pruneVersions(path)
}
func (s *Service) snapshotDocumentVersion(path string, now time.Time, comment, actor string) error {
if err := lifecycle.ValidateCurrentPath(path); err != nil {
return err
}
raw, err := s.fs.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
fm, body, err := content.ParseDocument(raw)
if err != nil {
return err
}
comment = strings.TrimSpace(comment)
if comment != "" {
if fm.Params == nil {
fm.Params = make(map[string]any)
}
fm.Params["version_comment"] = comment
fm.Params["versioned_at"] = now.UTC().Format(time.RFC3339)
}
actor = strings.TrimSpace(actor)
if actor != "" {
if fm.Params == nil {
fm.Params = make(map[string]any)
}
fm.Params["version_actor"] = actor
}
versionPath, err := s.uniqueDerivedPath(func(ts time.Time) string {
return lifecycle.BuildVersionPath(path, ts)
}, now)
if err != nil {
return err
}
rendered, err := marshalDocument(fm, body)
if err != nil {
return err
}
if err := s.fs.WriteFile(versionPath, rendered, 0o644); err != nil {
return err
}
return s.pruneVersions(path)
}
func (s *Service) trashFile(path string, now time.Time) (string, error) {
if err := lifecycle.ValidateCurrentPath(path); err != nil {
return "", err
}
if _, err := s.fs.Stat(path); err != nil {
return "", err
}
trashPath, err := s.uniqueDerivedPath(func(ts time.Time) string {
return lifecycle.BuildTrashPath(path, ts)
}, now)
if err != nil {
return "", err
}
if err := s.fs.Rename(path, trashPath); err != nil {
return "", err
}
return trashPath, nil
}
func (s *Service) pruneVersions(currentPath string) error {
maxVersions := s.cfg.Content.MaxVersionsPerFile
if maxVersions <= 0 {
return nil
}
dir := filepath.Dir(currentPath)
entries, err := s.fs.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
type candidate struct {
name string
path string
}
candidates := make([]candidate, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
fullPath := filepath.Join(dir, entry.Name())
original, state, ok := lifecycle.ParsePath(fullPath)
if !ok || state != lifecycle.StateVersion {
continue
}
if filepath.Clean(original) != filepath.Clean(currentPath) {
continue
}
candidates = append(candidates, candidate{name: entry.Name(), path: fullPath})
}
if len(candidates) <= maxVersions {
return nil
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].name > candidates[j].name
})
for _, candidate := range candidates[maxVersions:] {
if err := s.fs.Remove(candidate.path); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func (s *Service) uniqueDerivedPath(build func(time.Time) string, now time.Time) (string, error) {
for i := 0; i < 10_000; i++ {
candidate := build(now.Add(time.Duration(i) * time.Second))
if _, err := s.fs.Stat(candidate); err != nil {
if os.IsNotExist(err) {
return candidate, nil
}
return "", err
}
}
return "", os.ErrExist
}
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sphireinc/foundry/internal/admin/types"
"gopkg.in/yaml.v3"
)
const documentLockTTL = 2 * time.Minute
type documentLockRecord struct {
SourcePath string `yaml:"source_path"`
Username string `yaml:"username"`
Name string `yaml:"name,omitempty"`
Role string `yaml:"role,omitempty"`
Token string `yaml:"token"`
LastBeatAt time.Time `yaml:"last_beat_at"`
ExpiresAt time.Time `yaml:"expires_at"`
}
type documentLockFile struct {
Locks []documentLockRecord `yaml:"locks"`
}
func (s *Service) AcquireDocumentLock(ctx context.Context, req types.DocumentLockRequest) (*types.DocumentLockResponse, error) {
identity, ok := currentIdentity(ctx)
if !ok {
return nil, fmt.Errorf("admin identity is required")
}
sourcePath, err := s.resolveContentPathAllowDerived(req.SourcePath)
if err != nil {
return nil, err
}
now := time.Now().UTC()
s.lockMu.Lock()
defer s.lockMu.Unlock()
locks, err := s.loadLocksLocked(now)
if err != nil {
return nil, err
}
if existing, ok := locks[sourcePath]; ok {
if existing.Token == strings.TrimSpace(req.LockToken) || strings.EqualFold(existing.Username, identity.Username) {
existing.LastBeatAt = now
existing.ExpiresAt = now.Add(documentLockTTL)
locks[sourcePath] = existing
if err := s.saveLocksLocked(locks); err != nil {
return nil, err
}
return &types.DocumentLockResponse{Lock: toDocumentLock(existing, identity.Username, sourcePath, s.cfg.ContentDir)}, nil
}
return &types.DocumentLockResponse{Lock: toDocumentLock(existing, identity.Username, sourcePath, s.cfg.ContentDir)}, nil
}
token, err := randomLockToken()
if err != nil {
return nil, err
}
record := documentLockRecord{
SourcePath: sourcePath,
Username: identity.Username,
Name: identity.Name,
Role: identity.Role,
Token: token,
LastBeatAt: now,
ExpiresAt: now.Add(documentLockTTL),
}
locks[sourcePath] = record
if err := s.saveLocksLocked(locks); err != nil {
return nil, err
}
return &types.DocumentLockResponse{Lock: toDocumentLock(record, identity.Username, sourcePath, s.cfg.ContentDir)}, nil
}
func (s *Service) HeartbeatDocumentLock(ctx context.Context, req types.DocumentLockRequest) (*types.DocumentLockResponse, error) {
identity, ok := currentIdentity(ctx)
if !ok {
return nil, fmt.Errorf("admin identity is required")
}
sourcePath, err := s.resolveContentPathAllowDerived(req.SourcePath)
if err != nil {
return nil, err
}
now := time.Now().UTC()
s.lockMu.Lock()
defer s.lockMu.Unlock()
locks, err := s.loadLocksLocked(now)
if err != nil {
return nil, err
}
record, ok := locks[sourcePath]
if !ok {
return nil, fmt.Errorf("document lock not found")
}
if record.Token != strings.TrimSpace(req.LockToken) || !strings.EqualFold(record.Username, identity.Username) {
return nil, fmt.Errorf("document is locked by another user")
}
record.LastBeatAt = now
record.ExpiresAt = now.Add(documentLockTTL)
locks[sourcePath] = record
if err := s.saveLocksLocked(locks); err != nil {
return nil, err
}
return &types.DocumentLockResponse{Lock: toDocumentLock(record, identity.Username, sourcePath, s.cfg.ContentDir)}, nil
}
func (s *Service) ReleaseDocumentLock(ctx context.Context, req types.DocumentLockRequest) error {
identity, ok := currentIdentity(ctx)
if !ok {
return fmt.Errorf("admin identity is required")
}
sourcePath, err := s.resolveContentPathAllowDerived(req.SourcePath)
if err != nil {
return err
}
now := time.Now().UTC()
s.lockMu.Lock()
defer s.lockMu.Unlock()
locks, err := s.loadLocksLocked(now)
if err != nil {
return err
}
record, ok := locks[sourcePath]
if !ok {
return nil
}
if record.Token != strings.TrimSpace(req.LockToken) && !strings.EqualFold(record.Username, identity.Username) && !adminauthCapabilityAllowed(identity, "users.manage") {
return fmt.Errorf("document is locked by another user")
}
delete(locks, sourcePath)
return s.saveLocksLocked(locks)
}
func (s *Service) DocumentLock(ctx context.Context, sourcePath string) (*types.DocumentLock, error) {
identity, _ := currentIdentity(ctx)
fullPath, err := s.resolveContentPathAllowDerived(sourcePath)
if err != nil {
return nil, err
}
now := time.Now().UTC()
s.lockMu.Lock()
defer s.lockMu.Unlock()
locks, err := s.loadLocksLocked(now)
if err != nil {
return nil, err
}
record, ok := locks[fullPath]
if !ok {
return nil, nil
}
username := ""
if identity != nil {
username = identity.Username
}
lock := toDocumentLock(record, username, fullPath, s.cfg.ContentDir)
return lock, nil
}
func (s *Service) ensureDocumentLock(ctx context.Context, sourcePath, lockToken string) error {
identity, ok := currentIdentity(ctx)
if !ok {
return nil
}
if adminauthCapabilityAllowed(identity, "documents.write") || adminauthCapabilityAllowed(identity, "documents.review") || adminauthCapabilityAllowed(identity, "documents.lifecycle") {
return nil
}
fullPath, err := s.resolveContentPathAllowDerived(sourcePath)
if err != nil {
return err
}
now := time.Now().UTC()
s.lockMu.Lock()
defer s.lockMu.Unlock()
locks, err := s.loadLocksLocked(now)
if err != nil {
return err
}
record, ok := locks[fullPath]
if !ok {
return fmt.Errorf("document must be locked before saving")
}
if record.Token != strings.TrimSpace(lockToken) || !strings.EqualFold(record.Username, identity.Username) {
return fmt.Errorf("document is locked by another user")
}
record.LastBeatAt = now
record.ExpiresAt = now.Add(documentLockTTL)
locks[fullPath] = record
return s.saveLocksLocked(locks)
}
func (s *Service) loadLocksLocked(now time.Time) (map[string]documentLockRecord, error) {
records := make(map[string]documentLockRecord)
if strings.TrimSpace(s.cfg.Admin.LockFile) == "" {
return records, nil
}
body, err := s.fs.ReadFile(s.cfg.Admin.LockFile)
if err != nil {
if os.IsNotExist(err) {
return records, nil
}
return nil, err
}
var file documentLockFile
if err := yaml.Unmarshal(body, &file); err != nil {
return nil, err
}
for _, record := range file.Locks {
if record.SourcePath == "" || now.After(record.ExpiresAt) {
continue
}
records[record.SourcePath] = record
}
return records, nil
}
func (s *Service) saveLocksLocked(locks map[string]documentLockRecord) error {
entries := make([]documentLockRecord, 0, len(locks))
for _, record := range locks {
entries = append(entries, record)
}
body, err := yaml.Marshal(documentLockFile{Locks: entries})
if err != nil {
return err
}
if err := s.fs.MkdirAll(filepath.Dir(s.cfg.Admin.LockFile), 0o755); err != nil {
return err
}
return s.fs.WriteFile(s.cfg.Admin.LockFile, body, 0o600)
}
func toDocumentLock(record documentLockRecord, currentUsername, sourcePath, contentDir string) *types.DocumentLock {
expires := record.ExpiresAt
lastBeat := record.LastBeatAt
lock := &types.DocumentLock{
SourcePath: displayDocumentPath(sourcePath, contentDir),
Username: record.Username,
Name: record.Name,
Role: record.Role,
OwnedByMe: strings.EqualFold(record.Username, currentUsername),
Token: record.Token,
ExpiresAt: &expires,
LastBeatAt: &lastBeat,
}
if !lock.OwnedByMe {
lock.Token = ""
}
return lock
}
func randomLockToken() (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
package service
import (
"context"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/lifecycle"
"github.com/sphireinc/foundry/internal/media"
"github.com/sphireinc/foundry/internal/safepath"
"gopkg.in/yaml.v3"
)
const maxMediaUploadSize = 256 << 20
var mediaReferencePattern = regexp.MustCompile(`media:[a-z]+/[^\s"'<>]+`)
func (s *Service) ListMedia(ctx context.Context, query ...string) ([]types.MediaItem, error) {
if err := requireCapability(ctx, "media.read"); err != nil {
return nil, err
}
search := ""
if len(query) > 0 {
search = strings.ToLower(strings.TrimSpace(query[0]))
}
var items []types.MediaItem
usageCounts, err := s.mediaUsageCounts()
if err != nil {
return nil, err
}
for _, collection := range []string{"images", "videos", "audio", "documents", "uploads", "assets"} {
root, err := s.mediaRoot(collection)
if err != nil {
return nil, err
}
if _, err := os.Stat(root); err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if isMediaMetadataFile(path) || lifecycle.IsDerivedPath(path) {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
ref, err := media.NewReference(collection, filepath.ToSlash(rel))
if err != nil {
return err
}
resolved, err := media.ResolveReference(ref)
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
metadata, err := s.loadMediaMetadataFromPath(path)
if err != nil {
return err
}
if search != "" && !matchesMediaQuery(resolved.Path, ref, metadata, search) {
return nil
}
items = append(items, types.MediaItem{
Collection: collection,
Path: resolved.Path,
Name: filepath.Base(resolved.Path),
Reference: ref,
PublicURL: resolved.PublicURL,
Kind: string(resolved.Kind),
Size: info.Size(),
UsedByCount: usageCounts[ref],
Metadata: metadata,
})
return nil
})
if err != nil {
return nil, err
}
}
sort.Slice(items, func(i, j int) bool {
if items[i].Collection != items[j].Collection {
return items[i].Collection < items[j].Collection
}
return items[i].Path < items[j].Path
})
return items, nil
}
func (s *Service) GetMediaDetail(ctx context.Context, reference string) (*types.MediaDetailResponse, error) {
if err := requireCapability(ctx, "media.read"); err != nil {
return nil, err
}
item, path, err := s.resolveMediaItem(reference)
if err != nil {
return nil, err
}
metadata, err := s.loadMediaMetadataFromPath(path)
if err != nil {
return nil, err
}
item.Metadata = metadata
usedBy, err := s.mediaUsage(reference)
if err != nil {
return nil, err
}
return &types.MediaDetailResponse{MediaItem: item, UsedBy: usedBy}, nil
}
func (s *Service) SaveMedia(ctx context.Context, collection, dir, filename, contentType string, body []byte) (*types.MediaUploadResponse, error) {
if err := requireCapability(ctx, "media.write"); err != nil {
return nil, err
}
_ = contentType
if len(body) == 0 {
return nil, fmt.Errorf("media upload body is required")
}
if len(body) > maxMediaUploadSize {
return nil, fmt.Errorf("media upload exceeds %d bytes", maxMediaUploadSize)
}
now := time.Now()
uploadInfo, err := media.PrepareUpload(collection, filename, body, now)
if err != nil {
return nil, err
}
root, err := s.mediaRoot(uploadInfo.Collection)
if err != nil {
return nil, err
}
cleanDir, err := s.cleanMediaDir(uploadInfo.Collection, dir)
if err != nil {
return nil, err
}
targetDir := root
relPrefix := ""
if cleanDir != "" {
targetDir, err = safepath.ResolveRelativeUnderRoot(root, filepath.FromSlash(cleanDir))
if err != nil {
return nil, err
}
relPrefix = cleanDir + "/"
}
if err := s.fs.MkdirAll(targetDir, 0o755); err != nil {
return nil, err
}
fullPath := filepath.Join(targetDir, uploadInfo.StoredFilename)
if err := ensureNoSymlinkEscape(root, fullPath); err != nil {
return nil, err
}
finalName := uploadInfo.StoredFilename
created := true
if _, err := s.fs.Stat(fullPath); err == nil {
return nil, fmt.Errorf("generated media filename collision; retry upload")
} else if !os.IsNotExist(err) {
return nil, err
}
if err := s.fs.WriteFile(fullPath, body, 0o644); err != nil {
return nil, err
}
if err := s.writeUploadedMediaMetadata(fullPath, uploadInfo, actorLabelFromContext(ctx), now); err != nil {
return nil, err
}
ref, err := media.NewReference(uploadInfo.Collection, relPrefix+finalName)
if err != nil {
return nil, err
}
resolved, err := media.ResolveReference(ref)
if err != nil {
return nil, err
}
return &types.MediaUploadResponse{
MediaItem: types.MediaItem{
Collection: uploadInfo.Collection,
Path: resolved.Path,
Name: finalName,
Reference: ref,
PublicURL: resolved.PublicURL,
Kind: string(uploadInfo.Kind),
Size: uploadInfo.Size,
Metadata: types.MediaMetadata{
Title: uploadTitle(uploadInfo.OriginalFilename),
OriginalFilename: uploadInfo.OriginalFilename,
StoredFilename: uploadInfo.StoredFilename,
Extension: uploadInfo.Extension,
MIMEType: uploadInfo.MIMEType,
Kind: string(uploadInfo.Kind),
ContentHash: uploadInfo.ContentHash,
FileSize: uploadInfo.Size,
Width: uploadInfo.Width,
Height: uploadInfo.Height,
UploadedAt: timePtr(now.UTC()),
UploadedBy: actorLabelFromContext(ctx),
},
},
Created: created,
}, nil
}
func (s *Service) ReplaceMedia(ctx context.Context, reference, contentType string, body []byte) (*types.MediaReplaceResponse, error) {
if err := requireCapability(ctx, "media.write"); err != nil {
return nil, err
}
_ = contentType
if len(body) == 0 {
return nil, fmt.Errorf("media upload body is required")
}
if len(body) > maxMediaUploadSize {
return nil, fmt.Errorf("media upload exceeds %d bytes", maxMediaUploadSize)
}
item, fullPath, err := s.resolveMediaItem(reference)
if err != nil {
return nil, err
}
root, err := s.mediaRoot(item.Collection)
if err != nil {
return nil, err
}
if err := ensureNoSymlinkEscape(root, fullPath); err != nil {
return nil, err
}
now := time.Now()
uploadInfo, err := media.PrepareUpload(item.Collection, item.Name, body, now)
if err != nil {
return nil, err
}
if err := s.versionFile(fullPath, now); err != nil {
return nil, err
}
if err := s.versionMediaMetadataForPrimary(fullPath, now); err != nil && !os.IsNotExist(err) {
return nil, err
}
if err := s.fs.WriteFile(fullPath, body, 0o644); err != nil {
return nil, err
}
currentMetadata, err := s.loadMediaMetadataFromPath(fullPath)
if err != nil {
return nil, err
}
uploadInfo.OriginalFilename = firstNonEmptyMedia(currentMetadata.OriginalFilename, item.Name)
uploadInfo.StoredFilename = item.Name
uploadInfo.SafeFilename = item.Name
mergedMetadata := preserveEditableMediaMetadata(currentMetadata, uploadInfo, actorLabelFromContext(ctx), now)
if err := s.writeMediaMetadataDocument(fullPath, mergedMetadata); err != nil {
return nil, err
}
info, err := s.fs.Stat(fullPath)
if err != nil {
return nil, err
}
item.Size = info.Size()
item.Kind = string(uploadInfo.Kind)
item.Metadata = mergedMetadata
return &types.MediaReplaceResponse{
MediaItem: item,
Replaced: true,
}, nil
}
func (s *Service) SaveMediaMetadata(ctx context.Context, reference string, metadata types.MediaMetadata, versionComment, actor string) (*types.MediaDetailResponse, error) {
if err := requireCapability(ctx, "media.write"); err != nil {
return nil, err
}
item, path, err := s.resolveMediaItem(reference)
if err != nil {
return nil, err
}
existingMetadata, err := s.loadMediaMetadataFromPath(path)
if err != nil {
return nil, err
}
metadata = mergeEditableMediaMetadata(existingMetadata, metadata)
sidecar, err := s.mediaSidecarPath(path)
if err != nil {
return nil, err
}
now := time.Now()
if mediaMetadataEmpty(metadata) {
if err := s.snapshotMediaMetadataVersion(path, now, versionComment, actor); err != nil {
return nil, err
}
if err := s.fs.Remove(sidecar); err != nil && !os.IsNotExist(err) {
return nil, err
}
} else {
if err := s.snapshotMediaMetadataVersion(path, now, versionComment, actor); err != nil {
return nil, err
}
body, err := yaml.Marshal(metadata)
if err != nil {
return nil, err
}
if err := s.fs.WriteFile(sidecar, body, 0o644); err != nil {
return nil, err
}
}
item.Metadata = metadata
usedBy, err := s.mediaUsage(reference)
if err != nil {
return nil, err
}
return &types.MediaDetailResponse{MediaItem: item, UsedBy: usedBy}, nil
}
func matchesMediaQuery(pathValue, reference string, metadata types.MediaMetadata, query string) bool {
for _, candidate := range []string{
pathValue,
reference,
metadata.Title,
metadata.Alt,
metadata.Caption,
metadata.Description,
metadata.Credit,
metadata.OriginalFilename,
metadata.StoredFilename,
metadata.MIMEType,
metadata.Kind,
metadata.ContentHash,
metadata.UploadedBy,
} {
if strings.Contains(strings.ToLower(candidate), query) {
return true
}
}
for _, tag := range metadata.Tags {
if strings.Contains(strings.ToLower(tag), query) {
return true
}
}
return false
}
func (s *Service) collectionDir(collection string) (string, error) {
switch strings.TrimSpace(collection) {
case "images":
return s.cfg.Content.ImagesDir, nil
case "videos":
return s.cfg.Content.VideoDir, nil
case "audio":
return s.cfg.Content.AudioDir, nil
case "documents":
return s.cfg.Content.DocumentsDir, nil
case "uploads":
return s.cfg.Content.UploadsDir, nil
case "assets":
return s.cfg.Content.AssetsDir, nil
default:
return "", fmt.Errorf("unsupported media collection: %s", collection)
}
}
func (s *Service) mediaRoot(collection string) (string, error) {
collectionDir, err := s.collectionDir(collection)
if err != nil {
return "", err
}
return filepath.Join(s.cfg.ContentDir, collectionDir), nil
}
func (s *Service) resolveMediaItem(reference string) (types.MediaItem, string, error) {
ref, err := media.ResolveReference(reference)
if err != nil {
return types.MediaItem{}, "", err
}
root, err := s.mediaRoot(ref.Collection)
if err != nil {
return types.MediaItem{}, "", err
}
fullPath := filepath.Join(root, filepath.FromSlash(ref.Path))
if err := ensureNoSymlinkEscape(root, fullPath); err != nil {
return types.MediaItem{}, "", err
}
if lifecycle.IsDerivedPath(fullPath) {
return types.MediaItem{}, "", fmt.Errorf("media reference must point to a current media file")
}
info, err := s.fs.Stat(fullPath)
if err != nil {
return types.MediaItem{}, "", err
}
return types.MediaItem{
Collection: ref.Collection,
Path: ref.Path,
Name: filepath.Base(ref.Path),
Reference: reference,
PublicURL: ref.PublicURL,
Kind: string(ref.Kind),
Size: info.Size(),
}, fullPath, nil
}
func (s *Service) loadMediaMetadataFromPath(path string) (types.MediaMetadata, error) {
var metadata types.MediaMetadata
sidecar, err := s.mediaSidecarPath(path)
if err != nil {
return metadata, err
}
body, err := s.fs.ReadFile(sidecar)
if err != nil {
if os.IsNotExist(err) {
return metadata, nil
}
return metadata, err
}
if err := yaml.Unmarshal(body, &metadata); err != nil {
return metadata, err
}
return normalizeMediaMetadata(metadata), nil
}
func mediaMetadataPath(path string) string {
return path + ".meta.yaml"
}
func mediaMetadataVersionPath(primaryPath string, now time.Time) string {
return lifecycle.BuildVersionPath(primaryPath, now) + ".meta.yaml"
}
func mediaMetadataTrashPath(primaryPath string, now time.Time) string {
return lifecycle.BuildTrashPath(primaryPath, now) + ".meta.yaml"
}
func isMediaMetadataFile(path string) bool {
return strings.HasSuffix(strings.ToLower(path), ".meta.yaml")
}
func normalizeMediaMetadata(metadata types.MediaMetadata) types.MediaMetadata {
metadata.Title = strings.TrimSpace(metadata.Title)
metadata.Alt = strings.TrimSpace(metadata.Alt)
metadata.Caption = strings.TrimSpace(metadata.Caption)
metadata.Description = strings.TrimSpace(metadata.Description)
metadata.Credit = strings.TrimSpace(metadata.Credit)
metadata.OriginalFilename = strings.TrimSpace(metadata.OriginalFilename)
metadata.StoredFilename = strings.TrimSpace(metadata.StoredFilename)
metadata.Extension = strings.ToLower(strings.TrimSpace(metadata.Extension))
metadata.MIMEType = strings.ToLower(strings.TrimSpace(metadata.MIMEType))
metadata.Kind = strings.TrimSpace(metadata.Kind)
metadata.ContentHash = strings.ToLower(strings.TrimSpace(metadata.ContentHash))
metadata.Width = max(metadata.Width, 0)
metadata.Height = max(metadata.Height, 0)
metadata.FocalX = strings.TrimSpace(metadata.FocalX)
metadata.FocalY = strings.TrimSpace(metadata.FocalY)
metadata.UploadedBy = strings.TrimSpace(metadata.UploadedBy)
if len(metadata.Tags) > 0 {
tags := make([]string, 0, len(metadata.Tags))
for _, tag := range metadata.Tags {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
metadata.Tags = tags
}
return metadata
}
type mediaMetadataDocument struct {
types.MediaMetadata `yaml:",inline"`
VersionComment string `yaml:"version_comment,omitempty"`
VersionedAt string `yaml:"versioned_at,omitempty"`
VersionActor string `yaml:"version_actor,omitempty"`
}
func mediaMetadataEmpty(metadata types.MediaMetadata) bool {
return metadata.Title == "" &&
metadata.Alt == "" &&
metadata.Caption == "" &&
metadata.Description == "" &&
metadata.Credit == "" &&
len(metadata.Tags) == 0 &&
metadata.OriginalFilename == "" &&
metadata.StoredFilename == "" &&
metadata.Extension == "" &&
metadata.MIMEType == "" &&
metadata.Kind == "" &&
metadata.ContentHash == "" &&
metadata.FileSize == 0 &&
metadata.Width == 0 &&
metadata.Height == 0 &&
metadata.FocalX == "" &&
metadata.FocalY == "" &&
metadata.UploadedAt == nil &&
metadata.UploadedBy == ""
}
func (s *Service) versionMediaMetadataForPrimary(primaryPath string, now time.Time) error {
sidecar, err := s.mediaSidecarPath(primaryPath)
if err != nil {
return err
}
if _, err := s.fs.Stat(sidecar); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
versionPath, err := s.uniqueDerivedPath(func(ts time.Time) string {
return mediaMetadataVersionPath(primaryPath, ts)
}, now)
if err != nil {
return err
}
if err := s.fs.Rename(sidecar, versionPath); err != nil {
return err
}
return s.pruneVersions(sidecar)
}
func (s *Service) snapshotMediaMetadataVersion(primaryPath string, now time.Time, versionComment, actor string) error {
sidecar, err := s.mediaSidecarPath(primaryPath)
if err != nil {
return err
}
body, err := s.fs.ReadFile(sidecar)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
versionPath, err := s.uniqueDerivedPath(func(ts time.Time) string {
return mediaMetadataVersionPath(primaryPath, ts)
}, now)
if err != nil {
return err
}
if strings.TrimSpace(versionComment) == "" {
if strings.TrimSpace(actor) == "" {
if err := s.fs.WriteFile(versionPath, body, 0o644); err != nil {
return err
}
return s.pruneVersions(sidecar)
}
}
var metadataDoc mediaMetadataDocument
if err := yaml.Unmarshal(body, &metadataDoc); err != nil {
return err
}
metadataDoc.VersionComment = strings.TrimSpace(versionComment)
metadataDoc.VersionedAt = now.UTC().Format(time.RFC3339)
metadataDoc.VersionActor = strings.TrimSpace(actor)
versionBody, err := yaml.Marshal(metadataDoc)
if err != nil {
return err
}
if err := s.fs.WriteFile(versionPath, versionBody, 0o644); err != nil {
return err
}
return s.pruneVersions(sidecar)
}
func (s *Service) trashMediaMetadataForPrimary(primaryPath string, now time.Time) error {
sidecar, err := s.mediaSidecarPath(primaryPath)
if err != nil {
return err
}
if _, err := s.fs.Stat(sidecar); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
trashPath, err := s.uniqueDerivedPath(func(ts time.Time) string {
return mediaMetadataTrashPath(primaryPath, ts)
}, now)
if err != nil {
return err
}
return s.fs.Rename(sidecar, trashPath)
}
func (s *Service) mediaSidecarPath(primaryPath string) (string, error) {
_, _, resolved, err := s.mediaReferenceInfoForPath(primaryPath)
if err != nil {
return "", err
}
root, err := s.mediaRoot(resolved.Collection)
if err != nil {
return "", err
}
sidecar := mediaMetadataPath(primaryPath)
if err := ensureNoSymlinkEscape(root, sidecar); err != nil {
return "", err
}
return sidecar, nil
}
func (s *Service) writeUploadedMediaMetadata(primaryPath string, info media.UploadInfo, actor string, now time.Time) error {
metadata := types.MediaMetadata{
Title: uploadTitle(info.OriginalFilename),
OriginalFilename: info.OriginalFilename,
StoredFilename: info.StoredFilename,
Extension: info.Extension,
MIMEType: info.MIMEType,
Kind: string(info.Kind),
ContentHash: info.ContentHash,
FileSize: info.Size,
Width: info.Width,
Height: info.Height,
UploadedAt: timePtr(now.UTC()),
UploadedBy: strings.TrimSpace(actor),
}
return s.writeMediaMetadataDocument(primaryPath, metadata)
}
func (s *Service) writeMediaMetadataDocument(primaryPath string, metadata types.MediaMetadata) error {
sidecar, err := s.mediaSidecarPath(primaryPath)
if err != nil {
return err
}
body, err := yaml.Marshal(normalizeMediaMetadata(metadata))
if err != nil {
return err
}
return s.fs.WriteFile(sidecar, body, 0o644)
}
func preserveEditableMediaMetadata(existing types.MediaMetadata, info media.UploadInfo, actor string, now time.Time) types.MediaMetadata {
existing = normalizeMediaMetadata(existing)
existing.OriginalFilename = info.OriginalFilename
existing.StoredFilename = info.StoredFilename
existing.Extension = info.Extension
existing.MIMEType = info.MIMEType
existing.Kind = string(info.Kind)
existing.ContentHash = info.ContentHash
existing.FileSize = info.Size
existing.Width = info.Width
existing.Height = info.Height
if existing.UploadedAt == nil {
existing.UploadedAt = timePtr(now.UTC())
}
if strings.TrimSpace(existing.UploadedBy) == "" {
existing.UploadedBy = strings.TrimSpace(actor)
}
if strings.TrimSpace(existing.Title) == "" {
existing.Title = uploadTitle(info.OriginalFilename)
}
return existing
}
func mergeEditableMediaMetadata(existing, requested types.MediaMetadata) types.MediaMetadata {
existing = normalizeMediaMetadata(existing)
requested = normalizeMediaMetadata(requested)
existing.Title = requested.Title
existing.Alt = requested.Alt
existing.Caption = requested.Caption
existing.Description = requested.Description
existing.Credit = requested.Credit
existing.Tags = append([]string(nil), requested.Tags...)
existing.FocalX = requested.FocalX
existing.FocalY = requested.FocalY
return existing
}
func (s *Service) mediaUsageCounts() (map[string]int, error) {
graph, err := s.load(context.Background(), true)
if err != nil {
return nil, err
}
counts := make(map[string]int)
for _, doc := range graph.Documents {
raw, err := s.fs.ReadFile(doc.SourcePath)
if err != nil {
return nil, err
}
seen := make(map[string]struct{})
for _, match := range mediaReferencePattern.FindAllString(string(raw), -1) {
seen[match] = struct{}{}
}
for ref := range seen {
counts[ref]++
}
}
return counts, nil
}
func uploadTitle(filename string) string {
name := strings.TrimSuffix(filepath.Base(strings.TrimSpace(filename)), filepath.Ext(strings.TrimSpace(filename)))
name = strings.TrimSpace(strings.ReplaceAll(name, "-", " "))
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
if name == "" {
return "Upload"
}
return name
}
func firstNonEmptyMedia(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func actorLabelFromContext(ctx context.Context) string {
if identity, ok := currentIdentity(ctx); ok {
if strings.TrimSpace(identity.Name) != "" {
return strings.TrimSpace(identity.Name)
}
return strings.TrimSpace(identity.Username)
}
return ""
}
func timePtr(v time.Time) *time.Time {
return &v
}
func (s *Service) cleanMediaDir(collection, value string) (string, error) {
value = strings.TrimSpace(strings.ReplaceAll(value, `\`, "/"))
value = strings.Trim(value, "/")
if value == "" {
return "", nil
}
cleaned := path.Clean(value)
if cleaned == "." || cleaned == "/" || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("invalid media dir: path must stay inside the media root")
}
cleaned = strings.TrimPrefix(cleaned, "/")
contentPrefix := strings.Trim(strings.ReplaceAll(s.cfg.ContentDir, `\`, "/"), "/")
contentBase := strings.Trim(filepath.Base(s.cfg.ContentDir), "/")
collectionDir, err := s.collectionDir(collection)
if err != nil {
return "", err
}
collectionPrefix := strings.Trim(strings.ReplaceAll(collectionDir, `\`, "/"), "/")
for _, prefix := range []string{
collectionPrefix,
path.Join(contentBase, collectionPrefix),
path.Join(contentPrefix, collectionPrefix),
} {
prefix = strings.Trim(prefix, "/")
if prefix == "" {
continue
}
if cleaned == prefix {
return "", nil
}
if strings.HasPrefix(cleaned, prefix+"/") {
cleaned = strings.TrimPrefix(cleaned, prefix+"/")
break
}
}
return strings.TrimPrefix(cleaned, "/"), nil
}
package service
import (
"context"
"os"
"time"
)
func (s *Service) DeleteMedia(ctx context.Context, reference string) error {
if err := requireCapability(ctx, "media.lifecycle"); err != nil {
return err
}
_, fullPath, err := s.resolveMediaItem(reference)
if err != nil {
return err
}
now := time.Now()
if _, err := s.trashFile(fullPath, now); err != nil {
return err
}
if err := s.trashMediaMetadataForPrimary(fullPath, now); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
package service
import (
"context"
"os"
"os/exec"
"strings"
"time"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/hostservice"
"github.com/sphireinc/foundry/internal/logx"
"github.com/sphireinc/foundry/internal/standalone"
"github.com/sphireinc/foundry/internal/updater"
)
func (s *Service) GetOperationsStatus(ctx context.Context) (*types.OperationsStatusResponse, error) {
_ = ctx
projectDir, err := os.Getwd()
if err != nil {
return nil, err
}
resp := &types.OperationsStatusResponse{}
serviceStatus, err := hostservice.CheckStatus(projectDir)
if err == nil && serviceStatus != nil {
resp.ServiceInstalled = serviceStatus.Installed
resp.ServiceRunning = serviceStatus.Running
resp.ServiceEnabled = serviceStatus.Enabled
resp.ServiceMessage = serviceStatus.Message
if serviceStatus.Metadata != nil {
resp.ServiceName = serviceStatus.Metadata.Name
resp.ServiceFile = serviceStatus.Metadata.ServicePath
resp.ServiceLog = serviceStatus.Metadata.LogPath
}
}
if standaloneState, running, err := standalone.RunningState(projectDir); err == nil && standaloneState != nil {
resp.StandalonePID = standaloneState.PID
resp.StandaloneLog = standaloneState.LogPath
resp.StandaloneActive = running
}
status, err := s.GetSystemStatus(context.Background())
if err == nil && status != nil {
resp.Checks = append([]types.HealthCheck(nil), status.Checks...)
}
return resp, nil
}
func (s *Service) ReadOperationsLog(ctx context.Context, lines int) (*types.OperationsLogResponse, error) {
_ = ctx
projectDir, err := os.Getwd()
if err != nil {
return nil, err
}
serviceStatus, err := hostservice.CheckStatus(projectDir)
if err == nil && serviceStatus != nil && serviceStatus.Metadata != nil && strings.TrimSpace(serviceStatus.Metadata.LogPath) != "" {
content, readErr := standalone.ReadLastLines(serviceStatus.Metadata.LogPath, lines)
if readErr == nil {
return &types.OperationsLogResponse{
Source: "service",
LogPath: serviceStatus.Metadata.LogPath,
Content: content,
}, nil
}
}
if standaloneState, running, err := standalone.RunningState(projectDir); err == nil && standaloneState != nil && running {
content, readErr := standalone.ReadLastLines(standaloneState.LogPath, lines)
if readErr == nil {
return &types.OperationsLogResponse{
Source: "standalone",
LogPath: standaloneState.LogPath,
Content: content,
}, nil
}
}
return &types.OperationsLogResponse{Source: "none", Content: ""}, nil
}
func (s *Service) ClearOperationalCaches(ctx context.Context) error {
_ = ctx
s.invalidateGraphCache()
return nil
}
func (s *Service) RebuildSite(ctx context.Context) error {
_ = ctx
projectDir, err := os.Getwd()
if err != nil {
return err
}
executable, err := hostservice.EnsureExecutable(projectDir)
if err != nil {
return err
}
cmd := exec.Command(executable, "build")
cmd.Dir = projectDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
return err
}
s.invalidateGraphCache()
return nil
}
func (s *Service) CheckForUpdates(ctx context.Context) (*types.UpdateStatusResponse, error) {
projectDir, err := os.Getwd()
if err != nil {
return nil, err
}
logx.Info("admin update check requested", "project_dir", projectDir)
info, err := updater.Check(ctx, projectDir)
if err != nil {
return nil, err
}
logx.Info("admin update check completed", "current_version", info.CurrentVersion, "latest_version", info.LatestVersion, "has_update", info.HasUpdate, "install_mode", info.InstallMode, "apply_supported", info.ApplySupported)
return &types.UpdateStatusResponse{
Repo: info.Repo,
CurrentVersion: info.CurrentVersion,
CurrentDisplayVersion: info.CurrentDisplayVersion,
LatestVersion: info.LatestVersion,
HasUpdate: info.HasUpdate,
InstallMode: string(info.InstallMode),
ApplySupported: info.ApplySupported,
ReleaseURL: info.ReleaseURL,
PublishedAt: info.PublishedAt.UTC().Format(time.RFC3339),
Body: info.Body,
AssetName: info.AssetName,
Instructions: info.Instructions,
NearestTag: info.NearestTag,
CurrentCommit: info.CurrentCommit,
Dirty: info.Dirty,
}, nil
}
func (s *Service) ApplyUpdate(ctx context.Context) (*types.UpdateStatusResponse, error) {
projectDir, err := os.Getwd()
if err != nil {
return nil, err
}
logx.Info("admin update apply requested", "project_dir", projectDir)
info, err := updater.ScheduleApply(ctx, projectDir)
if err != nil {
return nil, err
}
logx.Info("admin update apply scheduled", "current_version", info.CurrentVersion, "latest_version", info.LatestVersion, "install_mode", info.InstallMode, "asset_name", info.AssetName)
return &types.UpdateStatusResponse{
Repo: info.Repo,
CurrentVersion: info.CurrentVersion,
CurrentDisplayVersion: info.CurrentDisplayVersion,
LatestVersion: info.LatestVersion,
HasUpdate: info.HasUpdate,
InstallMode: string(info.InstallMode),
ApplySupported: info.ApplySupported,
ReleaseURL: info.ReleaseURL,
PublishedAt: info.PublishedAt.UTC().Format(time.RFC3339),
Body: info.Body,
AssetName: info.AssetName,
Instructions: info.Instructions,
NearestTag: info.NearestTag,
CurrentCommit: info.CurrentCommit,
Dirty: info.Dirty,
}, nil
}
package service
import (
"context"
"fmt"
"strings"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/plugins"
)
func (s *Service) ListPlugins(ctx context.Context) ([]types.PluginRecord, error) {
_ = ctx
status, err := s.GetSystemStatus(context.Background())
if err != nil {
return nil, err
}
installed, err := plugins.ListInstalled(s.cfg.PluginsDir)
if err != nil {
return nil, err
}
metaByName := make(map[string]plugins.Metadata, len(installed))
for _, meta := range installed {
metaByName[meta.Name] = meta
}
out := make([]types.PluginRecord, 0, len(status.Plugins))
for _, pluginStatus := range status.Plugins {
record := types.PluginRecord{
Name: pluginStatus.Name,
Title: pluginStatus.Title,
Version: pluginStatus.Version,
Enabled: pluginStatus.Enabled,
Status: pluginStatus.Status,
Health: pluginStatus.Status,
}
if meta, ok := metaByName[pluginStatus.Name]; ok {
record.Description = meta.Description
record.Author = meta.Author
record.Repo = meta.Repo
record.CompatibilityVersion = meta.CompatibilityVersion
record.MinFoundryVersion = meta.MinFoundryVersion
record.FoundryAPI = meta.FoundryAPI
record.Requires = append([]string(nil), meta.Requires...)
record.ConfigSchema = toFieldSchema(meta.ConfigSchema)
record.Dependencies = toPluginDependencies(meta.Dependencies)
record.Permissions = meta.Permissions
record.RiskTier = meta.Permissions.RiskTier()
record.RequiresApproval = meta.Permissions.Capabilities.RequiresAdminApproval
record.PermissionSummary = meta.Permissions.Summary()
record.Runtime = meta.Runtime
record.RuntimeSummary = meta.Runtime.Summary()
if hasRollback, _ := plugins.HasRollback(s.cfg.PluginsDir, meta.Name); hasRollback {
record.CanRollback = true
}
report := plugins.DiagnoseInstalled(s.cfg.PluginsDir, meta, pluginStatus.Enabled)
record.Health = report.Status
record.Diagnostics = toPluginDiagnostics(report.Diagnostics)
record.RiskTier = report.Security.RiskTier
record.RequiresApproval = report.Security.RequiresApproval
security := report.Security
record.Security = &security
record.SecurityFindings = append([]plugins.SecurityFinding(nil), report.Security.Findings...)
record.SecurityMismatches = toPluginDiagnostics(report.Security.Mismatches)
}
out = append(out, record)
}
return out, nil
}
func (s *Service) ValidatePlugin(ctx context.Context, name string) (*types.PluginRecord, error) {
_ = ctx
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("plugin name is required")
}
items, err := s.ListPlugins(context.Background())
if err != nil {
return nil, err
}
for _, item := range items {
if item.Name == name {
record := item
return &record, nil
}
}
return nil, fmt.Errorf("plugin not found: %s", name)
}
func (s *Service) EnablePlugin(ctx context.Context, name string, approveRisk, acknowledgeMismatches bool) error {
_ = ctx
meta, err := plugins.LoadMetadata(s.cfg.PluginsDir, name)
if err != nil {
return err
}
report := plugins.AnalyzeInstalled(meta)
if plugins.SecurityApprovalRequired(meta, report) && !approveRisk {
return fmt.Errorf("plugin %q requires explicit risk approval before enabling", name)
}
if len(report.Mismatches) > 0 && !acknowledgeMismatches {
return fmt.Errorf("plugin %q has %d security mismatch(es); acknowledge_mismatches is required", name, len(report.Mismatches))
}
if err := plugins.EnsureRuntimeSupported(meta); err != nil {
return err
}
if err := plugins.EnableInConfig(consts.ConfigFilePath, name); err != nil {
return err
}
if !containsString(s.cfg.Plugins.Enabled, name) {
s.cfg.Plugins.Enabled = append(s.cfg.Plugins.Enabled, name)
}
return nil
}
func (s *Service) DisablePlugin(ctx context.Context, name string) error {
_ = ctx
if err := plugins.DisableInConfig(consts.ConfigFilePath, name); err != nil {
return err
}
out := make([]string, 0, len(s.cfg.Plugins.Enabled))
for _, enabled := range s.cfg.Plugins.Enabled {
if enabled != name {
out = append(out, enabled)
}
}
s.cfg.Plugins.Enabled = out
return nil
}
func (s *Service) InstallPlugin(ctx context.Context, url, name string, approveRisk, acknowledgeMismatches bool) (*types.PluginRecord, error) {
_ = ctx
meta, err := plugins.Install(plugins.InstallOptions{
PluginsDir: s.cfg.PluginsDir,
URL: url,
Name: name,
ApproveRisk: approveRisk,
})
if err != nil {
return nil, err
}
report := plugins.DiagnoseInstalled(s.cfg.PluginsDir, meta, false)
if len(report.Security.Mismatches) > 0 && !acknowledgeMismatches {
_ = plugins.Uninstall(s.cfg.PluginsDir, meta.Name)
return nil, fmt.Errorf("plugin %q has %d security mismatch(es); acknowledge_mismatches is required", meta.Name, len(report.Security.Mismatches))
}
security := report.Security
return &types.PluginRecord{
Name: meta.Name,
Title: meta.Title,
Version: meta.Version,
Description: meta.Description,
Author: meta.Author,
Repo: meta.Repo,
Status: "installed",
Health: report.Status,
FoundryAPI: meta.FoundryAPI,
MinFoundryVersion: meta.MinFoundryVersion,
CompatibilityVersion: meta.CompatibilityVersion,
Requires: append([]string(nil), meta.Requires...),
Dependencies: toPluginDependencies(meta.Dependencies),
ConfigSchema: toFieldSchema(meta.ConfigSchema),
Permissions: meta.Permissions,
RiskTier: report.Security.RiskTier,
RequiresApproval: report.Security.RequiresApproval,
PermissionSummary: meta.Permissions.Summary(),
Runtime: meta.Runtime,
RuntimeSummary: meta.Runtime.Summary(),
Security: &security,
SecurityFindings: append([]plugins.SecurityFinding(nil), report.Security.Findings...),
SecurityMismatches: toPluginDiagnostics(report.Security.Mismatches),
}, nil
}
func (s *Service) UpdatePlugin(ctx context.Context, name string, approveRisk, acknowledgeMismatches bool) (*types.PluginRecord, error) {
_ = ctx
meta, err := plugins.UpdateInstalled(s.cfg.PluginsDir, name, approveRisk)
if err != nil {
return nil, err
}
report := plugins.DiagnoseInstalled(s.cfg.PluginsDir, meta, containsString(s.cfg.Plugins.Enabled, meta.Name))
if len(report.Security.Mismatches) > 0 && !acknowledgeMismatches {
return nil, fmt.Errorf("plugin %q has %d security mismatch(es); acknowledge_mismatches is required", meta.Name, len(report.Security.Mismatches))
}
security := report.Security
return &types.PluginRecord{
Name: meta.Name,
Title: meta.Title,
Version: meta.Version,
Description: meta.Description,
Author: meta.Author,
Repo: meta.Repo,
Enabled: containsString(s.cfg.Plugins.Enabled, meta.Name),
Status: "updated",
Health: report.Status,
CanRollback: true,
FoundryAPI: meta.FoundryAPI,
MinFoundryVersion: meta.MinFoundryVersion,
CompatibilityVersion: meta.CompatibilityVersion,
Requires: append([]string(nil), meta.Requires...),
Dependencies: toPluginDependencies(meta.Dependencies),
ConfigSchema: toFieldSchema(meta.ConfigSchema),
Permissions: meta.Permissions,
RiskTier: report.Security.RiskTier,
RequiresApproval: report.Security.RequiresApproval,
PermissionSummary: meta.Permissions.Summary(),
Runtime: meta.Runtime,
RuntimeSummary: meta.Runtime.Summary(),
Security: &security,
SecurityFindings: append([]plugins.SecurityFinding(nil), report.Security.Findings...),
SecurityMismatches: toPluginDiagnostics(report.Security.Mismatches),
Diagnostics: toPluginDiagnostics(report.Diagnostics),
}, nil
}
func (s *Service) RollbackPlugin(ctx context.Context, name string) (*types.PluginRecord, error) {
_ = ctx
meta, err := plugins.RollbackInstalled(s.cfg.PluginsDir, name)
if err != nil {
return nil, err
}
report := plugins.DiagnoseInstalled(s.cfg.PluginsDir, meta, containsString(s.cfg.Plugins.Enabled, meta.Name))
security := report.Security
return &types.PluginRecord{
Name: meta.Name,
Title: meta.Title,
Version: meta.Version,
Description: meta.Description,
Author: meta.Author,
Repo: meta.Repo,
Enabled: containsString(s.cfg.Plugins.Enabled, meta.Name),
Status: "rolled back",
Health: report.Status,
CanRollback: true,
FoundryAPI: meta.FoundryAPI,
MinFoundryVersion: meta.MinFoundryVersion,
CompatibilityVersion: meta.CompatibilityVersion,
Requires: append([]string(nil), meta.Requires...),
Dependencies: toPluginDependencies(meta.Dependencies),
ConfigSchema: toFieldSchema(meta.ConfigSchema),
Permissions: meta.Permissions,
RiskTier: report.Security.RiskTier,
RequiresApproval: report.Security.RequiresApproval,
PermissionSummary: meta.Permissions.Summary(),
Runtime: meta.Runtime,
RuntimeSummary: meta.Runtime.Summary(),
Security: &security,
SecurityFindings: append([]plugins.SecurityFinding(nil), report.Security.Findings...),
SecurityMismatches: toPluginDiagnostics(report.Security.Mismatches),
Diagnostics: toPluginDiagnostics(report.Diagnostics),
}, nil
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
package service
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/sphireinc/foundry/internal/admin/audit"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/lifecycle"
"github.com/sphireinc/foundry/internal/media"
"github.com/sphireinc/foundry/internal/ops"
"gopkg.in/yaml.v3"
)
var serviceProcessStartedAt = time.Now().UTC()
const runtimeAuditWindow = 24 * time.Hour
type runtimeSessionFile struct {
Sessions []struct {
TokenHash string `yaml:"token_hash"`
Token string `yaml:"token"`
Username string `yaml:"username"`
RemoteAddr string `yaml:"remote_addr"`
IssuedAt time.Time `yaml:"issued_at"`
LastSeen time.Time `yaml:"last_seen"`
ExpiresAt time.Time `yaml:"expires_at"`
} `yaml:"sessions"`
}
type runtimeSessionAnalysis struct {
ActiveSessions int
ConcurrentUsers int
AddressSpreadUsers int
LongLivedSessions int
IdleSessions int
}
func (s *Service) GetRuntimeStatus(ctx context.Context) (*types.RuntimeStatus, error) {
if err := requireCapability(ctx, "debug.read"); err != nil {
return nil, err
}
now := time.Now().UTC()
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
userCPU, systemCPU := processCPUTime()
status := &types.RuntimeStatus{
CapturedAt: now,
UptimeSeconds: int64(time.Since(serviceProcessStartedAt).Seconds()),
GoVersion: runtime.Version(),
NumCPU: runtime.NumCPU(),
LiveReloadMode: s.cfg.Server.LiveReloadMode,
HeapAllocBytes: mem.HeapAlloc,
HeapInuseBytes: mem.HeapInuse,
HeapObjects: mem.HeapObjects,
StackInuseBytes: mem.StackInuse,
SysBytes: mem.Sys,
NumGC: mem.NumGC,
NextGCBytes: mem.NextGC,
Goroutines: runtime.NumGoroutine(),
ProcessUserCPUMS: userCPU.Milliseconds(),
ProcessSystemCPUMS: systemCPU.Milliseconds(),
Content: types.RuntimeContentStatus{
ByType: map[string]int{},
ByLang: map[string]int{},
ByStatus: map[string]int{},
MediaCounts: map[string]int{},
},
Storage: types.RuntimeStorageStatus{
MediaBytes: map[string]int64{},
MediaCounts: map[string]int{},
LargestFiles: make([]types.RuntimeFileStat, 0),
},
Integrity: types.RuntimeIntegrityStatus{},
Activity: types.RuntimeActivityStatus{
RecentAuditByAction: map[string]int{},
AuditWindowHours: int(runtimeAuditWindow / time.Hour),
},
}
if mem.LastGC > 0 {
lastGC := time.Unix(0, int64(mem.LastGC)).UTC()
status.LastGCAt = &lastGC
}
s.populateRuntimeGraphMetrics(ctx, status)
s.populateRuntimeStorageMetrics(status)
s.populateRuntimeActivityMetrics(status, now)
s.populateRuntimeBuildStatus(status)
return status, nil
}
func (s *Service) ValidateSite(ctx context.Context) (*types.SiteValidationResponse, error) {
graph, err := s.load(ctx, true)
if err != nil {
return nil, err
}
report := ops.AnalyzeSite(s.cfg, graph)
return &types.SiteValidationResponse{
BrokenMediaRefs: append([]string(nil), report.BrokenMediaRefs...),
BrokenInternalLinks: append([]string(nil), report.BrokenInternalLinks...),
MissingTemplates: append([]string(nil), report.MissingTemplates...),
OrphanedMedia: append([]string(nil), report.OrphanedMedia...),
DuplicateURLs: append([]string(nil), report.DuplicateURLs...),
DuplicateSlugs: append([]string(nil), report.DuplicateSlugs...),
TaxonomyInconsistency: append([]string(nil), report.TaxonomyInconsistency...),
MessageCount: len(report.BrokenMediaRefs) +
len(report.BrokenInternalLinks) +
len(report.MissingTemplates) +
len(report.OrphanedMedia) +
len(report.DuplicateURLs) +
len(report.DuplicateSlugs) +
len(report.TaxonomyInconsistency),
}, nil
}
func (s *Service) populateRuntimeGraphMetrics(ctx context.Context, status *types.RuntimeStatus) {
if s == nil || status == nil {
return
}
graph, err := s.load(ctx, true)
if err != nil || graph == nil {
return
}
status.Content.DocumentCount = len(graph.Documents)
status.Content.RouteCount = len(graph.ByURL)
status.Content.TaxonomyCount = len(graph.Taxonomies.Values)
for _, doc := range graph.Documents {
if doc == nil {
continue
}
status.Content.ByType[doc.Type]++
status.Content.ByLang[doc.Lang]++
docStatus := strings.TrimSpace(doc.Status)
if docStatus == "" {
docStatus = "unknown"
}
status.Content.ByStatus[docStatus]++
}
for _, terms := range graph.Taxonomies.Values {
status.Content.TaxonomyTermCount += len(terms)
}
report := ops.AnalyzeSite(s.cfg, graph)
status.Integrity = types.RuntimeIntegrityStatus{
BrokenMediaRefs: len(report.BrokenMediaRefs),
BrokenInternalLinks: len(report.BrokenInternalLinks),
MissingTemplates: len(report.MissingTemplates),
OrphanedMedia: len(report.OrphanedMedia),
DuplicateURLs: len(report.DuplicateURLs),
DuplicateSlugs: len(report.DuplicateSlugs),
TaxonomyInconsistency: len(report.TaxonomyInconsistency),
}
}
func (s *Service) populateRuntimeStorageMetrics(status *types.RuntimeStatus) {
if s == nil || s.cfg == nil || status == nil {
return
}
largest := make([]types.RuntimeFileStat, 0, 5)
status.Storage.ContentBytes = walkAndSummarizeDir(s.cfg.ContentDir, &largest, func(rel string, size int64) {
if rel == "" {
return
}
base := filepath.Base(rel)
if lifecycle.IsDerivedPath(base) {
status.Storage.DerivedBytes += size
if strings.Contains(base, ".version.") {
status.Storage.DerivedVersionCount++
}
if strings.Contains(base, ".trash.") {
status.Storage.DerivedTrashCount++
}
}
})
status.Storage.PublicBytes = walkAndSummarizeDir(s.cfg.PublicDir, &largest, nil)
for _, collection := range media.SupportedCollections {
root, ok := runtimeMediaRoot(s.cfg, collection)
if !ok {
continue
}
walkAndSummarizeDir(root, nil, func(rel string, size int64) {
if rel == "" {
return
}
name := filepath.Base(rel)
if strings.HasSuffix(name, ".meta.yaml") || lifecycle.IsDerivedPath(name) {
return
}
status.Storage.MediaCounts[collection]++
status.Storage.MediaBytes[collection] += size
status.Content.MediaCounts[collection]++
})
}
sort.Slice(largest, func(i, j int) bool {
if largest[i].SizeBytes == largest[j].SizeBytes {
return largest[i].Path < largest[j].Path
}
return largest[i].SizeBytes > largest[j].SizeBytes
})
if len(largest) > 5 {
largest = largest[:5]
}
status.Storage.LargestFiles = largest
}
func (s *Service) populateRuntimeActivityMetrics(status *types.RuntimeStatus, now time.Time) {
if s == nil || s.cfg == nil || status == nil {
return
}
sessionAnalysis := analyzeActiveSessions(s.cfg.Admin.SessionStoreFile, now)
status.Activity.ActiveSessions = sessionAnalysis.ActiveSessions
status.Activity.ConcurrentUsers = sessionAnalysis.ConcurrentUsers
status.Activity.AddressSpreadUsers = sessionAnalysis.AddressSpreadUsers
status.Activity.LongLivedSessions = sessionAnalysis.LongLivedSessions
status.Activity.IdleSessions = sessionAnalysis.IdleSessions
s.lockMu.Lock()
locks, err := s.loadLocksLocked(now)
s.lockMu.Unlock()
if err == nil {
status.Activity.ActiveDocumentLocks = len(locks)
}
items, err := audit.List(s.cfg, 200)
if err != nil {
return
}
windowStart := now.Add(-runtimeAuditWindow)
for _, entry := range items {
if entry.Timestamp.IsZero() || entry.Timestamp.Before(windowStart) {
continue
}
status.Activity.RecentAuditEvents++
action := strings.TrimSpace(entry.Action)
if action == "" {
action = "unknown"
}
status.Activity.RecentAuditByAction[action]++
if strings.EqualFold(action, "login") && strings.EqualFold(strings.TrimSpace(entry.Outcome), "fail") {
status.Activity.RecentFailedLogins++
}
}
}
func (s *Service) populateRuntimeBuildStatus(status *types.RuntimeStatus) {
if s == nil || s.cfg == nil || status == nil {
return
}
path := filepath.Join(s.cfg.DataDir, "admin", "build-report.json")
body, err := os.ReadFile(path)
if err != nil {
return
}
var report ops.BuildReport
if err := json.Unmarshal(body, &report); err != nil {
return
}
status.LastBuild = &types.RuntimeBuildStatus{
GeneratedAt: report.GeneratedAt,
Environment: report.Environment,
Target: report.Target,
Preview: report.Preview,
DocumentCount: report.DocumentCount,
RouteCount: report.RouteCount,
PrepareMS: report.Stats.Prepare.Milliseconds(),
AssetsMS: report.Stats.Assets.Milliseconds(),
DocumentsMS: report.Stats.Documents.Milliseconds(),
TaxonomiesMS: report.Stats.Taxonomies.Milliseconds(),
SearchMS: report.Stats.Search.Milliseconds(),
}
}
func countActiveSessions(path string, now time.Time) int {
return analyzeActiveSessions(path, now).ActiveSessions
}
func analyzeActiveSessions(path string, now time.Time) runtimeSessionAnalysis {
if strings.TrimSpace(path) == "" {
return runtimeSessionAnalysis{}
}
body, err := os.ReadFile(path)
if err != nil {
return runtimeSessionAnalysis{}
}
var file runtimeSessionFile
if err := yaml.Unmarshal(body, &file); err != nil {
return runtimeSessionAnalysis{}
}
result := runtimeSessionAnalysis{}
usernameCounts := map[string]int{}
usernameAddresses := map[string]map[string]struct{}{}
for _, session := range file.Sessions {
if strings.TrimSpace(session.TokenHash) == "" && strings.TrimSpace(session.Token) == "" {
continue
}
if now.After(session.ExpiresAt) {
continue
}
result.ActiveSessions++
if !session.IssuedAt.IsZero() && now.Sub(session.IssuedAt) >= 12*time.Hour {
result.LongLivedSessions++
}
lastSeen := session.LastSeen
if lastSeen.IsZero() {
lastSeen = session.IssuedAt
}
if !lastSeen.IsZero() && now.Sub(lastSeen) >= 30*time.Minute {
result.IdleSessions++
}
username := strings.ToLower(strings.TrimSpace(session.Username))
if username == "" {
continue
}
usernameCounts[username]++
address := strings.TrimSpace(session.RemoteAddr)
if address == "" {
continue
}
if _, ok := usernameAddresses[username]; !ok {
usernameAddresses[username] = map[string]struct{}{}
}
usernameAddresses[username][address] = struct{}{}
}
for _, count := range usernameCounts {
if count > 1 {
result.ConcurrentUsers++
}
}
for _, addresses := range usernameAddresses {
if len(addresses) > 1 {
result.AddressSpreadUsers++
}
}
return result
}
func walkAndSummarizeDir(root string, largest *[]types.RuntimeFileStat, onFile func(rel string, size int64)) int64 {
root = strings.TrimSpace(root)
if root == "" {
return 0
}
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return 0
}
var total int64
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil || d == nil {
return nil
}
if d.Type()&os.ModeSymlink != 0 {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
size := info.Size()
total += size
rel, relErr := filepath.Rel(".", path)
if relErr != nil {
rel = path
}
rel = filepath.ToSlash(rel)
if onFile != nil {
onFile(rel, size)
}
if largest != nil {
*largest = append(*largest, types.RuntimeFileStat{
Path: rel,
SizeBytes: size,
})
}
return nil
})
return total
}
func runtimeMediaRoot(cfg *config.Config, collection string) (string, bool) {
if cfg == nil {
return "", false
}
switch strings.TrimSpace(collection) {
case "images":
return filepath.Join(cfg.ContentDir, cfg.Content.ImagesDir), true
case "videos":
return filepath.Join(cfg.ContentDir, cfg.Content.VideoDir), true
case "audio":
return filepath.Join(cfg.ContentDir, cfg.Content.AudioDir), true
case "documents":
return filepath.Join(cfg.ContentDir, cfg.Content.DocumentsDir), true
case "uploads":
return filepath.Join(cfg.ContentDir, cfg.Content.UploadsDir), true
case "assets":
return filepath.Join(cfg.ContentDir, cfg.Content.AssetsDir), true
default:
return "", false
}
}
package service
import (
"context"
"os"
"sync"
"time"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/site"
)
// FileSystem abstracts filesystem access for admin service operations so tests
// can substitute a controlled implementation.
type FileSystem interface {
ReadFile(name string) ([]byte, error)
WriteFile(name string, data []byte, perm os.FileMode) error
Stat(name string) (os.FileInfo, error)
ReadDir(name string) ([]os.DirEntry, error)
MkdirAll(path string, perm os.FileMode) error
Rename(oldpath, newpath string) error
Remove(name string) error
}
type osFS struct{}
func (osFS) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
func (osFS) WriteFile(name string, data []byte, perm os.FileMode) error {
return os.WriteFile(name, data, perm)
}
func (osFS) Stat(name string) (os.FileInfo, error) { return os.Stat(name) }
func (osFS) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
func (osFS) MkdirAll(path string, perm os.FileMode) error { return os.MkdirAll(path, perm) }
func (osFS) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) }
func (osFS) Remove(name string) error { return os.Remove(name) }
// GraphLoader loads a site graph for admin read operations.
//
// The boolean controls whether draft content should be included.
type GraphLoader func(context.Context, *config.Config, bool) (*content.SiteGraph, error)
// StatusProvider contributes one cohesive section of data to the admin status
// dashboard.
type StatusProvider interface {
Name() string
Provide(context.Context, *Service, *types.SystemStatus) error
}
// Service is the main business-logic layer behind the admin API.
//
// It owns filesystem access, graph loading, plugin metadata access, and a
// short-lived graph cache used by multiple admin endpoints.
type Service struct {
cfg *config.Config
fs FileSystem
loadGraph GraphLoader
pluginMetadata func() map[string]plugins.Metadata
mu sync.RWMutex
lockMu sync.Mutex
statusProviders map[string]StatusProvider
graphCache map[bool]cachedGraph
}
type cachedGraph struct {
graph *content.SiteGraph
loadedAt time.Time
}
const graphCacheTTL = time.Second
// Option customizes Service construction for tests and embedding.
type Option func(*Service)
// WithFS overrides the filesystem implementation used by the service.
func WithFS(fs FileSystem) Option {
return func(s *Service) {
if fs != nil {
s.fs = fs
}
}
}
// WithGraphLoader overrides the graph loader used by the service.
func WithGraphLoader(loader GraphLoader) Option {
return func(s *Service) {
if loader != nil {
s.loadGraph = loader
}
}
}
// WithPluginMetadata overrides plugin metadata lookup used by admin extension
// and plugin-management views.
func WithPluginMetadata(loader func() map[string]plugins.Metadata) Option {
return func(s *Service) {
if loader != nil {
s.pluginMetadata = loader
}
}
}
// New constructs the admin service with default providers and a default graph
// loader based on the site package.
func New(cfg *config.Config, opts ...Option) *Service {
s := &Service{
cfg: cfg,
fs: osFS{},
statusProviders: make(map[string]StatusProvider),
graphCache: make(map[bool]cachedGraph),
loadGraph: func(ctx context.Context, cfg *config.Config, includeDrafts bool) (*content.SiteGraph, error) {
graph, _, err := site.LoadConfiguredGraph(ctx, cfg, includeDrafts)
return graph, err
},
pluginMetadata: func() map[string]plugins.Metadata {
return map[string]plugins.Metadata{}
},
}
for _, opt := range opts {
opt(s)
}
s.RegisterStatusProvider(configStatusProvider{})
s.RegisterStatusProvider(authSecurityStatusProvider{})
s.RegisterStatusProvider(contentStatusProvider{})
s.RegisterStatusProvider(themeStatusProvider{})
s.RegisterStatusProvider(pluginStatusProvider{})
s.RegisterStatusProvider(taxonomyStatusProvider{})
return s
}
// Config returns the service's active site configuration.
func (s *Service) Config() *config.Config {
return s.cfg
}
// RegisterStatusProvider adds or replaces a named dashboard status provider.
func (s *Service) RegisterStatusProvider(provider StatusProvider) {
if provider == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.statusProviders[provider.Name()] = provider
}
// load returns a cached site graph when possible and reloads it when the cache
// has expired.
func (s *Service) load(ctx context.Context, includeDrafts bool) (*content.SiteGraph, error) {
s.mu.RLock()
cached, ok := s.graphCache[includeDrafts]
s.mu.RUnlock()
if ok && cached.graph != nil && time.Since(cached.loadedAt) < graphCacheTTL {
return cached.graph, nil
}
graph, err := s.loadGraph(ctx, s.cfg, includeDrafts)
if err != nil {
return nil, err
}
s.mu.Lock()
s.graphCache[includeDrafts] = cachedGraph{
graph: graph,
loadedAt: time.Now(),
}
s.mu.Unlock()
return graph, nil
}
// providers returns the registered status providers in unspecified order.
func (s *Service) providers() []StatusProvider {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]StatusProvider, 0, len(s.statusProviders))
for _, p := range s.statusProviders {
out = append(out, p)
}
return out
}
// invalidateGraphCache clears all cached graph variants after content-affecting
// admin operations.
func (s *Service) invalidateGraphCache() {
s.mu.Lock()
s.graphCache = make(map[bool]cachedGraph)
s.mu.Unlock()
}
package service
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/customfields"
"github.com/sphireinc/foundry/internal/plugins"
"gopkg.in/yaml.v3"
)
func (s *Service) ListSettingsSections(ctx context.Context) ([]types.SettingsSection, error) {
_ = ctx
sections := []types.SettingsSection{
{Key: "site", Title: "Site", Capability: "config.manage", Writable: true, Source: "core"},
{Key: "server", Title: "Server", Capability: "config.manage", Writable: true, Source: "core"},
{Key: "build", Title: "Build", Capability: "config.manage", Writable: true, Source: "core"},
{Key: "content", Title: "Content", Capability: "config.manage", Writable: true, Source: "core"},
{Key: "taxonomies", Title: "Taxonomies", Capability: "config.manage", Writable: true, Source: "core"},
{Key: "plugins", Title: "Plugins", Capability: "plugins.manage", Writable: true, Source: "core"},
{Key: "seo", Title: "SEO", Capability: "config.manage", Writable: true, Source: "core"},
{Key: "feed", Title: "Feed", Capability: "config.manage", Writable: true, Source: "core"},
{Key: "deploy", Title: "Deploy", Capability: "config.manage", Writable: true, Source: "core"},
{Key: "params", Title: "Params", Capability: "config.manage", Writable: true, Source: "core"},
{Key: "menus", Title: "Menus", Capability: "config.manage", Writable: true, Source: "core"},
}
for pluginName, meta := range s.pluginMetadata() {
for _, section := range meta.AdminExtensions.SettingsSections {
sections = append(sections, types.SettingsSection{
Key: section.Key,
Title: section.Title,
Capability: firstNonEmptyString(section.Capability, "config.manage"),
Description: section.Description,
Writable: true,
Source: pluginName,
Schema: toFieldSchema(section.Schema),
})
}
}
return sections, nil
}
func (s *Service) ListAdminExtensions(ctx context.Context) (*types.AdminExtensionRegistry, error) {
_ = ctx
registry := &types.AdminExtensionRegistry{}
for pluginName, meta := range s.pluginMetadata() {
for _, page := range meta.AdminExtensions.Pages {
moduleURL, styleURLs := s.adminExtensionAssetURLs(pluginName, page.Module, page.Styles)
registry.Pages = append(registry.Pages, types.AdminExtensionPage{
Plugin: pluginName,
Key: page.Key,
Title: page.Title,
Route: page.Route,
NavGroup: page.NavGroup,
Capability: page.Capability,
Description: page.Description,
ModuleURL: moduleURL,
StyleURLs: styleURLs,
})
}
for _, widget := range meta.AdminExtensions.Widgets {
moduleURL, styleURLs := s.adminExtensionAssetURLs(pluginName, widget.Module, widget.Styles)
registry.Widgets = append(registry.Widgets, types.AdminExtensionWidget{
Plugin: pluginName,
Key: widget.Key,
Title: widget.Title,
Slot: widget.Slot,
Capability: widget.Capability,
Description: widget.Description,
ModuleURL: moduleURL,
StyleURLs: styleURLs,
})
}
for _, slot := range meta.AdminExtensions.Slots {
registry.Slots = append(registry.Slots, types.AdminExtensionSlot{
Plugin: pluginName,
Name: slot.Name,
Description: slot.Description,
})
}
for _, setting := range meta.AdminExtensions.SettingsSections {
registry.Settings = append(registry.Settings, types.AdminExtensionSetting{
Plugin: pluginName,
Key: setting.Key,
Title: setting.Title,
Capability: setting.Capability,
Description: setting.Description,
Schema: toFieldSchema(setting.Schema),
})
}
}
return registry, nil
}
func (s *Service) AllowsAdminAsset(pluginName, assetPath string) bool {
pluginName = strings.TrimSpace(pluginName)
assetPath = strings.TrimSpace(assetPath)
if pluginName == "" || assetPath == "" {
return false
}
meta, ok := s.pluginMetadata()[pluginName]
if !ok {
return false
}
allowed := make(map[string]struct{})
for _, page := range meta.AdminExtensions.Pages {
if clean, err := plugins.NormalizeAdminAssetPath(page.Module); err == nil && clean != "" {
allowed[clean] = struct{}{}
}
for _, style := range page.Styles {
if clean, err := plugins.NormalizeAdminAssetPath(style); err == nil && clean != "" {
allowed[clean] = struct{}{}
}
}
}
for _, widget := range meta.AdminExtensions.Widgets {
if clean, err := plugins.NormalizeAdminAssetPath(widget.Module); err == nil && clean != "" {
allowed[clean] = struct{}{}
}
for _, style := range widget.Styles {
if clean, err := plugins.NormalizeAdminAssetPath(style); err == nil && clean != "" {
allowed[clean] = struct{}{}
}
}
}
_, ok = allowed[assetPath]
return ok
}
func (s *Service) LoadConfigDocument(ctx context.Context) (*types.ConfigDocumentResponse, error) {
_ = ctx
path := consts.ConfigFilePath
b, err := s.fs.ReadFile(path)
if err != nil {
return nil, err
}
return &types.ConfigDocumentResponse{Path: path, Raw: string(b)}, nil
}
func (s *Service) SaveConfigDocument(ctx context.Context, raw string) (*types.ConfigDocumentResponse, error) {
_ = ctx
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("config body is required")
}
tmp := &config.Config{}
if err := config.UnmarshalYAML([]byte(raw), tmp); err != nil {
return nil, err
}
if err := s.fs.MkdirAll(filepath.Dir(consts.ConfigFilePath), 0o755); err != nil {
return nil, err
}
if err := s.fs.WriteFile(consts.ConfigFilePath, []byte(raw+"\n"), 0o644); err != nil {
return nil, err
}
return &types.ConfigDocumentResponse{Path: consts.ConfigFilePath, Raw: raw + "\n"}, nil
}
func (s *Service) LoadCustomCSSDocument(ctx context.Context) (*types.CustomCSSDocumentResponse, error) {
_ = ctx
path := filepath.Join(s.cfg.ContentDir, s.cfg.Content.AssetsDir, "css", "custom.css")
b, err := s.fs.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &types.CustomCSSDocumentResponse{Path: path, Raw: ""}, nil
}
return nil, err
}
return &types.CustomCSSDocumentResponse{Path: path, Raw: string(b)}, nil
}
func (s *Service) SaveCustomCSSDocument(ctx context.Context, raw string) (*types.CustomCSSDocumentResponse, error) {
_ = ctx
path := filepath.Join(s.cfg.ContentDir, s.cfg.Content.AssetsDir, "css", "custom.css")
if err := s.fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
normalized := strings.ReplaceAll(raw, "\r\n", "\n")
if normalized != "" && !strings.HasSuffix(normalized, "\n") {
normalized += "\n"
}
if err := s.fs.WriteFile(path, []byte(normalized), 0o644); err != nil {
return nil, err
}
return &types.CustomCSSDocumentResponse{Path: path, Raw: normalized}, nil
}
func (s *Service) LoadCustomFieldsDocument(ctx context.Context) (*types.CustomFieldsDocumentResponse, error) {
_ = ctx
store, err := customfields.Load(s.cfg)
if err != nil {
return nil, err
}
path := customfields.Path(s.cfg)
raw, err := s.fs.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
return &types.CustomFieldsDocumentResponse{
Path: displayCustomFieldsPath(path),
Raw: string(raw),
Values: customfields.NormalizeValues(store.Values),
Contracts: sharedFieldContractsForManifest(s.activeThemeManifest()),
}, nil
}
func (s *Service) SaveCustomFieldsDocument(ctx context.Context, raw string, values map[string]any) (*types.CustomFieldsDocumentResponse, error) {
_ = ctx
path := customfields.Path(s.cfg)
var store customfields.Store
if strings.TrimSpace(raw) != "" {
if err := yaml.Unmarshal([]byte(raw), &store); err != nil {
return nil, err
}
} else {
store.Values = customfields.NormalizeValues(values)
}
store.Values = customfields.NormalizeValues(store.Values)
if err := customfields.ValidateRoot(store.Values); err != nil {
return nil, err
}
body, err := yaml.Marshal(&store)
if err != nil {
return nil, err
}
if err := s.fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
if err := s.fs.WriteFile(path, body, 0o644); err != nil {
return nil, err
}
s.invalidateGraphCache()
return &types.CustomFieldsDocumentResponse{
Path: displayCustomFieldsPath(path),
Raw: string(body),
Values: customfields.NormalizeValues(store.Values),
Contracts: sharedFieldContractsForManifest(s.activeThemeManifest()),
}, nil
}
func (s *Service) LoadSettingsForm(ctx context.Context) (*types.SettingsFormResponse, error) {
_ = ctx
body, err := s.fs.ReadFile(consts.ConfigFilePath)
if err != nil {
return nil, err
}
var cfg config.Config
if err := config.UnmarshalYAML(body, &cfg); err != nil {
return nil, err
}
return &types.SettingsFormResponse{Path: consts.ConfigFilePath, Value: cfg}, nil
}
func (s *Service) SaveSettingsForm(ctx context.Context, value config.Config) (*types.SettingsFormResponse, error) {
_ = ctx
value.MarkAdminLocalOnlyExplicit()
value.ApplyDefaults()
if errs := config.Validate(&value); len(errs) > 0 {
return nil, errs[0]
}
body, err := yaml.Marshal(&value)
if err != nil {
return nil, err
}
if err := s.fs.MkdirAll(filepath.Dir(consts.ConfigFilePath), 0o755); err != nil {
return nil, err
}
if err := s.fs.WriteFile(consts.ConfigFilePath, body, 0o644); err != nil {
return nil, err
}
s.cfg = &value
s.invalidateGraphCache()
return &types.SettingsFormResponse{Path: consts.ConfigFilePath, Value: value}, nil
}
func (s *Service) adminExtensionAssetURLs(pluginName, module string, styles []string) (string, []string) {
var moduleURL string
if clean, err := plugins.NormalizeAdminAssetPath(module); err == nil && clean != "" {
moduleURL = s.cfg.AdminPath() + "/extensions/" + pluginName + "/" + clean
}
styleURLs := make([]string, 0, len(styles))
for _, style := range styles {
clean, err := plugins.NormalizeAdminAssetPath(style)
if err != nil || clean == "" {
continue
}
styleURLs = append(styleURLs, s.cfg.AdminPath()+"/extensions/"+pluginName+"/"+clean)
}
return moduleURL, styleURLs
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
package service
import (
"context"
"errors"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/admin/users"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/theme"
)
func (s *Service) GetSystemStatus(ctx context.Context) (*types.SystemStatus, error) {
status := &types.SystemStatus{
Name: s.cfg.Name,
Title: s.cfg.Title,
BaseURL: s.cfg.BaseURL,
DefaultLang: s.cfg.DefaultLang,
PublicDir: s.cfg.PublicDir,
ContentDir: s.cfg.ContentDir,
DataDir: s.cfg.DataDir,
ThemesDir: s.cfg.ThemesDir,
PluginsDir: s.cfg.PluginsDir,
AdminEnabled: s.cfg.Admin.Enabled,
AdminLocalOnly: s.cfg.Admin.LocalOnly,
Content: types.ContentStatus{
ByType: make(map[string]int),
ByLang: make(map[string]int),
},
Plugins: make([]types.PluginStatus, 0),
Taxonomies: make([]types.TaxonomyStatus, 0),
Checks: make([]types.HealthCheck, 0),
}
for _, provider := range s.providers() {
if err := provider.Provide(ctx, s, status); err != nil {
status.Checks = append(status.Checks, types.HealthCheck{
Name: provider.Name(),
Status: "fail",
Message: err.Error(),
})
} else {
status.Checks = append(status.Checks, types.HealthCheck{
Name: provider.Name(),
Status: "ok",
})
}
}
sort.Slice(status.Plugins, func(i, j int) bool {
return status.Plugins[i].Name < status.Plugins[j].Name
})
sort.Slice(status.Taxonomies, func(i, j int) bool {
return status.Taxonomies[i].Name < status.Taxonomies[j].Name
})
sort.Slice(status.Checks, func(i, j int) bool {
return status.Checks[i].Name < status.Checks[j].Name
})
return status, nil
}
type configStatusProvider struct{}
func (configStatusProvider) Name() string { return "config" }
func (configStatusProvider) Provide(_ context.Context, _ *Service, _ *types.SystemStatus) error {
return nil
}
type authSecurityStatusProvider struct{}
func (authSecurityStatusProvider) Name() string { return "auth-security" }
func (authSecurityStatusProvider) Provide(_ context.Context, s *Service, _ *types.SystemStatus) error {
if s == nil || s.cfg == nil || !s.cfg.Admin.Enabled {
return nil
}
issues := make([]string, 0, 3)
if !s.cfg.Admin.LocalOnly && strings.TrimSpace(s.cfg.Admin.SessionSecret) == "" {
issues = append(issues, "admin.session_secret is not set for a non-local admin deployment")
}
baseURL := strings.ToLower(strings.TrimSpace(s.cfg.BaseURL))
if !s.cfg.Admin.LocalOnly && (baseURL == "" || !strings.HasPrefix(baseURL, "https://")) {
issues = append(issues, "admin is exposed non-locally without an https base_url; secure cookies and proxy/TLS behavior should be reviewed")
}
all, err := users.Load(s.cfg.Admin.UsersFile)
if err == nil {
if hasPlaintextTOTPSecrets(all) {
issues = append(issues, "one or more admin users still store plaintext TOTP secrets")
}
if usersNeedTOTPKey(all) && strings.TrimSpace(s.cfg.Admin.TOTPSecretKey) == "" {
issues = append(issues, "admin.totp_secret_key is not set while TOTP secrets or TOTP-enabled users exist")
}
}
if len(issues) == 0 {
return nil
}
return errors.New(strings.Join(issues, "; "))
}
func hasPlaintextTOTPSecrets(entries []users.User) bool {
for _, user := range entries {
secret := strings.TrimSpace(user.TOTPSecret)
if secret != "" && !strings.HasPrefix(secret, "enc:v1:") {
return true
}
}
return false
}
func usersNeedTOTPKey(entries []users.User) bool {
for _, user := range entries {
if user.TOTPEnabled || strings.TrimSpace(user.TOTPSecret) != "" {
return true
}
}
return false
}
type contentStatusProvider struct{}
func (contentStatusProvider) Name() string { return "content" }
func (contentStatusProvider) Provide(ctx context.Context, s *Service, status *types.SystemStatus) error {
graph, err := s.load(ctx, true)
if err != nil {
return err
}
status.Content.DocumentCount = len(graph.Documents)
status.Content.RouteCount = len(graph.ByURL)
for _, docs := range graph.ByType {
for _, doc := range docs {
status.Content.ByType[doc.Type]++
status.Content.ByLang[doc.Lang]++
if doc.Draft {
status.Content.DraftCount++
}
}
break
}
for typ, docs := range graph.ByType {
status.Content.ByType[typ] = len(docs)
}
for lang, docs := range graph.ByLang {
status.Content.ByLang[lang] = len(docs)
}
return nil
}
type themeStatusProvider struct{}
func (themeStatusProvider) Name() string { return "theme" }
func (themeStatusProvider) Provide(_ context.Context, s *Service, status *types.SystemStatus) error {
status.Theme.Current = s.cfg.Theme
status.Theme.Valid = false
if err := theme.ValidateInstalled(s.cfg.ThemesDir, s.cfg.Theme); err != nil {
return err
}
manifest, err := theme.LoadManifest(s.cfg.ThemesDir, s.cfg.Theme)
if err != nil {
return err
}
status.Theme.Valid = true
status.Theme.Title = manifest.Title
status.Theme.Version = manifest.Version
status.Theme.Description = manifest.Description
return nil
}
type pluginStatusProvider struct{}
func (pluginStatusProvider) Name() string { return "plugins" }
func (pluginStatusProvider) Provide(_ context.Context, s *Service, status *types.SystemStatus) error {
installed, err := plugins.ListInstalled(s.cfg.PluginsDir)
if err != nil {
return err
}
enabledSet := make(map[string]struct{}, len(s.cfg.Plugins.Enabled))
for _, name := range s.cfg.Plugins.Enabled {
enabledSet[name] = struct{}{}
}
statuses := plugins.EnabledPluginStatus(s.cfg.PluginsDir, s.cfg.Plugins.Enabled)
added := make(map[string]struct{})
for _, meta := range installed {
_, enabled := enabledSet[meta.Name]
pstatus := statuses[meta.Name]
if pstatus == "" {
if enabled {
pstatus = "enabled"
} else {
pstatus = "installed"
}
}
status.Plugins = append(status.Plugins, types.PluginStatus{
Name: meta.Name,
Title: meta.Title,
Version: meta.Version,
Enabled: enabled,
Status: pstatus,
})
added[meta.Name] = struct{}{}
}
for _, name := range s.cfg.Plugins.Enabled {
if _, ok := added[name]; ok {
continue
}
status.Plugins = append(status.Plugins, types.PluginStatus{
Name: name,
Title: "-",
Version: "-",
Enabled: true,
Status: statuses[name],
})
}
return nil
}
type taxonomyStatusProvider struct{}
func (taxonomyStatusProvider) Name() string { return "taxonomies" }
func (taxonomyStatusProvider) Provide(ctx context.Context, s *Service, status *types.SystemStatus) error {
graph, err := s.load(ctx, true)
if err != nil {
return err
}
for name, terms := range graph.Taxonomies.Values {
status.Taxonomies = append(status.Taxonomies, types.TaxonomyStatus{
Name: name,
TermCount: len(terms),
})
}
return nil
}
package service
import (
"context"
"fmt"
"strings"
"github.com/sphireinc/foundry/internal/admin/types"
adminui "github.com/sphireinc/foundry/internal/admin/ui"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/theme"
)
func (s *Service) ListThemes(ctx context.Context) ([]types.ThemeRecord, error) {
_ = ctx
items, err := theme.ListInstalled(s.cfg.ThemesDir)
if err != nil {
return nil, err
}
out := make([]types.ThemeRecord, 0, len(items))
for _, item := range items {
if item.Name == "admin-themes" {
continue
}
record := types.ThemeRecord{Name: item.Name, Kind: "frontend", Current: item.Name == s.cfg.Theme}
if manifest, err := theme.LoadManifest(s.cfg.ThemesDir, item.Name); err == nil {
record.Title = manifest.Title
record.Version = manifest.Version
record.Description = manifest.Description
record.Repo = manifest.Repo
record.SDKVersion = manifest.SDKVersion
record.CompatibilityVersion = manifest.CompatibilityVersion
record.MinFoundryVersion = manifest.MinFoundryVersion
record.SupportedLayouts = manifest.RequiredLayouts()
record.Screenshots = append([]string(nil), manifest.Screenshots...)
record.ConfigSchema = toFieldSchema(manifest.ConfigSchema)
record.Security = manifest.Security
record.SecuritySummary = manifest.Security.Summary()
if securityReport, secErr := theme.AnalyzeInstalledSecurity(s.cfg.ThemesDir, item.Name); secErr == nil {
record.SecurityReport = securityReport
}
}
if validation, err := theme.ValidateInstalledDetailed(s.cfg.ThemesDir, item.Name); err == nil {
record.Valid = validation.Valid
record.Diagnostics = toValidationDiagnostics(validation.Diagnostics)
}
out = append(out, record)
}
adminThemes, err := adminui.ListInstalled(s.cfg.ThemesDir)
if err != nil {
return nil, err
}
for _, item := range adminThemes {
record := types.ThemeRecord{Name: item.Name, Kind: "admin", Current: item.Name == s.cfg.Admin.Theme}
if manifest, err := adminui.LoadManifest(s.cfg.ThemesDir, item.Name); err == nil {
record.Title = manifest.Title
record.Version = manifest.Version
record.Description = manifest.Description
record.Repo = manifest.Repo
record.AdminAPI = manifest.AdminAPI
record.SDKVersion = manifest.SDKVersion
record.CompatibilityVersion = manifest.CompatibilityVersion
record.Components = append([]string(nil), manifest.Components...)
record.WidgetSlots = append([]string(nil), manifest.WidgetSlots...)
record.Screenshots = append([]string(nil), manifest.Screenshots...)
}
if validation, err := adminui.ValidateTheme(s.cfg.ThemesDir, item.Name); err == nil {
record.Valid = validation.Valid
record.Diagnostics = append(record.Diagnostics, toAdminThemeDiagnostics(validation.Diagnostics)...)
}
out = append(out, record)
}
return out, nil
}
func (s *Service) InstallTheme(ctx context.Context, url, name, kind string) (*types.ThemeRecord, error) {
_ = ctx
installKind := theme.InstallKind(strings.TrimSpace(kind))
if installKind == "" {
installKind = theme.InstallKindFrontend
}
meta, err := theme.Install(theme.InstallOptions{
ThemesDir: s.cfg.ThemesDir,
URL: url,
Name: name,
Kind: installKind,
})
if err != nil {
return nil, err
}
switch m := meta.(type) {
case *theme.Manifest:
record := &types.ThemeRecord{
Name: m.Name,
Kind: "frontend",
Title: m.Title,
Version: m.Version,
Description: m.Description,
Repo: m.Repo,
SDKVersion: m.SDKVersion,
CompatibilityVersion: m.CompatibilityVersion,
MinFoundryVersion: m.MinFoundryVersion,
SupportedLayouts: append([]string(nil), m.RequiredLayouts()...),
Screenshots: append([]string(nil), m.Screenshots...),
ConfigSchema: toFieldSchema(m.ConfigSchema),
Security: m.Security,
SecuritySummary: m.Security.Summary(),
}
if securityReport, secErr := theme.AnalyzeInstalledSecurity(s.cfg.ThemesDir, m.Name); secErr == nil {
record.SecurityReport = securityReport
}
if validation, err := theme.ValidateInstalledDetailed(s.cfg.ThemesDir, m.Name); err == nil {
record.Valid = validation.Valid
record.Diagnostics = toValidationDiagnostics(validation.Diagnostics)
}
return record, nil
case *adminui.Manifest:
record := &types.ThemeRecord{
Name: m.Name,
Kind: "admin",
Title: m.Title,
Version: m.Version,
Description: m.Description,
Repo: m.Repo,
AdminAPI: m.AdminAPI,
SDKVersion: m.SDKVersion,
CompatibilityVersion: m.CompatibilityVersion,
Components: append([]string(nil), m.Components...),
WidgetSlots: append([]string(nil), m.WidgetSlots...),
Screenshots: append([]string(nil), m.Screenshots...),
}
if validation, err := adminui.ValidateTheme(s.cfg.ThemesDir, m.Name); err == nil {
record.Valid = validation.Valid
record.Diagnostics = toAdminThemeDiagnostics(validation.Diagnostics)
}
return record, nil
default:
return nil, fmt.Errorf("unexpected installed theme metadata type")
}
}
func (s *Service) SwitchTheme(ctx context.Context, name string) error {
_ = ctx
if err := theme.ValidateInstalled(s.cfg.ThemesDir, name); err != nil {
return err
}
if err := theme.SwitchInConfig(consts.ConfigFilePath, name); err != nil {
return err
}
s.cfg.Theme = name
s.invalidateGraphCache()
return nil
}
func (s *Service) SwitchAdminTheme(ctx context.Context, name string) error {
_ = ctx
validation, err := adminui.ValidateTheme(s.cfg.ThemesDir, name)
if err != nil {
return err
}
if !validation.Valid {
return fmt.Errorf("admin theme %q is invalid", name)
}
if err := config.UpsertNestedScalar(consts.ConfigFilePath, []string{"admin", "theme"}, name); err != nil {
return err
}
s.cfg.Admin.Theme = name
return nil
}
func (s *Service) ValidateTheme(ctx context.Context, name, kind string) (*types.ThemeRecord, error) {
_ = ctx
name = strings.TrimSpace(name)
kind = strings.TrimSpace(kind)
if name == "" {
return nil, fmt.Errorf("theme name is required")
}
if kind == "" {
kind = "frontend"
}
items, err := s.ListThemes(context.Background())
if err != nil {
return nil, err
}
for _, item := range items {
if item.Name == name && item.Kind == kind {
record := item
return &record, nil
}
}
return nil, fmt.Errorf("theme not found: %s", name)
}
package service
import (
"context"
"fmt"
"strings"
adminauth "github.com/sphireinc/foundry/internal/admin/auth"
"github.com/sphireinc/foundry/internal/admin/types"
"github.com/sphireinc/foundry/internal/admin/users"
)
func (s *Service) ListUsers(ctx context.Context) ([]types.UserSummary, error) {
_ = ctx
list, err := users.Load(s.cfg.Admin.UsersFile)
if err != nil {
return nil, err
}
out := make([]types.UserSummary, 0, len(list))
for _, user := range list {
out = append(out, types.UserSummary{
Username: user.Username,
Name: user.Name,
Email: user.Email,
Role: normalizeUserRole(user.Role),
Capabilities: append([]string(nil), user.Capabilities...),
Disabled: user.Disabled,
TOTPEnabled: user.TOTPEnabled,
})
}
return out, nil
}
func (s *Service) SaveUser(ctx context.Context, req types.UserSaveRequest) (*types.UserSummary, error) {
_ = ctx
all, err := users.Load(s.cfg.Admin.UsersFile)
if err != nil {
return nil, err
}
username := strings.TrimSpace(req.Username)
if username == "" {
return nil, fmt.Errorf("username is required")
}
role := normalizeUserRole(req.Role)
var passwordHash string
if strings.TrimSpace(req.Password) != "" {
if err := adminauth.ValidatePassword(s.cfg, req.Password); err != nil {
return nil, err
}
passwordHash, err = users.HashPassword(req.Password)
if err != nil {
return nil, err
}
}
found := false
revokeSessions := false
for i := range all {
if strings.EqualFold(all[i].Username, username) {
if passwordHash != "" {
revokeSessions = true
}
if all[i].Disabled != req.Disabled {
revokeSessions = true
}
if strings.TrimSpace(all[i].Role) != role {
revokeSessions = true
}
if !stringSlicesEqual(all[i].Capabilities, req.Capabilities) {
revokeSessions = true
}
all[i].Username = username
all[i].Name = strings.TrimSpace(req.Name)
all[i].Email = strings.TrimSpace(req.Email)
all[i].Role = role
all[i].Capabilities = append([]string(nil), req.Capabilities...)
all[i].Disabled = req.Disabled
if passwordHash != "" {
all[i].PasswordHash = passwordHash
}
found = true
break
}
}
if !found {
if passwordHash == "" {
return nil, fmt.Errorf("password is required for a new user")
}
all = append(all, users.User{
Username: username,
Name: strings.TrimSpace(req.Name),
Email: strings.TrimSpace(req.Email),
Role: role,
Capabilities: append([]string(nil), req.Capabilities...),
PasswordHash: passwordHash,
Disabled: req.Disabled,
})
}
if err := users.Save(s.cfg.Admin.UsersFile, all); err != nil {
return nil, err
}
if revokeSessions {
adminauth.New(s.cfg).RevokeUserSessions(username)
}
return &types.UserSummary{
Username: username,
Name: strings.TrimSpace(req.Name),
Email: strings.TrimSpace(req.Email),
Role: role,
Capabilities: append([]string(nil), req.Capabilities...),
Disabled: req.Disabled,
}, nil
}
func (s *Service) DeleteUser(ctx context.Context, username string) error {
_ = ctx
all, err := users.Load(s.cfg.Admin.UsersFile)
if err != nil {
return err
}
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("username is required")
}
out := make([]users.User, 0, len(all))
removed := false
for _, user := range all {
if strings.EqualFold(user.Username, username) {
removed = true
continue
}
out = append(out, user)
}
if !removed {
return fmt.Errorf("user not found: %s", username)
}
if err := users.Save(s.cfg.Admin.UsersFile, out); err != nil {
return err
}
adminauth.New(s.cfg).RevokeUserSessions(username)
return nil
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if strings.TrimSpace(a[i]) != strings.TrimSpace(b[i]) {
return false
}
}
return true
}
func normalizeUserRole(role string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case "admin":
return "admin"
case "editor":
return "editor"
case "author":
return "author"
case "reviewer":
return "reviewer"
default:
return "editor"
}
}
package ui
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/safepath"
"gopkg.in/yaml.v3"
)
// Manifest is the contract Foundry reads from an admin theme's
// admin-theme.yaml.
//
// Admin themes should declare the admin API version, SDK version, shell
// components, and widget slots they support so alternate admin frontends remain
// compatible with Foundry's extension system.
type Manifest struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
Version string `yaml:"version"`
Description string `yaml:"description"`
Author string `yaml:"author"`
License string `yaml:"license"`
Repo string `yaml:"repo,omitempty"`
AdminAPI string `yaml:"admin_api"`
SDKVersion string `yaml:"sdk_version,omitempty"`
CompatibilityVersion string `yaml:"compatibility_version,omitempty"`
Components []string `yaml:"components"`
WidgetSlots []string `yaml:"widget_slots,omitempty"`
Screenshots []string `yaml:"screenshots,omitempty"`
}
// Diagnostic is a single validation finding for an admin theme
type Diagnostic struct {
Severity string
Path string
Message string
}
// ValidationResult summarizes admin theme validation
type ValidationResult struct {
Valid bool
Diagnostics []Diagnostic
}
// ThemeInfo identifies an installed admin theme directory
type ThemeInfo struct {
Name string
Path string
}
var requiredComponents = []string{
"shell",
"login",
"navigation",
"documents",
"media",
"users",
"config",
"plugins",
"themes",
"audit",
}
var requiredWidgetSlots = []string{
"overview.after",
"documents.sidebar",
"media.sidebar",
"plugins.sidebar",
}
// ListInstalled returns all admin themes under themesDir/admin-themes.
func ListInstalled(themesDir string) ([]ThemeInfo, error) {
root := filepath.Join(themesDir, "admin-themes")
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return []ThemeInfo{}, nil
}
return nil, err
}
out := make([]ThemeInfo, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
out = append(out, ThemeInfo{Name: entry.Name(), Path: filepath.Join(root, entry.Name())})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// LoadManifest reads and normalizes admin-theme.yaml for an admin theme.
//
// When the manifest is missing, Foundry synthesizes a default contract so the
// built-in admin theme can still work
func LoadManifest(themesDir, name string) (*Manifest, error) {
name, err := safepath.ValidatePathComponent("admin theme name", name)
if err != nil {
return nil, err
}
path := filepath.Join(themesDir, "admin-themes", name, "admin-theme.yaml")
body, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Manifest{
Name: name,
Title: name,
Version: "0.0.0",
AdminAPI: consts.AdminAPIContractVersion,
SDKVersion: consts.AdminSDKVersion,
CompatibilityVersion: consts.AdminThemeCompatibility,
Components: append([]string(nil), requiredComponents...),
WidgetSlots: append([]string(nil), requiredWidgetSlots...),
}, nil
}
return nil, err
}
var manifest Manifest
if err := yaml.Unmarshal(body, &manifest); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if strings.TrimSpace(manifest.Name) == "" {
manifest.Name = name
}
if strings.TrimSpace(manifest.Title) == "" {
manifest.Title = manifest.Name
}
if strings.TrimSpace(manifest.Version) == "" {
manifest.Version = "0.0.0"
}
if strings.TrimSpace(manifest.AdminAPI) == "" {
manifest.AdminAPI = consts.AdminAPIContractVersion
}
if strings.TrimSpace(manifest.SDKVersion) == "" {
manifest.SDKVersion = consts.AdminSDKVersion
}
if strings.TrimSpace(manifest.CompatibilityVersion) == "" {
manifest.CompatibilityVersion = consts.AdminThemeCompatibility
}
if len(manifest.Components) == 0 {
manifest.Components = append([]string(nil), requiredComponents...)
}
if len(manifest.WidgetSlots) == 0 {
manifest.WidgetSlots = append([]string(nil), requiredWidgetSlots...)
}
return &manifest, nil
}
// ValidateTheme validates an installed admin theme against Foundry's required
// contract
func ValidateTheme(themesDir, name string) (*ValidationResult, error) {
name, err := safepath.ValidatePathComponent("admin theme name", name)
if err != nil {
return nil, err
}
root := filepath.Join(themesDir, "admin-themes", name)
if _, err := os.Stat(root); err != nil {
return nil, err
}
manifest, err := LoadManifest(themesDir, name)
if err != nil {
return nil, err
}
result := &ValidationResult{Valid: true, Diagnostics: make([]Diagnostic, 0)}
add := func(severity, path, message string) {
result.Diagnostics = append(result.Diagnostics, Diagnostic{
Severity: severity,
Path: filepath.ToSlash(path),
Message: message,
})
if severity == "error" {
result.Valid = false
}
}
if manifest.Name != name {
add("error", filepath.Join(root, "admin-theme.yaml"), fmt.Sprintf("admin theme manifest name %q must match directory %q", manifest.Name, name))
}
if manifest.AdminAPI != consts.AdminAPIContractVersion {
add("error", filepath.Join(root, "admin-theme.yaml"), fmt.Sprintf("unsupported admin_api %q", manifest.AdminAPI))
}
if manifest.SDKVersion != consts.AdminSDKVersion {
add("error", filepath.Join(root, "admin-theme.yaml"), fmt.Sprintf("unsupported sdk_version %q", manifest.SDKVersion))
}
if manifest.CompatibilityVersion != consts.AdminThemeCompatibility {
add("error", filepath.Join(root, "admin-theme.yaml"), fmt.Sprintf("unsupported compatibility_version %q", manifest.CompatibilityVersion))
}
for _, rel := range []string{"index.html", filepath.Join("assets", "admin.css"), filepath.Join("assets", "admin.js")} {
path := filepath.Join(root, rel)
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
add("error", path, "missing required admin theme file")
continue
}
return nil, err
}
}
declared := make(map[string]struct{}, len(manifest.Components))
for _, component := range manifest.Components {
declared[strings.TrimSpace(component)] = struct{}{}
}
for _, component := range requiredComponents {
if _, ok := declared[component]; !ok {
add("error", filepath.Join(root, "admin-theme.yaml"), fmt.Sprintf("missing required admin component %q", component))
}
}
declaredSlots := make(map[string]struct{}, len(manifest.WidgetSlots))
for _, slot := range manifest.WidgetSlots {
declaredSlots[strings.TrimSpace(slot)] = struct{}{}
}
for _, slot := range requiredWidgetSlots {
if _, ok := declaredSlots[slot]; !ok {
add("error", filepath.Join(root, "admin-theme.yaml"), fmt.Sprintf("missing required admin widget slot %q", slot))
}
}
return result, nil
}
package ui
import (
"bytes"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/safepath"
)
type Manager struct {
cfg *config.Config
}
func NewManager(cfg *config.Config) *Manager {
return &Manager{cfg: cfg}
}
func (m *Manager) RenderIndex() ([]byte, error) {
tmplBody, err := m.loadIndexTemplate()
if err != nil {
return nil, err
}
tmpl, err := template.New("admin-index").Parse(tmplBody)
if err != nil {
return nil, err
}
data := struct {
Title string
AdminPath string
DefaultLang string
ThemeName string
ThemeBase string
}{
Title: m.cfg.Title,
AdminPath: m.cfg.AdminPath(),
DefaultLang: m.cfg.DefaultLang,
ThemeName: m.themeName(),
ThemeBase: m.cfg.AdminPath() + "/theme",
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (m *Manager) AssetHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/"))
name = filepath.ToSlash(filepath.Clean(name))
name = strings.TrimPrefix(name, "/")
if name == "." || name == "" {
http.NotFound(w, r)
return
}
assetsRoot := filepath.Join(m.themeRoot(), "assets")
path, err := safepath.ResolveRelativeUnderRoot(assetsRoot, filepath.FromSlash(name))
if err == nil && fileExists(path) {
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, r, path)
return
}
body, contentType, ok := fallbackAsset(name)
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write([]byte(body))
})
}
func (m *Manager) loadIndexTemplate() (string, error) {
path := filepath.Join(m.themeRoot(), "index.html")
if fileExists(path) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(b), nil
}
return defaultIndexTemplate, nil
}
func (m *Manager) themeRoot() string {
return filepath.Join(m.cfg.ThemesDir, "admin-themes", m.themeName())
}
func (m *Manager) themeName() string {
if m == nil || m.cfg == nil || strings.TrimSpace(m.cfg.Admin.Theme) == "" {
return "default"
}
return strings.TrimSpace(m.cfg.Admin.Theme)
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func fallbackAsset(name string) (string, string, bool) {
switch name {
case "admin.css":
return defaultCSS, "text/css; charset=utf-8", true
case "admin.js":
return defaultJS, "application/javascript; charset=utf-8", true
default:
return "", "", false
}
}
const defaultIndexTemplate = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }} Admin</title>
<link rel="stylesheet" href="{{ .ThemeBase }}/admin.css">
</head>
<body>
<div id="app" data-admin-base="{{ .AdminPath }}" data-default-lang="{{ .DefaultLang }}" data-theme="{{ .ThemeName }}">
<noscript>Foundry admin requires JavaScript.</noscript>
</div>
<script type="module" src="{{ .ThemeBase }}/admin.js"></script>
</body>
</html>
`
const defaultCSS = `:root {
--bg: #f4efe6;
--panel: rgba(255,255,255,0.85);
--line: rgba(24,31,41,0.12);
--text: #182029;
--muted: #5d6773;
--accent: #0c7c59;
--accent-strong: #0a6448;
--danger: #9d2a2a;
--shadow: 0 18px 60px rgba(16,24,32,0.08);
--radius: 22px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(12,124,89,0.14), transparent 26rem),
linear-gradient(180deg, #fbf8f2, #ece5d7);
}
.admin-shell {
max-width: 1200px;
margin: 0 auto;
padding: 32px 20px 48px;
}
.admin-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-end;
margin-bottom: 24px;
}
.admin-title {
margin: 0;
font-size: clamp(2rem, 3vw, 3.1rem);
letter-spacing: -0.04em;
}
.admin-subtitle {
margin: 8px 0 0;
color: var(--muted);
max-width: 40rem;
}
.admin-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255,255,255,0.72);
border: 1px solid var(--line);
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 20px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.panel-body {
padding: 20px;
}
.panel-title {
margin: 0 0 14px;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.field, .actions, .stack { display: grid; gap: 12px; }
label {
display: grid;
gap: 6px;
font-size: 0.94rem;
}
input, button, select, textarea {
font: inherit;
}
input, select, textarea {
width: 100%;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.92);
color: var(--text);
}
button {
border: 0;
border-radius: 14px;
padding: 12px 16px;
background: var(--accent);
color: white;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: rgba(24,32,41,0.08);
color: var(--text);
}
button:hover { background: var(--accent-strong); }
button.secondary:hover { background: rgba(24,32,41,0.14); }
.note, .status-line {
color: var(--muted);
font-size: 0.92rem;
}
.error {
color: var(--danger);
font-weight: 600;
}
.cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.stat {
padding: 18px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.72);
}
.stat strong {
display: block;
font-size: 1.65rem;
letter-spacing: -0.04em;
}
.stat span {
color: var(--muted);
font-size: 0.92rem;
}
.list {
display: grid;
gap: 12px;
}
.item {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.72);
}
.item-header {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 6px;
}
.item-title {
font-weight: 700;
}
.item-meta, .item-path {
color: var(--muted);
font-size: 0.9rem;
}
.media-ref {
display: inline-block;
margin-top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(12,124,89,0.1);
color: var(--accent-strong);
font-family: "IBM Plex Mono", monospace;
font-size: 0.84rem;
}
@media (max-width: 960px) {
.grid, .cards { grid-template-columns: 1fr; }
}
`
const defaultJS = `(() => {
const root = document.getElementById('app');
if (!root) return;
const adminBase = root.dataset.adminBase || '/__admin';
const tokenKey = 'foundry.admin.token';
const state = {
token: window.localStorage.getItem(tokenKey) || '',
status: null,
documents: [],
media: [],
error: '',
loading: false
};
const headers = () => {
const token = state.token.trim();
return token ? { 'X-Foundry-Admin-Token': token } : {};
};
const setToken = (value) => {
state.token = value.trim();
if (state.token) {
window.localStorage.setItem(tokenKey, state.token);
} else {
window.localStorage.removeItem(tokenKey);
}
};
const escapeHTML = (value) => String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
const renderStatusCards = () => {
if (!state.status) {
return '<div class="status-line">Enter a token and load data to inspect the admin API.</div>';
}
const content = state.status.content || {};
const checks = Array.isArray(state.status.checks) ? state.status.checks : [];
return '<div class="cards">' +
'<div class="stat"><strong>' + escapeHTML(content.document_count ?? 0) + '</strong><span>documents</span></div>' +
'<div class="stat"><strong>' + escapeHTML(content.draft_count ?? 0) + '</strong><span>drafts</span></div>' +
'<div class="stat"><strong>' + escapeHTML(checks.length) + '</strong><span>health checks</span></div>' +
'</div>';
};
const renderDocuments = () => {
if (!state.documents.length) {
return '<div class="status-line">No documents loaded yet.</div>';
}
return '<div class="list">' + state.documents.map((doc) => (
'<div class="item">' +
'<div class="item-header">' +
'<div class="item-title">' + escapeHTML(doc.title || doc.slug || doc.id) + '</div>' +
'<div class="item-meta">' + escapeHTML(doc.type) + ' · ' + escapeHTML(doc.lang) + '</div>' +
'</div>' +
'<div class="item-path">' + escapeHTML(doc.url || doc.source_path) + '</div>' +
'</div>'
)).join('') + '</div>';
};
const renderMedia = () => {
if (!state.media.length) {
return '<div class="status-line">No uploaded media found yet.</div>';
}
return '<div class="list">' + state.media.map((item) => (
'<div class="item">' +
'<div class="item-header">' +
'<div class="item-title">' + escapeHTML(item.name) + '</div>' +
'<div class="item-meta">' + escapeHTML(item.kind) + ' · ' + escapeHTML(item.collection) + '</div>' +
'</div>' +
'<div class="item-path">' + escapeHTML(item.public_url) + '</div>' +
'<span class="media-ref">' + escapeHTML(item.reference) + '</span>' +
'</div>'
)).join('') + '</div>';
};
const render = () => {
root.innerHTML = '' +
'<div class="admin-shell">' +
'<header class="admin-header">' +
'<div>' +
'<h1 class="admin-title">Foundry Admin</h1>' +
'<p class="admin-subtitle">Themeable admin shell with token-based API access. Tokens stay in local storage in this browser only.</p>' +
'</div>' +
'<div class="admin-badge">API base: ' + escapeHTML(adminBase) + '/api</div>' +
'</header>' +
'<div class="grid">' +
'<section class="panel"><div class="panel-body">' +
'<h2 class="panel-title">Session</h2>' +
'<form id="token-form" class="stack">' +
'<label>Access token<input id="token-input" type="password" autocomplete="off" placeholder="Paste admin token" value="' + escapeHTML(state.token) + '"></label>' +
'<div class="actions">' +
'<button type="submit">Load admin data</button>' +
'<button class="secondary" type="button" id="clear-token">Clear token</button>' +
'</div>' +
'</form>' +
'<p class="note">Accepted header: <code>X-Foundry-Admin-Token</code> or bearer token.</p>' +
'<form id="upload-form" class="stack">' +
'<h2 class="panel-title">Upload Media</h2>' +
'<label>Collection<select id="media-collection"><option value="">Auto</option><option value="images">images</option><option value="videos">videos</option><option value="audio">audio</option><option value="documents">documents</option></select></label>' +
'<label>File<input id="media-file" type="file"></label>' +
'<button type="submit">Upload</button>' +
'</form>' +
'<div id="session-status" class="status-line"></div>' +
'<div id="session-error" class="error"></div>' +
'</div></section>' +
'<section class="stack">' +
'<section class="panel"><div class="panel-body"><h2 class="panel-title">Status</h2>' + renderStatusCards() + '</div></section>' +
'<section class="panel"><div class="panel-body"><h2 class="panel-title">Documents</h2>' + renderDocuments() + '</div></section>' +
'<section class="panel"><div class="panel-body"><h2 class="panel-title">Media</h2>' + renderMedia() + '</div></section>' +
'</section>' +
'</div>' +
'</div>';
const tokenForm = document.getElementById('token-form');
const clearToken = document.getElementById('clear-token');
const uploadForm = document.getElementById('upload-form');
const tokenInput = document.getElementById('token-input');
const sessionStatus = document.getElementById('session-status');
const sessionError = document.getElementById('session-error');
sessionStatus.textContent = state.loading ? 'Loading…' : (state.token ? 'Token stored locally in this browser.' : 'Enter a token to load admin data.');
sessionError.textContent = state.error || '';
tokenForm.addEventListener('submit', async (event) => {
event.preventDefault();
setToken(tokenInput.value);
await loadAll();
});
clearToken.addEventListener('click', () => {
setToken('');
state.status = null;
state.documents = [];
state.media = [];
state.error = '';
render();
});
uploadForm.addEventListener('submit', async (event) => {
event.preventDefault();
if (!state.token) {
state.error = 'Enter an admin token before uploading media.';
render();
return;
}
const fileInput = document.getElementById('media-file');
const collectionInput = document.getElementById('media-collection');
const file = fileInput.files && fileInput.files[0];
if (!file) {
state.error = 'Choose a file to upload.';
render();
return;
}
state.loading = true;
state.error = '';
render();
try {
const form = new FormData();
form.append('file', file);
form.append('collection', collectionInput.value);
const response = await fetch(adminBase + '/api/media/upload', {
method: 'POST',
headers: headers(),
body: form
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || 'media upload failed');
}
await loadAll();
} catch (error) {
state.loading = false;
state.error = error.message || String(error);
render();
}
});
};
const loadJSON = async (path) => {
const response = await fetch(adminBase + path, { headers: headers() });
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || ('request failed for ' + path));
}
return response.json();
};
const loadAll = async () => {
if (!state.token) {
render();
return;
}
state.loading = true;
state.error = '';
render();
try {
const [status, documents, media] = await Promise.all([
loadJSON('/api/status'),
loadJSON('/api/documents?include_drafts=1'),
loadJSON('/api/media')
]);
state.status = status;
state.documents = Array.isArray(documents) ? documents : [];
state.media = Array.isArray(media) ? media : [];
} catch (error) {
state.error = error.message || String(error);
} finally {
state.loading = false;
render();
}
};
render();
if (state.token) {
void loadAll();
}
})();
`
package users
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/crypto/argon2"
"gopkg.in/yaml.v3"
)
const (
pbkdf2HashAlgorithm = "pbkdf2-sha256"
pbkdf2HashIterations = 120000
pbkdf2HashKeyLength = 32
argon2idHashAlgorithm = "argon2id"
argon2idMemoryKB uint32 = 64 * 1024
argon2idIterations uint32 = 3
argon2idParallelism uint8 = 2
argon2idSaltLength = 16
argon2idKeyLength uint32 = 32
)
type File struct {
Users []User `yaml:"users"`
}
type User struct {
Username string `yaml:"username"`
Name string `yaml:"name"`
Email string `yaml:"email"`
Role string `yaml:"role,omitempty"`
Capabilities []string `yaml:"capabilities,omitempty"`
PasswordHash string `yaml:"password_hash"`
Disabled bool `yaml:"disabled,omitempty"`
TOTPEnabled bool `yaml:"totp_enabled,omitempty"`
TOTPSecret string `yaml:"totp_secret,omitempty"`
ResetTokenHash string `yaml:"reset_token_hash,omitempty"`
ResetTokenExpires time.Time `yaml:"reset_token_expires,omitempty"`
}
func Load(path string) ([]User, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var file File
if err := yaml.Unmarshal(b, &file); err != nil {
return nil, err
}
out := make([]User, 0, len(file.Users))
for _, user := range file.Users {
user.Username = strings.TrimSpace(user.Username)
user.Name = strings.TrimSpace(user.Name)
user.Email = strings.TrimSpace(user.Email)
user.Role = strings.TrimSpace(user.Role)
user.PasswordHash = strings.TrimSpace(user.PasswordHash)
user.TOTPSecret = strings.TrimSpace(user.TOTPSecret)
user.ResetTokenHash = strings.TrimSpace(user.ResetTokenHash)
if user.Username == "" {
continue
}
out = append(out, user)
}
return out, nil
}
func Find(path, username string) (*User, error) {
entries, err := Load(path)
if err != nil {
return nil, err
}
username = strings.TrimSpace(strings.ToLower(username))
for _, user := range entries {
if strings.ToLower(user.Username) == username {
return &user, nil
}
}
return nil, os.ErrNotExist
}
func Save(path string, entries []User) error {
file := File{Users: entries}
b, err := yaml.Marshal(&file)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
func UpdatePasswordHash(path, username, passwordHash string) error {
all, err := Load(path)
if err != nil {
return err
}
username = strings.TrimSpace(strings.ToLower(username))
for i := range all {
if strings.ToLower(strings.TrimSpace(all[i].Username)) != username {
continue
}
all[i].PasswordHash = strings.TrimSpace(passwordHash)
return Save(path, all)
}
return os.ErrNotExist
}
func UpdateTOTPSecret(path, username, totpSecret string) error {
all, err := Load(path)
if err != nil {
return err
}
username = strings.TrimSpace(strings.ToLower(username))
for i := range all {
if strings.ToLower(strings.TrimSpace(all[i].Username)) != username {
continue
}
all[i].TOTPSecret = strings.TrimSpace(totpSecret)
return Save(path, all)
}
return os.ErrNotExist
}
func HashPassword(password string) (string, error) {
password = strings.TrimSpace(password)
if password == "" {
return "", fmt.Errorf("password cannot be empty")
}
salt := make([]byte, argon2idSaltLength)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(password), salt, argon2idIterations, argon2idMemoryKB, argon2idParallelism, argon2idKeyLength)
return fmt.Sprintf(
"%s$%d$%d$%d$%s$%s",
argon2idHashAlgorithm,
argon2idMemoryKB,
argon2idIterations,
argon2idParallelism,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
func VerifyPassword(encodedHash, password string) bool {
ok, _, err := VerifyPasswordWithUpgrade(encodedHash, password)
return err == nil && ok
}
func VerifyPasswordWithUpgrade(encodedHash, password string) (bool, string, error) {
encodedHash = strings.TrimSpace(encodedHash)
if encodedHash == "" {
return false, "", nil
}
switch hashAlgorithm(encodedHash) {
case argon2idHashAlgorithm:
ok, err := verifyArgon2id(encodedHash, password)
return ok, "", err
case pbkdf2HashAlgorithm:
ok, err := verifyPBKDF2(encodedHash, password)
if err != nil || !ok {
return ok, "", err
}
upgraded, err := HashPassword(password)
return true, upgraded, err
default:
return false, "", nil
}
}
func hashAlgorithm(encodedHash string) string {
parts := strings.Split(strings.TrimSpace(encodedHash), "$")
if len(parts) == 0 {
return ""
}
return strings.TrimSpace(parts[0])
}
func verifyArgon2id(encodedHash, password string) (bool, error) {
parts := strings.Split(strings.TrimSpace(encodedHash), "$")
if len(parts) != 6 || parts[0] != argon2idHashAlgorithm {
return false, nil
}
memory, err := strconv.ParseUint(parts[1], 10, 32)
if err != nil {
return false, err
}
iterations, err := strconv.ParseUint(parts[2], 10, 32)
if err != nil {
return false, err
}
parallelism, err := strconv.ParseUint(parts[3], 10, 8)
if err != nil {
return false, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, err
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, uint32(iterations), uint32(memory), uint8(parallelism), uint32(len(want)))
if len(got) != len(want) {
return false, nil
}
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
func verifyPBKDF2(encodedHash, password string) (bool, error) {
parts := strings.Split(strings.TrimSpace(encodedHash), "$")
if len(parts) != 4 || parts[0] != pbkdf2HashAlgorithm {
return false, nil
}
iterations, err := parsePositiveInt(parts[1])
if err != nil {
return false, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false, err
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false, err
}
got := pbkdf2SHA256([]byte(password), salt, iterations, len(want))
if len(got) != len(want) {
return false, nil
}
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
func parsePositiveInt(raw string) (int, error) {
var value int
for _, r := range raw {
if r < '0' || r > '9' {
return 0, fmt.Errorf("invalid integer")
}
value = value*10 + int(r-'0')
}
if value <= 0 {
return 0, fmt.Errorf("invalid integer")
}
return value, nil
}
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
hLen := 32
blocks := (keyLen + hLen - 1) / hLen
out := make([]byte, 0, blocks*hLen)
for block := 1; block <= blocks; block++ {
u := hmacSHA256(password, append(salt, byte(block>>24), byte(block>>16), byte(block>>8), byte(block)))
t := append([]byte(nil), u...)
for i := 1; i < iterations; i++ {
u = hmacSHA256(password, u)
for j := range t {
t[j] ^= u[j]
}
}
out = append(out, t...)
}
return out[:keyLen]
}
func hmacSHA256(key, data []byte) []byte {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write(data)
return mac.Sum(nil)
}
package assets
import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/lifecycle"
"github.com/sphireinc/foundry/internal/safepath"
)
type Hooks interface {
OnAssetsBuilding(*config.Config) error
}
type noopHooks struct{}
func (noopHooks) OnAssetsBuilding(*config.Config) error { return nil }
func Sync(cfg *config.Config, hooks Hooks) error {
if hooks == nil {
hooks = noopHooks{}
}
if err := hooks.OnAssetsBuilding(cfg); err != nil {
return err
}
if err := os.MkdirAll(cfg.PublicDir, 0o755); err != nil {
return fmt.Errorf("create public dir: %w", err)
}
if cfg.Build.CopyAssets {
src, err := safepath.ResolveRelativeUnderRoot(cfg.ContentDir, cfg.Content.AssetsDir)
if err != nil {
return err
}
dst := filepath.Join(cfg.PublicDir, "assets")
if err := copyDirIfExists(src, dst); err != nil {
return err
}
}
if cfg.Build.CopyImages {
src, err := safepath.ResolveRelativeUnderRoot(cfg.ContentDir, cfg.Content.ImagesDir)
if err != nil {
return err
}
dst := filepath.Join(cfg.PublicDir, "images")
if err := copyDirIfExists(src, dst); err != nil {
return err
}
}
if cfg.Build.CopyUploads {
src, err := safepath.ResolveRelativeUnderRoot(cfg.ContentDir, cfg.Content.UploadsDir)
if err != nil {
return err
}
dst := filepath.Join(cfg.PublicDir, "uploads")
if err := copyDirIfExists(src, dst); err != nil {
return err
}
videoSrc, err := safepath.ResolveRelativeUnderRoot(cfg.ContentDir, cfg.Content.VideoDir)
if err != nil {
return err
}
if err := copyDirIfExists(videoSrc, filepath.Join(cfg.PublicDir, "videos")); err != nil {
return err
}
audioSrc, err := safepath.ResolveRelativeUnderRoot(cfg.ContentDir, cfg.Content.AudioDir)
if err != nil {
return err
}
if err := copyDirIfExists(audioSrc, filepath.Join(cfg.PublicDir, "audio")); err != nil {
return err
}
documentsSrc, err := safepath.ResolveRelativeUnderRoot(cfg.ContentDir, cfg.Content.DocumentsDir)
if err != nil {
return err
}
if err := copyDirIfExists(documentsSrc, filepath.Join(cfg.PublicDir, "documents")); err != nil {
return err
}
}
themeName, err := safepath.ValidatePathComponent("theme name", cfg.Theme)
if err != nil {
return err
}
themeAssetsSrc := filepath.Join(cfg.ThemesDir, themeName, "assets")
themeAssetsDst := filepath.Join(cfg.PublicDir, "theme")
if err := copyDirIfExists(themeAssetsSrc, themeAssetsDst); err != nil {
return err
}
for _, pluginName := range cfg.Plugins.Enabled {
pluginName = strings.TrimSpace(pluginName)
if pluginName == "" {
continue
}
pluginName, err = safepath.ValidatePathComponent("plugin name", pluginName)
if err != nil {
return err
}
src := filepath.Join(cfg.PluginsDir, pluginName, "assets")
dst := filepath.Join(cfg.PublicDir, "plugins", pluginName)
if err := copyDirIfExists(src, dst); err != nil {
return err
}
}
if err := buildCSSBundle(cfg); err != nil {
return err
}
return nil
}
func buildCSSBundle(cfg *config.Config) error {
themeName, err := safepath.ValidatePathComponent("theme name", cfg.Theme)
if err != nil {
return err
}
contentAssetsRoot, err := safepath.ResolveRelativeUnderRoot(cfg.ContentDir, cfg.Content.AssetsDir)
if err != nil {
return err
}
themeCSSRoot := filepath.Join(cfg.ThemesDir, themeName, "assets", "css")
contentCSSRoot := filepath.Join(contentAssetsRoot, "css")
targetDir := filepath.Join(cfg.PublicDir, "assets", "css")
targetFile := filepath.Join(targetDir, "foundry.bundle.css")
files := make([]string, 0)
themeFiles, err := listFiles(themeCSSRoot, ".css")
if err != nil {
return err
}
contentFiles, err := listFiles(contentCSSRoot, ".css")
if err != nil {
return err
}
files = append(files, themeFiles...)
files = append(files, contentFiles...)
if len(files) == 0 {
return nil
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("create css bundle dir: %w", err)
}
var sb strings.Builder
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
return fmt.Errorf("read css file %s: %w", f, err)
}
sb.WriteString("/* ")
sb.WriteString(filepath.ToSlash(f))
sb.WriteString(" */\n")
sb.Write(b)
sb.WriteString("\n\n")
}
if err := os.WriteFile(targetFile, []byte(sb.String()), 0o644); err != nil {
return fmt.Errorf("write css bundle: %w", err)
}
return nil
}
func listFiles(root, ext string) ([]string, error) {
out := make([]string, 0)
info, err := os.Lstat(root)
if err != nil {
if os.IsNotExist(err) {
return out, nil
}
return nil, fmt.Errorf("stat %s: %w", root, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("symlinked asset root is not allowed: %s", root)
}
if !info.IsDir() {
return out, nil
}
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinked asset path is not allowed: %s", path)
}
if d.IsDir() {
return nil
}
if shouldSkipAssetPath(path) {
return nil
}
if strings.EqualFold(filepath.Ext(path), ext) {
out = append(out, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk files in %s: %w", root, err)
}
sort.Strings(out)
return out, nil
}
func copyDirIfExists(src, dst string) error {
info, err := os.Lstat(src)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("stat %s: %w", src, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinked asset root is not allowed: %s", src)
}
if !info.IsDir() {
return nil
}
return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinked asset path is not allowed: %s", path)
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
if shouldSkipAssetPath(path) {
return nil
}
return copyFile(path, target, info.Mode())
})
}
func shouldSkipAssetPath(path string) bool {
base := filepath.Base(path)
return strings.HasSuffix(strings.ToLower(base), ".meta.yaml") || lifecycle.IsDerivedPath(path)
}
func copyFile(src, dst string, mode os.FileMode) error {
if mode&os.ModeSymlink != 0 {
return fmt.Errorf("symlinked asset file is not allowed: %s", src)
}
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open src file %s: %w", src, err)
}
defer func(in *os.File) {
err := in.Close()
if err != nil {
_ = fmt.Errorf("close src file %s: %w", src, err)
}
}(in)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("mkdir dst dir %s: %w", filepath.Dir(dst), err)
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
if err != nil {
return fmt.Errorf("open dst file %s: %w", dst, err)
}
defer func(out *os.File) {
err := out.Close()
if err != nil {
_ = fmt.Errorf("close dst file %s: %w", dst, err)
}
}(out)
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy %s -> %s: %w", src, dst, err)
}
return nil
}
package backup
import (
"sync"
"time"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/logx"
)
type AutoRunner struct {
cfg *config.Config
mu sync.Mutex
timer *time.Timer
running bool
pending bool
debounce time.Duration
}
func NewAutoRunner(cfg *config.Config) *AutoRunner {
if cfg == nil || !cfg.Backup.Enabled || !cfg.Backup.OnChange {
return nil
}
return &AutoRunner{
cfg: cfg,
debounce: time.Duration(cfg.Backup.DebounceSeconds) * time.Second,
}
}
func (r *AutoRunner) Notify(path string) {
if r == nil || !r.shouldTrack(path) {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.timer != nil {
r.timer.Stop()
}
r.timer = time.AfterFunc(r.debounce, r.run)
}
func (r *AutoRunner) Stop() {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.timer != nil {
r.timer.Stop()
r.timer = nil
}
}
func (r *AutoRunner) shouldTrack(path string) bool {
if r == nil || path == "" {
return false
}
if PathIsUnderBackupRoot(r.cfg, path) {
return false
}
rel, err := filepathRelAbs(r.cfg.ContentDir, path)
if err != nil {
return false
}
_ = rel
return true
}
func (r *AutoRunner) run() {
r.mu.Lock()
if r.running {
r.pending = true
r.mu.Unlock()
return
}
r.running = true
r.mu.Unlock()
snapshot, err := CreateManagedSnapshot(r.cfg)
if err != nil {
logx.Warn("auto backup failed", "error", err)
} else {
logx.Info("auto backup created", "path", snapshot.Path, "size_bytes", snapshot.SizeBytes)
}
r.mu.Lock()
r.running = false
if r.pending {
r.pending = false
if r.timer != nil {
r.timer.Stop()
}
r.timer = time.AfterFunc(r.debounce, r.run)
}
r.mu.Unlock()
}
func filepathRelAbs(root, target string) (string, error) {
rootAbs, err := filepathAbs(root)
if err != nil {
return "", err
}
targetAbs, err := filepathAbs(target)
if err != nil {
return "", err
}
rel, err := filepathRel(rootAbs, targetAbs)
if err != nil {
return "", err
}
if rel == ".." || stringsHasDotDotPrefix(rel) {
return "", errOutsideRoot
}
return rel, nil
}
package backup
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/sphireinc/foundry/internal/config"
)
type Snapshot struct {
Path string
SizeBytes int64
CreatedAt time.Time
SourceBytes int64
FreeBytes uint64
RequiredFree uint64
}
type manifest struct {
CreatedAt time.Time `json:"created_at"`
ContentDir string `json:"content_dir"`
SourceBytes int64 `json:"source_bytes"`
FreeBytes uint64 `json:"free_bytes"`
RequiredFree uint64 `json:"required_free_bytes"`
HeadroomPercent int `json:"headroom_percent"`
}
func CreateManagedSnapshot(cfg *config.Config) (*Snapshot, error) {
if cfg == nil {
return nil, fmt.Errorf("config is nil")
}
if err := os.MkdirAll(cfg.Backup.Dir, 0o755); err != nil {
return nil, err
}
target := nextManagedSnapshotPath(cfg.Backup.Dir, time.Now())
snapshot, err := CreateZipSnapshot(cfg, target)
if err != nil {
return nil, err
}
if cfg.Backup.RetentionCount > 0 {
if err := Prune(cfg.Backup.Dir, cfg.Backup.RetentionCount); err != nil {
return snapshot, err
}
}
return snapshot, nil
}
func CreateZipSnapshot(cfg *config.Config, target string) (*Snapshot, error) {
if cfg == nil {
return nil, fmt.Errorf("config is nil")
}
target = strings.TrimSpace(target)
if target == "" {
return nil, fmt.Errorf("backup target must not be empty")
}
if filepath.Ext(target) == "" {
target += ".zip"
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return nil, err
}
sourceBytes, err := dirSize(cfg.ContentDir)
if err != nil {
return nil, err
}
freeBytes, err := freeSpace(filepath.Dir(target))
if err != nil {
return nil, err
}
required := requiredFreeBytes(sourceBytes, cfg.Backup.HeadroomPercent, cfg.Backup.MinFreeMB)
if freeBytes < required {
return nil, fmt.Errorf("not enough free space for backup: required %d bytes, available %d bytes", required, freeBytes)
}
tmpTarget := target + ".tmp"
_ = os.Remove(tmpTarget)
file, err := os.Create(tmpTarget)
if err != nil {
return nil, err
}
createdAt := time.Now().UTC()
zw := zip.NewWriter(file)
writeErr := addPathToZip(zw, cfg.ContentDir, cfg.ContentDir)
if writeErr == nil {
writeErr = writeManifest(zw, manifest{
CreatedAt: createdAt,
ContentDir: cfg.ContentDir,
SourceBytes: sourceBytes,
FreeBytes: freeBytes,
RequiredFree: required,
HeadroomPercent: cfg.Backup.HeadroomPercent,
})
}
closeErr := zw.Close()
fileCloseErr := file.Close()
if writeErr != nil {
_ = os.Remove(tmpTarget)
return nil, writeErr
}
if closeErr != nil {
_ = os.Remove(tmpTarget)
return nil, closeErr
}
if fileCloseErr != nil {
_ = os.Remove(tmpTarget)
return nil, fileCloseErr
}
if err := os.Rename(tmpTarget, target); err != nil {
_ = os.Remove(tmpTarget)
return nil, err
}
info, err := os.Stat(target)
if err != nil {
return nil, err
}
return &Snapshot{
Path: target,
SizeBytes: info.Size(),
CreatedAt: createdAt,
SourceBytes: sourceBytes,
FreeBytes: freeBytes,
RequiredFree: required,
}, nil
}
func List(dir string) ([]Snapshot, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil, fmt.Errorf("backup dir must not be empty")
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
out := make([]Snapshot, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".zip" {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
out = append(out, Snapshot{
Path: filepath.Join(dir, entry.Name()),
SizeBytes: info.Size(),
CreatedAt: info.ModTime(),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].CreatedAt.After(out[j].CreatedAt)
})
return out, nil
}
func Prune(dir string, retain int) error {
if retain < 0 {
return fmt.Errorf("retain must not be negative")
}
items, err := List(dir)
if err != nil {
return err
}
for idx, item := range items {
if idx < retain {
continue
}
if err := os.Remove(item.Path); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func PathIsUnderBackupRoot(cfg *config.Config, candidate string) bool {
if cfg == nil || strings.TrimSpace(cfg.Backup.Dir) == "" || strings.TrimSpace(candidate) == "" {
return false
}
backupRoot, err := filepath.Abs(cfg.Backup.Dir)
if err != nil {
return false
}
check, err := filepath.Abs(candidate)
if err != nil {
return false
}
rel, err := filepath.Rel(backupRoot, check)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
func requiredFreeBytes(sourceBytes int64, headroomPercent int, minFreeMB int64) uint64 {
if headroomPercent < 100 {
headroomPercent = 100
}
if minFreeMB < 0 {
minFreeMB = 0
}
base := uint64(sourceBytes)
headroom := base * uint64(headroomPercent) / 100
withBuffer := base + uint64(minFreeMB)*1024*1024
if withBuffer > headroom {
return withBuffer
}
return headroom
}
func generatedName(now time.Time) string {
return fmt.Sprintf("content-backup-%s.zip", now.UTC().Format("20060102-150405.000000000"))
}
func nextManagedSnapshotPath(dir string, now time.Time) string {
base := generatedName(now)
target := filepath.Join(dir, base)
if _, err := os.Stat(target); os.IsNotExist(err) {
return target
}
stamp := now.UTC().Format("20060102-150405.000000000")
for i := 1; ; i++ {
candidate := filepath.Join(dir, fmt.Sprintf("content-backup-%s-%02d.zip", stamp, i))
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
}
func dirSize(root string) (int64, error) {
var total int64
err := filepath.Walk(root, func(current string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info == nil || info.IsDir() {
return nil
}
total += info.Size()
return nil
})
return total, err
}
func addPathToZip(zw *zip.Writer, root, source string) error {
return filepath.Walk(source, func(current string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info == nil || info.IsDir() {
return nil
}
rel, err := filepath.Rel(filepath.Dir(root), current)
if err != nil {
return err
}
writer, err := zw.Create(filepath.ToSlash(rel))
if err != nil {
return err
}
file, err := os.Open(current)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
})
}
func writeManifest(zw *zip.Writer, data manifest) error {
writer, err := zw.Create("backup-manifest.json")
if err != nil {
return err
}
enc := json.NewEncoder(writer)
enc.SetIndent("", " ")
return enc.Encode(data)
}
//go:build darwin || linux
package backup
import "syscall"
func freeSpace(path string) (uint64, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, err
}
return stat.Bavail * uint64(stat.Bsize), nil
}
package backup
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/sphireinc/foundry/internal/config"
)
type GitSnapshot struct {
RepoDir string
Revision string
CreatedAt time.Time
Message string
Changed bool
Pushed bool
RemoteURL string
Branch string
}
func CreateGitSnapshot(cfg *config.Config, message string, push bool) (*GitSnapshot, error) {
if cfg == nil {
return nil, fmt.Errorf("config is nil")
}
repoDir := filepath.Join(cfg.Backup.Dir, "git")
if err := os.MkdirAll(repoDir, 0o755); err != nil {
return nil, err
}
if err := ensureGitRepo(repoDir); err != nil {
return nil, err
}
remoteURL := strings.TrimSpace(cfg.Backup.GitRemoteURL)
branch := strings.TrimSpace(cfg.Backup.GitBranch)
if branch == "" {
branch = "main"
}
if remoteURL != "" {
if err := ensureGitRemote(repoDir, remoteURL); err != nil {
return nil, err
}
}
if err := syncContentWorkingTree(repoDir, cfg.ContentDir); err != nil {
return nil, err
}
if strings.TrimSpace(message) == "" {
message = "Foundry content snapshot " + time.Now().UTC().Format(time.RFC3339)
}
if _, err := gitOutput(repoDir, "add", "-A"); err != nil {
return nil, err
}
changed, err := gitHasStagedChanges(repoDir)
if err != nil {
return nil, err
}
if !changed {
rev, _ := gitOutput(repoDir, "rev-parse", "HEAD")
return &GitSnapshot{
RepoDir: repoDir,
Revision: strings.TrimSpace(rev),
CreatedAt: time.Now().UTC(),
Message: message,
Changed: false,
RemoteURL: remoteURL,
Branch: branch,
}, nil
}
if _, err := gitOutputWithConfig(repoDir, []string{"-c", "user.name=Foundry", "-c", "user.email=foundry@localhost", "commit", "-m", message}); err != nil {
return nil, err
}
rev, err := gitOutput(repoDir, "rev-parse", "HEAD")
if err != nil {
return nil, err
}
logInfo, err := gitOutput(repoDir, "log", "-1", "--pretty=format:%cI")
if err != nil {
return nil, err
}
createdAt, _ := time.Parse(time.RFC3339, strings.TrimSpace(logInfo))
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
snapshot := &GitSnapshot{
RepoDir: repoDir,
Revision: strings.TrimSpace(rev),
CreatedAt: createdAt,
Message: message,
Changed: true,
RemoteURL: remoteURL,
Branch: branch,
}
if remoteURL != "" && (push || cfg.Backup.GitPushOnChange) {
if err := pushGitSnapshot(repoDir, branch); err != nil {
return nil, err
}
snapshot.Pushed = true
}
return snapshot, nil
}
func ListGitSnapshots(cfg *config.Config, limit int) ([]GitSnapshot, error) {
if cfg == nil {
return nil, fmt.Errorf("config is nil")
}
if limit <= 0 {
limit = 20
}
repoDir := filepath.Join(cfg.Backup.Dir, "git")
if _, err := os.Stat(filepath.Join(repoDir, ".git")); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
out, err := gitOutput(repoDir, "log", fmt.Sprintf("-%d", limit), "--pretty=format:%H|%cI|%s")
if err != nil {
if strings.Contains(err.Error(), "does not have any commits yet") {
return nil, nil
}
return nil, err
}
lines := strings.Split(strings.TrimSpace(out), "\n")
items := make([]GitSnapshot, 0, len(lines))
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.SplitN(line, "|", 3)
if len(parts) != 3 {
continue
}
createdAt, _ := time.Parse(time.RFC3339, parts[1])
items = append(items, GitSnapshot{
RepoDir: repoDir,
Revision: parts[0],
CreatedAt: createdAt,
Message: parts[2],
Changed: true,
RemoteURL: strings.TrimSpace(cfg.Backup.GitRemoteURL),
Branch: strings.TrimSpace(cfg.Backup.GitBranch),
})
}
return items, nil
}
func ensureGitRepo(repoDir string) error {
if info, err := os.Stat(filepath.Join(repoDir, ".git")); err == nil && info.IsDir() {
return nil
}
_, err := gitOutput(repoDir, "init")
return err
}
func ensureGitRemote(repoDir, remoteURL string) error {
current, err := gitOutput(repoDir, "remote", "get-url", "origin")
if err == nil {
if strings.TrimSpace(current) == strings.TrimSpace(remoteURL) {
return nil
}
_, err = gitOutput(repoDir, "remote", "set-url", "origin", remoteURL)
return err
}
_, err = gitOutput(repoDir, "remote", "add", "origin", remoteURL)
return err
}
func pushGitSnapshot(repoDir, branch string) error {
if strings.TrimSpace(branch) == "" {
branch = "main"
}
_, err := gitOutput(repoDir, "push", "-u", "origin", "HEAD:"+branch)
return err
}
func syncContentWorkingTree(repoDir, contentDir string) error {
entries, err := os.ReadDir(repoDir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.Name() == ".git" {
continue
}
if err := os.RemoveAll(filepath.Join(repoDir, entry.Name())); err != nil {
return err
}
}
target := filepath.Join(repoDir, filepath.Base(contentDir))
return copyDir(contentDir, target)
}
func copyDir(source, target string) error {
return filepath.Walk(source, func(current string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(source, current)
if err != nil {
return err
}
dest := filepath.Join(target, rel)
if info.IsDir() {
return os.MkdirAll(dest, 0o755)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
input, err := os.ReadFile(current)
if err != nil {
return err
}
return os.WriteFile(dest, input, info.Mode())
})
}
func gitHasStagedChanges(repoDir string) (bool, error) {
cmd := exec.Command("git", "-C", repoDir, "diff", "--cached", "--quiet")
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return true, nil
}
return false, err
}
return false, nil
}
func gitOutput(repoDir string, args ...string) (string, error) {
return gitOutputWithConfig(repoDir, args)
}
func gitOutputWithConfig(repoDir string, args []string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", repoDir}, args...)...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("git %s failed: %s", strings.Join(args, " "), msg)
}
return stdout.String(), nil
}
package backup
import (
"errors"
"path/filepath"
"strings"
)
var errOutsideRoot = errors.New("path outside root")
func filepathAbs(path string) (string, error) {
return filepath.Abs(path)
}
func filepathRel(root, target string) (string, error) {
return filepath.Rel(root, target)
}
func stringsHasDotDotPrefix(rel string) bool {
return strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
package backup
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/sphireinc/foundry/internal/config"
)
func RestoreZipSnapshot(cfg *config.Config, source string) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
source = strings.TrimSpace(source)
if source == "" {
return fmt.Errorf("backup source must not be empty")
}
if !PathIsUnderBackupRoot(cfg, source) {
return fmt.Errorf("backup source is outside backup root")
}
reader, err := zip.OpenReader(source)
if err != nil {
return err
}
defer reader.Close()
contentDir := cfg.ContentDir
contentBase := filepath.Base(contentDir)
restoreRoot := filepath.Join(filepath.Dir(contentDir), ".foundry-restore-"+time.Now().UTC().Format("20060102-150405.000"))
extractedContentRoot := filepath.Join(restoreRoot, contentBase)
if err := os.MkdirAll(restoreRoot, 0o755); err != nil {
return err
}
defer os.RemoveAll(restoreRoot)
foundContent := false
for _, file := range reader.File {
name := filepath.Clean(filepath.FromSlash(file.Name))
if name == "." || name == "backup-manifest.json" {
continue
}
if !strings.HasPrefix(name, contentBase+string(filepath.Separator)) {
continue
}
foundContent = true
target := filepath.Join(restoreRoot, name)
if err := ensureWithinRoot(restoreRoot, target); err != nil {
return err
}
if file.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
rc, err := file.Open()
if err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.Mode())
if err != nil {
_ = rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
_ = out.Close()
_ = rc.Close()
return err
}
if err := out.Close(); err != nil {
_ = rc.Close()
return err
}
if err := rc.Close(); err != nil {
return err
}
}
if !foundContent {
return fmt.Errorf("backup archive does not contain %q", contentBase)
}
if _, err := os.Stat(extractedContentRoot); err != nil {
return fmt.Errorf("restored content root missing: %w", err)
}
if _, err := CreateManagedSnapshot(cfg); err != nil {
return fmt.Errorf("pre-restore snapshot failed: %w", err)
}
backupOld := contentDir + ".restore-old-" + time.Now().UTC().Format("20060102-150405.000")
if err := os.Rename(contentDir, backupOld); err != nil {
return err
}
if err := os.Rename(extractedContentRoot, contentDir); err != nil {
_ = os.Rename(backupOld, contentDir)
return err
}
return os.RemoveAll(backupOld)
}
func ensureWithinRoot(root, target string) error {
rootAbs, err := filepath.Abs(root)
if err != nil {
return err
}
targetAbs, err := filepath.Abs(target)
if err != nil {
return err
}
rel, err := filepath.Rel(rootAbs, targetAbs)
if err != nil {
return err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("archive entry escapes restore root")
}
return nil
}
package cache
import "sync"
type MemoryStore struct {
mu sync.RWMutex
data map[string]any
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
data: make(map[string]any),
}
}
func (c *MemoryStore) Get(key string) (any, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *MemoryStore) Set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func (c *MemoryStore) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
func (c *MemoryStore) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.data = make(map[string]any)
}
package cliout
import (
"fmt"
"os"
"github.com/fatih/color"
)
var (
headingColor = color.New(color.Bold, color.FgCyan)
labelColor = color.New(color.Bold, color.FgBlue)
okColor = color.New(color.Bold, color.FgGreen)
warnColor = color.New(color.Bold, color.FgYellow)
failColor = color.New(color.Bold, color.FgRed)
mutedColor = color.New(color.Faint)
)
func Heading(text string) string {
return headingColor.Sprint(text)
}
func Label(text string) string {
return labelColor.Sprint(text)
}
func OK(text string) string {
return okColor.Sprint(text)
}
func Warning(text string) string {
return warnColor.Sprint(text)
}
func Fail(text string) string {
return failColor.Sprint(text)
}
func Muted(text string) string {
return mutedColor.Sprint(text)
}
func Successf(format string, args ...any) {
_, err := fmt.Fprintln(color.Output, OK(fmt.Sprintf(format, args...)))
if err != nil {
return // TODO Handle this error at some point, even if redundant
}
}
func Errorf(format string, args ...any) {
_, err := fmt.Fprintln(color.Error, Fail(fmt.Sprintf(format, args...)))
if err != nil {
return // TODO Handle this error at some point, even if redundant
}
}
func Println(text string) {
_, err := fmt.Fprintln(color.Output, text)
if err != nil {
return // TODO Handle this error at some point, even if redundant
}
}
func Stderr(text string) {
_, err := fmt.Fprintln(os.Stderr, text)
if err != nil {
return // TODO Handle this error at some point, even if redundant
}
}
func StatusLabel(ok bool) string {
if ok {
return OK("OK")
}
return Fail("FAIL")
}
package admincmd
import (
"fmt"
"strings"
"github.com/sphireinc/foundry/internal/admin/users"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
)
type command struct{}
func (command) Name() string { return "admin" }
func (command) Summary() string { return "Admin auth helpers" }
func (command) Group() string { return "utility" }
func (command) RequiresConfig() bool { return false }
func (command) Details() []string {
return []string{
"foundry admin hash-password <password>",
"foundry admin sample-user <username> <name> <email> <password>",
}
}
func (command) Run(_ *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry admin [hash-password|sample-user] ...")
}
switch strings.TrimSpace(args[2]) {
case "hash-password":
if len(args) != 4 {
return fmt.Errorf("usage: foundry admin hash-password <password>")
}
hash, err := users.HashPassword(args[3])
if err != nil {
return err
}
fmt.Println(hash)
return nil
case "sample-user":
if len(args) != 7 {
return fmt.Errorf("usage: foundry admin sample-user <username> <name> <email> <password>")
}
hash, err := users.HashPassword(args[6])
if err != nil {
return err
}
fmt.Printf("users:\n - username: %s\n name: %s\n email: %s\n role: admin\n password_hash: %s\n", args[3], args[4], args[5], hash)
return nil
default:
return fmt.Errorf("unknown admin subcommand: %s", args[2])
}
}
func init() {
registry.Register(command{})
}
package assetscmd
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/assets"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
)
type command struct{}
func (command) Name() string {
return "assets"
}
func (command) Summary() string {
return "Build and inspect assets"
}
func (command) Group() string {
return "asset commands"
}
func (command) Details() []string {
return []string{
"foundry assets build",
"foundry assets clean",
"foundry assets list",
}
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry assets [build|clean|list]")
}
switch args[2] {
case "build":
if err := assets.Sync(cfg, nil); err != nil {
return err
}
fmt.Println("assets built")
return nil
case "clean":
targets := []string{
filepath.Join(cfg.PublicDir, "assets"),
filepath.Join(cfg.PublicDir, "images"),
filepath.Join(cfg.PublicDir, "uploads"),
filepath.Join(cfg.PublicDir, "theme"),
filepath.Join(cfg.PublicDir, "plugins"),
}
for _, target := range targets {
if _, err := os.Stat(target); err != nil {
if os.IsNotExist(err) {
continue
}
return err
}
if err := os.RemoveAll(target); err != nil {
return err
}
fmt.Printf("removed %s\n", target)
}
return nil
case "list":
return listAssets(cfg)
}
return fmt.Errorf("unknown assets subcommand: %s", args[2])
}
func listAssets(cfg *config.Config) error {
type row struct {
Kind string
Path string
}
rows := make([]row, 0)
addFiles := func(kind, root string) error {
info, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if !info.IsDir() {
return nil
}
return filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.IsDir() {
return nil
}
rows = append(rows, row{
Kind: kind,
Path: filepath.ToSlash(path),
})
return nil
})
}
if err := addFiles("content-assets", filepath.Join(cfg.ContentDir, cfg.Content.AssetsDir)); err != nil {
return err
}
if err := addFiles("content-images", filepath.Join(cfg.ContentDir, cfg.Content.ImagesDir)); err != nil {
return err
}
if err := addFiles("content-uploads", filepath.Join(cfg.ContentDir, cfg.Content.UploadsDir)); err != nil {
return err
}
if err := addFiles("theme-assets", filepath.Join(cfg.ThemesDir, cfg.Theme, "assets")); err != nil {
return err
}
for _, pluginName := range cfg.Plugins.Enabled {
pluginName = strings.TrimSpace(pluginName)
if pluginName == "" {
continue
}
if err := addFiles("plugin-assets", filepath.Join(cfg.PluginsDir, pluginName, "assets")); err != nil {
return err
}
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Kind != rows[j].Kind {
return rows[i].Kind < rows[j].Kind
}
return rows[i].Path < rows[j].Path
})
if len(rows) == 0 {
fmt.Println("no assets found")
return nil
}
kindWidth := len("KIND")
for _, row := range rows {
if len(row.Kind) > kindWidth {
kindWidth = len(row.Kind)
}
}
fmt.Printf("%-*s %s\n", kindWidth, "KIND", "PATH")
for _, row := range rows {
fmt.Printf("%-*s %s\n", kindWidth, row.Kind, row.Path)
}
fmt.Println("")
fmt.Printf("%d asset file(s)\n", len(rows))
return nil
}
func init() {
registry.Register(command{})
}
package backupcmd
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/sphireinc/foundry/internal/backup"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
)
type command struct{}
func init() {
registry.Register(command{})
}
func (command) Name() string { return "backup" }
func (command) Summary() string { return "Create and inspect content backups" }
func (command) Group() string { return "backup commands" }
func (command) Details() []string {
return []string{
"foundry backup create [target.zip]",
"foundry backup list",
"foundry backup git-snapshot [message] [--push]",
"foundry backup git-log [limit]",
}
}
func (command) RequiresConfig() bool { return true }
func (command) Run(cfg *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry backup [create|list|git-snapshot|git-log]")
}
switch strings.TrimSpace(args[2]) {
case "create":
return runCreate(cfg, args)
case "list":
return runList(cfg)
case "git-snapshot":
return runGitSnapshot(cfg, args)
case "git-log":
return runGitLog(cfg, args)
default:
return fmt.Errorf("unknown backup subcommand: %s", args[2])
}
}
func runCreate(cfg *config.Config, args []string) error {
var (
snapshot *backup.Snapshot
err error
)
if len(args) >= 4 && strings.TrimSpace(args[3]) != "" {
target := strings.TrimSpace(args[3])
if filepath.Ext(target) == "" {
target += ".zip"
}
snapshot, err = backup.CreateZipSnapshot(cfg, target)
} else {
snapshot, err = backup.CreateManagedSnapshot(cfg)
}
if err != nil {
return err
}
cliout.Successf("Backup created")
fmt.Printf("%s %s\n", cliout.Label("Path:"), snapshot.Path)
fmt.Printf("%s %d bytes\n", cliout.Label("Archive:"), snapshot.SizeBytes)
fmt.Printf("%s %d bytes\n", cliout.Label("Source:"), snapshot.SourceBytes)
fmt.Printf("%s %s\n", cliout.Label("Created:"), snapshot.CreatedAt.Format(time.RFC3339))
return nil
}
func runList(cfg *config.Config) error {
items, err := backup.List(cfg.Backup.Dir)
if err != nil {
return err
}
if len(items) == 0 {
fmt.Println("No backups found.")
return nil
}
for _, item := range items {
fmt.Printf("- %s (%d bytes, %s)\n", item.Path, item.SizeBytes, item.CreatedAt.Format(time.RFC3339))
}
return nil
}
func runGitSnapshot(cfg *config.Config, args []string) error {
message := ""
push := false
for _, arg := range args[3:] {
switch strings.TrimSpace(arg) {
case "":
case "--push":
push = true
default:
if message == "" {
message = strings.TrimSpace(arg)
continue
}
return fmt.Errorf("usage: foundry backup git-snapshot [message] [--push]")
}
}
snapshot, err := backup.CreateGitSnapshot(cfg, message, push)
if err != nil {
return err
}
if !snapshot.Changed {
cliout.Successf("No content changes to snapshot")
} else {
cliout.Successf("Git snapshot created")
}
fmt.Printf("%s %s\n", cliout.Label("Repo:"), snapshot.RepoDir)
fmt.Printf("%s %s\n", cliout.Label("Revision:"), snapshot.Revision)
if snapshot.RemoteURL != "" {
fmt.Printf("%s %s\n", cliout.Label("Remote:"), snapshot.RemoteURL)
fmt.Printf("%s %s\n", cliout.Label("Branch:"), snapshot.Branch)
fmt.Printf("%s %t\n", cliout.Label("Pushed:"), snapshot.Pushed)
}
fmt.Printf("%s %s\n", cliout.Label("Created:"), snapshot.CreatedAt.Format(time.RFC3339))
return nil
}
func runGitLog(cfg *config.Config, args []string) error {
limit := 10
if len(args) >= 4 {
if parsed, err := strconv.Atoi(strings.TrimSpace(args[3])); err == nil && parsed > 0 {
limit = parsed
}
}
items, err := backup.ListGitSnapshots(cfg, limit)
if err != nil {
return err
}
if len(items) == 0 {
fmt.Println("No git snapshots found.")
return nil
}
for _, item := range items {
fmt.Printf("- %s %s %s\n", item.Revision, item.CreatedAt.Format(time.RFC3339), item.Message)
}
return nil
}
package clean
import (
"fmt"
"os"
"path/filepath"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
)
type command struct{}
func (command) Name() string {
return "clean"
}
func (command) Summary() string {
return "Remove generated build artifacts"
}
func (command) Group() string {
return "core commands"
}
func (command) Details() []string {
return nil
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *config.Config, _ []string) error {
paths := []string{
cfg.PublicDir,
"bin",
"tmp",
}
for _, p := range paths {
p = filepath.Clean(p)
if p == "." || p == "/" || p == "" {
return fmt.Errorf("refusing to clean unsafe path: %q", p)
}
if _, err := os.Stat(p); err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("stat %s: %w", p, err)
}
if err := os.RemoveAll(p); err != nil {
return fmt.Errorf("remove %s: %w", p, err)
}
fmt.Printf("removed %s\n", p)
}
return nil
}
func init() {
registry.Register(command{})
}
package configcmd
import (
"fmt"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/commands/registry"
foundryconfig "github.com/sphireinc/foundry/internal/config"
)
type command struct{}
func (command) Name() string {
return "config"
}
func (command) Summary() string {
return "Validate site configuration"
}
func (command) Group() string {
return "config commands"
}
func (command) Details() []string {
return []string{
"foundry config check",
}
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *foundryconfig.Config, args []string) error {
if len(args) < 3 || args[2] != "check" {
return fmt.Errorf("usage: foundry config check")
}
errs := foundryconfig.Validate(cfg)
if len(errs) == 0 {
cliout.Successf("config OK")
return nil
}
cliout.Println(cliout.Fail("config check failed:"))
for _, err := range errs {
fmt.Printf("- %v\n", err)
}
return fmt.Errorf("config validation failed with %d error(s)", len(errs))
}
func init() {
registry.Register(command{})
}
package contentcmd
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/backup"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/site"
"gopkg.in/yaml.v3"
)
type command struct{}
type contentRow struct {
Type string
Lang string
Title string
Slug string
URL string
Draft bool
Source string
}
func (command) Name() string {
return "content"
}
func (command) Summary() string {
return "Manage and inspect content"
}
func (command) Group() string {
return "content commands"
}
func (command) Details() []string {
return []string{
"foundry content lint",
"foundry content new page <slug>",
"foundry content new post <slug>",
"foundry content list",
"foundry content graph",
"foundry content export <bundle.zip>",
"foundry content import markdown <dir>",
"foundry content import wordpress <wxr.xml>",
"foundry content migrate layout <from> <to>",
"foundry content migrate field-rename <schema> <old> <new>",
}
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry content [lint|new|list|graph|export|import|migrate]")
}
switch args[2] {
case "lint":
return runLint(cfg)
case "new":
return runNew(cfg, args)
case "list":
return runList(cfg)
case "graph":
return runGraph(cfg)
case "export":
return runExport(cfg, args)
case "import":
return runImport(cfg, args)
case "migrate":
return runMigrate(cfg, args)
default:
return fmt.Errorf("unknown content subcommand: %s", args[2])
}
}
func runLint(cfg *config.Config) error {
graph, err := loadGraph(cfg, true)
if err != nil {
return err
}
errCount := 0
seenSource := make(map[string]struct{})
seenSlugByTypeLang := make(map[string]string)
for _, doc := range graph.Documents {
if strings.TrimSpace(doc.Title) == "" {
fmt.Printf("missing title: %s\n", doc.SourcePath)
errCount++
}
if strings.TrimSpace(doc.Slug) == "" {
fmt.Printf("missing slug: %s\n", doc.SourcePath)
errCount++
}
if strings.TrimSpace(doc.Layout) == "" {
fmt.Printf("missing layout: %s\n", doc.SourcePath)
errCount++
}
if strings.TrimSpace(doc.Type) == "" {
fmt.Printf("missing type: %s\n", doc.SourcePath)
errCount++
}
if strings.TrimSpace(doc.Lang) == "" {
fmt.Printf("missing lang: %s\n", doc.SourcePath)
errCount++
}
if strings.TrimSpace(doc.URL) == "" {
fmt.Printf("missing URL: %s\n", doc.SourcePath)
errCount++
}
if _, ok := seenSource[doc.SourcePath]; ok {
fmt.Printf("duplicate source path: %s\n", doc.SourcePath)
errCount++
}
seenSource[doc.SourcePath] = struct{}{}
key := doc.Type + "|" + doc.Lang + "|" + doc.Slug
if other, ok := seenSlugByTypeLang[key]; ok {
fmt.Printf("duplicate slug within type/lang %q for %s and %s\n", key, other, doc.SourcePath)
errCount++
} else {
seenSlugByTypeLang[key] = doc.SourcePath
}
}
if errCount > 0 {
return fmt.Errorf("content lint failed with %d error(s)", errCount)
}
fmt.Printf("content lint OK (%d document(s))\n", len(graph.Documents))
return nil
}
func runNew(cfg *config.Config, args []string) error {
if len(args) < 5 {
return fmt.Errorf("usage: foundry content new [page|post] <slug>")
}
kind := strings.TrimSpace(args[3])
slug := normalizeSlug(args[4])
if slug == "" {
return fmt.Errorf("slug must not be empty")
}
var path string
switch kind {
case "page":
path = filepath.Join(cfg.ContentDir, cfg.Content.PagesDir, slug+".md")
case "post":
path = filepath.Join(cfg.ContentDir, cfg.Content.PostsDir, slug+".md")
default:
return fmt.Errorf("unknown content type: %s", kind)
}
body, err := content.BuildNewContent(cfg, kind, slug)
if err != nil {
return err
}
return writeNewContentFile(path, body)
}
func runList(cfg *config.Config) error {
graph, err := loadGraph(cfg, true)
if err != nil {
return err
}
rows := make([]contentRow, 0, len(graph.Documents))
for _, doc := range graph.Documents {
rows = append(rows, contentRow{
Type: doc.Type,
Lang: doc.Lang,
Title: doc.Title,
Slug: doc.Slug,
URL: doc.URL,
Draft: doc.Draft,
Source: doc.SourcePath,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Type != rows[j].Type {
return rows[i].Type < rows[j].Type
}
if rows[i].Lang != rows[j].Lang {
return rows[i].Lang < rows[j].Lang
}
if rows[i].URL != rows[j].URL {
return rows[i].URL < rows[j].URL
}
return rows[i].Source < rows[j].Source
})
typeWidth := len("TYPE")
langWidth := len("LANG")
slugWidth := len("SLUG")
draftWidth := len("DRAFT")
for _, row := range rows {
if len(row.Type) > typeWidth {
typeWidth = len(row.Type)
}
if len(row.Lang) > langWidth {
langWidth = len(row.Lang)
}
if len(row.Slug) > slugWidth {
slugWidth = len(row.Slug)
}
}
fmt.Printf("%-*s %-*s %-*s %-*s %s\n",
typeWidth, "TYPE",
langWidth, "LANG",
slugWidth, "SLUG",
draftWidth, "DRAFT",
"TITLE",
)
for _, row := range rows {
draft := "false"
if row.Draft {
draft = "true"
}
fmt.Printf("%-*s %-*s %-*s %-*s %s\n",
typeWidth, row.Type,
langWidth, row.Lang,
slugWidth, row.Slug,
draftWidth, draft,
row.Title,
)
}
fmt.Println("")
fmt.Printf("%d document(s)\n", len(rows))
return nil
}
func runGraph(cfg *config.Config) error {
graph, err := loadGraph(cfg, true)
if err != nil {
return err
}
fmt.Println("Site graph")
fmt.Println("----------")
fmt.Printf("documents: %d\n", len(graph.Documents))
fmt.Printf("urls: %d\n", len(graph.ByURL))
fmt.Printf("languages: %d\n", len(graph.ByLang))
fmt.Printf("types: %d\n", len(graph.ByType))
fmt.Println("")
fmt.Println("By language:")
langs := sortedKeysDocs(graph.ByLang)
for _, lang := range langs {
fmt.Printf("- %s: %d\n", lang, len(graph.ByLang[lang]))
}
fmt.Println("")
fmt.Println("By type:")
types := sortedKeysDocs(graph.ByType)
for _, typ := range types {
fmt.Printf("- %s: %d\n", typ, len(graph.ByType[typ]))
}
fmt.Println("")
if graph.Taxonomies.Values != nil && len(graph.Taxonomies.Values) > 0 {
fmt.Println("Taxonomies:")
for _, name := range graph.Taxonomies.OrderedNames() {
def := graph.Taxonomies.Definition(name)
terms := graph.Taxonomies.Values[name]
fmt.Printf("- %s (%s): %d term(s)\n", name, def.DisplayTitle(cfg.DefaultLang), len(terms))
for _, term := range graph.Taxonomies.OrderedTerms(name) {
fmt.Printf(" - %s: %d document(s)\n", term, len(terms[term]))
}
}
fmt.Println("")
}
fmt.Println("Documents:")
rows := make([]contentRow, 0, len(graph.Documents))
for _, doc := range graph.Documents {
rows = append(rows, contentRow{
Type: doc.Type,
Lang: doc.Lang,
Title: doc.Title,
Slug: doc.Slug,
URL: doc.URL,
Draft: doc.Draft,
Source: doc.SourcePath,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].URL != rows[j].URL {
return rows[i].URL < rows[j].URL
}
return rows[i].Source < rows[j].Source
})
for _, row := range rows {
fmt.Printf("- %s [%s/%s] %s\n", row.URL, row.Type, row.Lang, row.Source)
}
return nil
}
func runExport(cfg *config.Config, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry content export <bundle.zip>")
}
target := strings.TrimSpace(args[3])
if target == "" {
return fmt.Errorf("bundle path must not be empty")
}
if _, err := backup.CreateZipSnapshot(cfg, target); err != nil {
return err
}
fmt.Printf("exported content bundle to %s\n", target)
return nil
}
func runImport(cfg *config.Config, args []string) error {
if len(args) < 5 {
return fmt.Errorf("usage: foundry content import [markdown|wordpress] <source>")
}
switch strings.TrimSpace(args[3]) {
case "markdown":
return importMarkdownTree(cfg, strings.TrimSpace(args[4]))
case "wordpress":
return importWordPress(cfg, strings.TrimSpace(args[4]))
default:
return fmt.Errorf("unknown import type: %s", args[3])
}
}
func runMigrate(cfg *config.Config, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry content migrate [layout|field-rename] ...")
}
switch strings.TrimSpace(args[3]) {
case "layout":
if len(args) < 6 {
return fmt.Errorf("usage: foundry content migrate layout <from> <to> [--dry-run]")
}
return migrateLayouts(cfg, strings.TrimSpace(args[4]), strings.TrimSpace(args[5]), hasDryRunFlag(args[6:]))
case "field-rename":
if len(args) < 7 {
return fmt.Errorf("usage: foundry content migrate field-rename <schema> <old> <new> [--dry-run]")
}
return migrateFieldRename(cfg, strings.TrimSpace(args[4]), strings.TrimSpace(args[5]), strings.TrimSpace(args[6]), hasDryRunFlag(args[7:]))
default:
return fmt.Errorf("unknown migrate target: %s", args[3])
}
}
func hasDryRunFlag(args []string) bool {
for _, arg := range args {
if strings.TrimSpace(arg) == "--dry-run" {
return true
}
}
return false
}
func loadGraph(cfg *config.Config, includeDrafts bool) (*content.SiteGraph, error) {
graph, _, err := site.LoadConfiguredGraph(context.Background(), cfg, includeDrafts)
if err != nil {
return nil, err
}
return graph, nil
}
func importMarkdownTree(cfg *config.Config, source string) error {
return filepath.Walk(source, func(current string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info == nil || info.IsDir() {
return nil
}
if strings.ToLower(filepath.Ext(current)) != ".md" {
return nil
}
rel, err := filepath.Rel(source, current)
if err != nil {
return err
}
targetRoot := filepath.Join(cfg.ContentDir, cfg.Content.PagesDir)
if strings.Contains(strings.ToLower(filepath.ToSlash(rel)), "/posts/") || strings.HasPrefix(strings.ToLower(filepath.ToSlash(rel)), "posts/") {
targetRoot = filepath.Join(cfg.ContentDir, cfg.Content.PostsDir)
rel = path.Base(filepath.ToSlash(rel))
}
target := filepath.Join(targetRoot, filepath.Base(rel))
return copyFile(current, target)
})
}
var (
wpItemRE = regexp.MustCompile(`(?s)<item>(.*?)</item>`)
wpTitleRE = regexp.MustCompile(`(?s)<title>(.*?)</title>`)
wpSlugRE = regexp.MustCompile(`(?s)<wp:post_name>(.*?)</wp:post_name>`)
wpTypeRE = regexp.MustCompile(`(?s)<wp:post_type>(.*?)</wp:post_type>`)
wpStatusRE = regexp.MustCompile(`(?s)<wp:status>(.*?)</wp:status>`)
wpContentRE = regexp.MustCompile(`(?s)<content:encoded><!\\[CDATA\\[(.*?)\\]\\]></content:encoded>`)
wpDateRE = regexp.MustCompile(`(?s)<wp:post_date>(.*?)</wp:post_date>`)
xmlTagStripRE = regexp.MustCompile(`<[^>]+>`)
)
func importWordPress(cfg *config.Config, source string) error {
body, err := os.ReadFile(source)
if err != nil {
return err
}
items := wpItemRE.FindAllStringSubmatch(string(body), -1)
for _, item := range items {
if len(item) != 2 {
continue
}
kind := strings.TrimSpace(extractMatch(wpTypeRE, item[1]))
if kind != "post" && kind != "page" {
continue
}
slug := normalizeSlug(strings.TrimSpace(extractMatch(wpSlugRE, item[1])))
if slug == "" {
slug = normalizeSlug(strings.TrimSpace(extractMatch(wpTitleRE, item[1])))
}
if slug == "" {
continue
}
title := strings.TrimSpace(htmlUnescape(extractMatch(wpTitleRE, item[1])))
status := strings.TrimSpace(extractMatch(wpStatusRE, item[1]))
contentBody := htmlUnescape(extractMatch(wpContentRE, item[1]))
date := strings.TrimSpace(extractMatch(wpDateRE, item[1]))
targetDir := filepath.Join(cfg.ContentDir, cfg.Content.PagesDir)
layout := cfg.Content.DefaultLayoutPage
if kind == "post" {
targetDir = filepath.Join(cfg.ContentDir, cfg.Content.PostsDir)
layout = cfg.Content.DefaultLayoutPost
}
draft := "false"
if status != "publish" {
draft = "true"
}
frontmatter := fmt.Sprintf("---\ntitle: %s\nslug: %s\nlayout: %s\ndraft: %s\n", yamlScalar(title), slug, layout, draft)
if date != "" && kind == "post" {
frontmatter += fmt.Sprintf("date: %s\n", strings.Split(date, " ")[0])
}
frontmatter += "---\n\n" + strings.TrimSpace(xmlTagStripRE.ReplaceAllString(contentBody, "")) + "\n"
if err := writeImportedContentFile(filepath.Join(targetDir, slug+".md"), frontmatter); err != nil {
return err
}
}
return nil
}
func migrateLayouts(cfg *config.Config, from, to string, dryRun bool) error {
return rewriteMarkdownFiles(cfg, func(path string, fm *content.FrontMatter, body string) (string, bool, error) {
if fm == nil || strings.TrimSpace(fm.Layout) != from {
return "", false, nil
}
fm.Layout = to
return marshalFrontMatter(fm, body)
}, dryRun)
}
func migrateFieldRename(cfg *config.Config, schema, from, to string, dryRun bool) error {
return rewriteMarkdownFiles(cfg, func(path string, fm *content.FrontMatter, body string) (string, bool, error) {
if fm == nil || fm.Fields == nil || strings.TrimSpace(from) == "" || strings.TrimSpace(to) == "" {
return "", false, nil
}
if schema != "" {
if value, ok := fm.Params["schema"].(string); !ok || strings.TrimSpace(value) != schema {
return "", false, nil
}
}
value, ok := fm.Fields[from]
if !ok {
return "", false, nil
}
delete(fm.Fields, from)
fm.Fields[to] = value
return marshalFrontMatter(fm, body)
}, dryRun)
}
func rewriteMarkdownFiles(cfg *config.Config, rewrite func(path string, fm *content.FrontMatter, body string) (string, bool, error), dryRun bool) error {
for _, root := range []string{
filepath.Join(cfg.ContentDir, cfg.Content.PagesDir),
filepath.Join(cfg.ContentDir, cfg.Content.PostsDir),
} {
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info == nil || info.IsDir() || strings.ToLower(filepath.Ext(path)) != ".md" {
return err
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
fm, body, err := content.ParseDocument(src)
if err != nil {
return err
}
updated, changed, err := rewrite(path, fm, body)
if err != nil || !changed {
return err
}
if dryRun {
fmt.Printf("would update %s\n", path)
return nil
}
return os.WriteFile(path, []byte(updated), 0o644)
}); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func marshalFrontMatter(fm *content.FrontMatter, body string) (string, bool, error) {
out, err := yaml.Marshal(fm)
if err != nil {
return "", false, err
}
return "---\n" + string(out) + "---\n\n" + strings.TrimLeft(body, "\n"), true, nil
}
func copyFile(source, target string) error {
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
out, err := os.Create(target)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func writeImportedContentFile(path, body string) error {
if _, err := os.Stat(path); err == nil {
return nil
}
return writeNewContentFile(path, body)
}
func extractMatch(re *regexp.Regexp, body string) string {
match := re.FindStringSubmatch(body)
if len(match) != 2 {
return ""
}
return strings.TrimSpace(match[1])
}
func htmlUnescape(value string) string {
replacer := strings.NewReplacer("<", "<", ">", ">", "&", "&", """, `"`, "'", "'")
return replacer.Replace(strings.TrimSpace(value))
}
func yamlScalar(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return `""`
}
out, err := yaml.Marshal(value)
if err != nil {
return `""`
}
return strings.TrimSpace(string(out))
}
func normalizeSlug(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
s = strings.ReplaceAll(s, " ", "-")
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
s = strings.Trim(s, "-")
return s
}
func writeNewContentFile(path, body string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("path must not be empty")
}
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("file already exists: %s", path)
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
return err
}
fmt.Printf("created %s\n", path)
return nil
}
func sortedKeysDocs[T any](m map[string][]T) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func init() {
registry.Register(command{})
}
package debug
import (
"fmt"
"reflect"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/debugutil"
"github.com/sphireinc/foundry/internal/plugins"
"gopkg.in/yaml.v3"
)
type command struct{}
func (command) Name() string {
return "debug"
}
func (command) Summary() string {
return "Show internal diagnostic information"
}
func (command) Group() string {
return "debug commands"
}
func (command) Details() []string {
return []string{
"foundry debug routes",
"foundry debug plugins",
"foundry debug config",
}
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry debug [routes|plugins|config]")
}
switch args[2] {
case "routes":
return runRoutes(cfg)
case "plugins":
return runPlugins(cfg)
case "config":
return runConfig(cfg)
default:
return fmt.Errorf("unknown debug subcommand: %s", args[2])
}
}
func runRoutes(cfg *config.Config) error {
graph, err := debugutil.LoadGraph(cfg)
if err != nil {
return err
}
type row struct {
URL string
Type string
Lang string
Slug string
Layout string
Title string
Source string
}
rows := make([]row, 0, len(graph.Documents))
for _, doc := range graph.Documents {
rows = append(rows, row{
URL: doc.URL,
Type: doc.Type,
Lang: doc.Lang,
Slug: doc.Slug,
Layout: doc.Layout,
Title: doc.Title,
Source: doc.SourcePath,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].URL != rows[j].URL {
return rows[i].URL < rows[j].URL
}
return rows[i].Source < rows[j].Source
})
urlWidth := len("URL")
typeWidth := len("TYPE")
langWidth := len("LANG")
slugWidth := len("SLUG")
layoutWidth := len("LAYOUT")
for _, row := range rows {
if len(row.URL) > urlWidth {
urlWidth = len(row.URL)
}
if len(row.Type) > typeWidth {
typeWidth = len(row.Type)
}
if len(row.Lang) > langWidth {
langWidth = len(row.Lang)
}
if len(row.Slug) > slugWidth {
slugWidth = len(row.Slug)
}
if len(row.Layout) > layoutWidth {
layoutWidth = len(row.Layout)
}
}
fmt.Printf("%-*s %-*s %-*s %-*s %-*s %s\n",
urlWidth, "URL",
typeWidth, "TYPE",
langWidth, "LANG",
slugWidth, "SLUG",
layoutWidth, "LAYOUT",
"SOURCE",
)
for _, row := range rows {
fmt.Printf("%-*s %-*s %-*s %-*s %-*s %s\n",
urlWidth, row.URL,
typeWidth, row.Type,
langWidth, row.Lang,
slugWidth, row.Slug,
layoutWidth, row.Layout,
row.Source,
)
}
fmt.Println("")
fmt.Printf("documents: %d\n", len(graph.Documents))
fmt.Printf("languages: %d\n", len(graph.ByLang))
fmt.Printf("types: %d\n", len(graph.ByType))
fmt.Printf("urls: %d\n", len(graph.ByURL))
return nil
}
func runPlugins(cfg *config.Config) error {
pm, err := plugins.NewManager(cfg.PluginsDir, cfg.Plugins.Enabled)
if err != nil {
return err
}
metas := pm.MetadataList()
if len(metas) == 0 {
fmt.Println("no enabled plugins")
return nil
}
pluginInstances := pm.Plugins()
pluginTypes := make(map[string]string, len(pluginInstances))
pluginHooks := make(map[string][]string, len(pluginInstances))
for _, p := range pluginInstances {
name := p.Name()
pluginTypes[name] = reflect.TypeOf(p).String()
pluginHooks[name] = debugutil.DetectHooks(p)
}
for _, meta := range metas {
fmt.Printf("Name: %s\n", meta.Name)
fmt.Printf("Title: %s\n", meta.Title)
fmt.Printf("Version: %s\n", meta.Version)
fmt.Printf("Directory: %s\n", meta.Directory)
fmt.Printf("Repo: %s\n", meta.Repo)
if t := pluginTypes[meta.Name]; t != "" {
fmt.Printf("Type: %s\n", t)
}
if hooks := pluginHooks[meta.Name]; len(hooks) > 0 {
fmt.Printf("Hooks: %s\n", strings.Join(hooks, ", "))
} else {
fmt.Printf("Hooks: none detected\n")
}
if len(meta.Requires) > 0 {
fmt.Println("Requires:")
for _, dep := range meta.Requires {
fmt.Printf(" - %s\n", dep)
}
}
fmt.Println("")
}
fmt.Printf("enabled plugins: %d\n", len(metas))
return nil
}
func runConfig(cfg *config.Config) error {
b, err := yaml.Marshal(cfg)
if err != nil {
return err
}
fmt.Print(string(b))
return nil
}
func init() {
registry.Register(command{})
}
package depscmd
import (
"context"
"fmt"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/deps"
"github.com/sphireinc/foundry/internal/site"
)
type command struct{}
func (command) Name() string {
return "deps"
}
func (command) Summary() string {
return "Inspect dependency and rebuild relationships"
}
func (command) Group() string {
return "dependency commands"
}
func (command) Details() []string {
return []string{
"foundry deps graph",
"foundry deps explain <url>",
}
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry deps [graph|explain]")
}
switch args[2] {
case "graph":
return runGraph(cfg)
case "explain":
if len(args) < 4 {
return fmt.Errorf("usage: foundry deps explain <url>")
}
return runExplain(cfg, args[3])
default:
return fmt.Errorf("unknown deps subcommand: %s", args[2])
}
}
func runGraph(cfg *config.Config) error {
graph, depGraph, err := loadGraphs(cfg)
if err != nil {
return err
}
nodes := depGraph.Nodes()
edges := depGraph.Edges()
fmt.Println("Dependency graph")
fmt.Println("----------------")
fmt.Printf("documents: %d\n", len(graph.Documents))
fmt.Printf("nodes: %d\n", len(nodes))
fmt.Printf("edges: %d\n", len(edges))
fmt.Println("")
fmt.Println("Nodes:")
for _, node := range nodes {
fmt.Printf("- %s [%s]", node.ID, node.Type)
if len(node.Meta) > 0 {
fmt.Printf(" %s", formatMeta(node.Meta))
}
fmt.Println("")
}
fmt.Println("")
fmt.Println("Edges:")
for _, edge := range edges {
fmt.Printf("- %s -> %s\n", edge.From, edge.To)
}
return nil
}
func runExplain(cfg *config.Config, targetURL string) error {
graph, depGraph, err := loadGraphs(cfg)
if err != nil {
return err
}
targetURL = strings.TrimSpace(targetURL)
if targetURL == "" {
return fmt.Errorf("url must not be empty")
}
if !strings.HasPrefix(targetURL, "/") {
targetURL = "/" + targetURL
}
if _, exists := graph.ByURL[targetURL]; !exists {
return fmt.Errorf("route not found: %s", targetURL)
}
outputID := "output:" + targetURL
node, ok := depGraph.Node(outputID)
if !ok {
return fmt.Errorf("dependency node not found for route: %s", targetURL)
}
fmt.Printf("Explain %s\n", targetURL)
fmt.Println("")
fmt.Printf("Node ID: %s\n", node.ID)
fmt.Printf("Type: %s\n", node.Type)
if len(node.Meta) > 0 {
fmt.Printf("Meta: %s\n", formatMeta(node.Meta))
}
fmt.Println("")
dependencies := depGraph.DependenciesOf(outputID)
directDependents := depGraph.DirectDependentsOf(outputID)
transitiveDependents := depGraph.DependentsOf(outputID)
if len(dependencies) > 0 {
fmt.Println("Depends on:")
for _, dep := range dependencies {
printNode(depGraph, dep)
}
fmt.Println("")
}
if len(directDependents) > 0 {
fmt.Println("Direct dependents:")
for _, dep := range directDependents {
printNode(depGraph, dep)
}
fmt.Println("")
}
if len(transitiveDependents) > 0 {
fmt.Println("Transitive dependents:")
for _, dep := range transitiveDependents {
printNode(depGraph, dep)
}
fmt.Println("")
}
fmt.Println("This route is itself a rebuild target when its inputs change.")
return nil
}
func loadGraphs(cfg *config.Config) (*content.SiteGraph, *deps.Graph, error) {
graph, _, err := site.LoadConfiguredGraph(context.Background(), cfg, true)
if err != nil {
return nil, nil, err
}
depGraph := deps.BuildSiteDependencyGraph(graph, cfg.Theme)
return graph, depGraph, nil
}
func printNode(g *deps.Graph, id string) {
if n, ok := g.Node(id); ok {
fmt.Printf("- %s [%s]", n.ID, n.Type)
if len(n.Meta) > 0 {
fmt.Printf(" %s", formatMeta(n.Meta))
}
fmt.Println("")
return
}
fmt.Printf("- %s\n", id)
}
func formatMeta(meta map[string]any) string {
if len(meta) == 0 {
return ""
}
keys := make([]string, 0, len(meta))
for k := range meta {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%v", k, meta[k]))
}
return "{" + strings.Join(parts, ", ") + "}"
}
func init() {
registry.Register(command{})
}
package doctor
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
adminusers "github.com/sphireinc/foundry/internal/admin/users"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/commands/registry"
foundryconfig "github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/ops"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/renderer"
"github.com/sphireinc/foundry/internal/router"
"github.com/sphireinc/foundry/internal/theme"
)
type command struct{}
func (command) Name() string {
return "doctor"
}
func (command) Summary() string {
return "Check project and environment health"
}
func (command) Group() string {
return "core commands"
}
func (command) Details() []string {
return nil
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *foundryconfig.Config, _ []string) error {
type result struct {
label string
ok bool
msg string
}
results := make([]result, 0)
failures := 0
add := func(label string, ok bool, msg string) {
results = append(results, result{
label: label,
ok: ok,
msg: msg,
})
if !ok {
failures++
}
}
if errs := foundryconfig.Validate(cfg); len(errs) == 0 {
add("config", true, "valid")
} else {
add("config", false, fmt.Sprintf("%d validation error(s)", len(errs)))
}
checkDir := func(label, path string) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
add(label, false, fmt.Sprintf("%s does not exist", path))
return
}
add(label, false, err.Error())
return
}
if !info.IsDir() {
add(label, false, fmt.Sprintf("%s is not a directory", path))
return
}
add(label, true, path)
}
checkDir("content_dir", cfg.ContentDir)
checkDir("themes_dir", cfg.ThemesDir)
checkDir("data_dir", cfg.DataDir)
checkDir("plugins_dir", cfg.PluginsDir)
if err := theme.NewManager(cfg.ThemesDir, cfg.Theme).MustExist(); err != nil {
add("theme", false, err.Error())
} else {
add("theme", true, cfg.Theme)
}
if _, err := plugins.NewManager(cfg.PluginsDir, cfg.Plugins.Enabled); err != nil {
add("plugins", false, err.Error())
} else {
add("plugins", true, fmt.Sprintf("%d enabled", len(cfg.Plugins.Enabled)))
}
pmStart := time.Now()
pm, err := plugins.NewManager(cfg.PluginsDir, cfg.Plugins.Enabled)
if err == nil {
err = pm.OnConfigLoaded(cfg)
}
pluginTiming := time.Since(pmStart)
if err != nil {
add("timing.plugin_config", false, err.Error())
} else {
add("timing.plugin_config", true, pluginTiming.String())
tempCfg := *cfg
tempCfg.PublicDir = filepath.Join(cfg.DataDir, ".doctor-public")
loader := content.NewLoader(&tempCfg, pm, true)
resolver := router.NewResolver(&tempCfg)
graph, timing, graphErr := ops.LoadGraphWithTiming(context.Background(), loader, resolver, pm)
if graphErr != nil {
add("timing.loader_router", false, graphErr.Error())
} else {
add("timing.loader", true, timing.Loader.String())
add("timing.router", true, timing.Router.String())
add("timing.route_hooks", true, timing.RouteHooks.String())
rendererEngine := renderer.New(&tempCfg, theme.NewManager(tempCfg.ThemesDir, tempCfg.Theme), pm)
renderMetrics, renderErr := ops.BuildRendererWithTiming(context.Background(), rendererEngine, graph)
if renderErr != nil {
add("timing.renderer", false, renderErr.Error())
} else {
add("timing.assets", true, renderMetrics.Assets.String())
add("timing.renderer", true, renderMetrics.Renderer.String())
}
feedMetrics, feedErr := ops.BuildFeedsWithTiming(&tempCfg, graph)
if feedErr != nil {
add("timing.feed", false, feedErr.Error())
} else {
add("timing.feed", true, feedMetrics.Feed.String())
}
report := ops.AnalyzeSite(&tempCfg, graph)
add("diagnostics.broken_links", len(report.BrokenInternalLinks) == 0, fmt.Sprintf("%d issue(s)", len(report.BrokenInternalLinks)))
add("diagnostics.broken_media", len(report.BrokenMediaRefs) == 0, fmt.Sprintf("%d issue(s)", len(report.BrokenMediaRefs)))
add("diagnostics.templates", len(report.MissingTemplates) == 0, fmt.Sprintf("%d issue(s)", len(report.MissingTemplates)))
add("diagnostics.orphaned_media", len(report.OrphanedMedia) == 0, fmt.Sprintf("%d issue(s)", len(report.OrphanedMedia)))
add("diagnostics.duplicate_routes", len(report.DuplicateURLs) == 0, fmt.Sprintf("%d issue(s)", len(report.DuplicateURLs)))
add("diagnostics.duplicate_slugs", len(report.DuplicateSlugs) == 0, fmt.Sprintf("%d issue(s)", len(report.DuplicateSlugs)))
add("diagnostics.taxonomies", len(report.TaxonomyInconsistency) == 0, fmt.Sprintf("%d issue(s)", len(report.TaxonomyInconsistency)))
}
}
genPath := filepath.Join("internal", "generated", "plugins_gen.go")
if _, err := os.Stat(genPath); err != nil {
if os.IsNotExist(err) {
add("plugin_sync", false, genPath+" not found")
} else {
add("plugin_sync", false, err.Error())
}
} else {
add("plugin_sync", true, genPath)
}
if cfg.Admin.Enabled {
userEntries, userErr := adminusers.Load(cfg.Admin.UsersFile)
if userErr != nil {
add("auth.users_file", false, userErr.Error())
} else {
legacyPBKDF2 := 0
plaintextTOTP := 0
for _, user := range userEntries {
if strings.HasPrefix(strings.TrimSpace(user.PasswordHash), "pbkdf2_sha256$") {
legacyPBKDF2++
}
secret := strings.TrimSpace(user.TOTPSecret)
if secret != "" && !strings.HasPrefix(secret, "enc:v1:") {
plaintextTOTP++
}
}
add("auth.legacy_password_hashes", legacyPBKDF2 == 0, fmt.Sprintf("%d remaining", legacyPBKDF2))
add("auth.plaintext_totp", plaintextTOTP == 0, fmt.Sprintf("%d remaining", plaintextTOTP))
}
sessionFile, sessionErr := os.ReadFile(cfg.Admin.SessionStoreFile)
if sessionErr != nil && !os.IsNotExist(sessionErr) {
add("auth.session_store", false, sessionErr.Error())
} else {
legacySessions := strings.Count(string(sessionFile), "\n - token:")
add("auth.legacy_sessions", legacySessions == 0, fmt.Sprintf("%d remaining", legacySessions))
}
}
for _, r := range results {
fmt.Printf("[%s] %-20s %s\n", cliout.StatusLabel(r.ok), cliout.Label(r.label), r.msg)
}
if failures > 0 {
return fmt.Errorf("doctor found %d problem(s)", failures)
}
cliout.Successf("doctor OK")
return nil
}
func init() {
registry.Register(command{})
}
package feedcmd
import (
"context"
"encoding/xml"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/feed"
"github.com/sphireinc/foundry/internal/site"
)
type command struct{}
type rss struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Channel rssChannel `xml:"channel"`
}
type rssChannel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Items []rssItem `xml:"item"`
}
type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description,omitempty"`
PubDate string `xml:"pubDate,omitempty"`
}
type sitemapURLSet struct {
XMLName xml.Name `xml:"urlset"`
Xmlns string `xml:"xmlns,attr"`
URLs []sitemapURL `xml:"url"`
}
type sitemapURL struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
}
func (command) Name() string {
return "feed"
}
func (command) Summary() string {
return "Build and validate RSS and sitemap output"
}
func (command) Group() string {
return "feed commands"
}
func (command) Details() []string {
return []string{
"foundry feed build",
"foundry feed validate",
}
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry feed [build|validate]")
}
switch args[2] {
case "build":
return runBuild(cfg)
case "validate":
return runValidate(cfg)
}
return fmt.Errorf("unknown feed subcommand: %s", args[2])
}
func runBuild(cfg *config.Config) error {
graph, err := loadGraph(cfg)
if err != nil {
return err
}
rssXML, sitemapXML, err := feed.Build(cfg, graph)
if err != nil {
return err
}
rssTarget := filepath.Join(cfg.PublicDir, strings.TrimPrefix(cfg.Feed.RSSPath, "/"))
sitemapTarget := filepath.Join(cfg.PublicDir, strings.TrimPrefix(cfg.Feed.SitemapPath, "/"))
if err := os.MkdirAll(filepath.Dir(rssTarget), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(sitemapTarget), 0o755); err != nil {
return err
}
if err := os.WriteFile(rssTarget, rssXML, 0o644); err != nil {
return err
}
if err := os.WriteFile(sitemapTarget, sitemapXML, 0o644); err != nil {
return err
}
fmt.Printf("wrote %s\n", rssTarget)
fmt.Printf("wrote %s\n", sitemapTarget)
return nil
}
func runValidate(cfg *config.Config) error {
graph, err := loadGraph(cfg)
if err != nil {
return err
}
rssXML, sitemapXML, err := feed.Build(cfg, graph)
if err != nil {
return err
}
var rssDoc rss
if err := xml.Unmarshal(rssXML, &rssDoc); err != nil {
return fmt.Errorf("rss validation failed: %w", err)
}
var sitemapDoc sitemapURLSet
if err := xml.Unmarshal(sitemapXML, &sitemapDoc); err != nil {
return fmt.Errorf("sitemap validation failed: %w", err)
}
fmt.Printf("feed validation OK (rss items: %d, sitemap urls: %d)\n", len(rssDoc.Channel.Items), len(sitemapDoc.URLs))
return nil
}
func loadGraph(cfg *config.Config) (*content.SiteGraph, error) {
graph, _, err := site.LoadConfiguredGraph(context.Background(), cfg, false)
if err != nil {
return nil, err
}
return graph, nil
}
func init() {
registry.Register(command{})
}
package i18ncmd
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/site"
)
type command struct{}
func (command) Name() string {
return "i18n"
}
func (command) Summary() string {
return "Inspect and scaffold translated content"
}
func (command) Group() string {
return "i18n commands"
}
func (command) Details() []string {
return []string{
"foundry i18n list",
"foundry i18n missing",
"foundry i18n scaffold <lang> <type> <slug>",
}
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry i18n [list|missing|scaffold]")
}
switch args[2] {
case "list":
return runList(cfg)
case "missing":
return runMissing(cfg)
case "scaffold":
return runScaffold(cfg, args)
}
return fmt.Errorf("unknown i18n subcommand: %s", args[2])
}
func runList(cfg *config.Config) error {
graph, err := loadGraph(cfg)
if err != nil {
return err
}
langs := make([]string, 0, len(graph.ByLang))
for lang := range graph.ByLang {
langs = append(langs, lang)
}
sort.Strings(langs)
if len(langs) == 0 {
fmt.Println("no languages found")
return nil
}
for _, lang := range langs {
fmt.Printf("%s (%d)\n", lang, len(graph.ByLang[lang]))
}
return nil
}
func runMissing(cfg *config.Config) error {
graph, err := loadGraph(cfg)
if err != nil {
return err
}
defaultLang := cfg.DefaultLang
if strings.TrimSpace(defaultLang) == "" {
return fmt.Errorf("default language is empty")
}
defaultDocs := make(map[string]*content.Document)
for _, doc := range graph.Documents {
if doc.Lang != defaultLang {
continue
}
key := doc.Type + "|" + doc.Slug
defaultDocs[key] = doc
}
langs := make([]string, 0, len(graph.ByLang))
for lang := range graph.ByLang {
if lang == defaultLang {
continue
}
langs = append(langs, lang)
}
sort.Strings(langs)
missingCount := 0
for _, lang := range langs {
existing := make(map[string]struct{})
for _, doc := range graph.ByLang[lang] {
key := doc.Type + "|" + doc.Slug
existing[key] = struct{}{}
}
for key, doc := range defaultDocs {
if _, ok := existing[key]; ok {
continue
}
fmt.Printf("missing %s translation for [%s] %s (%s)\n", lang, doc.Type, doc.Slug, doc.SourcePath)
missingCount++
}
}
if missingCount == 0 {
fmt.Println("no missing translations")
return nil
}
return fmt.Errorf("found %d missing translation(s)", missingCount)
}
func runScaffold(cfg *config.Config, args []string) error {
if len(args) < 6 {
return fmt.Errorf("usage: foundry i18n scaffold <lang> <type> <slug>")
}
lang := strings.TrimSpace(args[3])
typ := strings.TrimSpace(args[4])
slug := strings.TrimSpace(args[5])
if lang == "" || typ == "" || slug == "" {
return fmt.Errorf("lang, type, and slug must not be empty")
}
if lang == cfg.DefaultLang {
return fmt.Errorf("scaffold language must not be the default language")
}
if typ != "page" && typ != "post" {
return fmt.Errorf("type must be page or post")
}
srcPath := defaultLanguagePath(cfg, typ, slug)
body, err := os.ReadFile(srcPath)
if err != nil {
return fmt.Errorf("read source document %s: %w", srcPath, err)
}
dstPath := translatedPath(cfg, lang, typ, slug)
if _, err := os.Stat(dstPath); err == nil {
return fmt.Errorf("translated file already exists: %s", dstPath)
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(dstPath, body, 0o644); err != nil {
return err
}
fmt.Printf("created %s\n", dstPath)
return nil
}
func loadGraph(cfg *config.Config) (*content.SiteGraph, error) {
graph, _, err := site.LoadConfiguredGraph(context.Background(), cfg, true)
if err != nil {
return nil, err
}
return graph, nil
}
func defaultLanguagePath(cfg *config.Config, typ, slug string) string {
switch typ {
case "page":
return filepath.Join(cfg.ContentDir, cfg.Content.PagesDir, slug+".md")
default:
return filepath.Join(cfg.ContentDir, cfg.Content.PostsDir, slug+".md")
}
}
func translatedPath(cfg *config.Config, lang, typ, slug string) string {
switch typ {
case "page":
return filepath.Join(cfg.ContentDir, cfg.Content.PagesDir, lang, slug+".md")
default:
return filepath.Join(cfg.ContentDir, cfg.Content.PostsDir, lang, slug+".md")
}
}
func init() {
registry.Register(command{})
}
package plugin
import (
"fmt"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/plugins"
"gopkg.in/yaml.v3"
)
type command struct{}
func (command) Name() string {
return "plugin"
}
func (command) Summary() string {
return "Manage plugins"
}
func (command) Group() string {
return "plugin commands"
}
func (command) Details() []string {
return []string{
"foundry plugin list --installed",
"foundry plugin list --enabled",
"foundry plugin info <name>",
"foundry plugin install <git-url|owner/repo> [name] [--approve-risk]",
"foundry plugin uninstall <name>",
"foundry plugin enable <name> [--approve-risk]",
"foundry plugin disable <name>",
"foundry plugin validate [<name>] [--security] [--strict-security]",
"foundry plugin deps <name>",
"foundry plugin update <name> [--approve-risk]",
"foundry plugin sync",
"foundry plugin security <name>",
}
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry plugin [list|info|install|uninstall|enable|disable|validate|deps|update|sync|security]")
}
project := plugins.NewProject(
consts.ConfigFilePath,
cfg.PluginsDir,
consts.GeneratedPluginsFile,
plugins.DefaultSyncModulePath,
)
switch args[2] {
case "list":
return runList(cfg, project, args)
case "info":
return runInfo(project, args)
case "install":
return runInstall(cfg, project, args)
case "uninstall":
return runUninstall(project, args)
case "enable":
return runEnable(project, args)
case "disable":
return runDisable(project, args)
case "validate":
return runValidate(cfg, project, args)
case "deps":
return runDeps(cfg, project, args)
case "update":
return runUpdate(project, args)
case "sync":
if err := project.Sync(); err != nil {
return err
}
cliout.Successf("plugin imports synced")
return nil
case "security":
return runSecurity(project, args)
}
return fmt.Errorf("unknown plugin subcommand: %s", args[2])
}
func runList(cfg *config.Config, project plugins.Project, args []string) error {
mode := "--enabled"
if len(args) >= 4 {
mode = args[3]
}
switch mode {
case "--enabled":
return printEnabledPluginTable(cfg, project)
case "--installed":
metas, err := project.ListInstalled()
if err != nil {
return err
}
return printInstalledPluginTable(metas)
default:
return fmt.Errorf("usage: foundry plugin list [--installed|--enabled]")
}
}
func runInfo(project plugins.Project, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry plugin info <name>")
}
meta, err := project.Metadata(args[3])
if err != nil {
return err
}
fmt.Printf("Name: %s\n", meta.Name)
fmt.Printf("Title: %s\n", meta.Title)
fmt.Printf("Version: %s\n", meta.Version)
fmt.Printf("Foundry API: %s\n", meta.FoundryAPI)
fmt.Printf("Min Foundry: %s\n", meta.MinFoundryVersion)
fmt.Printf("Description: %s\n", meta.Description)
fmt.Printf("Author: %s\n", meta.Author)
fmt.Printf("Homepage: %s\n", meta.Homepage)
fmt.Printf("License: %s\n", meta.License)
fmt.Printf("Directory: %s\n", meta.Directory)
fmt.Printf("Repo: %s\n", meta.Repo)
if len(meta.Requires) > 0 {
fmt.Println("Requires:")
for _, dep := range meta.Requires {
fmt.Printf(" - %s\n", dep)
}
}
fmt.Printf("Risk tier: %s\n", meta.Permissions.RiskTier())
fmt.Printf("Approval: %t\n", meta.Permissions.Capabilities.RequiresAdminApproval)
return nil
}
func runSecurity(project plugins.Project, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry plugin security <name>")
}
name := strings.TrimSpace(args[3])
meta, err := project.Metadata(name)
if err != nil {
return err
}
report, err := project.SecurityReport(name)
if err != nil {
return err
}
fmt.Printf("Name: %s\n", meta.Name)
fmt.Printf("Title: %s\n", meta.Title)
fmt.Printf("Risk tier: %s\n", report.RiskTier)
fmt.Printf("Approval: %t\n", report.RequiresApproval)
fmt.Printf("Runtime: %s\n", plugins.RuntimeModeLabel(report.Runtime.Mode))
fmt.Println("Summary:")
for _, item := range report.Summary {
fmt.Printf(" - %s\n", item)
}
if len(report.Findings) > 0 {
fmt.Println("Detected capabilities:")
for _, finding := range report.Findings {
fmt.Printf(" - %s: %s (%s, %s)\n", finding.Category, finding.Evidence, finding.Path, finding.EvidenceType)
}
}
if len(report.Mismatches) > 0 {
fmt.Println("Security mismatches:")
for _, diag := range report.Mismatches {
fmt.Printf(" - %s\n", diag.Message)
}
}
fmt.Println("Permissions:")
body, err := yaml.Marshal(report.DeclaredPermissions)
if err != nil {
return err
}
fmt.Print(string(body))
if len(report.Effective.DeniedReasons) > 0 {
fmt.Println("Enforcement:")
for _, reason := range report.Effective.DeniedReasons {
fmt.Printf(" - %s\n", reason)
}
}
return nil
}
func runInstall(cfg *config.Config, project plugins.Project, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry plugin install <git-url|owner/repo> [name]")
}
repoURL := strings.TrimSpace(args[3])
name := ""
if len(args) >= 5 {
name = strings.TrimSpace(args[4])
}
meta, err := project.Install(repoURL, name, hasApproveRiskFlag(args[3:]))
if err != nil {
return err
}
cliout.Successf("Installed plugin: %s", meta.Name)
fmt.Printf("%s %s\n", cliout.Label("Directory:"), meta.Directory)
fmt.Printf("%s %s\n", cliout.Label("Version:"), meta.Version)
missing, warnErr := project.MissingDependencies(meta, cfg.Plugins.Enabled)
if warnErr != nil {
return warnErr
}
if len(missing) > 0 {
fmt.Println("")
cliout.Println(cliout.Warning("Dependency warnings:"))
for _, dep := range missing {
if dep.Installed {
fmt.Printf("- Required plugin repo %s is installed as %q but not enabled\n", dep.Repo, dep.Name)
} else {
fmt.Printf("- Missing required plugin repo: %s\n", dep.Repo)
}
}
fmt.Println("")
cliout.Println(cliout.Heading("Suggested next steps:"))
for _, dep := range missing {
if dep.Installed {
fmt.Printf("Add %q to %s under plugins.enabled\n", dep.Name, consts.ConfigFilePath)
} else {
fmt.Printf("foundry plugin install %s\n", dep.Repo)
}
}
}
fmt.Println("")
cliout.Println(cliout.Heading("Next steps:"))
if len(missing) > 0 {
fmt.Println("1. Resolve the dependency warnings above")
fmt.Printf("2. Add %q to %s under plugins.enabled\n", meta.Name, consts.ConfigFilePath)
fmt.Println("3. Run foundry plugin sync")
fmt.Println("4. Run foundry build or foundry serve")
} else {
fmt.Printf("1. Add %q to %s under plugins.enabled\n", meta.Name, consts.ConfigFilePath)
fmt.Println("2. Run foundry plugin sync")
fmt.Println("3. Run foundry build or foundry serve")
}
return nil
}
func runUninstall(project plugins.Project, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry plugin uninstall <name>")
}
name := strings.TrimSpace(args[3])
if err := project.Uninstall(name); err != nil {
return err
}
cliout.Successf("Uninstalled plugin: %s", name)
fmt.Println("")
cliout.Println(cliout.Heading("Next steps:"))
fmt.Printf("1. Remove %q from %s under plugins.enabled\n", name, consts.ConfigFilePath)
fmt.Println("2. Run foundry plugin sync")
fmt.Println("3. Run foundry build or foundry serve")
return nil
}
func runEnable(project plugins.Project, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry plugin enable <name>")
}
name := strings.TrimSpace(args[3])
if err := project.Enable(name, hasApproveRiskFlag(args[3:])); err != nil {
return err
}
cliout.Successf("Enabled plugin: %s", name)
cliout.Println(cliout.Heading("Next steps:"))
fmt.Println("1. Run foundry plugin sync")
fmt.Println("2. Run foundry build or foundry serve")
return nil
}
func runDisable(project plugins.Project, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry plugin disable <name>")
}
name := strings.TrimSpace(args[3])
if err := project.Disable(name); err != nil {
return err
}
cliout.Successf("Disabled plugin: %s", name)
cliout.Println(cliout.Heading("Next steps:"))
fmt.Println("1. Run foundry plugin sync")
fmt.Println("2. Run foundry build or foundry serve")
return nil
}
func runValidate(cfg *config.Config, project plugins.Project, args []string) error {
securityMode := hasFlag(args[3:], "--security")
strictSecurity := hasFlag(args[3:], "--strict-security")
names := positionalArgs(args[3:])
if len(names) >= 1 {
name := strings.TrimSpace(names[0])
if err := project.Validate(name); err != nil {
return err
}
if securityMode || strictSecurity {
meta, err := project.Metadata(name)
if err != nil {
return err
}
report := plugins.AnalyzeInstalled(meta)
printSecurityValidation(name, report)
if strictSecurity && (!report.Effective.Allowed || len(report.Mismatches) > 0) {
return fmt.Errorf("plugin security validation failed for %s", name)
}
}
cliout.Println(cliout.Heading("Plugin validation"))
fmt.Println("")
fmt.Println("Legend:")
fmt.Printf(" %s valid and loadable\n", cliout.OK("OK"))
fmt.Printf(" %s invalid or not loadable\n", cliout.Fail("FAIL"))
fmt.Println("")
fmt.Printf("[%s] %s\n", cliout.OK("OK"), name)
return nil
}
report := project.ValidateEnabled(cfg.Plugins.Enabled)
cliout.Println(cliout.Heading("Plugin validation"))
fmt.Println("")
fmt.Println("Legend:")
fmt.Printf(" %s valid and loadable\n", cliout.OK("OK"))
fmt.Printf(" %s invalid or not loadable\n", cliout.Fail("FAIL"))
fmt.Println("")
for _, name := range report.Passed {
fmt.Printf("[%s] %s\n", cliout.OK("OK"), name)
}
for _, issue := range report.Issues {
fmt.Printf("[%s] %s\n", cliout.Fail("FAIL"), issue.String())
}
if securityMode || strictSecurity {
fmt.Println("")
cliout.Println(cliout.Heading("Security validation"))
for _, name := range cfg.Plugins.Enabled {
meta, err := project.Metadata(name)
if err != nil {
fmt.Printf("[%s] %s: %v\n", cliout.Fail("FAIL"), name, err)
continue
}
security := plugins.AnalyzeInstalled(meta)
printSecurityValidation(name, security)
if strictSecurity && (!security.Effective.Allowed || len(security.Mismatches) > 0) {
report.Issues = append(report.Issues, plugins.ValidationIssue{Name: name, Status: "security invalid", Err: fmt.Errorf("security mismatches detected")})
}
}
}
if len(report.Issues) == 0 {
fmt.Printf("\n%s %d enabled plugin(s) are valid\n", cliout.OK("All"), len(report.Passed))
return nil
}
return fmt.Errorf("plugin validation failed with %d issue(s)", len(report.Issues))
}
func runDeps(cfg *config.Config, project plugins.Project, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry plugin deps <name>")
}
name := strings.TrimSpace(args[3])
statuses, err := project.DependencyStatuses(name, cfg.Plugins.Enabled)
if err != nil {
return err
}
if len(statuses) == 0 {
fmt.Printf("Plugin %q has no declared dependencies\n", name)
return nil
}
fmt.Printf("Dependencies for %q:\n", name)
for _, dep := range statuses {
switch dep.Status {
case "enabled":
fmt.Printf("- %s [enabled as %s]\n", dep.Repo, dep.Name)
case "installed":
fmt.Printf("- %s [installed as %s, not enabled]\n", dep.Repo, dep.Name)
default:
fmt.Printf("- %s [missing]\n", dep.Repo)
}
}
return nil
}
func runUpdate(project plugins.Project, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry plugin update <name>")
}
name := strings.TrimSpace(args[3])
meta, err := project.Update(name, hasApproveRiskFlag(args[3:]))
if err != nil {
return err
}
fmt.Printf("Updated plugin: %s\n", meta.Name)
fmt.Printf("Directory: %s\n", meta.Directory)
fmt.Printf("Version: %s\n", meta.Version)
return nil
}
func printEnabledPluginTable(cfg *config.Config, project plugins.Project) error {
names := make([]string, 0, len(cfg.Plugins.Enabled))
seen := make(map[string]struct{}, len(cfg.Plugins.Enabled))
for _, name := range cfg.Plugins.Enabled {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
sort.Strings(names)
statuses := project.EnabledStatuses(names)
type row struct {
Name string
Status string
Version string
Title string
}
rows := make([]row, 0, len(names))
nameWidth := len("NAME")
statusWidth := len("STATUS")
versionWidth := len("VERSION")
for _, name := range names {
status := statuses[name]
if status == "" {
status = "enabled"
}
title := "-"
version := "-"
meta, err := project.Metadata(name)
if err == nil {
if strings.TrimSpace(meta.Title) != "" {
title = meta.Title
}
if strings.TrimSpace(meta.Version) != "" {
version = meta.Version
}
}
rows = append(rows, row{
Name: name,
Status: status,
Version: version,
Title: title,
})
if len(name) > nameWidth {
nameWidth = len(name)
}
if len(status) > statusWidth {
statusWidth = len(status)
}
if len(version) > versionWidth {
versionWidth = len(version)
}
}
fmt.Printf("%-*s %-*s %-*s %s\n", nameWidth, "NAME", statusWidth, "STATUS", versionWidth, "VERSION", "TITLE")
for _, row := range rows {
fmt.Printf("%-*s %-*s %-*s %s\n", nameWidth, row.Name, statusWidth, row.Status, versionWidth, row.Version, row.Title)
}
return nil
}
func printInstalledPluginTable(metas []plugins.Metadata) error {
nameWidth := len("NAME")
versionWidth := len("VERSION")
apiWidth := len("API")
for _, meta := range metas {
if len(meta.Name) > nameWidth {
nameWidth = len(meta.Name)
}
if len(meta.Version) > versionWidth {
versionWidth = len(meta.Version)
}
if len(meta.FoundryAPI) > apiWidth {
apiWidth = len(meta.FoundryAPI)
}
}
fmt.Printf("%-*s %-*s %-*s %s\n", nameWidth, "NAME", versionWidth, "VERSION", apiWidth, "API", "TITLE")
for _, meta := range metas {
fmt.Printf("%-*s %-*s %-*s %s\n", nameWidth, meta.Name, versionWidth, meta.Version, apiWidth, meta.FoundryAPI, meta.Title)
}
return nil
}
func hasApproveRiskFlag(args []string) bool {
return hasFlag(args, "--approve-risk")
}
func hasFlag(args []string, want string) bool {
for _, arg := range args {
if strings.TrimSpace(arg) == want {
return true
}
}
return false
}
func positionalArgs(args []string) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
arg = strings.TrimSpace(arg)
if arg == "" || strings.HasPrefix(arg, "--") {
continue
}
out = append(out, arg)
}
return out
}
func printSecurityValidation(name string, report plugins.SecurityReport) {
status := cliout.OK("OK")
if !report.Effective.Allowed || len(report.Mismatches) > 0 {
status = cliout.Fail("FAIL")
}
fmt.Printf("[%s] %s (%s)\n", status, name, report.RiskTier)
for _, mismatch := range report.Mismatches {
fmt.Printf(" - %s\n", mismatch.Message)
}
for _, reason := range report.Effective.DeniedReasons {
fmt.Printf(" - %s\n", reason)
}
}
func init() {
registry.Register(command{})
}
package registry
import (
"fmt"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/config"
)
// Command is the common interface implemented by all Foundry CLI commands.
type Command interface {
Name() string
Summary() string
Group() string
Details() []string
RequiresConfig() bool
Run(cfg *config.Config, args []string) error
}
// Info is the serializable command metadata used to build usage output.
type Info struct {
Name string
Summary string
Group string
Details []string
RequiresConfig bool
}
var commands = map[string]Command{}
// Register adds a command to the global CLI registry.
//
// Registration is expected to happen from package init functions and panics on
// programmer errors such as duplicate names.
func Register(cmd Command) {
if cmd == nil || cmd.Name() == "" {
panic("commands: invalid command registration")
}
if _, exists := commands[cmd.Name()]; exists {
panic("commands: duplicate command registration: " + cmd.Name())
}
commands[cmd.Name()] = cmd
}
// Lookup resolves a command from os.Args-style input.
func Lookup(args []string) (Command, bool) {
if len(args) < 2 {
return nil, false
}
cmd, ok := commands[args[1]]
return cmd, ok
}
// Run looks up and executes a registered command.
//
// The boolean result reports whether a matching command was found.
func Run(cfg *config.Config, args []string) (bool, error) {
cmd, ok := Lookup(args)
if !ok {
return false, nil
}
return true, cmd.Run(cfg, args)
}
// List returns all registered commands sorted by group and name.
func List() []Info {
out := make([]Info, 0, len(commands))
for _, cmd := range commands {
out = append(out, Info{
Name: cmd.Name(),
Summary: cmd.Summary(),
Group: cmd.Group(),
Details: cmd.Details(),
RequiresConfig: cmd.RequiresConfig(),
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Group != out[j].Group {
return out[i].Group < out[j].Group
}
return out[i].Name < out[j].Name
})
return out
}
// Usage renders grouped CLI usage text for all registered commands.
func Usage() string {
items := List()
if len(items) == 0 {
return "usage: foundry <command>"
}
grouped := make(map[string][]Info)
groups := make([]string, 0)
for _, item := range items {
group := item.Group
if group == "" {
group = "commands"
}
if _, ok := grouped[group]; !ok {
groups = append(groups, group)
}
grouped[group] = append(grouped[group], item)
}
sort.Strings(groups)
nameWidth := len("COMMAND")
for _, item := range items {
if len(item.Name) > nameWidth {
nameWidth = len(item.Name)
}
}
var sb strings.Builder
sb.WriteString(cliout.Heading("usage:"))
sb.WriteString(" foundry <command>\n")
for _, group := range groups {
sb.WriteString("\n")
sb.WriteString(cliout.Heading(group))
sb.WriteString(":\n")
for _, item := range grouped[group] {
sb.WriteString(fmt.Sprintf(" %-*s %s\n", nameWidth, cliout.Label(item.Name), item.Summary))
for _, detail := range item.Details {
sb.WriteString(fmt.Sprintf(" %-*s %s\n", nameWidth, "", detail))
}
}
}
return strings.TrimRight(sb.String(), "\n")
}
package releasecmd
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
)
var versionPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
type command struct{}
func init() {
registry.Register(command{})
}
func (command) Name() string { return "release" }
func (command) Summary() string { return "Cut a Foundry release tag" }
func (command) Group() string { return "runtime" }
func (command) RequiresConfig() bool { return false }
func (command) Details() []string {
return []string{
"foundry release cut v1.3.3",
"foundry release cut v1.3.3 --push",
}
}
func (command) Run(_ *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry release cut <vX.Y.Z> [--push]")
}
switch strings.TrimSpace(args[2]) {
case "cut":
return runCut(args)
default:
return fmt.Errorf("unknown release subcommand: %s", args[2])
}
}
func runCut(args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry release cut <vX.Y.Z> [--push]")
}
version := strings.TrimSpace(args[3])
if !versionPattern.MatchString(version) {
return fmt.Errorf("release version must match vX.Y.Z, got %q", version)
}
push := false
for _, arg := range args[4:] {
switch strings.TrimSpace(arg) {
case "--push":
push = true
case "":
default:
return fmt.Errorf("unknown release flag: %s", arg)
}
}
if err := requireGit(); err != nil {
return err
}
if err := requireRepoRoot(); err != nil {
return err
}
if err := requireCleanWorktree(); err != nil {
return err
}
if changed, err := updateEmbeddedReleaseVersion(version); err != nil {
return err
} else if changed {
if err := runGit("add", "internal/commands/version/release_version.go"); err != nil {
return err
}
if err := runGit("commit", "-m", "release: embed "+version); err != nil {
return err
}
cliout.Successf("Committed embedded release version %s", version)
}
if err := ensureTagAbsent(version); err != nil {
return err
}
message := "Foundry " + version
if err := runGit("tag", "-a", version, "-m", message); err != nil {
return err
}
cliout.Successf("Created release tag %s", version)
fmt.Printf("%s %s\n", cliout.Label("Message:"), message)
if push {
if err := runGit("push", "origin", "HEAD"); err != nil {
return err
}
if err := runGit("push", "origin", version); err != nil {
return err
}
cliout.Successf("Pushed release tag %s", version)
fmt.Println("GitHub Actions will now build and publish the release assets from the tagged commit.")
return nil
}
fmt.Println("Next step:")
fmt.Println(" git push origin HEAD")
fmt.Printf(" git push origin %s\n", version)
fmt.Println("Those pushes will trigger the GitHub Release workflow and upload the packaged artifacts.")
return nil
}
func requireGit() error {
if _, err := exec.LookPath("git"); err != nil {
return fmt.Errorf("git is required to cut a release")
}
return nil
}
func requireRepoRoot() error {
out, err := output("git", "rev-parse", "--show-toplevel")
if err != nil {
return fmt.Errorf("not inside a git repository")
}
wd, err := os.Getwd()
if err != nil {
return err
}
if filepathClean(out) != filepathClean(wd) {
return fmt.Errorf("run release cut from the repository root: %s", out)
}
return nil
}
func requireCleanWorktree() error {
out, err := output("git", "status", "--porcelain")
if err != nil {
return err
}
if strings.TrimSpace(out) != "" {
return fmt.Errorf("git worktree is not clean; commit or stash changes before cutting a release")
}
return nil
}
func ensureTagAbsent(version string) error {
out, err := output("git", "tag", "--list", version)
if err != nil {
return err
}
if strings.TrimSpace(out) != "" {
return fmt.Errorf("git tag %s already exists", version)
}
return nil
}
func runGit(args ...string) error {
cmd := exec.Command("git", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func output(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
message := strings.TrimSpace(stderr.String())
if message == "" {
return "", err
}
return "", fmt.Errorf("%s %s: %s", name, strings.Join(args, " "), message)
}
return strings.TrimSpace(stdout.String()), nil
}
func filepathClean(value string) string {
return strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
}
func updateEmbeddedReleaseVersion(version string) (bool, error) {
const target = "internal/commands/version/release_version.go"
body := "package version\n\n" +
"// ReleaseVersion is the repo-carried release fallback used when a build does\n" +
"// not have Git metadata available, such as container builds from a source\n" +
"// archive or a Docker context without .git.\n" +
fmt.Sprintf("const ReleaseVersion = %q\n", version)
current, err := os.ReadFile(target)
if err == nil && string(current) == body {
return false, nil
}
if err != nil && !os.IsNotExist(err) {
return false, err
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return false, err
}
if err := os.WriteFile(target, []byte(body), 0o644); err != nil {
return false, err
}
return true, nil
}
package routes
import (
"context"
"fmt"
"sort"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/site"
)
type command struct{}
type routeRow struct {
URL string
Type string
Lang string
Title string
Source string
}
func (command) Name() string {
return "routes"
}
func (command) Summary() string {
return "List and validate generated routes"
}
func (command) Group() string {
return "routing commands"
}
func (command) Details() []string {
return []string{
"foundry routes list",
"foundry routes check",
}
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry routes [list|check]")
}
switch args[2] {
case "list":
return runList(cfg)
case "check":
return runCheck(cfg)
default:
return fmt.Errorf("unknown routes subcommand: %s", args[2])
}
}
func runList(cfg *config.Config) error {
graph, err := loadGraph(cfg)
if err != nil {
return err
}
rows := make([]routeRow, 0, len(graph.Documents))
for _, doc := range graph.Documents {
rows = append(rows, routeRow{
URL: doc.URL,
Type: doc.Type,
Lang: doc.Lang,
Title: doc.Title,
Source: doc.SourcePath,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].URL != rows[j].URL {
return rows[i].URL < rows[j].URL
}
if rows[i].Lang != rows[j].Lang {
return rows[i].Lang < rows[j].Lang
}
return rows[i].Source < rows[j].Source
})
urlWidth := len("URL")
typeWidth := len("TYPE")
langWidth := len("LANG")
for _, row := range rows {
if len(row.URL) > urlWidth {
urlWidth = len(row.URL)
}
if len(row.Type) > typeWidth {
typeWidth = len(row.Type)
}
if len(row.Lang) > langWidth {
langWidth = len(row.Lang)
}
}
fmt.Printf("%-*s %-*s %-*s %s\n", urlWidth, "URL", typeWidth, "TYPE", langWidth, "LANG", "TITLE")
for _, row := range rows {
fmt.Printf("%-*s %-*s %-*s %s\n", urlWidth, row.URL, typeWidth, row.Type, langWidth, row.Lang, row.Title)
}
fmt.Println("")
fmt.Printf("%d route(s)\n", len(rows))
return nil
}
func runCheck(cfg *config.Config) error {
graph, err := loadGraph(cfg)
if err != nil {
return err
}
errCount := 0
seen := make(map[string]string)
for _, doc := range graph.Documents {
if doc.URL == "" {
fmt.Printf("empty URL: %s\n", doc.SourcePath)
errCount++
continue
}
if other, ok := seen[doc.URL]; ok {
fmt.Printf("duplicate URL %s for %s and %s\n", doc.URL, other, doc.SourcePath)
errCount++
continue
}
seen[doc.URL] = doc.SourcePath
}
if errCount > 0 {
return fmt.Errorf("route check failed with %d error(s)", errCount)
}
fmt.Printf("route check OK (%d route(s))\n", len(graph.Documents))
return nil
}
func loadGraph(cfg *config.Config) (*content.SiteGraph, error) {
graph, _, err := site.LoadConfiguredGraph(context.Background(), cfg, true)
if err != nil {
return nil, err
}
return graph, nil
}
func init() {
registry.Register(command{})
}
package servicecmd
import (
"fmt"
"os"
"strings"
"time"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/hostservice"
)
type command struct{}
func init() {
registry.Register(command{})
}
func (command) Name() string { return "service" }
func (command) Summary() string { return "Install and manage Foundry as an OS service" }
func (command) Group() string { return "runtime" }
func (command) Details() []string {
return []string{
"foundry service install",
"foundry service start",
"foundry service stop",
"foundry service restart",
"foundry service status",
"foundry service uninstall",
}
}
func (command) RequiresConfig() bool { return false }
func (command) Run(_ *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry service [install|start|stop|restart|status|uninstall]")
}
projectDir, err := os.Getwd()
if err != nil {
return err
}
switch strings.TrimSpace(args[2]) {
case "install":
meta, err := hostservice.Install(projectDir)
if err != nil {
return err
}
cliout.Successf("Foundry service installed")
fmt.Printf("%s %s\n", cliout.Label("Platform:"), meta.Platform)
fmt.Printf("%s %s\n", cliout.Label("Name:"), meta.Name)
fmt.Printf("%s %s\n", cliout.Label("Service file:"), meta.ServicePath)
fmt.Printf("%s %s\n", cliout.Label("Log:"), meta.LogPath)
if meta.Platform == "linux" {
fmt.Println("Note: for restart-on-boot after logout, your Linux user may need lingering enabled via `loginctl enable-linger $USER`.")
}
return nil
case "start":
if err := hostservice.Start(projectDir); err != nil {
return err
}
cliout.Successf("Foundry service started")
return nil
case "stop":
if err := hostservice.Stop(projectDir); err != nil {
return err
}
cliout.Successf("Foundry service stopped")
return nil
case "restart":
if err := hostservice.Restart(projectDir); err != nil {
return err
}
cliout.Successf("Foundry service restarted")
return nil
case "status":
status, err := hostservice.CheckStatus(projectDir)
if err != nil {
return err
}
fmt.Println(status.Message)
if status.Metadata != nil {
fmt.Printf("%s %s\n", cliout.Label("Platform:"), status.Metadata.Platform)
fmt.Printf("%s %s\n", cliout.Label("Name:"), status.Metadata.Name)
fmt.Printf("%s %v\n", cliout.Label("Installed:"), status.Installed)
fmt.Printf("%s %v\n", cliout.Label("Running:"), status.Running)
fmt.Printf("%s %v\n", cliout.Label("Enabled:"), status.Enabled)
fmt.Printf("%s %s\n", cliout.Label("Service file:"), status.Metadata.ServicePath)
fmt.Printf("%s %s\n", cliout.Label("Log:"), status.Metadata.LogPath)
if !status.Metadata.InstalledAt.IsZero() {
fmt.Printf("%s %s\n", cliout.Label("Installed at:"), status.Metadata.InstalledAt.Local().Format(time.RFC3339))
}
}
return nil
case "uninstall":
if err := hostservice.Uninstall(projectDir); err != nil {
return err
}
cliout.Successf("Foundry service uninstalled")
return nil
default:
return fmt.Errorf("unknown service subcommand: %s", args[2])
}
}
package standalonecmd
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/standalone"
)
type command struct {
name string
summary string
details []string
run func(*config.Config, []string) error
}
func (c command) Name() string { return c.name }
func (c command) Summary() string { return c.summary }
func (c command) Group() string { return "runtime" }
func (c command) Details() []string { return c.details }
func (c command) RequiresConfig() bool { return false }
func (c command) Run(cfg *config.Config, args []string) error {
return c.run(cfg, args)
}
func currentProjectDir() (string, error) {
return os.Getwd()
}
func runServeStandalone(_ *config.Config, _ []string) error {
projectDir, err := currentProjectDir()
if err != nil {
return err
}
state, err := standalone.Start(projectDir, os.Args)
if err != nil {
return err
}
cliout.Successf("Foundry standalone server started")
fmt.Printf("%s %d\n", cliout.Label("PID:"), state.PID)
fmt.Printf("%s %s\n", cliout.Label("Log:"), state.LogPath)
fmt.Printf("%s %s\n", cliout.Label("Project:"), state.ProjectDir)
return nil
}
func runStop(_ *config.Config, _ []string) error {
projectDir, err := currentProjectDir()
if err != nil {
return err
}
if err := standalone.Stop(projectDir); err != nil {
return err
}
cliout.Successf("Foundry standalone server stopped")
return nil
}
func runStatus(_ *config.Config, _ []string) error {
projectDir, err := currentProjectDir()
if err != nil {
return err
}
state, running, err := standalone.RunningState(projectDir)
if err != nil {
return err
}
if state == nil {
fmt.Println("not running")
return nil
}
if !running {
fmt.Println("not running (stale state found)")
fmt.Printf("%s %d\n", cliout.Label("PID:"), state.PID)
fmt.Printf("%s %s\n", cliout.Label("Log:"), state.LogPath)
return nil
}
fmt.Println("running")
fmt.Printf("%s %d\n", cliout.Label("PID:"), state.PID)
fmt.Printf("%s %s\n", cliout.Label("Started:"), state.StartedAt.Local().Format(time.RFC3339))
fmt.Printf("%s %s\n", cliout.Label("Log:"), state.LogPath)
return nil
}
func runRestart(_ *config.Config, _ []string) error {
projectDir, err := currentProjectDir()
if err != nil {
return err
}
state, err := standalone.Restart(projectDir, os.Args)
if err != nil {
return err
}
cliout.Successf("Foundry standalone server restarted")
fmt.Printf("%s %d\n", cliout.Label("PID:"), state.PID)
fmt.Printf("%s %s\n", cliout.Label("Log:"), state.LogPath)
return nil
}
func runLogs(_ *config.Config, args []string) error {
projectDir, err := currentProjectDir()
if err != nil {
return err
}
paths := standalone.ProjectPaths(projectDir)
lines := 120
follow := false
for _, arg := range args[2:] {
switch {
case strings.TrimSpace(arg) == "-f" || strings.TrimSpace(arg) == "--follow":
follow = true
case strings.HasPrefix(strings.TrimSpace(arg), "--lines="):
value := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(arg), "--lines="))
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid --lines value %q", value)
}
lines = n
default:
return fmt.Errorf("usage: foundry logs [--lines=N] [-f|--follow]")
}
}
body, err := standalone.ReadLastLines(paths.LogPath, lines)
if err != nil {
return err
}
if body != "" {
fmt.Println(body)
}
if follow {
return standalone.FollowLog(paths.LogPath, os.Stdout)
}
return nil
}
func init() {
registry.Register(command{
name: "serve-standalone",
summary: "Run Foundry in the background with PID/log management",
details: []string{"foundry serve-standalone", "foundry serve-standalone --debug"},
run: runServeStandalone,
})
registry.Register(command{
name: "stop",
summary: "Stop the standalone Foundry server",
details: []string{"foundry stop"},
run: runStop,
})
registry.Register(command{
name: "status",
summary: "Show standalone Foundry server status",
details: []string{"foundry status"},
run: runStatus,
})
registry.Register(command{
name: "restart",
summary: "Restart the standalone Foundry server",
details: []string{"foundry restart"},
run: runRestart,
})
registry.Register(command{
name: "logs",
summary: "Show standalone Foundry server logs",
details: []string{"foundry logs", "foundry logs --lines=200", "foundry logs -f"},
run: runLogs,
})
}
package themecmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/theme"
"gopkg.in/yaml.v3"
)
func runMigrateFieldContracts(cfg *config.Config, args []string) error {
if len(args) < 4 || strings.TrimSpace(args[3]) != "field-contracts" {
return fmt.Errorf("usage: foundry theme migrate field-contracts")
}
body, err := os.ReadFile(consts.ConfigFilePath)
if err != nil {
return err
}
var siteCfg config.Config
if err := config.UnmarshalYAML(body, &siteCfg); err != nil {
return err
}
if len(siteCfg.Fields.Schemas) == 0 {
cliout.Println(cliout.Warning("no legacy config-owned field schemas found in content/config/site.yaml"))
return nil
}
manifestPath := filepath.Join(cfg.ThemesDir, cfg.Theme, "theme.yaml")
manifest, err := theme.LoadManifest(cfg.ThemesDir, cfg.Theme)
if err != nil {
return err
}
if len(manifest.FieldContracts) > 0 {
return fmt.Errorf("theme %q already defines field_contracts; migrate manually or clear them first", cfg.Theme)
}
migrated := make([]theme.FieldContract, 0, len(siteCfg.Fields.Schemas))
for key, set := range siteCfg.Fields.Schemas {
contractKey := strings.TrimSpace(strings.ToLower(key))
if contractKey == "" {
continue
}
target := theme.FieldContractTarget{Scope: "document"}
switch contractKey {
case "default":
target.Types = []string{"page", "post"}
default:
target.Types = []string{contractKey}
}
migrated = append(migrated, theme.FieldContract{
Key: "migrated-" + strings.ReplaceAll(contractKey, "_", "-"),
Title: "Migrated " + strings.ToUpper(contractKey[:1]) + contractKey[1:] + " Fields",
Description: "Migrated from legacy content/config/site.yaml fields schema.",
Target: target,
Fields: append([]config.FieldDefinition(nil), set.Fields...),
})
}
if len(migrated) == 0 {
cliout.Println(cliout.Warning("no migratable field schemas found"))
return nil
}
manifest.FieldContracts = migrated
rendered, err := yaml.Marshal(manifest)
if err != nil {
return err
}
if err := os.WriteFile(manifestPath, rendered, 0o644); err != nil {
return err
}
if err := config.RemoveTopLevelKey(consts.ConfigFilePath, "fields"); err != nil {
return err
}
cliout.Successf("Migrated %d field contract(s) into theme %q", len(migrated), cfg.Theme)
fmt.Printf("%s %s\n", cliout.Label("Theme Manifest:"), manifestPath)
fmt.Printf("%s %s\n", cliout.Label("Config Updated:"), consts.ConfigFilePath)
fmt.Println("")
cliout.Println(cliout.Heading("Next steps:"))
fmt.Println("1. Review theme.yaml field_contracts")
fmt.Println("2. Move shared values into content/custom-fields.yaml if needed")
fmt.Println("3. Open the admin Custom Fields and Editor screens to verify the theme contract")
return nil
}
package themecmd
import (
"fmt"
"strings"
adminui "github.com/sphireinc/foundry/internal/admin/ui"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/theme"
)
type command struct{}
func (command) Name() string {
return "theme"
}
func (command) Summary() string {
return "Manage themes"
}
func (command) Group() string {
return "theme commands"
}
func (command) Details() []string {
return []string{
"foundry theme list",
"foundry theme current",
"foundry theme validate <name> [--security] [--csp]",
"foundry theme security <name>",
"foundry theme install <git-url|owner/repo> [name] [--admin]",
"foundry theme migrate field-contracts",
"foundry theme scaffold <name>",
"foundry theme switch <name>",
"foundry theme switch --admin <name>",
}
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry theme [list|current|validate|security|install|migrate|scaffold|switch]")
}
switch args[2] {
case "list":
return runList(cfg)
case "current":
return runCurrent(cfg)
case "validate":
return runValidate(cfg, args)
case "security":
return runSecurity(cfg, args)
case "install":
return runInstall(cfg, args)
case "migrate":
return runMigrateFieldContracts(cfg, args)
case "scaffold":
return runScaffold(cfg, args)
case "switch":
return runSwitch(cfg, args)
default:
return fmt.Errorf("unknown theme subcommand: %s", args[2])
}
}
func runList(cfg *config.Config) error {
themes, err := theme.ListInstalled(cfg.ThemesDir)
if err != nil {
return err
}
if len(themes) == 0 {
cliout.Println(cliout.Warning("no themes installed"))
return nil
}
nameWidth := len("NAME")
versionWidth := len("VERSION")
type row struct {
Name string
Version string
Title string
Status string
}
rows := make([]row, 0, len(themes))
for _, t := range themes {
manifest, err := theme.LoadManifest(cfg.ThemesDir, t.Name)
title := t.Name
version := "-"
if err == nil {
title = manifest.Title
version = manifest.Version
}
status := ""
if t.Name == cfg.Theme {
status = "current"
}
rows = append(rows, row{
Name: t.Name,
Version: version,
Title: title,
Status: status,
})
if len(t.Name) > nameWidth {
nameWidth = len(t.Name)
}
if len(version) > versionWidth {
versionWidth = len(version)
}
}
fmt.Printf("%-*s %-*s %-20s %s\n", nameWidth, cliout.Label("NAME"), versionWidth, cliout.Label("VERSION"), cliout.Label("TITLE"), cliout.Label("STATUS"))
for _, row := range rows {
fmt.Printf("%-*s %-*s %-20s %s\n", nameWidth, row.Name, versionWidth, row.Version, row.Title, row.Status)
}
return nil
}
func runCurrent(cfg *config.Config) error {
fmt.Println(cfg.Theme)
return nil
}
func runValidate(cfg *config.Config, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry theme validate <name> [--security] [--csp]")
}
name := strings.TrimSpace(args[3])
securityMode := hasThemeFlag(args[4:], "--security")
cspMode := hasThemeFlag(args[4:], "--csp")
result, err := theme.ValidateInstalledDetailed(cfg.ThemesDir, name)
if err != nil {
return err
}
manifest, err := theme.LoadManifest(cfg.ThemesDir, name)
if err != nil {
return err
}
if !result.Valid {
for _, diag := range result.Diagnostics {
fmt.Printf("[%s] %s: %s\n", diag.Severity, diag.Path, diag.Message)
}
return fmt.Errorf("theme %q is invalid", name)
}
cliout.Successf("Theme %q is valid", name)
fmt.Printf("%s %s\n", cliout.Label("Title:"), manifest.Title)
fmt.Printf("%s %s\n", cliout.Label("Version:"), manifest.Version)
fmt.Printf("%s %s\n", cliout.Label("Min Foundry Version:"), manifest.MinFoundryVersion)
if securityMode || cspMode {
report, err := theme.AnalyzeInstalledSecurity(cfg.ThemesDir, name)
if err != nil {
return err
}
printThemeSecurityReport(report, cspMode)
}
return nil
}
func runSecurity(cfg *config.Config, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry theme security <name>")
}
name := strings.TrimSpace(args[3])
report, err := theme.AnalyzeInstalledSecurity(cfg.ThemesDir, name)
if err != nil {
return err
}
printThemeSecurityReport(report, true)
return nil
}
func runInstall(cfg *config.Config, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry theme install <git-url|owner/repo> [name] [--admin]")
}
kind := theme.InstallKindFrontend
values := make([]string, 0, len(args)-3)
for _, arg := range args[3:] {
if strings.TrimSpace(arg) == "--admin" {
kind = theme.InstallKindAdmin
continue
}
values = append(values, arg)
}
if len(values) == 0 {
return fmt.Errorf("usage: foundry theme install <git-url|owner/repo> [name] [--admin]")
}
repoURL := strings.TrimSpace(values[0])
name := ""
if len(values) >= 2 {
name = strings.TrimSpace(values[1])
}
meta, err := theme.Install(theme.InstallOptions{
ThemesDir: cfg.ThemesDir,
URL: repoURL,
Name: name,
Kind: kind,
})
if err != nil {
return err
}
switch m := meta.(type) {
case *theme.Manifest:
cliout.Successf("Installed frontend theme: %s", m.Name)
fmt.Printf("%s %s\n", cliout.Label("Directory:"), filepathJoin(cfg.ThemesDir, m.Name))
fmt.Printf("%s %s\n", cliout.Label("Version:"), m.Version)
fmt.Println("")
cliout.Println(cliout.Heading("Next steps:"))
fmt.Printf("1. Run foundry theme validate %q\n", m.Name)
fmt.Printf("2. Run foundry theme switch %q\n", m.Name)
fmt.Println("3. Run foundry build or foundry serve")
case *adminui.Manifest:
cliout.Successf("Installed admin theme: %s", m.Name)
fmt.Printf("%s %s\n", cliout.Label("Directory:"), filepathJoin(cfg.ThemesDir, "admin-themes", m.Name))
fmt.Printf("%s %s\n", cliout.Label("Version:"), m.Version)
fmt.Println("")
cliout.Println(cliout.Heading("Next steps:"))
fmt.Printf("1. Validate the theme from the admin UI or with internal tooling\n")
fmt.Printf("2. Set admin.theme to %q in %s\n", m.Name, consts.ConfigFilePath)
fmt.Println("3. Run foundry build or foundry serve")
default:
return fmt.Errorf("unexpected installed theme metadata type")
}
return nil
}
func runScaffold(cfg *config.Config, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry theme scaffold <name>")
}
name := strings.TrimSpace(args[3])
path, err := theme.Scaffold(cfg.ThemesDir, name)
if err != nil {
return err
}
cliout.Successf("Scaffolded theme %q at %s", name, path)
return nil
}
func runSwitch(cfg *config.Config, args []string) error {
if len(args) < 4 {
return fmt.Errorf("usage: foundry theme switch [--admin] <name>")
}
adminKind := false
name := ""
for _, arg := range args[3:] {
if strings.TrimSpace(arg) == "--admin" {
adminKind = true
continue
}
if name == "" {
name = strings.TrimSpace(arg)
}
}
if name == "" {
return fmt.Errorf("usage: foundry theme switch [--admin] <name>")
}
if adminKind {
validation, err := adminui.ValidateTheme(cfg.ThemesDir, name)
if err != nil {
return err
}
if !validation.Valid {
return fmt.Errorf("admin theme %q is invalid", name)
}
if err := config.UpsertNestedScalar(consts.ConfigFilePath, []string{"admin", "theme"}, name); err != nil {
return err
}
cliout.Successf("Switched admin theme to %q", name)
cliout.Println(cliout.Heading("Next steps:"))
fmt.Println("1. Run foundry build or foundry serve")
return nil
}
if err := theme.ValidateInstalled(cfg.ThemesDir, name); err != nil {
return err
}
if err := theme.SwitchInConfig(consts.ConfigFilePath, name); err != nil {
return err
}
cliout.Successf("Switched theme to %q", name)
cliout.Println(cliout.Heading("Next steps:"))
fmt.Println("1. Run foundry build or foundry serve")
return nil
}
func filepathJoin(parts ...string) string {
return strings.ReplaceAll(strings.Join(parts, "/"), "//", "/")
}
func hasThemeFlag(args []string, want string) bool {
for _, arg := range args {
if strings.TrimSpace(arg) == want {
return true
}
}
return false
}
func printThemeSecurityReport(report *theme.SecurityReport, includeCSP bool) {
if report == nil {
return
}
fmt.Println("")
cliout.Println(cliout.Heading("Theme security"))
for _, item := range report.DeclaredSummary {
fmt.Printf(" - %s\n", item)
}
if len(report.DetectedAssets) > 0 {
fmt.Println("Detected remote assets:")
for _, item := range report.DetectedAssets {
fmt.Printf(" - %s [%s] (%s)\n", item.URL, item.Status, item.Path)
}
}
if len(report.DetectedRequests) > 0 {
fmt.Println("Detected frontend requests:")
for _, item := range report.DetectedRequests {
fmt.Printf(" - %s [%s] (%s)\n", item.URL, item.Status, item.Path)
}
}
if len(report.Mismatches) > 0 {
fmt.Println("Security mismatches:")
for _, diag := range report.Mismatches {
fmt.Printf(" - %s\n", diag.Message)
}
}
if includeCSP {
fmt.Println("CSP summary:")
for _, item := range report.CSPSummary {
fmt.Printf(" - %s\n", item)
}
fmt.Println("Generated CSP:")
fmt.Println(report.GeneratedCSP)
}
}
func init() {
registry.Register(command{})
}
package updatecmd
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/updater"
)
type command struct{}
func init() {
registry.Register(command{})
}
func (command) Name() string { return "update" }
func (command) Summary() string { return "Check and apply Foundry releases" }
func (command) Group() string { return "runtime" }
func (command) RequiresConfig() bool { return false }
func (command) Details() []string {
return []string{
"foundry update check",
"foundry update apply",
}
}
func (command) Run(_ *config.Config, args []string) error {
if len(args) < 3 {
return fmt.Errorf("usage: foundry update [check|apply|__helper]")
}
projectDir, _ := os.Getwd()
switch strings.TrimSpace(args[2]) {
case "check":
return runCheck(projectDir)
case "apply":
return runApply(projectDir)
case "__helper":
return runHelper(args)
default:
return fmt.Errorf("unknown update subcommand: %s", args[2])
}
}
func runCheck(projectDir string) error {
info, err := updater.Check(context.Background(), projectDir)
if err != nil {
return err
}
fmt.Printf("%s %s\n", cliout.Label("Current:"), info.CurrentVersion)
fmt.Printf("%s %s\n", cliout.Label("Latest:"), info.LatestVersion)
fmt.Printf("%s %s\n", cliout.Label("Install mode:"), info.InstallMode)
fmt.Printf("%s %t\n", cliout.Label("Update available:"), info.HasUpdate)
fmt.Printf("%s %t\n", cliout.Label("Apply supported:"), info.ApplySupported)
if info.ReleaseURL != "" {
fmt.Printf("%s %s\n", cliout.Label("Release:"), info.ReleaseURL)
}
if info.Instructions != "" {
fmt.Printf("%s %s\n", cliout.Label("Notes:"), info.Instructions)
}
return nil
}
func runApply(projectDir string) error {
info, err := updater.ScheduleApply(context.Background(), projectDir)
if err != nil {
return err
}
cliout.Successf("Update scheduled")
fmt.Printf("%s %s\n", cliout.Label("Current:"), info.CurrentVersion)
fmt.Printf("%s %s\n", cliout.Label("Latest:"), info.LatestVersion)
fmt.Printf("%s %s\n", cliout.Label("Asset:"), info.AssetName)
fmt.Println("Foundry will restart after the release binary is replaced.")
return nil
}
func runHelper(args []string) error {
var (
projectDir string
target string
source string
pid int
)
for _, arg := range args[3:] {
switch {
case strings.HasPrefix(arg, "--project-dir="):
projectDir = strings.TrimPrefix(arg, "--project-dir=")
case strings.HasPrefix(arg, "--target="):
target = strings.TrimPrefix(arg, "--target=")
case strings.HasPrefix(arg, "--source="):
source = strings.TrimPrefix(arg, "--source=")
case strings.HasPrefix(arg, "--pid="):
parsed, _ := strconv.Atoi(strings.TrimPrefix(arg, "--pid="))
pid = parsed
}
}
if projectDir == "" || target == "" || source == "" {
return fmt.Errorf("update helper requires --project-dir, --target, and --source")
}
time.Sleep(300 * time.Millisecond)
return updater.RunHelper(projectDir, target, source, pid)
}
package validate
import (
"context"
"fmt"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/commands/registry"
foundryconfig "github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/ops"
"github.com/sphireinc/foundry/internal/site"
"github.com/sphireinc/foundry/internal/theme"
)
type command struct{}
func (command) Name() string {
return "validate"
}
func (command) Summary() string {
return "Validate config, plugins, content, and routes"
}
func (command) Group() string {
return "core commands"
}
func (command) Details() []string {
return nil
}
func (command) RequiresConfig() bool {
return true
}
func (command) Run(cfg *foundryconfig.Config, _ []string) error {
errCount := 0
if errs := foundryconfig.Validate(cfg); len(errs) > 0 {
cliout.Println(cliout.Heading("config:"))
for _, err := range errs {
fmt.Printf("%s %v\n", cliout.Fail("-"), err)
}
errCount += len(errs)
}
if err := theme.NewManager(cfg.ThemesDir, cfg.Theme).MustExist(); err != nil {
fmt.Printf("%s %v\n", cliout.Fail("theme:"), err)
errCount++
}
graph, _, err := site.LoadConfiguredGraph(context.Background(), cfg, true)
if err != nil {
fmt.Printf("%s %v\n", cliout.Fail("site:"), err)
errCount++
} else {
report := ops.AnalyzeSite(cfg, graph)
fmt.Printf("%s %d document(s)\n", cliout.Label("validated"), len(graph.Documents))
fmt.Printf("%s %d route(s)\n", cliout.Label("validated"), len(graph.ByURL))
for _, msg := range report.Messages() {
fmt.Println(msg)
}
errCount += len(report.Messages())
}
if errCount > 0 {
return fmt.Errorf("validation failed with %d error(s)", errCount)
}
cliout.Successf("validation OK")
return nil
}
func init() {
registry.Register(command{})
}
package version
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
"github.com/sphireinc/foundry/internal/installmode"
)
type Metadata struct {
Version string `json:"version"`
DisplayVersion string `json:"display_version,omitempty"`
Commit string `json:"commit"`
BuiltAt string `json:"built_at"`
GoVersion string `json:"go_version"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
Executable string `json:"executable"`
InstallMode string `json:"install_mode"`
VCSRevision string `json:"vcs_revision,omitempty"`
VCSTime string `json:"vcs_time,omitempty"`
VCSModified bool `json:"vcs_modified"`
ModuleVersion string `json:"module_version,omitempty"`
NearestTag string `json:"nearest_tag,omitempty"`
CommitCount int `json:"commit_count,omitempty"`
Dirty bool `json:"dirty,omitempty"`
}
func Current(projectDir string) Metadata {
meta := Metadata{
Version: normalizeValue(Version, "dev"),
Commit: normalizeValue(Commit, "none"),
BuiltAt: normalizeValue(Date, "unknown"),
GoVersion: runtime.Version(),
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
InstallMode: string(installmode.Detect(projectDir)),
}
if exe, err := os.Executable(); err == nil {
meta.Executable = exe
}
if info, ok := debug.ReadBuildInfo(); ok && info != nil {
if info.GoVersion != "" {
meta.GoVersion = info.GoVersion
}
if info.Main.Version != "" && info.Main.Version != "(devel)" {
meta.ModuleVersion = info.Main.Version
if meta.Version == "" {
meta.Version = info.Main.Version
}
}
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
meta.VCSRevision = setting.Value
if meta.Commit == "" {
meta.Commit = shortRevision(setting.Value)
}
case "vcs.time":
meta.VCSTime = setting.Value
if meta.BuiltAt == "" {
meta.BuiltAt = setting.Value
}
case "vcs.modified":
meta.VCSModified = setting.Value == "true"
}
}
}
if meta.NearestTag == "" {
meta.NearestTag = gitNearestTag(projectDir)
}
if meta.Commit == "" {
meta.Commit = gitCommit(projectDir)
}
if meta.BuiltAt == "" {
meta.BuiltAt = gitCommitTime(projectDir)
}
if meta.CommitCount == 0 && meta.NearestTag != "" {
meta.CommitCount = gitCommitsSinceTag(projectDir, meta.NearestTag)
}
if !meta.VCSModified {
meta.Dirty = gitDirty(projectDir)
meta.VCSModified = meta.Dirty
} else {
meta.Dirty = true
}
if meta.Version == "" {
meta.Version = meta.NearestTag
}
if meta.Version == "" {
meta.Version = "dev"
}
if meta.Commit == "" {
meta.Commit = "unknown"
}
if meta.BuiltAt == "" {
meta.BuiltAt = "unknown"
}
meta.DisplayVersion = meta.Version
if meta.InstallMode == string(installmode.Source) {
meta.DisplayVersion = sourceDisplayVersion(meta)
}
return meta
}
func (m Metadata) String() string {
lines := []string{
fmt.Sprintf("Foundry %s", firstNonEmpty(m.DisplayVersion, m.Version)),
fmt.Sprintf("Commit: %s", m.Commit),
fmt.Sprintf("Built: %s", m.BuiltAt),
fmt.Sprintf("Go: %s", m.GoVersion),
fmt.Sprintf("Target: %s/%s", m.GOOS, m.GOARCH),
fmt.Sprintf("Install mode: %s", m.InstallMode),
}
if m.Executable != "" {
lines = append(lines, fmt.Sprintf("Executable: %s", m.Executable))
}
if m.VCSRevision != "" && shortRevision(m.VCSRevision) != m.Commit {
lines = append(lines, fmt.Sprintf("VCS revision: %s", m.VCSRevision))
}
if m.VCSTime != "" && m.VCSTime != m.BuiltAt {
lines = append(lines, fmt.Sprintf("VCS time: %s", m.VCSTime))
}
if m.VCSModified {
lines = append(lines, "VCS modified: true")
}
if m.ModuleVersion != "" && m.ModuleVersion != m.Version {
lines = append(lines, fmt.Sprintf("Module version: %s", m.ModuleVersion))
}
if m.InstallMode == string(installmode.Source) {
lines = append(lines, fmt.Sprintf("Nearest tag: %s", firstNonEmpty(m.NearestTag, "unknown")))
lines = append(lines, fmt.Sprintf("Current commit: %s", firstNonEmpty(m.Commit, "unknown")))
lines = append(lines, fmt.Sprintf("Local changes: %s", boolLabel(m.Dirty, "dirty", "clean")))
}
return strings.Join(lines, "\n")
}
func (m Metadata) ShortString() string {
return fmt.Sprintf("Foundry %s (%s)", firstNonEmpty(m.DisplayVersion, m.Version), m.Commit)
}
func (m Metadata) JSON() string {
body, err := json.MarshalIndent(m, "", " ")
if err != nil {
return "{}"
}
return string(body)
}
func normalizeValue(v, placeholder string) string {
v = strings.TrimSpace(v)
if v == "" || v == placeholder {
return ""
}
return v
}
func shortRevision(v string) string {
v = strings.TrimSpace(v)
if len(v) > 12 {
return v[:12]
}
return v
}
func gitNearestTag(projectDir string) string {
return gitOutput(projectDir, "describe", "--tags", "--abbrev=0")
}
func gitCommit(projectDir string) string {
return gitOutput(projectDir, "rev-parse", "--short", "HEAD")
}
func gitCommitTime(projectDir string) string {
value := gitOutput(projectDir, "show", "-s", "--format=%cI", "HEAD")
if value == "" {
return ""
}
if _, err := time.Parse(time.RFC3339, value); err != nil {
return ""
}
return value
}
func gitCommitsSinceTag(projectDir, tag string) int {
tag = strings.TrimSpace(tag)
if tag == "" {
return 0
}
value := gitOutput(projectDir, "rev-list", "--count", tag+"..HEAD")
if value == "" {
return 0
}
n, err := strconv.Atoi(value)
if err != nil {
return 0
}
return n
}
func gitDirty(projectDir string) bool {
return gitCommandSuccess(projectDir, "diff-index", "--quiet", "HEAD", "--")
}
func gitCommandSuccess(projectDir string, args ...string) bool {
if strings.TrimSpace(projectDir) == "" {
return false
}
cmd := exec.Command("git", args...)
cmd.Dir = projectDir
return cmd.Run() != nil
}
func sourceDisplayVersion(meta Metadata) string {
base := firstNonEmpty(meta.NearestTag, meta.Version, "dev")
commit := firstNonEmpty(meta.Commit, shortRevision(meta.VCSRevision))
suffix := ""
if meta.CommitCount > 0 && commit != "" {
suffix = fmt.Sprintf("+%d.g%s", meta.CommitCount, commit)
} else if commit != "" && (base == "dev" || base == "") {
suffix = "+" + commit
}
if meta.Dirty {
suffix += "-dirty"
}
return base + suffix
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func boolLabel(v bool, yes, no string) string {
if v {
return yes
}
return no
}
func gitOutput(projectDir string, args ...string) string {
if strings.TrimSpace(projectDir) == "" {
return ""
}
cmd := exec.Command("git", args...)
cmd.Dir = projectDir
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
package version
import (
"fmt"
"os"
"strings"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
)
var (
Version = embeddedVersion()
Commit = "none"
Date = "unknown"
)
func embeddedVersion() string {
value := strings.TrimSpace(ReleaseVersion)
if value == "" {
return "dev"
}
return value
}
type command struct{}
func (command) Name() string {
return "version"
}
func (command) Summary() string {
return "Print Foundry version information"
}
func (command) Group() string {
return "core commands"
}
func (command) Details() []string {
return []string{
"foundry version",
"foundry version --short",
"foundry version --json",
}
}
func (command) RequiresConfig() bool {
return false
}
func (command) Run(_ *config.Config, args []string) error {
projectDir, _ := os.Getwd()
meta := Current(projectDir)
switch outputMode(versionArgs(args)) {
case "short":
fmt.Println(meta.ShortString())
case "json":
fmt.Println(meta.JSON())
default:
fmt.Println(meta.String())
}
return nil
}
func String() string {
projectDir, _ := os.Getwd()
return Current(projectDir).String()
}
func outputMode(args []string) string {
for _, arg := range args {
switch strings.TrimSpace(arg) {
case "--short":
return "short"
case "--json":
return "json"
}
}
return "default"
}
func versionArgs(args []string) []string {
if len(args) <= 2 {
return nil
}
return args[2:]
}
func init() {
registry.Register(command{})
}
package config
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
)
type Config struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
BaseURL string `yaml:"base_url"`
Theme string `yaml:"theme"`
Environment string `yaml:"environment"`
Admin AdminConfig `yaml:"admin"`
Backup BackupConfig `yaml:"backup"`
DefaultLang string `yaml:"default_lang"`
ContentDir string `yaml:"content_dir"`
PublicDir string `yaml:"public_dir"`
ThemesDir string `yaml:"themes_dir"`
DataDir string `yaml:"data_dir"`
PluginsDir string `yaml:"plugins_dir"`
Permalinks map[string]string `yaml:"permalinks"`
Server ServerConfig `yaml:"server"`
Build BuildConfig `yaml:"build"`
Content ContentConfig `yaml:"content"`
Taxonomies TaxonomyConfig `yaml:"taxonomies"`
Plugins PluginConfig `yaml:"plugins"`
Fields FieldsConfig `yaml:"fields"`
SEO SEOConfig `yaml:"seo"`
Cache CacheConfig `yaml:"cache"`
Security SecurityConfig `yaml:"security"`
Feed FeedConfig `yaml:"feed"`
Deploy DeployConfig `yaml:"deploy"`
Params map[string]any `yaml:"params"`
Menus map[string][]MenuItem `yaml:"menus"`
}
type AdminConfig struct {
Enabled bool `yaml:"enabled"`
Addr string `yaml:"addr"`
Path string `yaml:"path"`
Debug AdminDebugConfig `yaml:"debug"`
LocalOnly bool `yaml:"local_only"`
AccessToken string `yaml:"access_token"`
SessionSecret string `yaml:"session_secret,omitempty"`
TOTPSecretKey string `yaml:"totp_secret_key,omitempty"`
Theme string `yaml:"theme"`
UsersFile string `yaml:"users_file"`
SessionStoreFile string `yaml:"session_store_file"`
LockFile string `yaml:"lock_file"`
SessionTTLMinutes int `yaml:"session_ttl_minutes"`
SessionIdleTimeoutMinutes int `yaml:"session_idle_timeout_minutes,omitempty"`
SessionMaxAgeMinutes int `yaml:"session_max_age_minutes,omitempty"`
SingleSessionPerUser bool `yaml:"single_session_per_user,omitempty"`
PasswordMinLength int `yaml:"password_min_length"`
PasswordResetTTL int `yaml:"password_reset_ttl_minutes"`
TOTPIssuer string `yaml:"totp_issuer"`
localOnlySet bool `yaml:"-"`
}
type AdminDebugConfig struct {
Pprof bool `yaml:"pprof"`
}
type BackupConfig struct {
Enabled bool `yaml:"enabled"`
Dir string `yaml:"dir"`
OnChange bool `yaml:"on_change"`
DebounceSeconds int `yaml:"debounce_seconds"`
RetentionCount int `yaml:"retention_count"`
MinFreeMB int64 `yaml:"min_free_mb"`
HeadroomPercent int `yaml:"headroom_percent"`
GitRemoteURL string `yaml:"git_remote_url,omitempty"`
GitBranch string `yaml:"git_branch,omitempty"`
GitPushOnChange bool `yaml:"git_push_on_change,omitempty"`
}
type ServerConfig struct {
Addr string `yaml:"addr"`
LiveReload bool `yaml:"live_reload"`
LiveReloadMode string `yaml:"live_reload_mode"`
AutoOpenBrowser bool `yaml:"auto_open_browser"`
DebugRoutes bool `yaml:"debug_routes"`
}
type BuildConfig struct {
CleanPublicDir bool `yaml:"clean_public_dir"`
IncludeDrafts bool `yaml:"include_drafts"`
MinifyHTML bool `yaml:"minify_html"`
CopyAssets bool `yaml:"copy_assets"`
CopyImages bool `yaml:"copy_images"`
CopyUploads bool `yaml:"copy_uploads"`
}
type ContentConfig struct {
PagesDir string `yaml:"pages_dir"`
PostsDir string `yaml:"posts_dir"`
ImagesDir string `yaml:"images_dir"`
VideoDir string `yaml:"videos_dir"`
AudioDir string `yaml:"audio_dir"`
DocumentsDir string `yaml:"documents_dir"`
AssetsDir string `yaml:"assets_dir"`
UploadsDir string `yaml:"uploads_dir"`
MaxVersionsPerFile int `yaml:"max_versions_per_file"`
DefaultLayoutPage string `yaml:"default_layout_page"`
DefaultLayoutPost string `yaml:"default_layout_post"`
DefaultPageSlugIndex string `yaml:"default_page_slug_index"`
}
type TaxonomyConfig struct {
Enabled bool `yaml:"enabled"`
DefaultSet []string `yaml:"default_set"`
Definitions map[string]TaxonomyDefinition `yaml:"definitions"`
}
type TaxonomyDefinition struct {
Title string `yaml:"title"`
Labels map[string]string `yaml:"labels"`
ArchiveLayout string `yaml:"archive_layout"`
TermLayout string `yaml:"term_layout"`
Order string `yaml:"order"`
}
type PluginConfig struct {
Enabled []string `yaml:"enabled"`
}
type FieldsConfig struct {
Enabled bool `yaml:"enabled"`
AllowAnything bool `yaml:"allow_anything"`
Schemas map[string]FieldSchemaSet `yaml:"schemas"`
}
type FieldSchemaSet struct {
Fields []FieldDefinition `yaml:"fields"`
}
type FieldDefinition struct {
Name string `yaml:"name"`
Label string `yaml:"label,omitempty"`
Type string `yaml:"type"`
Required bool `yaml:"required,omitempty"`
Default any `yaml:"default,omitempty"`
Enum []string `yaml:"enum,omitempty"`
Fields []FieldDefinition `yaml:"fields,omitempty"`
Item *FieldDefinition `yaml:"item,omitempty"`
Help string `yaml:"help,omitempty"`
Placeholder string `yaml:"placeholder,omitempty"`
}
type SEOConfig struct {
Enabled bool `yaml:"enabled"`
DefaultTitleSep string `yaml:"default_title_sep"`
}
type CacheConfig struct {
Enabled bool `yaml:"enabled"`
}
type SecurityConfig struct {
AllowUnsafeHTML bool `yaml:"allow_unsafe_html"`
}
type FeedConfig struct {
RSSPath string `yaml:"rss_path"`
SitemapPath string `yaml:"sitemap_path"`
RSSLimit int `yaml:"rss_limit"`
RSSTitle string `yaml:"rss_title"`
RSSDescription string `yaml:"rss_description"`
}
type DeployConfig struct {
DefaultTarget string `yaml:"default_target"`
Targets map[string]DeployTarget `yaml:"targets"`
}
type DeployTarget struct {
BaseURL string `yaml:"base_url"`
PublicDir string `yaml:"public_dir"`
Theme string `yaml:"theme"`
IncludeDrafts *bool `yaml:"include_drafts"`
Environment string `yaml:"environment"`
Preview *bool `yaml:"preview"`
LiveReloadMode string `yaml:"live_reload_mode"`
}
type MenuItem struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
}
func (c *Config) MarkAdminLocalOnlyExplicit() {
c.Admin.localOnlySet = true
}
func (c *Config) ApplyDefaults() {
if c.Name == "" {
c.Name = "foundry"
}
if strings.TrimSpace(c.Environment) == "" {
c.Environment = "default"
}
if c.Title == "" {
c.Title = "Foundry CMS"
}
if c.Theme == "" {
c.Theme = "default"
}
if c.Admin.Addr == "" {
c.Admin.Addr = ""
}
if strings.TrimSpace(c.Admin.SessionSecret) == "" {
c.Admin.SessionSecret = strings.TrimSpace(os.Getenv("FOUNDRY_ADMIN_SESSION_SECRET"))
}
if strings.TrimSpace(c.Admin.TOTPSecretKey) == "" {
c.Admin.TOTPSecretKey = strings.TrimSpace(os.Getenv("FOUNDRY_ADMIN_TOTP_SECRET_KEY"))
}
c.Admin.Path = normalizeAdminPath(c.Admin.Path)
if strings.TrimSpace(c.Admin.Theme) == "" {
c.Admin.Theme = "default"
}
if c.Admin.SessionTTLMinutes <= 0 {
c.Admin.SessionTTLMinutes = 30
}
if !c.Admin.localOnlySet {
c.Admin.LocalOnly = false
}
if c.DefaultLang == "" {
c.DefaultLang = "en"
}
if strings.TrimSpace(c.Backup.Dir) == "" {
c.Backup.Dir = filepath.Join(".foundry", "backups")
}
if strings.TrimSpace(c.Backup.GitBranch) == "" {
c.Backup.GitBranch = "main"
}
if c.Backup.DebounceSeconds <= 0 {
c.Backup.DebounceSeconds = 45
}
if c.Backup.RetentionCount <= 0 {
c.Backup.RetentionCount = 20
}
if c.Backup.MinFreeMB <= 0 {
c.Backup.MinFreeMB = 256
}
if c.Backup.HeadroomPercent < 100 {
c.Backup.HeadroomPercent = 125
}
if c.ContentDir == "" {
c.ContentDir = "content"
}
if strings.TrimSpace(c.Admin.UsersFile) == "" {
c.Admin.UsersFile = filepath.Join(c.ContentDir, "config", "admin-users.yaml")
}
if c.Admin.PasswordMinLength <= 0 {
c.Admin.PasswordMinLength = 12
}
if c.Admin.PasswordResetTTL <= 0 {
c.Admin.PasswordResetTTL = 30
}
if strings.TrimSpace(c.Admin.TOTPIssuer) == "" {
c.Admin.TOTPIssuer = "Foundry"
}
if c.PublicDir == "" {
c.PublicDir = "public"
}
if c.ThemesDir == "" {
c.ThemesDir = "themes"
}
if c.DataDir == "" {
c.DataDir = "data"
}
if strings.TrimSpace(c.Admin.SessionStoreFile) == "" {
c.Admin.SessionStoreFile = filepath.Join(c.DataDir, "admin", "sessions.yaml")
}
if strings.TrimSpace(c.Admin.LockFile) == "" {
c.Admin.LockFile = filepath.Join(c.DataDir, "admin", "locks.yaml")
}
if c.PluginsDir == "" {
c.PluginsDir = "plugins"
}
if c.Server.Addr == "" {
c.Server.Addr = ":8080"
}
if strings.TrimSpace(c.Server.LiveReloadMode) == "" {
c.Server.LiveReloadMode = "stream"
} else {
c.Server.LiveReloadMode = strings.ToLower(strings.TrimSpace(c.Server.LiveReloadMode))
}
if c.Content.PagesDir == "" {
c.Content.PagesDir = "pages"
}
if c.Content.PostsDir == "" {
c.Content.PostsDir = "posts"
}
if c.Content.ImagesDir == "" {
c.Content.ImagesDir = "images"
}
if c.Content.VideoDir == "" {
c.Content.VideoDir = "videos"
}
if c.Content.AudioDir == "" {
c.Content.AudioDir = "audio"
}
if c.Content.DocumentsDir == "" {
c.Content.DocumentsDir = "documents"
}
if c.Content.AssetsDir == "" {
c.Content.AssetsDir = "assets"
}
if c.Content.UploadsDir == "" {
c.Content.UploadsDir = "uploads"
}
if c.Content.MaxVersionsPerFile <= 0 {
c.Content.MaxVersionsPerFile = 10
}
if c.Content.DefaultLayoutPage == "" {
c.Content.DefaultLayoutPage = "page"
}
if c.Content.DefaultLayoutPost == "" {
c.Content.DefaultLayoutPost = "post"
}
if c.Content.DefaultPageSlugIndex == "" {
c.Content.DefaultPageSlugIndex = "index"
}
if c.Permalinks == nil {
c.Permalinks = map[string]string{
"page_default": "/:slug/",
"page_i18n": "/:lang/:slug/",
"post_default": "/posts/:slug/",
"post_i18n": "/:lang/posts/:slug/",
}
}
if c.Taxonomies.DefaultSet == nil {
c.Taxonomies.DefaultSet = []string{"tags", "categories"}
}
if c.Taxonomies.Definitions == nil {
c.Taxonomies.Definitions = map[string]TaxonomyDefinition{}
}
if c.Fields.Schemas == nil {
c.Fields.Schemas = map[string]FieldSchemaSet{}
}
if c.Deploy.Targets == nil {
c.Deploy.Targets = map[string]DeployTarget{}
}
if c.Feed.RSSPath == "" {
c.Feed.RSSPath = "/rss.xml"
}
if c.Feed.SitemapPath == "" {
c.Feed.SitemapPath = "/sitemap.xml"
}
if c.Feed.RSSLimit == 0 {
c.Feed.RSSLimit = 50
}
if c.Feed.RSSTitle == "" {
c.Feed.RSSTitle = c.Title
}
if c.Feed.RSSDescription == "" {
c.Feed.RSSDescription = c.Title
}
}
func (c *Config) AdminPath() string {
if c == nil {
return defaultAdminPath
}
return normalizeAdminPath(c.Admin.Path)
}
const defaultAdminPath = "/__admin"
func normalizeAdminPath(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, `\`, "/"))
if value == "" {
return defaultAdminPath
}
if !strings.HasPrefix(value, "/") {
value = "/" + value
}
value = path.Clean(value)
if value == "." || value == "" {
return defaultAdminPath
}
if value != "/" {
value = strings.TrimRight(value, "/")
}
if value == "/" {
return defaultAdminPath
}
return value
}
func (c *Config) ApplyDeployTarget(name string) error {
if c == nil || strings.TrimSpace(name) == "" {
return nil
}
target, ok := c.Deploy.Targets[strings.TrimSpace(name)]
if !ok {
return fmt.Errorf("unknown deploy target: %s", name)
}
if strings.TrimSpace(target.BaseURL) != "" {
c.BaseURL = strings.TrimSpace(target.BaseURL)
}
if strings.TrimSpace(target.PublicDir) != "" {
c.PublicDir = strings.TrimSpace(target.PublicDir)
}
if strings.TrimSpace(target.Theme) != "" {
c.Theme = strings.TrimSpace(target.Theme)
}
if target.IncludeDrafts != nil {
c.Build.IncludeDrafts = *target.IncludeDrafts
}
if strings.TrimSpace(target.Environment) != "" {
c.Environment = strings.TrimSpace(target.Environment)
}
if target.Preview != nil && *target.Preview {
c.Build.IncludeDrafts = true
}
if strings.TrimSpace(target.LiveReloadMode) != "" {
c.Server.LiveReloadMode = strings.ToLower(strings.TrimSpace(target.LiveReloadMode))
}
c.ApplyDefaults()
return nil
}
package config
import (
"bytes"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
func LoadYAMLDocument(path string) (*yaml.Node, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var doc yaml.Node
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, err
}
if len(doc.Content) == 0 {
return nil, fmt.Errorf("invalid config document")
}
return &doc, nil
}
func SaveYAMLDocument(path string, doc *yaml.Node) error {
if doc == nil {
return fmt.Errorf("yaml document is nil")
}
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(doc); err != nil {
_ = enc.Close()
return err
}
if err := enc.Close(); err != nil {
return err
}
tmpPath := path + ".tmp"
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := os.WriteFile(tmpPath, buf.Bytes(), 0o644); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func UpsertTopLevelScalar(path, key, value string) error {
doc, err := LoadYAMLDocument(path)
if err != nil {
return err
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode {
return fmt.Errorf("config root must be a mapping")
}
for i := 0; i < len(root.Content); i += 2 {
k := root.Content[i]
v := root.Content[i+1]
if k.Value == key {
v.Kind = yaml.ScalarNode
v.Tag = "!!str"
v.Value = value
return SaveYAMLDocument(path, doc)
}
}
root.Content = append(root.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value},
)
return SaveYAMLDocument(path, doc)
}
func RemoveTopLevelKey(path, key string) error {
doc, err := LoadYAMLDocument(path)
if err != nil {
return err
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode {
return fmt.Errorf("config root must be a mapping")
}
out := make([]*yaml.Node, 0, len(root.Content))
for i := 0; i < len(root.Content); i += 2 {
k := root.Content[i]
v := root.Content[i+1]
if k.Value == key {
continue
}
out = append(out, k, v)
}
root.Content = out
return SaveYAMLDocument(path, doc)
}
func UpsertNestedScalar(path string, keyPath []string, value string) error {
doc, err := LoadYAMLDocument(path)
if err != nil {
return err
}
if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
return fmt.Errorf("config root must be a mapping")
}
if len(keyPath) == 0 {
return fmt.Errorf("key path must not be empty")
}
current := doc.Content[0]
for i, key := range keyPath {
last := i == len(keyPath)-1
var next *yaml.Node
for j := 0; j < len(current.Content); j += 2 {
k := current.Content[j]
v := current.Content[j+1]
if k.Value == key {
next = v
break
}
}
if next == nil {
next = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
if last {
next = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
}
current.Content = append(current.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
next,
)
}
if last {
next.Kind = yaml.ScalarNode
next.Tag = "!!str"
next.Value = value
next.Content = nil
return SaveYAMLDocument(path, doc)
}
if next.Kind != yaml.MappingNode {
next.Kind = yaml.MappingNode
next.Tag = "!!map"
next.Value = ""
next.Content = nil
}
current = next
}
return SaveYAMLDocument(path, doc)
}
func EnsureStringListValue(path string, keyPath []string, value string) error {
doc, err := LoadYAMLDocument(path)
if err != nil {
return err
}
seq, err := ensureSequenceAtPath(doc.Content[0], keyPath)
if err != nil {
return err
}
for _, item := range seq.Content {
if item.Kind == yaml.ScalarNode && item.Value == value {
return SaveYAMLDocument(path, doc)
}
}
seq.Content = append(seq.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: value,
})
return SaveYAMLDocument(path, doc)
}
func RemoveStringListValue(path string, keyPath []string, value string) error {
doc, err := LoadYAMLDocument(path)
if err != nil {
return err
}
seq, err := findSequenceAtPath(doc.Content[0], keyPath)
if err != nil {
return err
}
if seq == nil {
return SaveYAMLDocument(path, doc)
}
out := make([]*yaml.Node, 0, len(seq.Content))
for _, item := range seq.Content {
if item.Kind == yaml.ScalarNode && item.Value == value {
continue
}
out = append(out, item)
}
seq.Content = out
return SaveYAMLDocument(path, doc)
}
func ensureSequenceAtPath(root *yaml.Node, keyPath []string) (*yaml.Node, error) {
if root == nil {
return nil, fmt.Errorf("root node is nil")
}
if root.Kind != yaml.MappingNode {
return nil, fmt.Errorf("config root must be a mapping")
}
if len(keyPath) == 0 {
return nil, fmt.Errorf("key path must not be empty")
}
current := root
for i, key := range keyPath {
last := i == len(keyPath)-1
var next *yaml.Node
for j := 0; j < len(current.Content); j += 2 {
k := current.Content[j]
v := current.Content[j+1]
if k.Value == key {
next = v
break
}
}
if next == nil {
var newVal *yaml.Node
if last {
newVal = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
} else {
newVal = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
}
current.Content = append(current.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
newVal,
)
next = newVal
}
if last {
if next.Kind != yaml.SequenceNode {
return nil, fmt.Errorf("%v must be a sequence", keyPath)
}
return next, nil
}
if next.Kind != yaml.MappingNode {
return nil, fmt.Errorf("%v must contain a mapping at %q", keyPath, key)
}
current = next
}
return nil, fmt.Errorf("invalid key path")
}
func findSequenceAtPath(root *yaml.Node, keyPath []string) (*yaml.Node, error) {
if root == nil {
return nil, fmt.Errorf("root node is nil")
}
if root.Kind != yaml.MappingNode {
return nil, fmt.Errorf("config root must be a mapping")
}
if len(keyPath) == 0 {
return nil, fmt.Errorf("key path must not be empty")
}
current := root
for i, key := range keyPath {
last := i == len(keyPath)-1
var next *yaml.Node
for j := 0; j < len(current.Content); j += 2 {
k := current.Content[j]
v := current.Content[j+1]
if k.Value == key {
next = v
break
}
}
if next == nil {
return nil, nil
}
if last {
if next.Kind != yaml.SequenceNode {
return nil, fmt.Errorf("%v must be a sequence", keyPath)
}
return next, nil
}
if next.Kind != yaml.MappingNode {
return nil, fmt.Errorf("%v must contain a mapping at %q", keyPath, key)
}
current = next
}
return nil, nil
}
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
type LoadOptions struct {
Environment string
Target string
OverlayPaths []string
}
func Load(path string) (*Config, error) {
return LoadWithOptions(path, LoadOptions{})
}
func LoadWithOptions(path string, opts LoadOptions) (*Config, error) {
root, err := loadConfigNode(path)
if err != nil {
return nil, err
}
overlayPaths := make([]string, 0, len(opts.OverlayPaths)+1)
if env := strings.TrimSpace(opts.Environment); env != "" && env != "default" {
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
candidate := filepath.Join(filepath.Dir(path), base+"."+env+filepath.Ext(path))
if _, err := os.Stat(candidate); err == nil {
overlayPaths = append(overlayPaths, candidate)
}
}
overlayPaths = append(overlayPaths, opts.OverlayPaths...)
for _, overlayPath := range overlayPaths {
if strings.TrimSpace(overlayPath) == "" {
continue
}
overlay, err := loadConfigNode(overlayPath)
if err != nil {
return nil, err
}
root = mergeNodes(root, overlay)
}
var cfg Config
b, err := yaml.Marshal(root)
if err != nil {
return nil, fmt.Errorf("marshal merged config: %w", err)
}
if err := UnmarshalYAML(b, &cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
target := strings.TrimSpace(opts.Target)
if target == "" {
target = strings.TrimSpace(cfg.Deploy.DefaultTarget)
}
if target != "" {
if err := cfg.ApplyDeployTarget(target); err != nil {
return nil, fmt.Errorf("apply deploy target: %w", err)
}
}
if env := strings.TrimSpace(opts.Environment); env != "" {
cfg.Environment = env
}
cfg.ApplyDefaults()
return &cfg, nil
}
func UnmarshalYAML(b []byte, cfg *Config) error {
var doc yaml.Node
if err := yaml.Unmarshal(b, &doc); err != nil {
return err
}
cfg.Admin.localOnlySet = yamlMappingPathExists(doc.Content, "admin", "local_only")
if err := yaml.Unmarshal(b, cfg); err != nil {
return err
}
cfg.ApplyDefaults()
if errs := Validate(cfg); len(errs) > 0 {
return errs[0]
}
return nil
}
func yamlMappingPathExists(nodes []*yaml.Node, path ...string) bool {
if len(nodes) == 0 || len(path) == 0 {
return false
}
node := nodes[0]
for _, part := range path {
if node == nil || node.Kind != yaml.MappingNode {
return false
}
next := -1
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value == part {
next = i + 1
break
}
}
if next < 0 {
return false
}
node = node.Content[next]
}
return true
}
func loadConfigNode(path string) (*yaml.Node, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var doc yaml.Node
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
if len(doc.Content) == 0 {
return &yaml.Node{Kind: yaml.MappingNode}, nil
}
return doc.Content[0], nil
}
func mergeNodes(base, overlay *yaml.Node) *yaml.Node {
if base == nil {
return cloneNode(overlay)
}
if overlay == nil {
return cloneNode(base)
}
if base.Kind != yaml.MappingNode || overlay.Kind != yaml.MappingNode {
return cloneNode(overlay)
}
merged := cloneNode(base)
index := make(map[string]int)
for i := 0; i+1 < len(merged.Content); i += 2 {
index[merged.Content[i].Value] = i
}
for i := 0; i+1 < len(overlay.Content); i += 2 {
key := overlay.Content[i]
value := overlay.Content[i+1]
if existing, ok := index[key.Value]; ok {
merged.Content[existing+1] = mergeNodes(merged.Content[existing+1], value)
continue
}
merged.Content = append(merged.Content, cloneNode(key), cloneNode(value))
}
return merged
}
func cloneNode(node *yaml.Node) *yaml.Node {
if node == nil {
return nil
}
cloned := *node
if len(node.Content) > 0 {
cloned.Content = make([]*yaml.Node, len(node.Content))
for i, child := range node.Content {
cloned.Content[i] = cloneNode(child)
}
}
return &cloned
}
package config
import (
"fmt"
"path"
"regexp"
"strings"
"github.com/sphireinc/foundry/internal/safepath"
)
var adminPathSegmentRE = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
func Validate(cfg *Config) []error {
if cfg == nil {
return []error{fmt.Errorf("config is nil")}
}
var errs []error
require := func(name, value string) {
if strings.TrimSpace(value) == "" {
errs = append(errs, fmt.Errorf("%s must not be empty", name))
}
}
require("theme", cfg.Theme)
require("default_lang", cfg.DefaultLang)
require("backup.dir", cfg.Backup.Dir)
require("content_dir", cfg.ContentDir)
require("public_dir", cfg.PublicDir)
require("themes_dir", cfg.ThemesDir)
require("data_dir", cfg.DataDir)
require("plugins_dir", cfg.PluginsDir)
require("content.pages_dir", cfg.Content.PagesDir)
require("content.posts_dir", cfg.Content.PostsDir)
require("content.images_dir", cfg.Content.ImagesDir)
require("content.videos_dir", cfg.Content.VideoDir)
require("content.audio_dir", cfg.Content.AudioDir)
require("content.documents_dir", cfg.Content.DocumentsDir)
require("content.assets_dir", cfg.Content.AssetsDir)
require("content.uploads_dir", cfg.Content.UploadsDir)
require("content.default_layout_page", cfg.Content.DefaultLayoutPage)
require("content.default_layout_post", cfg.Content.DefaultLayoutPost)
if cfg.Content.MaxVersionsPerFile <= 0 {
errs = append(errs, fmt.Errorf("content.max_versions_per_file must be greater than zero"))
}
require("server.addr", cfg.Server.Addr)
require("feed.rss_path", cfg.Feed.RSSPath)
require("feed.sitemap_path", cfg.Feed.SitemapPath)
require("server.live_reload_mode", cfg.Server.LiveReloadMode)
if strings.TrimSpace(cfg.Environment) == "" {
errs = append(errs, fmt.Errorf("environment must not be empty"))
}
if cfg.Feed.RSSPath != "" && !strings.HasPrefix(cfg.Feed.RSSPath, "/") {
errs = append(errs, fmt.Errorf("feed.rss_path must start with '/'"))
}
if cfg.Feed.SitemapPath != "" && !strings.HasPrefix(cfg.Feed.SitemapPath, "/") {
errs = append(errs, fmt.Errorf("feed.sitemap_path must start with '/'"))
}
if cfg.Feed.RSSPath != "" && cfg.Feed.RSSPath == cfg.Feed.SitemapPath {
errs = append(errs, fmt.Errorf("feed.rss_path and feed.sitemap_path must not be the same"))
}
if cfg.Server.LiveReloadMode != "" {
switch strings.ToLower(strings.TrimSpace(cfg.Server.LiveReloadMode)) {
case "stream", "poll":
default:
errs = append(errs, fmt.Errorf("server.live_reload_mode must be one of: stream, poll"))
}
}
if cfg.Backup.DebounceSeconds <= 0 {
errs = append(errs, fmt.Errorf("backup.debounce_seconds must be greater than zero"))
}
if cfg.Backup.RetentionCount < 0 {
errs = append(errs, fmt.Errorf("backup.retention_count must not be negative"))
}
if cfg.Backup.MinFreeMB < 0 {
errs = append(errs, fmt.Errorf("backup.min_free_mb must not be negative"))
}
if cfg.Backup.HeadroomPercent < 100 {
errs = append(errs, fmt.Errorf("backup.headroom_percent must be at least 100"))
}
if strings.TrimSpace(cfg.Backup.GitRemoteURL) != "" && strings.TrimSpace(cfg.Backup.GitBranch) == "" {
errs = append(errs, fmt.Errorf("backup.git_branch must not be empty when backup.git_remote_url is set"))
}
if cfg.DefaultLang != "" && strings.Contains(cfg.DefaultLang, "/") {
errs = append(errs, fmt.Errorf("default_lang must not contain '/'"))
}
if _, err := safepath.ValidatePathComponent("theme", cfg.Theme); err != nil {
errs = append(errs, err)
}
adminPath := cfg.AdminPath()
if !strings.HasPrefix(adminPath, "/") {
errs = append(errs, fmt.Errorf("admin.path must start with '/'"))
}
if adminPath == "/" {
errs = append(errs, fmt.Errorf("admin.path must not be '/'"))
}
if path.Clean(adminPath) != adminPath {
errs = append(errs, fmt.Errorf("admin.path must be normalized"))
}
for _, part := range strings.Split(strings.TrimPrefix(adminPath, "/"), "/") {
if strings.TrimSpace(part) == "" {
continue
}
if _, err := safepath.ValidatePathComponent("admin.path", part); err != nil {
errs = append(errs, err)
continue
}
if !adminPathSegmentRE.MatchString(part) {
errs = append(errs, fmt.Errorf("admin.path segments may only contain letters, numbers, '.', '_' or '-'"))
}
}
if _, err := safepath.ValidatePathComponent("admin.theme", cfg.Admin.Theme); err != nil {
errs = append(errs, err)
}
if strings.TrimSpace(cfg.Admin.UsersFile) == "" {
errs = append(errs, fmt.Errorf("admin.users_file must not be empty"))
}
if strings.TrimSpace(cfg.Admin.SessionStoreFile) == "" {
errs = append(errs, fmt.Errorf("admin.session_store_file must not be empty"))
}
if strings.TrimSpace(cfg.Admin.LockFile) == "" {
errs = append(errs, fmt.Errorf("admin.lock_file must not be empty"))
}
if cfg.Admin.SessionTTLMinutes <= 0 {
errs = append(errs, fmt.Errorf("admin.session_ttl_minutes must be greater than zero"))
}
if cfg.Admin.PasswordMinLength < 8 {
errs = append(errs, fmt.Errorf("admin.password_min_length must be at least 8"))
}
if cfg.Admin.PasswordResetTTL <= 0 {
errs = append(errs, fmt.Errorf("admin.password_reset_ttl_minutes must be greater than zero"))
}
if strings.TrimSpace(cfg.Admin.TOTPIssuer) == "" {
errs = append(errs, fmt.Errorf("admin.totp_issuer must not be empty"))
}
for _, name := range cfg.Plugins.Enabled {
if strings.TrimSpace(name) == "" {
continue
}
if _, err := safepath.ValidatePathComponent("plugin name", name); err != nil {
errs = append(errs, err)
}
}
if strings.TrimSpace(cfg.Deploy.DefaultTarget) != "" {
if _, ok := cfg.Deploy.Targets[strings.TrimSpace(cfg.Deploy.DefaultTarget)]; !ok {
errs = append(errs, fmt.Errorf("deploy.default_target must reference a configured deploy target"))
}
}
for name, target := range cfg.Deploy.Targets {
if _, err := safepath.ValidatePathComponent("deploy target", name); err != nil {
errs = append(errs, err)
}
if strings.TrimSpace(target.Theme) != "" {
if _, err := safepath.ValidatePathComponent("deploy target theme", target.Theme); err != nil {
errs = append(errs, err)
}
}
if strings.TrimSpace(target.LiveReloadMode) != "" {
switch strings.ToLower(strings.TrimSpace(target.LiveReloadMode)) {
case "stream", "poll":
default:
errs = append(errs, fmt.Errorf("deploy.targets.%s.live_reload_mode must be one of: stream, poll", name))
}
}
if strings.TrimSpace(target.Environment) != "" && strings.Contains(target.Environment, "/") {
errs = append(errs, fmt.Errorf("deploy.targets.%s.environment must not contain '/'", name))
}
}
return errs
}
package content
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/i18n"
)
func BuildNewContent(cfg *config.Config, kind, slug string) (string, error) {
return BuildNewContentWithOptions(cfg, NewContentOptions{
Kind: kind,
Slug: slug,
Archetype: kind,
Lang: cfg.DefaultLang,
})
}
type NewContentOptions struct {
Kind string
Slug string
Archetype string
Lang string
}
func BuildNewContentWithOptions(cfg *config.Config, opts NewContentOptions) (string, error) {
kind := strings.TrimSpace(opts.Kind)
slug := strings.TrimSpace(opts.Slug)
archetype := strings.TrimSpace(opts.Archetype)
lang := normalizeContentLang(cfg, opts.Lang)
if kind == "" {
return "", fmt.Errorf("content kind must not be empty")
}
if slug == "" {
return "", fmt.Errorf("slug must not be empty")
}
if archetype == "" {
archetype = kind
}
if body, ok, err := loadArchetype(cfg, archetype, kind, slug, lang); err != nil {
return "", err
} else if ok {
return body, nil
}
switch kind {
case "page":
return defaultPageArchetype(cfg, slug, lang), nil
case "post":
return defaultPostArchetype(cfg, slug, lang), nil
default:
return "", fmt.Errorf("unknown content type: %s", kind)
}
}
func loadArchetype(cfg *config.Config, archetype, kind, slug, lang string) (string, bool, error) {
path := filepath.Join(cfg.ContentDir, "archetypes", archetype+".md")
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", false, nil
}
return "", false, fmt.Errorf("read archetype %s: %w", path, err)
}
return renderArchetype(cfg, kind, slug, lang, string(b)), true, nil
}
func renderArchetype(cfg *config.Config, kind, slug, lang, body string) string {
title := humanizeSlug(slug)
layout := cfg.Content.DefaultLayoutPage
draft := "false"
date := ""
if kind == "post" {
layout = cfg.Content.DefaultLayoutPost
draft = "true"
date = time.Now().Format("2006-01-02")
}
if strings.TrimSpace(layout) == "" {
layout = kind
}
replacements := map[string]string{
"{{title}}": title,
"{{slug}}": slug,
"{{layout}}": layout,
"{{draft}}": draft,
"{{date}}": date,
"{{lang}}": normalizeContentLang(cfg, lang),
"{{type}}": kind,
}
for old, newValue := range replacements {
body = strings.ReplaceAll(body, old, newValue)
}
return body
}
func defaultPageArchetype(cfg *config.Config, slug, lang string) string {
title := humanizeSlug(slug)
layout := cfg.Content.DefaultLayoutPage
if strings.TrimSpace(layout) == "" {
layout = "page"
}
frontmatter := fmt.Sprintf(`---
title: %s
slug: %s
layout: %s
draft: false
`, title, slug, layout)
lang = normalizeContentLang(cfg, lang)
if lang != "" && lang != cfg.DefaultLang {
frontmatter += fmt.Sprintf("lang: %s\n", lang)
}
frontmatter += `---
# ` + title + "\n\n"
return frontmatter
}
func defaultPostArchetype(cfg *config.Config, slug, lang string) string {
title := humanizeSlug(slug)
layout := cfg.Content.DefaultLayoutPost
if strings.TrimSpace(layout) == "" {
layout = "post"
}
frontmatter := fmt.Sprintf(`---
title: %s
slug: %s
layout: %s
draft: true
date: %s
summary: ""
`, title, slug, layout, time.Now().Format("2006-01-02"))
lang = normalizeContentLang(cfg, lang)
if lang != "" && lang != cfg.DefaultLang {
frontmatter += fmt.Sprintf("lang: %s\n", lang)
}
frontmatter += `---
# ` + title + "\n\n"
return frontmatter
}
func humanizeSlug(slug string) string {
if slug == "" {
return ""
}
parts := strings.Split(slug, "-")
for i, part := range parts {
if part == "" {
continue
}
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
return strings.Join(parts, " ")
}
func normalizeContentLang(cfg *config.Config, lang string) string {
lang = strings.TrimSpace(lang)
if lang == "" {
return cfg.DefaultLang
}
lang = i18n.NormalizeTag(lang)
if !i18n.IsValidTag(lang) {
return cfg.DefaultLang
}
return lang
}
package content
import (
"bytes"
"fmt"
"github.com/adrg/frontmatter"
)
func ParseDocument(src []byte) (*FrontMatter, string, error) {
var fm FrontMatter
body, err := frontmatter.Parse(bytes.NewReader(src), &fm)
if err != nil {
return nil, "", fmt.Errorf("parse frontmatter: %w", err)
}
return &fm, string(body), nil
}
package content
import (
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/taxonomy"
)
type SiteGraph struct {
Config *config.Config
Documents []*Document
ByURL map[string]*Document
ByType map[string][]*Document
ByLang map[string][]*Document
Taxonomies *taxonomy.Index
Data map[string]any
}
func NewSiteGraph(cfg *config.Config) *SiteGraph {
return &SiteGraph{
Config: cfg,
Documents: make([]*Document, 0),
ByURL: make(map[string]*Document),
ByType: make(map[string][]*Document),
ByLang: make(map[string][]*Document),
Taxonomies: taxonomy.New(buildTaxonomyDefinitions(cfg)),
Data: make(map[string]any),
}
}
func (g *SiteGraph) Add(doc *Document) {
g.Documents = append(g.Documents, doc)
g.ByType[doc.Type] = append(g.ByType[doc.Type], doc)
g.ByLang[doc.Lang] = append(g.ByLang[doc.Lang], doc)
if doc.URL != "" {
g.ByURL[doc.URL] = doc
}
g.Taxonomies.AddDocument(
doc.ID,
doc.URL,
doc.Lang,
doc.Type,
doc.Title,
doc.Slug,
doc.Taxonomies,
)
}
func buildTaxonomyDefinitions(cfg *config.Config) map[string]taxonomy.Definition {
out := make(map[string]taxonomy.Definition)
if cfg == nil {
return out
}
for _, name := range cfg.Taxonomies.DefaultSet {
if name == "" {
continue
}
out[name] = taxonomy.Definition{Name: name}
}
for name, def := range cfg.Taxonomies.Definitions {
out[name] = taxonomy.Definition{
Name: name,
Title: def.Title,
Labels: def.Labels,
ArchiveLayout: def.ArchiveLayout,
TermLayout: def.TermLayout,
Order: def.Order,
}
}
return out
}
package content
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/customfields"
"github.com/sphireinc/foundry/internal/data"
"github.com/sphireinc/foundry/internal/fields"
"github.com/sphireinc/foundry/internal/i18n"
"github.com/sphireinc/foundry/internal/lifecycle"
"github.com/sphireinc/foundry/internal/markup"
"github.com/sphireinc/foundry/internal/theme"
)
// Hooks exposes the content-loading lifecycle to plugins and other integrators.
//
// The hook order for a full load is:
// 1. OnDataLoaded
// 2. OnGraphBuilding
// 3. For each discovered document:
// a. OnContentDiscovered
// b. OnFrontmatterParsed
// c. OnMarkdownRendered
// d. OnDocumentParsed
// 4. OnTaxonomyBuilt
// 5. OnGraphBuilt
//
// Hook implementations may mutate Document and SiteGraph values, but should do
// so carefully because later phases depend on normalized data.
type Hooks interface {
OnContentDiscovered(path string) error
OnFrontmatterParsed(*Document) error
OnMarkdownRendered(*Document) error
OnDocumentParsed(*Document) error
OnDataLoaded(map[string]any) error
OnGraphBuilding(*SiteGraph) error
OnGraphBuilt(*SiteGraph) error
OnTaxonomyBuilt(*SiteGraph) error
}
// noopHooks provides a type-safe no-op Hooks implementation.
type noopHooks struct{}
func (noopHooks) OnContentDiscovered(path string) error { _ = path; return nil }
func (noopHooks) OnFrontmatterParsed(*Document) error { return nil }
func (noopHooks) OnMarkdownRendered(*Document) error { return nil }
func (noopHooks) OnDocumentParsed(*Document) error { return nil }
func (noopHooks) OnDataLoaded(map[string]any) error { return nil }
func (noopHooks) OnGraphBuilding(*SiteGraph) error { return nil }
func (noopHooks) OnGraphBuilt(*SiteGraph) error { return nil }
func (noopHooks) OnTaxonomyBuilt(*SiteGraph) error { return nil }
// Loader reads content files and data files from disk, normalizes them into
// Documents, and assembles the SiteGraph used by rendering and serving.
type Loader struct {
cfg *config.Config
hooks Hooks
includeDrafts bool
themeManifest *theme.Manifest
}
// NewLoader constructs a loader for the current configuration.
//
// When includeDrafts is false, draft and scheduled-unpublished documents are
// omitted from the resulting graph.
func NewLoader(cfg *config.Config, hooks Hooks, includeDrafts bool) *Loader {
if hooks == nil {
hooks = noopHooks{}
}
var manifest *theme.Manifest
if cfg != nil {
if loaded, err := theme.LoadManifest(cfg.ThemesDir, cfg.Theme); err == nil {
manifest = loaded
}
}
return &Loader{
cfg: cfg,
hooks: hooks,
includeDrafts: includeDrafts,
themeManifest: manifest,
}
}
// Load reads content and data from disk and returns a fully assembled SiteGraph.
func (l *Loader) Load(ctx context.Context) (*SiteGraph, error) {
_ = ctx
graph := NewSiteGraph(l.cfg)
store, err := data.LoadDir(l.cfg.DataDir)
if err != nil {
return nil, fmt.Errorf("load data dir: %w", err)
}
graph.Data = store.All()
if customFieldStore, err := customfields.Load(l.cfg); err == nil {
graph.Data["custom_fields"] = customFieldStore.Values
} else {
return nil, fmt.Errorf("load custom fields: %w", err)
}
if err := l.hooks.OnDataLoaded(graph.Data); err != nil {
return nil, err
}
if err := l.hooks.OnGraphBuilding(graph); err != nil {
return nil, err
}
if err := l.loadSection(graph, "page", filepath.Join(l.cfg.ContentDir, l.cfg.Content.PagesDir)); err != nil {
return nil, err
}
if err := l.loadSection(graph, "post", filepath.Join(l.cfg.ContentDir, l.cfg.Content.PostsDir)); err != nil {
return nil, err
}
if err := l.hooks.OnTaxonomyBuilt(graph); err != nil {
return nil, err
}
if err := l.hooks.OnGraphBuilt(graph); err != nil {
return nil, err
}
return graph, nil
}
// loadSection walks a content section root and adds valid Markdown documents to
// the graph.
func (l *Loader) loadSection(graph *SiteGraph, docType, root string) error {
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("walk section: %w", err)
}
if info.IsDir() || filepath.Ext(path) != ".md" || lifecycle.IsDerivedPath(path) {
return nil
}
if err := l.hooks.OnContentDiscovered(path); err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
lang, relDocPath, isDefault := l.resolveLanguage(rel)
doc, err := l.loadDocument(path, relDocPath, lang, isDefault, docType)
if err != nil {
return err
}
if doc == nil {
return nil
}
if doc.Draft && !l.includeDrafts {
return nil
}
if err := l.hooks.OnDocumentParsed(doc); err != nil {
return err
}
graph.Add(doc)
return nil
})
}
// resolveLanguage splits a section-relative path into language and document path
// components using Foundry's language directory convention.
func (l *Loader) resolveLanguage(rel string) (lang, relDocPath string, isDefault bool) {
return i18n.SplitLeadingLang(rel, l.cfg.DefaultLang)
}
// loadDocument reads, parses, normalizes, and renders a single Markdown file
// into a Document.
func (l *Loader) loadDocument(path, relPath, lang string, isDefault bool, docType string) (*Document, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read document %s: %w", path, err)
}
fm, body, err := ParseDocument(b)
if err != nil {
return nil, fmt.Errorf("parse document %s: %w", path, err)
}
slug := fm.Slug
if slug == "" {
base := filepath.Base(relPath)
slug = strings.TrimSuffix(base, filepath.Ext(base))
}
layout := fm.Layout
if layout == "" {
if docType == "post" {
layout = l.cfg.Content.DefaultLayoutPost
} else {
layout = l.cfg.Content.DefaultLayoutPage
}
}
taxes := make(map[string][]string)
if len(fm.Tags) > 0 {
taxes["tags"] = append([]string{}, fm.Tags...)
}
if len(fm.Categories) > 0 {
taxes["categories"] = append([]string{}, fm.Categories...)
}
for k, v := range fm.Taxonomies {
taxes[k] = append([]string{}, v...)
}
doc := &Document{
ID: lang + ":" + docType + ":" + strings.TrimSuffix(relPath, ".md"),
Type: docType,
Lang: lang,
IsDefault: isDefault,
Title: fm.Title,
Slug: slug,
Layout: layout,
SourcePath: filepath.ToSlash(path),
RelPath: relPath,
RawBody: body,
Summary: buildSummary(fm.Summary, body),
Date: fm.Date,
CreatedAt: fm.CreatedAt,
UpdatedAt: fm.UpdatedAt,
Draft: fm.Draft,
Author: strings.TrimSpace(fm.Author),
LastEditor: strings.TrimSpace(fm.LastEditor),
Params: fm.Params,
Fields: fields.ApplyDefaults(fields.Normalize(fm.Fields), l.fieldDefinitionsForDocument(docType, layout, slug)),
Taxonomies: taxes,
}
workflow := WorkflowFromFrontMatter(fm, time.Now().UTC())
doc.Status = workflow.Status
if workflow.Status == "scheduled" && !l.includeDrafts {
return nil, nil
}
if workflow.ScheduledUnpublish != nil && time.Now().UTC().After(*workflow.ScheduledUnpublish) && !l.includeDrafts {
return nil, nil
}
if doc.Title == "" {
doc.Title = slug
}
if err := l.hooks.OnFrontmatterParsed(doc); err != nil {
return nil, err
}
htmlBody, err := markup.MarkdownToHTML(doc.RawBody, l.cfg.Security.AllowUnsafeHTML)
if err != nil {
return nil, fmt.Errorf("render markdown %s: %w", path, err)
}
doc.HTMLBody = htmlBody
if err := l.hooks.OnMarkdownRendered(doc); err != nil {
return nil, err
}
return doc, nil
}
func (l *Loader) fieldDefinitionsForDocument(docType, layout, slug string) []fields.Definition {
defs := theme.DocumentFieldDefinitions(l.cfg.ThemesDir, l.cfg.Theme, docType, layout, slug)
if len(defs) > 0 {
return defs
}
return fields.DefinitionsFor(l.cfg, docType)
}
func buildSummary(explicit, body string) string {
if strings.TrimSpace(explicit) != "" {
return strings.TrimSpace(explicit)
}
body = strings.TrimSpace(body)
body = strings.ReplaceAll(body, "\n", " ")
body = strings.ReplaceAll(body, "\r", " ")
body = strings.Join(strings.Fields(body), " ")
const maxLen = 180
if utf8.RuneCountInString(body) <= maxLen {
return body
}
runes := []rune(body)
return strings.TrimSpace(string(runes[:maxLen])) + "..."
}
package content
import (
"fmt"
"strings"
"unicode"
)
// AuthorSlug normalizes a human author name into the canonical segment used by
// Foundry's built-in author archive routes.
func AuthorSlug(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return ""
}
var b strings.Builder
lastDash := false
for _, r := range name {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
lastDash = false
case r == '-' || unicode.IsSpace(r) || r == '_' || r == '.':
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "author"
}
return out
}
// AuthorArchiveURL returns the canonical built-in author archive URL for a
// language-aware site.
func AuthorArchiveURL(defaultLang, lang, author string) string {
slug := AuthorSlug(author)
if slug == "" {
return ""
}
if strings.TrimSpace(lang) == "" || strings.TrimSpace(lang) == strings.TrimSpace(defaultLang) {
return fmt.Sprintf("/authors/%s/", slug)
}
return fmt.Sprintf("/%s/authors/%s/", strings.TrimSpace(lang), slug)
}
// SearchPageURL returns the canonical built-in search page URL for a
// language-aware site.
func SearchPageURL(defaultLang, lang string) string {
if strings.TrimSpace(lang) == "" || strings.TrimSpace(lang) == strings.TrimSpace(defaultLang) {
return "/search/"
}
return fmt.Sprintf("/%s/search/", strings.TrimSpace(lang))
}
package content
import (
"github.com/fsnotify/fsnotify"
)
func NewWatcher() (*fsnotify.Watcher, error) {
return fsnotify.NewWatcher()
}
package content
import (
"strings"
"time"
)
type Workflow struct {
Status string
Archived bool
InReview bool
ScheduledPublish *time.Time
ScheduledUnpublish *time.Time
EditorialNote string
}
func WorkflowFromFrontMatter(fm *FrontMatter, now time.Time) Workflow {
workflow := Workflow{Status: "published"}
if fm == nil {
return workflow
}
if fm.Draft {
workflow.Status = "draft"
}
if fm.Params != nil {
if value, ok := fm.Params["workflow"].(string); ok && normalizeWorkflowStatus(value) != "" {
workflow.Status = normalizeWorkflowStatus(value)
}
if archived, ok := fm.Params["archived"].(bool); ok && archived {
workflow.Status = "archived"
workflow.Archived = true
}
if note, ok := fm.Params["editorial_note"].(string); ok {
workflow.EditorialNote = strings.TrimSpace(note)
}
if ts := timeFromParam(fm.Params["scheduled_publish_at"]); ts != nil {
workflow.ScheduledPublish = ts
if workflow.Status == "published" && now.Before(*ts) {
workflow.Status = "scheduled"
}
}
if ts := timeFromParam(fm.Params["scheduled_unpublish_at"]); ts != nil {
workflow.ScheduledUnpublish = ts
if now.After(*ts) {
workflow.Status = "draft"
}
}
}
workflow.InReview = workflow.Status == "in_review"
if workflow.Status == "archived" {
workflow.Archived = true
}
return workflow
}
func normalizeWorkflowStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "draft", "in_review", "scheduled", "published", "archived":
return strings.ToLower(strings.TrimSpace(value))
default:
return ""
}
}
func timeFromParam(value any) *time.Time {
switch typed := value.(type) {
case time.Time:
t := typed
return &t
case *time.Time:
return typed
case string:
typed = strings.TrimSpace(typed)
if typed == "" {
return nil
}
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04", "2006-01-02"} {
if parsed, err := time.Parse(layout, typed); err == nil {
return &parsed
}
}
}
return nil
}
func ApplyWorkflowToFrontMatter(fm *FrontMatter, status string, publishAt, unpublishAt *time.Time, note string) {
if fm == nil {
return
}
if fm.Params == nil {
fm.Params = make(map[string]any)
}
status = normalizeWorkflowStatus(status)
if status == "" {
status = "draft"
}
fm.Params["workflow"] = status
switch status {
case "draft":
fm.Draft = true
delete(fm.Params, "archived")
case "in_review":
fm.Draft = true
delete(fm.Params, "archived")
case "scheduled":
fm.Draft = true
delete(fm.Params, "archived")
case "published":
fm.Draft = false
delete(fm.Params, "archived")
case "archived":
fm.Draft = true
fm.Params["archived"] = true
}
if publishAt != nil {
fm.Params["scheduled_publish_at"] = publishAt.UTC().Format(time.RFC3339)
} else {
delete(fm.Params, "scheduled_publish_at")
}
if unpublishAt != nil {
fm.Params["scheduled_unpublish_at"] = unpublishAt.UTC().Format(time.RFC3339)
} else {
delete(fm.Params, "scheduled_unpublish_at")
}
note = strings.TrimSpace(note)
if note != "" {
fm.Params["editorial_note"] = note
} else {
delete(fm.Params, "editorial_note")
}
}
package customfields
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sphireinc/foundry/internal/config"
"gopkg.in/yaml.v3"
)
type Store struct {
Values map[string]any `yaml:"values"`
}
func Path(cfg *config.Config) string {
contentDir := "content"
if cfg != nil && strings.TrimSpace(cfg.ContentDir) != "" {
contentDir = cfg.ContentDir
}
return filepath.Join(contentDir, "custom-fields.yaml")
}
func Load(cfg *config.Config) (*Store, error) {
path := Path(cfg)
body, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Store{Values: map[string]any{}}, nil
}
return nil, err
}
var store Store
if err := yaml.Unmarshal(body, &store); err != nil {
return nil, err
}
if store.Values == nil {
store.Values = map[string]any{}
}
return &store, nil
}
func Save(cfg *config.Config, store *Store) error {
if store == nil {
store = &Store{}
}
if store.Values == nil {
store.Values = map[string]any{}
}
body, err := yaml.Marshal(store)
if err != nil {
return err
}
path := Path(cfg)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, body, 0o644)
}
func NormalizeValues(value any) map[string]any {
if value == nil {
return map[string]any{}
}
if typed, ok := value.(map[string]any); ok {
return typed
}
return map[string]any{}
}
func ExtractGroup(values map[string]any, key string) map[string]any {
if values == nil {
return map[string]any{}
}
group, ok := values[strings.TrimSpace(key)]
if !ok {
return map[string]any{}
}
return NormalizeValues(group)
}
func SetGroup(values map[string]any, key string, group map[string]any) map[string]any {
if values == nil {
values = map[string]any{}
}
key = strings.TrimSpace(key)
if key == "" {
return values
}
values[key] = group
return values
}
func ValidateRoot(values map[string]any) error {
if values == nil {
return nil
}
for key, value := range values {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("custom field groups must not use empty keys")
}
if _, ok := value.(map[string]any); !ok {
return fmt.Errorf("custom field group %q must be an object", key)
}
}
return nil
}
package data
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
func LoadDir(root string) (*Store, error) {
store := New()
info, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return store, nil
}
return nil, fmt.Errorf("stat data dir: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("data path is not a directory: %s", root)
}
err = filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return fmt.Errorf("walk data dir: %w", walkErr)
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".yaml", ".yml", ".json":
default:
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return fmt.Errorf("relative path for %s: %w", path, err)
}
key := normalizeKey(rel)
val, err := loadFile(path, ext)
if err != nil {
return fmt.Errorf("load data file %s: %w", path, err)
}
store.Set(key, val)
return nil
})
if err != nil {
return nil, err
}
return store, nil
}
func loadFile(path, ext string) (any, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
var v any
switch ext {
case ".yaml", ".yml":
if err := yaml.Unmarshal(b, &v); err != nil {
return nil, fmt.Errorf("unmarshal yaml: %w", err)
}
case ".json":
if err := json.Unmarshal(b, &v); err != nil {
return nil, fmt.Errorf("unmarshal json: %w", err)
}
default:
return nil, fmt.Errorf("unsupported extension: %s", ext)
}
return v, nil
}
func normalizeKey(rel string) string {
rel = filepath.ToSlash(rel)
ext := filepath.Ext(rel)
rel = strings.TrimSuffix(rel, ext)
return rel
}
package data
type Store struct {
values map[string]any
}
func New() *Store {
return &Store{
values: make(map[string]any),
}
}
func (s *Store) Set(key string, value any) {
s.values[key] = value
}
func (s *Store) Get(key string) (any, bool) {
v, ok := s.values[key]
return v, ok
}
func (s *Store) All() map[string]any {
return s.values
}
package debugutil
import (
"context"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/site"
)
func LoadGraph(cfg *config.Config) (*content.SiteGraph, error) {
graph, _, err := site.LoadConfiguredGraph(context.Background(), cfg, true)
if err != nil {
return nil, err
}
return graph, nil
}
package debugutil
import "github.com/sphireinc/foundry/internal/plugins"
func DetectHooks(p plugins.Plugin) []string {
type hookCheck struct {
name string
ok bool
}
hooks := []hookCheck{
{"ConfigLoadedHook", implements[plugins.ConfigLoadedHook](p)},
{"ContentDiscoveredHook", implements[plugins.ContentDiscoveredHook](p)},
{"FrontmatterParsedHook", implements[plugins.FrontmatterParsedHook](p)},
{"MarkdownRenderedHook", implements[plugins.MarkdownRenderedHook](p)},
{"DocumentParsedHook", implements[plugins.DocumentParsedHook](p)},
{"DataLoadedHook", implements[plugins.DataLoadedHook](p)},
{"GraphBuildingHook", implements[plugins.GraphBuildingHook](p)},
{"GraphBuiltHook", implements[plugins.GraphBuiltHook](p)},
{"TaxonomyBuiltHook", implements[plugins.TaxonomyBuiltHook](p)},
{"RoutesAssignedHook", implements[plugins.RoutesAssignedHook](p)},
{"ContextHook", implements[plugins.ContextHook](p)},
{"AssetsHook", implements[plugins.AssetsHook](p)},
{"HTMLSlotsHook", implements[plugins.HTMLSlotsHook](p)},
{"BeforeRenderHook", implements[plugins.BeforeRenderHook](p)},
{"AfterRenderHook", implements[plugins.AfterRenderHook](p)},
{"AssetsBuildingHook", implements[plugins.AssetsBuildingHook](p)},
{"BuildStartedHook", implements[plugins.BuildStartedHook](p)},
{"BuildCompletedHook", implements[plugins.BuildCompletedHook](p)},
{"ServerStartedHook", implements[plugins.ServerStartedHook](p)},
{"RoutesRegisterHook", implements[plugins.RoutesRegisterHook](p)},
{"CLIHook", implements[plugins.CLIHook](p)},
}
out := make([]string, 0)
for _, h := range hooks {
if h.ok {
out = append(out, h.name)
}
}
return out
}
func Implements[T any](v any) bool {
return implements[T](v)
}
func implements[T any](v any) bool {
_, ok := v.(T)
return ok
}
package deps
import (
"fmt"
"path/filepath"
"github.com/sphireinc/foundry/internal/content"
)
func BuildSiteDependencyGraph(site *content.SiteGraph, themeName string) *Graph {
g := NewGraph()
outputIDs := make(map[string]struct{})
baseTemplatePath := filepath.Join(site.Config.ThemesDir, themeName, "layouts", "base.html")
baseTemplateID := templateNodeID(baseTemplatePath)
g.AddNode(&Node{
ID: baseTemplateID,
Type: NodeTemplate,
Meta: map[string]any{"path": filepath.ToSlash(baseTemplatePath)},
})
for _, doc := range site.Documents {
sourceID := sourceNodeID(doc.SourcePath)
docID := documentNodeID(doc.ID)
layoutPath := filepath.Join(site.Config.ThemesDir, themeName, "layouts", doc.Layout+".html")
layoutID := templateNodeID(layoutPath)
outputID := outputNodeID(doc.URL)
g.AddNode(&Node{
ID: sourceID,
Type: NodeSource,
Meta: map[string]any{"path": filepath.ToSlash(doc.SourcePath)},
})
g.AddNode(&Node{
ID: docID,
Type: NodeDocument,
Meta: map[string]any{"document_id": doc.ID, "url": doc.URL, "type": doc.Type},
})
g.AddNode(&Node{
ID: layoutID,
Type: NodeTemplate,
Meta: map[string]any{"path": filepath.ToSlash(layoutPath)},
})
g.AddNode(&Node{
ID: outputID,
Type: NodeOutput,
Meta: map[string]any{"url": doc.URL},
})
outputIDs[outputID] = struct{}{}
g.AddEdge(sourceID, docID)
g.AddEdge(docID, outputID)
g.AddEdge(layoutID, outputID)
g.AddEdge(baseTemplateID, outputID)
for taxonomy, terms := range doc.Taxonomies {
def := site.Taxonomies.Definition(taxonomy)
layoutPath := filepath.Join(site.Config.ThemesDir, themeName, "layouts", def.EffectiveTermLayout()+".html")
layoutID := templateNodeID(layoutPath)
g.AddNode(&Node{
ID: layoutID,
Type: NodeTemplate,
Meta: map[string]any{"path": filepath.ToSlash(layoutPath)},
})
for _, term := range terms {
taxID := taxonomyNodeID(taxonomy, term, doc.Lang)
taxURL := taxonomyOutputURL(site.Config.DefaultLang, doc.Lang, taxonomy, term)
taxOutputID := outputNodeID(taxURL)
g.AddNode(&Node{
ID: taxID,
Type: NodeTaxonomy,
Meta: map[string]any{
"taxonomy": taxonomy,
"term": term,
"lang": doc.Lang,
},
})
g.AddNode(&Node{
ID: taxOutputID,
Type: NodeOutput,
Meta: map[string]any{"url": taxURL},
})
outputIDs[taxOutputID] = struct{}{}
g.AddEdge(docID, taxID)
g.AddEdge(taxID, taxOutputID)
g.AddEdge(layoutID, taxOutputID)
g.AddEdge(baseTemplateID, taxOutputID)
}
}
}
for key := range site.Data {
dataID := dataNodeID(key)
g.AddNode(&Node{
ID: dataID,
Type: NodeData,
Meta: map[string]any{"key": key},
})
for outputID := range outputIDs {
g.AddEdge(dataID, outputID)
}
}
return g
}
func taxonomyOutputURL(defaultLang, lang, taxonomy, term string) string {
if lang == "" || lang == defaultLang {
return fmt.Sprintf("/%s/%s/", taxonomy, term)
}
return fmt.Sprintf("/%s/%s/%s/", lang, taxonomy, term)
}
func sourceNodeID(path string) string {
return "source:" + filepath.ToSlash(path)
}
func documentNodeID(id string) string {
return "document:" + id
}
func templateNodeID(path string) string {
return "template:" + filepath.ToSlash(path)
}
func dataNodeID(key string) string {
return "data:" + key
}
func taxonomyNodeID(taxonomy, term, lang string) string {
return fmt.Sprintf("taxonomy:%s:%s:%s", taxonomy, term, lang)
}
func outputNodeID(url string) string {
return "output:" + url
}
package deps
import "sort"
type NodeType string
const (
NodeSource NodeType = "source"
NodeDocument NodeType = "document"
NodeTemplate NodeType = "template"
NodeData NodeType = "data"
NodeTaxonomy NodeType = "taxonomy"
NodeOutput NodeType = "output"
)
type Node struct {
ID string `json:"id"`
Type NodeType `json:"type"`
Meta map[string]any `json:"meta"`
}
type Edge struct {
From string `json:"from"`
To string `json:"to"`
}
type Graph struct {
nodes map[string]*Node
forward map[string]map[string]struct{}
reverse map[string]map[string]struct{}
}
func NewGraph() *Graph {
return &Graph{
nodes: make(map[string]*Node),
forward: make(map[string]map[string]struct{}),
reverse: make(map[string]map[string]struct{}),
}
}
func (g *Graph) AddNode(n *Node) {
if n == nil || n.ID == "" {
return
}
if _, ok := g.nodes[n.ID]; !ok {
g.nodes[n.ID] = n
}
}
func (g *Graph) AddEdge(from, to string) {
if from == "" || to == "" {
return
}
if _, ok := g.forward[from]; !ok {
g.forward[from] = make(map[string]struct{})
}
if _, ok := g.reverse[to]; !ok {
g.reverse[to] = make(map[string]struct{})
}
g.forward[from][to] = struct{}{}
g.reverse[to][from] = struct{}{}
}
func (g *Graph) Node(id string) (*Node, bool) {
n, ok := g.nodes[id]
return n, ok
}
func (g *Graph) Nodes() []*Node {
out := make([]*Node, 0, len(g.nodes))
for _, n := range g.nodes {
out = append(out, n)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Type != out[j].Type {
return out[i].Type < out[j].Type
}
return out[i].ID < out[j].ID
})
return out
}
func (g *Graph) Edges() []Edge {
out := make([]Edge, 0)
for from, tos := range g.forward {
for to := range tos {
out = append(out, Edge{
From: from,
To: to,
})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].From != out[j].From {
return out[i].From < out[j].From
}
return out[i].To < out[j].To
})
return out
}
func (g *Graph) DirectDependentsOf(id string) []string {
out := make([]string, 0)
for dep := range g.forward[id] {
out = append(out, dep)
}
sort.Strings(out)
return out
}
func (g *Graph) DependenciesOf(id string) []string {
out := make([]string, 0)
for dep := range g.reverse[id] {
out = append(out, dep)
}
sort.Strings(out)
return out
}
func (g *Graph) DependentsOf(id string) []string {
seen := make(map[string]struct{})
queue := []string{id}
out := make([]string, 0)
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for dep := range g.forward[cur] {
if _, ok := seen[dep]; ok {
continue
}
seen[dep] = struct{}{}
out = append(out, dep)
queue = append(queue, dep)
}
}
sort.Strings(out)
return out
}
func (g *Graph) Export() map[string]any {
return map[string]any{
"nodes": g.Nodes(),
"edges": g.Edges(),
}
}
package deps
type ChangeSet struct {
Sources []string
Templates []string
DataKeys []string
Assets []string
Full bool
}
type RebuildPlan struct {
OutputURLs []string
FullRebuild bool
}
func ResolveRebuildPlan(g *Graph, changes ChangeSet) RebuildPlan {
if changes.Full {
return RebuildPlan{FullRebuild: true}
}
affected := make(map[string]struct{})
addDependents := func(nodeID string) {
for _, dep := range g.DependentsOf(nodeID) {
affected[dep] = struct{}{}
}
}
for _, s := range changes.Sources {
addDependents(sourceNodeID(s))
}
for _, t := range changes.Templates {
addDependents(templateNodeID(t))
}
for _, d := range changes.DataKeys {
addDependents(dataNodeID(d))
}
plan := RebuildPlan{
OutputURLs: make([]string, 0),
}
for id := range affected {
n, ok := g.Node(id)
if !ok {
continue
}
if n.Type == NodeOutput {
if v, ok := n.Meta["url"].(string); ok {
plan.OutputURLs = append(plan.OutputURLs, v)
}
}
}
return plan
}
package diag
import (
"errors"
"fmt"
)
type Kind string
const (
KindUnknown Kind = "unknown"
KindUsage Kind = "usage"
KindConfig Kind = "config"
KindPlugin Kind = "plugin"
KindIO Kind = "io"
KindDependency Kind = "dependency"
KindBuild Kind = "build"
KindServe Kind = "serve"
KindRender Kind = "render"
KindInternal Kind = "internal"
)
type Error struct {
Kind Kind
Op string
Err error
}
func (e *Error) Error() string {
switch {
case e == nil:
return ""
case e.Op != "" && e.Err != nil:
return fmt.Sprintf("%s: %v", e.Op, e.Err)
case e.Op != "":
return e.Op
case e.Err != nil:
return e.Err.Error()
default:
return string(e.Kind)
}
}
func (e *Error) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func New(kind Kind, msg string) error {
return &Error{
Kind: kind,
Op: msg,
}
}
func Wrap(kind Kind, op string, err error) error {
if err == nil {
return nil
}
var de *Error
if errors.As(err, &de) {
if de.Kind == KindUnknown && kind != KindUnknown {
return &Error{
Kind: kind,
Op: opOrFallback(op, de.Op),
Err: de.Err,
}
}
if op != "" {
return &Error{
Kind: de.Kind,
Op: op,
Err: de.Err,
}
}
return err
}
return &Error{
Kind: kind,
Op: op,
Err: err,
}
}
func KindOf(err error) Kind {
var de *Error
if errors.As(err, &de) && de.Kind != "" {
return de.Kind
}
return KindUnknown
}
func Present(err error) string {
if err == nil {
return ""
}
kind := KindOf(err)
switch kind {
case KindUsage:
return fmt.Sprintf("usage error: %v", err)
case KindConfig:
return fmt.Sprintf("config error: %v", err)
case KindPlugin:
return fmt.Sprintf("plugin error: %v", err)
case KindIO:
return fmt.Sprintf("io error: %v", err)
case KindDependency:
return fmt.Sprintf("dependency error: %v", err)
case KindBuild:
return fmt.Sprintf("build error: %v", err)
case KindServe:
return fmt.Sprintf("serve error: %v", err)
case KindRender:
return fmt.Sprintf("render error: %v", err)
case KindInternal:
return fmt.Sprintf("internal error: %v", err)
default:
return err.Error()
}
}
func ExitCode(err error) int {
switch KindOf(err) {
case KindUsage:
return 2
default:
return 1
}
}
func opOrFallback(primary, fallback string) string {
if primary != "" {
return primary
}
return fallback
}
package feed
import (
"encoding/xml"
"sort"
"strings"
"time"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
)
type RSS struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Atom string `xml:"xmlns:atom,attr,omitempty"`
Channel RSSChannel `xml:"channel"`
}
type RSSChannel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
LastBuildDate string `xml:"lastBuildDate,omitempty"`
Items []RSSItem `xml:"item"`
}
type RSSItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
GUID string `xml:"guid"`
Description string `xml:"description,omitempty"`
PubDate string `xml:"pubDate,omitempty"`
}
type URLSet struct {
XMLName xml.Name `xml:"urlset"`
Xmlns string `xml:"xmlns,attr"`
URLs []SitemapURL `xml:"url"`
}
type SitemapURL struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
}
func Build(cfg *config.Config, graph *content.SiteGraph) ([]byte, []byte, error) {
rssXML, err := BuildRSS(cfg, graph)
if err != nil {
return nil, nil, err
}
sitemapXML, err := BuildSitemap(cfg, graph)
if err != nil {
return nil, nil, err
}
return rssXML, sitemapXML, nil
}
func BuildRSS(cfg *config.Config, graph *content.SiteGraph) ([]byte, error) {
posts := collectPosts(graph)
limit := cfg.Feed.RSSLimit
if limit > 0 && len(posts) > limit {
posts = posts[:limit]
}
baseURL := strings.TrimRight(cfg.BaseURL, "/")
items := make([]RSSItem, 0, len(posts))
var lastBuild string
for _, post := range posts {
item := RSSItem{
Title: post.Title,
Link: absoluteURL(baseURL, post.URL),
GUID: absoluteURL(baseURL, post.URL),
Description: post.Summary,
}
if post.Date != nil {
item.PubDate = post.Date.Format(time.RFC1123Z)
if lastBuild == "" {
lastBuild = item.PubDate
}
}
items = append(items, item)
}
payload := RSS{
Version: "2.0",
Atom: "http://www.w3.org/2005/Atom",
Channel: RSSChannel{
Title: choose(cfg.Feed.RSSTitle, cfg.Title),
Link: baseURL,
Description: choose(cfg.Feed.RSSDescription, cfg.Title),
LastBuildDate: lastBuild,
Items: items,
},
}
out, err := xml.MarshalIndent(payload, "", " ")
if err != nil {
return nil, err
}
return append([]byte(xml.Header), out...), nil
}
func BuildSitemap(cfg *config.Config, graph *content.SiteGraph) ([]byte, error) {
baseURL := strings.TrimRight(cfg.BaseURL, "/")
urls := make([]SitemapURL, 0, len(graph.Documents))
for _, doc := range graph.Documents {
if doc == nil || doc.Draft {
continue
}
entry := SitemapURL{
Loc: absoluteURL(baseURL, doc.URL),
}
if doc.UpdatedAt != nil {
entry.LastMod = doc.UpdatedAt.Format("2006-01-02")
} else if doc.Date != nil {
entry.LastMod = doc.Date.Format("2006-01-02")
}
urls = append(urls, entry)
}
payload := URLSet{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: urls,
}
out, err := xml.MarshalIndent(payload, "", " ")
if err != nil {
return nil, err
}
return append([]byte(xml.Header), out...), nil
}
func collectPosts(graph *content.SiteGraph) []*content.Document {
posts := make([]*content.Document, 0)
for _, doc := range graph.ByType["post"] {
if doc == nil || doc.Draft {
continue
}
posts = append(posts, doc)
}
sort.Slice(posts, func(i, j int) bool {
var ti, tj time.Time
if posts[i].Date != nil {
ti = *posts[i].Date
}
if posts[j].Date != nil {
tj = *posts[j].Date
}
return ti.After(tj)
})
return posts
}
func absoluteURL(baseURL, path string) string {
baseURL = strings.TrimRight(baseURL, "/")
if path == "" {
return baseURL
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return baseURL + path
}
func choose(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
package fields
import (
"fmt"
"strings"
"github.com/sphireinc/foundry/internal/config"
)
// Definition is the schema field definition type used by Foundry's structured
// content-modeling system.
type Definition = config.FieldDefinition
// SchemaSet is the configured set of field schemas keyed by content kind.
type SchemaSet = config.FieldSchemaSet
// Normalize ensures a field-value map is always non-nil.
func Normalize(in map[string]any) map[string]any {
if in == nil {
return map[string]any{}
}
return in
}
// DefinitionsFor returns the field definitions for a content kind.
//
// Kind-specific schemas take precedence over the "default" schema.
func DefinitionsFor(cfg *config.Config, kind string) []Definition {
if cfg == nil {
return nil
}
kind = strings.ToLower(strings.TrimSpace(kind))
if schema, ok := cfg.Fields.Schemas[kind]; ok {
return append([]Definition(nil), schema.Fields...)
}
if schema, ok := cfg.Fields.Schemas["default"]; ok {
return append([]Definition(nil), schema.Fields...)
}
return nil
}
// ApplyDefaults fills missing schema-defined fields with default values or
// empty container values for object/repeater fields.
func ApplyDefaults(values map[string]any, defs []Definition) map[string]any {
out := cloneMap(values)
for _, def := range defs {
if _, ok := out[def.Name]; ok {
continue
}
if def.Default != nil {
out[def.Name] = cloneValue(def.Default)
continue
}
switch normalizeType(def.Type) {
case "object":
out[def.Name] = ApplyDefaults(nil, def.Fields)
case "repeater":
out[def.Name] = []any{}
}
}
return out
}
// PruneToDefinitions removes values that are not declared by defs while
// preserving recursively-declared object/repeater structure.
func PruneToDefinitions(values map[string]any, defs []Definition) map[string]any {
values = Normalize(values)
if len(defs) == 0 {
return map[string]any{}
}
out := make(map[string]any, len(defs))
for _, def := range defs {
value, ok := values[def.Name]
if !ok {
continue
}
switch normalizeType(def.Type) {
case "object":
if obj, ok := value.(map[string]any); ok {
out[def.Name] = PruneToDefinitions(obj, def.Fields)
}
case "repeater":
if items, ok := value.([]any); ok {
if def.Item == nil {
out[def.Name] = cloneValue(items)
continue
}
pruned := make([]any, 0, len(items))
for _, item := range items {
switch normalizeType(def.Item.Type) {
case "object":
if obj, ok := item.(map[string]any); ok {
pruned = append(pruned, PruneToDefinitions(obj, def.Item.Fields))
}
default:
pruned = append(pruned, cloneValue(item))
}
}
out[def.Name] = pruned
}
default:
out[def.Name] = cloneValue(value)
}
}
return out
}
// Validate checks a field-value map against schema definitions.
//
// When allowAnything is false, values not declared in defs are rejected.
func Validate(values map[string]any, defs []Definition, allowAnything bool) []error {
var errs []error
values = Normalize(values)
known := make(map[string]Definition, len(defs))
for _, def := range defs {
known[def.Name] = def
value, ok := values[def.Name]
if !ok {
if def.Required {
errs = append(errs, fmt.Errorf("field %q is required", def.Name))
}
continue
}
errs = append(errs, validateValue(def.Name, value, def)...)
}
if !allowAnything {
for name := range values {
if _, ok := known[name]; !ok {
errs = append(errs, fmt.Errorf("field %q is not allowed by schema", name))
}
}
}
return errs
}
// validateValue validates a single value recursively against one field
// definition.
func validateValue(path string, value any, def Definition) []error {
var errs []error
switch normalizeType(def.Type) {
case "text", "textarea":
if _, ok := value.(string); !ok {
errs = append(errs, fmt.Errorf("field %q must be a string", path))
}
case "bool":
if _, ok := value.(bool); !ok {
errs = append(errs, fmt.Errorf("field %q must be a boolean", path))
}
case "number":
switch value.(type) {
case int, int64, float64, float32:
default:
errs = append(errs, fmt.Errorf("field %q must be numeric", path))
}
case "select":
text, ok := value.(string)
if !ok {
errs = append(errs, fmt.Errorf("field %q must be a string", path))
} else if len(def.Enum) > 0 && !contains(def.Enum, text) {
errs = append(errs, fmt.Errorf("field %q must be one of %s", path, strings.Join(def.Enum, ", ")))
}
case "object":
obj, ok := value.(map[string]any)
if !ok {
errs = append(errs, fmt.Errorf("field %q must be an object", path))
} else {
for _, err := range Validate(obj, def.Fields, false) {
errs = append(errs, fmt.Errorf("%s.%v", path, err))
}
}
case "repeater":
items, ok := value.([]any)
if !ok {
errs = append(errs, fmt.Errorf("field %q must be a list", path))
} else if def.Item != nil {
for index, item := range items {
errs = append(errs, validateValue(fmt.Sprintf("%s[%d]", path, index), item, *def.Item)...)
}
}
}
return errs
}
func normalizeType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "text", "string":
return "text"
case "textarea":
return "textarea"
case "bool", "boolean":
return "bool"
case "number", "int", "float":
return "number"
case "select", "enum":
return "select"
case "object":
return "object"
case "repeater", "list", "array":
return "repeater"
default:
return "text"
}
}
func contains(values []string, target string) bool {
for _, value := range values {
if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) {
return true
}
}
return false
}
func cloneMap(values map[string]any) map[string]any {
if values == nil {
return map[string]any{}
}
out := make(map[string]any, len(values))
for key, value := range values {
out[key] = cloneValue(value)
}
return out
}
func cloneValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneMap(typed)
case []any:
out := make([]any, len(typed))
for i := range typed {
out[i] = cloneValue(typed[i])
}
return out
default:
return typed
}
}
package hostservice
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const (
metadataFile = "service.json"
logFileName = "service.log"
binaryName = "foundry-service"
)
type Metadata struct {
Name string `json:"name"`
Label string `json:"label"`
Platform string `json:"platform"`
ProjectDir string `json:"project_dir"`
ServicePath string `json:"service_path"`
Executable string `json:"executable"`
LogPath string `json:"log_path"`
InstalledAt time.Time `json:"installed_at"`
InstallScope string `json:"install_scope"`
}
type Status struct {
Installed bool
Running bool
Enabled bool
Message string
Metadata *Metadata
}
func ProjectRunDir(projectDir string) string {
return filepath.Join(projectDir, ".foundry", "run")
}
func EnsureRunDir(projectDir string) (string, error) {
runDir := ProjectRunDir(projectDir)
if err := os.MkdirAll(runDir, 0o755); err != nil {
return "", err
}
return runDir, nil
}
func metadataPath(projectDir string) string {
return filepath.Join(ProjectRunDir(projectDir), metadataFile)
}
func logPath(projectDir string) string {
return filepath.Join(ProjectRunDir(projectDir), logFileName)
}
func LoadMetadata(projectDir string) (*Metadata, error) {
body, err := os.ReadFile(metadataPath(projectDir))
if err != nil {
return nil, err
}
var meta Metadata
if err := json.Unmarshal(body, &meta); err != nil {
return nil, err
}
return &meta, nil
}
func SaveMetadata(projectDir string, meta Metadata) error {
if _, err := EnsureRunDir(projectDir); err != nil {
return err
}
body, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return os.WriteFile(metadataPath(projectDir), body, 0o644)
}
func RemoveMetadata(projectDir string) error {
if err := os.Remove(metadataPath(projectDir)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func ServiceName(projectDir string) string {
base := strings.ToLower(filepath.Base(projectDir))
base = sanitize(base)
sum := sha1.Sum([]byte(filepath.Clean(projectDir)))
return fmt.Sprintf("foundry-%s-%s", base, hex.EncodeToString(sum[:])[:8])
}
func ServiceLabel(projectDir string) string {
return "io.getfoundry." + ServiceName(projectDir)
}
func sanitize(value string) string {
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "site"
}
return out
}
func shouldUseManagedBinary(executablePath, projectDir string) bool {
exe := filepath.Clean(executablePath)
tmp := filepath.Clean(os.TempDir())
if strings.Contains(exe, string(filepath.Separator)+"go-build"+string(filepath.Separator)) {
return fileExists(filepath.Join(projectDir, "cmd", "foundry", "main.go"))
}
if strings.HasPrefix(exe, tmp+string(filepath.Separator)) && fileExists(filepath.Join(projectDir, "cmd", "foundry", "main.go")) {
return true
}
return false
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func EnsureExecutable(projectDir string) (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
if !shouldUseManagedBinary(exe, projectDir) {
return exe, nil
}
return ensureManagedBinary(projectDir)
}
func ensureManagedBinary(projectDir string) (string, error) {
if _, err := exec.LookPath("go"); err != nil {
return "", fmt.Errorf("foundry was launched via go run but go is not available in PATH")
}
runDir, err := EnsureRunDir(projectDir)
if err != nil {
return "", err
}
name := binaryName
if runtime.GOOS == "windows" {
name += ".exe"
}
target := filepath.Join(runDir, name)
cmd := exec.Command("go", "build", "-o", target, "./cmd/foundry")
cmd.Dir = projectDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Env = append(os.Environ(),
"CGO_ENABLED=0",
"GOOS="+runtime.GOOS,
"GOARCH="+runtime.GOARCH,
)
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("build managed service binary: %w", err)
}
return target, nil
}
func Install(projectDir string) (*Metadata, error) {
executable, err := EnsureExecutable(projectDir)
if err != nil {
return nil, err
}
meta, err := install(projectDir, executable)
if err != nil {
return nil, err
}
if meta.InstalledAt.IsZero() {
meta.InstalledAt = time.Now().UTC()
}
if err := SaveMetadata(projectDir, *meta); err != nil {
return nil, err
}
return meta, nil
}
func Uninstall(projectDir string) error {
meta, err := LoadMetadata(projectDir)
if err != nil {
if os.IsNotExist(err) {
meta, err = metadataForProject(projectDir)
if err != nil {
return err
}
if err := uninstall(meta); err != nil {
return err
}
return nil
}
return err
}
if err := uninstall(meta); err != nil {
return err
}
return RemoveMetadata(projectDir)
}
func Start(projectDir string) error {
meta, err := resolvedMetadata(projectDir)
if err != nil {
return err
}
return start(meta)
}
func Stop(projectDir string) error {
meta, err := resolvedMetadata(projectDir)
if err != nil {
return err
}
return stop(meta)
}
func Restart(projectDir string) error {
meta, err := resolvedMetadata(projectDir)
if err != nil {
return err
}
return restart(meta)
}
func CheckStatus(projectDir string) (*Status, error) {
meta, err := resolvedMetadata(projectDir)
if err != nil {
if os.IsNotExist(err) {
return status(projectDir, nil)
}
return nil, err
}
return status(projectDir, meta)
}
func resolvedMetadata(projectDir string) (*Metadata, error) {
meta, err := LoadMetadata(projectDir)
if err == nil {
return meta, nil
}
if !os.IsNotExist(err) {
return nil, err
}
return metadataForProject(projectDir)
}
func metadataForProject(projectDir string) (*Metadata, error) {
servicePath, scope, platform, err := servicePath(projectDir)
if err != nil {
return nil, err
}
return &Metadata{
Name: ServiceName(projectDir),
Label: ServiceLabel(projectDir),
Platform: platform,
ProjectDir: projectDir,
ServicePath: servicePath,
LogPath: logPath(projectDir),
InstallScope: scope,
}, nil
}
//go:build linux
package hostservice
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func servicePath(projectDir string) (string, string, string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", "", "", err
}
name := ServiceName(projectDir) + ".service"
return filepath.Join(home, ".config", "systemd", "user", name), "user", "linux", nil
}
func install(projectDir, executable string) (*Metadata, error) {
meta, err := metadataForProject(projectDir)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(meta.ServicePath), 0o755); err != nil {
return nil, err
}
meta.Executable = executable
body := renderSystemdUnit(*meta)
if err := os.WriteFile(meta.ServicePath, []byte(body), 0o644); err != nil {
return nil, err
}
if err := runCmd("systemctl", "--user", "daemon-reload"); err != nil {
return nil, err
}
if err := runCmd("systemctl", "--user", "enable", meta.Name+".service"); err != nil {
return nil, err
}
if err := runCmd("systemctl", "--user", "restart", meta.Name+".service"); err != nil {
return nil, err
}
return meta, nil
}
func uninstall(meta *Metadata) error {
_ = runCmd("systemctl", "--user", "disable", "--now", meta.Name+".service")
_ = runCmd("systemctl", "--user", "daemon-reload")
if err := os.Remove(meta.ServicePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func start(meta *Metadata) error {
return runCmd("systemctl", "--user", "start", meta.Name+".service")
}
func stop(meta *Metadata) error {
return runCmd("systemctl", "--user", "stop", meta.Name+".service")
}
func restart(meta *Metadata) error {
return runCmd("systemctl", "--user", "restart", meta.Name+".service")
}
func status(projectDir string, meta *Metadata) (*Status, error) {
result := &Status{Metadata: meta}
if meta == nil {
return result, nil
}
if _, err := os.Stat(meta.ServicePath); err == nil {
result.Installed = true
}
result.Running = cmdSucceeds("systemctl", "--user", "is-active", "--quiet", meta.Name+".service")
result.Enabled = cmdSucceeds("systemctl", "--user", "is-enabled", "--quiet", meta.Name+".service")
if !result.Installed {
result.Message = "service file not installed"
} else if result.Running {
result.Message = "service is running"
} else {
result.Message = "service is installed but not running"
}
return result, nil
}
func renderSystemdUnit(meta Metadata) string {
return fmt.Sprintf(`[Unit]
Description=Foundry CMS (%s)
After=network.target
[Service]
Type=simple
WorkingDirectory=%s
ExecStart=%s serve
Restart=always
RestartSec=3
StandardOutput=append:%s
StandardError=append:%s
[Install]
WantedBy=default.target
`, escapeSystemd(meta.Name), escapeSystemd(meta.ProjectDir), escapeSystemd(meta.Executable), escapeSystemd(meta.LogPath), escapeSystemd(meta.LogPath))
}
func escapeSystemd(value string) string {
replacer := strings.NewReplacer("\\", "\\\\", "\n", " ")
return replacer.Replace(value)
}
func runCmd(name string, args ...string) error {
cmd := exec.Command(name, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
message := strings.TrimSpace(stderr.String())
if message == "" {
return err
}
return fmt.Errorf("%s %s: %s", name, strings.Join(args, " "), message)
}
return nil
}
func cmdSucceeds(name string, args ...string) bool {
cmd := exec.Command(name, args...)
return cmd.Run() == nil
}
package i18n
type Manager struct {
translations map[string]map[string]string
}
func New() *Manager {
return &Manager{
translations: make(map[string]map[string]string),
}
}
func (m *Manager) Add(lang string, key string, value string) {
if _, ok := m.translations[lang]; !ok {
m.translations[lang] = make(map[string]string)
}
m.translations[lang][key] = value
}
func (m *Manager) T(lang, key string) string {
if v, ok := m.translations[lang][key]; ok {
return v
}
return key
}
package i18n
import (
"strings"
"unicode"
)
func NormalizeTag(tag string) string {
tag = strings.TrimSpace(tag)
tag = strings.ReplaceAll(tag, "_", "-")
tag = strings.ToLower(tag)
return tag
}
func IsValidTag(tag string) bool {
tag = NormalizeTag(tag)
if tag == "" {
return false
}
parts := strings.Split(tag, "-")
if len(parts) == 0 {
return false
}
// Primary language subtag: allow 2–3 ASCII letters.
if !isAlpha(parts[0]) || len(parts[0]) < 2 || len(parts[0]) > 3 {
return false
}
// Subsequent subtags: allow 2–8 ASCII alnum chars.
for _, part := range parts[1:] {
if len(part) < 2 || len(part) > 8 {
return false
}
if !isAlnum(part) {
return false
}
}
return true
}
func SplitLeadingLang(rel string, defaultLang string) (lang, relDocPath string, isDefault bool) {
rel = filepathToSlash(rel)
defaultLang = NormalizeTag(defaultLang)
parts := strings.Split(rel, "/")
if len(parts) > 1 {
candidate := NormalizeTag(parts[0])
if IsValidTag(candidate) {
return candidate, strings.Join(parts[1:], "/"), candidate == defaultLang
}
}
return defaultLang, rel, true
}
func isAlpha(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r > unicode.MaxASCII || !unicode.IsLetter(r) {
return false
}
}
return true
}
func isAlnum(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return true
}
func filepathToSlash(s string) string {
return strings.ReplaceAll(s, `\`, `/`)
}
package installmode
import (
"os"
"path/filepath"
"strings"
"github.com/sphireinc/foundry/internal/standalone"
)
type Mode string
const (
Standalone Mode = "standalone"
Docker Mode = "docker"
Source Mode = "source"
Binary Mode = "binary"
Unknown Mode = "unknown"
)
func Detect(projectDir string) Mode {
if _, err := os.Stat("/.dockerenv"); err == nil {
return Docker
}
exe, err := os.Executable()
if err != nil {
return Unknown
}
cleanExe := filepath.Clean(exe)
tmp := filepath.Clean(os.TempDir())
if strings.Contains(cleanExe, string(filepath.Separator)+"go-build"+string(filepath.Separator)) ||
strings.HasPrefix(cleanExe, tmp+string(filepath.Separator)) {
return Source
}
if state, running, err := standalone.RunningState(projectDir); err == nil && state != nil && running {
return Standalone
}
return Binary
}
package installutil
import (
"archive/zip"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/sphireinc/foundry/internal/safepath"
)
func NormalizeGitHubInstallURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "git@") {
return raw
}
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err == nil && strings.EqualFold(u.Host, "github.com") {
u.Path = strings.TrimSuffix(u.Path, "/")
if !strings.HasSuffix(u.Path, ".git") {
u.Path += ".git"
}
return u.String()
}
return raw
}
if strings.Count(raw, "/") == 1 {
return "https://github.com/" + raw + ".git"
}
return raw
}
func ValidateGitHubInstallURL(kind, raw string, validateName func(string) (string, error)) (string, error) {
normalized := NormalizeGitHubInstallURL(raw)
if strings.TrimSpace(normalized) == "" {
return "", nil
}
if strings.HasPrefix(normalized, "git@github.com:") {
name, err := InferRepoName(normalized, kind, validateName)
if err != nil {
return "", err
}
if _, err := validateName(name); err != nil {
return "", fmt.Errorf("invalid GitHub repository path: %w", err)
}
return normalized, nil
}
u, err := url.Parse(normalized)
if err != nil {
return "", fmt.Errorf("parse %s URL: %w", kind, err)
}
if !strings.EqualFold(u.Scheme, "https") {
return "", fmt.Errorf("%s URL must use https or git@github.com", kind)
}
if !strings.EqualFold(u.Host, "github.com") {
return "", fmt.Errorf("%s URL must target github.com", kind)
}
path := strings.Trim(strings.TrimSuffix(u.Path, ".git"), "/")
parts := strings.Split(path, "/")
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return "", fmt.Errorf("%s URL must point to a GitHub owner/repository", kind)
}
if _, err := validateName(parts[1]); err != nil {
return "", fmt.Errorf("invalid GitHub repository path: %w", err)
}
return normalized, nil
}
func InferRepoName(raw, kind string, validateName func(string) (string, error)) (string, error) {
if strings.HasPrefix(raw, "git@") {
idx := strings.Index(raw, ":")
if idx >= 0 && idx+1 < len(raw) {
path := strings.Trim(raw[idx+1:], "/")
parts := strings.Split(path, "/")
name := parts[len(parts)-1]
name = strings.TrimSuffix(name, ".git")
name = strings.TrimSpace(name)
if name != "" {
return validateName(name)
}
}
return "", fmt.Errorf("could not infer %s name from URL", kind)
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("parse %s URL: %w", kind, err)
}
path := strings.Trim(strings.TrimSpace(u.Path), "/")
if path == "" {
return "", fmt.Errorf("could not infer %s name from URL", kind)
}
parts := strings.Split(path, "/")
name := parts[len(parts)-1]
name = strings.TrimSuffix(name, ".git")
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("could not infer %s name from URL", kind)
}
return validateName(name)
}
func RepoZipURL(repoURL string) (string, error) {
if strings.HasPrefix(repoURL, "git@github.com:") {
path := strings.TrimPrefix(repoURL, "git@github.com:")
path = strings.Trim(strings.TrimSuffix(path, ".git"), "/")
if strings.Count(path, "/") != 1 {
return "", fmt.Errorf("zip fallback requires a GitHub owner/repo path")
}
return fmt.Sprintf("https://github.com/%s/archive/refs/heads/main.zip", path), nil
}
u, err := url.Parse(repoURL)
if err != nil {
return "", err
}
if !strings.EqualFold(u.Host, "github.com") {
return "", fmt.Errorf("zip fallback currently supports GitHub only")
}
path := strings.Trim(strings.TrimSuffix(u.Path, ".git"), "/")
return fmt.Sprintf("https://github.com/%s/archive/refs/heads/main.zip", path), nil
}
func DownloadAndExtractRepoArchive(client *http.Client, repoURL, targetRoot, targetName, tempPrefix, kind string, maxBytes int64) error {
zipURL, err := RepoZipURL(repoURL)
if err != nil {
return err
}
targetName, err = safepath.ValidatePathComponent(kind+" install name", targetName)
if err != nil {
return err
}
targetDir, err := safepath.ResolveRelativeUnderRoot(targetRoot, targetName)
if err != nil {
return err
}
resp, err := client.Get(zipURL)
if err != nil {
return fmt.Errorf("download %s zip: %w", kind, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %s", resp.Status)
}
tmpFile, err := os.CreateTemp("", tempPrefix+"-*.zip")
if err != nil {
return err
}
defer os.Remove(tmpFile.Name())
written, err := io.Copy(tmpFile, io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return err
}
if written > maxBytes {
return fmt.Errorf("%s zip exceeds %d bytes", kind, maxBytes)
}
_ = tmpFile.Close()
zr, err := zip.OpenReader(tmpFile.Name())
if err != nil {
return err
}
defer zr.Close()
tempDir, err := os.MkdirTemp("", tempPrefix)
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
tempDirAbs, err := filepath.Abs(tempDir)
if err != nil {
return err
}
for _, f := range zr.File {
cleanName := filepath.Clean(filepath.FromSlash(strings.TrimSpace(f.Name)))
if cleanName == "." || cleanName == "" || cleanName == ".." || filepath.IsAbs(cleanName) || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) {
return fmt.Errorf("zip entry escapes target dir: %s", f.Name)
}
if f.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("zip contains unsupported symlink entry: %s", f.Name)
}
fp, err := filepath.Abs(filepath.Join(tempDirAbs, cleanName))
if err != nil {
return err
}
if fp != tempDirAbs && !strings.HasPrefix(fp, tempDirAbs+string(filepath.Separator)) {
return fmt.Errorf("zip entry escapes target dir: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(fp, f.Mode()); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(fp), 0o755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.Create(fp)
if err != nil {
_ = rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
_ = rc.Close()
_ = out.Close()
return err
}
_ = rc.Close()
_ = out.Close()
}
entries, err := os.ReadDir(tempDir)
if err != nil || len(entries) == 0 {
return fmt.Errorf("zip extraction failed")
}
root, err := filepath.Abs(filepath.Join(tempDirAbs, entries[0].Name()))
if err != nil {
return err
}
if root != tempDirAbs && !strings.HasPrefix(root, tempDirAbs+string(filepath.Separator)) {
return fmt.Errorf("zip extraction failed: root entry escapes temp dir")
}
rootInfo, err := os.Stat(root)
if err != nil {
return err
}
if !rootInfo.IsDir() {
return fmt.Errorf("zip extraction failed: root entry is not a directory")
}
return os.Rename(root, targetDir)
}
func StripVCSMetadata(targetRoot, targetName string) error {
targetName, err := safepath.ValidatePathComponent("install name", targetName)
if err != nil {
return err
}
targetDir, err := safepath.ResolveRelativeUnderRoot(targetRoot, targetName)
if err != nil {
return err
}
for _, rel := range []string{".git", ".gitmodules"} {
path := filepath.Join(targetDir, rel)
if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove VCS metadata %q: %w", rel, err)
}
}
return nil
}
func SafeArchivePath(root, name string) (string, error) {
if strings.TrimSpace(name) == "" {
return "", fmt.Errorf("zip contains empty entry name")
}
if filepath.IsAbs(name) {
return "", fmt.Errorf("zip entry escapes target dir: %s", name)
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", err
}
target := filepath.Join(rootAbs, filepath.Clean(filepath.FromSlash(name)))
targetAbs, err := filepath.Abs(target)
if err != nil {
return "", err
}
rootWithSep := rootAbs + string(filepath.Separator)
if targetAbs != rootAbs && !strings.HasPrefix(targetAbs, rootWithSep) {
return "", fmt.Errorf("zip entry escapes target dir: %s", name)
}
return targetAbs, nil
}
package lifecycle
import "time"
type PathInfo struct {
Path string
OriginalPath string
State State
Timestamp *time.Time
}
func DescribePath(path string) PathInfo {
original, state, ts, ok := ParsePathDetails(path)
if !ok {
return PathInfo{
Path: path,
OriginalPath: path,
State: StateCurrent,
}
}
return PathInfo{
Path: path,
OriginalPath: original,
State: state,
Timestamp: &ts,
}
}
package lifecycle
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
)
// TimestampFormat is the sortable UTC timestamp format used in lifecycle-managed
// filenames such as foo.version.<timestamp>.md and foo.trash.<timestamp>.md.
const TimestampFormat = "20060102T150405Z"
var derivedStemRE = regexp.MustCompile(`^(.*)\.(version|trash)\.(\d{8}T\d{6}Z)$`)
// State identifies whether a lifecycle-managed path is current, versioned, or
// trashed.
type State string
const (
StateCurrent State = "current"
StateVersion State = "version"
StateTrash State = "trash"
)
// IsDerivedPath reports whether path is a lifecycle-managed version or trash
// file rather than the current canonical file.
func IsDerivedPath(path string) bool {
_, _, ok := ParsePath(path)
return ok
}
// IsVersionPath reports whether path is a retained previous version.
func IsVersionPath(path string) bool {
_, state, ok := ParsePath(path)
return ok && state == StateVersion
}
// IsTrashPath reports whether path is a soft-deleted file.
func IsTrashPath(path string) bool {
_, state, ok := ParsePath(path)
return ok && state == StateTrash
}
// ParsePath resolves a lifecycle-managed path back to its canonical current
// path and lifecycle state.
func ParsePath(path string) (string, State, bool) {
original, state, _, ok := ParsePathDetails(path)
return original, state, ok
}
// ParsePathDetails resolves a lifecycle-managed path back to its canonical
// current path, lifecycle state, and timestamp.
func ParsePathDetails(path string) (string, State, time.Time, bool) {
dir := filepath.Dir(path)
base := filepath.Base(path)
stem, ext := splitStemAndExt(base)
match := derivedStemRE.FindStringSubmatch(stem)
if len(match) != 4 {
return "", "", time.Time{}, false
}
original := filepath.Join(dir, match[1]+ext)
ts, err := time.Parse(TimestampFormat, match[3])
if err != nil {
return "", "", time.Time{}, false
}
switch match[2] {
case "version":
return original, StateVersion, ts, true
case "trash":
return original, StateTrash, ts, true
default:
return "", "", time.Time{}, false
}
}
// BuildVersionPath returns the versioned filename for the current path at now.
func BuildVersionPath(path string, now time.Time) string {
return buildDerivedPath(path, "version", now)
}
// BuildTrashPath returns the trash filename for the current path at now.
func BuildTrashPath(path string, now time.Time) string {
return buildDerivedPath(path, "trash", now)
}
func buildDerivedPath(path, kind string, now time.Time) string {
dir := filepath.Dir(path)
base := filepath.Base(path)
stem, ext := splitStemAndExt(base)
if original, _, ok := ParsePath(path); ok {
base = filepath.Base(original)
stem, ext = splitStemAndExt(base)
}
return filepath.Join(dir, stem+"."+kind+"."+now.UTC().Format(TimestampFormat)+ext)
}
func splitStemAndExt(base string) (string, string) {
if strings.HasSuffix(base, ".meta.yaml") {
core := strings.TrimSuffix(base, ".meta.yaml")
ext := filepath.Ext(core)
if ext == "" {
return core, ".meta.yaml"
}
return strings.TrimSuffix(core, ext), ext + ".meta.yaml"
}
ext := filepath.Ext(base)
if ext == "" {
return base, ""
}
return strings.TrimSuffix(base, ext), ext
}
// OriginalPath returns the canonical current path for either a current or
// lifecycle-derived path.
func OriginalPath(path string) string {
if original, _, ok := ParsePath(path); ok {
return original
}
return path
}
// ValidateCurrentPath rejects version/trash paths where a canonical current
// path is required.
func ValidateCurrentPath(path string) error {
if IsDerivedPath(path) {
return fmt.Errorf("lifecycle-managed derived file paths are not valid current paths: %s", filepath.Base(path))
}
return nil
}
package logx
import (
"log/slog"
"os"
"strings"
"sync"
)
var once sync.Once
func InitFromEnv() {
once.Do(func() {
level := parseLevel(os.Getenv("FOUNDRY_LOG"))
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
})
slog.SetDefault(slog.New(handler))
})
}
func parseLevel(v string) slog.Level {
switch strings.ToLower(strings.TrimSpace(v)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func Debug(msg string, args ...any) {
slog.Debug(msg, args...)
}
func Info(msg string, args ...any) {
slog.Info(msg, args...)
}
func Warn(msg string, args ...any) {
slog.Warn(msg, args...)
}
func Error(msg string, args ...any) {
slog.Error(msg, args...)
}
package markup
import (
"bytes"
"html/template"
"regexp"
"strconv"
"strings"
"github.com/sphireinc/foundry/internal/media"
"github.com/yuin/goldmark"
rendererhtml "github.com/yuin/goldmark/renderer/html"
)
var headingTagRE = regexp.MustCompile(`<(h[1-6])>(.*?)</h[1-6]>`)
var stripTagsRE = regexp.MustCompile(`<[^>]+>`)
var invalidSlugCharsRE = regexp.MustCompile(`[^a-z0-9\s-]`)
var multiDashRE = regexp.MustCompile(`-+`)
var multiSpaceRE = regexp.MustCompile(`\s+`)
func MarkdownToHTML(input string, allowUnsafeHTML bool) (template.HTML, error) {
var buf bytes.Buffer
md := goldmark.New()
if allowUnsafeHTML {
md = goldmark.New(goldmark.WithRendererOptions(rendererhtml.WithUnsafe()))
}
if err := md.Convert([]byte(input), &buf); err != nil {
return "", err
}
html := addHeadingIDs(buf.String())
html = media.RewriteHTML(html)
return template.HTML(html), nil
}
func addHeadingIDs(html string) string {
used := make(map[string]int)
return headingTagRE.ReplaceAllStringFunc(html, func(match string) string {
parts := headingTagRE.FindStringSubmatch(match)
if len(parts) != 3 {
return match
}
tag := parts[1]
inner := parts[2]
text := strings.TrimSpace(stripTagsRE.ReplaceAllString(inner, ""))
if text == "" {
return match
}
id := slugify(text)
if id == "" {
return match
}
if n, exists := used[id]; exists {
n++
used[id] = n
id = id + "-" + strconv.Itoa(n)
} else {
used[id] = 0
}
return "<" + tag + ` id="` + id + `">` + inner + "</" + tag + ">"
})
}
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = invalidSlugCharsRE.ReplaceAllString(s, "")
s = multiSpaceRE.ReplaceAllString(s, "-")
s = multiDashRE.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
package media
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"html/template"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/sphireinc/foundry/internal/lifecycle"
)
type Kind string
const (
KindImage Kind = "image"
KindVideo Kind = "video"
KindAudio Kind = "audio"
KindFile Kind = "file"
)
const ReferenceScheme = "media:"
var SupportedCollections = []string{"images", "videos", "audio", "documents", "uploads", "assets"}
var (
allowedImageExts = map[string]struct{}{
".png": {},
".jpg": {},
".jpeg": {},
".gif": {},
".webp": {},
".avif": {},
}
allowedUploadExts = map[string]struct{}{
".png": {},
".jpg": {},
".jpeg": {},
".gif": {},
".webp": {},
".avif": {},
".mp4": {},
".webm": {},
".mov": {},
".m4v": {},
".ogv": {},
".mp3": {},
".wav": {},
".ogg": {},
".m4a": {},
".flac": {},
".pdf": {},
".txt": {},
".csv": {},
".zip": {},
}
forceDownloadExts = map[string]struct{}{
".css": {},
".htm": {},
".html": {},
".js": {},
".mjs": {},
".svg": {},
".xml": {},
}
)
var (
imgTagRE = regexp.MustCompile(`(?i)<img\b[^>]*\bsrc="([^"]+)"[^>]*>`)
linkTagRE = regexp.MustCompile(`(?is)<a\b([^>]*?)\bhref="([^"]+)"([^>]*)>(.*?)</a>`)
attrRE = regexp.MustCompile(`([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*"([^"]*)"`)
spacesRE = regexp.MustCompile(`\s+`)
unsafeNameRE = regexp.MustCompile(`[^a-z0-9._-]+`)
)
type Reference struct {
Collection string
Path string
PublicURL string
Kind Kind
}
type UploadInfo struct {
Collection string
OriginalFilename string
SafeFilename string
StoredFilename string
MIMEType string
Extension string
Kind Kind
ContentHash string
Size int64
Width int
Height int
}
func ResolveReference(ref string) (Reference, error) {
ref = strings.TrimSpace(ref)
if !strings.HasPrefix(ref, ReferenceScheme) {
return Reference{}, fmt.Errorf("unsupported media reference: %s", ref)
}
raw := strings.TrimPrefix(ref, ReferenceScheme)
raw = strings.TrimSpace(strings.ReplaceAll(raw, `\`, "/"))
raw = strings.TrimPrefix(raw, "/")
if raw == "" {
return Reference{}, fmt.Errorf("media reference path cannot be empty")
}
parts := strings.SplitN(raw, "/", 2)
if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" {
return Reference{}, fmt.Errorf("media reference must include collection and path")
}
collection := canonicalCollection(strings.TrimSpace(parts[0]))
if !isSupportedCollection(collection) {
return Reference{}, fmt.Errorf("unsupported media collection: %s", collection)
}
cleanPath, err := cleanRelativePath(parts[1])
if err != nil {
return Reference{}, err
}
return Reference{
Collection: collection,
Path: cleanPath,
PublicURL: "/" + collection + "/" + cleanPath,
Kind: DetectKind(cleanPath),
}, nil
}
func isSupportedCollection(collection string) bool {
collection = canonicalCollection(collection)
for _, candidate := range SupportedCollections {
if collection == candidate {
return true
}
}
return false
}
func MustReference(collection, relPath string) string {
ref, err := NewReference(collection, relPath)
if err != nil {
panic(err)
}
return ref
}
func NewReference(collection, relPath string) (string, error) {
collection = canonicalCollection(strings.TrimSpace(collection))
cleanPath, err := cleanRelativePath(relPath)
if err != nil {
return "", err
}
ref, err := ResolveReference(ReferenceScheme + collection + "/" + cleanPath)
if err != nil {
return "", err
}
return ReferenceScheme + ref.Collection + "/" + ref.Path, nil
}
func DetectKind(name string) Kind {
switch strings.ToLower(filepath.Ext(name)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif":
return KindImage
case ".mp4", ".webm", ".mov", ".m4v", ".ogv":
return KindVideo
case ".mp3", ".wav", ".ogg", ".m4a", ".flac":
return KindAudio
default:
return KindFile
}
}
func DefaultCollection(filename, contentType string) string {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(contentType)), "image/") {
return "images"
}
switch DetectKind(filename) {
case KindImage:
return "images"
case KindVideo:
return "videos"
case KindAudio:
return "audio"
default:
return "documents"
}
}
func PrepareUpload(requestedCollection, filename string, body []byte, now time.Time) (UploadInfo, error) {
safeName := SanitizeFilename(filename)
ext := strings.ToLower(filepath.Ext(safeName))
if ext == "" {
return UploadInfo{}, fmt.Errorf("uploaded media must have a file extension")
}
detected := detectedContentType(body)
if isDangerousServedType(ext, detected) {
return UploadInfo{}, fmt.Errorf("uploaded media type is not allowed")
}
collection := canonicalCollection(strings.TrimSpace(requestedCollection))
if collection == "" {
collection = DefaultCollection(safeName, detected)
}
if !isSupportedCollection(collection) {
return UploadInfo{}, fmt.Errorf("unsupported media collection: %s", collection)
}
if collection == "assets" {
return UploadInfo{}, fmt.Errorf("admin media uploads cannot write to the assets collection")
}
kind := DetectKind(safeName)
switch collection {
case "images":
if kind != KindImage {
return UploadInfo{}, fmt.Errorf("images collection only accepts image files")
}
if _, ok := allowedImageExts[ext]; !ok {
return UploadInfo{}, fmt.Errorf("unsupported image file type: %s", ext)
}
if !matchesDetectedType(ext, detected) {
return UploadInfo{}, fmt.Errorf("uploaded file content does not match its extension")
}
case "videos":
if kind != KindVideo {
return UploadInfo{}, fmt.Errorf("videos collection only accepts video files")
}
if _, ok := allowedUploadExts[ext]; !ok {
return UploadInfo{}, fmt.Errorf("unsupported video file type: %s", ext)
}
if !matchesDetectedType(ext, detected) {
return UploadInfo{}, fmt.Errorf("uploaded file content does not match its extension")
}
case "audio":
if kind != KindAudio {
return UploadInfo{}, fmt.Errorf("audio collection only accepts audio files")
}
if _, ok := allowedUploadExts[ext]; !ok {
return UploadInfo{}, fmt.Errorf("unsupported audio file type: %s", ext)
}
if !matchesDetectedType(ext, detected) {
return UploadInfo{}, fmt.Errorf("uploaded file content does not match its extension")
}
case "documents":
if kind != KindFile {
return UploadInfo{}, fmt.Errorf("documents collection only accepts document files")
}
if _, ok := allowedUploadExts[ext]; !ok {
return UploadInfo{}, fmt.Errorf("unsupported document file type: %s", ext)
}
if !matchesDetectedType(ext, detected) {
return UploadInfo{}, fmt.Errorf("uploaded file content does not match its extension")
}
case "uploads":
if _, ok := allowedUploadExts[ext]; !ok {
return UploadInfo{}, fmt.Errorf("unsupported upload file type: %s", ext)
}
if !matchesDetectedType(ext, detected) {
return UploadInfo{}, fmt.Errorf("uploaded file content does not match its extension")
}
default:
return UploadInfo{}, fmt.Errorf("unsupported media collection: %s", collection)
}
storedName, err := BuildStoredFilename(safeName, now)
if err != nil {
return UploadInfo{}, err
}
dim := imageDimensions(body, kind)
return UploadInfo{
Collection: collection,
OriginalFilename: filepath.Base(strings.TrimSpace(filename)),
SafeFilename: safeName,
StoredFilename: storedName,
MIMEType: detected,
Extension: ext,
Kind: kind,
ContentHash: ContentHash(body),
Size: int64(len(body)),
Width: dim.Width,
Height: dim.Height,
}, nil
}
type dimensions struct {
Width int
Height int
}
func imageDimensions(body []byte, kind Kind) dimensions {
if kind != KindImage || len(body) == 0 {
return dimensions{}
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
if err != nil {
return dimensions{}
}
return dimensions{Width: cfg.Width, Height: cfg.Height}
}
func canonicalCollection(collection string) string {
switch strings.TrimSpace(collection) {
case "video":
return "videos"
case "videos":
return "videos"
case "audio":
return "audio"
case "uploads":
return "uploads"
case "assets":
return "assets"
default:
return strings.TrimSpace(collection)
}
}
func BuildStoredFilename(safeName string, now time.Time) (string, error) {
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(safeName)))
base := strings.TrimSuffix(strings.TrimSpace(safeName), ext)
if base == "" || ext == "" {
return "", fmt.Errorf("stored filename requires a base name and extension")
}
suffix, err := randomSuffix(4)
if err != nil {
return "", err
}
return base + "-" + now.UTC().Format("20060102T150405Z") + "-" + suffix + ext, nil
}
func ContentHash(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
func ShouldForceDownload(name string) bool {
_, ok := forceDownloadExts[strings.ToLower(filepath.Ext(strings.TrimSpace(name)))]
return ok
}
func SanitizeFilename(name string) string {
name = filepath.Base(strings.TrimSpace(name))
rawExt := filepath.Ext(name)
ext := strings.ToLower(rawExt)
base := strings.TrimSuffix(name, rawExt)
base = strings.ToLower(strings.TrimSpace(base))
base = spacesRE.ReplaceAllString(base, "-")
base = unsafeNameRE.ReplaceAllString(base, "-")
base = strings.Trim(base, "-.")
if base == "" {
base = "file"
}
return base + ext
}
func RewriteHTML(html string) string {
html = imgTagRE.ReplaceAllStringFunc(html, rewriteImageTag)
html = linkTagRE.ReplaceAllStringFunc(html, rewriteLinkTag)
return html
}
func rewriteImageTag(tag string) string {
match := imgTagRE.FindStringSubmatch(tag)
if len(match) != 2 {
return tag
}
ref, err := ResolveReference(match[1])
if err != nil {
return tag
}
if ref.Kind == KindImage {
return strings.Replace(tag, `src="`+match[1]+`"`, `src="`+template.HTMLEscapeString(ref.PublicURL)+`"`, 1)
}
attrs := parseAttributes(tag)
classAttr := renderOptionalAttribute("class", attrs["class"])
titleAttr := renderOptionalAttribute("title", firstNonEmpty(attrs["title"], attrs["alt"]))
labelAttr := renderOptionalAttribute("aria-label", firstNonEmpty(attrs["alt"], attrs["title"]))
switch ref.Kind {
case KindVideo:
return `<video controls preload="metadata" src="` + template.HTMLEscapeString(ref.PublicURL) + `"` + classAttr + titleAttr + labelAttr + `></video>`
case KindAudio:
return `<audio controls preload="metadata" src="` + template.HTMLEscapeString(ref.PublicURL) + `"` + classAttr + titleAttr + labelAttr + `></audio>`
default:
return `<a href="` + template.HTMLEscapeString(ref.PublicURL) + `"` + classAttr + titleAttr + `>` + template.HTMLEscapeString(firstNonEmpty(attrs["alt"], path.Base(ref.Path))) + `</a>`
}
}
func rewriteLinkTag(tag string) string {
match := linkTagRE.FindStringSubmatch(tag)
if len(match) != 5 {
return tag
}
ref, err := ResolveReference(match[2])
if err != nil {
return tag
}
return strings.Replace(tag, `href="`+match[2]+`"`, `href="`+template.HTMLEscapeString(ref.PublicURL)+`"`, 1)
}
func parseAttributes(tag string) map[string]string {
out := make(map[string]string)
for _, match := range attrRE.FindAllStringSubmatch(tag, -1) {
if len(match) != 3 {
continue
}
out[strings.ToLower(match[1])] = match[2]
}
return out
}
func renderOptionalAttribute(name, value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
return ` ` + name + `="` + template.HTMLEscapeString(value) + `"`
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func cleanRelativePath(value string) (string, error) {
value = strings.TrimSpace(strings.ReplaceAll(value, `\`, "/"))
value = strings.TrimPrefix(value, "/")
if value == "" {
return "", fmt.Errorf("media path cannot be empty")
}
cleaned := path.Clean(value)
if cleaned == "." || cleaned == "/" || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("invalid media path: path must stay inside the media root")
}
cleaned = strings.TrimPrefix(cleaned, "/")
if cleaned == "" || cleaned == "." {
return "", fmt.Errorf("media path cannot be empty")
}
if lifecycle.IsDerivedPath(cleaned) {
return "", fmt.Errorf("media path must reference a current media file")
}
return cleaned, nil
}
func detectedContentType(body []byte) string {
if len(body) == 0 {
return "application/octet-stream"
}
sample := body
if len(sample) > 512 {
sample = sample[:512]
}
return strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(sample), ";")[0]))
}
func randomSuffix(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func isDangerousServedType(ext, detected string) bool {
if _, ok := forceDownloadExts[ext]; ok {
return true
}
switch detected {
case "application/javascript", "application/xhtml+xml", "image/svg+xml", "text/css", "text/html", "text/javascript", "text/xml", "application/xml":
return true
default:
return false
}
}
func matchesDetectedType(ext, detected string) bool {
switch ext {
case ".png":
return detected == "image/png"
case ".jpg", ".jpeg":
return detected == "image/jpeg"
case ".gif":
return detected == "image/gif"
case ".webp":
return detected == "image/webp"
case ".avif":
return detected == "image/avif" || detected == "application/octet-stream"
case ".mp4", ".m4v":
return detected == "video/mp4"
case ".webm":
return detected == "video/webm"
case ".mov":
return detected == "video/quicktime" || detected == "application/octet-stream"
case ".ogv":
return detected == "video/ogg" || detected == "application/ogg"
case ".mp3":
return detected == "audio/mpeg" || detected == "application/octet-stream"
case ".wav":
return detected == "audio/wave" || detected == "audio/x-wav" || detected == "application/octet-stream"
case ".ogg":
return detected == "audio/ogg" || detected == "application/ogg"
case ".m4a":
return detected == "audio/mp4" || detected == "application/octet-stream"
case ".flac":
return detected == "audio/flac" || detected == "application/octet-stream"
case ".pdf":
return detected == "application/pdf"
case ".txt":
return detected == "text/plain" || detected == "application/octet-stream"
case ".csv":
return detected == "text/csv" || detected == "text/plain" || detected == "application/octet-stream"
case ".zip":
return detected == "application/zip" || detected == "application/x-zip-compressed" || detected == "application/octet-stream"
default:
return false
}
}
package ops
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/feed"
"github.com/sphireinc/foundry/internal/renderer"
"github.com/sphireinc/foundry/internal/router"
"github.com/sphireinc/foundry/internal/site"
)
// TimingBreakdown records high-level build timings for diagnostics and doctor
// output.
type TimingBreakdown struct {
PluginConfig time.Duration
Loader time.Duration
Router time.Duration
RouteHooks time.Duration
Assets time.Duration
Renderer time.Duration
Feed time.Duration
}
// PreviewLink identifies a previewable document emitted into preview-links.json.
type PreviewLink struct {
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"type"`
Lang string `json:"lang"`
SourcePath string `json:"source_path"`
URL string `json:"url"`
PreviewURL string `json:"preview_url"`
}
// PreviewManifest is the preview-links.json payload written during preview
// builds.
type PreviewManifest struct {
GeneratedAt time.Time `json:"generated_at"`
Environment string `json:"environment"`
Target string `json:"target,omitempty"`
Links []PreviewLink `json:"links"`
}
// BuildReport is the persisted operational summary for the most recent build.
type BuildReport struct {
GeneratedAt time.Time `json:"generated_at"`
Environment string `json:"environment"`
Target string `json:"target,omitempty"`
Preview bool `json:"preview"`
DocumentCount int `json:"document_count"`
RouteCount int `json:"route_count"`
Stats renderer.BuildStats `json:"stats"`
}
// TimedRouteHooks wraps site route hooks and records how long they take.
type TimedRouteHooks struct {
Hooks site.RouteHooks
Duration time.Duration
}
func (h *TimedRouteHooks) OnRoutesAssigned(graph *content.SiteGraph) error {
if h == nil || h.Hooks == nil {
return nil
}
start := time.Now()
err := h.Hooks.OnRoutesAssigned(graph)
h.Duration += time.Since(start)
return err
}
// LoadGraphWithTiming loads the site graph, assigns URLs, and records coarse
// timing for those stages.
func LoadGraphWithTiming(ctx context.Context, loader site.Loader, resolver *router.Resolver, hooks site.RouteHooks) (*content.SiteGraph, TimingBreakdown, error) {
var metrics TimingBreakdown
start := time.Now()
graph, err := loader.Load(ctx)
metrics.Loader = time.Since(start)
if err != nil {
return nil, metrics, err
}
start = time.Now()
if err := resolver.AssignURLs(graph); err != nil {
return nil, metrics, err
}
metrics.Router = time.Since(start)
if hooks != nil {
start = time.Now()
if err := hooks.OnRoutesAssigned(graph); err != nil {
return nil, metrics, err
}
metrics.RouteHooks = time.Since(start)
}
return graph, metrics, nil
}
// BuildFeedsWithTiming runs feed generation and records its duration.
func BuildFeedsWithTiming(cfg *config.Config, graph *content.SiteGraph) (TimingBreakdown, error) {
var metrics TimingBreakdown
start := time.Now()
if _, _, err := feed.Build(cfg, graph); err != nil {
return metrics, err
}
metrics.Feed = time.Since(start)
return metrics, nil
}
// BuildRendererWithTiming runs the renderer build pass and records its timing.
func BuildRendererWithTiming(ctx context.Context, r *renderer.Renderer, graph *content.SiteGraph) (TimingBreakdown, error) {
var metrics TimingBreakdown
start := time.Now()
stats, err := r.BuildWithStats(ctx, graph)
if err != nil {
return metrics, err
}
metrics.Renderer = time.Since(start)
metrics.Assets = stats.Assets
return metrics, nil
}
// WritePreviewManifest writes preview-links.json for non-published documents
// when preview manifest output is enabled.
func WritePreviewManifest(cfg *config.Config, graph *content.SiteGraph, target string, enabled bool) error {
manifestPath := filepath.Join(cfg.PublicDir, "preview-links.json")
if !enabled {
if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove preview manifest: %w", err)
}
return nil
}
links := make([]PreviewLink, 0)
baseURL := strings.TrimRight(cfg.BaseURL, "/")
for _, doc := range graph.Documents {
if doc == nil {
continue
}
if doc.Status == "published" && !doc.Draft {
continue
}
previewURL := doc.URL
if baseURL != "" {
previewURL = baseURL + doc.URL
}
links = append(links, PreviewLink{
Title: doc.Title,
Status: doc.Status,
Type: doc.Type,
Lang: doc.Lang,
SourcePath: doc.SourcePath,
URL: doc.URL,
PreviewURL: previewURL,
})
}
sort.Slice(links, func(i, j int) bool {
if links[i].Status != links[j].Status {
return links[i].Status < links[j].Status
}
if links[i].Lang != links[j].Lang {
return links[i].Lang < links[j].Lang
}
return links[i].URL < links[j].URL
})
if err := os.MkdirAll(cfg.PublicDir, 0o755); err != nil {
return fmt.Errorf("mkdir public dir: %w", err)
}
body, err := json.MarshalIndent(PreviewManifest{
GeneratedAt: time.Now().UTC(),
Environment: cfg.Environment,
Target: target,
Links: links,
}, "", " ")
if err != nil {
return fmt.Errorf("marshal preview manifest: %w", err)
}
if err := os.WriteFile(manifestPath, append(body, '\n'), 0o644); err != nil {
return fmt.Errorf("write preview manifest: %w", err)
}
return nil
}
// WriteBuildReport persists the latest build summary for admin diagnostics and
// the Debug dashboard.
func WriteBuildReport(cfg *config.Config, graph *content.SiteGraph, target string, preview bool, stats renderer.BuildStats) error {
if cfg == nil {
return nil
}
path := filepath.Join(cfg.DataDir, "admin", "build-report.json")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("mkdir build report dir: %w", err)
}
report := BuildReport{
GeneratedAt: time.Now().UTC(),
Environment: cfg.Environment,
Target: strings.TrimSpace(target),
Preview: preview,
DocumentCount: 0,
RouteCount: 0,
Stats: stats,
}
if graph != nil {
report.DocumentCount = len(graph.Documents)
report.RouteCount = len(graph.ByURL)
}
body, err := json.MarshalIndent(report, "", " ")
if err != nil {
return fmt.Errorf("marshal build report: %w", err)
}
if err := os.WriteFile(path, append(body, '\n'), 0o644); err != nil {
return fmt.Errorf("write build report: %w", err)
}
return nil
}
package ops
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/lifecycle"
"github.com/sphireinc/foundry/internal/media"
"github.com/sphireinc/foundry/internal/theme"
)
var (
markdownLinkRE = regexp.MustCompile(`\[[^\]]*\]\(([^)]+)\)`)
htmlHrefRE = regexp.MustCompile(`(?i)\bhref="([^"]+)"`)
htmlSrcRE = regexp.MustCompile(`(?i)\bsrc="([^"]+)"`)
)
// DiagnosticReport aggregates site-level validation findings discovered outside
// the strict config/theme validators.
type DiagnosticReport struct {
BrokenMediaRefs []string
BrokenInternalLinks []string
MissingTemplates []string
OrphanedMedia []string
DuplicateURLs []string
DuplicateSlugs []string
TaxonomyInconsistency []string
}
// Messages flattens all diagnostic categories into a single ordered message
// slice suitable for CLI output.
func (r DiagnosticReport) Messages() []string {
var out []string
out = append(out, r.DuplicateURLs...)
out = append(out, r.DuplicateSlugs...)
out = append(out, r.BrokenMediaRefs...)
out = append(out, r.BrokenInternalLinks...)
out = append(out, r.MissingTemplates...)
out = append(out, r.OrphanedMedia...)
out = append(out, r.TaxonomyInconsistency...)
return out
}
// AnalyzeSite inspects the loaded graph and supporting files for operational
// issues such as broken references, missing templates, and orphaned media.
func AnalyzeSite(cfg *config.Config, graph *content.SiteGraph) DiagnosticReport {
report := DiagnosticReport{}
if cfg == nil || graph == nil {
return report
}
seenURLs := make(map[string]string)
seenSlugs := make(map[string]string)
referencedMedia := make(map[string]struct{})
allowedTaxonomies := make(map[string]struct{})
for _, name := range cfg.Taxonomies.DefaultSet {
allowedTaxonomies[strings.TrimSpace(name)] = struct{}{}
}
for name := range cfg.Taxonomies.Definitions {
allowedTaxonomies[strings.TrimSpace(name)] = struct{}{}
}
for _, doc := range graph.Documents {
if doc == nil {
continue
}
if other, ok := seenURLs[doc.URL]; ok {
report.DuplicateURLs = append(report.DuplicateURLs, fmt.Sprintf("duplicate URL %s for %s and %s", doc.URL, other, doc.SourcePath))
} else {
seenURLs[doc.URL] = doc.SourcePath
}
slugKey := doc.Type + "|" + doc.Lang + "|" + doc.Slug
if other, ok := seenSlugs[slugKey]; ok {
report.DuplicateSlugs = append(report.DuplicateSlugs, fmt.Sprintf("duplicate slug within type/lang %q for %s and %s", slugKey, other, doc.SourcePath))
} else {
seenSlugs[slugKey] = doc.SourcePath
}
for name := range doc.Taxonomies {
if _, ok := allowedTaxonomies[name]; !ok {
report.TaxonomyInconsistency = append(report.TaxonomyInconsistency, fmt.Sprintf("document %s uses unknown taxonomy %s", doc.SourcePath, name))
}
}
layoutPath := theme.NewManager(cfg.ThemesDir, cfg.Theme).LayoutPath(doc.Layout)
if strings.TrimSpace(layoutPath) == "" {
report.MissingTemplates = append(report.MissingTemplates, fmt.Sprintf("document %s uses invalid layout %s", doc.SourcePath, doc.Layout))
} else if _, err := os.Stat(layoutPath); err != nil {
report.MissingTemplates = append(report.MissingTemplates, fmt.Sprintf("document %s is missing layout template %s", doc.SourcePath, doc.Layout))
}
for _, ref := range collectReferences(doc.RawBody) {
switch {
case strings.HasPrefix(ref, media.ReferenceScheme):
if err := validateMediaReference(cfg, ref); err != nil {
report.BrokenMediaRefs = append(report.BrokenMediaRefs, fmt.Sprintf("document %s has broken media reference %s: %v", doc.SourcePath, ref, err))
} else {
resolved, _ := media.ResolveReference(ref)
referencedMedia[resolved.Collection+"/"+resolved.Path] = struct{}{}
}
case isInternalLink(ref):
if err := validateInternalReference(cfg, ref, seenURLs); err != nil {
report.BrokenInternalLinks = append(report.BrokenInternalLinks, fmt.Sprintf("document %s has broken internal link %s: %v", doc.SourcePath, ref, err))
}
}
}
}
for _, orphan := range findOrphanedMedia(cfg, referencedMedia) {
report.OrphanedMedia = append(report.OrphanedMedia, orphan)
}
return report
}
func collectReferences(raw string) []string {
refs := make([]string, 0)
add := func(value string) {
value = strings.TrimSpace(value)
if value != "" {
refs = append(refs, value)
}
}
for _, match := range markdownLinkRE.FindAllStringSubmatch(raw, -1) {
if len(match) == 2 {
add(match[1])
}
}
for _, match := range htmlHrefRE.FindAllStringSubmatch(raw, -1) {
if len(match) == 2 {
add(match[1])
}
}
for _, match := range htmlSrcRE.FindAllStringSubmatch(raw, -1) {
if len(match) == 2 {
add(match[1])
}
}
return refs
}
func isInternalLink(ref string) bool {
ref = strings.TrimSpace(ref)
return strings.HasPrefix(ref, "/") && !strings.HasPrefix(ref, "//")
}
func validateMediaReference(cfg *config.Config, ref string) error {
resolved, err := media.ResolveReference(ref)
if err != nil {
return err
}
root := mediaRoot(cfg, resolved.Collection)
if root == "" {
return fmt.Errorf("unknown media collection")
}
_, err = os.Stat(filepath.Join(root, filepath.FromSlash(resolved.Path)))
return err
}
func validateInternalReference(cfg *config.Config, ref string, seen map[string]string) error {
ref = strings.TrimSpace(ref)
if idx := strings.Index(ref, "#"); idx >= 0 {
ref = ref[:idx]
}
if ref == "" {
return nil
}
if _, ok := seen[ref]; ok {
return nil
}
if ref == cfg.Feed.RSSPath || ref == cfg.Feed.SitemapPath || ref == "/search.json" || ref == "/preview-links.json" {
return nil
}
switch {
case strings.HasPrefix(ref, "/images/"):
_, err := os.Stat(filepath.Join(cfg.ContentDir, cfg.Content.ImagesDir, strings.TrimPrefix(ref, "/images/")))
return err
case strings.HasPrefix(ref, "/videos/"):
_, err := os.Stat(filepath.Join(cfg.ContentDir, cfg.Content.VideoDir, strings.TrimPrefix(ref, "/videos/")))
return err
case strings.HasPrefix(ref, "/audio/"):
_, err := os.Stat(filepath.Join(cfg.ContentDir, cfg.Content.AudioDir, strings.TrimPrefix(ref, "/audio/")))
return err
case strings.HasPrefix(ref, "/documents/"):
_, err := os.Stat(filepath.Join(cfg.ContentDir, cfg.Content.DocumentsDir, strings.TrimPrefix(ref, "/documents/")))
return err
case strings.HasPrefix(ref, "/uploads/"):
_, err := os.Stat(filepath.Join(cfg.ContentDir, cfg.Content.UploadsDir, strings.TrimPrefix(ref, "/uploads/")))
return err
case strings.HasPrefix(ref, "/assets/"):
_, err := os.Stat(filepath.Join(cfg.ContentDir, cfg.Content.AssetsDir, strings.TrimPrefix(ref, "/assets/")))
return err
default:
return fmt.Errorf("route not found")
}
}
func findOrphanedMedia(cfg *config.Config, referenced map[string]struct{}) []string {
orphaned := make([]string, 0)
for _, collection := range []string{"images", "videos", "audio", "documents", "uploads", "assets"} {
root := mediaRoot(cfg, collection)
if root == "" {
continue
}
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil || d == nil || d.IsDir() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
if strings.HasSuffix(rel, ".meta.yaml") || lifecycle.IsDerivedPath(rel) {
return nil
}
key := collection + "/" + rel
if _, ok := referenced[key]; !ok {
orphaned = append(orphaned, fmt.Sprintf("orphaned media %s", filepath.ToSlash(filepath.Join(cfg.ContentDir, mediaSubdir(cfg, collection), rel))))
}
return nil
})
}
return orphaned
}
func mediaRoot(cfg *config.Config, collection string) string {
subdir := mediaSubdir(cfg, collection)
if subdir == "" {
return ""
}
return filepath.Join(cfg.ContentDir, subdir)
}
func mediaSubdir(cfg *config.Config, collection string) string {
switch collection {
case "images":
return cfg.Content.ImagesDir
case "videos":
return cfg.Content.VideoDir
case "audio":
return cfg.Content.AudioDir
case "documents":
return cfg.Content.DocumentsDir
case "uploads":
return cfg.Content.UploadsDir
case "assets":
return cfg.Content.AssetsDir
default:
return ""
}
}
package ops
import (
"context"
"github.com/sphireinc/foundry/internal/assets"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/deps"
"github.com/sphireinc/foundry/internal/diag"
"github.com/sphireinc/foundry/internal/router"
)
// GraphLoader loads the site graph for operational helpers.
type GraphLoader interface {
Load(context.Context) (*content.SiteGraph, error)
}
// RouteHookRunner represents post-routing hooks that must run before serving or
// rendering.
type RouteHookRunner interface {
OnRoutesAssigned(*content.SiteGraph) error
}
// AssetHookRunner represents asset-build hooks.
type AssetHookRunner interface {
OnAssetsBuilding(*config.Config) error
}
// PreparedGraph combines the loaded site graph with its dependency graph.
type PreparedGraph struct {
Graph *content.SiteGraph
DepGraph *deps.Graph
}
// LoadPreparedGraph loads the graph, assigns routes, runs route hooks, and
// builds the dependency graph used by preview rebuilds.
func LoadPreparedGraph(ctx context.Context, loader GraphLoader, resolver *router.Resolver, hooks RouteHookRunner, activeTheme string) (*PreparedGraph, error) {
if loader == nil {
return nil, diag.New(diag.KindInternal, "loader is nil")
}
if resolver == nil {
return nil, diag.New(diag.KindInternal, "resolver is nil")
}
graph, err := loader.Load(ctx)
if err != nil {
return nil, diag.Wrap(diag.KindBuild, "load site graph", err)
}
if err := resolver.AssignURLs(graph); err != nil {
return nil, diag.Wrap(diag.KindBuild, "assign urls", err)
}
if hooks != nil {
if err := hooks.OnRoutesAssigned(graph); err != nil {
return nil, diag.Wrap(diag.KindPlugin, "run route hooks", err)
}
}
return &PreparedGraph{
Graph: graph,
DepGraph: deps.BuildSiteDependencyGraph(graph, activeTheme),
}, nil
}
// SyncAssets runs the asset pipeline with Foundry's standard diagnostic
// wrapping.
func SyncAssets(cfg *config.Config, hooks AssetHookRunner) error {
if err := assets.Sync(cfg, hooks); err != nil {
return diag.Wrap(diag.KindIO, "sync assets", err)
}
return nil
}
package platformapi
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/content"
sdkassets "github.com/sphireinc/foundry/sdk"
)
const (
// RouteBase is the public Foundry platform surface for frontend clients.
RouteBase = "/__foundry"
// APIBase is the live JSON API consumed by the Frontend SDK.
APIBase = RouteBase + "/api"
// SDKBase serves the browser-consumable Frontend and Admin SDK bundles.
SDKBase = RouteBase + "/sdk"
)
// Hooks is the minimal server hook surface needed to compose the platform API
// into the preview server.
type Hooks interface {
RegisterRoutes(*http.ServeMux)
OnServerStarted(string) error
OnRoutesAssigned(*content.SiteGraph) error
OnAssetsBuilding(*config.Config) error
}
type hookSet struct {
base Hooks
api *API
}
func (h hookSet) UnwrapHooks() any {
return h.base
}
// API serves Foundry's stable frontend-facing platform contract.
//
// Frontend themes and JS applications should prefer this surface, or the
// official Frontend SDK layered on top of it, rather than depending on
// server-internal Go types.
type API struct {
cfg *config.Config
mu sync.RWMutex
graph *content.SiteGraph
}
// CapabilitiesResponse advertises available modules and optional platform
// features to frontend clients.
type CapabilitiesResponse struct {
SDKVersion string `json:"sdk_version"`
Modules map[string]bool `json:"modules"`
Features map[string]bool `json:"features"`
}
// SiteInfoResponse contains site metadata needed by frontend themes and apps.
type SiteInfoResponse struct {
Name string `json:"name"`
Title string `json:"title"`
BaseURL string `json:"base_url"`
Theme string `json:"theme"`
Environment string `json:"environment"`
DefaultLang string `json:"default_lang"`
Menus map[string][]NavItem `json:"menus,omitempty"`
Params map[string]any `json:"params,omitempty"`
Taxonomies map[string]any `json:"taxonomies,omitempty"`
}
// NavItem is the normalized navigation item shape returned by the platform API.
type NavItem struct {
Name string `json:"name"`
URL string `json:"url"`
}
// RouteRecord describes a public route in the current site graph.
type RouteRecord struct {
Kind string `json:"kind"`
ID string `json:"id,omitempty"`
URL string `json:"url"`
Type string `json:"type,omitempty"`
Lang string `json:"lang,omitempty"`
Title string `json:"title,omitempty"`
ContentID string `json:"content_id,omitempty"`
TaxonomyName string `json:"taxonomy_name,omitempty"`
TaxonomyTerm string `json:"taxonomy_term,omitempty"`
}
// ContentSummary is the lightweight content shape used in collections and
// search results.
type ContentSummary struct {
ID string `json:"id"`
Type string `json:"type"`
Lang string `json:"lang"`
Title string `json:"title"`
Slug string `json:"slug"`
URL string `json:"url"`
Layout string `json:"layout"`
Summary string `json:"summary,omitempty"`
Date *time.Time `json:"date,omitempty"`
Author string `json:"author,omitempty"`
LastEditor string `json:"last_editor,omitempty"`
Taxonomies map[string][]string `json:"taxonomies,omitempty"`
}
// ContentDetail is the fuller content shape returned by the content endpoint.
//
// HTMLBody contains rendered output suitable for display. RawBody is included
// for preview or source-aware clients.
type ContentDetail struct {
ContentSummary
HTMLBody string `json:"html_body,omitempty"`
RawBody string `json:"raw_body,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
Params map[string]any `json:"params,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// CollectionResponse is the normalized paginated result for collection queries.
type CollectionResponse struct {
Items []ContentSummary `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
}
// SearchEntry is the normalized record returned by search endpoints and static
// search artifacts.
type SearchEntry struct {
Title string `json:"title"`
URL string `json:"url"`
Summary string `json:"summary,omitempty"`
Snippet string `json:"snippet,omitempty"`
Content string `json:"content,omitempty"`
Type string `json:"type"`
Lang string `json:"lang"`
Layout string `json:"layout,omitempty"`
Taxonomies map[string][]string `json:"taxonomies,omitempty"`
}
// PreviewLink identifies a previewable content item and its URLs.
type PreviewLink struct {
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"type"`
Lang string `json:"lang"`
SourcePath string `json:"source_path"`
URL string `json:"url"`
PreviewURL string `json:"preview_url"`
}
// PreviewManifest is the persisted summary emitted during preview builds.
type PreviewManifest struct {
GeneratedAt time.Time `json:"generated_at"`
Environment string `json:"environment"`
Target string `json:"target,omitempty"`
Links []PreviewLink `json:"links"`
}
// NewHooks composes the platform API hook set with an existing server hook
// chain.
func NewHooks(cfg *config.Config, base Hooks) Hooks {
if cfg == nil {
return base
}
return hookSet{
base: base,
api: &API{cfg: cfg},
}
}
func (h hookSet) RegisterRoutes(mux *http.ServeMux) {
if h.base != nil {
h.base.RegisterRoutes(mux)
}
if h.api == nil || mux == nil {
return
}
h.api.RegisterRoutes(mux)
}
func (h hookSet) OnServerStarted(addr string) error {
if h.base == nil {
return nil
}
return h.base.OnServerStarted(addr)
}
func (h hookSet) OnRoutesAssigned(graph *content.SiteGraph) error {
if h.api != nil {
h.api.SetGraph(graph)
}
if h.base == nil {
return nil
}
return h.base.OnRoutesAssigned(graph)
}
func (h hookSet) OnAssetsBuilding(cfg *config.Config) error {
if h.base == nil {
return nil
}
return h.base.OnAssetsBuilding(cfg)
}
// SetGraph updates the graph used to answer live platform API requests.
func (a *API) SetGraph(graph *content.SiteGraph) {
a.mu.Lock()
a.graph = graph
a.mu.Unlock()
}
func (a *API) currentGraph() *content.SiteGraph {
a.mu.RLock()
defer a.mu.RUnlock()
return a.graph
}
// RegisterRoutes mounts the live platform API and SDK asset handlers.
func (a *API) RegisterRoutes(mux *http.ServeMux) {
mux.Handle(SDKBase+"/", http.StripPrefix(SDKBase+"/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
sdkassets.Handler().ServeHTTP(w, r)
})))
mux.HandleFunc(APIBase+"/capabilities", a.handleCapabilities)
mux.HandleFunc(APIBase+"/site", a.handleSite)
mux.HandleFunc(APIBase+"/navigation", a.handleNavigation)
mux.HandleFunc(APIBase+"/routes/resolve", a.handleRouteResolve)
mux.HandleFunc(APIBase+"/content", a.handleContent)
mux.HandleFunc(APIBase+"/collections", a.handleCollections)
mux.HandleFunc(APIBase+"/search", a.handleSearch)
mux.HandleFunc(APIBase+"/preview", a.handlePreview)
}
func (a *API) handleCapabilities(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, CapabilitiesResponse{
SDKVersion: consts.SDKVersion,
Modules: map[string]bool{
"site": true,
"navigation": true,
"routes": true,
"content": true,
"collections": true,
"search": true,
"media": true,
"preview": true,
},
Features: map[string]bool{
"search": true,
"preview": false,
"preview_manifest": true,
"live_preview": false,
"navigation": true,
"taxonomies": true,
"media_refs": true,
},
})
}
func (a *API) handleSite(w http.ResponseWriter, req *http.Request) {
graph := a.requireGraph(w, req)
if graph == nil {
return
}
writeJSON(w, buildSiteInfo(graph))
}
func (a *API) handleNavigation(w http.ResponseWriter, req *http.Request) {
graph := a.requireGraph(w, req)
if graph == nil {
return
}
writeJSON(w, buildNavigation(graph))
}
func (a *API) handleRouteResolve(w http.ResponseWriter, req *http.Request) {
graph := a.requireGraph(w, req)
if graph == nil {
return
}
path := normalizeURLPath(req.URL.Query().Get("path"))
for _, route := range buildRouteRecords(graph) {
if route.URL == path {
writeJSON(w, route)
return
}
}
http.NotFound(w, req)
}
func (a *API) handleContent(w http.ResponseWriter, req *http.Request) {
graph := a.requireGraph(w, req)
if graph == nil {
return
}
id := strings.TrimSpace(req.URL.Query().Get("id"))
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
for _, doc := range graph.Documents {
if doc != nil && doc.ID == id && !doc.Draft && !documentArchived(doc) {
writeJSON(w, buildContentDetail(doc))
return
}
}
http.NotFound(w, req)
}
func (a *API) handleCollections(w http.ResponseWriter, req *http.Request) {
graph := a.requireGraph(w, req)
if graph == nil {
return
}
items := buildCollectionItems(graph)
query := strings.TrimSpace(req.URL.Query().Get("q"))
typ := strings.TrimSpace(req.URL.Query().Get("type"))
lang := strings.TrimSpace(req.URL.Query().Get("lang"))
taxonomyName := strings.TrimSpace(req.URL.Query().Get("taxonomy"))
term := strings.TrimSpace(req.URL.Query().Get("term"))
if typ != "" {
items = filterContent(items, func(item ContentSummary) bool { return item.Type == typ })
}
if lang != "" {
items = filterContent(items, func(item ContentSummary) bool { return item.Lang == lang })
}
if taxonomyName != "" && term != "" {
items = filterContent(items, func(item ContentSummary) bool {
return containsString(item.Taxonomies[taxonomyName], term)
})
}
if query != "" {
needle := strings.ToLower(query)
items = filterContent(items, func(item ContentSummary) bool {
return strings.Contains(strings.ToLower(strings.Join([]string{
item.Title,
item.Slug,
item.URL,
item.Summary,
}, " ")), needle)
})
}
page := parsePositiveInt(req.URL.Query().Get("page"), 1)
pageSize := parsePositiveInt(req.URL.Query().Get("page_size"), 20)
total := len(items)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
writeJSON(w, CollectionResponse{
Items: items[start:end],
Page: page,
PageSize: pageSize,
Total: total,
})
}
func (a *API) handleSearch(w http.ResponseWriter, req *http.Request) {
graph := a.requireGraph(w, req)
if graph == nil {
return
}
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
items := buildSearchEntries(graph)
if query != "" {
items = rankSearchEntries(items, query)
}
writeJSON(w, map[string]any{
"query": query,
"items": items,
})
}
func (a *API) handlePreview(w http.ResponseWriter, req *http.Request) {
graph := a.requireGraph(w, req)
if graph == nil {
return
}
writeJSON(w, buildPreviewManifest(graph))
}
func (a *API) requireGraph(w http.ResponseWriter, req *http.Request) *content.SiteGraph {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return nil
}
graph := a.currentGraph()
if graph == nil {
http.Error(w, "site graph is unavailable", http.StatusServiceUnavailable)
return nil
}
return graph
}
func buildSiteInfo(graph *content.SiteGraph) SiteInfoResponse {
var taxonomies map[string]any
if graph != nil && graph.Config != nil && len(graph.Config.Taxonomies.Definitions) > 0 {
taxonomies = make(map[string]any, len(graph.Config.Taxonomies.Definitions))
for name, def := range graph.Config.Taxonomies.Definitions {
taxonomies[name] = map[string]any{
"title": def.Title,
"labels": def.Labels,
"archive_layout": def.ArchiveLayout,
"term_layout": def.TermLayout,
"order": def.Order,
}
}
}
return SiteInfoResponse{
Name: graph.Config.Name,
Title: graph.Config.Title,
BaseURL: graph.Config.BaseURL,
Theme: graph.Config.Theme,
Environment: graph.Config.Environment,
DefaultLang: graph.Config.DefaultLang,
Menus: buildNavigation(graph),
Params: graph.Config.Params,
Taxonomies: taxonomies,
}
}
func buildNavigation(graph *content.SiteGraph) map[string][]NavItem {
if graph == nil || graph.Config == nil {
return map[string][]NavItem{}
}
out := map[string][]NavItem{}
if len(graph.Config.Menus) > 0 {
for key, items := range graph.Config.Menus {
for _, item := range items {
out[key] = append(out[key], NavItem{Name: item.Name, URL: normalizeURLPath(item.URL)})
}
}
}
if len(out) == 0 {
if raw, ok := graph.Data["navigation"].(map[string]any); ok {
for key, value := range raw {
list, ok := value.([]any)
if !ok {
continue
}
for _, entry := range list {
item, ok := entry.(map[string]any)
if !ok {
continue
}
name, _ := item["name"].(string)
urlValue, _ := item["url"].(string)
if strings.TrimSpace(name) == "" || strings.TrimSpace(urlValue) == "" {
continue
}
out[key] = append(out[key], NavItem{Name: name, URL: normalizeURLPath(urlValue)})
}
}
}
}
return out
}
func buildRouteRecords(graph *content.SiteGraph) []RouteRecord {
out := make([]RouteRecord, 0, len(graph.Documents)+8)
for _, doc := range graph.Documents {
if doc == nil || doc.Draft || documentArchived(doc) {
continue
}
out = append(out, RouteRecord{
Kind: "document",
ID: doc.ID,
URL: doc.URL,
Type: doc.Type,
Lang: doc.Lang,
Title: doc.Title,
ContentID: doc.ID,
})
}
langs := make(map[string]struct{})
for _, doc := range graph.Documents {
if doc == nil || doc.Draft || documentArchived(doc) {
continue
}
if strings.TrimSpace(doc.Lang) != "" {
langs[doc.Lang] = struct{}{}
}
}
if len(langs) == 0 && graph.Config != nil {
langs[graph.Config.DefaultLang] = struct{}{}
}
for lang := range langs {
out = append(out, RouteRecord{
Kind: "search",
URL: content.SearchPageURL(graph.Config.DefaultLang, lang),
Lang: lang,
Title: "Search",
})
}
seenAuthors := make(map[string]struct{})
for _, doc := range graph.Documents {
if doc == nil || doc.Draft || documentArchived(doc) {
continue
}
name := strings.TrimSpace(doc.Author)
if name == "" {
continue
}
key := doc.Lang + "|" + name
if _, ok := seenAuthors[key]; ok {
continue
}
seenAuthors[key] = struct{}{}
out = append(out, RouteRecord{
Kind: "author",
URL: content.AuthorArchiveURL(graph.Config.DefaultLang, doc.Lang, name),
Lang: doc.Lang,
Title: name,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].URL < out[j].URL })
return out
}
func buildCollectionItems(graph *content.SiteGraph) []ContentSummary {
out := make([]ContentSummary, 0, len(graph.Documents))
for _, doc := range graph.Documents {
if doc == nil || doc.Draft || documentArchived(doc) {
continue
}
out = append(out, buildContentSummary(doc))
}
sort.Slice(out, func(i, j int) bool { return out[i].URL < out[j].URL })
return out
}
func buildContentSummary(doc *content.Document) ContentSummary {
return ContentSummary{
ID: doc.ID,
Type: doc.Type,
Lang: doc.Lang,
Title: doc.Title,
Slug: doc.Slug,
URL: doc.URL,
Layout: doc.Layout,
Summary: doc.Summary,
Date: doc.Date,
Author: doc.Author,
LastEditor: doc.LastEditor,
Taxonomies: cloneTaxonomies(doc.Taxonomies),
}
}
func buildContentDetail(doc *content.Document) ContentDetail {
summary := buildContentSummary(doc)
return ContentDetail{
ContentSummary: summary,
HTMLBody: string(doc.HTMLBody),
RawBody: doc.RawBody,
Fields: cloneMap(doc.Fields),
Params: cloneMap(doc.Params),
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
}
}
func buildSearchEntries(graph *content.SiteGraph) []SearchEntry {
out := make([]SearchEntry, 0, len(graph.Documents))
for _, doc := range graph.Documents {
if doc == nil || doc.Draft || documentArchived(doc) {
continue
}
out = append(out, SearchEntry{
Title: doc.Title,
URL: doc.URL,
Summary: doc.Summary,
Snippet: deriveSearchSnippet(doc.Title, doc.Summary, normalizeSearchContent(doc), ""),
Content: normalizeSearchContent(doc),
Type: doc.Type,
Lang: doc.Lang,
Layout: doc.Layout,
Taxonomies: cloneTaxonomies(doc.Taxonomies),
})
}
return out
}
func rankSearchEntries(items []SearchEntry, query string) []SearchEntry {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return items
}
type scored struct {
entry SearchEntry
score int
}
scoredItems := make([]scored, 0, len(items))
for _, item := range items {
score := 0
title := strings.ToLower(item.Title)
summary := strings.ToLower(item.Summary)
content := strings.ToLower(item.Content)
url := strings.ToLower(item.URL)
if strings.Contains(title, query) {
score += 6
}
if strings.Contains(summary, query) {
score += 4
}
if strings.Contains(content, query) {
score += 2
}
if strings.Contains(url, query) {
score += 1
}
if score == 0 {
continue
}
item.Snippet = deriveSearchSnippet(item.Title, item.Summary, item.Content, query)
scoredItems = append(scoredItems, scored{entry: item, score: score})
}
sort.SliceStable(scoredItems, func(i, j int) bool {
if scoredItems[i].score == scoredItems[j].score {
return scoredItems[i].entry.Title < scoredItems[j].entry.Title
}
return scoredItems[i].score > scoredItems[j].score
})
out := make([]SearchEntry, 0, len(scoredItems))
for _, item := range scoredItems {
out = append(out, item.entry)
}
return out
}
func deriveSearchSnippet(title, summary, content, query string) string {
if strings.TrimSpace(summary) != "" {
return strings.TrimSpace(summary)
}
body := strings.TrimSpace(content)
if body == "" {
return strings.TrimSpace(title)
}
if query == "" {
return firstRunes(body, 180)
}
lower := strings.ToLower(body)
idx := strings.Index(lower, strings.ToLower(strings.TrimSpace(query)))
if idx < 0 {
return firstRunes(body, 180)
}
start := idx - 60
if start < 0 {
start = 0
}
end := start + 180
runes := []rune(body)
if start > len(runes) {
start = 0
}
if end > len(runes) {
end = len(runes)
}
snippet := strings.TrimSpace(string(runes[start:end]))
if start > 0 {
snippet = "..." + snippet
}
if end < len(runes) {
snippet += "..."
}
return snippet
}
func firstRunes(value string, max int) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) <= max {
return string(runes)
}
return strings.TrimSpace(string(runes[:max])) + "..."
}
func WriteStaticArtifacts(cfg *config.Config, graph *content.SiteGraph) error {
if cfg == nil || graph == nil {
return nil
}
root := filepath.Join(cfg.PublicDir, "__foundry")
if err := os.MkdirAll(filepath.Join(root, "content"), 0o755); err != nil {
return err
}
if err := writeJSONFile(filepath.Join(root, "capabilities.json"), CapabilitiesResponse{
SDKVersion: "v1",
Modules: map[string]bool{
"site": true,
"navigation": true,
"routes": true,
"content": true,
"collections": true,
"search": true,
"media": true,
"preview": true,
},
Features: map[string]bool{
"search": true,
"preview": false,
"preview_manifest": true,
"live_preview": false,
"navigation": true,
"taxonomies": true,
"media_refs": true,
},
}); err != nil {
return err
}
if err := writeJSONFile(filepath.Join(root, "site.json"), buildSiteInfo(graph)); err != nil {
return err
}
if err := writeJSONFile(filepath.Join(root, "navigation.json"), buildNavigation(graph)); err != nil {
return err
}
if err := writeJSONFile(filepath.Join(root, "routes.json"), buildRouteRecords(graph)); err != nil {
return err
}
if err := writeJSONFile(filepath.Join(root, "collections.json"), map[string]any{"items": buildCollectionItems(graph)}); err != nil {
return err
}
if err := writeJSONFile(filepath.Join(root, "search.json"), buildSearchEntries(graph)); err != nil {
return err
}
if err := writeJSONFile(filepath.Join(root, "preview.json"), buildPreviewManifest(graph)); err != nil {
return err
}
for _, doc := range graph.Documents {
if doc == nil || doc.Draft || documentArchived(doc) {
continue
}
filename := filepath.Join(root, "content", url.PathEscape(doc.ID)+".json")
if err := writeJSONFile(filename, buildContentDetail(doc)); err != nil {
return err
}
}
return sdkassets.CopyToDir(filepath.Join(root, "sdk"))
}
func buildPreviewManifest(graph *content.SiteGraph) PreviewManifest {
manifest := PreviewManifest{
GeneratedAt: time.Now().UTC(),
Environment: graph.Config.Environment,
Links: make([]PreviewLink, 0),
}
for _, doc := range graph.Documents {
if doc == nil {
continue
}
if doc.Status == "published" && !doc.Draft {
continue
}
manifest.Links = append(manifest.Links, PreviewLink{
Title: doc.Title,
Status: doc.Status,
Type: doc.Type,
Lang: doc.Lang,
SourcePath: doc.SourcePath,
URL: doc.URL,
PreviewURL: doc.URL,
})
}
sort.Slice(manifest.Links, func(i, j int) bool {
if manifest.Links[i].Status != manifest.Links[j].Status {
return manifest.Links[i].Status < manifest.Links[j].Status
}
return manifest.Links[i].URL < manifest.Links[j].URL
})
return manifest
}
func writeJSONFile(path string, v any) error {
body, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(body, '\n'), 0o644)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v)
}
func normalizeURLPath(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "/"
}
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
return value
}
if !strings.HasPrefix(value, "/") {
value = "/" + value
}
if value != "/" && !strings.Contains(filepath.Base(value), ".") && !strings.HasSuffix(value, "/") {
value += "/"
}
return value
}
func parsePositiveInt(value string, fallback int) int {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
n := fallback
if _, err := fmt.Sscanf(value, "%d", &n); err != nil || n <= 0 {
return fallback
}
return n
}
func normalizeSearchContent(doc *content.Document) string {
if doc == nil {
return ""
}
if strings.TrimSpace(doc.RawBody) != "" {
return strings.Join(strings.Fields(doc.RawBody), " ")
}
return strings.Join(strings.Fields(string(doc.HTMLBody)), " ")
}
func cloneTaxonomies(in map[string][]string) map[string][]string {
if len(in) == 0 {
return nil
}
out := make(map[string][]string, len(in))
for key, values := range in {
out[key] = append([]string(nil), values...)
}
return out
}
func cloneMap(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func filterContent(items []ContentSummary, fn func(ContentSummary) bool) []ContentSummary {
out := make([]ContentSummary, 0, len(items))
for _, item := range items {
if fn(item) {
out = append(out, item)
}
}
return out
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func documentArchived(doc *content.Document) bool {
if doc == nil || doc.Params == nil {
return false
}
value, ok := doc.Params["archived"].(bool)
return ok && value
}
package plugins
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strings"
)
type SecurityReport struct {
DeclaredPermissions PermissionSet `json:"declared_permissions,omitempty"`
Runtime RuntimeConfig `json:"runtime,omitempty"`
RiskTier string `json:"risk_tier"`
RequiresApproval bool `json:"requires_approval"`
Summary []string `json:"summary,omitempty"`
Findings []SecurityFinding `json:"findings,omitempty"`
Mismatches []ValidationDiagnostic `json:"mismatches,omitempty"`
Effective SecurityEnforcementState `json:"effective"`
}
type SecurityFinding struct {
Category string `json:"category"`
Evidence string `json:"evidence"`
EvidenceType string `json:"evidence_type,omitempty"`
Path string `json:"path,omitempty"`
Message string `json:"message,omitempty"`
}
type SecurityEnforcementState struct {
Mode string `json:"mode,omitempty"`
RuntimeHost string `json:"runtime_host,omitempty"`
RuntimeSupported bool `json:"runtime_supported"`
Strict bool `json:"strict"`
Allowed bool `json:"allowed"`
ApprovalRequired bool `json:"approval_required"`
DeniedReasons []string `json:"denied_reasons,omitempty"`
CapabilityBoundary []string `json:"capability_boundary,omitempty"`
}
func AnalyzeInstalled(meta Metadata) SecurityReport {
report := SecurityReport{
DeclaredPermissions: meta.Permissions,
Runtime: meta.Runtime,
RiskTier: meta.Permissions.RiskTier(),
RequiresApproval: meta.Permissions.Capabilities.RequiresAdminApproval,
Summary: append(append([]string{}, meta.Permissions.Summary()...), meta.Runtime.Summary()...),
Findings: []SecurityFinding{},
Mismatches: []ValidationDiagnostic{},
Effective: SecurityEnforcementState{
Mode: strings.TrimSpace(meta.Runtime.Mode),
RuntimeHost: ResolveRuntimeHost(meta).Name(),
RuntimeSupported: EnsureRuntimeSupported(meta) == nil,
Strict: true,
Allowed: true,
},
}
findings := analyzePluginDirectory(meta.Directory)
report.Findings = findings
report.Mismatches = compareDeclaredPermissions(meta, findings)
if len(report.Mismatches) > 0 {
report.RequiresApproval = true
if report.RiskTier == "low" {
report.RiskTier = "medium"
}
}
if strings.EqualFold(meta.Runtime.Mode, "rpc") {
report.RequiresApproval = true
if report.RiskTier == "low" {
report.RiskTier = "medium"
}
}
report.Effective.ApprovalRequired = report.RequiresApproval
if err := EnsureRuntimeSupported(meta); err != nil {
report.Effective.RuntimeSupported = false
report.Effective.Allowed = false
report.Effective.DeniedReasons = append(report.Effective.DeniedReasons, err.Error())
}
if len(report.Mismatches) > 0 {
report.Effective.Allowed = false
report.Effective.DeniedReasons = append(report.Effective.DeniedReasons, "declared permissions do not match detected capabilities")
}
report.Effective.CapabilityBoundary = capabilityBoundaryForRuntime(meta.Runtime)
return report
}
func analyzePluginDirectory(root string) []SecurityFinding {
fset := token.NewFileSet()
findings := []SecurityFinding{}
seen := map[string]struct{}{}
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
name := d.Name()
if name == ".git" || name == "vendor" || name == "node_modules" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, parseErr := parser.ParseFile(fset, path, nil, parser.ParseComments)
if parseErr != nil {
addFinding(&findings, seen, "parser", filepath.ToSlash(path), "parse", parseErr.Error(), "direct")
return nil
}
imports := map[string]string{}
for _, spec := range file.Imports {
importPath := strings.Trim(spec.Path.Value, `"`)
alias := filepath.Base(importPath)
if spec.Name != nil {
alias = spec.Name.Name
}
imports[alias] = importPath
switch importPath {
case "net/http":
addFinding(&findings, seen, "network.outbound", filepath.ToSlash(path), "import net/http", "uses net/http", "heuristic")
case "os/exec":
addFinding(&findings, seen, "process.exec", filepath.ToSlash(path), "import os/exec", "uses os/exec", "heuristic")
case "plugin":
addFinding(&findings, seen, "capabilities.dynamic_loading", filepath.ToSlash(path), "import plugin", "uses Go dynamic plugin loading", "direct")
case "net/url":
addFinding(&findings, seen, "network.outbound", filepath.ToSlash(path), "import net/url", "constructs or parses remote URLs", "heuristic")
case "syscall", "unsafe":
addFinding(&findings, seen, "capabilities.dangerous", filepath.ToSlash(path), "import "+importPath, "uses low-level unsafe package", "direct")
}
if strings.Contains(importPath, "grpc") {
addFinding(&findings, seen, "network.outbound", filepath.ToSlash(path), "import "+importPath, "uses gRPC package", "heuristic")
}
if strings.Contains(importPath, "websocket") {
addFinding(&findings, seen, "network.outbound", filepath.ToSlash(path), "import "+importPath, "uses websocket package", "heuristic")
}
if strings.Contains(importPath, "/internal/backup") {
addFinding(&findings, seen, "admin.operations.backups", filepath.ToSlash(path), "import "+importPath, "touches backup APIs", "direct")
}
if strings.Contains(importPath, "/internal/updater") {
addFinding(&findings, seen, "admin.operations.updates", filepath.ToSlash(path), "import "+importPath, "touches update APIs", "direct")
}
if strings.Contains(importPath, "/internal/admin/audit") {
addFinding(&findings, seen, "admin.audit.read", filepath.ToSlash(path), "import "+importPath, "touches admin audit APIs", "direct")
}
if strings.Contains(importPath, "/internal/admin/users") {
addFinding(&findings, seen, "admin.users.read", filepath.ToSlash(path), "import "+importPath, "touches admin users APIs", "direct")
}
}
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok {
continue
}
switch fn.Name.Name {
case "RegisterRoutes":
addFinding(&findings, seen, "network.inbound.register_routes", filepath.ToSlash(path), "RegisterRoutes", "registers preview routes", "direct")
case "OnContext":
addFinding(&findings, seen, "render.context", filepath.ToSlash(path), "OnContext", "reads or mutates render context", "direct")
case "OnAssets":
addFinding(&findings, seen, "render.assets", filepath.ToSlash(path), "OnAssets", "injects assets into render pipeline", "direct")
case "OnHTMLSlots":
addFinding(&findings, seen, "render.html_slots", filepath.ToSlash(path), "OnHTMLSlots", "injects HTML into theme slots", "direct")
case "OnAfterRender":
addFinding(&findings, seen, "render.after_render", filepath.ToSlash(path), "OnAfterRender", "mutates final rendered HTML", "direct")
case "OnRoutesAssigned":
addFinding(&findings, seen, "graph.mutate", filepath.ToSlash(path), "OnRoutesAssigned", "observes or mutates assigned routes", "direct")
case "OnGraphBuilding", "OnGraphBuilt":
addFinding(&findings, seen, "graph.read", filepath.ToSlash(path), fn.Name.Name, "observes site graph", "direct")
case "OnTaxonomyBuilt":
addFinding(&findings, seen, "graph.taxonomies.inspect", filepath.ToSlash(path), fn.Name.Name, "observes taxonomy graph", "direct")
case "OnDocumentParsed", "OnFrontmatterParsed", "OnMarkdownRendered":
addFinding(&findings, seen, "content.documents.read", filepath.ToSlash(path), fn.Name.Name, "reads document content", "direct")
case "OnServerStarted":
addFinding(&findings, seen, "runtime.server.on_started", filepath.ToSlash(path), fn.Name.Name, "hooks server startup", "direct")
}
ast.Inspect(fn.Body, func(node ast.Node) bool {
switch n := node.(type) {
case *ast.BasicLit:
if n.Kind == token.STRING {
value := strings.Trim(n.Value, `"`)
if looksLikeSecretPath(value) {
addFinding(&findings, seen, "secrets.path_access", filepath.ToSlash(path), value, "references secret-looking path or file name", "heuristic")
}
switch {
case strings.Contains(value, "/api/backups"):
addFinding(&findings, seen, "admin.operations.backups", filepath.ToSlash(path), value, "references backup admin API", "heuristic")
case strings.Contains(value, "/api/update"):
addFinding(&findings, seen, "admin.operations.updates", filepath.ToSlash(path), value, "references update admin API", "heuristic")
case strings.Contains(value, "/api/operations/rebuild"):
addFinding(&findings, seen, "admin.operations.rebuild", filepath.ToSlash(path), value, "references rebuild admin API", "heuristic")
case strings.Contains(value, "/api/operations/cache/clear"):
addFinding(&findings, seen, "admin.operations.clear_cache", filepath.ToSlash(path), value, "references cache-clear admin API", "heuristic")
case strings.Contains(value, "/api/audit"):
addFinding(&findings, seen, "admin.audit.read", filepath.ToSlash(path), value, "references audit admin API", "heuristic")
case strings.Contains(value, "/api/users"):
addFinding(&findings, seen, "admin.users.read", filepath.ToSlash(path), value, "references users admin API", "heuristic")
}
}
}
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
importPath := imports[ident.Name]
if importPath == "" {
return true
}
name := importPath + "." + sel.Sel.Name
switch {
case name == "os.ReadFile" || name == "os.Open" || name == "io/ioutil.ReadFile" || name == "io/ioutil.ReadDir" || name == "path/filepath.Walk" || name == "path/filepath.WalkDir":
addFinding(&findings, seen, "filesystem.read", filepath.ToSlash(path), name, "reads from filesystem", "direct")
case name == "os.WriteFile" || name == "io/ioutil.WriteFile" || name == "os.OpenFile" || name == "os.Rename":
addFinding(&findings, seen, "filesystem.write", filepath.ToSlash(path), name, "writes to filesystem", "direct")
case name == "os.Remove" || name == "os.RemoveAll":
addFinding(&findings, seen, "filesystem.delete", filepath.ToSlash(path), name, "deletes filesystem paths", "direct")
case name == "os.Getenv" || name == "os.Environ":
addFinding(&findings, seen, "environment.read", filepath.ToSlash(path), name, "reads environment variables", "direct")
case name == "net/http.Get" || name == "net/http.Post" || name == "net/http.NewRequest" || name == "net/http.NewRequestWithContext" || name == "net.Dial" || name == "net.DialTimeout":
addFinding(&findings, seen, "network.outbound", filepath.ToSlash(path), name, "makes outbound network calls", "direct")
case name == "os/exec.Command" || name == "os/exec.CommandContext":
addFinding(&findings, seen, "process.exec", filepath.ToSlash(path), name, "executes local commands", "direct")
if len(call.Args) > 0 {
if lit, ok := call.Args[0].(*ast.BasicLit); ok {
value := strings.Trim(lit.Value, `"`)
if value == "sh" || value == "bash" || value == "zsh" || value == "cmd" || value == "powershell" {
addFinding(&findings, seen, "process.shell", filepath.ToSlash(path), name+"("+value+")", "executes shell process", "direct")
}
}
}
case strings.HasSuffix(name, ".Client.Do"):
addFinding(&findings, seen, "network.outbound", filepath.ToSlash(path), name, "makes outbound network calls through http.Client", "direct")
case strings.HasSuffix(name, "/internal/admin/users.Save"):
addFinding(&findings, seen, "admin.users.write", filepath.ToSlash(path), name, "mutates admin users", "direct")
case strings.HasSuffix(name, "/internal/admin/users.Load") || strings.HasSuffix(name, "/internal/admin/users.Find"):
addFinding(&findings, seen, "admin.users.read", filepath.ToSlash(path), name, "reads admin users", "direct")
case strings.Contains(name, "/internal/admin/auth.") && (strings.HasSuffix(name, ".StartPasswordReset") || strings.HasSuffix(name, ".CompletePasswordReset")):
addFinding(&findings, seen, "admin.users.reset_passwords", filepath.ToSlash(path), name, "resets admin passwords", "direct")
case strings.Contains(name, "/internal/admin/auth.") && (strings.HasSuffix(name, ".RevokeSessions") || strings.HasSuffix(name, ".RevokeAllSessions")):
addFinding(&findings, seen, "admin.users.revoke_sessions", filepath.ToSlash(path), name, "revokes admin sessions", "direct")
case strings.HasSuffix(name, "/internal/backup.CreateManagedSnapshot") || strings.HasSuffix(name, "/internal/backup.CreateZipSnapshot") || strings.HasSuffix(name, "/internal/backup.RestoreZipSnapshot") || strings.HasSuffix(name, "/internal/backup.CreateGitSnapshot") || strings.HasSuffix(name, "/internal/backup.ListGitSnapshots") || strings.HasSuffix(name, "/internal/backup.List"):
addFinding(&findings, seen, "admin.operations.backups", filepath.ToSlash(path), name, "touches backup operations", "direct")
case strings.HasSuffix(name, "/internal/updater.Check") || strings.HasSuffix(name, "/internal/updater.ScheduleApply"):
addFinding(&findings, seen, "admin.operations.updates", filepath.ToSlash(path), name, "touches update operations", "direct")
}
return true
})
}
return nil
})
sort.Slice(findings, func(i, j int) bool {
if findings[i].Category != findings[j].Category {
return findings[i].Category < findings[j].Category
}
if findings[i].Path != findings[j].Path {
return findings[i].Path < findings[j].Path
}
return findings[i].Evidence < findings[j].Evidence
})
return findings
}
func addFinding(findings *[]SecurityFinding, seen map[string]struct{}, category, path, evidence, message, evidenceType string) {
key := category + "|" + path + "|" + evidence
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
*findings = append(*findings, SecurityFinding{
Category: category,
Path: path,
Evidence: evidence,
Message: message,
EvidenceType: evidenceType,
})
}
func compareDeclaredPermissions(meta Metadata, findings []SecurityFinding) []ValidationDiagnostic {
out := []ValidationDiagnostic{}
add := func(category, evidence, message string) {
out = append(out, ValidationDiagnostic{
Severity: "error",
Path: filepath.ToSlash(filepath.Join(meta.Directory, "plugin.yaml")),
Message: fmt.Sprintf("%s (%s): %s", category, evidence, message),
})
}
for _, finding := range findings {
switch finding.Category {
case "filesystem.read":
if !meta.Permissions.Filesystem.Read.Content && !meta.Permissions.Filesystem.Read.Data && !meta.Permissions.Filesystem.Read.Public && !meta.Permissions.Filesystem.Read.Themes && !meta.Permissions.Filesystem.Read.Plugins && !meta.Permissions.Filesystem.Read.Config && len(meta.Permissions.Filesystem.Read.Custom) == 0 {
add(finding.Category, finding.Evidence, "filesystem read not declared in permissions.filesystem.read")
}
case "filesystem.write":
if !meta.Permissions.Filesystem.Write.Content && !meta.Permissions.Filesystem.Write.Data && !meta.Permissions.Filesystem.Write.Public && !meta.Permissions.Filesystem.Write.Cache && !meta.Permissions.Filesystem.Write.Backups && len(meta.Permissions.Filesystem.Write.Custom) == 0 {
add(finding.Category, finding.Evidence, "filesystem write not declared in permissions.filesystem.write")
}
case "filesystem.delete":
if !meta.Permissions.Filesystem.Delete.Content && !meta.Permissions.Filesystem.Delete.Data && !meta.Permissions.Filesystem.Delete.Public && !meta.Permissions.Filesystem.Delete.Cache && !meta.Permissions.Filesystem.Delete.Backups && len(meta.Permissions.Filesystem.Delete.Custom) == 0 {
add(finding.Category, finding.Evidence, "filesystem delete not declared in permissions.filesystem.delete")
}
case "environment.read":
if !meta.Permissions.Environment.Read.Allowed {
add(finding.Category, finding.Evidence, "environment access not declared in permissions.environment.read")
}
case "network.outbound":
if !meta.Permissions.Network.Outbound.HTTP && !meta.Permissions.Network.Outbound.HTTPS && !meta.Permissions.Network.Outbound.WebSocket && !meta.Permissions.Network.Outbound.GRPC && len(meta.Permissions.Network.Outbound.CustomSchemes) == 0 {
add(finding.Category, finding.Evidence, "outbound network access not declared in permissions.network.outbound")
}
case "network.inbound.register_routes":
if !meta.Permissions.Network.Inbound.RegisterRoutes {
add(finding.Category, finding.Evidence, "route registration not declared in permissions.network.inbound.register_routes")
}
case "process.exec":
if !meta.Permissions.Process.Exec.Allowed {
add(finding.Category, finding.Evidence, "process execution not declared in permissions.process.exec")
}
case "process.shell":
if !meta.Permissions.Process.Shell.Allowed {
add(finding.Category, finding.Evidence, "shell execution not declared in permissions.process.shell")
}
case "render.context":
if !meta.Permissions.Render.Context.Read && !meta.Permissions.Render.Context.Write {
add(finding.Category, finding.Evidence, "render context access not declared in permissions.render.context")
}
case "render.assets":
if !meta.Permissions.Render.Assets.InjectCSS && !meta.Permissions.Render.Assets.InjectJS && !meta.Permissions.Render.Assets.InjectRemoteAssets {
add(finding.Category, finding.Evidence, "asset injection not declared in permissions.render.assets")
}
case "render.html_slots":
if !meta.Permissions.Render.HTMLSlots.Inject {
add(finding.Category, finding.Evidence, "slot injection not declared in permissions.render.html_slots")
}
case "render.after_render":
if !meta.Permissions.Render.AfterRender.MutateHTML {
add(finding.Category, finding.Evidence, "after-render mutation not declared in permissions.render.after_render")
}
case "graph.read":
if !meta.Permissions.Graph.Read && !meta.Permissions.Graph.Mutate {
add(finding.Category, finding.Evidence, "graph access not declared in permissions.graph")
}
case "graph.mutate":
if !meta.Permissions.Graph.Mutate && !meta.Permissions.Graph.Routes.Mutate {
add(finding.Category, finding.Evidence, "graph/route mutation not declared in permissions.graph")
}
case "graph.taxonomies.inspect":
if !meta.Permissions.Graph.Taxonomies.Inspect && !meta.Permissions.Graph.Read {
add(finding.Category, finding.Evidence, "taxonomy graph access not declared in permissions.graph.taxonomies.inspect")
}
case "content.documents.read":
if !meta.Permissions.Content.Documents.Read {
add(finding.Category, finding.Evidence, "document access not declared in permissions.content.documents.read")
}
case "runtime.server.on_started":
if !meta.Permissions.Runtime.Server.OnStarted {
add(finding.Category, finding.Evidence, "server-start hook not declared in permissions.runtime.server.on_started")
}
case "capabilities.dangerous":
if !meta.Permissions.Capabilities.Dangerous {
add(finding.Category, finding.Evidence, "dangerous capability not declared in permissions.capabilities.dangerous")
}
case "capabilities.dynamic_loading":
if !meta.Permissions.Capabilities.Dangerous {
add(finding.Category, finding.Evidence, "dynamic plugin loading must declare permissions.capabilities.dangerous")
}
case "secrets.path_access":
if !meta.Permissions.Secrets.Access.EnvSecrets && !meta.Permissions.Secrets.Access.DeployKeys && !meta.Permissions.Secrets.Access.SessionStore && !meta.Permissions.Secrets.Access.UpdateCredentials {
add(finding.Category, finding.Evidence, "secret-looking file access must declare matching permissions.secrets.access capability")
}
case "admin.operations.backups":
if !meta.Permissions.Admin.Operations.Backups {
add(finding.Category, finding.Evidence, "backup operations not declared in permissions.admin.operations.backups")
}
case "admin.operations.updates":
if !meta.Permissions.Admin.Operations.Updates {
add(finding.Category, finding.Evidence, "update operations not declared in permissions.admin.operations.updates")
}
case "admin.operations.rebuild":
if !meta.Permissions.Admin.Operations.Rebuild {
add(finding.Category, finding.Evidence, "rebuild operations not declared in permissions.admin.operations.rebuild")
}
case "admin.operations.clear_cache":
if !meta.Permissions.Admin.Operations.ClearCache {
add(finding.Category, finding.Evidence, "cache clear operations not declared in permissions.admin.operations.clear_cache")
}
case "admin.audit.read":
if !meta.Permissions.Admin.Audit.Read {
add(finding.Category, finding.Evidence, "audit reads not declared in permissions.admin.audit.read")
}
case "admin.users.read":
if !meta.Permissions.Admin.Users.Read {
add(finding.Category, finding.Evidence, "admin user reads not declared in permissions.admin.users.read")
}
case "admin.users.write":
if !meta.Permissions.Admin.Users.Write {
add(finding.Category, finding.Evidence, "admin user writes not declared in permissions.admin.users.write")
}
case "admin.users.revoke_sessions":
if !meta.Permissions.Admin.Users.RevokeSessions {
add(finding.Category, finding.Evidence, "session revocation not declared in permissions.admin.users.revoke_sessions")
}
case "admin.users.reset_passwords":
if !meta.Permissions.Admin.Users.ResetPasswords {
add(finding.Category, finding.Evidence, "password reset operations not declared in permissions.admin.users.reset_passwords")
}
}
}
if len(meta.AdminExtensions.Pages) > 0 && !meta.Permissions.Admin.Extensions.Pages {
add("admin.extensions.pages", "plugin.yaml:admin.pages", "admin page extensions not declared in permissions.admin.extensions.pages")
}
if len(meta.AdminExtensions.Widgets) > 0 && !meta.Permissions.Admin.Extensions.Widgets {
add("admin.extensions.widgets", "plugin.yaml:admin.widgets", "admin widgets not declared in permissions.admin.extensions.widgets")
}
if len(meta.AdminExtensions.SettingsSections) > 0 && !meta.Permissions.Admin.Extensions.SettingsSections {
add("admin.extensions.settings_sections", "plugin.yaml:admin.settings_sections", "admin settings sections not declared in permissions.admin.extensions.settings_sections")
}
if len(meta.AdminExtensions.Slots) > 0 && !meta.Permissions.Admin.Extensions.Slots {
add("admin.extensions.slots", "plugin.yaml:admin.slots", "admin slots not declared in permissions.admin.extensions.slots")
}
return out
}
func SecurityApprovalRequired(meta Metadata, report SecurityReport) bool {
return report.RequiresApproval || len(report.Mismatches) > 0 || strings.EqualFold(meta.Runtime.Mode, "rpc")
}
func capabilityBoundaryForRuntime(runtime RuntimeConfig) []string {
mode := strings.ToLower(strings.TrimSpace(runtime.Mode))
if mode == "rpc" {
return []string{
"host-to-plugin messages only expose declared hook payloads",
"plugin process receives sanitized environment only",
"host does not expose direct config, session, or filesystem channels",
}
}
return []string{
"in-process plugin shares Foundry process memory",
"declared permissions are advisory and validated, not OS-isolated",
}
}
func looksLikeSecretPath(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return false
}
candidates := []string{".env", "id_rsa", "credentials", "secrets", "session", "passwd", "private_key"}
for _, candidate := range candidates {
if strings.Contains(value, candidate) {
return true
}
}
return containsSensitivePathToken(value, "token")
}
func containsSensitivePathToken(value, token string) bool {
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
return r == '/' || r == '\\' || r == '.' || r == '-' || r == ' '
}) {
if part == token {
return true
}
}
return false
}
package plugins
import (
"fmt"
"strings"
)
const SupportedFoundryAPI = "v1"
func validateMetadataCompatibility(meta Metadata) error {
if strings.TrimSpace(meta.FoundryAPI) == "" {
return fmt.Errorf("missing required field %q", "foundry_api")
}
if meta.FoundryAPI != SupportedFoundryAPI {
return fmt.Errorf("unsupported foundry_api %q (supported: %s)", meta.FoundryAPI, SupportedFoundryAPI)
}
if strings.TrimSpace(meta.MinFoundryVersion) == "" {
return fmt.Errorf("missing required field %q", "min_foundry_version")
}
for _, page := range meta.AdminExtensions.Pages {
if strings.TrimSpace(page.NavGroup) == "" {
continue
}
switch normalizeAdminNavGroup(page.NavGroup) {
case "dashboard", "content", "manage", "admin":
default:
return fmt.Errorf("unsupported admin.pages.nav_group %q for page %q (supported: dashboard, content, manage, admin)", page.NavGroup, page.Key)
}
}
if err := validatePermissions(meta.Permissions); err != nil {
return err
}
if err := validateRuntime(meta.Runtime); err != nil {
return err
}
return nil
}
func validatePermissions(p PermissionSet) error {
for _, method := range p.Network.Outbound.Methods {
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS":
default:
return fmt.Errorf("unsupported permissions.network.outbound.methods value %q", method)
}
}
if !p.Process.Exec.Allowed && len(p.Process.Exec.Commands) > 0 {
return fmt.Errorf("permissions.process.exec.allowed must be true when commands are declared")
}
if !p.Environment.Read.Allowed && len(p.Environment.Read.Variables) > 0 {
return fmt.Errorf("permissions.environment.read.allowed must be true when variables are declared")
}
if (p.Network.Outbound.HTTP || p.Network.Outbound.HTTPS || p.Network.Outbound.WebSocket || p.Network.Outbound.GRPC) && len(p.Network.Outbound.Methods) == 0 {
return fmt.Errorf("permissions.network.outbound.methods must declare at least one HTTP method when outbound network access is enabled")
}
if !p.Capabilities.RequiresAdminApproval && (p.Capabilities.Dangerous ||
p.Process.Exec.Allowed ||
p.Process.Shell.Allowed ||
p.Process.SpawnBackground.Allowed ||
p.Secrets.Access.AdminTokens ||
p.Secrets.Access.SessionStore ||
p.Secrets.Access.PasswordHashes ||
p.Secrets.Access.TOTPSecrets ||
p.Secrets.Access.EnvSecrets ||
p.Secrets.Access.DeployKeys ||
p.Secrets.Access.UpdateCredentials) {
return fmt.Errorf("dangerous or secret-accessing plugins must set permissions.capabilities.requires_admin_approval=true")
}
return nil
}
func validateRuntime(r RuntimeConfig) error {
switch strings.TrimSpace(r.Mode) {
case "", "in_process", "rpc":
default:
return fmt.Errorf("unsupported runtime.mode %q (supported: in_process, rpc)", r.Mode)
}
switch strings.TrimSpace(r.Sandbox.Profile) {
case "", "default", "strict":
default:
return fmt.Errorf("unsupported runtime.sandbox.profile %q (supported: default, strict)", r.Sandbox.Profile)
}
if strings.TrimSpace(r.Mode) == "rpc" && len(r.Command) == 0 && strings.TrimSpace(r.Socket) == "" {
return fmt.Errorf("runtime.mode rpc must declare runtime.command or runtime.socket")
}
return nil
}
package plugins
import (
"fmt"
"io"
"sort"
"strings"
)
// CommandContext is the execution context passed to plugin CLI commands.
//
// Args contains only the arguments that follow the plugin command name.
type CommandContext struct {
Args []string
Stdout io.Writer
Stderr io.Writer
}
// CLIHook lets a plugin contribute subcommands to the Foundry CLI.
//
// Commands are discovered during command dispatch and names must be unique
// across all enabled plugins.
type CLIHook interface {
Commands() []Command
}
// Command describes a single plugin-owned CLI command.
type Command struct {
Name string
Summary string
Description string
Run func(ctx CommandContext) error
}
// Commands returns all valid CLI commands exposed by a enabled plugins, sorted by
// command name.
func (m *Manager) Commands() []Command {
commands := make([]Command, 0)
for _, p := range m.plugins {
hook, ok := p.(CLIHook)
if !ok {
continue
}
for _, cmd := range hook.Commands() {
if strings.TrimSpace(cmd.Name) == "" || cmd.Run == nil {
continue
}
commands = append(commands, cmd)
}
}
sort.Slice(commands, func(i, j int) bool {
return commands[i].Name < commands[j].Name
})
return commands
}
// RunCommand executes a plugin CLI command by name.
func (m *Manager) RunCommand(name string, ctx CommandContext) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("plugin command name cannot be empty")
}
for _, cmd := range m.Commands() {
if cmd.Name == name {
return cmd.Run(ctx)
}
}
return fmt.Errorf("unknown plugin command: %s", name)
}
package plugins
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/sphireinc/foundry/internal/installutil"
"github.com/sphireinc/foundry/internal/safepath"
)
var pluginDownloadClient = &http.Client{Timeout: 30 * time.Second}
const (
pluginCloneTimeout = 2 * time.Minute
pluginZipMaxBytes = 128 << 20
)
type InstallOptions struct {
PluginsDir string
URL string
Name string
ApproveRisk bool
}
// Install clones or downloads a plugin repository into PluginsDir and returns
// its normalized metadata.
//
// Foundry prefers git clone and falls back to a constrained GitHub zip
// download. The install path is validated so plugin names remain filesystem-safe.
func Install(opts InstallOptions) (Metadata, error) {
repoURL, err := validateInstallURL(opts.URL)
if err != nil {
return Metadata{}, err
}
if strings.TrimSpace(repoURL) == "" {
return Metadata{}, fmt.Errorf("plugin URL cannot be empty")
}
pluginsDir := strings.TrimSpace(opts.PluginsDir)
if pluginsDir == "" {
return Metadata{}, fmt.Errorf("plugins directory cannot be empty")
}
name := strings.TrimSpace(opts.Name)
if name == "" {
name, err = inferPluginName(repoURL)
if err != nil {
return Metadata{}, err
}
}
name, err = validatePluginName(name)
if err != nil {
return Metadata{}, err
}
targetDir, err := safepath.ResolveRelativeUnderRoot(pluginsDir, name)
if err != nil {
return Metadata{}, err
}
if _, err := os.Stat(targetDir); err == nil {
return Metadata{}, fmt.Errorf("plugin directory already exists: %s", targetDir)
} else if !os.IsNotExist(err) {
return Metadata{}, err
}
if err := os.MkdirAll(pluginsDir, 0o755); err != nil {
return Metadata{}, fmt.Errorf("create plugins dir: %w", err)
}
cloneCtx, cancel := context.WithTimeout(context.Background(), pluginCloneTimeout)
defer cancel()
cmd := exec.CommandContext(cloneCtx, "git", "clone", repoURL, targetDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("git clone failed because git not available, downloading repository archive instead")
if err := downloadAndExtract(repoURL, targetDir); err != nil {
return Metadata{}, fmt.Errorf("git clone failed and zip fallback failed: %w", err)
}
}
if err := stripVCSMetadata(targetDir); err != nil {
_ = removeInstalledPluginDir(pluginsDir, name)
return Metadata{}, err
}
meta, err := LoadMetadata(pluginsDir, name)
if err != nil {
_ = removeInstalledPluginDir(pluginsDir, name)
return Metadata{}, err
}
if strings.TrimSpace(meta.Name) != "" && meta.Name != name {
_ = removeInstalledPluginDir(pluginsDir, name)
return Metadata{}, fmt.Errorf("plugin metadata name %q does not match install directory %q", meta.Name, name)
}
report := AnalyzeInstalled(meta)
if SecurityApprovalRequired(meta, report) && !opts.ApproveRisk {
_ = removeInstalledPluginDir(pluginsDir, name)
return Metadata{}, fmt.Errorf("plugin %q requires explicit approval due to declared or detected risky capabilities; rerun with approval", meta.Name)
}
return meta, nil
}
// Uninstall removes an installed plugin directory by name.
func Uninstall(pluginsDir, name string) error {
pluginsDir = strings.TrimSpace(pluginsDir)
if pluginsDir == "" {
return fmt.Errorf("plugins directory cannot be empty")
}
var err error
name, err = validatePluginName(name)
if err != nil {
return err
}
targetDir, err := safepath.ResolveRelativeUnderRoot(pluginsDir, name)
if err != nil {
return err
}
info, err := os.Stat(targetDir)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("plugin %q is not installed", name)
}
return err
}
if !info.IsDir() {
return fmt.Errorf("plugin path %q is not a directory", targetDir)
}
if err := removeInstalledPluginDir(pluginsDir, name); err != nil {
return fmt.Errorf("remove plugin directory: %w", err)
}
return nil
}
func removeInstalledPluginDir(pluginsDir, name string) error {
validatedName, err := validatePluginName(name)
if err != nil {
return err
}
// This delete path is constrained to a single validated directory name under
// the configured plugins root. ResolveRelativeUnderRoot rejects absolute
// paths, separators, and traversal, and RemoveRelativeUnderRoot performs the
// final root-bounded removal.
return safepath.RemoveRelativeUnderRoot(strings.TrimSpace(pluginsDir), validatedName)
}
// repoZipURL returns the GitHub archive URL used by the zip fallback path.
func downloadAndExtract(repoURL, targetDir string) error {
targetRoot := filepath.Dir(targetDir)
targetName := filepath.Base(targetDir)
return installutil.DownloadAndExtractRepoArchive(
pluginDownloadClient,
repoURL,
targetRoot,
targetName,
"foundry-plugin",
"plugin",
pluginZipMaxBytes,
)
}
func stripVCSMetadata(targetDir string) error {
return installutil.StripVCSMetadata(filepath.Dir(targetDir), filepath.Base(targetDir))
}
// normalizeInstallURL expands shorthand repository references into cloneable
// URLs where possible.
func normalizeInstallURL(raw string) string {
return installutil.NormalizeGitHubInstallURL(raw)
}
// validateInstallURL constrains plugin install URLs to supported GitHub forms.
func validateInstallURL(raw string) (string, error) {
return installutil.ValidateGitHubInstallURL("plugin", raw, validatePluginName)
}
func validatePluginName(name string) (string, error) {
return safepath.ValidatePathComponent("plugin name", name)
}
func inferPluginName(raw string) (string, error) {
return installutil.InferRepoName(raw, "plugin", validatePluginName)
}
package plugins
import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
type ValidationDiagnostic struct {
Severity string
Path string
Message string
}
type HealthReport struct {
Healthy bool
Status string
Diagnostics []ValidationDiagnostic
Security SecurityReport
}
func rollbackRoot(pluginsDir, name string) string {
return filepath.Join(pluginsDir, ".rollback", name)
}
func listRollbacks(pluginsDir, name string) ([]string, error) {
root := rollbackRoot(pluginsDir, name)
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
out := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
out = append(out, filepath.Join(root, entry.Name()))
}
}
sort.Strings(out)
return out, nil
}
func latestRollback(pluginsDir, name string) (string, bool, error) {
items, err := listRollbacks(pluginsDir, name)
if err != nil || len(items) == 0 {
return "", false, err
}
return items[len(items)-1], true, nil
}
func HasRollback(pluginsDir, name string) (bool, error) {
_, ok, err := latestRollback(pluginsDir, name)
if err != nil {
return false, err
}
return ok, nil
}
func backupInstalled(pluginsDir, name string) (string, error) {
targetDir := filepath.Join(pluginsDir, name)
if _, err := os.Stat(targetDir); err != nil {
return "", err
}
root := rollbackRoot(pluginsDir, name)
if err := os.MkdirAll(root, 0o755); err != nil {
return "", err
}
timestamp := time.Now().UTC().Format("20060102T150405.000000000Z")
backupDir := filepath.Join(root, timestamp)
for attempt := 0; attempt < 5; attempt++ {
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
break
}
backupDir = filepath.Join(root, fmt.Sprintf("%s-%d", timestamp, attempt+1))
}
if err := os.Rename(targetDir, backupDir); err != nil {
return "", err
}
return backupDir, nil
}
func RollbackInstalled(pluginsDir, name string) (Metadata, error) {
backupDir, ok, err := latestRollback(pluginsDir, name)
if err != nil {
return Metadata{}, err
}
if !ok {
return Metadata{}, fmt.Errorf("plugin %q has no rollback snapshot", name)
}
targetDir := filepath.Join(pluginsDir, name)
if _, err := os.Stat(targetDir); err == nil {
if _, err := backupInstalled(pluginsDir, name); err != nil {
return Metadata{}, fmt.Errorf("backup current plugin before rollback: %w", err)
}
} else if !os.IsNotExist(err) {
return Metadata{}, err
}
if err := os.Rename(backupDir, targetDir); err != nil {
return Metadata{}, err
}
return LoadMetadata(pluginsDir, name)
}
func DiagnoseInstalled(pluginsDir string, meta Metadata, enabled bool) HealthReport {
report := HealthReport{Healthy: true, Status: "ok", Diagnostics: make([]ValidationDiagnostic, 0)}
add := func(severity, path, message string) {
report.Diagnostics = append(report.Diagnostics, ValidationDiagnostic{
Severity: severity,
Path: filepath.ToSlash(path),
Message: message,
})
if severity == "error" {
report.Healthy = false
}
}
if err := validateMetadataCompatibility(meta); err != nil {
add("error", filepath.Join(meta.Directory, "plugin.yaml"), err.Error())
}
if _, err := validatePluginForSync(pluginsDir, meta.Name); err != nil {
add("error", meta.Directory, err.Error())
}
if meta.CompatibilityVersion == "" {
add("warn", filepath.Join(meta.Directory, "plugin.yaml"), "compatibility_version is not declared")
}
if len(meta.ConfigSchema) == 0 {
add("warn", filepath.Join(meta.Directory, "plugin.yaml"), "config_schema is empty")
}
if len(meta.Screenshots) == 0 {
add("warn", filepath.Join(meta.Directory, "plugin.yaml"), "screenshots are not declared")
}
report.Security = AnalyzeInstalled(meta)
for _, mismatch := range report.Security.Mismatches {
add("error", mismatch.Path, mismatch.Message)
}
if enabled && !report.Healthy {
report.Status = "degraded"
} else if enabled {
report.Status = "enabled"
} else if report.Healthy {
report.Status = "installed"
} else {
report.Status = "invalid"
}
return report
}
package plugins
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
foundryconfig "github.com/sphireinc/foundry/internal/config"
)
func ListInstalled(pluginsDir string) ([]Metadata, error) {
entries, err := os.ReadDir(pluginsDir)
if err != nil {
if os.IsNotExist(err) {
return []Metadata{}, nil
}
return nil, err
}
out := make([]Metadata, 0)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
meta, err := LoadMetadata(pluginsDir, name)
if err != nil {
return nil, err
}
out = append(out, meta)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Name < out[j].Name
})
return out, nil
}
func ValidateInstalledPlugin(pluginsDir, name string) error {
if _, err := validatePluginForSync(pluginsDir, name); err != nil {
return err
}
meta, err := LoadMetadata(pluginsDir, name)
if err != nil {
return err
}
report := AnalyzeInstalled(meta)
if len(report.Mismatches) > 0 {
return fmt.Errorf("plugin security validation failed with %d mismatch(es)", len(report.Mismatches))
}
return nil
}
func EnableInConfig(configPath, name string) error {
var err error
name, err = validatePluginName(name)
if err != nil {
return err
}
return foundryconfig.EnsureStringListValue(configPath, []string{"plugins", "enabled"}, name)
}
func DisableInConfig(configPath, name string) error {
var err error
name, err = validatePluginName(name)
if err != nil {
return err
}
return foundryconfig.RemoveStringListValue(configPath, []string{"plugins", "enabled"}, name)
}
func UpdateInstalled(pluginsDir, name string, approveRisk bool) (Metadata, error) {
var err error
name, err = validatePluginName(name)
if err != nil {
return Metadata{}, err
}
targetDir := filepath.Join(pluginsDir, name)
if _, err := os.Stat(targetDir); err != nil {
if os.IsNotExist(err) {
return Metadata{}, fmt.Errorf("plugin %q is not installed", name)
}
return Metadata{}, err
}
gitDir := filepath.Join(targetDir, ".git")
if info, err := os.Stat(gitDir); err == nil && info.IsDir() {
cmd := exec.Command("git", "-C", targetDir, "pull", "--ff-only")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return Metadata{}, fmt.Errorf("git pull failed: %w", err)
}
return LoadMetadata(pluginsDir, name)
}
meta, err := LoadMetadata(pluginsDir, name)
if err != nil {
return Metadata{}, err
}
if strings.TrimSpace(meta.Repo) == "" {
return Metadata{}, fmt.Errorf("plugin %q cannot be updated: no .git directory and no repo metadata", name)
}
tmpName := name + "-update-tmp"
tmpDir := filepath.Join(pluginsDir, tmpName)
_ = os.RemoveAll(tmpDir)
installMeta, err := Install(InstallOptions{
PluginsDir: pluginsDir,
URL: meta.Repo,
Name: tmpName,
ApproveRisk: approveRisk,
})
if err != nil {
_ = os.RemoveAll(tmpDir)
return Metadata{}, fmt.Errorf("update fallback install failed: %w", err)
}
if _, err := backupInstalled(pluginsDir, name); err != nil {
_ = os.RemoveAll(tmpDir)
return Metadata{}, fmt.Errorf("backup current plugin: %w", err)
}
if err := os.Rename(filepath.Join(pluginsDir, tmpName), targetDir); err != nil {
if backupDir, ok, latestErr := latestRollback(pluginsDir, name); latestErr == nil && ok {
_ = os.Rename(backupDir, targetDir)
}
return Metadata{}, fmt.Errorf("replace plugin with updated version: %w", err)
}
installMeta.Name = name
installMeta.Directory = filepath.Join(pluginsDir, name)
return installMeta, nil
}
package plugins
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sphireinc/foundry/internal/config"
"gopkg.in/yaml.v3"
)
// Metadata describes a plugin's declarative contract from plugin.yaml.
//
// Foundry uses this metadata for validation, dependency checks, admin UI
// display, and extension discovery. Runtime hooks still come from the plugin's
// implementation.
type Metadata struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
Version string `yaml:"version"`
Description string `yaml:"description"`
Author string `yaml:"author"`
Homepage string `yaml:"homepage"`
License string `yaml:"license"`
Repo string `yaml:"repo"`
Requires []string `yaml:"requires"`
Dependencies []Dependency `yaml:"dependencies,omitempty"`
FoundryAPI string `yaml:"foundry_api"`
MinFoundryVersion string `yaml:"min_foundry_version"`
CompatibilityVersion string `yaml:"compatibility_version,omitempty"`
ConfigSchema []config.FieldDefinition `yaml:"config_schema,omitempty"`
Permissions PermissionSet `yaml:"permissions,omitempty"`
Runtime RuntimeConfig `yaml:"runtime,omitempty"`
AdminExtensions AdminExtensions `yaml:"admin,omitempty"`
Screenshots []string `yaml:"screenshots,omitempty"`
Directory string `yaml:"-"`
}
type PermissionSet struct {
Filesystem FilesystemPermissions `yaml:"filesystem,omitempty" json:"filesystem,omitempty"`
Network NetworkPermissions `yaml:"network,omitempty" json:"network,omitempty"`
Process ProcessPermissions `yaml:"process,omitempty" json:"process,omitempty"`
Environment EnvironmentPermissions `yaml:"environment,omitempty" json:"environment,omitempty"`
Config ConfigPermissions `yaml:"config,omitempty" json:"config,omitempty"`
Content ContentPermissions `yaml:"content,omitempty" json:"content,omitempty"`
Render RenderPermissions `yaml:"render,omitempty" json:"render,omitempty"`
Graph GraphPermissions `yaml:"graph,omitempty" json:"graph,omitempty"`
Admin AdminPermissions `yaml:"admin,omitempty" json:"admin,omitempty"`
Runtime RuntimePermissions `yaml:"runtime,omitempty" json:"runtime,omitempty"`
Secrets SecretPermissions `yaml:"secrets,omitempty" json:"secrets,omitempty"`
Capabilities CapabilityPermissions `yaml:"capabilities,omitempty" json:"capabilities,omitempty"`
}
type FilesystemPermissions struct {
Read FilesystemReadPermissions `yaml:"read,omitempty" json:"read,omitempty"`
Write FilesystemWritePermissions `yaml:"write,omitempty" json:"write,omitempty"`
Delete FilesystemDeletePermissions `yaml:"delete,omitempty" json:"delete,omitempty"`
}
type FilesystemReadPermissions struct {
Content bool `yaml:"content,omitempty" json:"content,omitempty"`
Data bool `yaml:"data,omitempty" json:"data,omitempty"`
Public bool `yaml:"public,omitempty" json:"public,omitempty"`
Themes bool `yaml:"themes,omitempty" json:"themes,omitempty"`
Plugins bool `yaml:"plugins,omitempty" json:"plugins,omitempty"`
Config bool `yaml:"config,omitempty" json:"config,omitempty"`
Custom []string `yaml:"custom,omitempty" json:"custom,omitempty"`
}
type FilesystemWritePermissions struct {
Content bool `yaml:"content,omitempty" json:"content,omitempty"`
Data bool `yaml:"data,omitempty" json:"data,omitempty"`
Public bool `yaml:"public,omitempty" json:"public,omitempty"`
Cache bool `yaml:"cache,omitempty" json:"cache,omitempty"`
Backups bool `yaml:"backups,omitempty" json:"backups,omitempty"`
Custom []string `yaml:"custom,omitempty" json:"custom,omitempty"`
}
type FilesystemDeletePermissions struct {
Content bool `yaml:"content,omitempty" json:"content,omitempty"`
Data bool `yaml:"data,omitempty" json:"data,omitempty"`
Public bool `yaml:"public,omitempty" json:"public,omitempty"`
Cache bool `yaml:"cache,omitempty" json:"cache,omitempty"`
Backups bool `yaml:"backups,omitempty" json:"backups,omitempty"`
Custom []string `yaml:"custom,omitempty" json:"custom,omitempty"`
}
type NetworkPermissions struct {
Outbound NetworkOutboundPermissions `yaml:"outbound,omitempty" json:"outbound,omitempty"`
Inbound NetworkInboundPermissions `yaml:"inbound,omitempty" json:"inbound,omitempty"`
}
type NetworkOutboundPermissions struct {
HTTP bool `yaml:"http,omitempty" json:"http,omitempty"`
HTTPS bool `yaml:"https,omitempty" json:"https,omitempty"`
WebSocket bool `yaml:"websocket,omitempty" json:"websocket,omitempty"`
GRPC bool `yaml:"grpc,omitempty" json:"grpc,omitempty"`
CustomSchemes []string `yaml:"custom_schemes,omitempty" json:"custom_schemes,omitempty"`
Domains []string `yaml:"domains,omitempty" json:"domains,omitempty"`
Methods []string `yaml:"methods,omitempty" json:"methods,omitempty"`
}
type NetworkInboundPermissions struct {
RegisterRoutes bool `yaml:"register_routes,omitempty" json:"register_routes,omitempty"`
AdminRoutes bool `yaml:"admin_routes,omitempty" json:"admin_routes,omitempty"`
PublicRoutes bool `yaml:"public_routes,omitempty" json:"public_routes,omitempty"`
BindExternalServices bool `yaml:"bind_external_services,omitempty" json:"bind_external_services,omitempty"`
}
type ProcessPermissions struct {
Exec ProcessExecPermissions `yaml:"exec,omitempty" json:"exec,omitempty"`
Shell AllowedPermission `yaml:"shell,omitempty" json:"shell,omitempty"`
SpawnBackground AllowedPermission `yaml:"spawn_background,omitempty" json:"spawn_background,omitempty"`
}
type ProcessExecPermissions struct {
Allowed bool `yaml:"allowed,omitempty" json:"allowed,omitempty"`
Commands []string `yaml:"commands,omitempty" json:"commands,omitempty"`
}
type AllowedPermission struct {
Allowed bool `yaml:"allowed,omitempty" json:"allowed,omitempty"`
}
type EnvironmentPermissions struct {
Read EnvironmentReadPermissions `yaml:"read,omitempty" json:"read,omitempty"`
}
type EnvironmentReadPermissions struct {
Allowed bool `yaml:"allowed,omitempty" json:"allowed,omitempty"`
Variables []string `yaml:"variables,omitempty" json:"variables,omitempty"`
}
type ConfigPermissions struct {
Read ConfigReadPermissions `yaml:"read,omitempty" json:"read,omitempty"`
Write ConfigWritePermissions `yaml:"write,omitempty" json:"write,omitempty"`
}
type ConfigReadPermissions struct {
Site bool `yaml:"site,omitempty" json:"site,omitempty"`
PluginConfig bool `yaml:"plugin_config,omitempty" json:"plugin_config,omitempty"`
ThemeManifest bool `yaml:"theme_manifest,omitempty" json:"theme_manifest,omitempty"`
RawFiles bool `yaml:"raw_files,omitempty" json:"raw_files,omitempty"`
}
type ConfigWritePermissions struct {
Site bool `yaml:"site,omitempty" json:"site,omitempty"`
PluginConfig bool `yaml:"plugin_config,omitempty" json:"plugin_config,omitempty"`
ThemeManifest bool `yaml:"theme_manifest,omitempty" json:"theme_manifest,omitempty"`
}
type ContentPermissions struct {
Documents DocumentPermissions `yaml:"documents,omitempty" json:"documents,omitempty"`
Media MediaPermissions `yaml:"media,omitempty" json:"media,omitempty"`
Taxonomies TaxonomyPermissions `yaml:"taxonomies,omitempty" json:"taxonomies,omitempty"`
SharedFields SharedFieldPermissions `yaml:"shared_fields,omitempty" json:"shared_fields,omitempty"`
}
type DocumentPermissions struct {
Read bool `yaml:"read,omitempty" json:"read,omitempty"`
Write bool `yaml:"write,omitempty" json:"write,omitempty"`
Delete bool `yaml:"delete,omitempty" json:"delete,omitempty"`
Workflow bool `yaml:"workflow,omitempty" json:"workflow,omitempty"`
Versions bool `yaml:"versions,omitempty" json:"versions,omitempty"`
}
type MediaPermissions struct {
Read bool `yaml:"read,omitempty" json:"read,omitempty"`
Write bool `yaml:"write,omitempty" json:"write,omitempty"`
Delete bool `yaml:"delete,omitempty" json:"delete,omitempty"`
Metadata bool `yaml:"metadata,omitempty" json:"metadata,omitempty"`
Versions bool `yaml:"versions,omitempty" json:"versions,omitempty"`
}
type TaxonomyPermissions struct {
Read bool `yaml:"read,omitempty" json:"read,omitempty"`
Write bool `yaml:"write,omitempty" json:"write,omitempty"`
}
type SharedFieldPermissions struct {
Read bool `yaml:"read,omitempty" json:"read,omitempty"`
Write bool `yaml:"write,omitempty" json:"write,omitempty"`
}
type RenderPermissions struct {
Context RenderContextPermissions `yaml:"context,omitempty" json:"context,omitempty"`
HTMLSlots RenderHTMLSlotPermissions `yaml:"html_slots,omitempty" json:"html_slots,omitempty"`
Assets RenderAssetPermissions `yaml:"assets,omitempty" json:"assets,omitempty"`
AfterRender RenderAfterRenderPermission `yaml:"after_render,omitempty" json:"after_render,omitempty"`
}
type RenderContextPermissions struct {
Read bool `yaml:"read,omitempty" json:"read,omitempty"`
Write bool `yaml:"write,omitempty" json:"write,omitempty"`
}
type RenderHTMLSlotPermissions struct {
Inject bool `yaml:"inject,omitempty" json:"inject,omitempty"`
}
type RenderAssetPermissions struct {
InjectCSS bool `yaml:"inject_css,omitempty" json:"inject_css,omitempty"`
InjectJS bool `yaml:"inject_js,omitempty" json:"inject_js,omitempty"`
InjectRemoteAssets bool `yaml:"inject_remote_assets,omitempty" json:"inject_remote_assets,omitempty"`
}
type RenderAfterRenderPermission struct {
MutateHTML bool `yaml:"mutate_html,omitempty" json:"mutate_html,omitempty"`
}
type GraphPermissions struct {
Read bool `yaml:"read,omitempty" json:"read,omitempty"`
Mutate bool `yaml:"mutate,omitempty" json:"mutate,omitempty"`
Routes GraphRoutePermissions `yaml:"routes,omitempty" json:"routes,omitempty"`
Taxonomies GraphTaxonomyPermissions `yaml:"taxonomies,omitempty" json:"taxonomies,omitempty"`
}
type GraphRoutePermissions struct {
Inspect bool `yaml:"inspect,omitempty" json:"inspect,omitempty"`
Mutate bool `yaml:"mutate,omitempty" json:"mutate,omitempty"`
}
type GraphTaxonomyPermissions struct {
Inspect bool `yaml:"inspect,omitempty" json:"inspect,omitempty"`
Mutate bool `yaml:"mutate,omitempty" json:"mutate,omitempty"`
}
type AdminPermissions struct {
Extensions AdminExtensionPermissions `yaml:"extensions,omitempty" json:"extensions,omitempty"`
Users AdminUserPermissions `yaml:"users,omitempty" json:"users,omitempty"`
Audit AdminAuditPermissions `yaml:"audit,omitempty" json:"audit,omitempty"`
Diagnostics AdminDiagnosticsPermissions `yaml:"diagnostics,omitempty" json:"diagnostics,omitempty"`
Operations AdminOperationsPermissions `yaml:"operations,omitempty" json:"operations,omitempty"`
}
type AdminExtensionPermissions struct {
Pages bool `yaml:"pages,omitempty" json:"pages,omitempty"`
Widgets bool `yaml:"widgets,omitempty" json:"widgets,omitempty"`
SettingsSections bool `yaml:"settings_sections,omitempty" json:"settings_sections,omitempty"`
Slots bool `yaml:"slots,omitempty" json:"slots,omitempty"`
}
type AdminUserPermissions struct {
Read bool `yaml:"read,omitempty" json:"read,omitempty"`
Write bool `yaml:"write,omitempty" json:"write,omitempty"`
RevokeSessions bool `yaml:"revoke_sessions,omitempty" json:"revoke_sessions,omitempty"`
ResetPasswords bool `yaml:"reset_passwords,omitempty" json:"reset_passwords,omitempty"`
}
type AdminAuditPermissions struct {
Read bool `yaml:"read,omitempty" json:"read,omitempty"`
}
type AdminDiagnosticsPermissions struct {
Read bool `yaml:"read,omitempty" json:"read,omitempty"`
Validate bool `yaml:"validate,omitempty" json:"validate,omitempty"`
}
type AdminOperationsPermissions struct {
Rebuild bool `yaml:"rebuild,omitempty" json:"rebuild,omitempty"`
ClearCache bool `yaml:"clear_cache,omitempty" json:"clear_cache,omitempty"`
Backups bool `yaml:"backups,omitempty" json:"backups,omitempty"`
Updates bool `yaml:"updates,omitempty" json:"updates,omitempty"`
}
type RuntimePermissions struct {
Server RuntimeServerPermissions `yaml:"server,omitempty" json:"server,omitempty"`
Metrics RuntimeBoolPermission `yaml:"metrics,omitempty" json:"metrics,omitempty"`
Logs RuntimeBoolPermission `yaml:"logs,omitempty" json:"logs,omitempty"`
}
type RuntimeServerPermissions struct {
OnStarted bool `yaml:"on_started,omitempty" json:"on_started,omitempty"`
RegisterRoutes bool `yaml:"register_routes,omitempty" json:"register_routes,omitempty"`
}
type RuntimeBoolPermission struct {
Read bool `yaml:"read,omitempty" json:"read,omitempty"`
}
type SecretPermissions struct {
Access SecretAccessPermissions `yaml:"access,omitempty" json:"access,omitempty"`
}
type SecretAccessPermissions struct {
AdminTokens bool `yaml:"admin_tokens,omitempty" json:"admin_tokens,omitempty"`
SessionStore bool `yaml:"session_store,omitempty" json:"session_store,omitempty"`
PasswordHashes bool `yaml:"password_hashes,omitempty" json:"password_hashes,omitempty"`
TOTPSecrets bool `yaml:"totp_secrets,omitempty" json:"totp_secrets,omitempty"`
EnvSecrets bool `yaml:"env_secrets,omitempty" json:"env_secrets,omitempty"`
DeployKeys bool `yaml:"deploy_keys,omitempty" json:"deploy_keys,omitempty"`
UpdateCredentials bool `yaml:"update_credentials,omitempty" json:"update_credentials,omitempty"`
}
type CapabilityPermissions struct {
Dangerous bool `yaml:"dangerous,omitempty" json:"dangerous,omitempty"`
RequiresAdminApproval bool `yaml:"requires_admin_approval,omitempty" json:"requires_admin_approval,omitempty"`
}
type RuntimeConfig struct {
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
ProtocolVersion string `yaml:"protocol_version,omitempty" json:"protocol_version,omitempty"`
Command []string `yaml:"command,omitempty" json:"command,omitempty"`
Socket string `yaml:"socket,omitempty" json:"socket,omitempty"`
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
Sandbox RuntimeSandbox `yaml:"sandbox,omitempty" json:"sandbox,omitempty"`
}
type RuntimeSandbox struct {
Profile string `yaml:"profile,omitempty" json:"profile,omitempty"`
AllowNetwork bool `yaml:"allow_network,omitempty" json:"allow_network,omitempty"`
AllowFilesystemWrite bool `yaml:"allow_filesystem_write,omitempty" json:"allow_filesystem_write,omitempty"`
AllowProcessExec bool `yaml:"allow_process_exec,omitempty" json:"allow_process_exec,omitempty"`
}
// Dependency declares another plugin repo that should be present for this
// plugin to operate correctly.
type Dependency struct {
Name string `yaml:"name"`
Version string `yaml:"version,omitempty"`
Optional bool `yaml:"optional,omitempty"`
}
// AdminExtensions declares the admin-facing surfaces a plugin contributes.
//
// These declarations are consumed by the Admin SDK and admin themes so plugin
// UI can be mounted through stable contracts instead of internal shell details.
type AdminExtensions struct {
Pages []AdminPage `yaml:"pages,omitempty"`
Widgets []AdminWidget `yaml:"widgets,omitempty"`
Slots []AdminSlot `yaml:"slots,omitempty"`
SettingsSections []AdminSettingsSection `yaml:"settings_sections,omitempty"`
}
// AdminPage declares a plugin-provided admin page.
//
// Route is the logical admin route segment, while Module and Styles point to
// plugin-relative assets served by Foundry when the admin shell mounts the
// page. Capability gates whether the current admin user may access the page.
type AdminPage struct {
Key string `yaml:"key"`
Title string `yaml:"title"`
Route string `yaml:"route"`
NavGroup string `yaml:"nav_group,omitempty"`
Capability string `yaml:"capability,omitempty"`
Description string `yaml:"description,omitempty"`
Module string `yaml:"module,omitempty"`
Styles []string `yaml:"styles,omitempty"`
}
// AdminWidget declares a plugin-provided widget for a named admin theme slot.
//
// Slot values must match widget slots supported by the active admin theme.
type AdminWidget struct {
Key string `yaml:"key"`
Title string `yaml:"title"`
Slot string `yaml:"slot"`
Capability string `yaml:"capability,omitempty"`
Description string `yaml:"description,omitempty"`
Module string `yaml:"module,omitempty"`
Styles []string `yaml:"styles,omitempty"`
}
// AdminSlot declares a logical admin slot that the plugin expects themes to
// expose or understand.
type AdminSlot struct {
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
}
// AdminSettingsSection declares plugin-owned settings that Foundry can surface
// in the admin UI and validate against a schema.
type AdminSettingsSection struct {
Key string `yaml:"key"`
Title string `yaml:"title"`
Capability string `yaml:"capability,omitempty"`
Description string `yaml:"description,omitempty"`
Schema []config.FieldDefinition `yaml:"schema,omitempty"`
}
// LoadMetadata reads plugin.yaml for a single plugin directory and applies
// Foundry defaults for omitted fields.
func LoadMetadata(pluginsDir, name string) (Metadata, error) {
var err error
name, err = validatePluginName(name)
if err != nil {
return Metadata{}, err
}
meta := Metadata{
Name: name,
Title: name,
Version: "0.0.0",
Directory: filepath.Join(pluginsDir, name),
Requires: []string{},
}
path := filepath.Join(pluginsDir, name, "plugin.yaml")
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return meta, nil
}
return Metadata{}, fmt.Errorf("read plugin metadata %s: %w", path, err)
}
if err := yaml.Unmarshal(b, &meta); err != nil {
return Metadata{}, fmt.Errorf("parse plugin metadata %s: %w", path, err)
}
meta.Name = strings.TrimSpace(meta.Name)
meta.Title = strings.TrimSpace(meta.Title)
meta.Version = strings.TrimSpace(meta.Version)
meta.Description = strings.TrimSpace(meta.Description)
meta.Author = strings.TrimSpace(meta.Author)
meta.Homepage = strings.TrimSpace(meta.Homepage)
meta.License = strings.TrimSpace(meta.License)
meta.Repo = normalizeRepoRef(meta.Repo)
meta.FoundryAPI = strings.TrimSpace(meta.FoundryAPI)
meta.MinFoundryVersion = strings.TrimSpace(meta.MinFoundryVersion)
meta.CompatibilityVersion = strings.TrimSpace(meta.CompatibilityVersion)
meta.Directory = filepath.Join(pluginsDir, name)
normalizePermissionSet(&meta.Permissions)
normalizeRuntimeConfig(&meta.Runtime)
if meta.Name == "" {
meta.Name = name
}
if meta.Title == "" {
meta.Title = meta.Name
}
if meta.Version == "" {
meta.Version = "0.0.0"
}
reqs := make([]string, 0, len(meta.Requires))
seen := make(map[string]struct{}, len(meta.Requires))
for _, r := range meta.Requires {
r = normalizeRepoRef(r)
if r == "" {
continue
}
if _, ok := seen[r]; ok {
continue
}
seen[r] = struct{}{}
reqs = append(reqs, r)
}
meta.Requires = reqs
for i := range meta.Dependencies {
meta.Dependencies[i].Name = normalizeRepoRef(meta.Dependencies[i].Name)
meta.Dependencies[i].Version = strings.TrimSpace(meta.Dependencies[i].Version)
}
for i := range meta.AdminExtensions.Pages {
meta.AdminExtensions.Pages[i].Key = strings.TrimSpace(meta.AdminExtensions.Pages[i].Key)
meta.AdminExtensions.Pages[i].Title = strings.TrimSpace(meta.AdminExtensions.Pages[i].Title)
meta.AdminExtensions.Pages[i].Route = strings.TrimSpace(meta.AdminExtensions.Pages[i].Route)
meta.AdminExtensions.Pages[i].NavGroup = normalizeAdminNavGroup(meta.AdminExtensions.Pages[i].NavGroup)
meta.AdminExtensions.Pages[i].Capability = strings.TrimSpace(meta.AdminExtensions.Pages[i].Capability)
meta.AdminExtensions.Pages[i].Description = strings.TrimSpace(meta.AdminExtensions.Pages[i].Description)
meta.AdminExtensions.Pages[i].Module = strings.TrimSpace(meta.AdminExtensions.Pages[i].Module)
for j := range meta.AdminExtensions.Pages[i].Styles {
meta.AdminExtensions.Pages[i].Styles[j] = strings.TrimSpace(meta.AdminExtensions.Pages[i].Styles[j])
}
}
if err := validateMetadataCompatibility(meta); err != nil {
return Metadata{}, fmt.Errorf("validate plugin metadata %s: %w", path, err)
}
return meta, nil
}
// LoadAllMetadata loads metadata for the enabled plugin list.
func LoadAllMetadata(pluginsDir string, enabled []string) (map[string]Metadata, error) {
out := make(map[string]Metadata, len(enabled))
for _, name := range enabled {
name = strings.TrimSpace(name)
if name == "" {
continue
}
meta, err := LoadMetadata(pluginsDir, name)
if err != nil {
return nil, err
}
out[name] = meta
}
return out, nil
}
// NormalizeAdminAssetPath validates a plugin-relative admin asset path.
//
// Plugin page and widget bundles are served from inside the plugin directory,
// so this helper rejects absolute paths and traversal segments before those
// paths are published to the admin shell.
func NormalizeAdminAssetPath(rel string) (string, error) {
rel = strings.TrimSpace(rel)
if rel == "" {
return "", fmt.Errorf("admin asset path cannot be empty")
}
clean := filepath.ToSlash(filepath.Clean(rel))
if strings.HasPrefix(clean, "/") || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("admin asset path must stay inside the plugin directory")
}
return clean, nil
}
func normalizeRepoRef(v string) string {
v = strings.TrimSpace(v)
v = strings.TrimPrefix(v, "https://")
v = strings.TrimPrefix(v, "http://")
v = strings.TrimPrefix(v, "git@")
v = strings.TrimPrefix(v, "ssh://")
v = strings.TrimPrefix(v, "github.com:")
v = strings.TrimPrefix(v, "github.com/")
v = strings.Trim(v, "/")
v = strings.TrimSuffix(v, ".git")
if v == "" {
return ""
}
if strings.Count(v, "/") == 1 {
return "github.com/" + v
}
return v
}
func normalizeAdminNavGroup(v string) string {
return strings.ToLower(strings.TrimSpace(v))
}
package plugins
import (
"fmt"
"net/http"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/renderer"
)
// Plugin is the minimum interface every Foundry plugin must implement.
//
// Plugins become active when their name appears in site configuration and the
// package has been registered with Register. Additional behavior is opt-in via
// the hook interfaces in this file.
type Plugin interface {
Name() string
}
// Factory constructs a fresh plugin instance for the current process.
//
// Foundry instantiates plugins during manager creation, so pluginsshould
// avoid global mutable state and return a new value on each call.
type Factory func() Plugin
// ConfigLoadedHook runs after site configuration has been loaded and defaults
// have been applied, but before content is discovered or the graph is built.
//
// Use it to validate plugin-specific configuration or to derive runtime state
// from cfg. Mutations to cfg should be conservative because later build stages
// depend on the normalized config.
type ConfigLoadedHook interface {
OnConfigLoaded(*config.Config) error
}
// ContentDiscoveredHook runs once for each content file path discovered during
// loader traversal, before frontmatter or Markdown parsing.
//
// The path is the filesystem path found by the loader. This hook is best used
// for inventorying files or early validation, not for mutating documents.
type ContentDiscoveredHook interface {
OnContentDiscovered(path string) error
}
// FrontmatterParsedHook runs after a document's frontmatter has been parsed and
// normalized, but before Markdown rendering.
//
// Use it to inspect or adjust structured metadata before the rendered body and
// derived fields are finalized.
type FrontmatterParsedHook interface {
OnFrontmatterParsed(*content.Document) error
}
// MarkdownRenderedHook runs after Markdown has been rendered into HTML for a
// document, but before the loader finishes constructing the final Document.
//
// This is the right place to inspect rendered HTML-dependent metadata, collect
// references, or rewrite rendered content.
type MarkdownRenderedHook interface {
OnMarkdownRendered(*content.Document) error
}
// DocumentParsedHook runs after Foundry has fully parsed a document, including
// frontmatter normalization and Markdown rendering.
//
// At this point the document is close to its final graph-ready form.
type DocumentParsedHook interface {
OnDocumentParsed(*content.Document) error
}
// DataLoadedHook runs after structured data files have been loaded into the
// shared site data map but before graph construction begins.
//
// Implementations may add derived values to the map for later template access.
type DataLoadedHook interface {
OnDataLoaded(map[string]any) error
}
// GraphBuildingHook runs immediately before the loader finalizes the site graph.
//
// It is the last hook that sees the graph while relationships and aggregates
// are still being assembled.
type GraphBuildingHook interface {
OnGraphBuilding(*content.SiteGraph) error
}
// GraphBuiltHook runs after the site graph has been fully assembled, but before
// taxonomy-specific post-processing and route assignment complete.
type GraphBuiltHook interface {
OnGraphBuilt(*content.SiteGraph) error
}
// TaxonomyBuiltHook runs after taxonomy pages and term indexes have been added
// to the site graph.
type TaxonomyBuiltHook interface {
OnTaxonomyBuilt(*content.SiteGraph) error
}
// RoutesAssignedHook runs after the router has assigned final output URLs to
// graph documents.
//
// Use it for work that depends on canonical routes, permalinks, or final slug
// resolution.
type RoutesAssignedHook interface {
OnRoutesAssigned(*content.SiteGraph) error
}
// ContextHook runs during rendering after ViewData has been assembled for a
// page, post, list, or index view and before assets/slots are finalized.
//
// Use it to enrich template context with derived values. Changes made here are
// visible to templates and later rendering hooks.
type ContextHook interface {
OnContext(*renderer.ViewData) error
}
// AssetsHook runs during rendering after ViewData has been built and before
// AssetSet is rendered into HTML slots.
//
// Add CSS or JS assets here when they should participate in the normal theme
// slot flow.
type AssetsHook interface {
OnAssets(*renderer.ViewData, *renderer.AssetSet) error
}
// HTMLSlotsHook runs during rendering after assets have been converted into
// HTML slots but before template execution.
//
// This hook is the best place to inject raw HTML fragments into named theme
// slots declared by the active theme manifest.
type HTMLSlotsHook interface {
OnHTMLSlots(*renderer.ViewData, *renderer.Slots) error
}
// BeforeRenderHook runs immediately before template execution for a single
// output URL.
//
// It sees the final ViewData after context, assets, and slots have been
// prepared.
type BeforeRenderHook interface {
OnBeforeRender(*renderer.ViewData) error
}
// AfterRenderHook runs after template execution for a single output URL.
//
// Hooks receive the rendered HTML bytes and may return a modified copy. Hooks
// are chained in plugin order, so each hook receives the output of the previous
// one.
type AfterRenderHook interface {
OnAfterRender(url string, html []byte) ([]byte, error)
}
// AssetsBuildingHook runs when Foundry is copying or building theme/media asset
// output for a build or serve cycle.
type AssetsBuildingHook interface {
OnAssetsBuilding(*config.Config) error
}
// BuildStartedHook runs once at the beginning of a build or serve graph refresh
// before content loading begins.
type BuildStartedHook interface {
OnBuildStarted() error
}
// BuildCompletedHook runs after a graph has been built successfully.
//
// The provided graph is the final graph used for rendering or serving.
type BuildCompletedHook interface {
OnBuildCompleted(*content.SiteGraph) error
}
// ServerStartedHook runs after the preview server has successfully bound its
// listening address.
type ServerStartedHook interface {
OnServerStarted(addr string) error
}
// RoutesRegisterHook lets a plugin register additional HTTP handlers on the
// preview server mux.
//
// Handlers registered here share the Foundry preview server process, so they
// should avoid conflicting with Foundry-owned paths and should apply their own
// authorization if they expose privileged behavior.
type RoutesRegisterHook interface {
RegisterRoutes(mux *http.ServeMux)
}
// Manager owns the enabled plugin instances for the current process and fans
// out lifecycle hooks to each plugin that implements them.
type Manager struct {
plugins []Plugin
metadata map[string]Metadata
}
// NewManager loads metadata for enabled plugins, validates dependency
// declarations, and instantiates registered plugin factories for the enabled
// list in configuration order.
func NewManager(pluginsDir string, enabled []string) (*Manager, error) {
m := &Manager{
plugins: make([]Plugin, 0),
metadata: make(map[string]Metadata),
}
metadata, err := LoadAllMetadata(pluginsDir, enabled)
if err != nil {
return nil, err
}
m.metadata = metadata
if err := validateDependencies(metadata); err != nil {
return nil, err
}
for _, name := range enabled {
name = strings.TrimSpace(name)
if name == "" {
continue
}
meta, ok := metadata[name]
if ok {
if err := EnsureRuntimeSupported(meta); err != nil {
return nil, err
}
if strings.EqualFold(strings.TrimSpace(meta.Runtime.Mode), "rpc") {
proxy, err := newRPCPluginProxy(meta)
if err != nil {
return nil, err
}
m.plugins = append(m.plugins, proxy)
continue
}
}
if factory, ok := registry[name]; ok {
m.plugins = append(m.plugins, factory())
}
}
return m, nil
}
// Plugins returns a shallow copy of the enabled plugin slice.
func (m *Manager) Plugins() []Plugin {
out := make([]Plugin, len(m.plugins))
copy(out, m.plugins)
return out
}
// Metadata returns a copy of metadata keyed by plugin name for enabled plugins.
func (m *Manager) Metadata() map[string]Metadata {
out := make(map[string]Metadata, len(m.metadata))
for k, v := range m.metadata {
out[k] = v
}
return out
}
// MetadataFor returns metadata for a single enabled plugin.
func (m *Manager) MetadataFor(name string) (Metadata, bool) {
meta, ok := m.metadata[name]
return meta, ok
}
// MetadataList returns enabled plugin metadata sorted by plugin name.
func (m *Manager) MetadataList() []Metadata {
items := make([]Metadata, 0, len(m.metadata))
for _, meta := range m.metadata {
items = append(items, meta)
}
sort.Slice(items, func(i, j int) bool {
return items[i].Name < items[j].Name
})
return items
}
func validateDependencies(metadata map[string]Metadata) error {
installedByRepo := make(map[string]string, len(metadata))
for pluginName, meta := range metadata {
repo := normalizeRepoRef(meta.Repo)
if repo == "" {
continue
}
if existing, ok := installedByRepo[repo]; ok && existing != pluginName {
return fmt.Errorf("duplicate plugin repo %q declared by %q and %q", repo, existing, pluginName)
}
installedByRepo[repo] = pluginName
}
for pluginName, meta := range metadata {
for _, dep := range meta.Requires {
if _, ok := installedByRepo[dep]; !ok {
return fmt.Errorf("plugin %q requires %q, but no enabled plugin declares repo %q", pluginName, dep, dep)
}
}
for _, dep := range meta.Dependencies {
if dep.Name == "" || dep.Optional {
continue
}
if _, ok := installedByRepo[dep.Name]; !ok {
return fmt.Errorf("plugin %q depends on %q, but no enabled plugin declares repo %q", pluginName, dep.Name, dep.Name)
}
}
}
return nil
}
func (m *Manager) OnConfigLoaded(cfg *config.Config) error {
for _, p := range m.plugins {
if hook, ok := p.(ConfigLoadedHook); ok {
if err := hook.OnConfigLoaded(cfg); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnContentDiscovered(path string) error {
for _, p := range m.plugins {
if hook, ok := p.(ContentDiscoveredHook); ok {
if err := hook.OnContentDiscovered(path); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnFrontmatterParsed(doc *content.Document) error {
for _, p := range m.plugins {
if hook, ok := p.(FrontmatterParsedHook); ok {
if err := hook.OnFrontmatterParsed(doc); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnMarkdownRendered(doc *content.Document) error {
for _, p := range m.plugins {
if hook, ok := p.(MarkdownRenderedHook); ok {
if err := hook.OnMarkdownRendered(doc); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnDocumentParsed(doc *content.Document) error {
for _, p := range m.plugins {
if hook, ok := p.(DocumentParsedHook); ok {
if err := hook.OnDocumentParsed(doc); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnDataLoaded(values map[string]any) error {
for _, p := range m.plugins {
if hook, ok := p.(DataLoadedHook); ok {
if err := hook.OnDataLoaded(values); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnGraphBuilding(graph *content.SiteGraph) error {
for _, p := range m.plugins {
if hook, ok := p.(GraphBuildingHook); ok {
if err := hook.OnGraphBuilding(graph); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnGraphBuilt(graph *content.SiteGraph) error {
for _, p := range m.plugins {
if hook, ok := p.(GraphBuiltHook); ok {
if err := hook.OnGraphBuilt(graph); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnTaxonomyBuilt(graph *content.SiteGraph) error {
for _, p := range m.plugins {
if hook, ok := p.(TaxonomyBuiltHook); ok {
if err := hook.OnTaxonomyBuilt(graph); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnRoutesAssigned(graph *content.SiteGraph) error {
for _, p := range m.plugins {
if hook, ok := p.(RoutesAssignedHook); ok {
if err := hook.OnRoutesAssigned(graph); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnContext(ctx *renderer.ViewData) error {
for _, p := range m.plugins {
if hook, ok := p.(ContextHook); ok {
if err := hook.OnContext(ctx); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnAssets(ctx *renderer.ViewData, assetSet *renderer.AssetSet) error {
for _, p := range m.plugins {
if hook, ok := p.(AssetsHook); ok {
if err := hook.OnAssets(ctx, assetSet); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnHTMLSlots(ctx *renderer.ViewData, slots *renderer.Slots) error {
for _, p := range m.plugins {
if hook, ok := p.(HTMLSlotsHook); ok {
if err := hook.OnHTMLSlots(ctx, slots); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnBeforeRender(ctx *renderer.ViewData) error {
for _, p := range m.plugins {
if hook, ok := p.(BeforeRenderHook); ok {
if err := hook.OnBeforeRender(ctx); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnAfterRender(url string, html []byte) ([]byte, error) {
out := html
for _, p := range m.plugins {
if hook, ok := p.(AfterRenderHook); ok {
next, err := hook.OnAfterRender(url, out)
if err != nil {
return nil, err
}
out = next
}
}
return out, nil
}
func (m *Manager) OnAssetsBuilding(cfg *config.Config) error {
for _, p := range m.plugins {
if hook, ok := p.(AssetsBuildingHook); ok {
if err := hook.OnAssetsBuilding(cfg); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnBuildStarted() error {
for _, p := range m.plugins {
if hook, ok := p.(BuildStartedHook); ok {
if err := hook.OnBuildStarted(); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnBuildCompleted(graph *content.SiteGraph) error {
for _, p := range m.plugins {
if hook, ok := p.(BuildCompletedHook); ok {
if err := hook.OnBuildCompleted(graph); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) OnServerStarted(addr string) error {
for _, p := range m.plugins {
if hook, ok := p.(ServerStartedHook); ok {
if err := hook.OnServerStarted(addr); err != nil {
return err
}
}
}
return nil
}
// RegisterRoutes asks each plugin that implements RoutesRegisterHook to attach
// its HTTP handlers to mux.
func (m *Manager) RegisterRoutes(mux *http.ServeMux) {
for _, p := range m.plugins {
if hook, ok := p.(RoutesRegisterHook); ok {
hook.RegisterRoutes(mux)
}
}
}
package plugins
import (
"fmt"
"strings"
)
type Project struct {
ConfigPath string
PluginsDir string
OutputPath string
ModulePath string
}
type MissingDependency struct {
Repo string
Installed bool
Name string
}
type DependencyStatus struct {
Repo string
Status string
Name string
}
func NewProject(configPath, pluginsDir, outputPath, modulePath string) Project {
return Project{
ConfigPath: strings.TrimSpace(configPath),
PluginsDir: strings.TrimSpace(pluginsDir),
OutputPath: strings.TrimSpace(outputPath),
ModulePath: strings.TrimSpace(modulePath),
}
}
func (p Project) Sync() error {
return SyncFromConfig(SyncOptions{
ConfigPath: p.ConfigPath,
PluginsDir: p.PluginsDir,
OutputPath: p.OutputPath,
ModulePath: p.ModulePath,
})
}
func (p Project) Install(url, name string, approveRisk bool) (Metadata, error) {
return Install(InstallOptions{
PluginsDir: p.PluginsDir,
URL: strings.TrimSpace(url),
Name: strings.TrimSpace(name),
ApproveRisk: approveRisk,
})
}
func (p Project) Uninstall(name string) error {
return Uninstall(p.PluginsDir, name)
}
func (p Project) Enable(name string, approveRisk bool) error {
meta, err := LoadMetadata(p.PluginsDir, name)
if err != nil {
return err
}
if err := EnsureRuntimeSupported(meta); err != nil {
return err
}
if SecurityApprovalRequired(meta, AnalyzeInstalled(meta)) && !approveRisk {
return fmt.Errorf("plugin %q requires explicit approval due to declared or detected risky capabilities", name)
}
return EnableInConfig(p.ConfigPath, name)
}
func (p Project) Disable(name string) error {
return DisableInConfig(p.ConfigPath, name)
}
func (p Project) Update(name string, approveRisk bool) (Metadata, error) {
return UpdateInstalled(p.PluginsDir, name, approveRisk)
}
func (p Project) Validate(name string) error {
return ValidateInstalledPlugin(p.PluginsDir, name)
}
func (p Project) ValidateEnabled(enabled []string) PluginValidationReport {
return ValidateEnabledPlugins(p.PluginsDir, enabled)
}
func (p Project) EnabledStatuses(enabled []string) map[string]string {
return EnabledPluginStatus(p.PluginsDir, enabled)
}
func (p Project) ListInstalled() ([]Metadata, error) {
return ListInstalled(p.PluginsDir)
}
func (p Project) Metadata(name string) (Metadata, error) {
return LoadMetadata(p.PluginsDir, name)
}
func (p Project) SecurityReport(name string) (SecurityReport, error) {
meta, err := p.Metadata(name)
if err != nil {
return SecurityReport{}, err
}
return AnalyzeInstalled(meta), nil
}
func (p Project) MissingDependencies(installed Metadata, enabled []string) ([]MissingDependency, error) {
if len(installed.Requires) == 0 {
return nil, nil
}
enabledMetadata, err := LoadAllMetadata(p.PluginsDir, enabled)
if err != nil {
return nil, err
}
enabledRepos := make(map[string]string, len(enabledMetadata))
for name, meta := range enabledMetadata {
repo := strings.TrimSpace(meta.Repo)
if repo == "" {
continue
}
enabledRepos[repo] = name
}
installedOnDisk, err := p.scanInstalledPluginRepos()
if err != nil {
return nil, err
}
missing := make([]MissingDependency, 0)
seen := make(map[string]struct{})
for _, dep := range installed.Requires {
dep = strings.TrimSpace(dep)
if dep == "" {
continue
}
if _, ok := enabledRepos[dep]; ok {
continue
}
if _, dup := seen[dep]; dup {
continue
}
seen[dep] = struct{}{}
md := MissingDependency{Repo: dep}
if name, ok := installedOnDisk[dep]; ok {
md.Installed = true
md.Name = name
}
missing = append(missing, md)
}
return missing, nil
}
func (p Project) DependencyStatuses(name string, enabled []string) ([]DependencyStatus, error) {
meta, err := LoadMetadata(p.PluginsDir, name)
if err != nil {
return nil, err
}
if len(meta.Requires) == 0 {
return nil, nil
}
enabledMetadata, err := LoadAllMetadata(p.PluginsDir, enabled)
if err != nil {
return nil, err
}
enabledRepos := make(map[string]string, len(enabledMetadata))
for pluginName, m := range enabledMetadata {
if repo := strings.TrimSpace(m.Repo); repo != "" {
enabledRepos[repo] = pluginName
}
}
installed, err := p.ListInstalled()
if err != nil {
return nil, err
}
installedRepos := make(map[string]string, len(installed))
for _, m := range installed {
if repo := strings.TrimSpace(m.Repo); repo != "" {
installedRepos[repo] = m.Name
}
}
out := make([]DependencyStatus, 0, len(meta.Requires))
for _, dep := range meta.Requires {
dep = strings.TrimSpace(dep)
if dep == "" {
continue
}
status := DependencyStatus{Repo: dep}
switch {
case enabledRepos[dep] != "":
status.Status = "enabled"
status.Name = enabledRepos[dep]
case installedRepos[dep] != "":
status.Status = "installed"
status.Name = installedRepos[dep]
default:
status.Status = "missing"
}
out = append(out, status)
}
return out, nil
}
func (p Project) scanInstalledPluginRepos() (map[string]string, error) {
metas, err := p.ListInstalled()
if err != nil {
return nil, err
}
out := make(map[string]string)
for _, meta := range metas {
repo := strings.TrimSpace(meta.Repo)
if repo == "" {
continue
}
out[repo] = meta.Name
}
return out, nil
}
package plugins
var registry = map[string]Factory{}
// Register makes a plugin factory available to Foundry by name.
//
// Registration is expected to happen from package init functions in plugin
// packages. Register panics for empty names, nil factories, or duplicate names
// because those are programmer errors that would make plugin loading
// ambiguous.
func Register(name string, factory Factory) {
// TODO stop panicking and error gracefully
if name == "" || factory == nil {
panic("plugins: invalid registration")
}
if _, exists := registry[name]; exists {
panic("plugins: duplicate registration for " + name)
}
registry[name] = factory
}
package plugins
import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"github.com/sphireinc/foundry/internal/renderer"
"github.com/sphireinc/foundry/sdk/pluginrpc"
)
// RuntimeHost describes the execution host for a plugin runtime mode.
type RuntimeHost interface {
Name() string
Supports(meta Metadata) bool
}
type InProcessHost struct{}
func (InProcessHost) Name() string { return "in_process" }
func (InProcessHost) Supports(meta Metadata) bool {
return stringsEqualFoldEmpty(strings.TrimSpace(meta.Runtime.Mode), "in_process")
}
type RPCHost struct{}
func (RPCHost) Name() string { return "rpc" }
func (RPCHost) Supports(meta Metadata) bool {
return stringsEqualFoldEmpty(strings.TrimSpace(meta.Runtime.Mode), "rpc")
}
func ResolveRuntimeHost(meta Metadata) RuntimeHost {
if stringsEqualFoldEmpty(strings.TrimSpace(meta.Runtime.Mode), "rpc") {
return RPCHost{}
}
return InProcessHost{}
}
func EnsureRuntimeSupported(meta Metadata) error {
mode := strings.ToLower(strings.TrimSpace(meta.Runtime.Mode))
if mode == "" || mode == "in_process" {
return nil
}
if mode != "rpc" {
return fmt.Errorf("plugin %q declares unsupported runtime.mode=%q", meta.Name, meta.Runtime.Mode)
}
if len(meta.Runtime.Command) == 0 {
return fmt.Errorf("plugin %q declares runtime.mode=rpc but runtime.command is empty", meta.Name)
}
if strings.TrimSpace(meta.Runtime.ProtocolVersion) == "" {
return fmt.Errorf("plugin %q declares runtime.mode=rpc but runtime.protocol_version is empty", meta.Name)
}
if meta.Runtime.Sandbox.AllowNetwork {
return fmt.Errorf("plugin %q declares runtime.mode=rpc with sandbox.allow_network=true, which is not supported by the current RPC host", meta.Name)
}
if meta.Runtime.Sandbox.AllowFilesystemWrite {
return fmt.Errorf("plugin %q declares runtime.mode=rpc with sandbox.allow_filesystem_write=true, which is not supported by the current RPC host", meta.Name)
}
if meta.Runtime.Sandbox.AllowProcessExec {
return fmt.Errorf("plugin %q declares runtime.mode=rpc with sandbox.allow_process_exec=true, which is not supported by the current RPC host", meta.Name)
}
return nil
}
func stringsEqualFoldEmpty(v, want string) bool {
if v == "" {
v = "in_process"
}
return strings.EqualFold(v, want)
}
type rpcPluginProxy struct {
meta Metadata
client *rpcPluginClient
}
func newRPCPluginProxy(meta Metadata) (*rpcPluginProxy, error) {
if err := EnsureRuntimeSupported(meta); err != nil {
return nil, err
}
return &rpcPluginProxy{
meta: meta,
client: &rpcPluginClient{meta: meta},
}, nil
}
func (p *rpcPluginProxy) Name() string { return p.meta.Name }
func (p *rpcPluginProxy) OnContext(ctx *renderer.ViewData) error {
if ctx == nil {
return nil
}
resp, err := p.client.Context(toRPCContextRequest(ctx))
if err != nil {
return err
}
if len(resp.Data) == 0 {
return nil
}
if ctx.Data == nil {
ctx.Data = map[string]any{}
}
for key, value := range resp.Data {
ctx.Data[key] = value
}
return nil
}
type rpcPluginClient struct {
meta Metadata
mu sync.Mutex
cmd *exec.Cmd
stdin *bufio.Writer
encoder *json.Encoder
decoder *json.Decoder
nextID int
handshake *pluginrpc.HandshakeResponse
}
func (c *rpcPluginClient) Context(req pluginrpc.ContextRequest) (pluginrpc.ContextResponse, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStartedLocked(); err != nil {
return pluginrpc.ContextResponse{}, err
}
if c.handshake == nil || !hookSupported(c.handshake.SupportedHooks, pluginrpc.MethodContext) {
return pluginrpc.ContextResponse{}, nil
}
var resp pluginrpc.ContextResponse
if err := c.callLocked(pluginrpc.MethodContext, req, &resp); err != nil {
return pluginrpc.ContextResponse{}, err
}
return resp, nil
}
func (c *rpcPluginClient) ensureStartedLocked() error {
if c.cmd != nil {
return nil
}
cmd := exec.Command(c.meta.Runtime.Command[0], c.meta.Runtime.Command[1:]...)
cmd.Dir = c.meta.Directory
cmd.Env = c.rpcEnv()
stdinPipe, err := cmd.StdinPipe()
if err != nil {
return err
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return err
}
c.cmd = cmd
c.stdin = bufio.NewWriter(stdinPipe)
c.encoder = json.NewEncoder(c.stdin)
c.decoder = json.NewDecoder(bufio.NewReader(stdoutPipe))
var handshake pluginrpc.HandshakeResponse
if err := c.callLocked(pluginrpc.MethodHandshake, pluginrpc.HandshakeRequest{
PluginName: c.meta.Name,
ProtocolVersion: c.meta.Runtime.ProtocolVersion,
RequestedHooks: []string{pluginrpc.MethodContext},
SandboxProfile: c.meta.Runtime.Sandbox.Profile,
AllowNetwork: c.meta.Runtime.Sandbox.AllowNetwork,
AllowFSWrite: c.meta.Runtime.Sandbox.AllowFilesystemWrite,
AllowProcessExec: c.meta.Runtime.Sandbox.AllowProcessExec,
}, &handshake); err != nil {
_ = cmd.Process.Kill()
return err
}
c.handshake = &handshake
return nil
}
func (c *rpcPluginClient) callLocked(method string, params any, out any) error {
c.nextID++
body, err := json.Marshal(params)
if err != nil {
return err
}
if err := c.encoder.Encode(pluginrpc.Request{
ID: c.nextID,
Method: method,
Params: body,
}); err != nil {
return err
}
if err := c.stdin.Flush(); err != nil {
return err
}
var resp pluginrpc.Response
if err := c.decoder.Decode(&resp); err != nil {
return err
}
if resp.Error != "" {
return fmt.Errorf("rpc plugin %q %s failed: %s", c.meta.Name, method, resp.Error)
}
if out != nil && len(resp.Result) > 0 {
if err := json.Unmarshal(resp.Result, out); err != nil {
return err
}
}
return nil
}
func (c *rpcPluginClient) rpcEnv() []string {
path := ""
home := ""
goCache := ""
tmpDir := ""
for _, item := range os.Environ() {
if strings.HasPrefix(item, "PATH=") {
path = item
}
if strings.HasPrefix(item, "HOME=") {
home = item
}
if strings.HasPrefix(item, "GOCACHE=") {
goCache = item
}
if strings.HasPrefix(item, "TMPDIR=") {
tmpDir = item
}
}
env := []string{}
if path != "" {
env = append(env, path)
}
if home != "" {
env = append(env, home)
}
if goCache != "" {
env = append(env, goCache)
}
if tmpDir != "" {
env = append(env, tmpDir)
}
env = append(env,
"FOUNDRY_PLUGIN_NAME="+c.meta.Name,
"FOUNDRY_PLUGIN_DIR="+c.meta.Directory,
"FOUNDRY_PLUGIN_PROTOCOL="+c.meta.Runtime.ProtocolVersion,
)
for key, value := range c.meta.Runtime.Env {
env = append(env, key+"="+value)
}
return env
}
func hookSupported(items []string, want string) bool {
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(item), want) {
return true
}
}
return false
}
func toRPCContextRequest(view *renderer.ViewData) pluginrpc.ContextRequest {
req := pluginrpc.ContextRequest{
Data: map[string]any{},
Lang: view.Lang,
Title: view.Title,
RequestPath: view.RequestPath,
}
for key, value := range view.Data {
req.Data[key] = value
}
if view.Page != nil {
req.Page = &pluginrpc.PagePayload{
ID: view.Page.ID,
Type: view.Page.Type,
Lang: view.Page.Lang,
Status: view.Page.Status,
Title: view.Page.Title,
Slug: view.Page.Slug,
URL: view.Page.URL,
Layout: view.Page.Layout,
Summary: view.Page.Summary,
Draft: view.Page.Draft,
RawBody: view.Page.RawBody,
HTMLBody: string(view.Page.HTMLBody),
Params: cloneMap(view.Page.Params),
Fields: cloneMap(view.Page.Fields),
Taxonomies: cloneTaxonomies(view.Page.Taxonomies),
}
}
return req
}
func cloneMap(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneTaxonomies(in map[string][]string) map[string][]string {
if len(in) == 0 {
return nil
}
out := make(map[string][]string, len(in))
for key, values := range in {
out[key] = append([]string(nil), values...)
}
return out
}
var (
_ Plugin = (*rpcPluginProxy)(nil)
_ ContextHook = (*rpcPluginProxy)(nil)
)
package plugins
import (
"sort"
"strings"
)
func normalizePermissionSet(p *PermissionSet) {
if p == nil {
return
}
p.Filesystem.Read.Custom = normalizeStringList(p.Filesystem.Read.Custom)
p.Filesystem.Write.Custom = normalizeStringList(p.Filesystem.Write.Custom)
p.Filesystem.Delete.Custom = normalizeStringList(p.Filesystem.Delete.Custom)
p.Network.Outbound.CustomSchemes = normalizeStringList(p.Network.Outbound.CustomSchemes)
p.Network.Outbound.Domains = normalizeStringList(p.Network.Outbound.Domains)
p.Network.Outbound.Methods = normalizeHTTPMethodList(p.Network.Outbound.Methods)
if len(p.Network.Outbound.Methods) == 0 {
p.Network.Outbound.Methods = []string{"GET"}
}
p.Process.Exec.Commands = normalizeStringList(p.Process.Exec.Commands)
p.Environment.Read.Variables = normalizeStringList(p.Environment.Read.Variables)
}
func normalizeRuntimeConfig(r *RuntimeConfig) {
if r == nil {
return
}
r.Mode = strings.ToLower(strings.TrimSpace(r.Mode))
if r.Mode == "" {
r.Mode = "in_process"
}
r.ProtocolVersion = strings.TrimSpace(r.ProtocolVersion)
if r.ProtocolVersion == "" {
r.ProtocolVersion = "v1alpha1"
}
r.Command = normalizeOrderedStringList(r.Command)
r.Socket = strings.TrimSpace(r.Socket)
if r.Env == nil {
r.Env = map[string]string{}
}
cleanEnv := make(map[string]string, len(r.Env))
for key, value := range r.Env {
key = strings.TrimSpace(key)
if key == "" {
continue
}
cleanEnv[key] = strings.TrimSpace(value)
}
r.Env = cleanEnv
r.Sandbox.Profile = strings.ToLower(strings.TrimSpace(r.Sandbox.Profile))
if r.Sandbox.Profile == "" {
r.Sandbox.Profile = "default"
}
}
func normalizeStringList(items []string) []string {
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
sort.Strings(out)
return out
}
func normalizeOrderedStringList(items []string) []string {
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
return out
}
func normalizeHTTPMethodList(items []string) []string {
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
item = strings.ToUpper(strings.TrimSpace(item))
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
sort.Strings(out)
return out
}
func (p PermissionSet) RiskTier() string {
if p.Capabilities.Dangerous ||
p.Capabilities.RequiresAdminApproval ||
p.Process.Exec.Allowed ||
p.Process.Shell.Allowed ||
p.Process.SpawnBackground.Allowed ||
p.Secrets.Access.AdminTokens ||
p.Secrets.Access.SessionStore ||
p.Secrets.Access.PasswordHashes ||
p.Secrets.Access.TOTPSecrets ||
p.Secrets.Access.EnvSecrets ||
p.Secrets.Access.DeployKeys ||
p.Secrets.Access.UpdateCredentials ||
p.Admin.Operations.Updates ||
p.Admin.Users.Write ||
p.Admin.Users.ResetPasswords ||
p.Admin.Users.RevokeSessions {
return "high"
}
if p.Network.Outbound.HTTP || p.Network.Outbound.HTTPS || p.Network.Outbound.WebSocket || p.Network.Outbound.GRPC ||
p.Network.Inbound.RegisterRoutes || p.Network.Inbound.AdminRoutes || p.Network.Inbound.PublicRoutes ||
p.Filesystem.Write.Content || p.Filesystem.Write.Data || p.Filesystem.Write.Public || p.Filesystem.Write.Cache || p.Filesystem.Write.Backups ||
p.Filesystem.Delete.Content || p.Filesystem.Delete.Data || p.Filesystem.Delete.Public || p.Filesystem.Delete.Cache || p.Filesystem.Delete.Backups ||
p.Content.Documents.Write || p.Content.Documents.Delete || p.Content.Documents.Workflow ||
p.Content.Media.Write || p.Content.Media.Delete || p.Content.Media.Metadata ||
p.Config.Write.Site || p.Config.Write.PluginConfig || p.Config.Write.ThemeManifest ||
p.Admin.Operations.Backups || p.Admin.Operations.Rebuild || p.Admin.Operations.ClearCache {
return "medium"
}
return "low"
}
func (p PermissionSet) Summary() []string {
out := []string{}
if p.Content.Documents.Read {
out = append(out, "Reads documents")
}
if p.Content.Media.Read {
out = append(out, "Reads media")
}
if p.Render.Context.Write {
out = append(out, "Mutates render context")
}
if p.Render.HTMLSlots.Inject {
out = append(out, "Injects HTML slots")
}
if p.Render.Assets.InjectCSS || p.Render.Assets.InjectJS || p.Render.Assets.InjectRemoteAssets {
out = append(out, "Injects assets")
}
if p.Graph.Read || p.Graph.Routes.Inspect || p.Graph.Taxonomies.Inspect {
out = append(out, "Inspects site graph")
}
if p.Network.Outbound.HTTP || p.Network.Outbound.HTTPS || p.Network.Outbound.WebSocket || p.Network.Outbound.GRPC {
out = append(out, "Makes outbound network requests")
}
if p.Network.Inbound.RegisterRoutes || p.Network.Inbound.AdminRoutes || p.Network.Inbound.PublicRoutes {
out = append(out, "Registers routes")
}
if p.Process.Exec.Allowed || p.Process.Shell.Allowed || p.Process.SpawnBackground.Allowed {
out = append(out, "Runs local processes")
}
if p.Capabilities.RequiresAdminApproval {
out = append(out, "Requires admin approval")
}
if len(out) == 0 {
out = append(out, "No elevated permissions declared")
}
return out
}
func (r RuntimeConfig) Summary() []string {
out := []string{"Runtime: " + RuntimeModeLabel(r.Mode)}
if r.Mode == "rpc" {
if len(r.Command) > 0 {
out = append(out, "RPC command configured")
}
if r.Socket != "" {
out = append(out, "RPC socket declared")
}
out = append(out, "Sandbox profile: "+strings.TrimSpace(r.Sandbox.Profile))
}
return out
}
func RuntimeModeLabel(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "rpc":
return "out-of-process RPC"
default:
return "in-process"
}
}
package plugins
import (
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/consts"
"gopkg.in/yaml.v3"
)
const (
DefaultSyncConfigPath = consts.ConfigFilePath
DefaultSyncPluginsDir = "plugins"
DefaultSyncOutputPath = "internal/generated/plugins_gen.go"
DefaultSyncModulePath = "github.com/sphireinc/foundry"
)
type syncSiteConfig struct {
Plugins struct {
Enabled []string `yaml:"enabled"`
} `yaml:"plugins"`
}
type SyncOptions struct {
ConfigPath string
PluginsDir string
OutputPath string
ModulePath string
}
func SyncFromConfig(opts SyncOptions) error {
opts = normalizeSyncOptions(opts)
cfg, err := loadSyncConfig(opts.ConfigPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
return SyncEnabledPlugins(opts, cfg.Plugins.Enabled)
}
func SyncEnabledPlugins(opts SyncOptions, enabled []string) error {
opts = normalizeSyncOptions(opts)
enabled = uniqueSorted(enabled)
inProcess := make([]string, 0, len(enabled))
for _, name := range enabled {
meta, err := validatePluginForSync(opts.PluginsDir, name)
if err != nil {
return fmt.Errorf("validate plugin %s: %w", name, err)
}
if !strings.EqualFold(strings.TrimSpace(meta.Runtime.Mode), "rpc") {
inProcess = append(inProcess, name)
}
}
if err := os.MkdirAll(filepath.Dir(opts.OutputPath), 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
content := generateImportsFile(opts.ModulePath, inProcess)
existing, err := os.ReadFile(opts.OutputPath)
if err == nil && bytes.Equal(existing, []byte(content)) {
return nil
}
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read existing generated imports: %w", err)
}
if err := os.WriteFile(opts.OutputPath, []byte(content), 0o644); err != nil {
return fmt.Errorf("write generated imports: %w", err)
}
return nil
}
func normalizeSyncOptions(opts SyncOptions) SyncOptions {
if strings.TrimSpace(opts.ConfigPath) == "" {
opts.ConfigPath = DefaultSyncConfigPath
}
if strings.TrimSpace(opts.PluginsDir) == "" {
opts.PluginsDir = DefaultSyncPluginsDir
}
if strings.TrimSpace(opts.OutputPath) == "" {
opts.OutputPath = DefaultSyncOutputPath
}
if strings.TrimSpace(opts.ModulePath) == "" {
opts.ModulePath = DefaultSyncModulePath
}
return opts
}
func loadSyncConfig(path string) (*syncSiteConfig, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg syncSiteConfig
if err := yaml.Unmarshal(b, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func validatePluginForSync(pluginsDir, name string) (*Metadata, error) {
var err error
name, err = validatePluginName(name)
if err != nil {
return nil, err
}
root := filepath.Join(pluginsDir, name)
info, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("plugin %q is enabled but directory %q does not exist", name, root)
}
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("plugin path %q is not a directory", root)
}
meta, err := LoadMetadata(pluginsDir, name)
if err != nil {
return nil, err
}
if strings.EqualFold(strings.TrimSpace(meta.Runtime.Mode), "rpc") {
return &meta, EnsureRuntimeSupported(meta)
}
foundGo := false
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if filepath.Ext(path) == ".go" {
foundGo = true
}
return nil
})
if err != nil {
return nil, err
}
if !foundGo {
return nil, fmt.Errorf("plugin %q has no .go files under %q", name, root)
}
return &meta, nil
}
func uniqueSorted(in []string) []string {
set := make(map[string]struct{}, len(in))
for _, v := range in {
v = strings.TrimSpace(v)
if v == "" {
continue
}
set[v] = struct{}{}
}
out := make([]string, 0, len(set))
for v := range set {
out = append(out, v)
}
sort.Strings(out)
return out
}
func generateImportsFile(modulePath string, enabled []string) string {
var buf bytes.Buffer
buf.WriteString("// Code generated by plugin sync; DO NOT EDIT.\n")
buf.WriteString("package generated\n\n")
if len(enabled) == 0 {
return buf.String()
}
buf.WriteString("import (\n")
for _, name := range enabled {
_, _ = fmt.Fprintf(&buf, "\t_ %q\n", strings.TrimRight(modulePath, "/")+"/plugins/"+name)
}
buf.WriteString(")\n")
return buf.String()
}
func isValidRepoRef(v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
parts := strings.Split(v, "/")
return len(parts) >= 3 && parts[0] != "" && parts[1] != "" && parts[2] != ""
}
package plugins
import (
"fmt"
"sort"
"strings"
)
type ValidationIssue struct {
Name string
Status string
Err error
}
func (v ValidationIssue) String() string {
if v.Err == nil {
return fmt.Sprintf("%s: %s", v.Name, v.Status)
}
return fmt.Sprintf("%s: %s: %v", v.Name, v.Status, v.Err)
}
type PluginValidationReport struct {
Passed []string
Issues []ValidationIssue
}
func ValidateEnabledPlugins(pluginsDir string, enabled []string) PluginValidationReport {
report := PluginValidationReport{
Passed: make([]string, 0),
Issues: make([]ValidationIssue, 0),
}
normalized := make([]string, 0, len(enabled))
for _, name := range enabled {
name = strings.TrimSpace(name)
if name == "" {
continue
}
normalized = append(normalized, name)
}
sort.Strings(normalized)
metadata, err := LoadAllMetadata(pluginsDir, normalized)
if err != nil {
report.Issues = append(report.Issues, ValidationIssue{
Name: "*",
Status: "metadata load failed",
Err: err,
})
return report
}
for _, name := range normalized {
if err := ValidateInstalledPlugin(pluginsDir, name); err != nil {
report.Issues = append(report.Issues, ValidationIssue{
Name: name,
Status: "invalid",
Err: err,
})
continue
}
report.Passed = append(report.Passed, name)
}
if err := validateDependencies(metadata); err != nil {
report.Issues = append(report.Issues, ValidationIssue{
Name: "*",
Status: "dependency validation failed",
Err: err,
})
}
return report
}
func EnabledPluginStatus(pluginsDir string, enabled []string) map[string]string {
statuses := make(map[string]string, len(enabled))
for _, name := range enabled {
name = strings.TrimSpace(name)
if name == "" {
continue
}
statuses[name] = enabledPluginStatus(pluginsDir, name)
}
return statuses
}
func enabledPluginStatus(pluginsDir, name string) string {
if strings.TrimSpace(name) == "" {
return "invalid name"
}
meta, err := LoadMetadata(pluginsDir, name)
if err != nil {
msg := err.Error()
switch {
case strings.Contains(msg, "read ") && strings.Contains(msg, "plugin.yaml"):
return "metadata missing"
case strings.Contains(msg, "missing required field \"foundry_api\""):
return "api missing"
case strings.Contains(msg, "unsupported foundry_api"):
return "api unsupported"
case strings.Contains(msg, "missing required field \"min_foundry_version\""):
return "version missing"
default:
return "metadata error"
}
}
if err := ValidateInstalledPlugin(pluginsDir, name); err != nil {
msg := err.Error()
switch {
case strings.Contains(msg, "does not exist"):
return "not installed"
case strings.Contains(msg, "metadata name"):
return "metadata invalid"
case strings.Contains(msg, "invalid repo"):
return "metadata invalid"
case strings.Contains(msg, "invalid requires"):
return "metadata invalid"
case strings.Contains(msg, "has no .go files"):
return "code missing"
case strings.Contains(msg, "security validation failed"):
return "security mismatch"
default:
return "invalid"
}
}
if strings.TrimSpace(meta.Repo) == "" {
return "enabled"
}
return "enabled"
}
package renderer
import (
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/sphireinc/foundry/internal/assets"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/platformapi"
"github.com/sphireinc/foundry/internal/theme"
)
var stripHTMLTagsRE = regexp.MustCompile(`<[^>]+>`)
// Hooks lets plugins participate in the render pipeline.
//
// The hook order for a single page render is:
// 1. OnContext
// 2. OnAssets
// 3. OnHTMLSlots
// 4. OnBeforeRender
// 5. >> template execution
// 6. OnAfterRender
//
// Implementations should keep work fast and deterministic because these hooks
// run for every rendered output.
type Hooks interface {
OnContext(*ViewData) error
OnAssets(*ViewData, *AssetSet) error
OnBeforeRender(*ViewData) error
OnAfterRender(url string, html []byte) ([]byte, error)
OnAssetsBuilding(*config.Config) error
OnHTMLSlots(*ViewData, *Slots) error
}
type noopHooks struct{}
func (noopHooks) OnContext(*ViewData) error { return nil }
func (noopHooks) OnAssets(*ViewData, *AssetSet) error { return nil }
func (noopHooks) OnBeforeRender(*ViewData) error { return nil }
func (noopHooks) OnAfterRender(_ string, html []byte) ([]byte, error) { return html, nil }
func (noopHooks) OnAssetsBuilding(*config.Config) error { return nil }
func (noopHooks) OnHTMLSlots(*ViewData, *Slots) error { return nil }
// Renderer turns the site graph into HTML output using the active frontend
// theme and the provided render hooks.
type Renderer struct {
cfg *config.Config
themes *theme.Manager
hooks Hooks
}
// BuildStats records coarse timing breakdowns for a render/build pass.
type BuildStats struct {
Prepare time.Duration
Assets time.Duration
Documents time.Duration
Taxonomies time.Duration
Search time.Duration
}
// New constructs a renderer for the active theme.
func New(cfg *config.Config, themes *theme.Manager, hooks Hooks) *Renderer {
if hooks == nil {
hooks = noopHooks{}
}
return &Renderer{
cfg: cfg,
themes: themes,
hooks: hooks,
}
}
// NavItem is a normalized menu item exposed to templates.
type NavItem struct {
Name string
URL string
Active bool
}
// ViewData is the template context passed to frontend theme layouts.
//
// Theme authors can rely on these fields in templates, and render hooks may
// enrich or modify them before execution.
type ViewData struct {
Site *config.Config
Page *content.Document
Documents []*content.Document
Data map[string]any
Lang string
Title string
LiveReload bool
TaxonomyName string
TaxonomyTerm string
AuthorName string
SearchQuery string
RequestPath string
StatusCode int
Nav []NavItem
}
// Slots collects named HTML fragments that themes expose via pluginSlot in
// templates.
//
// Asset hooks and HTML slot hooks populate Slots before template execution.
type Slots struct {
values map[string][]template.HTML
}
// NewSlots creates an empty slot registry for a single render pass.
func NewSlots() *Slots {
return &Slots{
values: make(map[string][]template.HTML),
}
}
// Add appends an HTML fragment to a named slot.
//
// Slot names should match the theme manifest's declared slots. Empty names and
// empty HTML fragments are ignored.
func (s *Slots) Add(name string, html template.HTML) {
if s == nil || strings.TrimSpace(name) == "" || strings.TrimSpace(string(html)) == "" {
return
}
s.values[name] = append(s.values[name], html)
}
// Render concatenates all fragments for a slot in insertion order.
func (s *Slots) Render(name string) template.HTML {
if s == nil {
return ""
}
items := s.values[name]
if len(items) == 0 {
return ""
}
var sb strings.Builder
for _, item := range items {
sb.WriteString(string(item))
sb.WriteString("\n")
}
return template.HTML(sb.String())
}
// ScriptPosition controls where a script asset is rendered in theme slots.
type ScriptPosition string
const (
ScriptPositionHead ScriptPosition = "head"
ScriptPositionBodyEnd ScriptPosition = "body.end"
)
// AssetSet accumulates CSS and JS assets for a single render pass.
//
// Asset hooks append to the set, and the renderer publishes them into standard
// theme slots such as head.end and body.end.
type AssetSet struct {
styles []string
headScripts []string
bodyScripts []string
}
// NewAssetSet creates an empty asset accumulator for a render pass.
func NewAssetSet() *AssetSet {
return &AssetSet{
styles: make([]string, 0),
headScripts: make([]string, 0),
bodyScripts: make([]string, 0),
}
}
func (a *AssetSet) AddStyle(url string) {
url = strings.TrimSpace(url)
if url == "" {
return
}
if !containsString(a.styles, url) {
a.styles = append(a.styles, url)
}
}
func (a *AssetSet) AddScript(url string, pos ScriptPosition) {
url = strings.TrimSpace(url)
if url == "" {
return
}
switch pos {
case ScriptPositionHead:
if !containsString(a.headScripts, url) {
a.headScripts = append(a.headScripts, url)
}
default:
if !containsString(a.bodyScripts, url) {
a.bodyScripts = append(a.bodyScripts, url)
}
}
}
func (a *AssetSet) RenderInto(slots *Slots) {
if a == nil || slots == nil {
return
}
for _, href := range a.styles {
slots.Add("head.end", template.HTML(
`<link rel="stylesheet" href="`+template.HTMLEscapeString(href)+`">`,
))
}
for _, src := range a.headScripts {
slots.Add("head.end", template.HTML(
`<script src="`+template.HTMLEscapeString(src)+`"></script>`,
))
}
for _, src := range a.bodyScripts {
slots.Add("body.end", template.HTML(
`<script src="`+template.HTMLEscapeString(src)+`"></script>`,
))
}
}
func containsString(values []string, target string) bool {
for _, v := range values {
if v == target {
return true
}
}
return false
}
func (r *Renderer) Build(ctx context.Context, graph *content.SiteGraph) error {
_, err := r.BuildWithStats(ctx, graph)
return err
}
func (r *Renderer) BuildWithStats(ctx context.Context, graph *content.SiteGraph) (BuildStats, error) {
_ = ctx
var stats BuildStats
r.syncActiveTheme()
if err := r.prepareBuild(true, true, &stats); err != nil {
return stats, err
}
start := time.Now()
for _, doc := range graph.Documents {
if err := r.renderDocumentToDisk(graph, doc, false); err != nil {
return stats, err
}
}
stats.Documents = time.Since(start)
start = time.Now()
if err := r.buildTaxonomyArchives(ctx, graph, false, nil); err != nil {
return stats, err
}
stats.Taxonomies = time.Since(start)
if err := r.buildCoreRoutes(graph, false, nil); err != nil {
return stats, err
}
start = time.Now()
if err := r.writeSearchIndex(graph); err != nil {
return stats, err
}
stats.Search = time.Since(start)
if err := platformapi.WriteStaticArtifacts(r.cfg, graph); err != nil {
return stats, err
}
return stats, nil
}
func (r *Renderer) BuildURLs(ctx context.Context, graph *content.SiteGraph, urls []string) error {
_ = ctx
if err := r.prepareBuild(false, false, nil); err != nil {
return err
}
for _, url := range urls {
html, err := r.RenderURL(graph, url, false)
if err != nil {
if os.IsNotExist(err) {
continue
}
return err
}
if err := r.writeRenderedURL(url, html); err != nil {
return err
}
}
if err := r.buildCoreRoutes(graph, false, nil); err != nil {
return err
}
if err := r.writeSearchIndex(graph); err != nil {
return err
}
if err := platformapi.WriteStaticArtifacts(r.cfg, graph); err != nil {
return err
}
return nil
}
func (r *Renderer) BuildTaxonomyArchives(ctx context.Context, graph *content.SiteGraph) error {
return r.buildTaxonomyArchives(ctx, graph, false, nil)
}
func (r *Renderer) buildTaxonomyArchives(ctx context.Context, graph *content.SiteGraph, liveReload bool, filter map[string]struct{}) error {
_ = ctx
if err := r.prepareBuild(false, false, nil); err != nil {
return err
}
for _, taxonomyName := range graph.Taxonomies.OrderedNames() {
terms := graph.Taxonomies.Values[taxonomyName]
def := graph.Taxonomies.Definition(taxonomyName)
for _, term := range graph.Taxonomies.OrderedTerms(taxonomyName) {
entries := terms[term]
byLang := make(map[string][]*content.Document)
for _, entry := range entries {
doc, ok := r.findDocumentByID(graph, entry.DocumentID)
if !ok || doc.Draft {
continue
}
byLang[doc.Lang] = append(byLang[doc.Lang], doc)
}
for lang, docs := range byLang {
sort.Slice(docs, func(i, j int) bool {
return docs[i].URL < docs[j].URL
})
currentURL := r.taxonomyURL(lang, taxonomyName, term)
if !shouldBuildURL(filter, currentURL) {
continue
}
html, err := r.renderTaxonomyArchive(graph, def.EffectiveTermLayout(), currentURL, taxonomyName, term, lang, docs, liveReload)
if err != nil {
return fmt.Errorf("render taxonomy archive %s/%s/%s: %w", lang, taxonomyName, term, err)
}
if err := r.writeRenderedURL(currentURL, html); err != nil {
return fmt.Errorf("write taxonomy archive %s: %w", currentURL, err)
}
}
}
}
return nil
}
func (r *Renderer) renderDocumentToDisk(graph *content.SiteGraph, doc *content.Document, liveReload bool) error {
html, err := r.renderTemplate(doc.Layout, doc.URL, r.documentViewData(graph, doc, liveReload))
if err != nil {
return fmt.Errorf("render document %s: %w", doc.SourcePath, err)
}
if err := r.writeRenderedURL(doc.URL, html); err != nil {
return fmt.Errorf("write file for %s: %w", doc.URL, err)
}
return nil
}
func (r *Renderer) writeRenderedURL(url string, html []byte) error {
targetDir := filepath.Join(r.cfg.PublicDir, strings.TrimPrefix(url, "/"))
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("mkdir target %s: %w", targetDir, err)
}
targetFile := filepath.Join(targetDir, "index.html")
if err := os.WriteFile(targetFile, html, 0o644); err != nil {
return fmt.Errorf("write file %s: %w", targetFile, err)
}
return nil
}
func (r *Renderer) RenderURL(graph *content.SiteGraph, urlPath string, liveReload bool) ([]byte, error) {
return r.RenderURLWithQuery(graph, urlPath, "", liveReload)
}
func (r *Renderer) RenderURLWithQuery(graph *content.SiteGraph, urlPath string, rawQuery string, liveReload bool) ([]byte, error) {
if doc, ok := graph.ByURL[urlPath]; ok {
return r.renderTemplate(doc.Layout, doc.URL, r.documentViewData(graph, doc, liveReload))
}
if urlPath == "/" {
return r.renderTemplate("index", "/", r.indexViewData(graph, graph.Config.DefaultLang, "/", liveReload))
}
for lang := range graph.ByLang {
if urlPath == "/"+lang+"/" {
return r.renderTemplate("index", urlPath, r.indexViewData(graph, lang, urlPath, liveReload))
}
}
if vd, ok := r.findTaxonomyArchive(graph, urlPath, liveReload); ok {
vd.Nav = r.resolveNav(graph, urlPath)
layout := graph.Taxonomies.Definition(vd.TaxonomyName).EffectiveTermLayout()
return r.renderTemplate(layout, urlPath, vd)
}
if vd, ok := r.findSearchPage(graph, urlPath, rawQuery, liveReload); ok {
vd.Nav = r.resolveNav(graph, urlPath)
return r.renderTemplate("search", urlPath, vd)
}
if vd, ok := r.findAuthorArchive(graph, urlPath, liveReload); ok {
vd.Nav = r.resolveNav(graph, urlPath)
return r.renderTemplate("author", urlPath, vd)
}
return nil, os.ErrNotExist
}
type searchIndexEntry struct {
Title string `json:"title"`
URL string `json:"url"`
Summary string `json:"summary,omitempty"`
Snippet string `json:"snippet,omitempty"`
Content string `json:"content,omitempty"`
Type string `json:"type"`
Lang string `json:"lang"`
Layout string `json:"layout,omitempty"`
Tags []string `json:"tags,omitempty"`
Categories []string `json:"categories,omitempty"`
Taxonomies map[string][]string `json:"taxonomies,omitempty"`
}
func (r *Renderer) writeSearchIndex(graph *content.SiteGraph) error {
if r == nil || r.cfg == nil || graph == nil {
return nil
}
items := make([]searchIndexEntry, 0, len(graph.Documents))
for _, doc := range graph.Documents {
if doc == nil || doc.Draft || documentArchived(doc) {
continue
}
items = append(items, searchIndexEntry{
Title: doc.Title,
URL: doc.URL,
Summary: doc.Summary,
Snippet: buildSearchSnippet(doc.Summary, normalizeSearchContent(doc)),
Content: normalizeSearchContent(doc),
Type: doc.Type,
Lang: doc.Lang,
Layout: doc.Layout,
Tags: append([]string{}, doc.Taxonomies["tags"]...),
Categories: append([]string{}, doc.Taxonomies["categories"]...),
Taxonomies: cloneTaxonomies(doc.Taxonomies),
})
}
body, err := json.MarshalIndent(items, "", " ")
if err != nil {
return fmt.Errorf("marshal search index: %w", err)
}
path := filepath.Join(r.cfg.PublicDir, "search.json")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("mkdir search index dir: %w", err)
}
if err := os.WriteFile(path, append(body, '\n'), 0o644); err != nil {
return fmt.Errorf("write search index: %w", err)
}
return nil
}
func normalizeSearchContent(doc *content.Document) string {
if doc == nil {
return ""
}
text := strings.TrimSpace(doc.RawBody)
if text == "" {
text = strings.TrimSpace(stripHTMLTagsRE.ReplaceAllString(string(doc.HTMLBody), " "))
}
return strings.Join(strings.Fields(text), " ")
}
func buildSearchSnippet(summary, content string) string {
summary = strings.TrimSpace(summary)
if summary != "" {
return summary
}
runes := []rune(strings.TrimSpace(content))
if len(runes) <= 180 {
return string(runes)
}
return strings.TrimSpace(string(runes[:180])) + "..."
}
func cloneTaxonomies(in map[string][]string) map[string][]string {
if len(in) == 0 {
return nil
}
out := make(map[string][]string, len(in))
for key, values := range in {
out[key] = append([]string{}, values...)
}
return out
}
func documentArchived(doc *content.Document) bool {
if doc == nil || doc.Params == nil {
return false
}
value, ok := doc.Params["archived"]
if !ok {
return false
}
flag, ok := value.(bool)
return ok && flag
}
func (r *Renderer) prepareBuild(cleanPublicDir, syncAssets bool, stats *BuildStats) error {
start := time.Now()
if err := r.themes.MustExist(); err != nil {
return err
}
if cleanPublicDir {
if err := os.RemoveAll(r.cfg.PublicDir); err != nil {
return err
}
}
if err := os.MkdirAll(r.cfg.PublicDir, 0o755); err != nil {
return err
}
if syncAssets {
assetsStart := time.Now()
if err := assets.Sync(r.cfg, r.hooks); err != nil {
return err
}
if stats != nil {
stats.Assets = time.Since(assetsStart)
}
}
if stats != nil {
stats.Prepare = time.Since(start)
}
return nil
}
func (r *Renderer) documentViewData(graph *content.SiteGraph, doc *content.Document, liveReload bool) ViewData {
return ViewData{
Site: graph.Config,
Page: doc,
Data: graph.Data,
Lang: doc.Lang,
Title: doc.Title,
LiveReload: liveReload,
Nav: r.resolveNav(graph, doc.URL),
}
}
func (r *Renderer) indexViewData(graph *content.SiteGraph, lang, currentURL string, liveReload bool) ViewData {
return ViewData{
Site: graph.Config,
Data: graph.Data,
Lang: lang,
Title: graph.Config.Title,
Documents: r.documentsForLang(graph, lang),
LiveReload: liveReload,
Nav: r.resolveNav(graph, currentURL),
}
}
func (r *Renderer) buildCoreRoutes(graph *content.SiteGraph, liveReload bool, filter map[string]struct{}) error {
if graph == nil {
return nil
}
searchLangs := r.knownLangs(graph)
for _, lang := range searchLangs {
searchURL := content.SearchPageURL(r.cfg.DefaultLang, lang)
if !shouldBuildURL(filter, searchURL) {
continue
}
html, err := r.renderTemplate("search", searchURL, r.searchViewData(graph, lang, "", searchURL, liveReload))
if err != nil {
return fmt.Errorf("render search page %s: %w", searchURL, err)
}
if err := r.writeRenderedURL(searchURL, html); err != nil {
return fmt.Errorf("write search page %s: %w", searchURL, err)
}
}
for _, author := range r.authorArchives(graph) {
currentURL := content.AuthorArchiveURL(r.cfg.DefaultLang, author.lang, author.name)
if currentURL == "" || !shouldBuildURL(filter, currentURL) {
continue
}
html, err := r.renderTemplate("author", currentURL, ViewData{
Site: graph.Config,
Data: graph.Data,
Lang: author.lang,
Title: fmt.Sprintf("Author: %s", author.name),
Documents: author.documents,
LiveReload: liveReload,
AuthorName: author.name,
Nav: r.resolveNav(graph, currentURL),
})
if err != nil {
return fmt.Errorf("render author archive %s: %w", currentURL, err)
}
if err := r.writeRenderedURL(currentURL, html); err != nil {
return fmt.Errorf("write author archive %s: %w", currentURL, err)
}
}
if len(filter) == 0 || shouldBuildURL(filter, "/404/") || shouldBuildURL(filter, "/404.html") {
html, err := r.RenderNotFoundPage(graph, "/", false)
if err != nil {
return fmt.Errorf("render 404 page: %w", err)
}
if err := r.writeNotFoundPage(html); err != nil {
return fmt.Errorf("write 404 page: %w", err)
}
}
return nil
}
func (r *Renderer) renderTaxonomyArchive(
graph *content.SiteGraph,
layout string,
currentURL string,
taxonomyName string,
term string,
lang string,
docs []*content.Document,
liveReload bool,
) ([]byte, error) {
title := fmt.Sprintf("%s: %s", graph.Taxonomies.Definition(taxonomyName).DisplayTitle(lang), term)
return r.renderTemplate(layout, currentURL, ViewData{
Site: graph.Config,
Data: graph.Data,
Lang: lang,
Title: title,
Documents: docs,
LiveReload: liveReload,
TaxonomyName: taxonomyName,
TaxonomyTerm: term,
Nav: r.resolveNav(graph, currentURL),
})
}
func (r *Renderer) RenderNotFoundPage(graph *content.SiteGraph, requestPath string, liveReload bool) ([]byte, error) {
lang := r.langForPath(graph, requestPath)
return r.renderTemplate("404", requestPath, ViewData{
Site: graph.Config,
Data: graph.Data,
Lang: lang,
Title: "Page not found",
Documents: r.documentsForLang(graph, lang),
LiveReload: liveReload,
RequestPath: requestPath,
StatusCode: http.StatusNotFound,
Nav: r.resolveNav(graph, requestPath),
})
}
func (r *Renderer) writeNotFoundPage(html []byte) error {
path := filepath.Join(r.cfg.PublicDir, "404.html")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("mkdir 404 dir: %w", err)
}
if err := os.WriteFile(path, html, 0o644); err != nil {
return fmt.Errorf("write 404 file: %w", err)
}
return nil
}
func shouldBuildURL(filter map[string]struct{}, url string) bool {
if len(filter) == 0 {
return true
}
_, ok := filter[url]
return ok
}
func (r *Renderer) findTaxonomyArchive(graph *content.SiteGraph, urlPath string, liveReload bool) (ViewData, bool) {
clean := strings.Trim(urlPath, "/")
if clean == "" {
return ViewData{}, false
}
parts := strings.Split(clean, "/")
var lang, taxonomyName, term string
switch len(parts) {
case 2:
lang = r.cfg.DefaultLang
taxonomyName = parts[0]
term = parts[1]
case 3:
lang = parts[0]
taxonomyName = parts[1]
term = parts[2]
default:
return ViewData{}, false
}
taxonomyTerms, ok := graph.Taxonomies.Values[taxonomyName]
if !ok {
return ViewData{}, false
}
entries, ok := taxonomyTerms[term]
if !ok {
return ViewData{}, false
}
docs := make([]*content.Document, 0)
for _, entry := range entries {
if entry.Lang != lang {
continue
}
doc, ok := r.findDocumentByID(graph, entry.DocumentID)
if !ok || doc.Draft {
continue
}
docs = append(docs, doc)
}
if len(docs) == 0 {
return ViewData{}, false
}
sort.Slice(docs, func(i, j int) bool {
return docs[i].URL < docs[j].URL
})
def := graph.Taxonomies.Definition(taxonomyName)
return ViewData{
Site: graph.Config,
Data: graph.Data,
Lang: lang,
Title: fmt.Sprintf("%s: %s", def.DisplayTitle(lang), term),
Documents: docs,
LiveReload: liveReload,
TaxonomyName: taxonomyName,
TaxonomyTerm: term,
}, true
}
func (r *Renderer) findSearchPage(graph *content.SiteGraph, urlPath string, rawQuery string, liveReload bool) (ViewData, bool) {
lang := r.searchLangForPath(graph, urlPath)
if lang == "" {
return ViewData{}, false
}
queryValues, _ := url.ParseQuery(rawQuery)
query := strings.TrimSpace(queryValues.Get("q"))
return r.searchViewData(graph, lang, query, urlPath, liveReload), true
}
func (r *Renderer) searchViewData(graph *content.SiteGraph, lang, query, currentURL string, liveReload bool) ViewData {
docs := r.documentsForSearch(graph, lang, query)
title := "Search"
if query != "" {
title = fmt.Sprintf("Search: %s", query)
}
return ViewData{
Site: graph.Config,
Data: graph.Data,
Lang: lang,
Title: title,
Documents: docs,
LiveReload: liveReload,
SearchQuery: query,
}
}
func (r *Renderer) findAuthorArchive(graph *content.SiteGraph, urlPath string, liveReload bool) (ViewData, bool) {
clean := strings.Trim(urlPath, "/")
if clean == "" {
return ViewData{}, false
}
parts := strings.Split(clean, "/")
var lang, slug string
switch len(parts) {
case 2:
if parts[0] != "authors" {
return ViewData{}, false
}
lang = r.cfg.DefaultLang
slug = parts[1]
case 3:
if parts[1] != "authors" {
return ViewData{}, false
}
lang = parts[0]
slug = parts[2]
default:
return ViewData{}, false
}
for _, author := range r.authorArchives(graph) {
if author.lang != lang || content.AuthorSlug(author.name) != slug {
continue
}
return ViewData{
Site: graph.Config,
Data: graph.Data,
Lang: lang,
Title: fmt.Sprintf("Author: %s", author.name),
Documents: author.documents,
LiveReload: liveReload,
AuthorName: author.name,
}, true
}
return ViewData{}, false
}
func (r *Renderer) taxonomyURL(lang, taxonomyName, term string) string {
if lang == "" || lang == r.cfg.DefaultLang {
return fmt.Sprintf("/%s/%s/", taxonomyName, term)
}
return fmt.Sprintf("/%s/%s/%s/", lang, taxonomyName, term)
}
func (r *Renderer) findDocumentByID(graph *content.SiteGraph, id string) (*content.Document, bool) {
for _, doc := range graph.Documents {
if doc.ID == id {
return doc, true
}
}
return nil, false
}
func (r *Renderer) documentsForLang(graph *content.SiteGraph, lang string) []*content.Document {
docs := make([]*content.Document, 0, len(graph.ByLang[lang]))
for _, doc := range graph.ByLang[lang] {
docs = append(docs, doc)
}
sort.Slice(docs, func(i, j int) bool {
return docs[i].URL < docs[j].URL
})
return docs
}
func (r *Renderer) documentsForSearch(graph *content.SiteGraph, lang, query string) []*content.Document {
docs := make([]*content.Document, 0)
needle := strings.ToLower(strings.TrimSpace(query))
for _, doc := range graph.Documents {
if doc == nil || doc.Draft || documentArchived(doc) {
continue
}
if lang != "" && doc.Lang != lang {
continue
}
if needle != "" {
haystack := strings.ToLower(strings.Join([]string{
doc.Title,
doc.Slug,
doc.URL,
doc.Summary,
normalizeSearchContent(doc),
}, " "))
if !strings.Contains(haystack, needle) {
continue
}
}
docs = append(docs, doc)
}
sort.Slice(docs, func(i, j int) bool {
return docs[i].URL < docs[j].URL
})
return docs
}
type authorArchive struct {
name string
lang string
documents []*content.Document
}
func (r *Renderer) authorArchives(graph *content.SiteGraph) []authorArchive {
type key struct {
lang string
name string
}
grouped := make(map[key][]*content.Document)
for _, doc := range graph.Documents {
if doc == nil || doc.Draft || documentArchived(doc) {
continue
}
name := strings.TrimSpace(doc.Author)
if name == "" {
continue
}
k := key{lang: doc.Lang, name: name}
grouped[k] = append(grouped[k], doc)
}
out := make([]authorArchive, 0, len(grouped))
for k, docs := range grouped {
sort.Slice(docs, func(i, j int) bool {
return docs[i].URL < docs[j].URL
})
out = append(out, authorArchive{name: k.name, lang: k.lang, documents: docs})
}
sort.Slice(out, func(i, j int) bool {
if out[i].lang == out[j].lang {
return out[i].name < out[j].name
}
return out[i].lang < out[j].lang
})
return out
}
func (r *Renderer) searchLangForPath(graph *content.SiteGraph, urlPath string) string {
trimmed := strings.Trim(urlPath, "/")
switch trimmed {
case "search":
return r.cfg.DefaultLang
case "":
return ""
}
parts := strings.Split(trimmed, "/")
if len(parts) == 2 && parts[1] == "search" {
if _, ok := graph.ByLang[parts[0]]; ok {
return parts[0]
}
}
return ""
}
func (r *Renderer) langForPath(graph *content.SiteGraph, path string) string {
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return r.cfg.DefaultLang
}
parts := strings.Split(trimmed, "/")
if _, ok := graph.ByLang[parts[0]]; ok {
return parts[0]
}
return r.cfg.DefaultLang
}
func (r *Renderer) knownLangs(graph *content.SiteGraph) []string {
if graph == nil || len(graph.ByLang) == 0 {
return []string{r.cfg.DefaultLang}
}
langs := make([]string, 0, len(graph.ByLang))
for lang := range graph.ByLang {
if strings.TrimSpace(lang) == "" {
continue
}
langs = append(langs, lang)
}
if !containsString(langs, r.cfg.DefaultLang) {
langs = append(langs, r.cfg.DefaultLang)
}
sort.Strings(langs)
return langs
}
func (r *Renderer) resolveNav(graph *content.SiteGraph, currentURL string) []NavItem {
var base []NavItem
if len(r.cfg.Menus["main"]) > 0 {
base = make([]NavItem, 0, len(r.cfg.Menus["main"]))
for _, item := range r.cfg.Menus["main"] {
base = append(base, NavItem{
Name: item.Name,
URL: normalizeNavURL(item.URL),
})
}
} else if graph != nil && graph.Data != nil {
if nav := parseNavigationData(graph.Data["navigation"]); len(nav) > 0 {
base = nav
}
}
if len(base) == 0 {
base = []NavItem{
{Name: "Home", URL: "/"},
{Name: "Sample Post", URL: "/posts/hello-world/"},
{Name: "Tags", URL: "/tags/go/"},
{Name: "RSS", URL: normalizeNavURL(r.cfg.Feed.RSSPath)},
}
}
currentURL = normalizeNavURL(currentURL)
out := make([]NavItem, 0, len(base))
for _, item := range base {
item.Active = navItemIsActive(item.URL, currentURL)
out = append(out, item)
}
return out
}
func normalizeNavURL(u string) string {
// pure thievery lol
u = strings.TrimSpace(u)
if u == "" {
return "/"
}
if !strings.HasPrefix(u, "/") && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
u = "/" + u
}
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
return u
}
if u != "/" && !strings.Contains(filepath.Base(u), ".") && !strings.HasSuffix(u, "/") {
u += "/"
}
return u
}
func navItemIsActive(itemURL, currentURL string) bool {
if itemURL == "" || currentURL == "" {
return false
}
if itemURL == currentURL {
return true
}
if strings.HasPrefix(itemURL, "http://") || strings.HasPrefix(itemURL, "https://") {
return false
}
if itemURL == "/" {
return currentURL == "/"
}
return strings.HasPrefix(currentURL, itemURL)
}
func parseNavigationData(v any) []NavItem {
root, ok := v.(map[string]any)
if !ok {
return nil
}
raw, ok := root["main"]
if !ok {
return nil
}
list, ok := raw.([]any)
if !ok {
return nil
}
items := make([]NavItem, 0, len(list))
for _, entry := range list {
m, ok := entry.(map[string]any)
if !ok {
continue
}
name, _ := m["name"].(string)
url, _ := m["url"].(string)
if strings.TrimSpace(name) == "" || strings.TrimSpace(url) == "" {
continue
}
items = append(items, NavItem{
Name: name,
URL: normalizeNavURL(url),
})
}
return items
}
func (r *Renderer) renderTemplate(name string, targetURL string, data ViewData) ([]byte, error) {
r.syncActiveTheme()
manifest, _ := theme.LoadManifest(r.cfg.ThemesDir, r.cfg.Theme)
if err := r.hooks.OnContext(&data); err != nil {
return nil, err
}
assetSet := NewAssetSet()
if err := r.hooks.OnAssets(&data, assetSet); err != nil {
return nil, err
}
slots := NewSlots()
assetSet.RenderInto(slots)
if err := r.hooks.OnHTMLSlots(&data, slots); err != nil {
return nil, err
}
if err := r.hooks.OnBeforeRender(&data); err != nil {
return nil, err
}
data = sanitizeViewDataForTheme(data, manifest)
basePath := r.themes.LayoutPath("base")
pagePath := r.layoutPathWithFallback(name)
partials, err := filepath.Glob(filepath.Join(r.cfg.ThemesDir, r.cfg.Theme, "layouts", "partials", "*.html"))
if err != nil {
return nil, fmt.Errorf("glob partials: %w", err)
}
files := []string{basePath, pagePath}
files = append(files, partials...)
// These template functions are the stable extension helpers exposed to
// frontend themes:
// - safeHTML returns trusted template.HTML values unchanged.
// - field reads schema/custom fields from the current document.
// - data reads from the shared site data map loaded from content/data.
// - pluginSlot renders accumulated HTML for a declared theme slot.
tmpl, err := template.New("base.html").Funcs(template.FuncMap{
"safeHTML": func(v any) template.HTML {
if h, ok := v.(template.HTML); ok {
return h
}
return ""
},
"field": func(doc *content.Document, key string) any {
if doc == nil || doc.Fields == nil {
return nil
}
return doc.Fields[key]
},
"data": func(key string) any {
if data.Data == nil {
return nil
}
return data.Data[key]
},
"pluginSlot": func(name string) template.HTML {
return slots.Render(name)
},
}).ParseFiles(files...)
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
var sb strings.Builder
if err := tmpl.ExecuteTemplate(&sb, "base", data); err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}
html := []byte(sb.String())
html, err = r.hooks.OnAfterRender(targetURL, html)
if err != nil {
return nil, err
}
return html, nil
}
func (r *Renderer) ContentSecurityPolicy() string {
manifest, err := theme.LoadManifest(r.cfg.ThemesDir, r.cfg.Theme)
if err != nil {
return theme.ContentSecurityPolicy(nil)
}
return theme.ContentSecurityPolicy(manifest)
}
func sanitizeViewDataForTheme(data ViewData, manifest *theme.Manifest) ViewData {
security := theme.ThemeSecurity{}
if manifest != nil {
security = manifest.Security
}
out := data
out.Site = sanitizePublicSiteConfig(data.Site, security.TemplateContext)
out.Page = sanitizeDocumentForTheme(data.Page, security.TemplateContext)
out.Documents = sanitizeDocumentsForTheme(data.Documents, security.TemplateContext)
out.Data = sanitizeThemeDataMap(data.Data, security.TemplateContext)
return out
}
func sanitizePublicSiteConfig(site *config.Config, ctx theme.ThemeTemplateContext) *config.Config {
if site == nil {
return nil
}
copySite := *site
copySite.Admin = config.AdminConfig{
Enabled: site.Admin.Enabled,
Addr: site.Admin.Addr,
Path: site.Admin.Path,
LocalOnly: site.Admin.LocalOnly,
Theme: site.Admin.Theme,
Debug: config.AdminDebugConfig{Pprof: false},
}
copySite.Backup = config.BackupConfig{}
copySite.ContentDir = ""
copySite.PublicDir = ""
copySite.ThemesDir = ""
copySite.DataDir = ""
copySite.PluginsDir = ""
copySite.Build = config.BuildConfig{}
copySite.Content = config.ContentConfig{}
copySite.Plugins = config.PluginConfig{}
copySite.Fields = config.FieldsConfig{}
copySite.Cache = config.CacheConfig{}
copySite.Security = config.SecurityConfig{}
copySite.Deploy = config.DeployConfig{}
copySite.Params = nil
if themeTemplateBool(ctx.AllowSiteParams) {
copySite.Params = cloneMap(site.Params)
}
copySite.Menus = cloneMenus(site.Menus)
return ©Site
}
func sanitizeDocumentForTheme(doc *content.Document, ctx theme.ThemeTemplateContext) *content.Document {
if doc == nil {
return nil
}
cloned := *doc
cloned.Params = cloneMap(doc.Params)
cloned.Fields = nil
if themeTemplateBool(ctx.AllowContentFields) {
cloned.Fields = cloneMap(doc.Fields)
}
return &cloned
}
func sanitizeDocumentsForTheme(items []*content.Document, ctx theme.ThemeTemplateContext) []*content.Document {
if len(items) == 0 {
return nil
}
out := make([]*content.Document, 0, len(items))
for _, item := range items {
out = append(out, sanitizeDocumentForTheme(item, ctx))
}
return out
}
func sanitizeThemeDataMap(in map[string]any, ctx theme.ThemeTemplateContext) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
normalized := strings.ToLower(strings.TrimSpace(key))
if normalized == "custom_fields" && !themeTemplateBool(ctx.AllowSharedFields) {
continue
}
if !themeTemplateBool(ctx.AllowAdminState) && (normalized == "admin" || normalized == "auth" || normalized == "sessions") {
continue
}
if !themeTemplateBool(ctx.AllowRuntimeState) && (normalized == "runtime" || normalized == "debug" || normalized == "diagnostics") {
continue
}
out[key] = value
}
return out
}
func cloneMap(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneMenus(in map[string][]config.MenuItem) map[string][]config.MenuItem {
if len(in) == 0 {
return nil
}
out := make(map[string][]config.MenuItem, len(in))
for key, items := range in {
out[key] = append([]config.MenuItem(nil), items...)
}
return out
}
func themeTemplateBool(v *bool) bool {
return v != nil && *v
}
func (r *Renderer) syncActiveTheme() {
if r == nil || r.themes == nil || r.cfg == nil {
return
}
r.themes.SetActiveTheme(r.cfg.Theme)
}
func (r *Renderer) layoutPathWithFallback(name string) string {
candidates := []string{name}
switch name {
case "404":
candidates = append(candidates, "index")
case "search", "author":
candidates = append(candidates, "list", "index")
}
for _, candidate := range candidates {
path := r.themes.LayoutPath(candidate)
if _, err := os.Stat(path); err == nil {
return path
}
}
return r.themes.LayoutPath(name)
}
package router
import (
"fmt"
"path/filepath"
"strings"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
)
type Resolver struct {
cfg *config.Config
}
func NewResolver(cfg *config.Config) *Resolver {
return &Resolver{cfg: cfg}
}
func (r *Resolver) AssignURLs(graph *content.SiteGraph) error {
if graph == nil {
return fmt.Errorf("site graph is nil")
}
graph.ByURL = make(map[string]*content.Document, len(graph.Documents))
for _, doc := range graph.Documents {
if doc == nil {
continue
}
url, err := r.URLForDocument(doc)
if err != nil {
return fmt.Errorf("assign url for %s: %w", doc.SourcePath, err)
}
doc.URL = url
if existing, ok := graph.ByURL[url]; ok {
return fmt.Errorf(
"route collision: %q is generated by both %s and %s",
url,
existing.SourcePath,
doc.SourcePath,
)
}
graph.ByURL[url] = doc
}
return nil
}
func (r *Resolver) URLForDocument(doc *content.Document) (string, error) {
if doc == nil {
return "", fmt.Errorf("document is nil")
}
switch doc.Type {
case "page":
return r.pageURL(doc), nil
case "post":
return r.postURL(doc), nil
default:
return "", fmt.Errorf("unsupported document type %q", doc.Type)
}
}
func (r *Resolver) pageURL(doc *content.Document) string {
slug := strings.TrimSpace(doc.Slug)
lang := strings.TrimSpace(doc.Lang)
if slug == "" || slug == "index" {
if lang == "" || lang == r.cfg.DefaultLang {
return "/"
}
return "/" + lang + "/"
}
if lang == "" || lang == r.cfg.DefaultLang {
return "/" + slug + "/"
}
return "/" + lang + "/" + slug + "/"
}
func (r *Resolver) postURL(doc *content.Document) string {
slug := strings.TrimSpace(doc.Slug)
lang := strings.TrimSpace(doc.Lang)
if lang == "" || lang == r.cfg.DefaultLang {
return "/posts/" + slug + "/"
}
return "/" + lang + "/posts/" + slug + "/"
}
func sourceToSlug(path string) string {
base := filepath.Base(path)
return strings.TrimSuffix(base, filepath.Ext(base))
}
package safepath
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func ValidatePathComponent(kind, value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("%s cannot be empty", kind)
}
if filepath.IsAbs(value) {
return "", fmt.Errorf("%s must be a single directory name", kind)
}
if strings.Contains(value, "/") || strings.Contains(value, `\`) {
return "", fmt.Errorf("%s must be a single directory name", kind)
}
clean := filepath.Clean(value)
if clean == "." || clean == ".." || clean != value {
return "", fmt.Errorf("%s must be a single directory name", kind)
}
return value, nil
}
func ResolveRelativeUnderRoot(root, rel string) (string, error) {
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", err
}
rel = strings.TrimSpace(rel)
if rel == "" {
return "", fmt.Errorf("path cannot be empty")
}
clean := filepath.Clean(rel)
if filepath.IsAbs(clean) {
return "", fmt.Errorf("path must be relative")
}
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path must stay inside %s", root)
}
target := filepath.Join(rootAbs, clean)
ok, err := IsWithinRoot(rootAbs, target)
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("path must stay inside %s", root)
}
return target, nil
}
func IsWithinRoot(root, target string) (bool, error) {
rootAbs, err := filepath.Abs(root)
if err != nil {
return false, err
}
targetAbs, err := filepath.Abs(target)
if err != nil {
return false, err
}
rootWithSep := rootAbs + string(filepath.Separator)
return targetAbs == rootAbs || strings.HasPrefix(targetAbs, rootWithSep), nil
}
func RemoveRelativeUnderRoot(root, rel string) error {
target, err := ResolveRelativeUnderRoot(root, rel)
if err != nil {
return err
}
return os.RemoveAll(target)
}
package server
import (
"fmt"
"os/exec"
"runtime"
)
func openBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
cmd = exec.Command("xdg-open", url)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("open browser: %w", err)
}
return nil
}
//go:build darwin || linux
package server
import (
"syscall"
"time"
)
func processCPUTime() (time.Duration, time.Duration) {
var usage syscall.Rusage
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &usage); err != nil {
return 0, 0
}
return timevalToDuration(usage.Utime), timevalToDuration(usage.Stime)
}
func timevalToDuration(tv syscall.Timeval) time.Duration {
return time.Duration(tv.Sec)*time.Second + time.Duration(tv.Usec)*time.Microsecond
}
package server
import (
"encoding/json"
"net/http"
)
func (s *Server) handleDepsDebug(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
depGraph := s.depGraph
s.mu.RUnlock()
if depGraph == nil {
http.Error(w, `{"error":"dependency graph unavailable"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(depGraph.Export())
}
package server
import (
"net/http"
"github.com/sphireinc/foundry/internal/feed"
)
func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
_ = r
s.mu.RLock()
graph := s.graph
s.mu.RUnlock()
if graph == nil {
http.Error(w, "site graph unavailable", http.StatusServiceUnavailable)
return
}
payload, err := feed.BuildRSS(s.cfg, graph)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
_, _ = w.Write(payload)
}
func (s *Server) handleSitemap(w http.ResponseWriter, r *http.Request) {
_ = r
s.mu.RLock()
graph := s.graph
s.mu.RUnlock()
if graph == nil {
http.Error(w, "site graph unavailable", http.StatusServiceUnavailable)
return
}
payload, err := feed.BuildSitemap(s.cfg, graph)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
_, _ = w.Write(payload)
}
package server
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/fsnotify/fsnotify"
"github.com/sphireinc/foundry/internal/backup"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/deps"
"github.com/sphireinc/foundry/internal/diag"
"github.com/sphireinc/foundry/internal/logx"
"github.com/sphireinc/foundry/internal/media"
"github.com/sphireinc/foundry/internal/ops"
"github.com/sphireinc/foundry/internal/renderer"
"github.com/sphireinc/foundry/internal/router"
)
// Loader produces the site graph served by the preview server.
type Loader interface {
Load(context.Context) (*content.SiteGraph, error)
}
// Hooks exposes the preview-server lifecycle to integrators.
//
// The practical order is:
// 1. OnRoutesAssigned during rebuild
// 2. RegisterRoutes when the mux is assembled
// 3. OnAssetsBuilding during asset sync
// 4. OnServerStarted after the listener is ready
type Hooks interface {
RegisterRoutes(mux *http.ServeMux)
OnServerStarted(addr string) error
OnRoutesAssigned(graph *content.SiteGraph) error
OnAssetsBuilding(*config.Config) error
}
type noopHooks struct{}
func (noopHooks) RegisterRoutes(_ *http.ServeMux) {}
func (noopHooks) OnServerStarted(_ string) error { return nil }
func (noopHooks) OnRoutesAssigned(_ *content.SiteGraph) error { return nil }
func (noopHooks) OnAssetsBuilding(_ *config.Config) error { return nil }
// Server serves Foundry preview output, rebuilds on change, and exposes preview
// routes such as live reload and diagnostics.
type Server struct {
cfg *config.Config
loader Loader
router *router.Resolver
renderer *renderer.Renderer
hooks Hooks
preview bool
debug bool
activeReqs atomic.Int64
connMu sync.Mutex
connStates map[net.Conn]http.ConnState
mu sync.RWMutex
graph *content.SiteGraph
depGraph *deps.Graph
reloadSignal chan struct{}
reloadVer atomic.Uint64
}
// Option mutates preview-server construction behavior.
type Option func(*Server)
var requestSequence atomic.Uint64
const slowServeRequestInterval = 2 * time.Second
const debugHeartbeatInterval = 2 * time.Second
type runtimeSnapshot struct {
HeapAllocBytes uint64
HeapInuseBytes uint64
StackInuseBytes uint64
SysBytes uint64
NumGC uint32
Goroutines int
ActiveRequests int64
ProcessUserCPU time.Duration
ProcessSystemCPU time.Duration
}
// WithDebugMode enables additional debug logging and request instrumentation for
// the preview server.
func WithDebugMode(enabled bool) Option {
return func(s *Server) {
s.debug = enabled
}
}
// New constructs a preview server for the current configuration, graph loader,
// resolver, and renderer.
func New(
cfg *config.Config,
loader Loader,
router *router.Resolver,
renderer *renderer.Renderer,
hooks Hooks,
preview bool,
opts ...Option,
) *Server {
if hooks == nil {
hooks = noopHooks{}
}
s := &Server{
cfg: cfg,
loader: loader,
router: router,
renderer: renderer,
hooks: hooks,
preview: preview,
connStates: make(map[net.Conn]http.ConnState),
reloadSignal: make(chan struct{}, 1),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
return s
}
// ListenAndServe performs the initial rebuild, starts file watching, and serves
// preview traffic until ctx is canceled or the HTTP server exits.
func (s *Server) ListenAndServe(ctx context.Context) error {
if err := s.rebuild(ctx); err != nil {
return err
}
go s.watch(ctx)
serverURL := s.listenURL()
logx.Info(
"Foundry is running",
"url", serverURL,
"theme", s.cfg.Theme,
"preview", s.preview,
"live_reload", s.cfg.Server.LiveReload,
)
if err := s.hooks.OnServerStarted(serverURL); err != nil {
return diag.Wrap(diag.KindPlugin, "run server started hooks", err)
}
if s.cfg.Server.AutoOpenBrowser {
go s.openBrowserAfterStartup(serverURL)
}
if s.debug {
go s.debugHeartbeat(ctx)
}
srv := s.newHTTPServer(s.newMux())
go s.shutdownOnContextDone(ctx, srv)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return diag.Wrap(diag.KindServe, "listen and serve", err)
}
return nil
}
// newMux builds the HTTP route tree for preview mode.
func (s *Server) newMux() *http.ServeMux {
mux := http.NewServeMux()
if s.cfg.Server.LiveReload {
mux.HandleFunc("/__reload", s.handleReload)
mux.HandleFunc("/__reload/poll", s.handleReloadPoll)
}
if s.cfg.Server.DebugRoutes {
mux.HandleFunc("/__debug/deps", s.handleDepsDebug)
}
mux.HandleFunc(s.cfg.Feed.RSSPath, s.handleRSS)
mux.HandleFunc(s.cfg.Feed.SitemapPath, s.handleSitemap)
mux.Handle("/assets/", s.publicStaticHandler(false))
mux.Handle("/images/", s.publicStaticHandler(true))
mux.Handle("/videos/", s.publicStaticHandler(true))
mux.Handle("/audio/", s.publicStaticHandler(true))
mux.Handle("/documents/", s.publicStaticHandler(true))
mux.Handle("/uploads/", s.publicStaticHandler(true))
mux.Handle("/theme/", s.publicStaticHandler(false))
mux.Handle("/plugins/", s.publicStaticHandler(false))
s.hooks.RegisterRoutes(mux)
mux.HandleFunc("/", s.handlePage)
if s.debug {
return s.wrapDebugHTTP(mux)
}
return mux
}
// publicStaticHandler serves files from the generated public directory with
// conservative headers for user-managed media collections.
func (s *Server) publicStaticHandler(mediaCollection bool) http.Handler {
base := http.StripPrefix("/", http.FileServer(http.Dir(s.cfg.PublicDir)))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
if mediaCollection && media.ShouldForceDownload(r.URL.Path) {
w.Header().Set("Content-Disposition", "attachment")
}
base.ServeHTTP(w, r)
})
}
// newHTTPServer applies Foundry's timeout defaults to the underlying HTTP
// server.
func (s *Server) newHTTPServer(handler http.Handler) *http.Server {
srv := &http.Server{
Addr: s.cfg.Server.Addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
if s.debug {
srv.ConnState = s.onConnState
}
return srv
}
func (s *Server) shutdownOnContextDone(ctx context.Context, srv *http.Server) {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}
func (s *Server) openBrowserAfterStartup(serverURL string) {
time.Sleep(250 * time.Millisecond)
if err := openBrowser(serverURL); err != nil {
logx.Warn("open browser failed", "url", serverURL, "error", err)
}
}
func (s *Server) listenURL() string {
addr := s.cfg.Server.Addr
switch {
case strings.HasPrefix(addr, ":"):
return "http://localhost" + addr
case strings.HasPrefix(addr, "127.0.0.1:"), strings.HasPrefix(addr, "localhost:"):
return "http://" + addr
case strings.HasPrefix(addr, "http://"), strings.HasPrefix(addr, "https://"):
return addr
default:
return "http://" + addr
}
}
func (s *Server) rebuild(ctx context.Context) error {
start := time.Now()
before := s.captureRuntimeSnapshot()
if s.debug {
logx.Info("serve rebuild started", append([]any{"preview", s.preview}, before.logFields("runtime_")...)...)
}
prepared, err := ops.LoadPreparedGraph(ctx, s.loader, s.router, s.hooks, s.cfg.Theme)
if err != nil {
return err
}
if err := ops.SyncAssets(s.cfg, s.hooks); err != nil {
return err
}
s.updatePreparedGraph(prepared)
logx.Info("site rebuilt", "documents", len(prepared.Graph.Documents), "routes", len(prepared.Graph.ByURL))
if s.debug {
after := s.captureRuntimeSnapshot()
args := []any{"elapsed", time.Since(start).String()}
args = append(args, after.logFields("runtime_")...)
args = append(args, after.deltaFields(before, time.Since(start), "delta_")...)
logx.Info("serve rebuild finished", args...)
}
s.signalReload()
return nil
}
func (s *Server) incrementalRebuild(ctx context.Context, changes deps.ChangeSet) error {
start := time.Now()
before := s.captureRuntimeSnapshot()
if s.debug {
args := []any{
"full", changes.Full,
"sources", len(changes.Sources),
"templates", len(changes.Templates),
"data_keys", len(changes.DataKeys),
"assets", len(changes.Assets),
}
args = append(args, before.logFields("runtime_")...)
logx.Info("serve incremental rebuild started", args...)
}
oldDepGraph := s.currentDepGraph()
if len(changes.Assets) > 0 {
if err := ops.SyncAssets(s.cfg, s.hooks); err != nil {
return err
}
}
if oldDepGraph == nil || changes.Full {
logx.Debug("performing full rebuild")
return s.rebuild(ctx)
}
if !hasRenderableChanges(changes) {
logx.Debug("asset-only change detected", "asset_count", len(changes.Assets))
s.signalReload()
return nil
}
plan := deps.ResolveRebuildPlan(oldDepGraph, changes)
if plan.FullRebuild {
logx.Debug("dependency plan requested full rebuild")
return s.rebuild(ctx)
}
prepared, err := ops.LoadPreparedGraph(ctx, s.loader, s.router, s.hooks, s.cfg.Theme)
if err != nil {
return err
}
if len(plan.OutputURLs) > 0 {
if err := s.renderer.BuildURLs(ctx, prepared.Graph, plan.OutputURLs); err != nil {
return diag.Wrap(diag.KindRender, "build urls", err)
}
}
s.updatePreparedGraph(prepared)
logx.Debug("incremental rebuild complete", "output_count", len(plan.OutputURLs))
if s.debug {
after := s.captureRuntimeSnapshot()
args := []any{
"outputs", len(plan.OutputURLs),
"elapsed", time.Since(start).String(),
}
args = append(args, after.logFields("runtime_")...)
args = append(args, after.deltaFields(before, time.Since(start), "delta_")...)
logx.Info("serve incremental rebuild finished", args...)
}
s.signalReload()
return nil
}
func (s *Server) updatePreparedGraph(prepared *ops.PreparedGraph) {
if prepared == nil || prepared.Graph == nil {
return
}
s.mu.Lock()
s.graph = prepared.Graph
s.depGraph = prepared.DepGraph
s.mu.Unlock()
}
func (s *Server) currentDepGraph() *deps.Graph {
s.mu.RLock()
defer s.mu.RUnlock()
return s.depGraph
}
func hasRenderableChanges(changes deps.ChangeSet) bool {
// TODO this needs to be revisited, feels flaky
return changes.Full ||
len(changes.Sources) > 0 ||
len(changes.Templates) > 0 ||
len(changes.DataKeys) > 0
}
func (s *Server) signalReload() {
s.reloadVer.Add(1)
select {
case s.reloadSignal <- struct{}{}:
default:
}
}
func (s *Server) watch(ctx context.Context) {
w, err := content.NewWatcher()
if err != nil {
logx.Warn("watcher setup failed", "error", err)
return
}
defer func(w *fsnotify.Watcher) {
if err := w.Close(); err != nil {
logx.Warn("close watcher failed", "error", err)
}
}(w)
s.walkWatchRoots(w)
autoBackup := backup.NewAutoRunner(s.cfg)
if autoBackup != nil {
defer autoBackup.Stop()
}
var changedPaths []string
var debounce <-chan time.Time
for {
select {
case <-ctx.Done():
return
case ev := <-w.Events:
if ev.Op != 0 {
if backup.PathIsUnderBackupRoot(s.cfg, ev.Name) {
continue
}
if shouldAddWatch(ev.Name) {
_ = addWatchRecursively(w, ev.Name)
}
if autoBackup != nil {
autoBackup.Notify(ev.Name)
}
changedPaths = append(changedPaths, ev.Name)
debounce = time.After(250 * time.Millisecond)
}
case <-debounce:
if len(changedPaths) == 0 {
continue
}
changeSet := s.classifyChanges(changedPaths)
if err := s.incrementalRebuild(ctx, changeSet); err != nil {
logx.Error("incremental rebuild failed", "kind", diag.KindOf(err), "error", err)
}
changedPaths = nil
debounce = nil
case err := <-w.Errors:
if err != nil {
logx.Warn("watcher error", "error", err)
}
}
}
}
func (s *Server) watchRoots() []string {
return []string{
s.cfg.ContentDir,
s.cfg.ThemesDir,
s.cfg.DataDir,
s.cfg.PluginsDir,
filepath.Join(s.cfg.ContentDir, "config"),
}
}
type watcherAdder interface {
Add(string) error
}
func (s *Server) walkWatchRoots(w watcherAdder) {
for _, root := range s.watchRoots() {
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err == nil && info.IsDir() {
_ = w.Add(path)
}
return nil
})
}
}
func shouldAddWatch(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}
func addWatchRecursively(w watcherAdder, root string) error {
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err == nil && info.IsDir() {
_ = w.Add(path)
}
return nil
})
}
func (s *Server) classifyChanges(paths []string) deps.ChangeSet {
changes := deps.ChangeSet{
Sources: make([]string, 0),
Templates: make([]string, 0),
DataKeys: make([]string, 0),
Assets: make([]string, 0),
}
roots := s.changeRoots()
for _, path := range paths {
clean := filepath.ToSlash(path)
switch {
case clean == roots["config"]:
changes.Full = true
case strings.HasPrefix(clean, roots["pages"]+"/"), strings.HasPrefix(clean, roots["posts"]+"/"):
changes.Sources = append(changes.Sources, clean)
case strings.HasPrefix(clean, roots["theme_layouts"]+"/"):
changes.Templates = append(changes.Templates, clean)
case strings.HasPrefix(clean, roots["content_assets"]+"/"),
strings.HasPrefix(clean, roots["content_images"]+"/"),
strings.HasPrefix(clean, roots["content_videos"]+"/"),
strings.HasPrefix(clean, roots["content_audio"]+"/"),
strings.HasPrefix(clean, roots["content_documents"]+"/"),
strings.HasPrefix(clean, roots["content_uploads"]+"/"),
strings.HasPrefix(clean, roots["theme_assets"]+"/"):
changes.Assets = append(changes.Assets, clean)
case strings.HasPrefix(clean, roots["data"]+"/"):
key, err := s.classifyDataKey(path)
if err != nil {
changes.Full = true
continue
}
changes.DataKeys = append(changes.DataKeys, key)
case strings.HasPrefix(clean, roots["plugins"]+"/"):
changes.Full = true
case strings.HasPrefix(clean, roots["themes"]+"/"), strings.HasPrefix(clean, roots["content"]+"/"):
changes.Full = true
default:
changes.Full = true
}
}
return changes
}
func (s *Server) changeRoots() map[string]string {
// TODO I'm lazy but this hsould be abstracted I just don't feel like it right now
return map[string]string{
"config": filepath.ToSlash(s.contentConfigPath()),
"pages": filepath.ToSlash(filepath.Join(s.cfg.ContentDir, s.cfg.Content.PagesDir)),
"posts": filepath.ToSlash(filepath.Join(s.cfg.ContentDir, s.cfg.Content.PostsDir)),
"content_assets": filepath.ToSlash(filepath.Join(s.cfg.ContentDir, s.cfg.Content.AssetsDir)),
"content_images": filepath.ToSlash(filepath.Join(s.cfg.ContentDir, s.cfg.Content.ImagesDir)),
"content_videos": filepath.ToSlash(filepath.Join(s.cfg.ContentDir, s.cfg.Content.VideoDir)),
"content_audio": filepath.ToSlash(filepath.Join(s.cfg.ContentDir, s.cfg.Content.AudioDir)),
"content_documents": filepath.ToSlash(filepath.Join(s.cfg.ContentDir, s.cfg.Content.DocumentsDir)),
"content_uploads": filepath.ToSlash(filepath.Join(s.cfg.ContentDir, s.cfg.Content.UploadsDir)),
"theme_assets": filepath.ToSlash(filepath.Join(s.cfg.ThemesDir, s.cfg.Theme, "assets")),
"theme_layouts": filepath.ToSlash(filepath.Join(s.cfg.ThemesDir, s.cfg.Theme, "layouts")),
"data": filepath.ToSlash(s.cfg.DataDir),
"plugins": filepath.ToSlash(s.cfg.PluginsDir),
"themes": filepath.ToSlash(s.cfg.ThemesDir),
"content": filepath.ToSlash(s.cfg.ContentDir),
}
}
func (s *Server) contentConfigPath() string {
return filepath.Join(s.cfg.ContentDir, "config", "site.yaml")
}
func (s *Server) classifyDataKey(path string) (string, error) {
rel, err := filepath.Rel(s.cfg.DataDir, path)
if err != nil {
return "", err
}
return strings.TrimSuffix(filepath.ToSlash(rel), filepath.Ext(rel)), nil
}
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
lastSeen := s.reloadVer.Load()
notify := r.Context().Done()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-notify:
return
case <-s.reloadSignal:
if s.writeReloadEvent(w, flusher, &lastSeen) {
return
}
case <-ticker.C:
if s.writeReloadEvent(w, flusher, &lastSeen) {
return
}
}
}
}
func (s *Server) handleReloadPoll(w http.ResponseWriter, r *http.Request) {
current := s.reloadVer.Load()
since := parseReloadVersion(r.URL.Query().Get("since"))
payload := map[string]any{
"version": current,
"reload": since > 0 && current > since,
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_ = json.NewEncoder(w).Encode(payload)
}
func (s *Server) writeReloadEvent(w http.ResponseWriter, flusher http.Flusher, lastSeen *uint64) bool {
current := s.reloadVer.Load()
if current <= *lastSeen {
return false
}
*lastSeen = current
if _, err := fmt.Fprintf(w, "data: %s\n\n", `{"reload":true}`); err != nil {
return true
}
flusher.Flush()
return false
}
func parseReloadVersion(raw string) uint64 {
if strings.TrimSpace(raw) == "" {
return 0
}
value, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 64)
if err != nil {
return 0
}
return value
}
func (s *Server) handlePage(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
graph := s.graph
s.mu.RUnlock()
path := r.URL.Path
if !strings.HasSuffix(path, "/") && !strings.Contains(filepath.Base(path), ".") {
path += "/"
}
_, finishDebug := s.beginDebugRequest(r, path)
out, err := s.renderer.RenderURLWithQuery(graph, path, r.URL.RawQuery, s.cfg.Server.LiveReload)
finishDebug(err, len(out))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
notFound, nfErr := s.renderer.RenderNotFoundPage(graph, path, s.cfg.Server.LiveReload)
if nfErr == nil {
w.Header().Set("Content-Security-Policy", s.renderer.ContentSecurityPolicy())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write(notFound)
return
}
http.NotFound(w, r)
return
}
logx.Error("render page failed", "path", path, "error", err)
b, _ := json.Marshal(map[string]string{"error": err.Error()})
http.Error(w, string(b), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Security-Policy", s.renderer.ContentSecurityPolicy())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(out)
}
func (s *Server) wrapDebugHTTP(next http.Handler) *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := requestSequence.Add(1)
start := time.Now()
before := s.captureRuntimeSnapshot()
args := []any{
"http_request_id", reqID,
"method", r.Method,
"path", r.URL.Path,
"query", r.URL.RawQuery,
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
}
args = append(args, before.logFields("runtime_")...)
args = append(args, s.connectionStateFields("connections_")...)
logx.Info("http request started", args...)
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
after := s.captureRuntimeSnapshot()
finishArgs := []any{
"http_request_id", reqID,
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"elapsed", time.Since(start).String(),
}
finishArgs = append(finishArgs, after.logFields("runtime_")...)
finishArgs = append(finishArgs, after.deltaFields(before, time.Since(start), "delta_")...)
finishArgs = append(finishArgs, s.connectionStateFields("connections_")...)
logx.Info("http request finished", finishArgs...)
}))
return mux
}
func (s *Server) beginDebugRequest(r *http.Request, path string) (uint64, func(error, int)) {
if !s.debug {
return 0, func(error, int) {}
}
reqID := requestSequence.Add(1)
start := time.Now()
s.activeReqs.Add(1)
before := s.captureRuntimeSnapshot()
done := make(chan struct{})
args := []any{
"request_id", reqID,
"method", r.Method,
"path", path,
"remote_addr", r.RemoteAddr,
}
args = append(args, before.logFields("runtime_")...)
args = append(args, s.connectionStateFields("connections_")...)
logx.Info("serve request started", args...)
go func() {
ticker := time.NewTicker(slowServeRequestInterval)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
snapshot := s.captureRuntimeSnapshot()
args := []any{
"request_id", reqID,
"path", path,
"elapsed", time.Since(start).String(),
}
args = append(args, snapshot.logFields("runtime_")...)
args = append(args, snapshot.deltaFields(before, time.Since(start), "delta_")...)
args = append(args, s.connectionStateFields("connections_")...)
logx.Warn("serve request still rendering", args...)
}
}
}()
return reqID, func(err error, bytes int) {
close(done)
s.activeReqs.Add(-1)
after := s.captureRuntimeSnapshot()
elapsed := time.Since(start)
args := []any{
"request_id", reqID,
"path", path,
"elapsed", elapsed.String(),
"bytes", bytes,
}
args = append(args, after.logFields("runtime_")...)
args = append(args, after.deltaFields(before, elapsed, "delta_")...)
args = append(args, s.connectionStateFields("connections_")...)
if err != nil {
args = append(args, "error", err)
}
logx.Info("serve request finished", args...)
}
}
func (s *Server) debugHeartbeat(ctx context.Context) {
ticker := time.NewTicker(debugHeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
snapshot := s.captureRuntimeSnapshot()
args := snapshot.logFields("runtime_")
args = append(args, s.connectionStateFields("connections_")...)
logx.Info("serve debug heartbeat", args...)
}
}
}
func (s *Server) onConnState(conn net.Conn, state http.ConnState) {
s.connMu.Lock()
prev, hadPrev := s.connStates[conn]
if state == http.StateClosed || state == http.StateHijacked {
delete(s.connStates, conn)
} else {
s.connStates[conn] = state
}
snapshot := s.connectionStateSnapshotLocked()
s.connMu.Unlock()
args := []any{
"remote_addr", conn.RemoteAddr().String(),
"state", state.String(),
}
if hadPrev {
args = append(args, "previous_state", prev.String())
}
args = append(args, snapshot.logFields("connections_")...)
logx.Info("http connection state", args...)
}
type connectionSnapshot struct {
New int
Active int
Idle int
Hijacked int
Closed int
Total int
}
func (s *Server) connectionStateFields(prefix string) []any {
s.connMu.Lock()
snapshot := s.connectionStateSnapshotLocked()
s.connMu.Unlock()
return snapshot.logFields(prefix)
}
func (s *Server) connectionStateSnapshotLocked() connectionSnapshot {
var snapshot connectionSnapshot
for _, state := range s.connStates {
switch state {
case http.StateNew:
snapshot.New++
case http.StateActive:
snapshot.Active++
case http.StateIdle:
snapshot.Idle++
case http.StateHijacked:
snapshot.Hijacked++
case http.StateClosed:
snapshot.Closed++
}
}
snapshot.Total = len(s.connStates)
return snapshot
}
func (s connectionSnapshot) logFields(prefix string) []any {
return []any{
prefix + "new", s.New,
prefix + "active", s.Active,
prefix + "idle", s.Idle,
prefix + "hijacked", s.Hijacked,
prefix + "closed", s.Closed,
prefix + "total", s.Total,
}
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
func (r *statusRecorder) Flush() {
if flusher, ok := r.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := r.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("response writer does not support hijacking")
}
return hijacker.Hijack()
}
func (r *statusRecorder) Push(target string, opts *http.PushOptions) error {
if pusher, ok := r.ResponseWriter.(http.Pusher); ok {
return pusher.Push(target, opts)
}
return http.ErrNotSupported
}
func (s *Server) captureRuntimeSnapshot() runtimeSnapshot {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
userCPU, systemCPU := processCPUTime()
return runtimeSnapshot{
HeapAllocBytes: mem.HeapAlloc,
HeapInuseBytes: mem.HeapInuse,
StackInuseBytes: mem.StackInuse,
SysBytes: mem.Sys,
NumGC: mem.NumGC,
Goroutines: runtime.NumGoroutine(),
ActiveRequests: s.activeReqs.Load(),
ProcessUserCPU: userCPU,
ProcessSystemCPU: systemCPU,
}
}
func (s runtimeSnapshot) logFields(prefix string) []any {
return []any{
prefix + "heap_alloc_bytes", s.HeapAllocBytes,
prefix + "heap_inuse_bytes", s.HeapInuseBytes,
prefix + "stack_inuse_bytes", s.StackInuseBytes,
prefix + "sys_bytes", s.SysBytes,
prefix + "num_gc", s.NumGC,
prefix + "goroutines", s.Goroutines,
prefix + "active_requests", s.ActiveRequests,
prefix + "process_user_cpu_ms", s.ProcessUserCPU.Milliseconds(),
prefix + "process_system_cpu_ms", s.ProcessSystemCPU.Milliseconds(),
}
}
func (s runtimeSnapshot) deltaFields(before runtimeSnapshot, elapsed time.Duration, prefix string) []any {
userDelta := s.ProcessUserCPU - before.ProcessUserCPU
systemDelta := s.ProcessSystemCPU - before.ProcessSystemCPU
totalCPUPercent := 0.0
if elapsed > 0 {
totalCPUPercent = (float64(userDelta+systemDelta) / float64(elapsed)) * 100
}
return []any{
prefix + "heap_alloc_bytes", int64(s.HeapAllocBytes) - int64(before.HeapAllocBytes),
prefix + "heap_inuse_bytes", int64(s.HeapInuseBytes) - int64(before.HeapInuseBytes),
prefix + "stack_inuse_bytes", int64(s.StackInuseBytes) - int64(before.StackInuseBytes),
prefix + "sys_bytes", int64(s.SysBytes) - int64(before.SysBytes),
prefix + "num_gc", int64(s.NumGC) - int64(before.NumGC),
prefix + "goroutines", s.Goroutines - before.Goroutines,
prefix + "active_requests", s.ActiveRequests - before.ActiveRequests,
prefix + "process_user_cpu_ms", userDelta.Milliseconds(),
prefix + "process_system_cpu_ms", systemDelta.Milliseconds(),
prefix + "process_cpu_percent", fmt.Sprintf("%.2f", totalCPUPercent),
}
}
package site
import (
"context"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/diag"
"github.com/sphireinc/foundry/internal/router"
)
type Loader interface {
Load(context.Context) (*content.SiteGraph, error)
}
type RouteHooks interface {
OnRoutesAssigned(*content.SiteGraph) error
}
func LoadGraph(ctx context.Context, loader Loader, resolver *router.Resolver, hooks RouteHooks) (*content.SiteGraph, error) {
if loader == nil {
return nil, diag.New(diag.KindInternal, "loader is nil")
}
if resolver == nil {
return nil, diag.New(diag.KindInternal, "resolver is nil")
}
graph, err := loader.Load(ctx)
if err != nil {
return nil, diag.Wrap(diag.KindBuild, "load site graph", err)
}
if err := resolver.AssignURLs(graph); err != nil {
return nil, diag.Wrap(diag.KindBuild, "assign urls", err)
}
if hooks != nil {
if err := hooks.OnRoutesAssigned(graph); err != nil {
return nil, diag.Wrap(diag.KindPlugin, "run route hooks", err)
}
}
return graph, nil
}
package site
import (
"context"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/diag"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/router"
)
func NewPluginManager(cfg *config.Config) (*plugins.Manager, error) {
pm, err := plugins.NewManager(cfg.PluginsDir, cfg.Plugins.Enabled)
if err != nil {
return nil, diag.Wrap(diag.KindPlugin, "load plugins", err)
}
if err := pm.OnConfigLoaded(cfg); err != nil {
return nil, diag.Wrap(diag.KindPlugin, "run plugin config hooks", err)
}
return pm, nil
}
func LoadGraphWithManager(ctx context.Context, cfg *config.Config, pm *plugins.Manager, includeDrafts bool) (*content.SiteGraph, error) {
if cfg == nil {
return nil, diag.New(diag.KindInternal, "config is nil")
}
if pm == nil {
return nil, diag.New(diag.KindInternal, "plugin manager is nil")
}
loader := content.NewLoader(cfg, pm, includeDrafts)
resolver := router.NewResolver(cfg)
graph, err := LoadGraph(ctx, loader, resolver, pm)
if err != nil {
return nil, err
}
return graph, nil
}
func LoadConfiguredGraph(ctx context.Context, cfg *config.Config, includeDrafts bool) (*content.SiteGraph, *plugins.Manager, error) {
pm, err := NewPluginManager(cfg)
if err != nil {
return nil, nil, err
}
graph, err := LoadGraphWithManager(ctx, cfg, pm, includeDrafts)
if err != nil {
return nil, nil, err
}
return graph, pm, nil
}
//go:build darwin || linux
package standalone
import (
"os/exec"
"syscall"
)
func detachProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
func IsProcessAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil
}
func sendTerminate(pid int) error {
return syscall.Kill(pid, syscall.SIGTERM)
}
func sendKill(pid int) error {
return syscall.Kill(pid, syscall.SIGKILL)
}
package standalone
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const (
RunDirName = ".foundry/run"
StateFile = "standalone.json"
LogFile = "standalone.log"
ManagedBin = "foundry-standalone"
defaultLines = 120
)
var buildStandaloneBinary = func(projectDir, target string) error {
cmd := exec.Command("go", "build", "-o", target, "./cmd/foundry")
cmd.Dir = projectDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Env = append(os.Environ(),
"CGO_ENABLED=0",
"GOOS="+runtime.GOOS,
"GOARCH="+runtime.GOARCH,
)
return cmd.Run()
}
type State struct {
PID int `json:"pid"`
StartedAt time.Time `json:"started_at"`
ProjectDir string `json:"project_dir"`
LogPath string `json:"log_path"`
Command []string `json:"command"`
}
type Paths struct {
RunDir string
StatePath string
LogPath string
}
func ProjectPaths(projectDir string) Paths {
runDir := filepath.Join(projectDir, RunDirName)
return Paths{
RunDir: runDir,
StatePath: filepath.Join(runDir, StateFile),
LogPath: filepath.Join(runDir, LogFile),
}
}
func EnsureRunDir(projectDir string) (Paths, error) {
paths := ProjectPaths(projectDir)
if err := os.MkdirAll(paths.RunDir, 0o755); err != nil {
return Paths{}, err
}
return paths, nil
}
func LoadState(projectDir string) (*State, error) {
paths := ProjectPaths(projectDir)
body, err := os.ReadFile(paths.StatePath)
if err != nil {
return nil, err
}
var state State
if err := json.Unmarshal(body, &state); err != nil {
return nil, err
}
return &state, nil
}
func SaveState(projectDir string, state State) error {
paths, err := EnsureRunDir(projectDir)
if err != nil {
return err
}
body, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return os.WriteFile(paths.StatePath, body, 0o644)
}
func RemoveState(projectDir string) error {
paths := ProjectPaths(projectDir)
if err := os.Remove(paths.StatePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func RunningState(projectDir string) (*State, bool, error) {
state, err := LoadState(projectDir)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
if state.PID > 0 && IsProcessAlive(state.PID) {
return state, true, nil
}
return state, false, nil
}
func Start(projectDir string, rawArgs []string) (*State, error) {
if _, running, err := RunningState(projectDir); err != nil {
return nil, err
} else if running {
return nil, fmt.Errorf("Foundry standalone server is already running")
}
command, err := LaunchCommand(projectDir, rawArgs)
if err != nil {
return nil, err
}
return startWithCommand(projectDir, command)
}
func startWithCommand(projectDir string, command []string) (state *State, retErr error) {
if _, running, err := RunningState(projectDir); err != nil {
return nil, err
} else if running {
return nil, fmt.Errorf("Foundry standalone server is already running")
}
paths, err := EnsureRunDir(projectDir)
if err != nil {
return nil, err
}
_ = RemoveState(projectDir)
if len(command) == 0 {
return nil, fmt.Errorf("could not construct standalone launch command")
}
logFile, err := os.OpenFile(paths.LogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, err
}
defer func() {
if err := logFile.Close(); err != nil && retErr == nil {
retErr = err
}
}()
cmd := exec.Command(command[0], command[1:]...)
cmd.Dir = projectDir
cmd.Stdout = logFile
cmd.Stderr = logFile
devNull, err := os.Open(os.DevNull)
if err == nil {
defer devNull.Close()
cmd.Stdin = devNull
}
detachProcess(cmd)
if err := cmd.Start(); err != nil {
return nil, err
}
state = &State{
PID: cmd.Process.Pid,
StartedAt: time.Now().UTC(),
ProjectDir: projectDir,
LogPath: paths.LogPath,
Command: append([]string(nil), command...),
}
if err := SaveState(projectDir, *state); err != nil {
return nil, err
}
return state, nil
}
func Stop(projectDir string) error {
state, running, err := RunningState(projectDir)
if err != nil {
return err
}
if state == nil || !running {
_ = RemoveState(projectDir)
return fmt.Errorf("Foundry standalone server is not running")
}
if err := sendTerminate(state.PID); err != nil {
return err
}
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if !IsProcessAlive(state.PID) {
_ = RemoveState(projectDir)
return nil
}
time.Sleep(200 * time.Millisecond)
}
if err := sendKill(state.PID); err != nil {
return err
}
_ = RemoveState(projectDir)
return nil
}
func Restart(projectDir string, rawArgs []string) (*State, error) {
state, running, err := RunningState(projectDir)
if err != nil {
return nil, err
}
command := []string(nil)
if state != nil && len(state.Command) > 0 {
command = append([]string(nil), state.Command...)
}
if state != nil && running {
if err := Stop(projectDir); err != nil {
return nil, err
}
}
if len(command) > 0 {
return startWithCommand(projectDir, command)
}
return Start(projectDir, rawArgs)
}
func LaunchCommand(projectDir string, rawArgs []string) ([]string, error) {
exe, err := os.Executable()
if err != nil {
return nil, err
}
if shouldUseGoRun(exe, projectDir) {
managedBinary, err := ensureManagedBinary(projectDir)
if err != nil {
return nil, err
}
raw := append([]string(nil), rawArgs[1:]...)
replaced := false
for i, arg := range raw {
if strings.TrimSpace(arg) == "serve-standalone" {
raw[i] = "serve"
replaced = true
break
}
}
if !replaced {
raw = append([]string{"serve"}, raw...)
}
return append([]string{managedBinary}, raw...), nil
}
args := append([]string(nil), rawArgs[1:]...)
replaced := false
for i, arg := range args {
if strings.TrimSpace(arg) == "serve-standalone" {
args[i] = "serve"
replaced = true
break
}
}
if !replaced {
args = []string{"serve"}
}
return append([]string{exe}, args...), nil
}
func ensureManagedBinary(projectDir string) (string, error) {
paths, err := EnsureRunDir(projectDir)
if err != nil {
return "", err
}
name := ManagedBin
if runtime.GOOS == "windows" {
name += ".exe"
}
target := filepath.Join(paths.RunDir, name)
if _, err := exec.LookPath("go"); err != nil {
return "", fmt.Errorf("foundry was launched via go run but go is not available in PATH")
}
if err := buildStandaloneBinary(projectDir, target); err != nil {
return "", fmt.Errorf("build managed standalone binary: %w", err)
}
return target, nil
}
func shouldUseGoRun(executablePath, projectDir string) bool {
exe := filepath.Clean(executablePath)
tmp := filepath.Clean(os.TempDir())
if strings.Contains(exe, string(filepath.Separator)+"go-build"+string(filepath.Separator)) {
return fileExists(filepath.Join(projectDir, "cmd", "foundry", "main.go"))
}
if strings.HasPrefix(exe, tmp+string(filepath.Separator)) && fileExists(filepath.Join(projectDir, "cmd", "foundry", "main.go")) {
return true
}
return false
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func ReadLastLines(path string, lines int) (string, error) {
if lines <= 0 {
lines = defaultLines
}
body, err := os.ReadFile(path)
if err != nil {
return "", err
}
all := strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n")
if len(all) > 0 && all[len(all)-1] == "" {
all = all[:len(all)-1]
}
if len(all) > lines {
all = all[len(all)-lines:]
}
return strings.Join(all, "\n"), nil
}
func FollowLog(path string, out io.Writer) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Seek(0, io.SeekEnd); err != nil {
return err
}
reader := bufio.NewReader(f)
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
if _, werr := io.WriteString(out, line); werr != nil {
return werr
}
}
if err == nil {
continue
}
if err != io.EOF {
return err
}
time.Sleep(500 * time.Millisecond)
}
}
package taxonomy
import (
"sort"
"strings"
)
type Entry struct {
DocumentID string
URL string
Lang string
Type string
Title string
Slug string
}
type Definition struct {
Name string
Title string
Labels map[string]string
ArchiveLayout string
TermLayout string
Order string
}
type Index struct {
Values map[string]map[string][]Entry
Definitions map[string]Definition
}
func New(definitions map[string]Definition) *Index {
idx := &Index{
Values: make(map[string]map[string][]Entry),
Definitions: make(map[string]Definition),
}
for name, def := range definitions {
idx.Definitions[name] = normalizeDefinition(name, def)
}
return idx
}
func (i *Index) AddDocument(docID, url, lang, docType, title, slug string, taxonomies map[string][]string) {
for taxonomyName, terms := range taxonomies {
i.EnsureDefinition(taxonomyName)
if _, ok := i.Values[taxonomyName]; !ok {
i.Values[taxonomyName] = make(map[string][]Entry)
}
for _, term := range terms {
term = strings.TrimSpace(term)
if term == "" {
continue
}
i.Values[taxonomyName][term] = append(i.Values[taxonomyName][term], Entry{
DocumentID: docID,
URL: url,
Lang: lang,
Type: docType,
Title: title,
Slug: slug,
})
}
}
}
func (i *Index) EnsureDefinition(name string) {
if i.Definitions == nil {
i.Definitions = make(map[string]Definition)
}
if _, ok := i.Definitions[name]; ok {
return
}
i.Definitions[name] = normalizeDefinition(name, Definition{Name: name})
}
func (i *Index) Definition(name string) Definition {
if i.Definitions == nil {
return normalizeDefinition(name, Definition{Name: name})
}
if def, ok := i.Definitions[name]; ok {
return normalizeDefinition(name, def)
}
return normalizeDefinition(name, Definition{Name: name})
}
func (i *Index) OrderedNames() []string {
names := make([]string, 0, len(i.Values))
for name := range i.Values {
names = append(names, name)
}
sort.Slice(names, func(a, b int) bool {
defA := i.Definition(names[a])
defB := i.Definition(names[b])
titleA := strings.ToLower(defA.DisplayTitle(""))
titleB := strings.ToLower(defB.DisplayTitle(""))
if titleA != titleB {
return titleA < titleB
}
return names[a] < names[b]
})
return names
}
func (i *Index) OrderedTerms(name string) []string {
termsMap, ok := i.Values[name]
if !ok {
return nil
}
terms := make([]string, 0, len(termsMap))
for term := range termsMap {
terms = append(terms, term)
}
sort.Strings(terms)
return terms
}
func (d Definition) DisplayTitle(lang string) string {
if lang != "" && d.Labels != nil {
if label := strings.TrimSpace(d.Labels[lang]); label != "" {
return label
}
}
if title := strings.TrimSpace(d.Title); title != "" {
return title
}
if name := strings.TrimSpace(d.Name); name != "" {
return name
}
return "taxonomy"
}
func (d Definition) EffectiveTermLayout() string {
if strings.TrimSpace(d.TermLayout) != "" {
return d.TermLayout
}
if strings.TrimSpace(d.ArchiveLayout) != "" {
return d.ArchiveLayout
}
return "list"
}
func normalizeDefinition(name string, d Definition) Definition {
d.Name = strings.TrimSpace(name)
if strings.TrimSpace(d.Title) == "" {
d.Title = d.Name
}
if d.Labels == nil {
d.Labels = map[string]string{}
}
return d
}
package theme
import (
"strings"
foundryconfig "github.com/sphireinc/foundry/internal/config"
)
func normalizeFieldContractScope(scope string) string {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "", "document", "page":
return "document"
case "shared", "global":
return "shared"
default:
return strings.ToLower(strings.TrimSpace(scope))
}
}
func cloneFieldDefinitions(in []foundryconfig.FieldDefinition) []foundryconfig.FieldDefinition {
if len(in) == 0 {
return nil
}
out := make([]foundryconfig.FieldDefinition, 0, len(in))
for _, def := range in {
cloned := def
cloned.Enum = append([]string(nil), def.Enum...)
cloned.Fields = cloneFieldDefinitions(def.Fields)
if def.Item != nil {
item := *def.Item
item.Enum = append([]string(nil), def.Item.Enum...)
item.Fields = cloneFieldDefinitions(def.Item.Fields)
if def.Item.Item != nil {
nested := *def.Item.Item
nested.Enum = append([]string(nil), def.Item.Item.Enum...)
nested.Fields = cloneFieldDefinitions(def.Item.Item.Fields)
item.Item = &nested
}
cloned.Item = &item
}
out = append(out, cloned)
}
return out
}
func mergeFieldDefinitions(sets ...[]foundryconfig.FieldDefinition) []foundryconfig.FieldDefinition {
order := make([]string, 0)
merged := make(map[string]foundryconfig.FieldDefinition)
for _, set := range sets {
for _, def := range set {
name := strings.TrimSpace(def.Name)
if name == "" {
continue
}
if _, ok := merged[name]; !ok {
order = append(order, name)
}
merged[name] = def
}
}
out := make([]foundryconfig.FieldDefinition, 0, len(order))
for _, name := range order {
out = append(out, merged[name])
}
return out
}
func containsFold(values []string, target string) bool {
target = strings.TrimSpace(target)
for _, value := range values {
if strings.EqualFold(strings.TrimSpace(value), target) {
return true
}
}
return false
}
func contractMatchesDocument(contract FieldContract, docType, layout, slug string) bool {
if normalizeFieldContractScope(contract.Target.Scope) != "document" {
return false
}
if len(contract.Target.Types) > 0 && !containsFold(contract.Target.Types, docType) {
return false
}
if len(contract.Target.Layouts) > 0 && !containsFold(contract.Target.Layouts, layout) {
return false
}
if len(contract.Target.Slugs) > 0 && !containsFold(contract.Target.Slugs, slug) {
return false
}
return true
}
func ApplicableDocumentFieldContracts(manifest *Manifest, docType, layout, slug string) []FieldContract {
if manifest == nil {
return nil
}
out := make([]FieldContract, 0)
for _, contract := range manifest.FieldContracts {
if contractMatchesDocument(contract, docType, layout, slug) {
out = append(out, contract)
}
}
return out
}
func ApplicableDocumentFieldDefinitions(manifest *Manifest, docType, layout, slug string) []foundryconfig.FieldDefinition {
contracts := ApplicableDocumentFieldContracts(manifest, docType, layout, slug)
sets := make([][]foundryconfig.FieldDefinition, 0, len(contracts))
for _, contract := range contracts {
sets = append(sets, cloneFieldDefinitions(contract.Fields))
}
return mergeFieldDefinitions(sets...)
}
func SharedFieldContracts(manifest *Manifest) []FieldContract {
if manifest == nil {
return nil
}
out := make([]FieldContract, 0)
for _, contract := range manifest.FieldContracts {
if normalizeFieldContractScope(contract.Target.Scope) == "shared" {
out = append(out, contract)
}
}
return out
}
func DocumentFieldDefinitions(themesDir, themeName, docType, layout, slug string) []foundryconfig.FieldDefinition {
manifest, err := LoadManifest(themesDir, themeName)
if err != nil || manifest == nil {
return nil
}
return ApplicableDocumentFieldDefinitions(manifest, docType, layout, slug)
}
package theme
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
adminui "github.com/sphireinc/foundry/internal/admin/ui"
"github.com/sphireinc/foundry/internal/installutil"
"github.com/sphireinc/foundry/internal/safepath"
)
var themeDownloadClient = &http.Client{Timeout: 30 * time.Second}
const (
themeCloneTimeout = 2 * time.Minute
themeZipMaxBytes = 128 << 20
)
type InstallKind string
const (
InstallKindFrontend InstallKind = "frontend"
InstallKindAdmin InstallKind = "admin"
)
type InstallOptions struct {
ThemesDir string
URL string
Name string
Kind InstallKind
}
func Install(opts InstallOptions) (any, error) {
repoURL, err := validateInstallURL(opts.URL)
if err != nil {
return nil, err
}
if strings.TrimSpace(repoURL) == "" {
return nil, fmt.Errorf("theme URL cannot be empty")
}
themesDir := strings.TrimSpace(opts.ThemesDir)
if themesDir == "" {
return nil, fmt.Errorf("themes directory cannot be empty")
}
kind := opts.Kind
if kind == "" {
kind = InstallKindFrontend
}
if kind != InstallKindFrontend && kind != InstallKindAdmin {
return nil, fmt.Errorf("unsupported theme kind %q", kind)
}
name := strings.TrimSpace(opts.Name)
if name == "" {
name, err = inferThemeName(repoURL)
if err != nil {
return nil, err
}
}
name, err = validateThemeInstallName(name)
if err != nil {
return nil, err
}
targetRoot := themesDir
if kind == InstallKindAdmin {
targetRoot = filepath.Join(themesDir, "admin-themes")
}
targetDir := filepath.Join(targetRoot, name)
if _, err := os.Stat(targetDir); err == nil {
return nil, fmt.Errorf("theme directory already exists: %s", targetDir)
} else if !os.IsNotExist(err) {
return nil, err
}
if err := os.MkdirAll(targetRoot, 0o755); err != nil {
return nil, fmt.Errorf("create themes dir: %w", err)
}
cloneCtx, cancel := context.WithTimeout(context.Background(), themeCloneTimeout)
defer cancel()
cmd := exec.CommandContext(cloneCtx, "git", "clone", repoURL, targetDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("git clone failed because git not available, downloading repository archive instead")
if err := downloadAndExtractTheme(repoURL, targetDir); err != nil {
return nil, fmt.Errorf("git clone failed and zip fallback failed: %w", err)
}
}
if err := stripThemeVCSMetadata(targetDir); err != nil {
_ = os.RemoveAll(targetDir)
return nil, err
}
if kind == InstallKindAdmin {
meta, err := adminui.LoadManifest(themesDir, name)
if err != nil {
_ = os.RemoveAll(targetDir)
return nil, err
}
if strings.TrimSpace(meta.Name) != "" && meta.Name != name {
_ = os.RemoveAll(targetDir)
return nil, fmt.Errorf("admin theme manifest name %q does not match install directory %q", meta.Name, name)
}
return meta, nil
}
meta, err := LoadManifest(themesDir, name)
if err != nil {
_ = os.RemoveAll(targetDir)
return nil, err
}
if strings.TrimSpace(meta.Name) != "" && meta.Name != name {
_ = os.RemoveAll(targetDir)
return nil, fmt.Errorf("theme manifest name %q does not match install directory %q", meta.Name, name)
}
return meta, nil
}
func normalizeInstallURL(raw string) string {
return installutil.NormalizeGitHubInstallURL(raw)
}
func validateInstallURL(raw string) (string, error) {
return installutil.ValidateGitHubInstallURL("theme", raw, validateThemeInstallName)
}
func inferThemeName(repoURL string) (string, error) {
return installutil.InferRepoName(repoURL, "theme", validateThemeInstallName)
}
func validateThemeInstallName(name string) (string, error) {
return safepath.ValidatePathComponent("theme name", name)
}
func downloadAndExtractTheme(repoURL, targetDir string) error {
targetRoot := filepath.Dir(targetDir)
targetName := filepath.Base(targetDir)
return installutil.DownloadAndExtractRepoArchive(
themeDownloadClient,
repoURL,
targetRoot,
targetName,
"foundry-theme",
"theme",
themeZipMaxBytes,
)
}
func stripThemeVCSMetadata(targetDir string) error {
return installutil.StripVCSMetadata(filepath.Dir(targetDir), filepath.Base(targetDir))
}
package theme
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
foundryconfig "github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/safepath"
"gopkg.in/yaml.v3"
)
// Info identifies an installed frontend theme on disk.
type Info struct {
Name string
Path string
}
// Manifest is the contract Foundry reads from a frontend theme's theme.yaml.
//
// Theme authors should treat this as the supported manifest surface for
// declaring layout coverage, slot support, screenshots, and config schema.
type Manifest struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
Version string `yaml:"version"`
Description string `yaml:"description"`
Author string `yaml:"author"`
License string `yaml:"license"`
Repo string `yaml:"repo,omitempty"`
MinFoundryVersion string `yaml:"min_foundry_version"`
SDKVersion string `yaml:"sdk_version,omitempty"`
CompatibilityVersion string `yaml:"compatibility_version,omitempty"`
Layouts []string `yaml:"layouts"`
SupportedLayouts []string `yaml:"supported_layouts,omitempty"`
Slots []string `yaml:"slots"`
Screenshots []string `yaml:"screenshots,omitempty"`
ConfigSchema []foundryconfig.FieldDefinition `yaml:"config_schema,omitempty"`
FieldContracts []FieldContract `yaml:"field_contracts,omitempty"`
Security ThemeSecurity `yaml:"security,omitempty"`
}
type ThemeSecurity struct {
ExternalAssets ThemeExternalAssets `yaml:"external_assets,omitempty" json:"external_assets,omitempty"`
FrontendRequests ThemeFrontendRequests `yaml:"frontend_requests,omitempty" json:"frontend_requests,omitempty"`
TemplateContext ThemeTemplateContext `yaml:"template_context,omitempty" json:"template_context,omitempty"`
}
type ThemeExternalAssets struct {
Allowed bool `yaml:"allowed,omitempty" json:"allowed,omitempty"`
Scripts []string `yaml:"scripts,omitempty" json:"scripts,omitempty"`
Styles []string `yaml:"styles,omitempty" json:"styles,omitempty"`
Fonts []string `yaml:"fonts,omitempty" json:"fonts,omitempty"`
Images []string `yaml:"images,omitempty" json:"images,omitempty"`
Media []string `yaml:"media,omitempty" json:"media,omitempty"`
}
type ThemeFrontendRequests struct {
Allowed bool `yaml:"allowed,omitempty" json:"allowed,omitempty"`
Origins []string `yaml:"origins,omitempty" json:"origins,omitempty"`
Methods []string `yaml:"methods,omitempty" json:"methods,omitempty"`
}
type ThemeTemplateContext struct {
AllowSiteParams *bool `yaml:"allow_site_params,omitempty" json:"allow_site_params,omitempty"`
AllowContentFields *bool `yaml:"allow_content_fields,omitempty" json:"allow_content_fields,omitempty"`
AllowSharedFields *bool `yaml:"allow_shared_fields,omitempty" json:"allow_shared_fields,omitempty"`
AllowRuntimeState *bool `yaml:"allow_runtime_state,omitempty" json:"allow_runtime_state,omitempty"`
AllowAdminState *bool `yaml:"allow_admin_state,omitempty" json:"allow_admin_state,omitempty"`
AllowRawConfig *bool `yaml:"allow_raw_config,omitempty" json:"allow_raw_config,omitempty"`
}
type FieldContract struct {
Key string `yaml:"key"`
Title string `yaml:"title,omitempty"`
Description string `yaml:"description,omitempty"`
Target FieldContractTarget `yaml:"target"`
Fields []foundryconfig.FieldDefinition `yaml:"fields,omitempty"`
Scope string `yaml:"scope,omitempty"`
}
type FieldContractTarget struct {
Scope string `yaml:"scope,omitempty"`
Types []string `yaml:"types,omitempty"`
Layouts []string `yaml:"layouts,omitempty"`
Slugs []string `yaml:"slugs,omitempty"`
Key string `yaml:"key,omitempty"`
}
var requiredLaunchSlots = []string{
"head.end",
"body.start",
"body.end",
"page.before_main",
"page.after_main",
"page.before_content",
"page.after_content",
"post.before_header",
"post.after_header",
"post.before_content",
"post.after_content",
"post.sidebar.top",
"post.sidebar.overview",
"post.sidebar.bottom",
}
var requiredLaunchSlotFiles = map[string]string{
"head.end": filepath.Join("layouts", "partials", "head.html"),
"body.start": filepath.Join("layouts", "base.html"),
"body.end": filepath.Join("layouts", "base.html"),
"page.before_main": filepath.Join("layouts", "base.html"),
"page.after_main": filepath.Join("layouts", "base.html"),
"page.before_content": filepath.Join("layouts", "page.html"),
"page.after_content": filepath.Join("layouts", "page.html"),
"post.before_header": filepath.Join("layouts", "post.html"),
"post.after_header": filepath.Join("layouts", "post.html"),
"post.before_content": filepath.Join("layouts", "post.html"),
"post.after_content": filepath.Join("layouts", "post.html"),
"post.sidebar.top": filepath.Join("layouts", "post.html"),
"post.sidebar.overview": filepath.Join("layouts", "post.html"),
"post.sidebar.bottom": filepath.Join("layouts", "post.html"),
}
// ListInstalled returns all theme directories directly under themesDir
func ListInstalled(themesDir string) ([]Info, error) {
entries, err := os.ReadDir(themesDir)
if err != nil {
if os.IsNotExist(err) {
return []Info{}, nil
}
return nil, err
}
out := make([]Info, 0)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
out = append(out, Info{
Name: name,
Path: filepath.Join(themesDir, name),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].Name < out[j].Name
})
return out, nil
}
// LoadManifest reads and normalizes theme.yaml for a frontend theme.
//
// When optional fields are omitted, Foundry fills in compatibility and SDK
// defaults so theme validation and admin diagnostics can know about the theme
// consistently.
func LoadManifest(themesDir, name string) (*Manifest, error) {
var err error
name, err = safepath.ValidatePathComponent("theme name", name)
if err != nil {
return nil, err
}
path := filepath.Join(themesDir, name, "theme.yaml")
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("theme %q is missing theme.yaml", name)
}
return nil, err
}
var m Manifest
if err := yaml.Unmarshal(b, &m); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if strings.TrimSpace(m.Name) == "" {
m.Name = name
}
if strings.TrimSpace(m.Title) == "" {
m.Title = m.Name
}
if strings.TrimSpace(m.Version) == "" {
m.Version = "0.0.0"
}
if strings.TrimSpace(m.SDKVersion) == "" {
m.SDKVersion = consts.FrontendSDKVersion
}
if strings.TrimSpace(m.CompatibilityVersion) == "" {
m.CompatibilityVersion = consts.FrontendCompatibility
}
normalizeThemeSecurity(&m.Security)
return &m, nil
}
// RequiredLayouts returns the layouts this theme is expected to implement
//
// SupportedLayouts takes precedence over the older Layouts field. If neither is
// declared, Foundry assumes the baseline of base, index, page, post, and list.
func (m *Manifest) RequiredLayouts() []string {
if m == nil {
return []string{"base", "index", "page", "post", "list"}
}
if len(m.SupportedLayouts) > 0 {
return append([]string(nil), m.SupportedLayouts...)
}
if len(m.Layouts) > 0 {
return append([]string(nil), m.Layouts...)
}
return []string{"base", "index", "page", "post", "list"}
}
// ValidateInstalled validates frontend theme and returns the first fatal
// validation error when one exists.
func ValidateInstalled(themesDir, name string) error {
result, err := ValidateInstalledDetailed(themesDir, name)
if err != nil {
return err
}
if result.Valid {
return nil
}
for _, diagnostic := range result.Diagnostics {
if diagnostic.Severity == "error" {
if diagnostic.Path != "" {
return fmt.Errorf("%s: %s", diagnostic.Path, diagnostic.Message)
}
return fmt.Errorf("%s", diagnostic.Message)
}
}
return fmt.Errorf("theme validation failed")
}
// Scaffold creates a new frontend theme skeleton with the minimum files needed
// to pass Foundry validation.
func Scaffold(themesDir, name string) (string, error) {
var err error
name, err = safepath.ValidatePathComponent("theme name", name)
if err != nil {
return "", err
}
root := filepath.Join(themesDir, name)
if _, err := os.Stat(root); err == nil {
return "", fmt.Errorf("theme already exists: %s", root)
} else if !os.IsNotExist(err) {
return "", err
}
dirs := []string{
filepath.Join(root, "assets", "css"),
filepath.Join(root, "layouts"),
filepath.Join(root, "layouts", "partials"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
}
files := map[string]string{
filepath.Join(root, "theme.yaml"): scaffoldManifest(name),
filepath.Join(root, "assets", "css", "base.css"): scaffoldCSS(),
filepath.Join(root, "layouts", "base.html"): scaffoldBase(),
filepath.Join(root, "layouts", "index.html"): scaffoldIndex(),
filepath.Join(root, "layouts", "page.html"): scaffoldPage(),
filepath.Join(root, "layouts", "post.html"): scaffoldPost(),
filepath.Join(root, "layouts", "list.html"): scaffoldList(),
filepath.Join(root, "layouts", "partials", "head.html"): scaffoldHead(),
filepath.Join(root, "layouts", "partials", "header.html"): scaffoldHeader(),
filepath.Join(root, "layouts", "partials", "footer.html"): scaffoldFooter(),
}
for path, body := range files {
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
return "", err
}
}
return root, nil
}
// SwitchInConfig updates the site's configured active frontend theme.
func SwitchInConfig(configPath, themeName string) error {
var err error
themeName, err = safepath.ValidatePathComponent("theme name", themeName)
if err != nil {
return err
}
return foundryconfig.UpsertTopLevelScalar(configPath, "theme", themeName)
}
func scaffoldManifest(name string) string {
return fmt.Sprintf(`name: %s
title: %s
version: 0.1.0
description: A Foundry theme.
author: Unknown
license: MIT
min_foundry_version: 0.1.0
sdk_version: v1
compatibility_version: v1
layouts:
- base
- index
- page
- post
- list
supported_layouts:
- base
- index
- page
- post
- list
screenshots:
- screenshots/home.png
config_schema:
- name: accent_color
label: Accent Color
type: text
default: "#0c7c59"
slots:
- head.end
- body.start
- body.end
- page.before_main
- page.after_main
- page.before_content
- page.after_content
- post.before_header
- post.after_header
- post.before_content
- post.after_content
- post.sidebar.top
- post.sidebar.overview
- post.sidebar.bottom
security:
external_assets:
allowed: false
frontend_requests:
allowed: false
template_context:
allow_site_params: true
allow_content_fields: true
allow_shared_fields: true
`, name, humanizeName(name))
}
func humanizeName(name string) string {
parts := strings.Split(strings.ReplaceAll(name, "_", "-"), "-")
for i, part := range parts {
if part == "" {
continue
}
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
return strings.Join(parts, " ")
}
func scaffoldCSS() string {
return `html, body {
margin: 0;
padding: 0;
font-family: system-ui, sans-serif;
color: #111;
background: #fff;
}
a {
color: inherit;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem;
}
.site-header,
.site-footer {
border-bottom: 1px solid #ddd;
}
.site-footer {
border-top: 1px solid #ddd;
border-bottom: 0;
margin-top: 3rem;
}
.site-header .container,
.site-footer .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.prose {
line-height: 1.7;
}
.prose img {
max-width: 100%;
height: auto;
}
`
}
func scaffoldBase() string {
return `{{ define "base" }}
<!doctype html>
<html lang="{{ .Lang }}">
{{ template "head" . }}
<body>
{{ pluginSlot "body.start" }}
{{ template "header" . }}
{{ pluginSlot "page.before_main" }}
<main class="site-main">
<div class="container">
{{ template "content" . }}
</div>
</main>
{{ pluginSlot "page.after_main" }}
{{ template "footer" . }}
{{ pluginSlot "body.end" }}
{{ if .LiveReload }}
<script>
(() => {
const mode = '{{ .Site.Server.LiveReloadMode }}' || 'stream';
if (window.__foundryReloadSource) {
window.__foundryReloadSource.close();
window.__foundryReloadSource = null;
}
if (window.__foundryReloadPollTimer) {
window.clearTimeout(window.__foundryReloadPollTimer);
window.__foundryReloadPollTimer = null;
}
if (mode === 'poll') {
const state = { closed: false, version: 0 };
const close = () => {
state.closed = true;
if (window.__foundryReloadPollTimer) {
window.clearTimeout(window.__foundryReloadPollTimer);
window.__foundryReloadPollTimer = null;
}
};
const poll = async () => {
try {
const url = state.version > 0 ? '/__reload/poll?since=' + state.version : '/__reload/poll';
const response = await fetch(url, { cache: 'no-store', credentials: 'same-origin' });
if (!response.ok) {
throw new Error('reload poll failed with status ' + response.status);
}
const payload = await response.json();
if (state.closed) {
return;
}
if (payload && typeof payload.version === 'number') {
state.version = payload.version;
}
if (payload && payload.reload) {
close();
window.location.reload();
return;
}
} catch (_error) {
}
if (!state.closed) {
window.__foundryReloadPollTimer = window.setTimeout(poll, 1500);
}
};
window.addEventListener('pagehide', close, { once: true });
window.addEventListener('beforeunload', close, { once: true });
void poll();
return;
}
const es = new EventSource('/__reload');
window.__foundryReloadSource = es;
const close = () => {
if (window.__foundryReloadSource === es) {
window.__foundryReloadSource = null;
}
es.close();
};
es.onmessage = () => {
close();
window.location.reload();
};
window.addEventListener('pagehide', close, { once: true });
window.addEventListener('beforeunload', close, { once: true });
})();
</script>
{{ end }}
</body>
</html>
{{ end }}
`
}
func scaffoldHead() string {
return `{{ define "head" }}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/assets/css/foundry.bundle.css">
{{ pluginSlot "head.end" }}
</head>
{{ end }}
`
}
func scaffoldHeader() string {
return `{{ define "header" }}
<header class="site-header">
<div class="container">
<strong>{{ .Site.Title }}</strong>
<nav>
{{ range .Nav }}
<a href="{{ .URL }}">{{ .Name }}</a>
{{ end }}
</nav>
</div>
</header>
{{ end }}
`
}
func scaffoldFooter() string {
return `{{ define "footer" }}
<footer class="site-footer">
<div class="container">
<small>{{ .Site.Title }}</small>
</div>
</footer>
{{ end }}
`
}
func scaffoldIndex() string {
return `{{ define "content" }}
<h1>{{ .Site.Title }}</h1>
{{ if .Documents }}
<ul>
{{ range .Documents }}
<li><a href="{{ .URL }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ else }}
<p>No content found.</p>
{{ end }}
{{ end }}
`
}
func scaffoldPage() string {
return `{{ define "content" }}
<article class="prose">
<h1>{{ .Page.Title }}</h1>
{{ pluginSlot "page.before_content" }}
{{ safeHTML .Page.HTMLBody }}
{{ pluginSlot "page.after_content" }}
</article>
{{ end }}
`
}
func scaffoldPost() string {
return `{{ define "content" }}
<div class="split-layout">
<section class="prose">
{{ pluginSlot "post.before_header" }}
<h1>{{ .Page.Title }}</h1>
{{ pluginSlot "post.after_header" }}
{{ pluginSlot "post.before_content" }}
{{ safeHTML .Page.HTMLBody }}
{{ pluginSlot "post.after_content" }}
</section>
<aside>
{{ pluginSlot "post.sidebar.top" }}
<div>
{{ pluginSlot "post.sidebar.overview" }}
</div>
{{ pluginSlot "post.sidebar.bottom" }}
</aside>
</div>
{{ end }}
`
}
func scaffoldList() string {
return `{{ define "content" }}
<h1>{{ .Title }}</h1>
{{ if .Documents }}
<ul>
{{ range .Documents }}
<li><a href="{{ .URL }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ else }}
<p>No entries found.</p>
{{ end }}
{{ end }}
`
}
func validateRequiredLaunchSlots(root string, manifest *Manifest) error {
declared := make(map[string]struct{}, len(manifest.Slots))
for _, slot := range manifest.Slots {
slot = strings.TrimSpace(slot)
if slot == "" {
continue
}
declared[slot] = struct{}{}
}
for _, slot := range requiredLaunchSlots {
if _, ok := declared[slot]; !ok {
return fmt.Errorf("theme manifest is missing required slot %q", slot)
}
relPath := requiredLaunchSlotFiles[slot]
path := filepath.Join(root, relPath)
body, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read slot template %s: %w", path, err)
}
call := fmt.Sprintf(`pluginSlot %q`, slot)
if !strings.Contains(string(body), call) {
return fmt.Errorf("theme must render required slot %q in %s", slot, path)
}
}
return nil
}
package theme
import (
"fmt"
"os"
"path/filepath"
"github.com/sphireinc/foundry/internal/safepath"
)
type Manager struct {
root string
activeTheme string
}
func NewManager(root, activeTheme string) *Manager {
return &Manager{
root: root,
activeTheme: activeTheme,
}
}
func (m *Manager) SetActiveTheme(name string) {
m.activeTheme = name
}
func (m *Manager) LayoutPath(name string) string {
themeName, err := safepath.ValidatePathComponent("theme name", m.activeTheme)
if err != nil {
return ""
}
return filepath.Join(m.root, themeName, "layouts", name+".html")
}
func (m *Manager) MustExist() error {
themeName, err := safepath.ValidatePathComponent("theme name", m.activeTheme)
if err != nil {
return err
}
themeDir := filepath.Join(m.root, themeName)
info, err := os.Stat(themeDir)
if err != nil {
return fmt.Errorf("active theme not found: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("theme path is not a directory: %s", themeDir)
}
return nil
}
package theme
import (
"fmt"
"net/url"
"sort"
"strings"
)
func normalizeThemeSecurity(sec *ThemeSecurity) {
if sec == nil {
return
}
sec.ExternalAssets.Scripts = normalizeSecurityList(sec.ExternalAssets.Scripts)
sec.ExternalAssets.Styles = normalizeSecurityList(sec.ExternalAssets.Styles)
sec.ExternalAssets.Fonts = normalizeSecurityList(sec.ExternalAssets.Fonts)
sec.ExternalAssets.Images = normalizeSecurityList(sec.ExternalAssets.Images)
sec.ExternalAssets.Media = normalizeSecurityList(sec.ExternalAssets.Media)
sec.FrontendRequests.Origins = normalizeSecurityList(sec.FrontendRequests.Origins)
sec.FrontendRequests.Methods = normalizeHTTPMethods(sec.FrontendRequests.Methods)
if len(sec.FrontendRequests.Methods) == 0 {
sec.FrontendRequests.Methods = []string{"GET"}
}
if sec.TemplateContext.AllowSiteParams == nil {
sec.TemplateContext.AllowSiteParams = boolPtr(true)
}
if sec.TemplateContext.AllowContentFields == nil {
sec.TemplateContext.AllowContentFields = boolPtr(true)
}
if sec.TemplateContext.AllowSharedFields == nil {
sec.TemplateContext.AllowSharedFields = boolPtr(true)
}
if sec.TemplateContext.AllowRuntimeState == nil {
sec.TemplateContext.AllowRuntimeState = boolPtr(false)
}
if sec.TemplateContext.AllowAdminState == nil {
sec.TemplateContext.AllowAdminState = boolPtr(false)
}
if sec.TemplateContext.AllowRawConfig == nil {
sec.TemplateContext.AllowRawConfig = boolPtr(false)
}
}
func normalizeSecurityList(items []string) []string {
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if u, err := url.Parse(item); err == nil && u.Scheme != "" && u.Host != "" {
item = strings.TrimRight(u.Scheme+"://"+u.Host, "/")
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
sort.Strings(out)
return out
}
func normalizeHTTPMethods(items []string) []string {
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
item = strings.ToUpper(strings.TrimSpace(item))
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
sort.Strings(out)
return out
}
func themeExternalAssetAllowlist(sec ThemeSecurity) []string {
out := append([]string{}, sec.ExternalAssets.Scripts...)
out = append(out, sec.ExternalAssets.Styles...)
out = append(out, sec.ExternalAssets.Fonts...)
out = append(out, sec.ExternalAssets.Images...)
out = append(out, sec.ExternalAssets.Media...)
return normalizeSecurityList(out)
}
func URLAllowedByPatterns(raw string, patterns []string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return false
}
origin := strings.TrimRight(u.Scheme+"://"+u.Host, "/")
host := strings.ToLower(u.Hostname())
for _, pattern := range patterns {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
if strings.Contains(pattern, "://") {
p, err := url.Parse(pattern)
if err == nil && p.Scheme != "" && p.Host != "" {
if origin == strings.TrimRight(p.Scheme+"://"+p.Host, "/") || strings.HasPrefix(raw, pattern) {
return true
}
}
if strings.HasPrefix(raw, pattern) {
return true
}
continue
}
patternHost := strings.ToLower(strings.TrimPrefix(pattern, "*."))
if host == patternHost || strings.HasSuffix(host, "."+patternHost) {
return true
}
}
return false
}
func ContentSecurityPolicy(manifest *Manifest) string {
sec := ThemeSecurity{}
if manifest != nil {
sec = manifest.Security
}
normalizeThemeSecurity(&sec)
directives := [][]string{
{"default-src", "'self'"},
{"base-uri", "'self'"},
{"object-src", "'none'"},
{"frame-ancestors", "'self'"},
}
directives = append(directives, append([]string{"script-src", "'self'", "'unsafe-inline'"}, sec.ExternalAssets.Scripts...))
directives = append(directives, append([]string{"style-src", "'self'", "'unsafe-inline'"}, sec.ExternalAssets.Styles...))
directives = append(directives, append([]string{"font-src", "'self'", "data:"}, sec.ExternalAssets.Fonts...))
directives = append(directives, append([]string{"img-src", "'self'", "data:", "blob:"}, sec.ExternalAssets.Images...))
directives = append(directives, append([]string{"media-src", "'self'", "data:", "blob:"}, sec.ExternalAssets.Media...))
connect := []string{"connect-src", "'self'"}
if sec.FrontendRequests.Allowed {
connect = append(connect, sec.FrontendRequests.Origins...)
}
directives = append(directives, connect)
parts := make([]string, 0, len(directives))
for _, directive := range directives {
parts = append(parts, strings.Join(uniqueDirectiveValues(directive), " "))
}
return strings.Join(parts, "; ")
}
func uniqueDirectiveValues(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func (s ThemeSecurity) Summary() []string {
normalizeThemeSecurity(&s)
out := []string{}
if s.ExternalAssets.Allowed && len(themeExternalAssetAllowlist(s)) > 0 {
out = append(out, fmt.Sprintf("Remote assets: %d declared source(s)", len(themeExternalAssetAllowlist(s))))
} else {
out = append(out, "Remote assets blocked")
}
if s.FrontendRequests.Allowed && len(s.FrontendRequests.Origins) > 0 {
out = append(out, fmt.Sprintf("Frontend requests: %s", strings.Join(s.FrontendRequests.Origins, ", ")))
} else {
out = append(out, "Frontend requests blocked")
}
if !themeBoolValue(s.TemplateContext.AllowRawConfig) {
out = append(out, "Raw config hidden from templates")
}
if !themeBoolValue(s.TemplateContext.AllowAdminState) {
out = append(out, "Admin state hidden from templates")
}
return out
}
func themeBoolValue(v *bool) bool {
return v != nil && *v
}
func boolPtr(v bool) *bool {
return &v
}
package theme
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
type SecurityAssetFinding struct {
Kind string `json:"kind"`
URL string `json:"url"`
Path string `json:"path,omitempty"`
Status string `json:"status,omitempty"`
}
type SecurityReport struct {
Declared ThemeSecurity `json:"declared"`
DeclaredSummary []string `json:"declared_summary,omitempty"`
DetectedAssets []SecurityAssetFinding `json:"detected_assets,omitempty"`
DetectedRequests []SecurityAssetFinding `json:"detected_requests,omitempty"`
Mismatches []ValidationDiagnostic `json:"mismatches,omitempty"`
GeneratedCSP string `json:"generated_csp,omitempty"`
CSPSummary []string `json:"csp_summary,omitempty"`
}
func AnalyzeInstalledSecurity(themesDir, name string) (*SecurityReport, error) {
manifest, err := LoadManifest(themesDir, name)
if err != nil {
return nil, err
}
root := filepath.Join(themesDir, name)
report := &SecurityReport{
Declared: manifest.Security,
DeclaredSummary: manifest.Security.Summary(),
GeneratedCSP: ContentSecurityPolicy(manifest),
CSPSummary: summarizeCSP(manifest.Security),
}
detectedAssets, detectedRequests, walkErr := detectRemoteThemeReferences(root, manifest.Security)
if walkErr != nil {
return nil, walkErr
}
report.DetectedAssets = detectedAssets
report.DetectedRequests = detectedRequests
if validation, err := ValidateInstalledDetailed(themesDir, name); err == nil {
for _, diag := range validation.Diagnostics {
if strings.Contains(diag.Message, "security.") || strings.Contains(diag.Message, "remote asset") || strings.Contains(diag.Message, "frontend request") {
report.Mismatches = append(report.Mismatches, diag)
}
}
}
return report, nil
}
func detectRemoteThemeReferences(root string, sec ThemeSecurity) ([]SecurityAssetFinding, []SecurityAssetFinding, error) {
normalizeThemeSecurity(&sec)
assets := []SecurityAssetFinding{}
requests := []SecurityAssetFinding{}
seen := map[string]struct{}{}
assetAllowlist := themeExternalAssetAllowlist(sec)
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == ".git" || d.Name() == "node_modules" {
return filepath.SkipDir
}
return nil
}
switch strings.ToLower(filepath.Ext(path)) {
case ".html", ".css", ".js":
default:
return nil
}
body, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, raw := range remoteURLPattern.FindAllString(string(body), -1) {
key := path + "|" + raw
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
kind := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
if strings.EqualFold(filepath.Ext(path), ".js") {
requests = append(requests, SecurityAssetFinding{
Kind: kind,
URL: raw,
Path: filepath.ToSlash(path),
Status: allowState(sec.FrontendRequests.Allowed && URLAllowedByPatterns(raw, sec.FrontendRequests.Origins)),
})
continue
}
assets = append(assets, SecurityAssetFinding{
Kind: kind,
URL: raw,
Path: filepath.ToSlash(path),
Status: allowState(sec.ExternalAssets.Allowed && URLAllowedByPatterns(raw, assetAllowlist)),
})
}
return nil
})
sort.Slice(assets, func(i, j int) bool {
if assets[i].Path != assets[j].Path {
return assets[i].Path < assets[j].Path
}
return assets[i].URL < assets[j].URL
})
sort.Slice(requests, func(i, j int) bool {
if requests[i].Path != requests[j].Path {
return requests[i].Path < requests[j].Path
}
return requests[i].URL < requests[j].URL
})
return assets, requests, err
}
func summarizeCSP(sec ThemeSecurity) []string {
normalizeThemeSecurity(&sec)
out := []string{
"default-src self",
fmt.Sprintf("script sources: %d", len(sec.ExternalAssets.Scripts)+1),
fmt.Sprintf("style sources: %d", len(sec.ExternalAssets.Styles)+1),
}
if sec.FrontendRequests.Allowed && len(sec.FrontendRequests.Origins) > 0 {
out = append(out, "connect-src includes declared remote origins")
} else {
out = append(out, "connect-src restricted to self")
}
return out
}
func allowState(ok bool) string {
if ok {
return "declared"
}
return "undeclared"
}
package theme
import (
"fmt"
"html/template"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
foundryconfig "github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/safepath"
)
// ValidationDiagnostic is a single frontend-theme validation finding.
type ValidationDiagnostic struct {
Severity string `json:"severity"`
Path string `json:"path,omitempty"`
Message string `json:"message"`
}
// ValidationResult summarizes frontend-theme validation.
type ValidationResult struct {
Valid bool `json:"valid"`
Diagnostics []ValidationDiagnostic `json:"diagnostics,omitempty"`
}
var templateReferencePattern = regexp.MustCompile(`{{\s*(?:template|block)\s+"([^"]+)"`)
var remoteURLPattern = regexp.MustCompile(`(?i)(https?|wss?)://[^\s"'()<>]+`)
// ValidateInstalledDetailed performs Foundry's full frontend-theme validation
// pass and returns all diagnostics.
//
// Validation checks manifest compatibility, required layouts and partials,
// required launch slots, template references, and template parse validity.
func ValidateInstalledDetailed(themesDir, name string) (*ValidationResult, error) {
name, err := safepath.ValidatePathComponent("theme name", name)
if err != nil {
return nil, err
}
root := filepath.Join(themesDir, name)
info, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("theme %q does not exist", name)
}
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("theme path %q is not a directory", root)
}
manifest, err := LoadManifest(themesDir, name)
if err != nil {
return nil, err
}
result := &ValidationResult{Valid: true, Diagnostics: make([]ValidationDiagnostic, 0)}
add := func(severity, path, message string) {
result.Diagnostics = append(result.Diagnostics, ValidationDiagnostic{
Severity: severity,
Path: filepath.ToSlash(path),
Message: message,
})
if severity == "error" {
result.Valid = false
}
}
if strings.TrimSpace(manifest.Name) != name {
add("error", filepath.Join(root, "theme.yaml"), fmt.Sprintf("theme manifest name %q must match directory %q", manifest.Name, name))
}
if manifest.SDKVersion != consts.FrontendSDKVersion {
add("error", filepath.Join(root, "theme.yaml"), fmt.Sprintf("unsupported sdk_version %q", manifest.SDKVersion))
}
if manifest.CompatibilityVersion != consts.FrontendCompatibility {
add("error", filepath.Join(root, "theme.yaml"), fmt.Sprintf("unsupported compatibility_version %q", manifest.CompatibilityVersion))
}
validateFieldContracts(filepath.Join(root, "theme.yaml"), manifest, add)
requiredLayouts := manifest.RequiredLayouts()
for _, layout := range requiredLayouts {
path := filepath.Join(root, "layouts", layout+".html")
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
add("error", path, "missing required theme layout")
continue
}
return nil, err
}
}
requiredPartials := []string{
filepath.Join(root, "layouts", "partials", "head.html"),
filepath.Join(root, "layouts", "partials", "header.html"),
filepath.Join(root, "layouts", "partials", "footer.html"),
}
for _, path := range requiredPartials {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
add("error", path, "missing required theme partial")
continue
}
return nil, err
}
}
validateRequiredLaunchSlotsDetailed(root, manifest, add)
validateFieldContractsDetailed(root, manifest, add)
validateThemeSecurityDetailed(root, manifest, add)
validateTemplateReferences(root, manifest, add)
validateTemplateParsing(root, add)
sort.Slice(result.Diagnostics, func(i, j int) bool {
if result.Diagnostics[i].Severity != result.Diagnostics[j].Severity {
return result.Diagnostics[i].Severity < result.Diagnostics[j].Severity
}
if result.Diagnostics[i].Path != result.Diagnostics[j].Path {
return result.Diagnostics[i].Path < result.Diagnostics[j].Path
}
return result.Diagnostics[i].Message < result.Diagnostics[j].Message
})
return result, nil
}
func validateThemeSecurityDetailed(root string, manifest *Manifest, add func(severity, path, message string)) {
keyPath := filepath.Join(root, "theme.yaml")
normalizeThemeSecurity(&manifest.Security)
if !manifest.Security.ExternalAssets.Allowed {
for _, list := range [][]string{
manifest.Security.ExternalAssets.Scripts,
manifest.Security.ExternalAssets.Styles,
manifest.Security.ExternalAssets.Fonts,
manifest.Security.ExternalAssets.Images,
manifest.Security.ExternalAssets.Media,
} {
if len(list) > 0 {
add("error", keyPath, "security.external_assets.allowed must be true when remote asset allowlists are declared")
break
}
}
}
if !manifest.Security.FrontendRequests.Allowed && len(manifest.Security.FrontendRequests.Origins) > 0 {
add("error", keyPath, "security.frontend_requests.allowed must be true when remote request origins are declared")
}
if themeBoolValue(manifest.Security.TemplateContext.AllowRawConfig) {
add("error", keyPath, "security.template_context.allow_raw_config is not supported; templates only receive a curated public-safe site config")
}
if themeBoolValue(manifest.Security.TemplateContext.AllowAdminState) {
add("error", keyPath, "security.template_context.allow_admin_state is not supported; admin state is never exposed to themes")
}
if themeBoolValue(manifest.Security.TemplateContext.AllowRuntimeState) {
add("warning", keyPath, "security.template_context.allow_runtime_state is advisory only; undeclared runtime/admin keys are still filtered from template data")
}
assetAllowlist := themeExternalAssetAllowlist(manifest.Security)
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
name := d.Name()
if name == ".git" || name == "node_modules" {
return filepath.SkipDir
}
return nil
}
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".html", ".css", ".js":
default:
return nil
}
body, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
urls := remoteURLPattern.FindAllString(string(body), -1)
for _, raw := range urls {
switch ext {
case ".js":
if !manifest.Security.FrontendRequests.Allowed || !URLAllowedByPatterns(raw, manifest.Security.FrontendRequests.Origins) {
add("error", path, fmt.Sprintf("remote frontend request %q is not declared in security.frontend_requests.origins", raw))
}
default:
if !manifest.Security.ExternalAssets.Allowed || !URLAllowedByPatterns(raw, assetAllowlist) {
add("error", path, fmt.Sprintf("remote asset %q is not declared in security.external_assets allowlists", raw))
}
}
}
return nil
})
if err != nil {
add("error", root, err.Error())
}
}
func validateFieldContracts(manifestPath string, manifest *Manifest, add func(severity, path, message string)) {
root := filepath.Dir(manifestPath)
validateFieldContractsDetailed(root, manifest, add)
}
func validateFieldContractsDetailed(root string, manifest *Manifest, add func(severity, path, message string)) {
seen := make(map[string]struct{})
for index, contract := range manifest.FieldContracts {
keyPath := filepath.Join(root, "theme.yaml")
key := strings.TrimSpace(contract.Key)
if key == "" {
add("error", keyPath, fmt.Sprintf("field_contracts[%d] must define key", index))
} else {
if _, ok := seen[key]; ok {
add("error", keyPath, fmt.Sprintf("field_contracts[%d] key %q must be unique", index, key))
}
seen[key] = struct{}{}
}
scope := normalizeFieldContractScope(contract.Target.Scope)
switch scope {
case "document":
if len(contract.Target.Types) == 0 && len(contract.Target.Layouts) == 0 && len(contract.Target.Slugs) == 0 {
add("error", keyPath, fmt.Sprintf("field_contracts[%d] document target must declare at least one of types, layouts, or slugs", index))
}
case "shared":
if strings.TrimSpace(contract.Target.Key) == "" {
add("error", keyPath, fmt.Sprintf("field_contracts[%d] shared target must define target.key", index))
}
default:
add("error", keyPath, fmt.Sprintf("field_contracts[%d] has unsupported target.scope %q", index, contract.Target.Scope))
}
if len(contract.Fields) == 0 {
add("error", keyPath, fmt.Sprintf("field_contracts[%d] must define at least one field", index))
continue
}
validateFieldDefinitions(keyPath, contract.Fields, add)
}
}
func validateFieldDefinitions(path string, defs []foundryconfig.FieldDefinition, add func(severity, path, message string)) {
seen := make(map[string]struct{})
for _, def := range defs {
name := strings.TrimSpace(def.Name)
if name == "" {
add("error", path, "field definitions must have a name")
continue
}
if _, ok := seen[name]; ok {
add("error", path, fmt.Sprintf("field definition %q must be unique within its contract", name))
}
seen[name] = struct{}{}
if strings.TrimSpace(def.Type) == "" {
add("error", path, fmt.Sprintf("field %q must define a type", name))
}
switch strings.ToLower(strings.TrimSpace(def.Type)) {
case "object":
if len(def.Fields) == 0 {
add("error", path, fmt.Sprintf("object field %q must define nested fields", name))
} else {
validateFieldDefinitions(path, def.Fields, add)
}
case "repeater", "list", "array":
if def.Item == nil {
add("error", path, fmt.Sprintf("repeater field %q must define item", name))
} else {
validateFieldDefinitions(path, []foundryconfig.FieldDefinition{*def.Item}, add)
}
}
}
}
// validateRequiredLaunchSlotsDetailed enforces the slot contract Foundry expects
// launch-ready frontend themes to expose and actually render.
func validateRequiredLaunchSlotsDetailed(root string, manifest *Manifest, add func(severity, path, message string)) {
declared := make(map[string]struct{}, len(manifest.Slots))
for _, slot := range manifest.Slots {
declared[strings.TrimSpace(slot)] = struct{}{}
}
for _, slot := range requiredLaunchSlots {
if _, ok := declared[slot]; !ok {
add("error", filepath.Join(root, "theme.yaml"), fmt.Sprintf("theme manifest must declare required launch slot %q", slot))
}
}
for slot, relPath := range requiredLaunchSlotFiles {
path := filepath.Join(root, relPath)
body, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
add("error", path, fmt.Sprintf("missing layout required to render slot %q", slot))
continue
}
add("error", path, err.Error())
continue
}
call := fmt.Sprintf(`pluginSlot %q`, slot)
if !strings.Contains(string(body), call) {
add("error", path, fmt.Sprintf("required launch slot %q is not rendered", slot))
}
}
}
// validateTemplateReferences checks that template and block calls only target
// known layouts or partials.
func validateTemplateReferences(root string, manifest *Manifest, add func(severity, path, message string)) {
known := map[string]struct{}{
"base": {},
"content": {},
}
for _, layout := range manifest.RequiredLayouts() {
known[layout] = struct{}{}
}
partials, _ := filepath.Glob(filepath.Join(root, "layouts", "partials", "*.html"))
for _, partial := range partials {
name := strings.TrimSuffix(filepath.Base(partial), filepath.Ext(partial))
known[name] = struct{}{}
}
files, _ := filepath.Glob(filepath.Join(root, "layouts", "*.html"))
files = append(files, partials...)
for _, path := range files {
body, err := os.ReadFile(path)
if err != nil {
continue
}
matches := templateReferencePattern.FindAllStringSubmatch(string(body), -1)
for _, match := range matches {
if len(match) < 2 {
continue
}
name := strings.TrimSpace(match[1])
if _, ok := known[name]; !ok {
add("error", path, fmt.Sprintf("template references unknown partial or layout %q", name))
}
}
}
}
// validateTemplateParsing parses the theme templates with Foundry's supported
// helper functions to catch syntax errors early.
func validateTemplateParsing(root string, add func(severity, path, message string)) {
partials, _ := filepath.Glob(filepath.Join(root, "layouts", "partials", "*.html"))
files, _ := filepath.Glob(filepath.Join(root, "layouts", "*.html"))
files = append(files, partials...)
if len(files) == 0 {
return
}
_, err := template.New("validate").Funcs(template.FuncMap{
"safeHTML": func(v any) template.HTML { return "" },
"field": func(any, string) any { return nil },
"data": func(string) any { return nil },
"pluginSlot": func(string) template.HTML {
return ""
},
}).ParseFiles(files...)
if err != nil {
add("error", filepath.Join(root, "layouts"), fmt.Sprintf("invalid template parse: %v", err))
}
}
//go:build darwin || linux
package updater
import (
"os"
"os/exec"
"syscall"
"time"
)
func execCommand(name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
}
func detachCommand(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
func terminatePID(pid int) error {
proc, err := os.FindProcess(pid)
if err != nil {
return err
}
return proc.Signal(syscall.SIGTERM)
}
func waitForExit(pid int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if err := syscall.Kill(pid, 0); err != nil {
return nil
}
time.Sleep(200 * time.Millisecond)
}
return nil
}
package updater
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
versioncmd "github.com/sphireinc/foundry/internal/commands/version"
"github.com/sphireinc/foundry/internal/installmode"
"github.com/sphireinc/foundry/internal/logx"
"github.com/sphireinc/foundry/internal/standalone"
)
const (
defaultRepo = "sphireinc/foundry"
defaultAPIBase = "https://api.github.com"
)
type InstallMode = installmode.Mode
const (
ModeStandalone InstallMode = installmode.Standalone
ModeDocker InstallMode = installmode.Docker
ModeSource InstallMode = installmode.Source
ModeBinary InstallMode = installmode.Binary
ModeUnknown InstallMode = installmode.Unknown
)
type ReleaseInfo struct {
Repo string `json:"repo"`
CurrentVersion string `json:"current_version"`
CurrentDisplayVersion string `json:"current_display_version,omitempty"`
LatestVersion string `json:"latest_version"`
HasUpdate bool `json:"has_update"`
InstallMode InstallMode `json:"install_mode"`
ApplySupported bool `json:"apply_supported"`
ReleaseURL string `json:"release_url"`
PublishedAt time.Time `json:"published_at"`
Body string `json:"body,omitempty"`
AssetName string `json:"asset_name,omitempty"`
AssetURL string `json:"asset_url,omitempty"`
ChecksumAssetURL string `json:"checksum_asset_url,omitempty"`
Instructions string `json:"instructions,omitempty"`
NearestTag string `json:"nearest_tag,omitempty"`
CurrentCommit string `json:"current_commit,omitempty"`
Dirty bool `json:"dirty,omitempty"`
}
type githubRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Assets []githubAsset `json:"assets"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
func Check(ctx context.Context, projectDir string) (*ReleaseInfo, error) {
repo := strings.TrimSpace(os.Getenv("FOUNDRY_UPDATE_REPO"))
if repo == "" {
repo = defaultRepo
}
apiBase := strings.TrimSpace(os.Getenv("FOUNDRY_UPDATE_API_BASE"))
if apiBase == "" {
apiBase = defaultAPIBase
}
mode := DetectInstallMode(projectDir)
currentMeta := versioncmd.Current(projectDir)
current := normalizeVersion(currentMeta.Version)
currentDisplay := formatVersion(firstNonEmpty(currentMeta.Version, currentMeta.NearestTag, current))
logx.Info("updater release check started", "project_dir", projectDir, "repo", repo, "install_mode", mode, "current_version", current)
info := &ReleaseInfo{
Repo: repo,
CurrentVersion: currentDisplay,
CurrentDisplayVersion: formatVersion(firstNonEmpty(currentMeta.DisplayVersion, currentDisplay)),
InstallMode: mode,
NearestTag: currentMeta.NearestTag,
CurrentCommit: currentMeta.Commit,
Dirty: currentMeta.Dirty,
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(apiBase, "/")+"/repos/"+repo+"/releases/latest", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "foundry-updater")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("release lookup failed: %s", strings.TrimSpace(string(body)))
}
var rel githubRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, err
}
latest := normalizeVersion(rel.TagName)
info.LatestVersion = formatVersion(firstNonEmpty(rel.TagName, latest))
info.ReleaseURL = rel.HTMLURL
info.Body = rel.Body
if ts, err := time.Parse(time.RFC3339, rel.PublishedAt); err == nil {
info.PublishedAt = ts
}
info.HasUpdate = compareVersions(latest, current) > 0
asset, checksum := selectAssets(rel.Assets)
if asset != nil {
info.AssetName = asset.Name
info.AssetURL = asset.BrowserDownloadURL
}
if checksum != nil {
info.ChecksumAssetURL = checksum.BrowserDownloadURL
}
info.ApplySupported = info.HasUpdate && mode == ModeStandalone && info.AssetURL != ""
info.Instructions = instructionsForMode(mode, currentMeta)
logx.Info("updater release check completed", "project_dir", projectDir, "latest_version", latest, "has_update", info.HasUpdate, "install_mode", mode, "apply_supported", info.ApplySupported, "asset_name", info.AssetName)
return info, nil
}
func DetectInstallMode(projectDir string) InstallMode {
return InstallMode(installmode.Detect(projectDir))
}
func ScheduleApply(ctx context.Context, projectDir string) (*ReleaseInfo, error) {
logx.Info("updater schedule apply started", "project_dir", projectDir)
info, err := Check(ctx, projectDir)
if err != nil {
return nil, err
}
if !info.ApplySupported {
logx.Info("updater schedule apply rejected", "project_dir", projectDir, "install_mode", info.InstallMode, "has_update", info.HasUpdate, "asset_name", info.AssetName)
return nil, fmt.Errorf("self-update is not supported for install mode %q", info.InstallMode)
}
exe, err := os.Executable()
if err != nil {
return nil, err
}
state, running, err := standalone.RunningState(projectDir)
if err != nil {
return nil, err
}
if state == nil || !running {
logx.Info("updater schedule apply rejected", "project_dir", projectDir, "reason", "standalone runtime is not running")
return nil, fmt.Errorf("standalone runtime is not running")
}
newBinaryPath, err := downloadReleaseBinary(ctx, projectDir, info)
if err != nil {
return nil, err
}
logx.Info("updater binary downloaded", "project_dir", projectDir, "binary_path", newBinaryPath, "asset_name", info.AssetName)
if err := StartHelper(projectDir, state.PID, exe, newBinaryPath); err != nil {
return nil, err
}
logx.Info("updater helper started", "project_dir", projectDir, "target_pid", state.PID, "asset_name", info.AssetName)
return info, nil
}
func StartHelper(projectDir string, pid int, targetExe, sourceBinary string) error {
exe, err := os.Executable()
if err != nil {
return err
}
logx.Info("updater starting helper process", "project_dir", projectDir, "target_pid", pid, "target_executable", targetExe, "source_binary", sourceBinary)
cmd := execCommand(exe, "__update-helper",
"--pid="+strconv.Itoa(pid),
"--project-dir="+projectDir,
"--target="+targetExe,
"--source="+sourceBinary,
)
cmd.Dir = projectDir
detachCommand(cmd)
return cmd.Start()
}
func RunHelper(projectDir, targetExe, sourceBinary string, pid int) error {
logx.Info("updater helper running", "project_dir", projectDir, "target_pid", pid, "target_executable", targetExe, "source_binary", sourceBinary)
if pid > 0 {
_ = terminatePID(pid)
_ = waitForExit(pid, 10*time.Second)
}
info, err := os.Stat(sourceBinary)
if err != nil {
return err
}
backupPath := targetExe + ".bak"
_ = os.Remove(backupPath)
if _, err := os.Stat(targetExe); err == nil {
if err := os.Rename(targetExe, backupPath); err != nil {
return err
}
}
if err := os.Rename(sourceBinary, targetExe); err != nil {
_ = os.Rename(backupPath, targetExe)
return err
}
if err := os.Chmod(targetExe, info.Mode()|0o111); err != nil {
return err
}
logx.Info("updater helper swapped binary", "project_dir", projectDir, "target_executable", targetExe, "backup_path", backupPath)
cmd := execCommand(targetExe, "restart")
cmd.Dir = projectDir
logx.Info("updater helper restarting target", "project_dir", projectDir, "target_executable", targetExe)
return cmd.Start()
}
func instructionsForMode(mode InstallMode, meta versioncmd.Metadata) string {
switch mode {
case ModeDocker:
return "Docker install detected. Pull the new image and recreate the container instead of in-place self-update."
case ModeSource:
if meta.Dirty {
return "Source install detected with local changes. Commit or stash your work before pulling, rebuilding Foundry, and restarting the process."
}
return "Source install detected. Pull the repo, rebuild Foundry, and restart the process."
case ModeBinary:
return "Binary install detected. Use a standalone managed runtime for in-place self-update support."
case ModeStandalone:
return "Standalone managed runtime detected. In-place self-update is available."
default:
return "Install mode could not be determined."
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func downloadReleaseBinary(ctx context.Context, projectDir string, info *ReleaseInfo) (string, error) {
if info == nil || strings.TrimSpace(info.AssetURL) == "" {
return "", fmt.Errorf("release asset URL is missing")
}
runDir, err := standalone.EnsureRunDir(projectDir)
if err != nil {
return "", err
}
tmpPath := filepath.Join(runDir.RunDir, "foundry-update-"+time.Now().UTC().Format("20060102-150405")+".bin")
body, err := downloadBytes(ctx, info.AssetURL)
if err != nil {
return "", err
}
if strings.TrimSpace(info.ChecksumAssetURL) != "" {
checksumBody, err := downloadBytes(ctx, info.ChecksumAssetURL)
if err == nil {
if err := verifyChecksum(body, info.AssetName, checksumBody); err != nil {
return "", err
}
}
}
bin, err := extractExecutable(body, info.AssetName)
if err != nil {
return "", err
}
if err := os.WriteFile(tmpPath, bin, 0o755); err != nil {
return "", err
}
return tmpPath, nil
}
func selectAssets(assets []githubAsset) (*githubAsset, *githubAsset) {
var best *githubAsset
for i := range assets {
name := assets[i].Name
lower := strings.ToLower(name)
if strings.Contains(lower, runtime.GOOS) && strings.Contains(lower, runtime.GOARCH) {
best = &assets[i]
break
}
}
if best == nil {
for i := range assets {
name := strings.ToLower(assets[i].Name)
if name == "foundry" || name == "foundry.exe" {
best = &assets[i]
break
}
}
}
var checksum *githubAsset
if best != nil {
for i := range assets {
if assets[i].Name == best.Name+".sha256" {
checksum = &assets[i]
break
}
}
}
if checksum == nil {
for i := range assets {
if strings.HasSuffix(strings.ToLower(assets[i].Name), ".sha256") {
checksum = &assets[i]
break
}
}
}
return best, checksum
}
func normalizeVersion(v string) string {
return strings.TrimPrefix(strings.TrimSpace(v), "v")
}
func formatVersion(v string) string {
v = strings.TrimSpace(v)
if v == "" || v == "unknown" || v == "dev" {
return v
}
if strings.HasPrefix(v, "v") {
return v
}
return "v" + v
}
func compareVersions(a, b string) int {
parse := func(v string) []int {
parts := strings.Split(normalizeVersion(v), ".")
out := make([]int, 0, len(parts))
for _, part := range parts {
n, _ := strconv.Atoi(strings.TrimLeftFunc(part, func(r rune) bool { return r < '0' || r > '9' }))
out = append(out, n)
}
return out
}
left := parse(a)
right := parse(b)
max := len(left)
if len(right) > max {
max = len(right)
}
for i := 0; i < max; i++ {
var lv, rv int
if i < len(left) {
lv = left[i]
}
if i < len(right) {
rv = right[i]
}
if lv < rv {
return -1
}
if lv > rv {
return 1
}
}
return 0
}
func downloadBytes(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "foundry-updater")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("download failed: %s", strings.TrimSpace(string(body)))
}
return io.ReadAll(resp.Body)
}
func verifyChecksum(body []byte, assetName string, checksumBody []byte) error {
sum := sha256.Sum256(body)
actual := hex.EncodeToString(sum[:])
lines := strings.Split(string(checksumBody), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
if len(fields) == 1 || strings.TrimSpace(fields[len(fields)-1]) == assetName {
if fields[0] == actual {
return nil
}
}
}
return fmt.Errorf("checksum verification failed for %s", assetName)
}
func extractExecutable(body []byte, assetName string) ([]byte, error) {
lower := strings.ToLower(assetName)
switch {
case strings.HasSuffix(lower, ".zip"):
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
return nil, err
}
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
base := filepath.Base(file.Name)
if base == "foundry" || base == "foundry.exe" {
rc, err := file.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
}
return nil, fmt.Errorf("no foundry executable in zip asset")
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
gz, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
base := filepath.Base(hdr.Name)
if base == "foundry" || base == "foundry.exe" {
return io.ReadAll(tr)
}
}
return nil, fmt.Errorf("no foundry executable in tar.gz asset")
default:
return body, nil
}
}
package aiwriter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
adminauth "github.com/sphireinc/foundry/internal/admin/auth"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/safepath"
)
const (
pluginName = "aiwriter"
defaultTimeoutSeconds = 60
defaultTemperature = 0.7
defaultMaxTokens = 2400
defaultOpenAIModel = "gpt-4o-mini"
defaultAnthropicModel = "claude-3-5-haiku-latest"
defaultGeminiModel = "gemini-1.5-flash"
defaultOpenAIEndpoint = "https://api.openai.com/{version}/responses"
defaultOpenAIVersion = "v1"
defaultAnthropicURL = "https://api.anthropic.com/{version}/messages"
defaultAnthropicVersion = "v1"
defaultGeminiEndpoint = "https://generativelanguage.googleapis.com/{version}/models/%s:generateContent"
defaultGeminiVersion = "v1beta"
defaultGeneratedStatus = "draft"
adminGenerateCapability = "documents.create"
)
var frontmatterValuePattern = regexp.MustCompile(`(?m)^([A-Za-z0-9_-]+):\s*"?([^"\n]+)"?\s*$`)
type Plugin struct {
mu sync.RWMutex
cfg *config.Config
settings Settings
client *http.Client
usage UsageStats
}
type Settings struct {
Provider string `json:"provider"`
APIKey string `json:"-"`
APIKeyEnv string `json:"api_key_env"`
Model string `json:"model"`
Endpoint string `json:"endpoint,omitempty"`
EndpointVersion string `json:"endpoint_version,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
DefaultStatus string `json:"default_status"`
DefaultAuthor string `json:"default_author,omitempty"`
DefaultLang string `json:"default_lang,omitempty"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
TimeoutSeconds int `json:"timeout_seconds"`
}
type GenerateRequest struct {
Prompt string `json:"prompt"`
Title string `json:"title,omitempty"`
Slug string `json:"slug,omitempty"`
Status string `json:"status,omitempty"`
Author string `json:"author,omitempty"`
Lang string `json:"lang,omitempty"`
Summary string `json:"summary,omitempty"`
Tags []string `json:"tags,omitempty"`
Categories []string `json:"categories,omitempty"`
}
type GenerateResponse struct {
Path string `json:"path"`
Title string `json:"title"`
Slug string `json:"slug"`
Status string `json:"status"`
Provider string `json:"provider"`
Model string `json:"model"`
Markdown string `json:"markdown"`
Usage TokenUsage `json:"usage"`
}
type TokenUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type UsageStats struct {
AIWrittenPosts int `json:"ai_written_posts"`
GenerationsThisRun int `json:"generations_this_run"`
PromptTokensThisRun int `json:"prompt_tokens_this_run"`
CompletionTokensThisRun int `json:"completion_tokens_this_run"`
TotalTokensThisRun int `json:"total_tokens_this_run"`
LastRequest TokenUsage `json:"last_request"`
}
type settingsResponse struct {
Settings
Configured bool `json:"configured"`
KeySource string `json:"key_source"`
Usage UsageStats `json:"usage"`
}
func New() *Plugin {
return &Plugin{}
}
func (p *Plugin) Name() string {
return pluginName
}
func (p *Plugin) OnConfigLoaded(cfg *config.Config) error {
settings := settingsFromConfig(cfg)
p.mu.Lock()
defer p.mu.Unlock()
p.cfg = cfg
p.settings = settings
p.client = &http.Client{Timeout: time.Duration(settings.TimeoutSeconds) * time.Second}
return nil
}
func (p *Plugin) RegisterRoutes(mux *http.ServeMux) {
cfg, _, _ := p.snapshot()
if mux == nil || cfg == nil {
return
}
auth := adminauth.New(cfg)
base := strings.TrimRight(cfg.AdminPath(), "/") + "/plugin-api/aiwriter"
mux.Handle(base+"/settings", auth.WrapCapability(http.HandlerFunc(p.handleSettings), adminGenerateCapability))
mux.Handle(base+"/generate", auth.WrapCapability(http.HandlerFunc(p.handleGenerate), adminGenerateCapability))
}
func (p *Plugin) handleSettings(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
cfg, settings, _ := p.snapshot()
_, source := settings.resolveAPIKey()
usage := p.usageSnapshot()
usage.AIWrittenPosts = countAIWrittenPosts(cfg)
writeJSON(w, http.StatusOK, settingsResponse{
Settings: settings.redacted(),
Configured: source != "missing",
KeySource: source,
Usage: usage,
})
}
func (p *Plugin) handleGenerate(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
defer req.Body.Close()
var body GenerateRequest
dec := json.NewDecoder(io.LimitReader(req.Body, 128*1024))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
resp, err := p.GenerateAndWrite(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (p *Plugin) GenerateAndWrite(ctx context.Context, body GenerateRequest) (*GenerateResponse, error) {
cfg, settings, client := p.snapshot()
if cfg == nil {
return nil, fmt.Errorf("ai writer plugin is not configured")
}
if strings.TrimSpace(body.Prompt) == "" {
return nil, fmt.Errorf("prompt is required")
}
if client == nil {
client = http.DefaultClient
}
prompt := buildPrompt(settings, body)
markdown, usage, err := requestCompletion(ctx, client, settings, prompt)
if err != nil {
return nil, err
}
p.recordUsage(usage)
markdown = normalizeMarkdown(markdown)
markdown, title, slug, status := completeMarkdownDocument(markdown, settings, body)
path, err := writePost(cfg, markdown)
if err != nil {
return nil, err
}
return &GenerateResponse{
Path: filepath.ToSlash(path),
Title: title,
Slug: slug,
Status: status,
Provider: settings.Provider,
Model: settings.Model,
Markdown: markdown,
Usage: usage,
}, nil
}
func (p *Plugin) snapshot() (*config.Config, Settings, *http.Client) {
p.mu.RLock()
defer p.mu.RUnlock()
return p.cfg, p.settings, p.client
}
func (p *Plugin) usageSnapshot() UsageStats {
p.mu.RLock()
defer p.mu.RUnlock()
return p.usage
}
func (p *Plugin) recordUsage(usage TokenUsage) {
if usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.TotalTokens == 0 {
return
}
p.mu.Lock()
defer p.mu.Unlock()
p.usage.GenerationsThisRun++
p.usage.PromptTokensThisRun += usage.PromptTokens
p.usage.CompletionTokensThisRun += usage.CompletionTokens
p.usage.TotalTokensThisRun += usage.TotalTokens
p.usage.LastRequest = usage
}
func settingsFromConfig(cfg *config.Config) Settings {
defaultLang := ""
if cfg != nil {
defaultLang = strings.TrimSpace(cfg.DefaultLang)
}
settings := Settings{
Provider: "openai",
APIKeyEnv: "OPENAI_API_KEY",
Model: defaultOpenAIModel,
EndpointVersion: defaultVersion("openai"),
Endpoint: defaultEndpoint("openai", defaultOpenAIModel, ""),
DefaultStatus: defaultGeneratedStatus,
DefaultLang: defaultLang,
Temperature: defaultTemperature,
MaxTokens: defaultMaxTokens,
TimeoutSeconds: defaultTimeoutSeconds,
}
if cfg == nil || cfg.Params == nil {
return settings
}
raw, _ := cfg.Params["ai_writer"]
values, ok := raw.(map[string]any)
if !ok {
if typed, ok := raw.(map[any]any); ok {
values = make(map[string]any, len(typed))
for k, v := range typed {
values[fmt.Sprint(k)] = v
}
}
}
if len(values) == 0 {
return settings
}
settings.Provider = lowerString(settingString(values, "provider", settings.Provider))
settings.APIKey = settingString(values, "api_key", "")
settings.APIKeyEnv = settingString(values, "api_key_env", defaultAPIKeyEnv(settings.Provider))
if settings.APIKey == "" && settings.APIKeyEnv != "" && !isValidEnvVarName(settings.APIKeyEnv) {
settings.APIKey = settings.APIKeyEnv
settings.APIKeyEnv = defaultAPIKeyEnv(settings.Provider)
}
settings.Model = settingString(values, "model", defaultModel(settings.Provider))
settings.EndpointVersion = settingString(values, "endpoint_version", defaultVersion(settings.Provider))
settings.Endpoint = resolveEndpoint(
settings.Provider,
settings.Model,
settingString(values, "endpoint", ""),
settings.EndpointVersion,
)
settings.SystemPrompt = settingString(values, "system_prompt", "")
settings.DefaultStatus = lowerString(settingString(values, "default_status", settings.DefaultStatus))
settings.DefaultAuthor = settingString(values, "default_author", "")
settings.DefaultLang = settingString(values, "default_lang", settings.DefaultLang)
settings.Temperature = settingFloat(values, "temperature", settings.Temperature)
settings.MaxTokens = settingInt(values, "max_tokens", settings.MaxTokens)
settings.TimeoutSeconds = settingInt(values, "timeout_seconds", settings.TimeoutSeconds)
if settings.TimeoutSeconds <= 0 {
settings.TimeoutSeconds = defaultTimeoutSeconds
}
if settings.MaxTokens <= 0 {
settings.MaxTokens = defaultMaxTokens
}
if settings.Temperature < 0 {
settings.Temperature = 0
}
if settings.APIKeyEnv == "" {
settings.APIKeyEnv = defaultAPIKeyEnv(settings.Provider)
}
if settings.Model == "" {
settings.Model = defaultModel(settings.Provider)
}
if settings.EndpointVersion == "" {
settings.EndpointVersion = defaultVersion(settings.Provider)
}
if settings.Endpoint == "" {
settings.Endpoint = defaultEndpoint(settings.Provider, settings.Model, settings.EndpointVersion)
}
if settings.DefaultStatus == "" {
settings.DefaultStatus = defaultGeneratedStatus
}
return settings
}
func (s Settings) redacted() Settings {
s.APIKey = ""
return s
}
func (s Settings) resolveAPIKey() (string, string) {
if strings.TrimSpace(s.APIKey) != "" {
return strings.TrimSpace(s.APIKey), "config"
}
envName := strings.TrimSpace(s.APIKeyEnv)
if envName == "" {
envName = defaultAPIKeyEnv(s.Provider)
}
if envName != "" {
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
return value, "env:" + envName
}
}
return "", "missing"
}
func requestCompletion(ctx context.Context, client *http.Client, settings Settings, prompt string) (string, TokenUsage, error) {
switch strings.ToLower(strings.TrimSpace(settings.Provider)) {
case "openai":
return requestOpenAI(ctx, client, settings, prompt)
case "anthropic", "claude":
return requestAnthropic(ctx, client, settings, prompt)
case "gemini", "google":
return requestGemini(ctx, client, settings, prompt)
default:
return "", TokenUsage{}, fmt.Errorf("unsupported AI provider %q", settings.Provider)
}
}
func requestOpenAI(ctx context.Context, client *http.Client, settings Settings, prompt string) (string, TokenUsage, error) {
key, source := settings.resolveAPIKey()
if key == "" {
return "", TokenUsage{}, fmt.Errorf("OpenAI API key is not configured (%s)", source)
}
payload := map[string]any{
"model": settings.Model,
"input": prompt,
"temperature": settings.Temperature,
"max_output_tokens": settings.MaxTokens,
}
var out struct {
OutputText string `json:"output_text"`
Output []struct {
Content []struct {
Text string `json:"text"`
Type string `json:"type"`
} `json:"content"`
} `json:"output"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := doJSON(ctx, client, http.MethodPost, settings.Endpoint, key, "bearer", payload, &out); err != nil {
return "", TokenUsage{}, err
}
usage := normalizeUsage(out.Usage.InputTokens, out.Usage.OutputTokens, out.Usage.TotalTokens)
if strings.TrimSpace(out.OutputText) != "" {
return out.OutputText, usage, nil
}
var b strings.Builder
for _, item := range out.Output {
for _, content := range item.Content {
if strings.TrimSpace(content.Text) != "" {
b.WriteString(content.Text)
}
}
}
text, err := requireText(b.String(), "OpenAI")
return text, usage, err
}
func requestAnthropic(ctx context.Context, client *http.Client, settings Settings, prompt string) (string, TokenUsage, error) {
key, source := settings.resolveAPIKey()
if key == "" {
return "", TokenUsage{}, fmt.Errorf("Anthropic API key is not configured (%s)", source)
}
payload := map[string]any{
"model": settings.Model,
"max_tokens": settings.MaxTokens,
"temperature": settings.Temperature,
"messages": []map[string]any{
{"role": "user", "content": prompt},
},
}
var out struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
if err := doJSON(ctx, client, http.MethodPost, settings.Endpoint, key, "anthropic", payload, &out); err != nil {
return "", TokenUsage{}, err
}
usage := normalizeUsage(out.Usage.InputTokens, out.Usage.OutputTokens, 0)
var b strings.Builder
for _, content := range out.Content {
if strings.TrimSpace(content.Text) != "" {
b.WriteString(content.Text)
}
}
text, err := requireText(b.String(), "Anthropic")
return text, usage, err
}
func requestGemini(ctx context.Context, client *http.Client, settings Settings, prompt string) (string, TokenUsage, error) {
key, source := settings.resolveAPIKey()
if key == "" {
return "", TokenUsage{}, fmt.Errorf("Gemini API key is not configured (%s)", source)
}
endpoint, err := endpointWithQueryKey(settings.Endpoint, key)
if err != nil {
return "", TokenUsage{}, err
}
payload := map[string]any{
"contents": []map[string]any{
{"role": "user", "parts": []map[string]any{{"text": prompt}}},
},
"generationConfig": map[string]any{
"temperature": settings.Temperature,
"maxOutputTokens": settings.MaxTokens,
},
}
var out struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
UsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
} `json:"usageMetadata"`
}
if err := doJSON(ctx, client, http.MethodPost, endpoint, "", "", payload, &out); err != nil {
return "", TokenUsage{}, err
}
usage := normalizeUsage(out.UsageMetadata.PromptTokenCount, out.UsageMetadata.CandidatesTokenCount, out.UsageMetadata.TotalTokenCount)
var b strings.Builder
for _, candidate := range out.Candidates {
for _, part := range candidate.Content.Parts {
if strings.TrimSpace(part.Text) != "" {
b.WriteString(part.Text)
}
}
}
text, err := requireText(b.String(), "Gemini")
return text, usage, err
}
func doJSON(ctx context.Context, client *http.Client, method, endpoint, key, authStyle string, payload any, out any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
switch authStyle {
case "bearer":
req.Header.Set("Authorization", "Bearer "+key)
case "anthropic":
req.Header.Set("x-api-key", key)
req.Header.Set("anthropic-version", "2023-06-01")
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
limited := io.LimitReader(resp.Body, 4*1024*1024)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
message, _ := io.ReadAll(limited)
return fmt.Errorf("AI provider returned %s: %s", resp.Status, strings.TrimSpace(string(message)))
}
if out == nil {
return nil
}
if err := json.NewDecoder(limited).Decode(out); err != nil {
return err
}
return nil
}
func normalizeUsage(prompt, completion, total int) TokenUsage {
if total == 0 && (prompt != 0 || completion != 0) {
total = prompt + completion
}
return TokenUsage{
PromptTokens: prompt,
CompletionTokens: completion,
TotalTokens: total,
}
}
func buildPrompt(settings Settings, req GenerateRequest) string {
var b strings.Builder
b.WriteString("You are writing content for Foundry, a Markdown-driven CMS written in Go.\n")
b.WriteString("Return exactly one complete Markdown post. Do not wrap the answer in code fences. Do not include commentary before or after the document.\n")
b.WriteString("The document must start with YAML frontmatter between --- markers, followed by a polished Markdown body.\n")
b.WriteString("Use frontmatter keys: title, slug, date, status, summary, tags, categories, author, lang, layout.\n")
b.WriteString("Foundry will add AI provenance frontmatter automatically; do not invent provider usage metadata.\n")
b.WriteString("Use layout: post. Use valid YAML arrays for tags and categories. Use status draft unless the request says otherwise.\n")
b.WriteString("Write helpful, original prose with clear headings, concise paragraphs, and practical examples when useful.\n")
if strings.TrimSpace(settings.SystemPrompt) != "" {
b.WriteString("\nAdditional site instructions:\n")
b.WriteString(strings.TrimSpace(settings.SystemPrompt))
b.WriteString("\n")
}
b.WriteString("\nRequested post options:\n")
appendPromptField(&b, "Title", req.Title)
appendPromptField(&b, "Slug", req.Slug)
appendPromptField(&b, "Status", firstNonEmpty(req.Status, settings.DefaultStatus))
appendPromptField(&b, "Author", firstNonEmpty(req.Author, settings.DefaultAuthor))
appendPromptField(&b, "Language", firstNonEmpty(req.Lang, settings.DefaultLang))
appendPromptField(&b, "Summary", req.Summary)
appendPromptList(&b, "Tags", req.Tags)
appendPromptList(&b, "Categories", req.Categories)
b.WriteString("\nUser prompt:\n")
b.WriteString(strings.TrimSpace(req.Prompt))
b.WriteString("\n")
return b.String()
}
func completeMarkdownDocument(markdown string, settings Settings, req GenerateRequest) (string, string, string, string) {
title := firstNonEmpty(req.Title, frontmatterValue(markdown, "title"), titleFromPrompt(req.Prompt))
slug := slugify(firstNonEmpty(req.Slug, frontmatterValue(markdown, "slug"), title))
status := lowerString(firstNonEmpty(req.Status, frontmatterValue(markdown, "status"), settings.DefaultStatus, defaultGeneratedStatus))
author := firstNonEmpty(req.Author, frontmatterValue(markdown, "author"), settings.DefaultAuthor)
lang := firstNonEmpty(req.Lang, frontmatterValue(markdown, "lang"), settings.DefaultLang)
summary := firstNonEmpty(req.Summary, frontmatterValue(markdown, "summary"))
if hasFrontmatter(markdown) {
return markAIGenerated(markdown, settings), title, slug, status
}
var b strings.Builder
b.WriteString("---\n")
writeYAMLString(&b, "title", title)
writeYAMLString(&b, "slug", slug)
writeYAMLString(&b, "date", time.Now().UTC().Format(time.RFC3339))
writeYAMLString(&b, "status", status)
if summary != "" {
writeYAMLString(&b, "summary", summary)
}
writeYAMLList(&b, "tags", req.Tags)
writeYAMLList(&b, "categories", req.Categories)
if author != "" {
writeYAMLString(&b, "author", author)
}
if lang != "" {
writeYAMLString(&b, "lang", lang)
}
writeYAMLString(&b, "layout", "post")
writeYAMLBool(&b, "ai_generated", true)
writeYAMLString(&b, "ai_provider", settings.Provider)
writeYAMLString(&b, "ai_model", settings.Model)
writeYAMLString(&b, "ai_generated_at", time.Now().UTC().Format(time.RFC3339))
b.WriteString("---\n\n")
b.WriteString(strings.TrimSpace(markdown))
b.WriteString("\n")
return b.String(), title, slug, status
}
func markAIGenerated(markdown string, settings Settings) string {
if !hasFrontmatter(markdown) || frontmatterValue(markdown, "ai_generated") != "" {
return markdown
}
var b strings.Builder
b.WriteString("---\n")
writeYAMLBool(&b, "ai_generated", true)
writeYAMLString(&b, "ai_provider", settings.Provider)
writeYAMLString(&b, "ai_model", settings.Model)
writeYAMLString(&b, "ai_generated_at", time.Now().UTC().Format(time.RFC3339))
return strings.Replace(markdown, "---\n", b.String(), 1)
}
func writePost(cfg *config.Config, markdown string) (string, error) {
if cfg == nil {
return "", fmt.Errorf("config is required")
}
contentRoot := strings.TrimSpace(cfg.ContentDir)
if contentRoot == "" {
contentRoot = "content"
}
postsDir := strings.TrimSpace(cfg.Content.PostsDir)
if postsDir == "" {
postsDir = "posts"
}
postsRoot, err := safepath.ResolveRelativeUnderRoot(contentRoot, postsDir)
if err != nil {
return "", err
}
if err := os.MkdirAll(postsRoot, 0o755); err != nil {
return "", err
}
baseName := "ai-post-" + time.Now().UTC().Format("20060102-150405")
for i := 0; i < 1000; i++ {
name := baseName + ".md"
if i > 0 {
name = baseName + "-" + strconv.Itoa(i+1) + ".md"
}
target, err := safepath.ResolveRelativeUnderRoot(postsRoot, name)
if err != nil {
return "", err
}
if _, err := os.Stat(target); err == nil {
continue
} else if !os.IsNotExist(err) {
return "", err
}
if err := os.WriteFile(target, []byte(markdown), 0o644); err != nil {
return "", err
}
return target, nil
}
return "", fmt.Errorf("could not allocate a unique post filename")
}
func countAIWrittenPosts(cfg *config.Config) int {
root, err := postsRoot(cfg)
if err != nil {
return 0
}
count := 0
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil || d == nil || d.IsDir() || !strings.HasSuffix(strings.ToLower(d.Name()), ".md") {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return nil
}
target, err := safepath.ResolveRelativeUnderRoot(root, rel)
if err != nil {
return nil
}
b, err := os.ReadFile(target)
if err != nil {
return nil
}
if strings.EqualFold(frontmatterValue(string(b), "ai_generated"), "true") {
count++
}
return nil
})
return count
}
func postsRoot(cfg *config.Config) (string, error) {
if cfg == nil {
return "", fmt.Errorf("config is required")
}
contentRoot := strings.TrimSpace(cfg.ContentDir)
if contentRoot == "" {
contentRoot = "content"
}
postsDir := strings.TrimSpace(cfg.Content.PostsDir)
if postsDir == "" {
postsDir = "posts"
}
return safepath.ResolveRelativeUnderRoot(contentRoot, postsDir)
}
func normalizeMarkdown(input string) string {
out := strings.TrimSpace(input)
if strings.HasPrefix(out, "```") {
lines := strings.Split(out, "\n")
if len(lines) >= 2 {
first := strings.TrimSpace(lines[0])
last := strings.TrimSpace(lines[len(lines)-1])
if strings.HasPrefix(first, "```") && strings.HasPrefix(last, "```") {
out = strings.Join(lines[1:len(lines)-1], "\n")
}
}
}
return strings.TrimSpace(out) + "\n"
}
func slugify(input string) string {
input = strings.ToLower(strings.TrimSpace(input))
var b strings.Builder
lastDash := false
for _, r := range input {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
lastDash = false
case r == '-' || r == '_' || unicode.IsSpace(r):
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(b.String(), "-")
}
func hasFrontmatter(markdown string) bool {
markdown = strings.TrimSpace(markdown)
if !strings.HasPrefix(markdown, "---\n") {
return false
}
return strings.Contains(markdown[4:], "\n---")
}
func frontmatterValue(markdown, key string) string {
if !hasFrontmatter(markdown) {
return ""
}
end := strings.Index(strings.TrimPrefix(markdown, "---\n"), "\n---")
if end < 0 {
return ""
}
block := strings.TrimPrefix(markdown, "---\n")[:end]
for _, match := range frontmatterValuePattern.FindAllStringSubmatch(block, -1) {
if strings.EqualFold(match[1], key) {
return strings.TrimSpace(strings.Trim(match[2], `"'`))
}
}
return ""
}
func endpointWithQueryKey(endpoint, key string) (string, error) {
u, err := url.Parse(endpoint)
if err != nil {
return "", err
}
query := u.Query()
if query.Get("key") == "" {
query.Set("key", key)
}
u.RawQuery = query.Encode()
return u.String(), nil
}
func requireText(text, provider string) (string, error) {
text = strings.TrimSpace(text)
if text == "" {
return "", fmt.Errorf("%s response did not include generated text", provider)
}
return text, nil
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeJSONError(w http.ResponseWriter, status int, err error) {
message := "request failed"
if err != nil {
message = err.Error()
}
writeJSON(w, status, map[string]string{"message": message})
}
func settingString(values map[string]any, key, fallback string) string {
if values == nil {
return fallback
}
switch v := values[key].(type) {
case string:
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
case fmt.Stringer:
if strings.TrimSpace(v.String()) != "" {
return strings.TrimSpace(v.String())
}
}
return fallback
}
func settingInt(values map[string]any, key string, fallback int) int {
switch v := values[key].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
if parsed, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return parsed
}
}
return fallback
}
func settingFloat(values map[string]any, key string, fallback float64) float64 {
switch v := values[key].(type) {
case float64:
return v
case float32:
return float64(v)
case int:
return float64(v)
case string:
if parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
return parsed
}
}
return fallback
}
func defaultAPIKeyEnv(provider string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "anthropic", "claude":
return "ANTHROPIC_API_KEY"
case "gemini", "google":
return "GEMINI_API_KEY"
default:
return "OPENAI_API_KEY"
}
}
func isValidEnvVarName(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
for i, r := range value {
if i == 0 && !(r == '_' || unicode.IsLetter(r)) {
return false
}
if !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return true
}
func defaultModel(provider string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "anthropic", "claude":
return defaultAnthropicModel
case "gemini", "google":
return defaultGeminiModel
default:
return defaultOpenAIModel
}
}
func defaultVersion(provider string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "anthropic", "claude":
return defaultAnthropicVersion
case "gemini", "google":
return defaultGeminiVersion
default:
return defaultOpenAIVersion
}
}
func normalizedEndpointVersion(provider, version string) string {
version = strings.TrimSpace(version)
if version != "" {
return version
}
return defaultVersion(provider)
}
func resolveEndpoint(provider, model, endpointOverride, endpointVersionValue string) string {
endpointOverride = strings.TrimSpace(endpointOverride)
if endpointOverride == "" {
return defaultEndpoint(provider, model, endpointVersionValue)
}
return strings.ReplaceAll(endpointOverride, "{version}", normalizedEndpointVersion(provider, endpointVersionValue))
}
func defaultEndpoint(provider, model, endpointVersionValue string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "anthropic", "claude":
return strings.ReplaceAll(defaultAnthropicURL, "{version}", normalizedEndpointVersion(provider, endpointVersionValue))
case "gemini", "google":
endpoint := strings.ReplaceAll(defaultGeminiEndpoint, "{version}", normalizedEndpointVersion(provider, endpointVersionValue))
return fmt.Sprintf(endpoint, url.PathEscape(firstNonEmpty(model, defaultGeminiModel)))
default:
return strings.ReplaceAll(defaultOpenAIEndpoint, "{version}", normalizedEndpointVersion(provider, endpointVersionValue))
}
}
func lowerString(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func appendPromptField(b *strings.Builder, label, value string) {
if strings.TrimSpace(value) == "" {
return
}
b.WriteString("- ")
b.WriteString(label)
b.WriteString(": ")
b.WriteString(strings.TrimSpace(value))
b.WriteString("\n")
}
func appendPromptList(b *strings.Builder, label string, values []string) {
cleaned := cleanStringList(values)
if len(cleaned) == 0 {
return
}
appendPromptField(b, label, strings.Join(cleaned, ", "))
}
func writeYAMLString(b *strings.Builder, key, value string) {
b.WriteString(key)
b.WriteString(": ")
b.WriteString(strconv.Quote(strings.TrimSpace(value)))
b.WriteString("\n")
}
func writeYAMLBool(b *strings.Builder, key string, value bool) {
b.WriteString(key)
if value {
b.WriteString(": true\n")
return
}
b.WriteString(": false\n")
}
func writeYAMLList(b *strings.Builder, key string, values []string) {
cleaned := cleanStringList(values)
if len(cleaned) == 0 {
b.WriteString(key)
b.WriteString(": []\n")
return
}
b.WriteString(key)
b.WriteString(":\n")
for _, value := range cleaned {
b.WriteString(" - ")
b.WriteString(strconv.Quote(value))
b.WriteString("\n")
}
}
func cleanStringList(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, value)
}
return out
}
func titleFromPrompt(prompt string) string {
fields := strings.Fields(strings.TrimSpace(prompt))
if len(fields) == 0 {
return "AI Generated Post"
}
if len(fields) > 8 {
fields = fields[:8]
}
title := strings.Join(fields, " ")
title = strings.Trim(title, ".,:;!?\"'")
if title == "" {
return "AI Generated Post"
}
return title
}
func init() {
plugins.Register(pluginName, func() plugins.Plugin { return New() })
}
package readingtime
import (
"fmt"
"html/template"
"strings"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/renderer"
)
type Plugin struct{}
func (p *Plugin) Name() string {
return "readingtime"
}
func countWords(s string) int {
return len(strings.Fields(s))
}
func estimateMinutes(words int) int {
const wordsPerMinute = 200
mins := words / wordsPerMinute
if words%wordsPerMinute != 0 {
mins++
}
if mins < 1 {
mins = 1
}
return mins
}
func (p *Plugin) OnDocumentParsed(doc *content.Document) error {
_ = doc
return nil
}
func (p *Plugin) OnContext(ctx *renderer.ViewData) error {
if ctx.Page == nil {
return nil
}
if ctx.Data == nil {
ctx.Data = map[string]any{}
}
words := countWords(ctx.Page.RawBody)
ctx.Data["reading_time"] = estimateMinutes(words)
ctx.Data["word_count"] = words
return nil
}
func (p *Plugin) OnHTMLSlots(ctx *renderer.ViewData, slots *renderer.Slots) error {
if ctx.Page == nil || ctx.Page.Type != "post" {
return nil
}
wordCount := countWords(ctx.Page.RawBody)
readingTime := estimateMinutes(wordCount)
html := template.HTML(fmt.Sprintf(`
<div class="meta-panel-block">
<h3>Reading</h3>
<div class="meta-list">
<div><strong>Reading time:</strong> %v min</div>
<div><strong>Words:</strong> %v</div>
</div>
</div>
`, readingTime, wordCount))
slots.Add("post.sidebar.top", html)
return nil
}
func init() {
plugins.Register("readingtime", func() plugins.Plugin {
return &Plugin{}
})
}
package relatedposts
import (
"html/template"
"sort"
"strings"
"sync"
"time"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/renderer"
)
type Plugin struct {
mu sync.RWMutex
related map[string][]Item
}
type Item struct {
Title string
URL string
Summary string
Lang string
Type string
Date *time.Time
Score int
}
func (p *Plugin) Name() string {
return "relatedposts"
}
func (p *Plugin) OnRoutesAssigned(graph *content.SiteGraph) error {
result := make(map[string][]Item, len(graph.Documents))
for _, doc := range graph.Documents {
if doc == nil || doc.Draft {
continue
}
candidates := p.computeRelated(graph, doc)
result[doc.ID] = candidates
}
p.mu.Lock()
p.related = result
p.mu.Unlock()
return nil
}
func (p *Plugin) OnContext(ctx *renderer.ViewData) error {
if ctx.Page == nil {
return nil
}
p.mu.RLock()
items := cloneItems(p.related[ctx.Page.ID])
p.mu.RUnlock()
if ctx.Data == nil {
ctx.Data = map[string]any{}
}
ctx.Data["related_posts"] = items
ctx.Data["has_related_posts"] = len(items) > 0
return nil
}
func (p *Plugin) OnHTMLSlots(ctx *renderer.ViewData, slots *renderer.Slots) error {
if ctx.Page == nil || ctx.Page.Type != "post" {
return nil
}
p.mu.RLock()
items := cloneItems(p.related[ctx.Page.ID])
p.mu.RUnlock()
if len(items) == 0 {
return nil
}
var sb strings.Builder
sb.WriteString(`
<section class="home-section">
<div class="section-heading">
<div>
<h2>Related posts</h2>
<p>More content you might want to read next.</p>
</div>
</div>
<div class="post-grid">
`)
for _, item := range items {
sb.WriteString(`<article class="post-card">`)
sb.WriteString(`<div class="post-card-header"><div>`)
sb.WriteString(`<h3><a href="`)
sb.WriteString(template.HTMLEscapeString(item.URL))
sb.WriteString(`">`)
sb.WriteString(template.HTMLEscapeString(item.Title))
sb.WriteString(`</a></h3>`)
sb.WriteString(`<div class="post-card-meta">`)
if item.Date != nil {
sb.WriteString(template.HTMLEscapeString(item.Date.Format("Jan 2, 2006")))
} else {
sb.WriteString(`Related content`)
}
sb.WriteString(`</div>`)
sb.WriteString(`</div></div>`)
if strings.TrimSpace(item.Summary) != "" {
sb.WriteString(`<div class="post-card-summary">`)
sb.WriteString(template.HTMLEscapeString(item.Summary))
sb.WriteString(`</div>`)
}
sb.WriteString(`<div class="post-card-footer">`)
sb.WriteString(`<a class="post-card-link" href="`)
sb.WriteString(template.HTMLEscapeString(item.URL))
sb.WriteString(`">Read more</a>`)
sb.WriteString(`</div>`)
sb.WriteString(`</article>`)
}
sb.WriteString(`
</div>
</section>
`)
slots.Add("post.after_content", template.HTML(sb.String()))
return nil
}
func (p *Plugin) computeRelated(graph *content.SiteGraph, current *content.Document) []Item {
type scored struct {
doc *content.Document
score int
}
scoredDocs := make([]scored, 0)
for _, candidate := range graph.Documents {
if candidate == nil || candidate.Draft {
continue
}
if candidate.ID == current.ID {
continue
}
if candidate.Lang != current.Lang {
continue
}
if candidate.Type != current.Type {
continue
}
score := scoreDocuments(current, candidate)
if score <= 0 {
continue
}
scoredDocs = append(scoredDocs, scored{
doc: candidate,
score: score,
})
}
sort.Slice(scoredDocs, func(i, j int) bool {
if scoredDocs[i].score != scoredDocs[j].score {
return scoredDocs[i].score > scoredDocs[j].score
}
di := scoredDocs[i].doc.Date
dj := scoredDocs[j].doc.Date
switch {
case di != nil && dj != nil:
if !di.Equal(*dj) {
return di.After(*dj)
}
case di != nil && dj == nil:
return true
case di == nil && dj != nil:
return false
}
return scoredDocs[i].doc.Title < scoredDocs[j].doc.Title
})
// Fallback: if nothing is related by taxonomy, pick latest same-lang same-type docs.
if len(scoredDocs) == 0 {
fallback := make([]*content.Document, 0)
for _, candidate := range graph.Documents {
if candidate == nil || candidate.Draft {
continue
}
if candidate.ID == current.ID {
continue
}
if candidate.Lang != current.Lang {
continue
}
if candidate.Type != current.Type {
continue
}
fallback = append(fallback, candidate)
}
sort.Slice(fallback, func(i, j int) bool {
di := fallback[i].Date
dj := fallback[j].Date
switch {
case di != nil && dj != nil:
if !di.Equal(*dj) {
return di.After(*dj)
}
case di != nil && dj == nil:
return true
case di == nil && dj != nil:
return false
}
return fallback[i].Title < fallback[j].Title
})
limit := 3
if len(fallback) < limit {
limit = len(fallback)
}
items := make([]Item, 0, limit)
for _, doc := range fallback[:limit] {
items = append(items, Item{
Title: doc.Title,
URL: doc.URL,
Summary: doc.Summary,
Lang: doc.Lang,
Type: doc.Type,
Date: doc.Date,
Score: 0,
})
}
return items
}
limit := 5
if len(scoredDocs) < limit {
limit = len(scoredDocs)
}
items := make([]Item, 0, limit)
for _, entry := range scoredDocs[:limit] {
doc := entry.doc
items = append(items, Item{
Title: doc.Title,
URL: doc.URL,
Summary: doc.Summary,
Lang: doc.Lang,
Type: doc.Type,
Date: doc.Date,
Score: entry.score,
})
}
return items
}
func scoreDocuments(a, b *content.Document) int {
score := 0
for taxonomy, termsA := range a.Taxonomies {
termsB, ok := b.Taxonomies[taxonomy]
if !ok {
continue
}
shared := countSharedTerms(termsA, termsB)
if shared == 0 {
continue
}
switch taxonomy {
case "categories":
score += shared * 6
case "tags":
score += shared * 4
default:
score += shared * 2
}
}
return score
}
func countSharedTerms(a, b []string) int {
set := make(map[string]struct{}, len(a))
for _, v := range a {
set[v] = struct{}{}
}
count := 0
seen := make(map[string]struct{})
for _, v := range b {
if _, ok := set[v]; ok {
if _, dup := seen[v]; !dup {
count++
seen[v] = struct{}{}
}
}
}
return count
}
func cloneItems(in []Item) []Item {
if len(in) == 0 {
return nil
}
out := make([]Item, len(in))
copy(out, in)
return out
}
func init() {
plugins.Register("relatedposts", func() plugins.Plugin {
return &Plugin{
related: make(map[string][]Item),
}
})
}
package main
import (
"os"
"github.com/sphireinc/foundry/sdk/pluginrpc"
)
type handler struct{}
func (handler) Handshake(req pluginrpc.HandshakeRequest) (pluginrpc.HandshakeResponse, error) {
return pluginrpc.HandshakeResponse{
PluginName: req.PluginName,
ProtocolVersion: req.ProtocolVersion,
SupportedHooks: []string{pluginrpc.MethodContext},
}, nil
}
func (handler) Context(req pluginrpc.ContextRequest) (pluginrpc.ContextResponse, error) {
out := map[string]any{
"rpc_context_demo": map[string]any{
"enabled": true,
"title": req.Title,
"page_id": func() string {
if req.Page != nil {
return req.Page.ID
}
return ""
}(),
},
}
return pluginrpc.ContextResponse{Data: out}, nil
}
func (handler) Shutdown() error { return nil }
func main() {
server := pluginrpc.Server{
Reader: os.Stdin,
Writer: os.Stdout,
}
if err := server.Serve(handler{}); err != nil {
os.Exit(1)
}
}
package toc
import (
"fmt"
"html/template"
"regexp"
"strconv"
"strings"
"github.com/sphireinc/foundry/internal/content"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/renderer"
)
type Plugin struct{}
type Item struct {
Level int
Text string
ID string
}
var headingRE = regexp.MustCompile(`^(#{1,6})\s+(.+?)\s*$`)
var stripInlineCodeRE = regexp.MustCompile("`([^`]*)`")
var stripMarkdownLinkRE = regexp.MustCompile(`\[(.*?)\]\((.*?)\)`)
var stripEmphasisRE = regexp.MustCompile(`[*_~]+`)
var invalidSlugCharsRE = regexp.MustCompile(`[^a-z0-9\s-]`)
var multiDashRE = regexp.MustCompile(`-+`)
var multiSpaceRE = regexp.MustCompile(`\s+`)
func (p *Plugin) Name() string {
return "toc"
}
func (p *Plugin) OnDocumentParsed(doc *content.Document) error {
_ = doc
return nil
}
func (p *Plugin) OnContext(ctx *renderer.ViewData) error {
if ctx.Page == nil {
return nil
}
if ctx.Data == nil {
ctx.Data = map[string]any{}
}
items := extractTOC(ctx.Page.RawBody)
ctx.Data["toc"] = items
ctx.Data["has_toc"] = len(items) > 0
return nil
}
func (p *Plugin) OnAssets(ctx *renderer.ViewData, assetSet *renderer.AssetSet) error {
if ctx.Page == nil || ctx.Page.Type != "post" {
return nil
}
assetSet.AddStyle("/plugins/toc/css/toc.css")
return nil
}
func (p *Plugin) OnHTMLSlots(ctx *renderer.ViewData, slots *renderer.Slots) error {
if ctx.Page == nil || ctx.Page.Type != "post" {
return nil
}
items := extractTOC(ctx.Page.RawBody)
if len(items) == 0 {
return nil
}
var sb strings.Builder
sb.WriteString(`
<div class="meta-panel-block">
<h3>On this page</h3>
<div class="toc-list">
`)
for _, item := range items {
sb.WriteString(`<a class="toc-link toc-level-`)
sb.WriteString(strconv.Itoa(item.Level))
sb.WriteString(`" href="#`)
sb.WriteString(template.HTMLEscapeString(item.ID))
sb.WriteString(`">`)
sb.WriteString(template.HTMLEscapeString(item.Text))
sb.WriteString(`</a>`)
}
sb.WriteString(`
</div>
</div>
`)
slots.Add("post.sidebar.bottom", template.HTML(sb.String()))
return nil
}
func (p *Plugin) Commands() []plugins.Command {
return []plugins.Command{
{
Name: "toc",
Summary: "Inspect TOC plugin",
Description: "Shows information about the table of contents plugin.",
Run: func(ctx plugins.CommandContext) error {
_, err := fmt.Fprintln(ctx.Stdout, "TOC plugin is installed and available.")
return err
},
},
}
}
func extractTOC(body string) []Item {
lines := strings.Split(body, "\n")
items := make([]Item, 0)
used := make(map[string]int)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
matches := headingRE.FindStringSubmatch(line)
if len(matches) != 3 {
continue
}
level := len(matches[1])
text := normalizeHeadingText(matches[2])
if text == "" {
continue
}
id := slugify(text)
if id == "" {
continue
}
if n, exists := used[id]; exists {
n++
used[id] = n
id = id + "-" + strconv.Itoa(n)
} else {
used[id] = 0
}
items = append(items, Item{
Level: level,
Text: text,
ID: id,
})
}
return items
}
func normalizeHeadingText(s string) string {
s = strings.TrimSpace(s)
s = stripInlineCodeRE.ReplaceAllString(s, "$1")
s = stripMarkdownLinkRE.ReplaceAllString(s, "$1")
s = stripEmphasisRE.ReplaceAllString(s, "")
s = strings.TrimSpace(s)
return s
}
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = invalidSlugCharsRE.ReplaceAllString(s, "")
s = multiSpaceRE.ReplaceAllString(s, "-")
s = multiDashRE.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
func init() {
plugins.Register("toc", func() plugins.Plugin {
return &Plugin{}
})
}
package main
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const (
appName = "foundry"
entrypoint = "./cmd/foundry"
outputDir = "bin"
)
func main() {
version := getVersion()
commit := getGitCommit()
date := time.Now().UTC().Format(time.RFC3339)
targetGOOS := buildTargetGOOS()
targetGOARCH := buildTargetGOARCH()
if err := os.MkdirAll(outputDir, 0o755); err != nil {
fail("create output dir", err)
}
outputName := appName
if targetGOOS == "windows" {
outputName += ".exe"
}
outputPath := filepath.Join(outputDir, outputName)
ldflags := strings.Join([]string{
"-X github.com/sphireinc/foundry/internal/commands/version.Version=" + version,
"-X github.com/sphireinc/foundry/internal/commands/version.Commit=" + commit,
"-X github.com/sphireinc/foundry/internal/commands/version.Date=" + date,
}, " ")
fmt.Printf("Building %s\n", appName)
fmt.Printf(" version: %s\n", version)
fmt.Printf(" commit: %s\n", commit)
fmt.Printf(" built: %s\n", date)
fmt.Printf(" target: %s/%s\n", targetGOOS, targetGOARCH)
fmt.Printf(" output: %s\n", outputPath)
// Keep generated plugin imports up to date before building.
run("go", "run", "./cmd/plugin-sync")
// Build the main Foundry CLI binary.
runBuild(targetGOOS, targetGOARCH, "go", "build", "-ldflags", ldflags, "-o", outputPath, entrypoint)
sum, err := sha256File(outputPath)
if err != nil {
fail("compute checksum", err)
}
checksumPath := outputPath + ".sha256"
checksumBody := fmt.Sprintf("%s %s\n", sum, filepath.Base(outputPath))
if err := os.WriteFile(checksumPath, []byte(checksumBody), 0o644); err != nil {
fail("write checksum file", err)
}
archiveName := releaseArchiveName(targetGOOS, targetGOARCH)
archivePath := filepath.Join(outputDir, archiveName)
if err := createTarGz(archivePath, outputPath, filepath.Base(outputPath)); err != nil {
fail("create release archive", err)
}
archiveSum, err := sha256File(archivePath)
if err != nil {
fail("compute archive checksum", err)
}
archiveChecksumPath := archivePath + ".sha256"
archiveChecksumBody := fmt.Sprintf("%s %s\n", archiveSum, filepath.Base(archivePath))
if err := os.WriteFile(archiveChecksumPath, []byte(archiveChecksumBody), 0o644); err != nil {
fail("write archive checksum file", err)
}
fmt.Println("Build complete.")
fmt.Printf("Checksum: %s\n", sum)
fmt.Printf("Checksum file: %s\n", checksumPath)
fmt.Printf("Archive: %s\n", archivePath)
fmt.Printf("Archive checksum: %s\n", archiveChecksumPath)
fmt.Println("")
fmt.Printf("Run with: %s version\n", outputPath)
}
func buildTargetGOOS() string {
if value := strings.TrimSpace(os.Getenv("TARGET_GOOS")); value != "" {
return value
}
return runtime.GOOS
}
func buildTargetGOARCH() string {
if value := strings.TrimSpace(os.Getenv("TARGET_GOARCH")); value != "" {
return value
}
return runtime.GOARCH
}
func getVersion() string {
// Priority:
// 1. VERSION env var
// 2. git tag if available
// 3. dev
if v := strings.TrimSpace(os.Getenv("VERSION")); v != "" {
return v
}
if tag, err := outputQuiet("git", "describe", "--tags", "--abbrev=0"); err == nil && tag != "" {
return tag
}
return "dev"
}
func getGitCommit() string {
if commit, err := outputQuiet("git", "rev-parse", "--short", "HEAD"); err == nil && commit != "" {
return commit
}
return "none"
}
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func releaseArchiveName(goos, goarch string) string {
return fmt.Sprintf("foundry-%s-%s.tar.gz", goos, goarch)
}
func createTarGz(targetPath, sourcePath, archiveName string) error {
target, err := os.Create(targetPath)
if err != nil {
return err
}
defer target.Close()
gzw := gzip.NewWriter(target)
defer gzw.Close()
tw := tar.NewWriter(gzw)
defer tw.Close()
info, err := os.Stat(sourcePath)
if err != nil {
return err
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = archiveName
if err := tw.WriteHeader(header); err != nil {
return err
}
file, err := os.Open(sourcePath)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(tw, file)
return err
}
//func output(name string, args ...string) (string, error) {
// cmd := exec.Command(name, args...)
// cmd.Stderr = os.Stderr
// b, err := cmd.Output()
// if err != nil {
// return "", err
// }
// return strings.TrimSpace(string(b)), nil
//}
func outputQuiet(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
b, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
func run(name string, args ...string) {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
fail(fmt.Sprintf("%s %s", name, strings.Join(args, " ")), err)
}
}
func runBuild(goos, goarch, name string, args ...string) {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Env = append(os.Environ(),
"CGO_ENABLED=0",
"GOOS="+goos,
"GOARCH="+goarch,
)
if err := cmd.Run(); err != nil {
fail(fmt.Sprintf("%s %s", name, strings.Join(args, " ")), err)
}
}
func fail(step string, err error) {
_, _ = fmt.Fprintf(os.Stderr, "build-release: %s: %v\n", step, err)
os.Exit(1)
}
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
adminusers "github.com/sphireinc/foundry/internal/admin/users"
"github.com/sphireinc/foundry/internal/config"
"gopkg.in/yaml.v3"
)
const (
e2ePrefix = "e2e-"
configPath = "content/config/site.yaml"
)
func main() {
cfg, err := config.Load(configPath)
if err != nil {
fatalf("load config: %v", err)
}
if err := cleanupContent(cfg.ContentDir); err != nil {
fatalf("cleanup content: %v", err)
}
if err := cleanupBackups(cfg.Backup.Dir); err != nil {
fatalf("cleanup backups: %v", err)
}
if err := cleanupUsers(cfg.Admin.UsersFile); err != nil {
fatalf("cleanup users: %v", err)
}
if err := cleanupLocks(cfg.Admin.LockFile); err != nil {
fatalf("cleanup locks: %v", err)
}
if err := cleanupAudit(filepath.Join(cfg.DataDir, "admin", "audit.jsonl")); err != nil {
fatalf("cleanup audit: %v", err)
}
}
func cleanupContent(root string) error {
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
name := d.Name()
if !strings.Contains(strings.ToLower(name), e2ePrefix) {
return nil
}
if d.IsDir() {
if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) {
return err
}
return filepath.SkipDir
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
})
}
func cleanupBackups(dir string) error {
if strings.TrimSpace(dir) == "" {
return nil
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := strings.ToLower(entry.Name())
if !strings.Contains(name, e2ePrefix) {
continue
}
target := filepath.Join(dir, entry.Name())
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func cleanupUsers(path string) error {
entries, err := adminusers.Load(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
filtered := make([]adminusers.User, 0, len(entries))
changed := false
for _, entry := range entries {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(entry.Username)), e2ePrefix) {
changed = true
continue
}
filtered = append(filtered, entry)
}
if !changed {
return nil
}
return adminusers.Save(path, filtered)
}
type lockFile struct {
Locks []map[string]any `yaml:"locks"`
}
func cleanupLocks(path string) error {
if strings.TrimSpace(path) == "" {
return nil
}
body, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var file lockFile
if err := yaml.Unmarshal(body, &file); err != nil {
return err
}
filtered := make([]map[string]any, 0, len(file.Locks))
changed := false
for _, entry := range file.Locks {
sourcePath, _ := entry["source_path"].(string)
if strings.Contains(strings.ToLower(strings.TrimSpace(sourcePath)), e2ePrefix) {
changed = true
continue
}
filtered = append(filtered, entry)
}
if !changed {
return nil
}
file.Locks = filtered
out, err := yaml.Marshal(&file)
if err != nil {
return err
}
return os.WriteFile(path, out, 0o644)
}
func cleanupAudit(path string) error {
if strings.TrimSpace(path) == "" {
return nil
}
body, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
lines := bytes.Split(body, []byte{'\n'})
filtered := make([][]byte, 0, len(lines))
changed := false
for _, line := range lines {
if len(bytes.TrimSpace(line)) == 0 {
continue
}
if strings.Contains(strings.ToLower(string(line)), e2ePrefix) {
changed = true
continue
}
filtered = append(filtered, line)
}
if !changed {
return nil
}
if len(filtered) == 0 {
return os.WriteFile(path, []byte{}, 0o644)
}
out := bytes.Join(filtered, []byte{'\n'})
out = append(out, '\n')
return os.WriteFile(path, out, 0o644)
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
package main
import (
"fmt"
"os"
"os/exec"
)
func main() {
if err := run("go", "run", "./scripts/cmd/e2e-cleanup"); err != nil {
fatalf("pre-clean failed: %v", err)
}
testErr := run("npx", "playwright", "test")
cleanErr := run("go", "run", "./scripts/cmd/e2e-cleanup")
if cleanErr != nil {
fatalf("post-clean failed: %v", cleanErr)
}
if testErr != nil {
if exitErr, ok := testErr.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
fatalf("playwright failed: %v", testErr)
}
}
func run(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Env = os.Environ()
return cmd.Run()
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
package main
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"syscall"
)
const (
e2eTempPrefix = "foundry-e2e-"
)
func main() {
ctx, stop := signalContext()
defer stop()
root, err := os.Getwd()
if err != nil {
fatalf("get working directory: %v", err)
}
tempRoot, err := os.MkdirTemp("", e2eTempPrefix)
if err != nil {
fatalf("create temp workspace: %v", err)
}
defer os.RemoveAll(tempRoot)
tempContent := filepath.Join(tempRoot, "content")
tempData := filepath.Join(tempRoot, "data")
tempPublic := filepath.Join(tempRoot, "public")
tempBackups := filepath.Join(tempRoot, ".foundry", "backups")
tempOverlay := filepath.Join(tempRoot, "site.e2e.overlay.yaml")
if err := copyDir(filepath.Join(root, "content"), tempContent); err != nil {
fatalf("copy content: %v", err)
}
for _, dir := range []string{tempData, tempPublic, tempBackups} {
if err := os.MkdirAll(dir, 0o755); err != nil {
fatalf("create temp directory %s: %v", dir, err)
}
}
overlay := fmt.Sprintf(`content_dir: %q
public_dir: %q
data_dir: %q
backup:
dir: %q
admin:
users_file: %q
session_store_file: %q
lock_file: %q
`, tempContent, tempPublic, tempData, tempBackups,
filepath.Join(tempContent, "config", "admin-users.yaml"),
filepath.Join(tempData, "admin", "sessions.yaml"),
filepath.Join(tempData, "admin", "locks.yaml"),
)
if err := os.WriteFile(tempOverlay, []byte(overlay), 0o644); err != nil {
fatalf("write e2e config overlay: %v", err)
}
args := []string{"run", "./cmd/foundry", "--config-overlay", tempOverlay, "serve"}
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = root
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Env = os.Environ()
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return
}
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
fatalf("run foundry e2e server: %v", err)
}
}
func signalContext() (context.Context, context.CancelFunc) {
if runtime.GOOS == "windows" {
return context.WithCancel(context.Background())
}
return signalNotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
}
var signalNotifyContext = func(parent context.Context, signals ...os.Signal) (context.Context, context.CancelFunc) {
return signalNotifyContextImpl(parent, signals...)
}
func signalNotifyContextImpl(parent context.Context, signals ...os.Signal) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(parent)
ch := make(chan os.Signal, 1)
signalNotify(ch, signals...)
go func() {
select {
case <-ctx.Done():
case <-ch:
cancel()
}
signalStop(ch)
close(ch)
}()
return ctx, cancel
}
var signalNotify = func(ch chan<- os.Signal, signals ...os.Signal) {
signal.Notify(ch, signals...)
}
var signalStop = func(ch chan<- os.Signal) {
signal.Stop(ch)
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, info.Mode().Perm())
}
return copyFile(path, target, info.Mode())
})
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
package sdkassets
import (
"embed"
"io/fs"
"net/http"
"os"
"path/filepath"
)
//go:embed core/*.js admin/*.js frontend/*.js
var embedded embed.FS
func FS() fs.FS {
return embedded
}
func Handler() http.Handler {
return http.FileServer(http.FS(embedded))
}
func CopyToDir(target string) error {
return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
dst := filepath.Join(target, filepath.FromSlash(path))
if d.IsDir() {
return os.MkdirAll(dst, 0o755)
}
body, err := embedded.ReadFile(path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, body, 0o644)
})
}
package pluginrpc
import (
"bufio"
"encoding/json"
"fmt"
"io"
)
const (
MethodHandshake = "handshake"
MethodContext = "context"
MethodShutdown = "shutdown"
)
type Request struct {
ID int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type Response struct {
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
type HandshakeRequest struct {
PluginName string `json:"plugin_name"`
ProtocolVersion string `json:"protocol_version"`
RequestedHooks []string `json:"requested_hooks,omitempty"`
SandboxProfile string `json:"sandbox_profile,omitempty"`
AllowNetwork bool `json:"allow_network,omitempty"`
AllowFSWrite bool `json:"allow_filesystem_write,omitempty"`
AllowProcessExec bool `json:"allow_process_exec,omitempty"`
}
type HandshakeResponse struct {
PluginName string `json:"plugin_name"`
ProtocolVersion string `json:"protocol_version"`
SupportedHooks []string `json:"supported_hooks,omitempty"`
}
type ContextRequest struct {
Page *PagePayload `json:"page,omitempty"`
Data map[string]any `json:"data,omitempty"`
Lang string `json:"lang,omitempty"`
Title string `json:"title,omitempty"`
RequestPath string `json:"request_path,omitempty"`
}
type ContextResponse struct {
Data map[string]any `json:"data,omitempty"`
}
type PagePayload struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Lang string `json:"lang,omitempty"`
Status string `json:"status,omitempty"`
Title string `json:"title,omitempty"`
Slug string `json:"slug,omitempty"`
URL string `json:"url,omitempty"`
Layout string `json:"layout,omitempty"`
Summary string `json:"summary,omitempty"`
Draft bool `json:"draft,omitempty"`
Archived bool `json:"archived,omitempty"`
RawBody string `json:"raw_body,omitempty"`
HTMLBody string `json:"html_body,omitempty"`
Params map[string]any `json:"params,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
Taxonomies map[string][]string `json:"taxonomies,omitempty"`
}
type Handler interface {
Handshake(HandshakeRequest) (HandshakeResponse, error)
Context(ContextRequest) (ContextResponse, error)
Shutdown() error
}
type Server struct {
Reader io.Reader
Writer io.Writer
}
func (s Server) Serve(handler Handler) error {
decoder := json.NewDecoder(bufio.NewReader(s.Reader))
encoder := json.NewEncoder(s.Writer)
for {
var req Request
if err := decoder.Decode(&req); err != nil {
if err == io.EOF {
return nil
}
return err
}
resp := Response{ID: req.ID}
switch req.Method {
case MethodHandshake:
var body HandshakeRequest
if err := json.Unmarshal(req.Params, &body); err != nil {
resp.Error = err.Error()
} else if result, err := handler.Handshake(body); err != nil {
resp.Error = err.Error()
} else {
resp.Result = mustJSON(result)
}
case MethodContext:
var body ContextRequest
if err := json.Unmarshal(req.Params, &body); err != nil {
resp.Error = err.Error()
} else if result, err := handler.Context(body); err != nil {
resp.Error = err.Error()
} else {
resp.Result = mustJSON(result)
}
case MethodShutdown:
if err := handler.Shutdown(); err != nil {
resp.Error = err.Error()
}
if err := encoder.Encode(resp); err != nil {
return err
}
return nil
default:
resp.Error = fmt.Sprintf("unsupported method %q", req.Method)
}
if err := encoder.Encode(resp); err != nil {
return err
}
}
}
func mustJSON(v any) json.RawMessage {
body, err := json.Marshal(v)
if err != nil {
return json.RawMessage(`{}`)
}
return body
}