perkeep/website/camweb.go

1198 lines
34 KiB
Go

/*
Copyright 2011 Google Inc.
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 main // import "camlistore.org/website"
import (
"bytes"
"crypto/rand"
"crypto/tls"
"encoding/json"
"errors"
"flag"
"fmt"
"html/template"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"net/smtp"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
txttemplate "text/template"
"time"
"camlistore.org/pkg/deploy/gce"
"camlistore.org/pkg/netutil"
"camlistore.org/pkg/osutil"
"camlistore.org/pkg/types/camtypes"
"cloud.google.com/go/compute/metadata"
"cloud.google.com/go/datastore"
"cloud.google.com/go/logging"
"cloud.google.com/go/storage"
"github.com/russross/blackfriday"
"go4.org/cloud/cloudlaunch"
"go4.org/writerutil"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/net/context"
"golang.org/x/oauth2/google"
cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1"
compute "google.golang.org/api/compute/v1"
"google.golang.org/api/option"
storageapi "google.golang.org/api/storage/v1"
)
const defaultAddr = ":31798" // default webserver address
var h1TitlePattern = regexp.MustCompile(`<h1>([^<]+)</h1>`)
var (
httpAddr = flag.String("http", defaultAddr, "HTTP address")
httpsAddr = flag.String("https", "", "HTTPS address")
root = flag.String("root", "", "Website root (parent of 'static', 'content', and 'tmpl)")
logDir = flag.String("logdir", "", "Directory to write log files to (one per hour), or empty to not log.")
logStdout = flag.Bool("logstdout", true, "Whether to log to stdout")
tlsCertFile = flag.String("tlscert", "", "TLS cert file")
tlsKeyFile = flag.String("tlskey", "", "TLS private key file")
alsoRun = flag.String("also_run", "", "[optiona] Path to run as a child process. (Used to run camlistore.org's ./scripts/run-blob-server)")
devMode = flag.Bool("dev", false, "in dev mode")
flagStaging = flag.Bool("staging", false, "Deploy to a test GCE instance. Requires -cloudlaunch=true")
gceProjectID = flag.String("gce_project_id", "", "GCE project ID; required if not running on GCE and gce_log_name is specified.")
gceLogName = flag.String("gce_log_name", "", "GCE Cloud Logging log name; if non-empty, logs go to Cloud Logging instead of Apache-style local disk log files")
gceJWTFile = flag.String("gce_jwt_file", "", "If non-empty, a filename to the GCE Service Account's JWT (JSON) config file.")
gitContainer = flag.Bool("git_container", false, "Use git from the `camlistore/git` Docker container; if false, the system `git` is used.")
adminEmail = flag.String("email", "", "Address that Let's Encrypt will notify about problems with issued certificates")
flagChromeBugRepro = flag.Bool("chrome_bug", false, "Run the chrome bug repro demo for issue #660. True in production.")
)
const (
stagingInstName = "camweb-staging" // name of the GCE instance when testing
stagingHostname = "staging.camlistore.net"
)
var (
inProd bool
// inStaging is whether this instance is the staging server. This should only be true
// if inProd is also true - they are not mutually exclusive; staging is still prod -
// because we want to test the same code paths as in production. The code then runs
// on another GCE instance, and on the stagingHostname host.
inStaging bool
pageHTML, errorHTML, camliErrorHTML *template.Template
packageHTML *txttemplate.Template
buildbotBackend, buildbotHost string
// file extensions checked in order to satisfy file requests
fileExtensions = []string{".md", ".html"}
// files used to satisfy directory requests
indexFiles = []string{"index.html", "README.md"}
)
var fmap = template.FuncMap{
// "": textFmt, // Used to work in Go 1.5
"html": htmlFmt,
"htmlesc": htmlEscFmt,
}
// Template formatter for "" (default) format.
func textFmt(w io.Writer, format string, x ...interface{}) string {
writeAny(w, false, x[0])
return ""
}
// Template formatter for "html" format.
func htmlFmt(w io.Writer, format string, x ...interface{}) string {
writeAny(w, true, x[0])
return ""
}
// Template formatter for "htmlesc" format.
func htmlEscFmt(w io.Writer, format string, x ...interface{}) string {
var buf bytes.Buffer
writeAny(&buf, false, x[0])
template.HTMLEscape(w, buf.Bytes())
return ""
}
// Write anything to w; optionally html-escaped.
func writeAny(w io.Writer, html bool, x interface{}) {
switch v := x.(type) {
case []byte:
writeText(w, v, html)
case string:
writeText(w, []byte(v), html)
default:
if html {
var buf bytes.Buffer
fmt.Fprint(&buf, x)
writeText(w, buf.Bytes(), true)
} else {
fmt.Fprint(w, x)
}
}
}
// Write text to w; optionally html-escaped.
func writeText(w io.Writer, text []byte, html bool) {
if html {
template.HTMLEscape(w, text)
return
}
w.Write(text)
}
func applyTemplate(t *template.Template, name string, data interface{}) []byte {
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
log.Printf("%s.Execute: %s", name, err)
}
return buf.Bytes()
}
type pageParams struct {
title string // required
subtitle string // used by pkg doc
content []byte // required
}
func servePage(w http.ResponseWriter, params pageParams) {
title, subtitle, content := params.title, params.subtitle, params.content
// insert an "install command" if it applies
if strings.Contains(title, cmdPattern) && subtitle != cmdPattern {
toInsert := `
<h3>Installation</h3>
<pre>go get camlistore.org/cmd/` + subtitle + `</pre>
<h3>Overview</h3><p>`
content = bytes.Replace(content, []byte("<p>"), []byte(toInsert), 1)
}
d := struct {
Title string
Subtitle string
Content template.HTML
}{
title,
subtitle,
template.HTML(content),
}
if err := pageHTML.ExecuteTemplate(w, "page", &d); err != nil {
log.Printf("godocHTML.Execute: %s", err)
}
}
func readTemplate(name string) *template.Template {
fileName := filepath.Join(*root, "tmpl", name)
data, err := ioutil.ReadFile(fileName)
if err != nil {
log.Fatalf("ReadFile %s: %v", fileName, err)
}
t, err := template.New(name).Funcs(fmap).Parse(string(data))
if err != nil {
log.Fatalf("%s: %v", fileName, err)
}
return t
}
func readTemplates() {
pageHTML = readTemplate("page.html")
errorHTML = readTemplate("error.html")
camliErrorHTML = readTemplate("camlierror.html")
// TODO(mpl): see about not using text template anymore?
packageHTML = readTextTemplate("package.html")
}
func serveError(w http.ResponseWriter, r *http.Request, relpath string, err error) {
contents := applyTemplate(errorHTML, "errorHTML", err) // err may contain an absolute path!
w.WriteHeader(http.StatusNotFound)
servePage(w, pageParams{
title: "File " + relpath,
content: contents,
})
}
const gerritURLPrefix = "https://camlistore.googlesource.com/camlistore/+/"
var commitHash = regexp.MustCompile(`^p=camlistore.git;a=commit;h=([0-9a-f]+)$`)
// empty return value means don't redirect.
func redirectPath(u *url.URL) string {
// Example:
// /code/?p=camlistore.git;a=commit;h=b0d2a8f0e5f27bbfc025a96ec3c7896b42d198ed
if strings.HasPrefix(u.Path, "/code/") {
m := commitHash.FindStringSubmatch(u.RawQuery)
if len(m) == 2 {
return gerritURLPrefix + m[1]
}
}
if strings.HasPrefix(u.Path, "/gw/") {
path := strings.TrimPrefix(u.Path, "/gw/")
if strings.HasPrefix(path, "doc") || strings.HasPrefix(path, "clients") {
return gerritURLPrefix + "master/" + path
}
// Assume it's a commit
return gerritURLPrefix + path
}
if strings.HasPrefix(u.Path, "/docs/") {
return "/doc/" + strings.TrimPrefix(u.Path, "/docs/")
}
// strip directory index files
for _, x := range indexFiles {
if strings.HasSuffix(u.Path, "/"+x) {
return strings.TrimSuffix(u.Path, x)
}
}
// strip common file extensions
for _, x := range fileExtensions {
if strings.HasSuffix(u.Path, x) {
return strings.TrimSuffix(u.Path, x)
}
}
return ""
}
func mainHandler(rw http.ResponseWriter, req *http.Request) {
if target := redirectPath(req.URL); target != "" {
http.Redirect(rw, req, target, http.StatusFound)
return
}
if dest, ok := issueRedirect(req.URL.Path); ok {
http.Redirect(rw, req, dest, http.StatusFound)
return
}
findAndServeFile(rw, req, filepath.Join(*root, "content"))
}
func docHandler(rw http.ResponseWriter, req *http.Request) {
if target := redirectPath(req.URL); target != "" {
http.Redirect(rw, req, target, http.StatusFound)
return
}
findAndServeFile(rw, req, filepath.Dir(*root))
}
// modtime is the modification time of the resource to be served, or IsZero().
// return value is whether this request is now complete.
func checkLastModified(w http.ResponseWriter, r *http.Request, modtime time.Time) bool {
if modtime.IsZero() {
return false
}
// The Date-Modified header truncates sub-second precision, so
// use mtime < t+1s instead of mtime <= t to check for unmodified.
if t, err := time.Parse(http.TimeFormat, r.Header.Get("If-Modified-Since")); err == nil && modtime.Before(t.Add(1*time.Second)) {
h := w.Header()
delete(h, "Content-Type")
delete(h, "Content-Length")
w.WriteHeader(http.StatusNotModified)
return true
}
w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat))
return false
}
// findAndServeFile finds the file in root to satisfy req. This method will
// map URLs to exact filename matches, falling back to files ending in ".md" or
// ".html". For example, a request for "/foo" may be served by a file named
// foo, foo.md, or foo.html. Requests that map to directories may be served by
// an index.html or README.md file in that directory.
func findAndServeFile(rw http.ResponseWriter, req *http.Request, root string) {
relPath := req.URL.Path[1:] // serveFile URL paths start with '/'
if strings.Contains(relPath, "..") {
return
}
var (
absPath string
fi os.FileInfo
err error
)
for _, ext := range append([]string{""}, fileExtensions...) {
absPath = filepath.Join(root, relPath+ext)
fi, err = os.Lstat(absPath)
if err == nil || !os.IsNotExist(err) {
break
}
}
if err != nil {
log.Print(err)
serveError(rw, req, relPath, err)
return
}
// If it's a directory without a trailing slash, redirect to
// the URL with a trailing slash so relative links within that
// directory work.
if fi.IsDir() && !strings.HasSuffix(req.URL.Path, "/") {
http.Redirect(rw, req, req.URL.Path+"/", http.StatusFound)
return
}
// if directory request, try to find an index file
if fi.IsDir() {
for _, index := range indexFiles {
childAbsPath := filepath.Join(root, relPath, index)
childFi, err := os.Lstat(childAbsPath)
if err != nil {
if os.IsNotExist(err) {
// didn't find this file, try the next
continue
}
log.Print(err)
serveError(rw, req, relPath, err)
return
}
fi = childFi
absPath = childAbsPath
break
}
}
if fi.IsDir() {
log.Printf("Error serving website content: %q is a directory", absPath)
serveError(rw, req, relPath, fmt.Errorf("error: %q is a directory", absPath))
return
}
if checkLastModified(rw, req, fi.ModTime()) {
return
}
serveFile(rw, req, relPath, absPath)
}
// configure blackfriday options. These are the same options that
// blackfriday.MarkdownCommon uses with minor additions.
const (
markdownHTMLFlags = 0 |
blackfriday.HTML_USE_XHTML |
blackfriday.HTML_USE_SMARTYPANTS |
blackfriday.HTML_SMARTYPANTS_FRACTIONS |
blackfriday.HTML_SMARTYPANTS_DASHES |
blackfriday.HTML_SMARTYPANTS_LATEX_DASHES
markdownExtensions = 0 |
blackfriday.EXTENSION_NO_INTRA_EMPHASIS |
blackfriday.EXTENSION_TABLES |
blackfriday.EXTENSION_FENCED_CODE |
blackfriday.EXTENSION_AUTOLINK |
blackfriday.EXTENSION_STRIKETHROUGH |
blackfriday.EXTENSION_SPACE_HEADERS |
blackfriday.EXTENSION_HEADER_IDS |
blackfriday.EXTENSION_BACKSLASH_LINE_BREAK |
blackfriday.EXTENSION_DEFINITION_LISTS |
blackfriday.EXTENSION_AUTO_HEADER_IDS
)
// serveFile serves a file from disk, converting any markdown to HTML.
func serveFile(w http.ResponseWriter, r *http.Request, relPath, absPath string) {
if !strings.HasSuffix(absPath, ".html") && !strings.HasSuffix(absPath, ".md") {
http.ServeFile(w, r, absPath)
return
}
data, err := ioutil.ReadFile(absPath)
if err != nil {
serveError(w, r, absPath, err)
return
}
var markdownRenderer = blackfriday.HtmlRenderer(markdownHTMLFlags, "", "")
data = blackfriday.MarkdownOptions(data, markdownRenderer, blackfriday.Options{Extensions: markdownExtensions})
title := ""
if m := h1TitlePattern.FindSubmatch(data); len(m) > 1 {
title = string(m[1])
}
servePage(w, pageParams{
title: title,
content: data,
})
}
func isBot(r *http.Request) bool {
agent := r.Header.Get("User-Agent")
return strings.Contains(agent, "Baidu") || strings.Contains(agent, "bingbot") ||
strings.Contains(agent, "Ezooms") || strings.Contains(agent, "Googlebot")
}
// redirectRootHandler redirects users to strip off "www." prefixes
// and redirects http to https.
type redirectRootHandler struct {
Handler http.Handler
}
func (h *redirectRootHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
// Some bots (especially Baidu) don't seem to respect robots.txt and swamp gitweb.cgi,
// so explicitly protect it from bots.
if ru := r.URL.RequestURI(); strings.Contains(ru, "/code/") && strings.Contains(ru, "?") && isBot(r) {
http.Error(rw, "bye", http.StatusUnauthorized)
log.Printf("bot denied")
return
}
host := strings.ToLower(r.Host)
if host == "www.camlistore.org" || (inProd && r.TLS == nil) {
if inStaging {
http.Redirect(rw, r, "https://"+stagingHostname+r.URL.RequestURI(), http.StatusFound)
return
}
http.Redirect(rw, r, "https://camlistore.org"+r.URL.RequestURI(), http.StatusFound)
return
}
h.Handler.ServeHTTP(rw, r)
}
// runAsChild runs res as a child process and
// does not wait for it to finish.
func runAsChild(res string) {
cmdName, err := exec.LookPath(res)
if err != nil {
log.Fatalf("Could not find %v in $PATH: %v", res, err)
}
cmd := exec.Command(cmdName)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
log.Printf("Running %v", res)
if err := cmd.Start(); err != nil {
log.Fatalf("Program %v failed to start: %v", res, err)
}
go func() {
if err := cmd.Wait(); err != nil {
log.Fatalf("Program %s did not end successfully: %v", res, err)
}
}()
}
func gceDeployHandlerConfig() (*gce.Config, error) {
if inProd {
return deployerCredsFromGCS()
}
clientId := os.Getenv("CAMLI_GCE_CLIENTID")
if clientId != "" {
return &gce.Config{
ClientID: clientId,
ClientSecret: os.Getenv("CAMLI_GCE_CLIENTSECRET"),
Project: os.Getenv("CAMLI_GCE_PROJECT"),
ServiceAccount: os.Getenv("CAMLI_GCE_SERVICE_ACCOUNT"),
DataDir: os.Getenv("CAMLI_GCE_DATA"),
}, nil
}
configFile := filepath.Join(osutil.CamliConfigDir(), "launcher-config.json")
data, err := ioutil.ReadFile(configFile)
if err != nil {
return nil, fmt.Errorf("error reading launcher-config.json (expected of type https://godoc.org/camlistore.org/pkg/deploy/gce#Config): %v", err)
}
var config gce.Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// gceDeployHandler returns an http.Handler for a GCE launcher,
// configured to run at /prefix/ (the trailing slash can be omitted).
// The launcher is not initialized if:
// - in production, the launcher-config.json file is not found in the relevant bucket
// - neither CAMLI_GCE_CLIENTID is set, nor launcher-config.json is found in the
// camlistore server config dir.
func gceDeployHandler(prefix string) (*gce.DeployHandler, error) {
var hostPort string
var err error
scheme := "https"
if inProd {
if inStaging {
hostPort = stagingHostname + ":443"
} else {
hostPort = "camlistore.org:443"
}
} else {
addr := *httpsAddr
if *devMode && *httpsAddr == "" {
addr = *httpAddr
scheme = "http"
}
hostPort, err = netutil.ListenHostPort(addr)
if err != nil {
// the deploy handler needs to know its own
// hostname or IP for the oauth2 callback.
return nil, fmt.Errorf("invalid -https flag: %v", err)
}
}
config, err := gceDeployHandlerConfig()
if config == nil {
return nil, err
}
gceh, err := gce.NewDeployHandlerFromConfig(hostPort, prefix, config)
if err != nil {
return nil, fmt.Errorf("NewDeployHandlerFromConfig: %v", err)
}
pageBytes, err := ioutil.ReadFile(filepath.Join(*root, "tmpl", "page.html"))
if err != nil {
return nil, err
}
if err := gceh.AddTemplateTheme(string(pageBytes)); err != nil {
return nil, fmt.Errorf("AddTemplateTheme: %v", err)
}
gceh.SetScheme(scheme)
log.Printf("Starting Camlistore launcher on %s://%s%s", scheme, hostPort, prefix)
return gceh, nil
}
var launchConfig = &cloudlaunch.Config{
Name: "camweb",
BinaryBucket: "camlistore-website-resource",
GCEProjectID: "camlistore-website",
Scopes: []string{
storageapi.DevstorageFullControlScope,
compute.ComputeScope,
logging.WriteScope,
datastore.ScopeDatastore,
cloudresourcemanager.CloudPlatformScope,
},
}
func checkInProduction() bool {
if !metadata.OnGCE() {
return false
}
proj, _ := metadata.ProjectID()
inst, _ := metadata.InstanceName()
log.Printf("Running on GCE: %v / %v", proj, inst)
prod := proj == "camlistore-website" && inst == "camweb" || inst == stagingInstName
inStaging = prod && inst == stagingInstName
return prod
}
const (
prodSrcDir = "/var/camweb/src/camlistore.org"
prodLECacheDir = "/var/le/letsencrypt.cache"
)
func setProdFlags() {
inProd = checkInProduction()
if !inProd {
return
}
if *devMode {
log.Fatal("can't use dev mode in production")
}
log.Printf("Running in production; configuring prod flags & containers")
*flagChromeBugRepro = true
*httpAddr = ":80"
*httpsAddr = ":443"
buildbotBackend = "https://travis-ci.org/camlistore/camlistore"
buildbotHost = "build.camlistore.org"
*gceLogName = "camweb-access-log"
if inStaging {
*gceLogName += "-staging"
}
*root = filepath.Join(prodSrcDir, "website")
*gitContainer = true
*adminEmail = "mathieu.lonjaret@gmail.com" // for let's encrypt
*emailsTo = "camlistore-commits@googlegroups.com"
*smtpServer = "50.19.239.94:2500" // double firewall: rinetd allow + AWS
if inStaging {
// in staging, keep emailsTo so we get in the loop that does the
// git pull and refreshes the content, but no smtpServer so
// we don't actually try to send any e-mail.
*smtpServer = ""
}
os.RemoveAll(prodSrcDir)
if err := os.MkdirAll(prodSrcDir, 0755); err != nil {
log.Fatal(err)
}
log.Printf("fetching git docker image...")
getDockerImage("camlistore/git", "docker-git.tar.gz")
getDockerImage("camlistore/demoblobserver", "docker-demoblobserver.tar.gz")
log.Printf("cloning camlistore git tree...")
cloneArgs := []string{
"run",
"--rm",
"-v", "/var/camweb:/var/camweb",
"camlistore/git",
"git",
"clone",
}
if inStaging {
// We work off the staging branch, so we stay in control of the
// website contents, regardless of which commits are landing on the
// master branch in the meantime.
cloneArgs = append(cloneArgs, "-b", "staging", "https://github.com/camlistore/camlistore.git", prodSrcDir)
} else {
cloneArgs = append(cloneArgs, "https://camlistore.googlesource.com/camlistore", prodSrcDir)
}
out, err := exec.Command("docker", cloneArgs...).CombinedOutput()
if err != nil {
log.Fatalf("git clone: %v, %s", err, out)
}
os.Chdir(*root)
log.Printf("Starting.")
sendStartingEmail()
}
func randHex(n int) string {
buf := make([]byte, n/2+1)
rand.Read(buf)
return fmt.Sprintf("%x", buf)[:n]
}
func removeContainer(name string) {
if err := exec.Command("docker", "kill", name).Run(); err == nil {
// It was actually running.
log.Printf("Killed old %q container.", name)
}
if err := exec.Command("docker", "rm", name).Run(); err == nil {
// Always try to remove, in case we end up with a stale,
// non-running one (which has happened in the past).
log.Printf("Removed old %q container.", name)
}
}
// runDemoBlobServerContainer runs the demo blobserver as name in a docker
// container. It is not run in daemon mode, so it never returns if successful.
func runDemoBlobServerContainer(name string) error {
removeContainer(name)
cmd := exec.Command("docker", "run",
"--rm",
"--name="+name,
"-e", "CAMLI_ROOT="+prodSrcDir+"/website/blobserver-example/root",
"-e", "CAMLI_PASSWORD="+randHex(20),
"-v", camSrcDir()+":"+prodSrcDir,
"--net=host",
"--workdir="+prodSrcDir,
"camlistore/demoblobserver",
"camlistored",
"--openbrowser=false",
"--listen=:3179",
"--configfile="+prodSrcDir+"/website/blobserver-example/example-blobserver-config.json")
stderr := &writerutil.PrefixSuffixSaver{N: 32 << 10}
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to run demo blob server: %v, stderr: %v", err, string(stderr.Bytes()))
}
return nil
}
func runDemoBlobserverLoop() {
if runtime.GOOS != "linux" {
return
}
if _, err := exec.LookPath("docker"); err != nil {
return
}
for {
if err := runDemoBlobServerContainer("demoblob3179"); err != nil {
log.Printf("%v", err)
}
if !inProd {
// Do not bother retrying if we're most likely just testing on localhost
return
}
time.Sleep(10 * time.Second)
}
}
func sendStartingEmail() {
if *smtpServer == "" {
return
}
contentRev, err := exec.Command("docker", "run",
"--rm",
"-v", "/var/camweb:/var/camweb",
"-w", prodSrcDir,
"camlistore/git",
"/bin/bash", "-c",
"git show --pretty=format:'%ad-%h' --abbrev-commit --date=short | head -1").Output()
cl, err := smtp.Dial(*smtpServer)
if err != nil {
log.Printf("Failed to connect to SMTP server: %v", err)
return
}
defer cl.Quit()
if err = cl.Mail("noreply@camlistore.org"); err != nil {
return
}
if err = cl.Rcpt("brad@danga.com"); err != nil {
return
}
if err = cl.Rcpt("mathieu.lonjaret@gmail.com"); err != nil {
return
}
wc, err := cl.Data()
if err != nil {
return
}
_, err = fmt.Fprintf(wc, `From: noreply@camlistore.org (Camlistore Website)
To: brad@danga.com, mathieu.lonjaret@gmail.com
Subject: Camlistore camweb restarting
Camlistore website starting with binary XXXXTODO and content at git rev %s
`, contentRev)
if err != nil {
return
}
wc.Close()
}
func getDockerImage(tag, file string) {
have, err := exec.Command("docker", "inspect", tag).Output()
if err == nil && len(have) > 0 {
return // we have it.
}
url := "https://storage.googleapis.com/camlistore-website-resource/" + file
err = exec.Command("/bin/bash", "-c", "curl --silent "+url+" | docker load").Run()
if err != nil {
log.Fatal(err)
}
}
// httpClient returns an http Client suitable for Google Cloud Storage or Google Cloud
// Logging calls with the projID project ID.
func httpClient(projID string) *http.Client {
if *gceJWTFile == "" {
log.Fatal("Cannot initialize an authorized http Client without --gce_jwt_file")
}
jsonSlurp, err := ioutil.ReadFile(*gceJWTFile)
if err != nil {
log.Fatalf("Error reading --gce_jwt_file value: %v", err)
}
jwtConf, err := google.JWTConfigFromJSON(jsonSlurp, logging.WriteScope)
if err != nil {
log.Fatalf("Error reading --gce_jwt_file value: %v", err)
}
return jwtConf.Client(context.Background())
}
// projectID returns the GCE project ID used for running this camweb on GCE
// and/or for logging on Google Cloud Logging, if any.
func projectID() string {
if *gceProjectID != "" {
return *gceProjectID
}
projID, err := metadata.ProjectID()
if projID == "" || err != nil {
log.Fatalf("GCE project ID needed but --gce_project_id not specified (and not running on GCE); metadata error: %v", err)
}
return projID
}
func initStaging() error {
if *flagStaging {
launchConfig.Name = stagingInstName
return nil
}
// If we are the instance that has just been deployed, we can't rely on
// *flagStaging, since there's no way to pass flags through launchConfig.
// And we need to know if we're a staging instance, so we can set
// launchConfig.Name properly before we get into restartLoop from
// MaybeDeploy. So we use our own instance name as a hint.
if !metadata.OnGCE() {
return nil
}
instName, err := metadata.InstanceName()
if err != nil {
return fmt.Errorf("Instance could not get its Instance Name: %v", err)
}
if instName == stagingInstName {
launchConfig.Name = stagingInstName
}
return nil
}
func main() {
flag.Parse()
if err := initStaging(); err != nil {
log.Fatalf("Error setting up staging: %v", err)
}
launchConfig.MaybeDeploy()
setProdFlags()
if *root == "" {
var err error
*root, err = os.Getwd()
if err != nil {
log.Fatalf("Failed to getwd: %v", err)
}
}
readTemplates()
if err := initGithubSyncing(); err != nil {
log.Fatalf("error setting up syncing to github: %v")
}
go runDemoBlobserverLoop()
mux := http.DefaultServeMux
mux.Handle("/favicon.ico", http.FileServer(http.Dir(filepath.Join(*root, "static"))))
mux.Handle("/robots.txt", http.FileServer(http.Dir(filepath.Join(*root, "static"))))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(*root, "static")))))
mux.Handle("/talks/", http.StripPrefix("/talks/", http.FileServer(http.Dir(filepath.Join(*root, "talks")))))
mux.Handle(pkgPattern, godocHandler{})
mux.Handle(cmdPattern, godocHandler{})
mux.Handle(appPattern, godocHandler{})
mux.HandleFunc(errPattern, errHandler)
mux.HandleFunc("/r/", gerritRedirect)
mux.HandleFunc("/dl/", releaseRedirect)
mux.HandleFunc("/debug/ip", ipHandler)
mux.HandleFunc("/debug/uptime", uptimeHandler)
mux.Handle("/doc/contributing", redirTo("/code#contributing"))
mux.Handle("/lists", redirTo("/community"))
mux.HandleFunc("/contributors", contribHandler())
mux.HandleFunc("/doc/", docHandler)
mux.HandleFunc("/", mainHandler)
if buildbotHost != "" && buildbotBackend != "" {
if _, err := url.Parse(buildbotBackend); err != nil {
log.Fatalf("Failed to parse %v as a URL: %v", buildbotBackend, err)
}
bbhpattern := strings.TrimRight(buildbotHost, "/") + "/"
mux.HandleFunc(bbhpattern, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, buildbotBackend, http.StatusFound)
})
}
gceLauncher, err := gceDeployHandler("/launch/")
if err != nil {
log.Printf("Not installing GCE /launch/ handler: %v", err)
mux.HandleFunc("/launch/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("GCE launcher disabled: %v", err), 500)
})
} else {
mux.Handle("/launch/", gceLauncher)
}
var handler http.Handler = &redirectRootHandler{Handler: mux}
if *logDir != "" || *logStdout {
handler = NewLoggingHandler(handler, NewApacheLogger(*logDir, *logStdout))
}
if *gceLogName != "" {
projID := projectID()
var hc *http.Client
if !metadata.OnGCE() {
hc = httpClient(projID)
}
ctx := context.Background()
var logc *logging.Client
if metadata.OnGCE() {
logc, err = logging.NewClient(ctx, projID)
} else {
logc, err = logging.NewClient(ctx, projID, option.WithHTTPClient(hc))
}
if err != nil {
log.Fatal(err)
}
if err := logc.Ping(ctx); err != nil {
log.Fatalf("Failed to ping Google Cloud Logging: %v", err)
}
handler = NewLoggingHandler(handler, gceLogger{logc.Logger(*gceLogName)})
if gceLauncher != nil {
var logc *logging.Client
if metadata.OnGCE() {
logc, err = logging.NewClient(ctx, projID)
} else {
logc, err = logging.NewClient(ctx, projID, option.WithHTTPClient(hc))
}
if err != nil {
log.Fatal(err)
}
commonLabels := logging.CommonLabels(map[string]string{
"from": "camli-gce-launcher",
})
logger := logc.Logger(*gceLogName, commonLabels).StandardLogger(logging.Default)
logger.SetPrefix("launcher: ")
gceLauncher.SetLogger(logger)
}
}
emailErr := make(chan error)
startEmailCommitLoop(emailErr)
if *alsoRun != "" {
runAsChild(*alsoRun)
}
httpServer := &http.Server{
Addr: *httpAddr,
Handler: handler,
ReadTimeout: 5 * time.Minute,
WriteTimeout: 30 * time.Minute,
}
httpErr := make(chan error)
go func() {
log.Printf("Listening for HTTP on %v", *httpAddr)
httpErr <- httpServer.ListenAndServe()
}()
httpsErr := make(chan error)
if *httpsAddr != "" {
go func() {
httpsErr <- serveHTTPS(httpServer)
}()
}
if *flagChromeBugRepro {
go func() {
log.Printf("Repro handler failed: %v", repro(":8001", "foo:bar"))
}()
}
select {
case err := <-emailErr:
log.Fatalf("Error sending emails: %v", err)
case err := <-httpErr:
log.Fatalf("Error serving HTTP: %v", err)
case err := <-httpsErr:
log.Fatalf("Error serving HTTPS: %v", err)
}
}
func serveHTTPS(httpServer *http.Server) error {
log.Printf("Starting TLS server on %s", *httpsAddr)
httpsServer := new(http.Server)
*httpsServer = *httpServer
httpsServer.Addr = *httpsAddr
cacheDir := autocert.DirCache("letsencrypt.cache")
var domain string
if !inProd {
if *tlsCertFile != "" && *tlsKeyFile != "" {
return httpsServer.ListenAndServeTLS(*tlsCertFile, *tlsKeyFile)
}
// Otherwise use Let's Encrypt, i.e. same use case as in prod
if strings.HasPrefix(*httpsAddr, ":") {
return errors.New("for Let's Encrypt, -https needs to start with a host name")
}
host, _, err := net.SplitHostPort(*httpsAddr)
if err != nil {
return err
}
domain = host
} else {
if inStaging {
domain = stagingHostname
} else {
domain = "camlistore.org"
}
cacheDir = autocert.DirCache(prodLECacheDir)
}
m := autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(domain),
Cache: cacheDir,
}
if *adminEmail != "" {
m.Email = *adminEmail
}
httpsServer.TLSConfig = &tls.Config{
GetCertificate: m.GetCertificate,
}
log.Printf("Listening for HTTPS on %v", *httpsAddr)
ln, err := net.Listen("tcp", *httpsAddr)
if err != nil {
return err
}
return httpsServer.Serve(tls.NewListener(tcpKeepAliveListener{ln.(*net.TCPListener)}, httpsServer.TLSConfig))
}
type tcpKeepAliveListener struct {
*net.TCPListener
}
func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) {
tc, err := ln.AcceptTCP()
if err != nil {
return
}
tc.SetKeepAlive(true)
tc.SetKeepAlivePeriod(3 * time.Minute)
return tc, nil
}
func deployerCredsFromGCS() (*gce.Config, error) {
ctx := context.Background()
sc, err := storage.NewClient(ctx)
if err != nil {
return nil, err
}
slurp := func(key string) ([]byte, error) {
const bucket = "camlistore-website-resource"
rc, err := sc.Bucket(bucket).Object(key).NewReader(ctx)
if err != nil {
return nil, fmt.Errorf("Error fetching GCS object %q in bucket %q: %v", key, bucket, err)
}
defer rc.Close()
return ioutil.ReadAll(rc)
}
var cfg gce.Config
data, err := slurp("launcher-config.json")
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("Could not JSON decode camli GCE launcher config: %v", err)
}
return &cfg, nil
}
var issueNum = regexp.MustCompile(`^/(?:issue|bug)s?(/\d*)?$`)
// issueRedirect returns whether the request should be redirected to the
// issues tracker, and the url for that redirection if yes, the empty
// string otherwise.
func issueRedirect(urlPath string) (string, bool) {
m := issueNum.FindStringSubmatch(urlPath)
if m == nil {
return "", false
}
issueNumber := strings.TrimPrefix(m[1], "/")
suffix := ""
if issueNumber != "" {
suffix = "/" + issueNumber
}
return "https://github.com/camlistore/camlistore/issues" + suffix, true
}
func gerritRedirect(w http.ResponseWriter, r *http.Request) {
dest := "https://camlistore-review.googlesource.com/"
if len(r.URL.Path) > len("/r/") {
dest += r.URL.Path[1:]
}
http.Redirect(w, r, dest, http.StatusFound)
}
func releaseRedirect(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/dl" || r.URL.Path == "/dl/" {
http.Redirect(w, r, "https://camlistore.org/download/", http.StatusFound)
return
}
dest := "https://storage.googleapis.com/camlistore-release/" + strings.TrimPrefix(r.URL.Path, "/dl/")
http.Redirect(w, r, dest, http.StatusFound)
}
func redirTo(dest string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, dest, http.StatusFound)
})
}
// Not sure what's making these broken URLs like:
//
// http://localhost:8080/code/?p=camlistore.git%3Bf=doc/json-signing/json-signing.txt%3Bhb=master
//
// ... but something is. Maybe Buzz? For now just re-write them
// . Doesn't seem to be a bug in the CGI implementation, though, which
// is what I'd originally suspected.
/*
func (fu *fixUpGitwebUrls) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
oldUrl := req.URL.String()
newUrl := strings.Replace(oldUrl, "%3B", ";", -1)
if newUrl == oldUrl {
fu.handler.ServeHTTP(rw, req)
return
}
http.Redirect(rw, req, newUrl, http.StatusFound)
}
*/
func ipHandler(w http.ResponseWriter, r *http.Request) {
out, _ := exec.Command("ip", "-f", "inet", "addr", "show", "dev", "eth0").Output()
str := string(out)
pos := strings.Index(str, "inet ")
if pos == -1 {
return
}
str = str[pos+5:]
pos = strings.Index(str, "/")
if pos == -1 {
return
}
str = str[:pos]
w.Write([]byte(str))
}
var startTime = time.Now()
func uptimeHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%v", time.Now().Sub(startTime))
}
const (
errPattern = "/err/"
toHyperlink = `<a href="$1$2">$1$2</a>`
)
var camliURLPattern = regexp.MustCompile(`(https?://camlistore.org)([a-zA-Z0-9\-\_/]+)?`)
func errHandler(w http.ResponseWriter, r *http.Request) {
errString := strings.TrimPrefix(r.URL.Path, errPattern)
defer func() {
if x := recover(); x != nil {
http.Error(w, fmt.Sprintf("unknown error: %v", errString), http.StatusNotFound)
}
}()
err := camtypes.Err(errString)
data := struct {
Code string
Description template.HTML
}{
Code: errString,
Description: template.HTML(camliURLPattern.ReplaceAllString(err.Error(), toHyperlink)),
}
contents := applyTemplate(camliErrorHTML, "camliErrorHTML", data)
w.WriteHeader(http.StatusFound)
servePage(w, pageParams{
title: errString,
content: contents,
})
}
func camSrcDir() string {
if inProd {
return prodSrcDir
}
dir, err := osutil.GoPackagePath("camlistore.org")
if err != nil {
log.Fatalf("Failed to find the root of the Camlistore source code via osutil.GoPackagePath: %v", err)
}
return dir
}