summaryrefslogtreecommitdiffstats
path: root/cnhttp.go
diff options
context:
space:
mode:
Diffstat (limited to 'cnhttp.go')
-rw-r--r--cnhttp.go645
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))
+}