Files
2026-08-15 07:13:00 +00:00

200 lines
5.5 KiB
Go

package adminapi
import (
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/store"
)
// projectOf renders a stored project for the wire.
func (s *Server) projectOf(p *store.Project) api.Project {
out := api.Project{
Name: p.Name,
DisplayName: p.DisplayName,
IndexFile: p.IndexFile,
NotFoundFile: p.NotFoundFile,
SPAFallback: p.SPAFallback,
CacheControl: p.CacheControl,
RetentionCount: p.RetentionCount,
RetentionGrace: p.RetentionGraceS,
MaxFiles: p.MaxFiles,
MaxFileBytes: p.MaxFileBytes,
MaxTotalBytes: p.MaxTotalBytes,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
if s.BaseURL != "" {
out.URL = api.SiteURL(s.BaseURL, p.Name)
}
out.ActiveDeployment = s.activeOf(p)
return out
}
// activeOf summarises what a project is serving right now.
//
// It reads the registry rather than the database so listing a hundred projects
// stays one query. The summary carries what is being served — id, size,
// timestamps — and not the row's metadata or error text; those come from
// fetching the deployment itself.
func (s *Server) activeOf(p *store.Project) *api.Deployment {
if s.Sites == nil {
return nil
}
sp, ok := s.Sites.Lookup(p.Name)
if !ok {
return nil
}
d := sp.Active()
if d == nil {
return nil
}
out := &api.Deployment{
ID: d.ID,
Project: p.Name,
State: api.StateReady, // only a ready deployment can be active
Active: true,
FileCount: d.FileCount,
TotalBytes: d.TotalBytes,
CreatedAt: d.CreatedAt,
}
if !d.ActivatedAt.IsZero() {
t := d.ActivatedAt
out.ActivatedAt = &t
}
return out
}
// deploymentOf renders a stored deployment for the wire.
//
// The row id stays behind: the wire only ever names a deployment by its public
// id, so nothing a client holds can be walked to a neighbouring row.
func deploymentOf(p *store.Project, d *store.Deployment) api.Deployment {
return api.Deployment{
ID: d.PublicID,
Project: p.Name,
State: string(d.State),
Active: d.Active,
FileCount: d.FileCount,
TotalBytes: d.TotalBytes,
Meta: d.Meta,
Error: d.Error,
CreatedAt: d.CreatedAt,
FinalizedAt: copyTime(d.FinalizedAt),
ActivatedAt: copyTime(d.ActivatedAt),
}
}
// keyOf renders a stored key. It deliberately has no access to the secret: the
// store never loads one in a form that could be rendered, only the hash.
func keyOf(k *store.APIKey, projectName string) api.Key {
return api.Key{
ID: k.ID,
Scope: string(k.Scope),
Project: projectName,
Name: k.Name,
CreatedAt: k.CreatedAt,
ExpiresAt: copyTime(k.ExpiresAt),
LastUsed: copyTime(k.LastUsedAt),
RevokedAt: copyTime(k.RevokedAt),
}
}
// copyTime defensively copies an optional timestamp so a response value cannot
// alias a cached store row.
func copyTime(t *time.Time) *time.Time {
if t == nil {
return nil
}
v := *t
return &v
}
// applyPatch folds a partial update into p, validating as it goes.
//
// A patch is all-or-nothing: it is applied to a copy by the caller, so a
// rejected field leaves the stored project untouched rather than half-updated.
func (s *Server) applyPatch(p *store.Project, patch *api.ProjectPatch) error {
if patch == nil {
return nil
}
if v := patch.DisplayName; v != nil {
if err := checkText("display_name", *v, maxDisplayNameLen); err != nil {
return err
}
p.DisplayName = *v
}
if v := patch.IndexFile; v != nil {
if err := checkSitePath("index_file", *v); err != nil {
return err
}
p.IndexFile = *v
}
if v := patch.NotFoundFile; v != nil {
// The empty string is how a patch clears the custom 404 document; every
// other value must name a real relative path.
if *v != "" {
if err := checkSitePath("not_found_file", *v); err != nil {
return err
}
}
p.NotFoundFile = *v
}
if v := patch.SPAFallback; v != nil {
p.SPAFallback = *v
}
if v := patch.CacheControl; v != nil {
if err := checkHeaderValue("cache_control", *v); err != nil {
return err
}
p.CacheControl = *v
}
if v := patch.RetentionCount; v != nil {
// At least one: retaining zero deployments would delete the active one.
if *v < 1 || *v > 1000 {
return api.Errorf(api.CodeBadRequest, "retention_count must be between 1 and 1000")
}
p.RetentionCount = *v
}
if v := patch.RetentionGrace; v != nil {
if *v < 0 || *v > 30*24*3600 {
return api.Errorf(api.CodeBadRequest,
"retention_grace_s must be between 0 and %d", 30*24*3600)
}
p.RetentionGraceS = *v
}
if v := patch.MaxFiles; v != nil {
if err := checkCeiling("max_files", int64(*v), int64(s.Limits.MaxManifestFiles)); err != nil {
return err
}
p.MaxFiles = *v
}
if v := patch.MaxFileBytes; v != nil {
if err := checkCeiling("max_file_bytes", *v, s.Limits.MaxFileBytes); err != nil {
return err
}
p.MaxFileBytes = *v
}
if v := patch.MaxTotalBytes; v != nil {
if *v < 1 {
return api.Errorf(api.CodeBadRequest, "max_total_bytes must be positive")
}
p.MaxTotalBytes = *v
}
return nil
}
// checkCeiling enforces that a per-project limit is positive and does not
// exceed the server-wide one. A project may lower its own ceiling but never
// raise it past what the operator configured.
func checkCeiling(field string, v, ceiling int64) error {
if v < 1 {
return api.Errorf(api.CodeBadRequest, "%s must be positive", field)
}
if ceiling > 0 && v > ceiling {
return api.Errorf(api.CodeBadRequest,
"%s must be at most the server limit of %d", field, ceiling)
}
return nil
}