blob: dbd565b049925ff7d45636c2896128336f465349 [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"cloud.google.com/go/compute/metadata"
"cloud.google.com/go/storage"
"google.golang.org/api/idtoken"
)
const rietveldBucket = "chromiumcodereview"
const privateProjectID = "chromiumcodereview-private"
const privateProjectURL = "https://" + privateProjectID + ".appspot.com"
// Attrs contains information for a GS object
type Attrs struct {
Private bool
StatusCode int
ContentType string
}
func main() {
http.Handle("/", http.HandlerFunc(pathHandler))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
log.Printf("Defaulting to port %s", port)
}
log.Printf("Listening on port %s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}
// pathHandler handles /<path> to access gs://chromiumcodereview/<path>.
func pathHandler(w http.ResponseWriter, req *http.Request) {
ctx := context.Background()
// Remove trailing slashes, so that '/<issue>/' works as well as '/<issue>'.
path := strings.TrimSuffix(req.URL.Path, "/")
client, err := storage.NewClient(ctx)
if err != nil {
log.Printf("failed to create storage client: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer client.Close()
obj := client.Bucket(rietveldBucket).Object(path)
attrs, err := fetchAttrs(ctx, obj)
if err != nil {
log.Printf("failed to fetch attributes for %s: %v", path, err)
errString := err.Error()
if err == storage.ErrObjectNotExist {
http.Error(w, errString, http.StatusNotFound)
} else {
http.Error(w, errString, http.StatusInternalServerError)
}
return
}
if attrs.Private {
// Private issues should only be accessed by a project protected with an
// IAP. Redirect to a protected project if necessary.
projectID := os.Getenv("GOOGLE_CLOUD_PROJECT")
if projectID != privateProjectID {
log.Printf("redirecting to %s", privateProjectURL+path)
http.Redirect(w, req, privateProjectURL+path, http.StatusMovedPermanently)
return
}
// Validate that the IAP JWT is valid and the user is authorized when trying
// to access a private issue.
err = authorize(ctx, req)
if err != nil {
log.Printf("not authorized: %v", err)
http.Error(w, "not authorized", http.StatusUnauthorized)
return
}
}
reader, err := obj.NewReader(ctx)
if err != nil {
log.Printf("failed to fetch %s: %v", path, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer reader.Close()
w.WriteHeader(attrs.StatusCode)
w.Header().Set("Content-Type", attrs.ContentType)
_, err = io.Copy(w, reader)
if err != nil {
log.Printf("failed to copy %s: %v", path, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func fetchAttrs(ctx context.Context, obj *storage.ObjectHandle) (*Attrs, error) {
attrs, err := obj.Attrs(ctx)
if err != nil {
return nil, err
}
private, ok := attrs.Metadata["Rietveld-Private"]
if !ok {
return nil, errors.New("expected object metadata to contain Rietveld-Private attribute")
}
statusCodeStr, ok := attrs.Metadata["Status-Code"]
if !ok {
return nil, errors.New("expected object metadata to contain Status-Code attribute")
}
statusCode, err := strconv.Atoi(statusCodeStr)
if err != nil {
return nil, fmt.Errorf("expected object Status-Code attribute to be an integer: %v", statusCode)
}
return &Attrs{
Private: private == "True",
StatusCode: statusCode,
ContentType: attrs.ContentType,
}, nil
}
// authorize validates the JWT token present in the request and ensures that the
// user is authorized to view private issues.
func authorize(ctx context.Context, req *http.Request) error {
jwt := req.Header.Get("X-Goog-IAP-JWT-Assertion")
if len(jwt) == 0 {
return errors.New("X-Goog-IAP-JWT-Assertion header not present")
}
projectID := os.Getenv("GOOGLE_CLOUD_PROJECT")
projectNumber, err := metadata.NumericProjectID()
if err != nil {
return fmt.Errorf("failed to fetch project number: %v", err)
}
aud := fmt.Sprintf("/projects/%s/apps/%s", projectNumber, projectID)
payload, err := idtoken.Validate(ctx, jwt, aud)
if err != nil {
return fmt.Errorf("idtoken.Validate: %v", err)
}
email, ok := payload.Claims["email"].(string)
if !ok {
return errors.New("email not present in JWT claims")
}
if !strings.HasSuffix(email, "@google.com") && !strings.HasSuffix(email, "@chromium.org") {
return fmt.Errorf("not a @google.com or @chromium.org account: %v", email)
}
return nil
}