blob: 09dd63d395409632274667c630784dfae5ebd22d [file] [log] [blame]
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"strings"
"unicode/utf8"
"cloud.google.com/go/storage"
"github.com/rs/cors"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
)
var (
// Flags.
host = flag.String("host", "localhost", "HTTP service host")
port = flag.String("port", ":8000", "HTTP service port (e.g., ':8000')")
promPort = flag.String("prom_port", ":20000", "Metrics service address (e.g., ':10110')")
local = flag.Bool("local", false, "Running locally if true. As opposed to in production.")
bucket = flag.String("bucket", "", "GCS bucket containing the files to serve.")
// Global GCS client.
gcsClient *storage.Client
)
func validatePath(path string) error {
if path == "" {
return errors.New("path is empty")
}
if !utf8.ValidString(path) {
return errors.New("path is not valid UTF-8")
}
if path == "." || path == ".." {
return errors.New("'.' and '..' are not allowed in GCS paths")
}
return nil
}
func storageHandler(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if err := validatePath(path); err != nil {
sklog.Error(err.Error())
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
gcsPath := fmt.Sprintf("gs://%s/%s", *bucket, path)
obj := gcsClient.Bucket(*bucket).Object(path)
reader, err := obj.NewReader(r.Context())
if err == storage.ErrObjectNotExist {
http.Error(w, "object not found", http.StatusNotFound)
return
} else if err != nil {
sklog.Errorf("Error creating object reader for %s: %s", gcsPath, err)
http.Error(w, "unknown error", http.StatusInternalServerError)
return
}
defer util.Close(reader)
w.Header().Set("Content-Type", reader.Attrs.ContentType)
w.Header().Set("Content-Encoding", reader.Attrs.ContentEncoding)
size, err := io.Copy(w, reader)
if err != nil {
sklog.Errorf("Error reading object %s: %s", gcsPath, err)
http.Error(w, "unknown error", http.StatusInternalServerError)
return
}
if size != reader.Attrs.Size {
errMsg := fmt.Sprintf("Read incorrect number of bytes for %s. Expected %d but read %d", gcsPath, reader.Attrs.Size, size)
sklog.Error(errMsg)
http.Error(w, errMsg, http.StatusInternalServerError)
return
}
}
func main() {
common.InitWithMust(
"autoroll-fe",
common.PrometheusOpt(promPort),
)
defer common.Defer()
if *bucket == "" {
sklog.Fatal("--bucket is required.")
}
*bucket = strings.TrimPrefix(*bucket, "gs://")
ctx := context.Background()
// Note: storage.NewClient() will use Application Default Credentials by
// default if option.WithHTTPClient is not provided, but doing to results in
// the caller needing to have the serviceusage.services.use permission for
// the project in question, which our developer accounts do not seem to
// have by default.
ts, err := google.DefaultTokenSource(ctx, storage.ScopeReadOnly)
if err != nil {
sklog.Fatal(err)
}
httpClient := httputils.DefaultClientConfig().WithTokenSource(ts).Client()
gcsClient, err = storage.NewClient(ctx, option.WithScopes(storage.ScopeReadOnly), storage.WithJSONReads(), option.WithHTTPClient(httpClient))
if err != nil {
sklog.Fatal(err)
}
serverURL := "https://" + *host
if *local {
serverURL = "http://" + *host + *port
}
h := httputils.LoggingRequestResponse(http.HandlerFunc(storageHandler))
h = httputils.XFrameOptionsDeny(h)
if !*local {
h = cors.New(cors.Options{
AllowedOrigins: []string{"*.skia.org", "*.luci.app"},
Debug: true,
}).Handler(h)
h = httputils.HealthzAndHTTPS(h)
}
sklog.Infof("Ready to serve on %s", serverURL)
sklog.Fatal(http.ListenAndServe(*port, h))
}