...

Source file src/code.rocketnine.space/tslocum/gmitohtml/pkg/gmitohtml/daemon.go

Documentation: code.rocketnine.space/tslocum/gmitohtml/pkg/gmitohtml

     1  package gmitohtml
     2  
     3  import (
     4  	"bytes"
     5  	"crypto/tls"
     6  	"crypto/x509"
     7  	"errors"
     8  	"fmt"
     9  	"html"
    10  	"io/ioutil"
    11  	"log"
    12  	"net/http"
    13  	"net/url"
    14  	"path"
    15  	"sort"
    16  	"strings"
    17  	"time"
    18  )
    19  
    20  var lastRequestTime = time.Now().Unix()
    21  
    22  var (
    23  	clientCerts        = make(map[string]tls.Certificate)
    24  	bookmarks          = make(map[string]string)
    25  	bookmarksSorted    []string
    26  	allowFileAccess    bool
    27  	onBookmarksChanged func()
    28  )
    29  
    30  var defaultBookmarks = map[string]string{
    31  	"gemini://gemini.circumlunar.space/": "Project Gemini",
    32  	"gemini://gus.guru/":                 "GUS - Gemini Universal Search",
    33  }
    34  
    35  // ErrInvalidCertificate is the error returned when an invalid certificate is provided.
    36  var ErrInvalidCertificate = errors.New("invalid certificate")
    37  
    38  func bookmarksList() []byte {
    39  	fakeURL, _ := url.Parse("/") // Always succeeds
    40  
    41  	var b bytes.Buffer
    42  	b.Write([]byte(`<ul>`))
    43  	for _, u := range bookmarksSorted {
    44  		b.Write([]byte(fmt.Sprintf(`<li><a href="%s">%s</a></li>`, rewriteURL(u, fakeURL), bookmarks[u])))
    45  	}
    46  	b.Write([]byte("</ul>"))
    47  	return b.Bytes()
    48  }
    49  
    50  // fetch downloads and converts a Gemini page.
    51  func fetch(u string) ([]byte, []byte, error) {
    52  	if u == "" {
    53  		return nil, nil, ErrInvalidURL
    54  	}
    55  
    56  	requestURL, err := url.ParseRequestURI(u)
    57  	if err != nil {
    58  		return nil, nil, err
    59  	}
    60  	if requestURL.Scheme == "" {
    61  		requestURL.Scheme = "gemini"
    62  	}
    63  
    64  	host := requestURL.Host
    65  	if strings.IndexRune(host, ':') == -1 {
    66  		host += ":1965"
    67  	}
    68  
    69  	tlsConfig := &tls.Config{
    70  		// This must be enabled until most sites have transitioned away from
    71  		// using self-signed certificates.
    72  		InsecureSkipVerify: true,
    73  	}
    74  
    75  	certHost := requestURL.Hostname()
    76  	if strings.HasPrefix(certHost, "www.") {
    77  		certHost = certHost[4:]
    78  	}
    79  
    80  	clientCert, certAvailable := clientCerts[certHost]
    81  	if certAvailable {
    82  		tlsConfig.Certificates = []tls.Certificate{clientCert}
    83  	}
    84  
    85  	conn, err := tls.Dial("tcp", host, tlsConfig)
    86  	if err != nil {
    87  		return nil, nil, err
    88  	}
    89  
    90  	// Send request header
    91  	conn.Write([]byte(requestURL.String() + "\r\n"))
    92  
    93  	data, err := ioutil.ReadAll(conn)
    94  	if err != nil {
    95  		return nil, nil, err
    96  	}
    97  
    98  	firstNewLine := -1
    99  	l := len(data)
   100  	if l > 2 {
   101  		for i := 1; i < l; i++ {
   102  			if data[i] == '\n' && data[i-1] == '\r' {
   103  				firstNewLine = i
   104  				break
   105  			}
   106  		}
   107  	}
   108  	var header []byte
   109  	if firstNewLine > -1 {
   110  		header = data[:firstNewLine]
   111  		data = data[firstNewLine+1:]
   112  	}
   113  
   114  	requestInput := bytes.HasPrefix(header, []byte("1"))
   115  	if requestInput {
   116  		requestSensitiveInput := bytes.HasPrefix(header, []byte("11"))
   117  
   118  		data = newPage()
   119  
   120  		data = append(data, []byte(inputPrompt)...)
   121  
   122  		data = bytes.Replace(data, []byte("~GEMINIINPUTFORM~"), []byte(html.EscapeString(rewriteURL(u, requestURL))), 1)
   123  
   124  		prompt := "(No input prompt)"
   125  		if len(header) > 3 {
   126  			prompt = string(header[3:])
   127  		}
   128  		data = bytes.Replace(data, []byte("~GEMINIINPUTPROMPT~"), []byte(prompt), 1)
   129  
   130  		inputType := "text"
   131  		if requestSensitiveInput {
   132  			inputType = "password"
   133  		}
   134  		data = bytes.Replace(data, []byte("~GEMINIINPUTTYPE~"), []byte(inputType), 1)
   135  
   136  		return header, fillTemplateVariables(data, u, false), nil
   137  	}
   138  
   139  	if !bytes.HasPrefix(header, []byte("2")) {
   140  		errorPage := newPage()
   141  		errorPage = append(errorPage, []byte(fmt.Sprintf("Server sent unexpected header:<br><br><b>%s</b>", header))...)
   142  		errorPage = append(errorPage, []byte(pageFooter)...)
   143  		return header, fillTemplateVariables(errorPage, u, false), nil
   144  	}
   145  
   146  	if bytes.HasPrefix(header, []byte("20 text/html")) {
   147  		return header, data, nil
   148  	}
   149  	return header, Convert(data, requestURL.String()), nil
   150  }
   151  
   152  func handleIndex(writer http.ResponseWriter, request *http.Request) {
   153  	address := request.FormValue("address")
   154  	if address != "" {
   155  		http.Redirect(writer, request, rewriteURL(address, request.URL), http.StatusSeeOther)
   156  		return
   157  	}
   158  
   159  	page := newPage()
   160  	page = append(page, bookmarksList()...)
   161  	page = append(page, pageFooter...)
   162  
   163  	writer.Write(fillTemplateVariables(page, request.URL.String(), true))
   164  }
   165  
   166  func fillTemplateVariables(data []byte, currentURL string, autofocus bool) []byte {
   167  	if strings.HasPrefix(currentURL, "gemini://") {
   168  		currentURL = currentURL[9:]
   169  	}
   170  	if currentURL == "/" {
   171  		currentURL = ""
   172  	}
   173  	data = bytes.ReplaceAll(data, []byte("~GEMINICURRENTURL~"), []byte(currentURL))
   174  
   175  	autofocusValue := ""
   176  	if autofocus {
   177  		autofocusValue = "autofocus"
   178  	}
   179  	data = bytes.ReplaceAll(data, []byte("~GEMINIAUTOFOCUS~"), []byte(autofocusValue))
   180  
   181  	return data
   182  }
   183  
   184  func handleRequest(writer http.ResponseWriter, request *http.Request) {
   185  	defer request.Body.Close()
   186  
   187  	lastRequestTime = time.Now().Unix()
   188  
   189  	if request.URL == nil {
   190  		return
   191  	}
   192  
   193  	if request.URL.Path == "/" {
   194  		handleIndex(writer, request)
   195  		return
   196  	}
   197  
   198  	pathSplit := strings.Split(request.URL.Path, "/")
   199  	if len(pathSplit) < 2 || (pathSplit[1] != "gemini" && (!allowFileAccess || pathSplit[1] != "file")) {
   200  		writer.Write([]byte("Error: invalid protocol, only Gemini is supported"))
   201  		return
   202  	}
   203  
   204  	scheme := "gemini://"
   205  	if pathSplit[1] == "file" {
   206  		scheme = "file://"
   207  	}
   208  
   209  	u, err := url.ParseRequestURI(scheme + strings.Join(pathSplit[2:], "/"))
   210  	if err != nil {
   211  		writer.Write([]byte("Error: invalid URL"))
   212  		return
   213  	}
   214  	if request.URL.RawQuery != "" {
   215  		u.RawQuery = request.URL.RawQuery
   216  	}
   217  
   218  	inputText := request.PostFormValue("input")
   219  	if inputText != "" {
   220  		u.RawQuery = inputText
   221  		http.Redirect(writer, request, rewriteURL(u.String(), u), http.StatusSeeOther)
   222  		return
   223  	}
   224  
   225  	var header []byte
   226  	var data []byte
   227  	if scheme == "gemini://" {
   228  		header, data, err = fetch(u.String())
   229  		if err != nil {
   230  			fmt.Fprintf(writer, "Error: failed to fetch %s: %s", u, err)
   231  			return
   232  		}
   233  	} else if allowFileAccess && scheme == "file://" {
   234  		header = []byte("20 text/gemini; charset=utf-8")
   235  		data, err = ioutil.ReadFile(path.Join("/", strings.Join(pathSplit[2:], "/")))
   236  		if err != nil {
   237  			fmt.Fprintf(writer, "Error: failed to read file %s: %s", u, err)
   238  			return
   239  		}
   240  		data = Convert(data, u.String())
   241  	} else {
   242  		writer.Write([]byte("Error: invalid URL"))
   243  		return
   244  	}
   245  
   246  	if len(header) > 0 && header[0] == '3' {
   247  		split := bytes.SplitN(header, []byte(" "), 2)
   248  		if len(split) == 2 {
   249  			http.Redirect(writer, request, rewriteURL(string(split[1]), u), http.StatusSeeOther)
   250  			return
   251  		}
   252  	}
   253  
   254  	if len(header) > 3 && !bytes.HasPrefix(header[3:], []byte("text/gemini")) {
   255  		writer.Header().Set("Content-Type", string(header[3:]))
   256  	} else {
   257  		writer.Header().Set("Content-Type", "text/html; charset=utf-8")
   258  	}
   259  
   260  	writer.Write(data)
   261  }
   262  
   263  func handleAssets(writer http.ResponseWriter, request *http.Request) {
   264  	assetLock.Lock()
   265  	defer assetLock.Unlock()
   266  
   267  	writer.Header().Set("Cache-Control", "max-age=86400")
   268  
   269  	http.FileServer(fs).ServeHTTP(writer, request)
   270  }
   271  
   272  func handleBookmarks(writer http.ResponseWriter, request *http.Request) {
   273  	writer.Header().Set("Content-Type", "text/html; charset=utf-8")
   274  	var data []byte
   275  
   276  	postAddress := request.PostFormValue("address")
   277  	postLabel := request.PostFormValue("label")
   278  	if postLabel == "" && postAddress != "" {
   279  		postLabel = postAddress
   280  	}
   281  
   282  	editBookmark := request.FormValue("edit")
   283  	if editBookmark != "" {
   284  		if postLabel == "" {
   285  			label, ok := bookmarks[editBookmark]
   286  			if !ok {
   287  				writer.Write([]byte("<h1>Error: bookmark not found</h1>"))
   288  				return
   289  			}
   290  
   291  			data = newPage()
   292  
   293  			data = append(data, []byte(fmt.Sprintf(`<form method="post" action="%s"><h3>Edit bookmark</h3><input type="text" size="40" name="address" placeholder="Address" value="%s" autofocus><br><br><input type="text" size="40" name="label" placeholder="Label" value="%s"><br><br><input type="submit" value="Update"></form>`, request.URL.Path+"?"+request.URL.RawQuery, html.EscapeString(editBookmark), html.EscapeString(label)))...)
   294  
   295  			data = append(data, []byte(pageFooter)...)
   296  
   297  			writer.Write(fillTemplateVariables(data, "", false))
   298  			return
   299  		}
   300  
   301  		if editBookmark != postAddress || bookmarks[editBookmark] != postLabel {
   302  			RemoveBookmark(editBookmark)
   303  			AddBookmark(postAddress, postLabel)
   304  		}
   305  	} else if postLabel != "" {
   306  		AddBookmark(postAddress, postLabel)
   307  	}
   308  
   309  	deleteBookmark := request.FormValue("delete")
   310  	if deleteBookmark != "" {
   311  		RemoveBookmark(deleteBookmark)
   312  	}
   313  
   314  	data = newPage()
   315  
   316  	addBookmark := request.FormValue("add")
   317  
   318  	addressFocus := "autofocus"
   319  	labelFocus := ""
   320  	if addBookmark != "" {
   321  		addressFocus = ""
   322  		labelFocus = "autofocus"
   323  	}
   324  
   325  	data = append(data, []byte(fmt.Sprintf(`<form method="post" action="/bookmarks"><h3>Add bookmark</h3><input type="text" size="40" name="address" placeholder="Address" value="%s" %s><br><br><input type="text" size="40" name="label" placeholder="Label" %s><br><br><input type="submit" value="Add"></form>`, html.EscapeString(addBookmark), addressFocus, labelFocus))...)
   326  
   327  	if len(bookmarks) > 0 && addBookmark == "" {
   328  		fakeURL, _ := url.Parse("/") // Always succeeds
   329  
   330  		data = append(data, []byte(`<br><h3>Bookmarks</h3><table border="1" cellpadding="5">`)...)
   331  		for _, u := range bookmarksSorted {
   332  			data = append(data, []byte(fmt.Sprintf(`<tr><td>%s<br><a href="%s">%s</a></td><td><a href="/bookmarks?edit=%s" class="navlink">Edit</a></td><td><a href="/bookmarks?delete=%s" onclick="return confirm('Are you sure you want to delete this bookmark?')" class="navlink">Delete</a></td></tr>`, html.EscapeString(bookmarks[u]), html.EscapeString(rewriteURL(u, fakeURL)), html.EscapeString(u), html.EscapeString(url.PathEscape(u)), html.EscapeString(url.PathEscape(u))))...)
   333  		}
   334  		data = append(data, []byte(`</table>`)...)
   335  	}
   336  
   337  	data = append(data, []byte(pageFooter)...)
   338  
   339  	writer.Write(fillTemplateVariables(data, "", false))
   340  }
   341  
   342  // SetOnBookmarksChanged sets the function called when a bookmark is changed.
   343  func SetOnBookmarksChanged(f func()) {
   344  	onBookmarksChanged = f
   345  }
   346  
   347  // StartDaemon starts the page conversion daemon.
   348  func StartDaemon(address string, hostname string, allowFile bool) error {
   349  	daemonAddress = address
   350  	if hostname != "" {
   351  		daemonAddress = hostname
   352  	}
   353  	allowFileAccess = allowFile
   354  
   355  	loadAssets()
   356  
   357  	if len(bookmarks) == 0 {
   358  		for u, label := range defaultBookmarks {
   359  			AddBookmark(u, label)
   360  		}
   361  	}
   362  
   363  	handler := http.NewServeMux()
   364  	handler.HandleFunc("/assets/style.css", handleAssets)
   365  	handler.HandleFunc("/bookmarks", handleBookmarks)
   366  	handler.HandleFunc("/", handleRequest)
   367  	go func() {
   368  		log.Fatal(http.ListenAndServe(address, handler))
   369  	}()
   370  
   371  	return nil
   372  }
   373  
   374  // LastRequestTime returns the time of the last request.
   375  func LastRequestTime() int64 {
   376  	return lastRequestTime
   377  }
   378  
   379  // SetClientCertificate sets the client certificate to use for a domain.
   380  func SetClientCertificate(domain string, certificate []byte, privateKey []byte) error {
   381  	if len(certificate) == 0 || len(privateKey) == 0 {
   382  		delete(clientCerts, domain)
   383  		return nil
   384  	}
   385  
   386  	clientCert, err := tls.X509KeyPair(certificate, privateKey)
   387  	if err != nil {
   388  		return ErrInvalidCertificate
   389  	}
   390  
   391  	leafCert, err := x509.ParseCertificate(clientCert.Certificate[0])
   392  	if err == nil {
   393  		clientCert.Leaf = leafCert
   394  	}
   395  
   396  	clientCerts[domain] = clientCert
   397  	return nil
   398  }
   399  
   400  // AddBookmark adds a bookmark.
   401  func AddBookmark(u string, label string) {
   402  	parsed, err := url.Parse(u)
   403  	if err != nil {
   404  		return
   405  	}
   406  	if parsed.Scheme == "" {
   407  		parsed.Scheme = "gemini"
   408  	}
   409  	parsed.Host = strings.ToLower(parsed.Host)
   410  
   411  	bookmarks[parsed.String()] = label
   412  
   413  	bookmarksUpdated()
   414  }
   415  
   416  // GetBookmarks returns all bookmarks.
   417  func GetBookmarks() map[string]string {
   418  	return bookmarks
   419  }
   420  
   421  // RemoveBookmark removes a bookmark.
   422  func RemoveBookmark(u string) {
   423  	delete(bookmarks, u)
   424  
   425  	bookmarksUpdated()
   426  }
   427  
   428  func bookmarksUpdated() {
   429  	var allURLs []string
   430  	for u := range bookmarks {
   431  		allURLs = append(allURLs, u)
   432  	}
   433  	sort.Slice(allURLs, func(i, j int) bool {
   434  		return strings.ToLower(bookmarks[allURLs[i]]) < strings.ToLower(bookmarks[allURLs[j]])
   435  	})
   436  
   437  	bookmarksSorted = allURLs
   438  
   439  	if onBookmarksChanged != nil {
   440  		onBookmarksChanged()
   441  	}
   442  }
   443  

View as plain text