diff options
Diffstat (limited to 'cnhttp.go')
-rw-r--r-- | cnhttp.go | 645 |
1 files changed, 645 insertions, 0 deletions
diff --git a/cnhttp.go b/cnhttp.go new file mode 100644 index 0000000..42f723d --- /dev/null +++ b/cnhttp.go @@ -0,0 +1,645 @@ +package main + +import ( + "bytes" + "errors" + "flag" + "html/template" + "io" + "log" + "net" + "net/http" + "net/url" + "path" + "sort" + "strconv" + "strings" + "time" + + "github.com/kballard/go-shellquote" + + "contnet.org/lib/cnm-go" + "contnet.org/lib/cnm-go/cnmfmt" + "contnet.org/lib/cnp-go" +) + +var ( + listen = flag.String("listen", "localhost:8080", "address for HTTP server to listen on") + cnphost = flag.String("cnp-host", "", "the CNP host to proxy (disables browser mode)") + nohighlight = flag.Bool("no-highlight", false, "do not include highlight.js scripts and stylesheets") + nostatic = flag.Bool("no-static", false, "do not serve static files on /static") + + templates = template.Must(template.New("").Funcs(map[string]interface{}{ + "inc": func(s string) string { return "" }, + "dec": func() string { return "" }, + "depth": func() int { return 0 }, + "sanchor": func() string { return "" }, + "lanchor": func() string { return "" }, + "anchor": func() string { return "" }, + "lang": func(s string) string { + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + return r + } + return '_' + }, s) + }, + "href": func(s string) template.HTMLAttr { + return "href=\"" + template.HTMLAttr(template.HTMLEscapeString(s)) + "\"" + }, + "cnmfmt": (func(cnmfmt.Text) template.HTML)(nil), + "tourl": (func(string) string)(nil), + }).ParseGlob("templates/*.html")) +) + +const ( + anchorCNPSchema = "cnp://" + anchorHTTPSchema = "http://" + anchorPrefix = `<a href="` + anchorSuffix = `">` +) + +var ( + anchorTemplate = template.Must(template.New("").Parse(anchorPrefix + "{{.}}" + anchorSuffix)) +) + +func escapeURL(urlStr string) string { + isCNP := len(urlStr) >= len(anchorCNPSchema) && strings.EqualFold(urlStr[:len(anchorCNPSchema)], anchorCNPSchema) + if isCNP { + urlStr = anchorHTTPSchema + urlStr[len(anchorCNPSchema):] + } + var buf bytes.Buffer + anchorTemplate.Execute(&buf, urlStr) + urlStr = buf.String() + urlStr = urlStr[len(anchorPrefix) : len(urlStr)-len(anchorSuffix)] + if isCNP { + urlStr = anchorCNPSchema + urlStr[len(anchorHTTPSchema):] + } + return urlStr +} + +type server struct { + host string + highlighting bool +} + +func newServer(host string, highlighting bool) *server { + return &server{host, highlighting} +} + +func (srv *server) handleError(w http.ResponseWriter, err error) { + code := http.StatusInternalServerError + switch err.(type) { + case cnp.ErrorDenied: + code = http.StatusForbidden + case cnp.ErrorInvalid: + code = http.StatusInternalServerError + case cnp.ErrorNotFound: + code = http.StatusNotFound + case cnp.ErrorNotSupported: + code = http.StatusInternalServerError + case cnp.ErrorRejected: + code = http.StatusUnprocessableEntity + case cnp.ErrorServerError: + code = http.StatusBadGateway + case cnp.ErrorSyntax: + code = http.StatusInternalServerError + case cnp.ErrorTooLarge: + code = http.StatusRequestEntityTooLarge + case cnp.ErrorVersion: + code = http.StatusInternalServerError + } + log.Printf("error: %T %v", err, err) + http.Error(w, http.StatusText(code), code) +} + +func (srv *server) handleIndex(w http.ResponseWriter, r *http.Request) { + req, _ := cnp.NewRequest("", "/", nil) + resp, _ := cnp.NewResponse(cnp.IntentOK, []byte{}) + srv.cnpCNMToHTML(w, r, req, resp) +} + +func (srv *server) handleCNP(w http.ResponseWriter, r *http.Request, path string) { + host := srv.host + if host == "" { + ss := strings.SplitN(strings.TrimLeft(path, "/"), "/", 2) + if len(ss) != 2 { + if len(ss[0]) > 0 { + http.Redirect(w, r, path+"/", http.StatusFound) + return + } + srv.handleIndex(w, r) + return + } + host = ss[0] + path = "/" + ss[1] + } + + req, err := cnp.NewRequest(host, path, nil) + if err != nil { + srv.handleError(w, err) + return + } + + if ims := r.Header.Get("If-Modified-Since"); ims != "" { + var t time.Time + t, err = http.ParseTime(ims) + if err == nil && !t.IsZero() { + req.SetIfModified(t) + } + } + + resp, err := cnp.Send(req) + intent := "n/a" + if resp != nil { + intent = resp.Intent() + } + log.Printf("req: %q -> %q", req.Intent(), intent) + if err != nil { + if e, ok := err.(*net.OpError); ok && e.Op == "dial" { + err = cnp.NewError(cnp.ReasonServerError) + } + srv.handleError(w, err) + return + } + + r.ParseForm() + _, preq := r.Form["req"] + _, phdr := r.Form["hdr"] + _, presp := r.Form["resp"] + _, praw := r.Form["raw"] + + if preq || phdr || presp { + srv.cnpParamsToHTTP(w, resp) + w.Header().Set("Content-Type", "text/plain") + w.Header().Del("Content-Length") + + if preq { + req.Write(w) + } + if presp { + resp.Write(w) + } else if phdr { + resp.Header.Write(w) + } + } else { + srv.cnpToWeb(w, r, req, resp, praw) + } +} + +func (srv *server) cnpToWeb(w http.ResponseWriter, r *http.Request, req *cnp.Request, resp *cnp.Response, raw bool) { + if err := resp.Validate(); err != nil { + srv.handleError(w, err) + return + } + + switch resp.ResponseIntent() { + case cnp.IntentOK: + srv.cnpParamsToHTTP(w, resp) + if !raw && resp.Type() == "text/cnm" { + srv.cnpCNMToHTML(w, r, req, resp) + } else { + io.Copy(w, resp.Body) + } + case cnp.IntentNotModified: + w.WriteHeader(http.StatusNotModified) + case cnp.IntentRedirect: + host, pth, _ := resp.Location() // already validated + loc, err := srv.cnpRedirectToHTTP(req, host, pth) + if err != nil { + srv.handleError(w, err) + return + } + log.Println("redirecting to", loc) + http.Redirect(w, r, loc, http.StatusFound) + case cnp.IntentError: + srv.handleError(w, cnp.NewError(resp.Reason())) + default: + srv.handleError(w, errors.New("unknown CNP response intent: "+resp.ResponseIntent())) + } +} + +func (srv *server) cnpRedirectToHTTP(req *cnp.Request, host, pth string) (string, error) { + var loc string + if host == "" || host == "." || host == srv.host { + loc = pth + if host == "." { + loc = path.Join(path.Dir(req.Path()), loc) + } + if srv.host == "" { + loc = path.Join("/", req.Host(), loc) + } + } else { + rr, err := cnp.NewRequest(host, pth, nil) + if err != nil { + return "", err + } + if srv.host == "" { + loc = path.Join("?", rr.Host(), rr.Path()) + } else { + loc = rr.URL().String() + } + } + if strings.HasSuffix(pth, "/") { + loc = loc + "/" + } + return loc, nil +} + +func (srv *server) cnpParamsToHTTP(w http.ResponseWriter, resp *cnp.Response) { + if l := resp.Length(); l > 0 { + w.Header().Set("Content-Length", strconv.FormatInt(l, 10)) + } + if t := resp.Type(); t != "" { + w.Header().Set("Content-Type", t) + } + if m := resp.Modified(); !m.IsZero() { + w.Header().Set("Last-Modified", m.Format(http.TimeFormat)) + } + if t := resp.Time(); !t.IsZero() { + w.Header().Set("Date", t.Format(http.TimeFormat)) + } + if n := resp.Name(); n != "" { + w.Header().Set("Content-Disposition", "inline; filename=\""+strings.Map(func(r rune) rune { + if r < ' ' || r == ' ' || r == '\'' { // filter out nulls, control codes, newlines and quotes + return -1 + } + return r + }, n)+"\"") + } +} + +type cnmPage struct { + URL string + Req string + Resp string + Doc *cnm.Document + Site []site + Netcat string + Toc tocSection + Highlight bool + Browser bool + depth int +} + +type tocSection struct { + Title string + Children []tocSection +} + +type site struct { + Path string + Name string + Children []site +} + +func genToc(b cnm.Block) []tocSection { + var res []tocSection + switch v := b.(type) { + case *cnm.SectionBlock: + if t := v.Title(); t != "" { + var ch []tocSection + for _, c := range v.Children() { + ch = append(ch, genToc(c)...) + } + res = append(res, tocSection{ + Title: v.Title(), + Children: ch, + }) + } else { + for _, bl := range v.Children() { + res = append(res, genToc(bl)...) + } + } + case *cnm.TableBlock: + for _, bl := range v.Rows() { + res = append(res, genToc(bl)...) + } + case *cnm.HeaderBlock: + for _, bl := range v.Children() { + res = append(res, genToc(bl)...) + } + case *cnm.RowBlock: + for _, bl := range v.Children() { + res = append(res, genToc(bl)...) + } + case *cnm.ContentBlock: + if v == nil { + break + } + for _, bl := range v.Children() { + res = append(res, genToc(bl)...) + } + case *cnm.ListBlock: + for _, bl := range v.Children() { + res = append(res, genToc(bl)...) + } + } + return res +} + +func cnmSite(prefix string, s cnm.Site) (st site) { + if ss := strings.Split(path.Clean(s.Path), "/"); len(ss) > 1 { + s.Path = strings.Join(ss[1:], "/") + return site{ + Path: path.Join(prefix, ss[0]), + Name: ss[0], + Children: []site{ + cnmSite(path.Join(prefix, ss[0]), s), + }, + } + } + + st.Path = path.Join(prefix, s.Path) + if s.Name == "" { + st.Name = s.Path + } else { + st.Name = s.Name + } + if len(s.Children) > 0 { + chs := make(map[string]site) + for _, ch := range s.Children { + chs[ch.Name] = cnmSite(st.Path, ch) + } + keys := make([]string, len(chs)) + i := 0 + for k := range chs { + keys[i] = k + i++ + } + sort.Slice(keys, func(i, j int) bool { + a, b := chs[keys[i]], chs[keys[j]] + da, db := strings.HasSuffix(a.Name, "/"), strings.HasSuffix(b.Name, "/") + if da && !db { + return true + } + if !da && db { + return false + } + return a.Name < b.Name + }) + st.Children = make([]site, len(keys)) + for i := range keys { + st.Children[i] = chs[keys[i]] + } + } + return +} + +func (srv *server) cnpCNMToHTML(w http.ResponseWriter, r *http.Request, req *cnp.Request, resp *cnp.Response) { + doc, err := cnm.ParseDocument(resp.Body) + if err != nil { + srv.handleError(w, err) + return + } + + w.Header().Set("Content-Type", "text/html") + w.Header().Del("Content-Length") + + var breq, bresp, buf bytes.Buffer + req.Header.Write(&breq) + resp.Header.Write(&bresp) + var st []site + if len(doc.Site.Children) > 0 { + st = []site{ + site{"/", "/", cnmSite("/", doc.Site).Children}, + } + } + + u := req.URL() + port := u.Port() + if port == "" { + port = strconv.Itoa(cnp.DefaultPort) + } + hdrs := req.Header.String() + hdrs = hdrs[:len(hdrs)-1] + host, _, _ := net.SplitHostPort(r.Host) + if host == "" && r.Host != "" { + host = r.Host + } + if host == "" { + host = r.URL.Hostname() + } + if host == "" { + host = req.Host() + } + sections := []string{} + sanchors := map[string]bool{} + lanchors := map[string]bool{} + anchor := func() template.URL { + var secs []string + for _, s := range sections { + if s = url.PathEscape(s); s != "" { + secs = append(secs, s) + } + } + return template.URL(strings.Join(secs, "/")) + } + err = templates.Funcs(map[string]interface{}{ + "inc": func(s string) string { sections = append(sections, s); return "" }, + "dec": func() string { sections = sections[:len(sections)-1]; return "" }, + "depth": func() int { + d := len(sections) + 1 + if d > 6 { + return 6 + } + return d + }, + "anchor": anchor, + "sanchor": func() template.URL { + s := url.PathEscape(sections[len(sections)-1]) + if sanchors[s] { + return "" + } + sanchors[s] = true + return template.URL(s) + }, + "lanchor": func() template.URL { + s := string(anchor()) + if lanchors[s] { + return "" + } + lanchors[s] = true + return template.URL(s) + }, + "cnmfmt": func(txt cnmfmt.Text) template.HTML { + return srv.fmtToHTML(req, txt) + }, + "tourl": func(s string) string { + u, _ := srv.linkToURL(req, s) + return u + }, + }).ExecuteTemplate(&buf, "page.html", cnmPage{ + URL: u.String(), + Req: breq.String(), + Resp: bresp.String(), + Doc: doc, + Netcat: shellquote.Join("echo", hdrs) + " | " + shellquote.Join("nc", host, port), + Site: st, + Toc: tocSection{Children: genToc(doc.Content)}, + Highlight: srv.highlighting, + Browser: srv.host == "", + }) + if err != nil { + srv.handleError(w, err) + return + } + + io.Copy(w, &buf) +} + +func (srv *server) fmtToHTML(req *cnp.Request, text cnmfmt.Text) template.HTML { + if len(text.Spans) == 0 { + return "" + } + + var last cnmfmt.Span + var buf bytes.Buffer + hadText := true + + spans := make([]cnmfmt.Span, len(text.Spans)+1) + copy(spans, text.Spans) + spans[len(spans)-1] = cnmfmt.Span{} // end all formats + + var tags []string + for _, span := range spans { + if last.Format.Link != span.Format.Link { + if !hadText && last.Format.Link != "" { // no text in link + template.HTMLEscape(&buf, []byte(last.Format.Link)) + } + hadText = false + } + srv.handleTags(req, &buf, &tags, last.Format, span.Format) + if span.Text != "" { + hadText = true + template.HTMLEscape(&buf, []byte(span.Text)) + } + last = span + } + + return template.HTML("<p>" + buf.String() + "</p>") +} + +func (srv *server) handleTags(req *cnp.Request, buf *bytes.Buffer, tags *[]string, old, new cnmfmt.Format) { + if old == new { + return + } + + close := map[string]bool{ + "b": old.Bold && !new.Bold, + "i": old.Italic && !new.Italic, + "u": old.Underline && !new.Underline, + "code": old.Monospace && !new.Monospace, + "a": old.Link != "" && old.Link != new.Link, + } + + open := map[string]bool{ + "b": !old.Bold && new.Bold, + "i": !old.Italic && new.Italic, + "u": !old.Underline && new.Underline, + "code": !old.Monospace && new.Monospace, + "a": new.Link != "" && old.Link != new.Link, + } + + t := *tags + + pop := len(t) + for i := len(t) - 1; i >= 0; i-- { + if close[t[i]] { + pop = i + } + } + for i := len(t) - 1; i >= pop; i-- { + if !close[t[i]] { + open[t[i]] = true + } + buf.WriteString("</" + t[i] + ">") + } + *tags = t[:pop] + + tagPush(buf, tags, open, "b", "<b>") + tagPush(buf, tags, open, "i", "<i>") + tagPush(buf, tags, open, "u", "<u>") + tagPush(buf, tags, open, "code", "<code>") + + link, extern := srv.linkToURL(req, new.Link) + tagPush(buf, tags, open, "a", "<a"+extern+" href=\""+escapeURL(link)+"\">") +} + +func (srv *server) linkToURL(req *cnp.Request, link string) (urlStr, extern string) { + urlStr = "#ZcnpfmtZ" + host := srv.host + if host == "" { + host = req.Host() + } + if u, err := url.Parse(link); err == nil { + if u.Scheme == "cnp" { + var lhost, lpath string + lhost = u.Host + lpath = u.Path + if u.Host == srv.host { + lhost = "" + } else { + extern = " class=\"cnp-external cnp-external-cnp\"" + } + urlStr = path.Join("/", lhost, lpath) + if strings.HasSuffix(link, "/") { + urlStr += "/" + } + if u.Fragment != "" { + urlStr += "#" + u.Fragment + } + } else if u.Scheme != "" { + extern = " class=\"cnp-external cnp-external-" + template.HTMLEscapeString(u.Scheme) + "\"" + urlStr = u.String() + } else { + urlStr = u.Path + if srv.host == "" { + if !strings.HasPrefix(urlStr, "/") { + urlStr = req.Path() + "/" + urlStr + } + urlStr = "/" + host + cnp.Clean("/"+urlStr) + } + if u.Fragment != "" { + urlStr += "#" + u.Fragment + } + } + } + return +} + +func tagPush(buf *bytes.Buffer, tags *[]string, ok map[string]bool, name, tag string) { + if ok[name] { + buf.WriteString(tag) + *tags = append(*tags, name) + } +} + +func (srv *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log.Printf("access: %s %s %q", r.RemoteAddr, r.Method, r.URL) + if r.Method == http.MethodPost { + u := r.FormValue("url") + req, err := cnp.NewRequestURL(u, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + http.Redirect(w, r, path.Join("/", req.Host(), req.Path()), http.StatusFound) + } else { + srv.handleCNP(w, r, r.URL.Path) + } +} + +func main() { + flag.Parse() + + srv := newServer(*cnphost, !*nohighlight) + + if !*nostatic { + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + } + http.Handle("/", srv) + + log.Printf("listening on %s", *listen) + panic(http.ListenAndServe(*listen, nil)) +} |