diff --git a/sumdb/cmd/proxy.go b/sumdb/cmd/proxy.go index c59b756..ebaa80b 100644 --- a/sumdb/cmd/proxy.go +++ b/sumdb/cmd/proxy.go @@ -28,7 +28,6 @@ import ( var ( listen = flag.String("listen", ":8089", "Address to set up HTTP server listening on") witnessSigs = flag.Uint("witnesses", 0, "Number of witness signatures required on a checkpoint. Setting this will pull checkpoints from the transparency-dev prod distributor.") - indexFile = flag.String("index", "", "Local path to an index.html file to serve at the log root /index.html") ) func main() { @@ -37,7 +36,6 @@ func main() { proxy := sumdb.NewProxy(sumdb.ProxyOpts{ WitnessSigs: *witnessSigs, - IndexFile: *indexFile, }) klog.Infof("tlog-tiles API listening on %s", *listen) if err := http.ListenAndServe(*listen, proxy); err != nil { diff --git a/sumdb/proxy.go b/sumdb/proxy.go index 98c0bb0..0d3a7ee 100644 --- a/sumdb/proxy.go +++ b/sumdb/proxy.go @@ -28,6 +28,7 @@ import ( "io" "k8s.io/klog/v2" + woodpeckerweb "github.com/transparency-dev/incubator/woodpecker-web" ) const ( @@ -48,10 +49,6 @@ type ProxyOpts struct { // distributor. // https://github.com/transparency-dev/distributor/ WitnessSigs uint - - // IndexFile is the local path to an index.html file to serve at the log root /index.html. - // If empty, nothing is served at /index.html. - IndexFile string } func newReverseProxy(opts ProxyOpts) *httputil.ReverseProxy { @@ -118,21 +115,19 @@ func newReverseProxy(opts ProxyOpts) *httputil.ReverseProxy { return proxy } -// NewProxy returns an http.Handler that proxies to the appropriate SumDB upstream. -// If IndexFile is set in ProxyOpts, it also serves this file at /index.html. func NewProxy(opts ProxyOpts) http.Handler { proxy := newReverseProxy(opts) - if opts.IndexFile == "" { - return proxy - } - prefix, _ := strings.CutSuffix(opts.PathPrefix, "/") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { inPath := strings.TrimPrefix(r.URL.Path, prefix) if inPath == "/index.html" || inPath == "/" { - http.ServeFile(w, r, opts.IndexFile) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(woodpeckerweb.IndexHTML); err != nil { + klog.Warningf("failed to write index HTML: %v", err) + } return } proxy.ServeHTTP(w, r) diff --git a/vindex/cmd/logandmap/main.go b/vindex/cmd/logandmap/main.go index cf97ae8..2feb294 100644 --- a/vindex/cmd/logandmap/main.go +++ b/vindex/cmd/logandmap/main.go @@ -51,6 +51,7 @@ import ( sdkmetric "go.opentelemetry.io/otel/sdk/metric" "golang.org/x/mod/sumdb/note" "k8s.io/klog/v2" + woodpeckerweb "github.com/transparency-dev/incubator/woodpecker-web" ) var ( @@ -311,8 +312,8 @@ func submitEntries(ctx context.Context, appender *tessera.Appender) { func runWebServer(vi *vindex.VerifiableIndex, inLogDir, outLogDir string) (func(context.Context) error, error) { srv := web.NewServer(vi.Lookup) - ilfs := http.FileServer(http.Dir(inLogDir)) - olfs := http.FileServer(http.Dir(outLogDir)) + ilfs := serveLogWithWoodpecker(inLogDir, woodpeckerweb.IndexHTML) + olfs := serveLogWithWoodpecker(outLogDir, woodpeckerweb.IndexHTML) r := mux.NewRouter() r.PathPrefix("/inputlog/").Handler(http.StripPrefix("/inputlog/", ilfs)) r.PathPrefix("/outputlog/").Handler(http.StripPrefix("/outputlog/", olfs)) @@ -405,3 +406,19 @@ func mapFnFromFlags() vindex.MapFn { } return mapFn } + +func serveLogWithWoodpecker(logDir string, woodpeckerHTML []byte) http.Handler { + fs := http.FileServer(http.Dir(logDir)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(woodpeckerHTML); err != nil { + klog.Warningf("failed to write index HTML: %v", err) + } + return + } + fs.ServeHTTP(w, r) + }) +} + diff --git a/vindex/cmd/sumdbindex/main.go b/vindex/cmd/sumdbindex/main.go index 5aef3bd..b1fd804 100644 --- a/vindex/cmd/sumdbindex/main.go +++ b/vindex/cmd/sumdbindex/main.go @@ -123,10 +123,11 @@ func run(ctx context.Context) error { if err != nil { return err } - sumProxy := sumdb.NewProxy(sumdb.ProxyOpts{ + sumProxyOpts := sumdb.ProxyOpts{ PathPrefix: "/inputlog/", WitnessSigs: *witnessSigs, - }) + } + sumProxy := sumdb.NewProxy(sumProxyOpts) outputLog, outputCloser := outputLogOrDie(ctx, outputLogDir) defer func() { @@ -300,3 +301,4 @@ func mapFn(data []byte) [][32]byte { return [][32]byte{sha256.Sum256(data[:modEnd])} } + diff --git a/vindex/internal/web/index.html b/vindex/internal/web/index.html new file mode 100644 index 0000000..86c59ab --- /dev/null +++ b/vindex/internal/web/index.html @@ -0,0 +1,452 @@ + + + + + + VIndex Web | Verifiable Index Viewer + + + + + + + +
+
+
+ + + +
+
+

VIndex Web

+

Verifiable Index Viewer

+
+
+
+
+ + Idle +
+
+
+ +
+ + + + +
+ +
+ + +
+ + +
+
+
+ + + + Enter a key above to search the index. +
+
+
+
+
+ + + + + + + diff --git a/vindex/internal/web/web.go b/vindex/internal/web/web.go index 4795f81..f1778ee 100644 --- a/vindex/internal/web/web.go +++ b/vindex/internal/web/web.go @@ -29,6 +29,9 @@ import ( "k8s.io/klog/v2" ) +//go:embed index.html +var indexHTML []byte + func NewServer(lookup func(context.Context, [sha256.Size]byte) (api.LookupResponse, error)) Server { return Server{ lookup: lookup, @@ -72,7 +75,22 @@ func (s Server) HandleLookup(w http.ResponseWriter, r *http.Request) { } } +func (s Server) HandleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/vindex" { + http.Redirect(w, r, "/vindex/", http.StatusMovedPermanently) + return + } + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(indexHTML); err != nil { + klog.Warningf("failed to write index HTML: %v", err) + } +} + func (s Server) RegisterHandlers(r *mux.Router) { r.HandleFunc("/vindex/lookup/{hash}", s.HandleLookup).Methods("GET") + r.HandleFunc("/vindex", s.HandleIndex).Methods("GET") + r.HandleFunc("/vindex/", s.HandleIndex).Methods("GET") + r.HandleFunc("/vindex/index.html", s.HandleIndex).Methods("GET") r.Handle("/metrics", promhttp.Handler()) } diff --git a/woodpecker-web/README.md b/woodpecker-web/README.md index 0e1f1b6..6f56f7b 100644 --- a/woodpecker-web/README.md +++ b/woodpecker-web/README.md @@ -25,6 +25,7 @@ The tiles are expected to be formatted as a stream of Length-Value Payloads (LVP - **Checkpoint Inspector**: View the raw checkpoint and signatures. - **Entry Browser**: Lists entries with their index and size. - **Jump to Index**: Quickly navigate to a specific entry by index. +- **Direct Linking**: Link directly to a specific entry by appending `#entry-` or `#` to the URL (e.g., `index.html#entry-123`). - **Detail Modal**: Inspect entries in both interpreted (text/JSON) and raw hex formats. ### For Log Operators diff --git a/woodpecker-web/index.html b/woodpecker-web/index.html index cb5a2b4..646905c 100644 --- a/woodpecker-web/index.html +++ b/woodpecker-web/index.html @@ -253,6 +253,17 @@

Entry Inspector

document.getElementById('sidebar-overlay').classList.toggle('hidden', !this.state.sidebarOpen); }, + getTargetIndexFromHash() { + const hash = window.location.hash; + if (hash) { + const match = hash.match(/^#?(?:entry-)?(\d+)$/); + if (match) { + return parseInt(match[1], 10); + } + } + return null; + }, + async refresh() { this.updateStatus('fetching', 'Pecking...'); try { @@ -261,8 +272,15 @@

Entry Inspector

const text = await response.text(); this.parseCheckpoint(text); this.updateStatus('success', 'Synchronized'); - const lastTileIndex = Math.floor((this.state.treeSize - 1) / this.config.tileSize); - if (lastTileIndex >= 0) this.fetchTile(lastTileIndex); + + const targetIndex = this.getTargetIndexFromHash(); + if (targetIndex !== null && targetIndex < this.state.treeSize) { + const tileIdx = Math.floor(targetIndex / this.config.tileSize); + this.fetchTile(tileIdx, targetIndex); + } else { + const lastTileIndex = Math.floor((this.state.treeSize - 1) / this.config.tileSize); + if (lastTileIndex >= 0) this.fetchTile(lastTileIndex); + } } catch (e) { this.log(`Sync Failed: ${e.message}`, true); this.updateStatus('error', 'Log Error'); @@ -503,6 +521,13 @@

Local Origin Missing

} } }); + window.addEventListener('hashchange', () => { + const targetIndex = this.getTargetIndexFromHash(); + if (targetIndex !== null && targetIndex < this.state.treeSize) { + const tileIdx = Math.floor(targetIndex / this.config.tileSize); + this.fetchTile(tileIdx, targetIndex); + } + }); } }; diff --git a/woodpecker-web/woodpecker.go b/woodpecker-web/woodpecker.go new file mode 100644 index 0000000..b7bf616 --- /dev/null +++ b/woodpecker-web/woodpecker.go @@ -0,0 +1,23 @@ +// Copyright 2026 Google LLC. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package woodpeckerweb embeds the Woodpecker Web static assets. +package woodpeckerweb + +import _ "embed" + +// IndexHTML is the raw HTML content of the Woodpecker Web client. +// It can be served at the root of a log to provide a web viewer. +//go:embed index.html +var IndexHTML []byte