| // docserver is a super simple Markdown (CommonMark) server. |
| // |
| // Every directory has an index.md and the first line of index.md is used as |
| // the display name for that directory. The directory name itself is used in |
| // the URL path. See README.md for more design details. |
| package main |
| |
| import ( |
| "context" |
| "flag" |
| "fmt" |
| "mime" |
| "net/http" |
| "net/url" |
| "path/filepath" |
| "runtime" |
| "strconv" |
| "strings" |
| "text/template" |
| "time" |
| |
| "github.com/fiorix/go-web/autogzip" |
| "github.com/gorilla/mux" |
| "github.com/russross/blackfriday/v2" |
| "go.skia.org/infra/docserverk/go/docset" |
| "go.skia.org/infra/go/common" |
| "go.skia.org/infra/go/httputils" |
| "go.skia.org/infra/go/login" |
| "go.skia.org/infra/go/sklog" |
| ) |
| |
| var ( |
| indexTemplate *template.Template |
| |
| primary *docset.DocSet |
| |
| // blackfridayOptList are options to pass to blackfriday.Run that turn off |
| // smartypants rendering which conflicts with graphviz dot markup. |
| blackfridayOptList = []blackfriday.Option{ |
| blackfriday.WithRenderer(blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{})), |
| blackfriday.WithExtensions(blackfriday.CommonExtensions), |
| } |
| ) |
| |
| // flags |
| var ( |
| docRepo = flag.String("doc_repo", "https://skia.googlesource.com/skia", "The repo to check out.") |
| local = flag.Bool("local", false, "Running locally if true. As opposed to in production.") |
| port = flag.String("port", ":8000", "HTTP service address (e.g., ':8000')") |
| preview = flag.Bool("preview", false, "Preview markdown changes to a local repo. Doesn't do pulls.") |
| promPort = flag.String("prom_port", ":20000", "Metrics service address (e.g., ':10110')") |
| refresh = flag.Duration("refresh", 5*time.Minute, "The duration between doc git repo refreshes.") |
| resourcesDir = flag.String("resources_dir", "", "The directory to find templates, JS, and CSS files. If blank the current directory will be used.") |
| workDir = flag.String("work_dir", "/tmp", "The directory to check out the doc repo into.") |
| ) |
| |
| func loadTemplates() { |
| indexTemplate = template.Must(template.ParseFiles( |
| filepath.Join(*resourcesDir, "templates/index.html"), |
| )) |
| } |
| |
| func Init() { |
| if *resourcesDir == "" { |
| _, filename, _, _ := runtime.Caller(0) |
| *resourcesDir = filepath.Join(filepath.Dir(filename), "../..") |
| } |
| loadTemplates() |
| |
| var err error |
| if err = docset.Init(*local); err != nil { |
| sklog.Fatalf("Failed to initialize docset: %s", err) |
| } |
| if *preview { |
| primary, err = docset.NewPreviewDocSet() |
| } else { |
| primary, err = docset.NewDocSet(context.Background(), *workDir, *docRepo) |
| } |
| if err != nil { |
| sklog.Fatalf("Failed to load the docset: %s", err) |
| } |
| if !*preview { |
| go docset.StartCleaner(*workDir) |
| } |
| } |
| |
| type Content struct { |
| Body string |
| Nav string |
| } |
| |
| // mainHandler handles the GET of the main page. |
| // |
| // Handles servering all the processed Markdown documents |
| // and other assetts in the doc repo. |
| func mainHandler(w http.ResponseWriter, r *http.Request) { |
| sklog.Infof("Main Handler: %q\n", r.URL.Path) |
| |
| // If the request begins with /_/ then it is an XHR request and we only need |
| // to return the content and not the surrounding markup. |
| bodyOnly := false |
| if strings.HasPrefix(r.URL.Path, "/_/") { |
| bodyOnly = true |
| r.URL.Path = r.URL.Path[2:] |
| } |
| |
| d := primary |
| // If there is a cl={issue_number} query parameter supplied then |
| // clone a new copy of the docs repo and patch that issue into it, |
| // and then serve this reqeust from that patched repo. |
| cl := r.FormValue("cl") |
| |
| // Images and other assets won't include the ?cl=[issueid], which means if we |
| // add a new image in a CL it won't normally show up in the preview, but we |
| // can extract the issue id from the Referer header if present. |
| ref := r.Header.Get("Referer") |
| if cl == "" && ref != "" { |
| if refParsed, err := url.Parse(ref); err == nil && (refParsed.Host == r.Host || refParsed.Host == "skia.org") { |
| cl = refParsed.Query().Get("cl") |
| } |
| } |
| if cl != "" { |
| issue, err := strconv.ParseInt(cl, 10, 64) |
| if err != nil { |
| httputils.ReportError(w, fmt.Errorf("Not a valid integer id for an issue."), "The CL given is not valid.", http.StatusInternalServerError) |
| return |
| } |
| d, err = docset.NewDocSetForIssue(context.Background(), *workDir, *docRepo, issue) |
| if err == docset.IssueCommittedErr { |
| httputils.ReportError(w, err, "Failed to load the given CL, that issue is closed.", http.StatusInternalServerError) |
| return |
| } |
| if err != nil { |
| httputils.ReportError(w, err, "Failed to load the given CL.", http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| // When running in local mode reload all templates and rebuild the navigation |
| // menu on every request, so we don't have to start and stop the server while |
| // developing. |
| if *local { |
| d.BuildNavigation() |
| loadTemplates() |
| } |
| if r.URL.Path == "/sitemap.txt" { |
| w.Header().Set("Content-Type", "text/plain") |
| if _, err := w.Write([]byte(d.SiteMap())); err != nil { |
| sklog.Errorf("Failed to write sitemap.txt: %s", err) |
| } |
| return |
| } |
| |
| filename, raw, err := d.RawFilename(r.URL.Path) |
| if err != nil { |
| sklog.Infof("Request for unknown path: %s", r.URL.Path) |
| http.NotFound(w, r) |
| return |
| } |
| |
| // Set the content type. |
| mimetype := "text/html" |
| if raw { |
| mimetype = mime.TypeByExtension(filepath.Ext(filename)) |
| } |
| w.Header().Set("Content-Type", mimetype) |
| |
| // Write the response. |
| b, err := d.Body(filename) |
| if err != nil { |
| httputils.ReportError(w, err, "Failed to load file", http.StatusInternalServerError) |
| return |
| } |
| if raw { |
| if _, err := w.Write(b); err != nil { |
| sklog.Errorf("Failed to write output: %s", err) |
| return |
| } |
| } else { |
| body := blackfriday.Run(b, blackfridayOptList...) |
| |
| if bodyOnly { |
| if _, err := w.Write(body); err != nil { |
| sklog.Errorf("Failed to write output: %s", err) |
| return |
| } |
| } else { |
| content := &Content{ |
| Body: string(body), |
| Nav: d.Navigation(), |
| } |
| if err := indexTemplate.Execute(w, content); err != nil { |
| sklog.Error("Failed to expand template:", err) |
| } |
| } |
| } |
| } |
| |
| func makeResourceHandler() func(http.ResponseWriter, *http.Request) { |
| fileServer := http.FileServer(http.Dir(*resourcesDir)) |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Add("Cache-Control", "max-age=300") |
| fileServer.ServeHTTP(w, r) |
| } |
| } |
| |
| func main() { |
| common.InitWithMust( |
| "docserver", |
| common.PrometheusOpt(promPort), |
| common.MetricsLoggingOpt(), |
| ) |
| |
| if !*local { |
| login.SimpleInitMust(*port, *local) |
| } |
| |
| Init() |
| |
| router := mux.NewRouter() |
| // Resources are served directly. |
| router.PathPrefix("/res/").HandlerFunc(makeResourceHandler()) |
| |
| if !*local { |
| router.HandleFunc("/logout/", login.LogoutHandler) |
| router.HandleFunc("/loginstatus/", login.StatusHandler) |
| router.HandleFunc("/oauth2callback/", login.OAuth2CallbackHandler) |
| } |
| |
| router.PathPrefix("/").HandlerFunc(autogzip.HandleFunc(mainHandler)) |
| |
| var h http.Handler = router |
| if !*local { |
| h = httputils.HealthzAndHTTPS(h) |
| } |
| http.Handle("/", h) |
| |
| sklog.Info("Ready to serve.") |
| sklog.Fatal(http.ListenAndServe(*port, nil)) |
| } |