diff --git a/internal/server/deploy.go b/internal/server/deploy.go index fb9ad2e..563c274 100644 --- a/internal/server/deploy.go +++ b/internal/server/deploy.go @@ -419,9 +419,11 @@ func (s *Server) handleSites(w http.ResponseWriter, r *http.Request) { "updatedAt": updatedAt, "bytes": s.siteBytes(e.Name()), } - views, current, deploys := s.meta.stats(e.Name()) + views, current, createdAt, deploys := s.meta.stats(e.Name()) entry["views"] = views.Total if current != nil { + entry["createdAt"] = createdAt + entry["updatedAt"] = current.Time entry["deployer"] = current.Deployer entry["deploys"] = deploys } diff --git a/internal/server/meta.go b/internal/server/meta.go index 187f79e..9f76ab7 100644 --- a/internal/server/meta.go +++ b/internal/server/meta.go @@ -31,8 +31,9 @@ type viewStats struct { } type siteMeta struct { - Deploys []deployRecord `json:"deploys"` - Views viewStats `json:"views"` + CreatedAt string `json:"createdAt,omitempty"` + Deploys []deployRecord `json:"deploys"` + Views viewStats `json:"views"` } type pendingViews struct { @@ -83,6 +84,12 @@ func (m *metaStore) load(site string) *siteMeta { log.Printf("meta: could not read %s: %v", m.path(site), err) } m.cache[site] = sm + if sm.CreatedAt == "" && len(sm.Deploys) > 0 { + // Legacy metadata did not persist creation separately. Preserve the + // oldest timestamp still available rather than letting it advance again. + sm.CreatedAt = sm.Deploys[0].Time + m.persist(site) + } return sm } @@ -131,13 +138,17 @@ func (m *metaStore) record(site, deployer, source string) int64 { m.mu.Lock() defer m.mu.Unlock() sm := m.load(site) + now := time.Now().UTC().Format(time.RFC3339) seq := int64(1) if n := len(sm.Deploys); n > 0 { seq = sm.Deploys[n-1].Seq + 1 } + if sm.CreatedAt == "" { + sm.CreatedAt = now + } sm.Deploys = append(sm.Deploys, deployRecord{ Seq: seq, - Time: time.Now().UTC().Format(time.RFC3339), + Time: now, Deployer: deployer, Source: source, }) @@ -148,7 +159,7 @@ func (m *metaStore) record(site, deployer, source string) int64 { return seq } -func (m *metaStore) stats(site string) (views viewStats, current *deployRecord, deploys int64) { +func (m *metaStore) stats(site string) (views viewStats, current *deployRecord, createdAt string, deploys int64) { m.mu.Lock() defer m.mu.Unlock() sm := m.load(site) @@ -156,12 +167,13 @@ func (m *metaStore) stats(site string) (views viewStats, current *deployRecord, if p := m.views[site]; p != nil { views.Total += p.count } + createdAt = sm.CreatedAt if n := len(sm.Deploys); n > 0 { rec := sm.Deploys[n-1] current = &rec deploys = rec.Seq } - return views, current, deploys + return views, current, createdAt, deploys } func (m *metaStore) drop(site string) { diff --git a/internal/server/meta_test.go b/internal/server/meta_test.go new file mode 100644 index 0000000..0ea5cd8 --- /dev/null +++ b/internal/server/meta_test.go @@ -0,0 +1,39 @@ +package server + +import "testing" + +func TestCreationTimeSurvivesDeployHistoryRetention(t *testing.T) { + dir := t.TempDir() + m, err := newMetaStore(dir) + if err != nil { + t.Fatal(err) + } + + const createdAt = "2001-02-03T04:05:06Z" + m.mu.Lock() + m.cache["site"] = &siteMeta{ + CreatedAt: createdAt, + Deploys: []deployRecord{{ + Seq: 1, + Time: createdAt, + }}, + } + m.persist("site") + m.mu.Unlock() + + for range maxDeployHistory { + m.record("site", "alice", "update") + } + + reopened, err := newMetaStore(dir) + if err != nil { + t.Fatal(err) + } + _, _, got, deploys := reopened.stats("site") + if got != createdAt { + t.Fatalf("creation time changed after history trimming: got %q, want %q", got, createdAt) + } + if deploys != maxDeployHistory+1 { + t.Fatalf("deploy count = %d, want %d", deploys, maxDeployHistory+1) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ad87507..256cb1d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/sdelcore/shared/internal/store" + "github.com/sdelcore/shared/internal/web" ) type Server struct { @@ -78,6 +79,10 @@ func (s *Server) ListenAndServe() error { s.api.ServeHTTP(w, r) case r.URL.Path == "/shared.js": s.handleSharedJS(w, r) + case r.URL.Path == "/favicon.svg" && siteFromHost(r.Host, s.BaseHost) == "": + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(web.FaviconSVG) // Base host only: on a site host this path belongs to the site, and // intercepting it would shadow a deployed file of the same name. case r.URL.Path == "/skill.md" && siteFromHost(r.Host, s.BaseHost) == "": diff --git a/internal/web/favicon.svg b/internal/web/favicon.svg new file mode 100644 index 0000000..644d8d0 --- /dev/null +++ b/internal/web/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/internal/web/home.html b/internal/web/home.html index 1f55913..565aa32 100644 --- a/internal/web/home.html +++ b/internal/web/home.html @@ -4,6 +4,7 @@ shared +