package main import ( "context" "flag" "fmt" "net/http" "path/filepath" "regexp" "strings" "time" "code.hackerspace.pl/hscloud/go/mirko" "code.hackerspace.pl/hscloud/go/pki" "github.com/golang/glog" "google.golang.org/grpc" dvpb "code.hackerspace.pl/hscloud/devtools/depotview/proto" "code.hackerspace.pl/hscloud/devtools/hackdoc/config" "code.hackerspace.pl/hscloud/devtools/hackdoc/source" ) var ( flagListen = "127.0.0.1:8080" flagDocRoot = "" flagDepotViewAddress = "" flagHackdocURL = "" flagGitwebDefaultBranch = "master" rePagePath = regexp.MustCompile(`^/([A-Za-z0-9_\-/\. ]*)$`) ) func init() { flag.Set("logtostderr", "true") } func main() { flag.StringVar(&flagListen, "pub_listen", flagListen, "Address to listen on for HTTP traffic") flag.StringVar(&flagDocRoot, "docroot", flagDocRoot, "Path from which to serve documents. Either this or depotview must be set") flag.StringVar(&flagDepotViewAddress, "depotview", flagDepotViewAddress, "gRPC endpoint of depotview to serve from Git. Either this or docroot must be set") flag.StringVar(&flagHackdocURL, "hackdoc_url", flagHackdocURL, "Public URL of hackdoc. If not given, autogenerate from listen path for dev purposes") flag.StringVar(&source.FlagGitwebURLPattern, "gitweb_url_pattern", source.FlagGitwebURLPattern, "Pattern to sprintf to for URL for viewing a file in Git. First string is ref/rev, second is bare file path (sans //)") flag.StringVar(&flagGitwebDefaultBranch, "gitweb_default_rev", flagGitwebDefaultBranch, "Default Git rev to render/link to") flag.Parse() if flagHackdocURL == "" { flagHackdocURL = fmt.Sprintf("http://%s", flagListen) } if flagDocRoot == "" && flagDepotViewAddress == "" { glog.Errorf("Either -docroot or -depotview must be set") } if flagDocRoot != "" && flagDepotViewAddress != "" { glog.Errorf("Only one of -docroot or -depotview must be set") } m := mirko.New() if err := m.Listen(); err != nil { glog.Exitf("Listen(): %v", err) } var s *service if flagDocRoot != "" { path, err := filepath.Abs(flagDocRoot) if err != nil { glog.Exitf("Could not dereference path %q: %w", path, err) } glog.Infof("Starting in docroot mode for %q -> %q", flagDocRoot, path) s = &service{ source: source.NewSingleRefProvider(source.NewLocal(path)), } } else { glog.Infof("Starting in depotview mode (server %q)", flagDepotViewAddress) conn, err := grpc.Dial(flagDepotViewAddress, pki.WithClientHSPKI()) if err != nil { glog.Exitf("grpc.Dial(%q): %v", flagDepotViewAddress, err) } stub := dvpb.NewDepotViewClient(conn) s = &service{ source: source.NewDepotView(stub), } } mux := http.NewServeMux() mux.HandleFunc("/", s.handler) srv := &http.Server{Addr: flagListen, Handler: mux} glog.Infof("Listening on %q...", flagListen) go func() { if err := srv.ListenAndServe(); err != nil { glog.Error(err) } }() <-m.Done() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() srv.Shutdown(ctx) } type service struct { source source.SourceProvider } func (s *service) handler(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" && r.Method != "HEAD" { w.WriteHeader(http.StatusMethodNotAllowed) fmt.Fprintf(w, "method not allowed") return } glog.Infof("%+v", r.URL.Query()) ref := r.URL.Query().Get("ref") if ref == "" { ref = flagGitwebDefaultBranch } ctx := r.Context() source, err := s.source.Source(ctx, ref) switch { case err != nil: glog.Errorf("Source(%q): %v", ref, err) handle500(w, r) return case source == nil: handle404(w, r) return } path := r.URL.Path if match := rePagePath.FindStringSubmatch(path); match != nil { req := &request{ w: w, r: r, ctx: r.Context(), ref: ref, source: source, } req.handlePage(match[1]) return } handle404(w, r) } type request struct { w http.ResponseWriter r *http.Request ctx context.Context ref string source source.Source // rpath is the path requested by the client rpath string } func (r *request) handle500() { handle500(r.w, r.r) } func (r *request) handle404() { handle404(r.w, r.r) } func (r *request) logRequest(format string, args ...interface{}) { logRequest(r.w, r.r, format, args...) } func urlPathToDepotPath(url string) string { // Sanitize request. parts := strings.Split(url, "/") for i, p := range parts { // Allow last part to be "", ie, for a path to end in / if p == "" { if i != len(parts)-1 { return "" } } // net/http sanitizes this anyway, but we better be sure. if p == "." || p == ".." { return "" } } path := "//" + strings.Join(parts, "/") return path } func (r *request) handlePageAuto(dirpath string) { cfg, err := config.ForPath(r.ctx, r.source, dirpath) if err != nil { glog.Errorf("could not get config for path %q: %w", dirpath, err) r.handle500() return } for _, f := range cfg.DefaultIndex { fpath := dirpath + f file, err := r.source.IsFile(r.ctx, fpath) if err != nil { glog.Errorf("IsFile(%q): %w", fpath, err) r.handle500() return } if file { r.handleMarkdown(fpath, cfg) return } } r.handle404() } func (r *request) handlePage(page string) { r.rpath = urlPathToDepotPath(page) if strings.HasSuffix(r.rpath, "/") { // Directory path given, autoresolve. dirpath := r.rpath if r.rpath != "//" { dirpath = strings.TrimSuffix(r.rpath, "/") + "/" } r.handlePageAuto(dirpath) return } // Otherwise, try loading the file. file, err := r.source.IsFile(r.ctx, r.rpath) if err != nil { glog.Errorf("IsFile(%q): %w", r.rpath, err) r.handle500() return } // File exists, render that. if file { parts := strings.Split(r.rpath, "/") dirpath := strings.Join(parts[:(len(parts)-1)], "/") cfg, err := config.ForPath(r.ctx, r.source, dirpath) if err != nil { glog.Errorf("could not get config for path %q: %w", dirpath, err) r.handle500() return } r.handleMarkdown(r.rpath, cfg) return } // Otherwise assume directory, try all posibilities. dirpath := r.rpath if r.rpath != "//" { dirpath = strings.TrimSuffix(r.rpath, "/") + "/" } r.handlePageAuto(dirpath) }