200 lines
5.5 KiB
Go
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
|
|
}
|