Merged Alex Schroeder's header-filling patch. Thanks!!
authorTim Vaughan <plugd@thelambdalab.xyz>
Sat, 19 Sep 2020 10:08:06 +0000 (12:08 +0200)
committerTim Vaughan <plugd@thelambdalab.xyz>
Sat, 19 Sep 2020 10:08:25 +0000 (12:08 +0200)
1  2 
elpher.el

diff --combined elpher.el
+++ b/elpher.el
@@@ -4,10 -4,10 +4,10 @@@
  
  ;; Author: Tim Vaughan <plugd@thelambdalab.xyz>
  ;; Created: 11 April 2019
 -;; Version: 2.8.0
 +;; Version: 2.10.1
  ;; Keywords: comm gopher
  ;; Homepage: http://thelambdalab.xyz/elpher
 -;; Package-Requires: ((emacs "26.1"))
 +;; Package-Requires: ((emacs "26.2"))
  
  ;; This file is not part of GNU Emacs.
  
@@@ -71,7 -71,7 +71,7 @@@
  ;;; Global constants
  ;;
  
 -(defconst elpher-version "2.8.0"
 +(defconst elpher-version "2.10.0"
    "Current version of elpher.")
  
  (defconst elpher-margin-width 6
  ;;
  
  (defgroup elpher nil
 -  "A gopher client."
 +  "A gopher and gemini client."
    :group 'applications)
  
  ;; General appearance and customizations
@@@ -134,16 -134,6 +134,16 @@@ The default behaviour is to use the ans
  sequences."
    :type '(boolean))
  
 +(defcustom elpher-certificate-directory
 +  (file-name-as-directory (locate-user-emacs-file "elpher-certificates"))
 +  "Specify the name of the directory where client certificates will be stored.
 +These certificates may be used for establishing authenticated TLS connections."
 +  :type '(directory))
 +
 +(defcustom elpher-openssl-command "openssl"
 +  "The command used to launch openssl when generating TLS client certificates."
 +  :type '(file))
 +
  (defcustom elpher-gemini-TLS-cert-checks nil
    "If non-nil, verify gemini server TLS certs using the default security level.
  Otherwise, certificate verification is disabled.
@@@ -209,7 -199,7 +209,7 @@@ some servers which do not support IPv6 
    "Face used for html type directory records.")
  
  (defface elpher-gemini
 -  '((t :inherit font-lock-regexp-grouping-backslash))
 +  '((t :inherit font-lock-constant-face))
    "Face used for Gemini type directory records.")
  
  (defface elpher-other-url
                ;; Gemini defaults
                (if (equal (url-filename url) "")
                    (setf (url-filename url) "/"))))
 -          url)
 +          (elpher-remove-redundant-ports url))
        (set-match-data data))))
  
 +(defun elpher-remove-redundant-ports (address)
 +  "Remove redundant port specifiers from ADDRESS.
 +Here 'redundant' means that the specified port matches the default
 +for that protocol, eg 70 for gopher."
 +  (if (and (not (elpher-address-special-p address))
 +           (eq (url-portspec address) ; (url-port) is too slow!
 +               (pcase (url-type address)
 +                 ("gemini" 1965)
 +                 ((or "gopher" "gophers") 70)
 +                 ("finger" 79)
 +                 (_ -1))))
 +      (setf (url-portspec address) nil))
 +  address)
 +
  (defun elpher-make-gopher-address (type selector host port &optional tls)
    "Create an ADDRESS object using gopher directory record attributes.
  The basic attributes include: TYPE, SELECTOR, HOST and PORT.
@@@ -585,9 -561,6 +585,9 @@@ ERROR can be either an error object or 
  (defvar elpher-use-tls nil
    "If non-nil, use TLS to communicate with gopher servers.")
  
 +(defvar elpher-client-certificate nil
 +  "If non-nil, contains client certificate details to use for TLS connections.")
 +
  (defun elpher-process-cleanup ()
    "Immediately shut down any extant elpher process and timers."
    (let ((p (get-process "elpher-process")))
@@@ -637,9 -610,7 +637,9 @@@ the host operating system and the loca
                                                  (cons 'gnutls-x509pki
                                                        (gnutls-boot-parameters
                                                         :type 'gnutls-x509pki
 -                                                       :hostname host)))))
 +                                                       :hostname host
 +                                                       :keylist
 +                                                       (elpher-get-current-keylist address))))))
                 (timer (run-at-time elpher-connection-timeout nil
                                     (lambda ()
                                       (elpher-process-cleanup)
                                        (t
                                         (elpher-network-error address "Connection time-out.")))))))
            (setq elpher-network-timer timer)
 -          (elpher-buffer-message (concat "Connecting to " host "..."))
 +          (elpher-buffer-message (concat "Connecting to " host "..."
 +                                         " (press 'u' to abort)"))
            (set-process-filter proc
                                (lambda (_proc string)
                                  (when timer
                                        (cond
                                         ((string-prefix-p "open" event)    ; request URL
                                          (elpher-buffer-message
 -                                         (concat "Connected to " host ". Receiving data..."))
 +                                         (concat "Connected to " host ". Receiving data..."
 +                                                 " (press 'u' to abort)"))
                                          (let ((inhibit-eol-conversion t))
                                            (process-send-string proc query-string)))
                                         ((string-prefix-p "deleted" event)) ; do nothing
         (error "Error initiating connection to server")))))
  
  
 +;;; Client-side TLS Certificate Management
 +;;
 +
 +(defun elpher-generate-certificate (common-name key-file cert-file &optional temporary)
 +  "Generate a key and a self-signed client TLS certificate using openssl.
 +
 +The Common Name field of the certificate is set to COMMON-NAME.  The
 +arguments KEY-FILE and CERT-FILE should contain the absolute paths of
 +the key and certificate files to write.
 +
 +If TEMPORARY is non-nil, the certificate will be given an exporation
 +period of one day, and the key and certificate files will be deleted
 +when the certificate is no longer needed for the current session.
 +
 +Otherwise, the certificate will be given a 100 year expiration period
 +and the files will not be deleted.
 +
 +The function returns a list containing the current host name, the
 +temporary flag, and the key and cert file names in the form required
 +by `gnutls-boot-parameters`."
 +  (let ((exp-key-file (expand-file-name key-file))
 +        (exp-cert-file (expand-file-name cert-file)))
 +    (condition-case nil
 +        (progn
 +          (call-process elpher-openssl-command nil nil nil
 +                        "req" "-x509" "-newkey" "rsa:2048"
 +                        "-days" (if temporary "1" "36500")
 +                        "-nodes"
 +                        "-subj" (concat "/CN=" common-name)
 +                        "-keyout" exp-key-file
 +                        "-out" exp-cert-file)
 +          (list (elpher-address-host (elpher-page-address elpher-current-page))
 +                temporary exp-key-file exp-cert-file))
 +      (error
 +       (message "Check that openssl is installed, or customize `elpher-openssl-command`.")
 +       (error "Program 'openssl', required for certificate generation, not found")))))
 +
 +(defun elpher-generate-throwaway-certificate ()
 +  "Generate and return details of a throwaway certificate.
 +The key and certificate files will be deleted when they are no
 +longer needed for this session."
 +  (let* ((file-base (make-temp-name "elpher"))
 +         (key-file (concat temporary-file-directory file-base ".key"))
 +         (cert-file (concat temporary-file-directory file-base ".crt")))
 +    (elpher-generate-certificate file-base key-file cert-file t)))
 +
 +(defun elpher-generate-persistent-certificate (file-base common-name)
 +  "Generate and return details of a persistent certificate.
 +The argument FILE-BASE is used as the base for the key and certificate
 +files, while COMMON-NAME specifies the common name field of the
 +certificate.
 +
 +The key and certificate files are written to in `elpher-certificate-directory'."
 +  (let* ((key-file (concat elpher-certificate-directory file-base ".key"))
 +         (cert-file (concat elpher-certificate-directory file-base ".crt")))
 +    (elpher-generate-certificate common-name key-file cert-file)))
 +
 +(defun elpher-get-existing-certificate (file-base)
 +  "Return a certificate object corresponding to an existing certificate.
 +It is assumed that the key files FILE-BASE.key and FILE-BASE.crt exist in
 +the directory `elpher-certificate-directory'."
 +  (let* ((key-file (concat elpher-certificate-directory file-base ".key"))
 +         (cert-file (concat elpher-certificate-directory file-base ".crt")))
 +    (list (elpher-address-host (elpher-page-address elpher-current-page))
 +          nil
 +          (expand-file-name key-file)
 +          (expand-file-name cert-file))))
 +
 +(defun elpher-install-and-use-existing-certificate (key-file-src cert-file-src file-base)
 +  "Install a key+certificate file pair in `elpher-certificate-directory'.
 +The strings KEY-FILE-SRC and CERT-FILE-SRC are the existing key and
 +certificate files to install.  The argument FILE-BASE is used as the
 +base for the installed key and certificate files."
 +  (let* ((key-file (concat elpher-certificate-directory file-base ".key"))
 +         (cert-file (concat elpher-certificate-directory file-base ".crt")))
 +    (if (or (file-exists-p key-file)
 +            (file-exists-p cert-file))
 +        (error "A certificate with base name %s is already installed" file-base))
 +    (copy-file key-file-src key-file)
 +    (copy-file cert-file-src cert-file)
 +    (list (elpher-address-host (elpher-page-address elpher-current-page))
 +          nil
 +          (expand-file-name key-file)
 +          (expand-file-name cert-file))))
 +
 +(defun elpher-list-existing-certificates ()
 +  "Return a list of the persistent certificates in `elpher-certificate-directory'."
 +  (unless (file-directory-p elpher-certificate-directory)
 +    (make-directory elpher-certificate-directory))
 +  (mapcar
 +   (lambda (file)
 +     (file-name-sans-extension file))
 +   (directory-files elpher-certificate-directory nil "\.key$")))
 +
 +(defun elpher-forget-current-certificate ()
 +  "Causes any current certificate to be forgotten.)
 +In the case of throwaway certificates, the key and certificate files
 +are also deleted."
 +  (interactive)
 +  (when elpher-client-certificate
 +    (unless (and (called-interactively-p 'any)
 +                 (not (y-or-n-p (concat "Really forget client certificate? "
 +                                        "(Throwaway certificates will be deleted.)"))))
 +      (when (cadr elpher-client-certificate)
 +        (delete-file (elt elpher-client-certificate 2))
 +        (delete-file (elt elpher-client-certificate 3)))
 +      (setq elpher-client-certificate nil)
 +      (if (called-interactively-p 'any)
 +          (message "Client certificate forgotten.")))))
 +
 +(defun elpher-get-current-keylist (address)
 +  "Retrieve the `gnutls-boot-parameters'-compatable keylist.
 +
 +This is obtained from the client certificate described by
 +`elpher-current-certificate', if one is available and the host for
 +that certificate matches the host in ADDRESS.
 +
 +If `elpher-current-certificate' is non-nil, and its host name doesn't
 +match that of ADDRESS, the certificate is forgotten."
 +  (if elpher-client-certificate
 +      (if (string= (car elpher-client-certificate)
 +                   (elpher-address-host address))
 +          (list (cddr elpher-client-certificate))
 +        (elpher-forget-current-certificate)
 +        (message "Disabling client certificate for new host")
 +        nil)
 +    nil))
 +
  
  ;;; Gopher selector retrieval
  ;;
@@@ -1138,10 -979,7 +1138,10 @@@ that the response was malformed.
          (?1 ; Input required
           (elpher-with-clean-buffer
            (insert "Gemini server is requesting input."))
 -         (let* ((query-string (read-string (concat response-meta ": ")))
 +         (let* ((query-string
 +                 (if (eq (elt response-code 1) ?1)
 +                     (read-passwd (concat response-meta ": "))
 +                   (read-string (concat response-meta ": "))))
                  (query-address (seq-copy (elpher-page-address elpher-current-page)))
                  (old-fname (url-filename query-address)))
             (setf (url-filename query-address)
           (error "Gemini server reports PERMANENT FAILURE for this request: %s %s"
                  response-code response-meta))
          (?6 ; Client certificate required
 -         (error "Gemini server requires client certificate (unsupported at this time)"))
 +         (elpher-with-clean-buffer
 +          (if elpher-client-certificate
 +              (insert "Gemini server does not recognise the provided TLS certificate:\n\n")
 +            (insert "Gemini server is requesting a valid TLS certificate:\n\n"))
 +          (auto-fill-mode 1)
 +          (elpher-gemini-insert-text response-meta))
 +         (let ((chosen-certificate (elpher-choose-client-certificate)))
 +           (unless chosen-certificate
 +             (error "Gemini server requires a client certificate and none was provided"))
 +           (setq elpher-client-certificate chosen-certificate))
 +         (elpher-with-clean-buffer)
 +         (elpher-get-gemini-response (elpher-page-address elpher-current-page) renderer))
          (_other
           (error "Gemini server response unknown: %s %s"
                  response-code response-meta))))))
  
 +(defun elpher-choose-client-certificate ()
 +  "Prompt for a client certificate to use to establish a TLS connection."
 +  (let* ((read-answer-short t))
 +    (pcase (read-answer "What do you want to do? "
 +                        '(("throwaway" ?t
 +                           "generate and use throw-away certificate")
 +                          ("persistent" ?p
 +                           "generate new or use existing persistent certificate")
 +                          ("abort" ?a
 +                           "stop immediately")))
 +      ("throwaway"
 +       (setq elpher-client-certificate (elpher-generate-throwaway-certificate)))
 +      ("persistent"
 +       (let* ((existing-certificates (elpher-list-existing-certificates))
 +              (file-base (completing-read
 +                          "Nickname for new or existing certificate (autocompletes, empty response aborts): "
 +                          existing-certificates)))
 +         (if (string-empty-p (string-trim file-base))
 +             nil
 +           (if (member file-base existing-certificates)
 +               (setq elpher-client-certificate
 +                     (elpher-get-existing-certificate file-base))
 +             (pcase (read-answer "Generate new certificate or install externally-generated one? "
 +                                 '(("new" ?n
 +                                    "generate new certificate")
 +                                   ("install" ?i
 +                                    "install existing certificate")
 +                                   ("abort" ?a
 +                                    "stop immediately")))
 +               ("new"
 +                (let ((common-name (read-string "Common Name field for new certificate: "
 +                                                file-base)))
 +                  (message "New key and self-signed certificate written to %s"
 +                           elpher-certificate-directory)
 +                  (elpher-generate-persistent-certificate file-base common-name)))
 +               ("install"
 +                (let* ((cert-file (read-file-name "Certificate file: " nil nil t))
 +                       (key-file (read-file-name "Key file: " nil nil t)))
 +                  (message "Key and certificate installed in %s for future use"
 +                           elpher-certificate-directory)
 +                  (elpher-install-and-use-existing-certificate key-file
 +                                                               cert-file
 +                                                               file-base)))
 +               ("abort" nil))))))
 +      ("abort" nil))))
 +
  (defun elpher-get-gemini-page (renderer)
    "Getter which retrieves and renders a Gemini page and renders it using RENDERER."
    (let* ((address (elpher-page-address elpher-current-page))
        (error
         (elpher-network-error address the-error)))))
  
 -
  (defun elpher-render-gemini (body &optional mime-type-string)
    "Render gemini response BODY with rendering MIME-TYPE-STRING."
    (if (not body)
@@@ -1314,10 -1096,7 +1314,10 @@@ For instance, the filename /a/b/../c/./
      (string-join (reverse path-reversed-normalized) "/")))
  
  (defun elpher-address-from-gemini-url (url)
 -  "Extract address from URL with defaults as per gemini map files."
 +  "Extract address from URL with defaults as per gemini map files.
 +While there's obviously some redundancy here between this function and
 +`elpher-address-from-url', gemini map file URLs require enough special
 +treatment that a separate function is warranted."
    (let ((address (url-generic-parse-url url))
          (current-address (elpher-page-address elpher-current-page)))
      (unless (and (url-type address) (not (url-fullness address))) ;avoid mangling mailto: urls
                          (url-filename address)))))
        (unless (url-type address)
          (setf (url-type address) "gemini"))
 -      (if (equal (url-type address) "gemini")
 -          (setf (url-filename address)
 -                (elpher-collapse-dot-sequences (url-filename address)))))
 -    address))
 +      (when (equal (url-type address) "gemini")
 +        (setf (url-filename address)
 +              (elpher-collapse-dot-sequences (url-filename address)))))
 +    (elpher-remove-redundant-ports address)))
  
  (defun elpher-gemini-insert-link (link-line)
    "Insert link described by LINK-LINE into a text/gemini document."
  The gemini map file line describing the header is given
  by HEADER-LINE."
    (when (string-match "^\\(#+\\)[ \t]*" header-line)
-     (let ((level (length (match-string 1 header-line)))
-           (header (substring header-line (match-end 0))))
+     (let* ((level (length (match-string 1 header-line)))
+            (header (substring header-line (match-end 0)))
+          (face (pcase level
+                    (1 'elpher-gemini-heading1)
+                    (2 'elpher-gemini-heading2)
+                    (3 'elpher-gemini-heading3)
+                    (_ 'default)))
+          (fill-column (/ (* fill-column
+                             (font-get (font-spec :name (face-font 'default)) :size))
+                          (font-get (font-spec :name (face-font face)) :size))))
        (unless (display-graphic-p)
          (insert (make-string level ?#) " "))
-       (insert (propertize header 'face
-                           (pcase level
-                             (1 'elpher-gemini-heading1)
-                             (2 'elpher-gemini-heading2)
-                             (3 'elpher-gemini-heading3)
-                             (_ 'default)))
-               "\n"))))
+       (insert (propertize header 'face face))
+       (newline))))
  
  (defun elpher-gemini-insert-text (text-line)
    "Insert a plain non-preformatted TEXT-LINE into a text/gemini document.
@@@ -1423,7 -1205,6 +1426,7 @@@ width defined by elpher-gemini-max-fill
      (elpher-page-address elpher-current-page)
      (buffer-string))))
  
 +
  ;; Finger page connection
  
  (defun elpher-get-finger-page (renderer)
@@@ -1450,22 -1231,6 +1453,22 @@@ The result is rendered using RENDERER.
           (elpher-network-error address the-error))))))
  
  
 +;; Telnet page connection
 +
 +(defun elpher-get-telnet-page (renderer)
 +  "Opens a telnet connection to the current page address (RENDERER must be nil)."
 +  (when renderer
 +    (elpher-visit-previous-page)
 +    (error "Command not supported for telnet URLs"))
 +  (let* ((address (elpher-page-address elpher-current-page))
 +         (host (elpher-address-host address))
 +         (port (elpher-address-port address)))
 +    (elpher-visit-previous-page)
 +    (if (> port 0)
 +        (telnet host port)
 +      (telnet host))))
 +
 +
  ;; Other URL page opening
  
  (defun elpher-get-other-url-page (renderer)
            (browse-web url)
          (browse-url url)))))
  
 -;; Telnet page connection
 -
 -(defun elpher-get-telnet-page (renderer)
 -  "Opens a telnet connection to the current page address (RENDERER must be nil)."
 -  (when renderer
 -    (elpher-visit-previous-page)
 -    (error "Command not supported for telnet URLs"))
 -  (let* ((address (elpher-page-address elpher-current-page))
 -         (host (elpher-address-host address))
 -         (port (elpher-address-port address)))
 -    (elpher-visit-previous-page)
 -    (if (> port 0)
 -        (telnet host port)
 -      (telnet host))))
  
  ;; Start page page retrieval
  
             " - R: reload current page (regenerates cache)\n"
             " - S: set character coding system for gopher (default is to autodetect)\n"
             " - T: toggle TLS gopher mode\n"
 +           " - F: forget/discard current TLS client certificate\n"
             " - .: display the raw server response for the current page\n"
             "\n"
             "Start your exploration of gopher space and gemini:\n")
@@@ -1684,7 -1462,7 +1687,7 @@@ When run interactively HOST-OR-URL is r
    (interactive "sGopher or Gemini URL: ")
    (let* ((cleaned-host-or-url (string-trim host-or-url))
           (address (elpher-address-from-url cleaned-host-or-url))
 -         (page (elpher-make-page cleaned-host-or-url address))) 
 +         (page (elpher-make-page cleaned-host-or-url address)))
      (switch-to-buffer "*elpher*")
      (elpher-visit-page page)
      nil))
      (define-key map (kbd "X") 'elpher-unbookmark-current)
      (define-key map (kbd "B") 'elpher-bookmarks)
      (define-key map (kbd "S") 'elpher-set-gopher-coding-system)
 +    (define-key map (kbd "F") 'elpher-forget-current-certificate)
      (when (fboundp 'evil-define-key*)
        (evil-define-key* 'motion map
          (kbd "TAB") 'elpher-next-link
          (kbd "x") 'elpher-unbookmark-link
          (kbd "X") 'elpher-unbookmark-current
          (kbd "B") 'elpher-bookmarks
 -        (kbd "S") 'elpher-set-gopher-coding-system))
 +        (kbd "S") 'elpher-set-gopher-coding-system
 +        (kbd "F") 'elpher-forget-current-certificate))
      map)
    "Keymap for gopher client.")