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 }