blob: fc3ed8ac6f5ad6b83116f8dc3119b8c0648e7493 [file] [log] [blame]
// This executable is a very simple web server that serves static HTML/CSS/JS content from GCS.
// The GCS content can be created via another process, e.g. from a CI job. There is a file that
// controls which directory should be the root of the served content, called the latest_file.
// Whatever process updates new content to GCS should update the latest file after the content
// is updated.
//
// THIS SERVER DOES NO AUTHENTICATION. If the content being served is sensitive, then it should
// be only used on an internal website.
package main
import (
"context"
"flag"
"io"
"net/http"
"path"
"strings"
"sync"
"time"
gstorage "cloud.google.com/go/storage"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/metrics2"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
)
const (
serverReadTimeout = 5 * time.Minute
serverWriteTimeout = 5 * time.Minute
)
func main() {
var (
gcsBucket = flag.String("gcs_bucket", "", "The GCS bucket from which to serve static content")
latestFile = flag.String("latest_file", "", "The path to a file in the gcs bucket that points to the latest content to serve")
refreshRate = flag.Duration("refresh_rate", time.Minute, "How often to re-poll and update latest_file")
port = flag.String("port", ":8000", "HTTP service address (e.g., ':8000')")
promPort = flag.String("prom_port", ":20000", "Metrics service address (e.g., ':10110')")
)
flag.Parse()
if *gcsBucket == "" || *latestFile == "" {
sklog.Fatalf("You must set gcs_bucket and latest_file")
}
common.InitWithMust(
"generic-k8s-app",
common.PrometheusOpt(promPort),
common.MetricsLoggingOpt(),
)
s, err := newServer(*gcsBucket, *latestFile, *refreshRate)
if err != nil {
sklog.Fatalf("Could not initialize server: %s", err)
}
server := &http.Server{
Addr: *port,
Handler: s,
ReadTimeout: serverReadTimeout,
WriteTimeout: serverWriteTimeout,
MaxHeaderBytes: 1 << 20,
}
sklog.Fatal(server.ListenAndServe())
}
type staticGCSServer struct {
client *gstorage.Client
bucket string
latestFile string
// pathToServe is the GCS path which should be the root of all served content.
pathToServe string
mutex sync.RWMutex // protects pathToServe
}
// ServeHTTP is a very simple web server backed by GCS. For all non-health checks, it tries to
// load the given file relative to the most recent pathToServe prefix in the configured GCS
// bucket. For example, if /foo/bar.css is the request, it will try to load the file
// gs://[bucket]/[pathToServe]/foo/bar.css and serve that. It provides mime types for HTML/CSS/JS
// files, otherwise some browsers will not render them properly. For a request to the root file,
// this is treated as requesting /index.html.
func (s *staticGCSServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.RequestURI == "/healthz" {
w.WriteHeader(http.StatusOK)
return
}
sklog.Infof("Request URI %s", r.RequestURI)
// path.Clean prevents directory traversal attacks, as it re-writes /../../foo/bar into
// /foo/bar, which prevents walking into parent directories.
fileName := strings.TrimPrefix(path.Clean(r.RequestURI), "/")
var gcsFilePath string
s.mutex.RLock()
if fileName == "" {
gcsFilePath = s.pathToServe + "/index.html"
} else {
gcsFilePath = s.pathToServe + "/" + fileName
}
s.mutex.RUnlock()
if strings.HasSuffix(gcsFilePath, ".html") {
metrics2.GetCounter("static_server_html_page_requests", map[string]string{
"bucket": s.bucket,
"request_uri": r.RequestURI,
}).Inc(1)
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
latestReader, err := s.client.Bucket(s.bucket).Object(gcsFilePath).NewReader(ctx)
if err != nil {
httputils.ReportError(w, skerr.Wrapf(err, "file %s", gcsFilePath), "Could not resolve file", http.StatusNotFound)
return
}
xb, err := io.ReadAll(latestReader)
if err != nil {
httputils.ReportError(w, skerr.Wrapf(err, "file %s", gcsFilePath), "Could not read file", http.StatusInternalServerError)
return
}
_ = latestReader.Close()
if strings.HasSuffix(gcsFilePath, ".js") {
w.Header().Set("Content-Type", "application/javascript")
} else if strings.HasSuffix(gcsFilePath, ".css") {
w.Header().Set("Content-Type", "text/css")
} else if strings.HasSuffix(gcsFilePath, ".html") {
w.Header().Set("Content-Type", "text/html")
} else {
// Just to be safe, assume everything with an unknown extension is plain text.
w.Header().Set("Content-Type", "text/plain")
}
_, err = w.Write(xb)
if err != nil {
sklog.Warningf("Error while writing response for file %s: %s", gcsFilePath, err)
}
}
// updatePathToServe attempts to load the content of the latestFile and setss that to be the
// new root of the content served.
func (s *staticGCSServer) updatePathToServe(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
latestReader, err := s.client.Bucket(s.bucket).Object(s.latestFile).NewReader(ctx)
if err != nil {
return skerr.Wrap(err)
}
xb, err := io.ReadAll(latestReader)
if err != nil {
return skerr.Wrap(err)
}
_ = latestReader.Close()
latestPath := strings.TrimSpace(string(xb))
sklog.Infof("Loaded latest path %s from gs://%s/%s", latestPath, s.bucket, s.latestFile)
s.mutex.Lock()
s.pathToServe = latestPath
s.mutex.Unlock()
return nil
}
// newServer initializes the server, loads the root of the content from latestFile, and starts
// a go routine to repeatedly update that root.
func newServer(gcsBucket, latestFile string, refreshRate time.Duration) (*staticGCSServer, error) {
ctx := context.Background()
client, err := gstorage.NewClient(ctx)
if err != nil {
return nil, skerr.Wrap(err)
}
s := &staticGCSServer{
client: client,
bucket: gcsBucket,
latestFile: latestFile,
}
err = s.updatePathToServe(ctx)
if err != nil {
return nil, skerr.Wrap(err)
}
go util.RepeatCtx(ctx, refreshRate, func(ctx context.Context) {
err = s.updatePathToServe(ctx)
if err != nil {
sklog.Warningf("Error while updating the latest value: %s", err)
}
})
return s, nil
}