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) 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 f.Close()
enc := json.NewEncoder(f)
return enc.Encode(entry)
}
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
sessionStorePath := ""
if cfg != nil && cfg.Admin.SessionTTLMinutes > 0 {
ttl = time.Duration(cfg.Admin.SessionTTLMinutes) * time.Minute
}
if cfg != nil {
sessionStorePath = cfg.Admin.SessionStoreFile
}
return &Middleware{
cfg: cfg,
sessions: NewSessionManager(sessionStorePath, ttl),
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")
}
if user.Disabled || !users.VerifyPassword(user.PasswordHash, password) {
m.loginThrottler.Failure(r, username, time.Now())
return nil, fmt.Errorf("invalid username or password")
}
if user.TOTPEnabled && !VerifyTOTP(user.TOTPSecret, totpCode, time.Now()) {
m.loginThrottler.Failure(r, username, time.Now())
return nil, fmt.Errorf("two-factor authentication code is required")
}
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(user.TOTPSecret, totpCode, time.Now()),
}
session, err := m.sessions.Issue(identity, 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) 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 := m.sessions.Authenticate(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
}
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 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/rand"
"encoding/base64"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
type Session struct {
Token string `yaml:"token"`
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"`
IssuedAt time.Time `yaml:"issued_at"`
LastSeen time.Time `yaml:"last_seen"`
ExpiresAt time.Time `yaml:"expires_at"`
}
type sessionFile struct {
Sessions []Session `yaml:"sessions"`
}
type SessionManager struct {
path string
ttl time.Duration
mu sync.Mutex
sessions map[string]Session
}
func NewSessionManager(path string, ttl time.Duration) *SessionManager {
if ttl <= 0 {
ttl = 30 * time.Minute
}
m := &SessionManager{
path: path,
ttl: ttl,
sessions: make(map[string]Session),
}
_ = m.load()
return m
}
func (m *SessionManager) Issue(identity Identity, 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{
Token: token,
CSRFToken: csrfToken,
Username: identity.Username,
Name: identity.Name,
Email: identity.Email,
Role: identity.Role,
Capabilities: append([]string(nil), identity.Capabilities...),
MFAComplete: identity.MFAComplete,
IssuedAt: now,
LastSeen: now,
ExpiresAt: now.Add(m.ttl),
}
m.mu.Lock()
defer m.mu.Unlock()
m.sessions[token] = session
if err := m.saveLocked(); err != nil {
delete(m.sessions, token)
return Session{}, err
}
return session, nil
}
func (m *SessionManager) Authenticate(token string, now time.Time) (Session, bool) {
m.mu.Lock()
defer m.mu.Unlock()
session, ok := m.sessions[token]
if !ok {
return Session{}, false
}
if now.After(session.ExpiresAt) {
delete(m.sessions, token)
_ = m.saveLocked()
return Session{}, false
}
session.LastSeen = now
session.ExpiresAt = now.Add(m.ttl)
m.sessions[token] = session
_ = m.saveLocked()
return session, true
}
func (m *SessionManager) Revoke(token string) {
if token == "" {
return
}
m.mu.Lock()
delete(m.sessions, 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) TTL() time.Duration {
return m.ttl
}
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 {
if session.Token == "" || now.After(session.ExpiresAt) {
continue
}
m.sessions[session.Token] = 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 {
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 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))
}
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/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")
}
if all[i].TOTPEnabled && !VerifyTOTP(all[i].TOTPSecret, 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
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
}
found := false
for i := range all {
if strings.EqualFold(all[i].Username, username) {
all[i].TOTPSecret = secret
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")
}
if !VerifyTOTP(all[i].TOTPSecret, code, time.Now()) {
return fmt.Errorf("invalid two-factor code")
}
all[i].TOTPEnabled = true
found = true
break
}
if !found {
return fmt.Errorf("user not found: %s", username)
}
return users.Save(m.cfg.Admin.UsersFile, all)
}
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"
"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"
)
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) 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 firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
package httpadmin
import (
"net/http"
"strconv"
"strings"
adminauth "github.com/sphireinc/foundry/internal/admin/auth"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
)
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/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 {
revoked = r.auth.RevokeUserSessions(strings.TrimSpace(body.Username))
}
r.logAuditRequest(req, "session.revoke", "success", strings.TrimSpace(body.Username), map[string]string{"revoked": strconv.Itoa(revoked)})
writeJSON(w, http.StatusOK, admintypes.SessionRevokeResponse{Revoked: revoked})
}
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, nil)
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, nil)
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, nil)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
package httpadmin
import (
"net/http"
"net/http/pprof"
"strings"
)
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("/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) 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
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"
adminaudit "github.com/sphireinc/foundry/internal/admin/audit"
admintypes "github.com/sphireinc/foundry/internal/admin/types"
)
func registerManagementRoutes(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/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"},
{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"},
{pattern: r.routePath("/api/themes"), handler: http.HandlerFunc(r.handleThemes), 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"},
{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"},
{pattern: r.routePath("/api/audit"), handler: http.HandlerFunc(r.handleAudit), capability: "audit.read"},
}
}
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) 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
}
user, err := r.service.SaveUser(req.Context(), body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "user.save", "success", user.Username, nil)
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, nil)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
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) 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) 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)
}
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); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "plugin.enable", "success", body.Name, nil)
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)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "plugin.install", "success", record.Name, nil)
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)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
r.logAuditRequest(req, "plugin.update", "success", record.Name, nil)
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"})
}
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)
}
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"
)
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 (
"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"
)
type routeDef struct {
pattern string
handler http.Handler
public bool
capability string
}
type Registrar func(*Router) []routeDef
type Router struct {
cfg *config.Config
service *service.Service
auth *adminauth.Middleware
ui *adminui.Manager
registrars []Registrar
}
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
}
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 pm, ok := base.(interface {
Metadata() map[string]plugins.Metadata
}); ok {
opts = append(opts, service.WithPluginMetadata(pm.Metadata))
}
svc := service.New(cfg, opts...)
router := New(cfg, svc)
return WrapHooks(base, router)
}
func (r *Router) RegisterRegistrar(reg Registrar) {
if reg == nil {
return
}
r.registrars = append(r.registrars, reg)
}
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))
}
}
}
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
}
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)
}
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 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
}
//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"
"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 := fields.DefinitionsFor(s.cfg, documentKindFromSourcePath(sourcePath, s.cfg))
if req.Fields != nil {
fm.Fields = fields.Normalize(req.Fields)
}
fm.Fields = fields.ApplyDefaults(fields.Normalize(fm.Fields), defs)
if errs := fields.Validate(fm.Fields, defs, s.cfg.Fields.AllowAnything); 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 := fields.DefinitionsFor(s.cfg, kind)
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
}
if req.Fields != nil {
fm.Fields = fields.Normalize(req.Fields)
}
defs := fields.DefinitionsFor(s.cfg, documentKindFromSourcePath(strings.TrimSpace(req.SourcePath), s.cfg))
fm.Fields = fields.ApplyDefaults(fields.Normalize(fm.Fields), defs)
fieldErrors := make([]string, 0)
for _, err := range fields.Validate(fm.Fields, defs, s.cfg.Fields.AllowAnything) {
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 {
return types.DocumentSummary{
ID: doc.ID,
Type: doc.Type,
Lang: doc.Lang,
Status: doc.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 (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
}
lock, err := s.DocumentLock(ctx, displayDocumentPath(doc.SourcePath, s.cfg.ContentDir))
if err != nil {
lock = nil
}
return types.DocumentDetail{
DocumentSummary: toSummary(doc),
RawBody: string(raw),
HTMLBody: string(doc.HTMLBody),
Params: doc.Params,
Fields: doc.Fields,
FieldSchema: toFieldSchema(fields.DefinitionsFor(s.cfg, doc.Type)),
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 == pagesDir || strings.HasPrefix(normalized, 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 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 (
"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
_, err := 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"
"os"
"path/filepath"
"strings"
"time"
adminauth "github.com/sphireinc/foundry/internal/admin/auth"
"github.com/sphireinc/foundry/internal/admin/types"
adminui "github.com/sphireinc/foundry/internal/admin/ui"
"github.com/sphireinc/foundry/internal/admin/users"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/plugins"
"github.com/sphireinc/foundry/internal/theme"
)
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) 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: "fields", Title: "Fields", Capability: "config.manage", Writable: true, Source: "core", Schema: toFieldSchema(flattenFieldSchemaDefinitions(s.cfg.Fields.Schemas))},
{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,
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) 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 (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
for i := range all {
if strings.EqualFold(all[i].Username, username) {
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
}
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 flattenFieldSchemaDefinitions(sets map[string]config.FieldSchemaSet) []config.FieldDefinition {
out := make([]config.FieldDefinition, 0)
for _, set := range sets {
out = append(out, set.Fields...)
}
return out
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
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)
}
return users.Save(s.cfg.Admin.UsersFile, out)
}
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) 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.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)
}
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.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) 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)
}
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)
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)
}
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) error {
_ = ctx
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) (*types.PluginRecord, error) {
_ = ctx
meta, err := plugins.Install(plugins.InstallOptions{
PluginsDir: s.cfg.PluginsDir,
URL: url,
Name: name,
})
if err != nil {
return nil, err
}
return &types.PluginRecord{
Name: meta.Name,
Title: meta.Title,
Version: meta.Version,
Description: meta.Description,
Author: meta.Author,
Repo: meta.Repo,
Status: "installed",
Health: plugins.DiagnoseInstalled(s.cfg.PluginsDir, meta, false).Status,
FoundryAPI: meta.FoundryAPI,
MinFoundryVersion: meta.MinFoundryVersion,
CompatibilityVersion: meta.CompatibilityVersion,
Requires: append([]string(nil), meta.Requires...),
Dependencies: toPluginDependencies(meta.Dependencies),
ConfigSchema: toFieldSchema(meta.ConfigSchema),
}, nil
}
func (s *Service) UpdatePlugin(ctx context.Context, name string) (*types.PluginRecord, error) {
_ = ctx
meta, err := plugins.UpdateInstalled(s.cfg.PluginsDir, name)
if err != nil {
return nil, err
}
report := plugins.DiagnoseInstalled(s.cfg.PluginsDir, meta, containsString(s.cfg.Plugins.Enabled, meta.Name))
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),
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))
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),
Diagnostics: toPluginDiagnostics(report.Diagnostics),
}, nil
}
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
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
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"
}
}
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
}
package service
import (
"context"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"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
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
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(),
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,
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.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.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,
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
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...)
return existing
}
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"
"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 {
Token string `yaml:"token"`
ExpiresAt time.Time `yaml:"expires_at"`
} `yaml:"sessions"`
}
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) 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
}
status.Activity.ActiveSessions = countActiveSessions(s.cfg.Admin.SessionStoreFile, now)
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 {
if strings.TrimSpace(path) == "" {
return 0
}
body, err := os.ReadFile(path)
if err != nil {
return 0
}
var file runtimeSessionFile
if err := yaml.Unmarshal(body, &file); err != nil {
return 0
}
count := 0
for _, session := range file.Sessions {
if strings.TrimSpace(session.Token) == "" || now.After(session.ExpiresAt) {
continue
}
count++
}
return count
}
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"
)
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) }
type GraphLoader func(context.Context, *config.Config, bool) (*content.SiteGraph, error)
type StatusProvider interface {
Name() string
Provide(context.Context, *Service, *types.SystemStatus) error
}
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
type Option func(*Service)
func WithFS(fs FileSystem) Option {
return func(s *Service) {
if fs != nil {
s.fs = fs
}
}
}
func WithGraphLoader(loader GraphLoader) Option {
return func(s *Service) {
if loader != nil {
s.loadGraph = loader
}
}
}
func WithPluginMetadata(loader func() map[string]plugins.Metadata) Option {
return func(s *Service) {
if loader != nil {
s.pluginMetadata = loader
}
}
}
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(contentStatusProvider{})
s.RegisterStatusProvider(themeStatusProvider{})
s.RegisterStatusProvider(pluginStatusProvider{})
s.RegisterStatusProvider(taxonomyStatusProvider{})
return s
}
func (s *Service) Config() *config.Config {
return s.cfg
}
func (s *Service) RegisterStatusProvider(provider StatusProvider) {
if provider == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.statusProviders[provider.Name()] = provider
}
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
}
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
}
func (s *Service) invalidateGraphCache() {
s.mu.Lock()
s.graphCache = make(map[bool]cachedGraph)
s.mu.Unlock()
}
package service
import (
"context"
"sort"
"github.com/sphireinc/foundry/internal/admin/types"
"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 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 ui
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/safepath"
"gopkg.in/yaml.v3"
)
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"`
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"`
}
type Diagnostic struct {
Severity string
Path string
Message string
}
type ValidationResult struct {
Valid bool
Diagnostics []Diagnostic
}
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",
}
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
}
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
}
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"
)
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
}
if path := filepath.Join(m.themeRoot(), "assets", filepath.FromSlash(name)); 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"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
hashAlgorithm = "pbkdf2-sha256"
hashIterations = 120000
hashKeyLength = 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 HashPassword(password string) (string, error) {
// TODO: this is all from Flight Manager, I should revisit this for security
password = strings.TrimSpace(password)
if password == "" {
return "", fmt.Errorf("password cannot be empty")
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := pbkdf2SHA256([]byte(password), salt, hashIterations, hashKeyLength)
return fmt.Sprintf(
"%s$%d$%s$%s",
hashAlgorithm,
hashIterations,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
func VerifyPassword(encodedHash, password string) bool {
// TODO: See HashPassword
parts := strings.Split(strings.TrimSpace(encodedHash), "$")
if len(parts) != 4 || parts[0] != hashAlgorithm {
return false
}
iterations, err := parsePositiveInt(parts[1])
if err != nil {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
got := pbkdf2SHA256([]byte(password), salt, iterations, len(want))
if len(got) != len(want) {
return false
}
return subtle.ConstantTimeCompare(got, want) == 1
}
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 {
// TODO: See HashPassword
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 {
// TODO: See HashPassword
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 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 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 (
"archive/zip"
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
"github.com/sphireinc/foundry/internal/consts"
"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 := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
file, err := os.Create(target)
if err != nil {
return err
}
defer file.Close()
zw := zip.NewWriter(file)
defer zw.Close()
for _, rel := range []string{cfg.ContentDir, cfg.DataDir} {
if err := addPathToZip(zw, rel, rel); err != nil {
return err
}
}
if _, err := os.Stat(consts.ConfigFilePath); err == nil {
if err := addPathToZip(zw, consts.ConfigFilePath, consts.ConfigFilePath); 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 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 {
return nil
}
if info.IsDir() {
return nil
}
rel, err := filepath.Rel(filepath.Dir(root), current)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
writer, err := zw.Create(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 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 (
"context"
"fmt"
"reflect"
"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/plugins"
"github.com/sphireinc/foundry/internal/site"
"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 := 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] = 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 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 {
_, ok := v.(T)
return ok
}
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 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"
"time"
"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)
}
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"
)
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]",
"foundry plugin uninstall <name>",
"foundry plugin enable <name>",
"foundry plugin disable <name>",
"foundry plugin validate",
"foundry plugin validate <name>",
"foundry plugin deps <name>",
"foundry plugin update <name>",
"foundry plugin sync",
}
}
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]")
}
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
}
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)
}
}
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)
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); 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 {
if len(args) >= 4 {
name := strings.TrimSpace(args[3])
if err := project.Validate(name); err != nil {
return err
}
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 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)
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 init() {
registry.Register(command{})
}
package registry
import (
"fmt"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/cliout"
"github.com/sphireinc/foundry/internal/config"
)
type Command interface {
Name() string
Summary() string
Group() string
Details() []string
RequiresConfig() bool
Run(cfg *config.Config, args []string) error
}
type Info struct {
Name string
Summary string
Group string
Details []string
RequiresConfig bool
}
var commands = map[string]Command{}
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
}
func Lookup(args []string) (Command, bool) {
if len(args) < 2 {
return nil, false
}
cmd, ok := commands[args[1]]
return cmd, ok
}
func Run(cfg *config.Config, args []string) (bool, error) {
cmd, ok := Lookup(args)
if !ok {
return false, nil
}
return true, cmd.Run(cfg, args)
}
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
}
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 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 themecmd
import (
"fmt"
"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/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>",
"foundry theme scaffold <name>",
"foundry theme switch <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|scaffold|switch]")
}
switch args[2] {
case "list":
return runList(cfg)
case "current":
return runCurrent(cfg)
case "validate":
return runValidate(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>")
}
name := strings.TrimSpace(args[3])
if err := theme.ValidateInstalled(cfg.ThemesDir, name); err != nil {
return err
}
manifest, err := theme.LoadManifest(cfg.ThemesDir, name)
if err != nil {
return err
}
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)
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 <name>")
}
name := strings.TrimSpace(args[3])
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 init() {
registry.Register(command{})
}
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 (
"fmt"
"runtime"
"github.com/sphireinc/foundry/internal/commands/registry"
"github.com/sphireinc/foundry/internal/config"
)
var (
Version = "dev"
Commit = "none"
Date = "unknown"
)
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 nil
}
func (command) RequiresConfig() bool {
return false
}
func (command) Run(_ *config.Config, _ []string) error {
fmt.Println(String())
return nil
}
func String() string {
return fmt.Sprintf(
"Foundry %s\ncommit: %s\nbuilt: %s\ngo: %s",
Version,
Commit,
Date,
runtime.Version(),
)
}
func init() {
registry.Register(command{})
}
package config
import (
"fmt"
"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"`
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"`
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"`
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 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) 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 = ""
}
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 {
c.Admin.LocalOnly = true
}
if c.DefaultLang == "" {
c.DefaultLang = "en"
}
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 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("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.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/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"
)
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
}
// these below are purley for type safety
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 }
type Loader struct {
cfg *config.Config
hooks Hooks
includeDrafts bool
}
func NewLoader(cfg *config.Config, hooks Hooks, includeDrafts bool) *Loader {
if hooks == nil {
hooks = noopHooks{}
}
return &Loader{
cfg: cfg,
hooks: hooks,
includeDrafts: includeDrafts,
}
}
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 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
}
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
})
}
func (l *Loader) resolveLanguage(rel string) (lang, relDocPath string, isDefault bool) {
return i18n.SplitLeadingLang(rel, l.cfg.DefaultLang)
}
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), fields.DefinitionsFor(l.cfg, docType)),
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 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 (
"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 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 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"
)
type Definition = config.FieldDefinition
type SchemaSet = config.FieldSchemaSet
func Normalize(in map[string]any) map[string]any {
if in == nil {
return map[string]any{}
}
return in
}
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
}
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
}
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
}
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 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 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"
)
const TimestampFormat = "20060102T150405Z"
var derivedStemRE = regexp.MustCompile(`^(.*)\.(version|trash)\.(\d{8}T\d{6}Z)$`)
type State string
const (
StateCurrent State = "current"
StateVersion State = "version"
StateTrash State = "trash"
)
func IsDerivedPath(path string) bool {
_, _, ok := ParsePath(path)
return ok
}
func IsVersionPath(path string) bool {
_, state, ok := ParsePath(path)
return ok && state == StateVersion
}
func IsTrashPath(path string) bool {
_, state, ok := ParsePath(path)
return ok && state == StateTrash
}
func ParsePath(path string) (string, State, bool) {
original, state, _, ok := ParsePathDetails(path)
return original, state, ok
}
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
}
}
func BuildVersionPath(path string, now time.Time) string {
return buildDerivedPath(path, "version", 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
}
func OriginalPath(path string) string {
if original, _, ok := ParsePath(path); ok {
return original
}
return path
}
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 (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"html/template"
"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
}
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
}
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)),
}, nil
}
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"
)
type TimingBreakdown struct {
PluginConfig time.Duration
Loader time.Duration
Router time.Duration
RouteHooks time.Duration
Assets time.Duration
Renderer time.Duration
Feed time.Duration
}
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"`
}
type PreviewManifest struct {
GeneratedAt time.Time `json:"generated_at"`
Environment string `json:"environment"`
Target string `json:"target,omitempty"`
Links []PreviewLink `json:"links"`
}
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"`
}
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
}
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
}
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
}
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
}
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
}
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="([^"]+)"`)
)
type DiagnosticReport struct {
BrokenMediaRefs []string
BrokenInternalLinks []string
MissingTemplates []string
OrphanedMedia []string
DuplicateURLs []string
DuplicateSlugs []string
TaxonomyInconsistency []string
}
func (r DiagnosticReport) Messages() []string {
out := make([]string, 0, len(r.BrokenMediaRefs)+len(r.BrokenInternalLinks)+len(r.MissingTemplates)+len(r.OrphanedMedia)+len(r.DuplicateURLs)+len(r.DuplicateSlugs)+len(r.TaxonomyInconsistency))
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
}
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"
)
type GraphLoader interface {
Load(context.Context) (*content.SiteGraph, error)
}
type RouteHookRunner interface {
OnRoutesAssigned(*content.SiteGraph) error
}
type AssetHookRunner interface {
OnAssetsBuilding(*config.Config) error
}
type PreparedGraph struct {
Graph *content.SiteGraph
DepGraph *deps.Graph
}
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
}
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 = "/__foundry"
APIBase = RouteBase + "/api"
SDKBase = RouteBase + "/sdk"
)
type Hooks interface {
RegisterRoutes(*http.ServeMux)
OnServerStarted(string) error
OnRoutesAssigned(*content.SiteGraph) error
OnAssetsBuilding(*config.Config) error
}
type hookSet struct {
base Hooks
api *API
}
type API struct {
cfg *config.Config
mu sync.RWMutex
graph *content.SiteGraph
}
type CapabilitiesResponse struct {
SDKVersion string `json:"sdk_version"`
Modules map[string]bool `json:"modules"`
Features map[string]bool `json:"features"`
}
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"`
}
type NavItem struct {
Name string `json:"name"`
URL string `json:"url"`
}
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"`
}
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"`
}
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"`
}
type CollectionResponse struct {
Items []ContentSummary `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
}
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"`
}
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"`
}
type PreviewManifest struct {
GeneratedAt time.Time `json:"generated_at"`
Environment string `json:"environment"`
Target string `json:"target,omitempty"`
Links []PreviewLink `json:"links"`
}
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)
}
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
}
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))
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,
})
}
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"
"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")
}
return nil
}
package plugins
import (
"fmt"
"io"
"sort"
"strings"
)
type CommandContext struct {
Args []string
Stdout io.Writer
Stderr io.Writer
}
type CLIHook interface {
Commands() []Command
}
type Command struct {
Name string
Summary string
Description string
Run func(ctx CommandContext) error
}
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
}
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 (
"archive/zip"
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"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
}
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 := filepath.Join(pluginsDir, name)
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)
}
}
meta, err := LoadMetadata(pluginsDir, name)
if err != nil {
_ = os.RemoveAll(targetDir)
return Metadata{}, err
}
if strings.TrimSpace(meta.Name) != "" && meta.Name != name {
_ = os.RemoveAll(targetDir)
return Metadata{}, fmt.Errorf("plugin metadata name %q does not match install directory %q", meta.Name, name)
}
return meta, nil
}
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 := filepath.Join(pluginsDir, name)
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 := os.RemoveAll(targetDir); err != nil {
return fmt.Errorf("remove plugin directory: %w", err)
}
return nil
}
func repoZipURL(repoURL string) (string, error) {
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.TrimSuffix(u.Path, ".git")
path = strings.Trim(path, "/")
return fmt.Sprintf("https://github.com/%s/archive/refs/heads/main.zip", path), nil
}
func downloadAndExtract(repoURL, targetDir string) error {
zipURL, err := repoZipURL(repoURL)
if err != nil {
return err
}
resp, err := pluginDownloadClient.Get(zipURL)
if err != nil {
return fmt.Errorf("download plugin zip: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %s", resp.Status)
}
tmpFile, err := os.CreateTemp("", "foundry-plugin-*.zip")
if err != nil {
return err
}
defer os.Remove(tmpFile.Name())
written, err := io.Copy(tmpFile, io.LimitReader(resp.Body, pluginZipMaxBytes+1))
if err != nil {
return err
}
if written > pluginZipMaxBytes {
return fmt.Errorf("plugin zip exceeds %d bytes", pluginZipMaxBytes)
}
tmpFile.Close()
zr, err := zip.OpenReader(tmpFile.Name())
if err != nil {
return err
}
defer zr.Close()
tempDir, err := os.MkdirTemp("", "foundry-plugin")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
for _, f := range zr.File {
if f.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("zip contains unsupported symlink entry: %s", f.Name)
}
fp, err := safeArchivePath(tempDir, f.Name)
if err != nil {
return err
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(fp, f.Mode()); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(fp), 0755); 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 := filepath.Join(tempDir, entries[0].Name())
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 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
}
func normalizeInstallURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "git@") {
return raw
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
u, err := url.Parse(raw)
if err != nil {
return raw
}
if strings.EqualFold(u.Host, "github.com") {
path := strings.TrimSuffix(u.Path, "/")
if !strings.HasSuffix(path, ".git") {
path += ".git"
}
u.Path = path
return u.String()
}
return raw
}
parts := strings.Split(raw, "/")
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
return "https://github.com/" + raw + ".git"
}
return raw
}
func validateInstallURL(raw string) (string, error) {
normalized := normalizeInstallURL(raw)
if strings.TrimSpace(normalized) == "" {
return "", nil
}
if strings.HasPrefix(normalized, "git@github.com:") {
name, err := inferPluginName(normalized)
if err != nil {
return "", err
}
if _, err := validatePluginName(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 plugin URL: %w", err)
}
if !strings.EqualFold(u.Scheme, "https") {
return "", fmt.Errorf("plugin URL must use https or git@github.com")
}
if !strings.EqualFold(u.Host, "github.com") {
return "", fmt.Errorf("plugin URL must target github.com")
}
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("plugin URL must point to a GitHub owner/repository")
}
if _, err := validatePluginName(parts[1]); err != nil {
return "", fmt.Errorf("invalid GitHub repository path: %w", err)
}
return normalized, nil
}
func validatePluginName(name string) (string, error) {
return safepath.ValidatePathComponent("plugin name", name)
}
func inferPluginName(raw string) (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 name, nil
}
}
return "", fmt.Errorf("could not infer plugin name from URL")
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("parse plugin URL: %w", err)
}
path := strings.TrimSpace(u.Path)
path = strings.Trim(path, "/")
if path == "" {
return "", fmt.Errorf("could not infer plugin name from URL")
}
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 plugin name from URL")
}
return name, nil
}
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
}
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")
}
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 {
return validatePluginForSync(pluginsDir, name)
}
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) (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,
})
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"
)
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"`
AdminExtensions AdminExtensions `yaml:"admin,omitempty"`
Screenshots []string `yaml:"screenshots,omitempty"`
Directory string `yaml:"-"`
}
type Dependency struct {
Name string `yaml:"name"`
Version string `yaml:"version,omitempty"`
Optional bool `yaml:"optional,omitempty"`
}
type AdminExtensions struct {
Pages []AdminPage `yaml:"pages,omitempty"`
Widgets []AdminWidget `yaml:"widgets,omitempty"`
Slots []AdminSlot `yaml:"slots,omitempty"`
SettingsSections []AdminSettingsSection `yaml:"settings_sections,omitempty"`
}
type AdminPage struct {
Key string `yaml:"key"`
Title string `yaml:"title"`
Route string `yaml:"route"`
Capability string `yaml:"capability,omitempty"`
Description string `yaml:"description,omitempty"`
Module string `yaml:"module,omitempty"`
Styles []string `yaml:"styles,omitempty"`
}
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"`
}
type AdminSlot struct {
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
}
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"`
}
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)
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)
}
if err := validateMetadataCompatibility(meta); err != nil {
return Metadata{}, fmt.Errorf("validate plugin metadata %s: %w", path, err)
}
return meta, nil
}
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
}
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
}
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"
)
type Plugin interface {
Name() string
}
type Factory func() Plugin
type ConfigLoadedHook interface {
OnConfigLoaded(*config.Config) error
}
type ContentDiscoveredHook interface {
OnContentDiscovered(path string) error
}
type FrontmatterParsedHook interface {
OnFrontmatterParsed(*content.Document) error
}
type MarkdownRenderedHook interface {
OnMarkdownRendered(*content.Document) error
}
type DocumentParsedHook interface {
OnDocumentParsed(*content.Document) error
}
type DataLoadedHook interface {
OnDataLoaded(map[string]any) error
}
type GraphBuildingHook interface {
OnGraphBuilding(*content.SiteGraph) error
}
type GraphBuiltHook interface {
OnGraphBuilt(*content.SiteGraph) error
}
type TaxonomyBuiltHook interface {
OnTaxonomyBuilt(*content.SiteGraph) error
}
type RoutesAssignedHook interface {
OnRoutesAssigned(*content.SiteGraph) error
}
type ContextHook interface {
OnContext(*renderer.ViewData) error
}
type AssetsHook interface {
OnAssets(*renderer.ViewData, *renderer.AssetSet) error
}
type HTMLSlotsHook interface {
OnHTMLSlots(*renderer.ViewData, *renderer.Slots) error
}
type BeforeRenderHook interface {
OnBeforeRender(*renderer.ViewData) error
}
type AfterRenderHook interface {
OnAfterRender(url string, html []byte) ([]byte, error)
}
type AssetsBuildingHook interface {
OnAssetsBuilding(*config.Config) error
}
type BuildStartedHook interface {
OnBuildStarted() error
}
type BuildCompletedHook interface {
OnBuildCompleted(*content.SiteGraph) error
}
type ServerStartedHook interface {
OnServerStarted(addr string) error
}
type RoutesRegisterHook interface {
RegisterRoutes(mux *http.ServeMux)
}
type Manager struct {
plugins []Plugin
metadata map[string]Metadata
}
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
}
if factory, ok := registry[name]; ok {
m.plugins = append(m.plugins, factory())
}
}
return m, nil
}
func (m *Manager) Plugins() []Plugin {
out := make([]Plugin, len(m.plugins))
copy(out, m.plugins)
return out
}
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
}
func (m *Manager) MetadataFor(name string) (Metadata, bool) {
meta, ok := m.metadata[name]
return meta, ok
}
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
}
func (m *Manager) RegisterRoutes(mux *http.ServeMux) {
for _, p := range m.plugins {
if hook, ok := p.(RoutesRegisterHook); ok {
hook.RegisterRoutes(mux)
}
}
}
package plugins
import "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) (Metadata, error) {
return Install(InstallOptions{
PluginsDir: p.PluginsDir,
URL: strings.TrimSpace(url),
Name: strings.TrimSpace(name),
})
}
func (p Project) Uninstall(name string) error {
return Uninstall(p.PluginsDir, name)
}
func (p Project) Enable(name string) error {
return EnableInConfig(p.ConfigPath, name)
}
func (p Project) Disable(name string) error {
return DisableInConfig(p.ConfigPath, name)
}
func (p Project) Update(name string) (Metadata, error) {
return UpdateInstalled(p.PluginsDir, name)
}
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) 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{}
func Register(name string, factory Factory) {
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 (
"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)
for _, name := range enabled {
if err := validatePluginForSync(opts.PluginsDir, name); err != nil {
return fmt.Errorf("validate plugin %s: %w", name, err)
}
}
if err := os.MkdirAll(filepath.Dir(opts.OutputPath), 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
content := generateImportsFile(opts.ModulePath, enabled)
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) error {
var err error
name, err = validatePluginName(name)
if err != nil {
return err
}
root := filepath.Join(pluginsDir, name)
info, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("plugin %q is enabled but directory %q does not exist", name, root)
}
return err
}
if !info.IsDir() {
return fmt.Errorf("plugin path %q is not a directory", root)
}
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 err
}
if !foundGo {
return fmt.Errorf("plugin %q has no .go files under %q", name, root)
}
if _, err := LoadMetadata(pluginsDir, name); err != nil {
return err
}
return 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 := validatePluginForSync(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 := validatePluginForSync(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"
default:
return "invalid"
}
}
if strings.TrimSpace(meta.Repo) == "" {
return "enabled"
}
return "enabled"
}
package renderer
import (
"context"
"encoding/json"
"fmt"
"html/template"
"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(`<[^>]+>`)
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 }
type Renderer struct {
cfg *config.Config
themes *theme.Manager
hooks Hooks
}
type BuildStats struct {
Prepare time.Duration
Assets time.Duration
Documents time.Duration
Taxonomies time.Duration
Search time.Duration
}
func New(cfg *config.Config, themes *theme.Manager, hooks Hooks) *Renderer {
if hooks == nil {
hooks = noopHooks{}
}
return &Renderer{
cfg: cfg,
themes: themes,
hooks: hooks,
}
}
type NavItem struct {
Name string
URL string
Active bool
}
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
Nav []NavItem
}
type Slots struct {
values map[string][]template.HTML
}
func NewSlots() *Slots {
return &Slots{
values: make(map[string][]template.HTML),
}
}
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)
}
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())
}
type ScriptPosition string
const (
ScriptPositionHead ScriptPosition = "head"
ScriptPositionBodyEnd ScriptPosition = "body.end"
)
type AssetSet struct {
styles []string
headScripts []string
bodyScripts []string
}
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
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)
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.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) {
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)
}
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) 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 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) 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) 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) {
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
}
basePath := r.themes.LayoutPath("base")
pagePath := r.themes.LayoutPath(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...)
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
}
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"
"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
}
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/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"
)
type Loader interface {
Load(context.Context) (*content.SiteGraph, error)
}
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 }
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
}
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
}
func WithDebugMode(enabled bool) Option {
return func(s *Server) {
s.debug = enabled
}
}
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
}
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
}
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
}
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)
})
}
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)
var changedPaths []string
var debounce <-chan time.Time
for {
select {
case <-ctx.Done():
return
case ev := <-w.Events:
if ev.Op != 0 {
if shouldAddWatch(ev.Name) {
_ = addWatchRecursively(w, 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.RenderURL(graph, path, s.cfg.Server.LiveReload)
finishDebug(err, len(out))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
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-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
}
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 (
"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"
)
type Info struct {
Name string
Path string
}
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"`
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"`
}
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"),
}
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
}
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
}
return &m, nil
}
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"}
}
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")
}
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
}
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
`, 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) 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"
"html/template"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/sphireinc/foundry/internal/consts"
"github.com/sphireinc/foundry/internal/safepath"
)
type ValidationDiagnostic struct {
Severity string `json:"severity"`
Path string `json:"path,omitempty"`
Message string `json:"message"`
}
type ValidationResult struct {
Valid bool `json:"valid"`
Diagnostics []ValidationDiagnostic `json:"diagnostics,omitempty"`
}
var templateReferencePattern = regexp.MustCompile(`{{\s*(?:template|block)\s+"([^"]+)"`)
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))
}
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)
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 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))
}
}
}
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))
}
}
}
}
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))
}
}
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 "reading-time"
}
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 {
words := countWords(doc.RawBody)
minutes := estimateMinutes(words)
if doc.Fields == nil {
doc.Fields = map[string]any{}
}
doc.Fields["reading_time"] = minutes
doc.Fields["word_count"] = words
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{}
}
if ctx.Page.Fields != nil {
ctx.Data["reading_time"] = ctx.Page.Fields["reading_time"]
ctx.Data["word_count"] = ctx.Page.Fields["word_count"]
}
return nil
}
func (p *Plugin) OnHTMLSlots(ctx *renderer.ViewData, slots *renderer.Slots) error {
if ctx.Page == nil || ctx.Page.Type != "post" || ctx.Page.Fields == nil {
return nil
}
readingTime, ok := ctx.Page.Fields["reading_time"]
if !ok {
return nil
}
wordCount, _ := ctx.Page.Fields["word_count"]
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("reading-time", 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 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 {
items := extractTOC(doc.RawBody)
if doc.Fields == nil {
doc.Fields = map[string]any{}
}
doc.Fields["toc"] = items
doc.Fields["has_toc"] = len(items) > 0
return nil
}
func (p *Plugin) OnContext(ctx *renderer.ViewData) error {
if ctx.Page == nil || ctx.Page.Fields == nil {
return nil
}
if ctx.Data == nil {
ctx.Data = map[string]any{}
}
if toc, ok := ctx.Page.Fields["toc"]; ok {
ctx.Data["toc"] = toc
}
if hasTOC, ok := ctx.Page.Fields["has_toc"]; ok {
ctx.Data["has_toc"] = hasTOC
}
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" || ctx.Page.Fields == nil {
return nil
}
raw, ok := ctx.Page.Fields["toc"]
if !ok {
return nil
}
items, ok := raw.([]Item)
if !ok || 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 (
"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)
if err := os.MkdirAll(outputDir, 0o755); err != nil {
fail("create output dir", err)
}
outputName := appName
if runtime.GOOS == "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(" 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.
run("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)
}
fmt.Println("Build complete.")
fmt.Printf("Checksum: %s\n", sum)
fmt.Printf("Checksum file: %s\n", checksumPath)
fmt.Println("")
fmt.Printf("Run with: %s version\n", outputPath)
}
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 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 fail(step string, err error) {
_, _ = fmt.Fprintf(os.Stderr, "build-release: %s: %v\n", step, err)
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)
})
}