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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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