Better grouping of text processing procedures.
[elpher.git] / elpher.el
1 ;;; elpher.el --- A friendly gopher and gemini client  -*- lexical-binding: t -*-
2
3 ;; Copyright (C) 2021 Jens Östlund <jostlund@gmail.com>
4 ;; Copyright (C) 2021 F. Jason Park <jp@neverwas.me>
5 ;; Copyright (C) 2021 Christopher Brannon <chris@the-brannons.com>
6 ;; Copyright (C) 2021 Omar Polo <op@omarpolo.com>
7 ;; Copyright (C) 2021 Noodles! <nnoodle@chiru.no>
8 ;; Copyright (C) 2020-2021 Alex Schroeder <alex@gnu.org>
9 ;; Copyright (C) 2020 Zhiwei Chen <chenzhiwei03@kuaishou.com>
10 ;; Copyright (C) 2020 condy0919 <condy0919@gmail.com>
11 ;; Copyright (C) 2020 Alexis <flexibeast@gmail.com>
12 ;; Copyright (C) 2020 Étienne Deparis <etienne@depar.is>
13 ;; Copyright (C) 2020 Simon Nicolussi <sinic@sinic.name>
14 ;; Copyright (C) 2020 Michel Alexandre Salim <michel@michel-slm.name>
15 ;; Copyright (C) 2020 Koushk Roy <kroy@twilio.com>
16 ;; Copyright (C) 2020 Vee <vee@vnsf.xyz>
17 ;; Copyright (C) 2020 Simon South <simon@simonsouth.net>
18 ;; Copyright (C) 2019-2021 Tim Vaughan <plugd@thelambdalab.xyz>
19
20 ;; Author: Tim Vaughan <plugd@thelambdalab.xyz>
21 ;; Created: 11 April 2019
22 ;; Version: 3.0.0
23 ;; Keywords: comm gopher
24 ;; Homepage: https://thelambdalab.xyz/elpher
25 ;; Package-Requires: ((emacs "27.1"))
26
27 ;; This file is not part of GNU Emacs.
28
29 ;; This program is free software: you can redistribute it and/or modify
30 ;; it under the terms of the GNU General Public License as published by
31 ;; the Free Software Foundation, either version 3 of the License, or
32 ;; (at your option) any later version.
33
34 ;; This program is distributed in the hope that it will be useful,
35 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
36 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
37 ;; GNU General Public License for more details.
38
39 ;; You should have received a copy of the GNU General Public License
40 ;; along with this file.  If not, see <http://www.gnu.org/licenses/>.
41
42 ;;; Commentary:
43
44 ;; Elpher aims to provide a practical and friendly gopher and gemini
45 ;; client for GNU Emacs.  It supports:
46
47 ;; - intuitive keyboard and mouse-driven browsing,
48 ;; - out-of-the-box compatibility with evil-mode,
49 ;; - clickable web and gopher links *in plain text*,
50 ;; - caching of visited sites,
51 ;; - pleasant and configurable colouring of Gopher directories,
52 ;; - direct visualisation of image files,
53 ;; - gopher connections using TLS encryption,
54 ;; - the fledgling Gemini protocol,
55 ;; - the greybeard Finger protocol.
56
57 ;; To launch Elpher, simply use 'M-x elpher'.  This will open a start
58 ;; page containing information on key bindings and suggested starting
59 ;; points for your gopher exploration.
60
61 ;; Full instructions can be found in the Elpher info manual.
62
63 ;; Elpher is under active development.  Any suggestions for
64 ;; improvements are welcome, and can be made on the official project
65 ;; page, gopher://thelambdalab.xyz/1/projects/elpher, or via the
66 ;; project mailing list at https://lists.sr.ht/~michel-slm/elpher.
67
68 ;;; Code:
69
70 (provide 'elpher)
71
72 ;;; Dependencies
73 ;;
74
75 (require 'seq)
76 (require 'pp)
77 (require 'shr)
78 (require 'url-util)
79 (require 'subr-x)
80 (require 'dns)
81 (require 'nsm)
82 (require 'gnutls)
83 (require 'socks)
84
85 ;;; Global constants
86 ;;
87
88 (defconst elpher-version "3.0.0"
89   "Current version of elpher.")
90
91 (defconst elpher-margin-width 6
92   "Width of left-hand margin used when rendering indicies.")
93
94 (defconst elpher-type-map
95   '(((gopher ?0) elpher-get-gopher-page elpher-render-text "txt" elpher-text)
96     ((gopher ?1) elpher-get-gopher-page elpher-render-index "/" elpher-index)
97     ((gopher ?4) elpher-get-gopher-page elpher-render-download "bin" elpher-binary)
98     ((gopher ?5) elpher-get-gopher-page elpher-render-download "bin" elpher-binary)
99     ((gopher ?7) elpher-get-gopher-query-page elpher-render-index "?" elpher-search)
100     ((gopher ?9) elpher-get-gopher-page elpher-render-download "bin" elpher-binary)
101     ((gopher ?g) elpher-get-gopher-page elpher-render-image "img" elpher-image)
102     ((gopher ?p) elpher-get-gopher-page elpher-render-image "img" elpher-image)
103     ((gopher ?I) elpher-get-gopher-page elpher-render-image "img" elpher-image)
104     ((gopher ?d) elpher-get-gopher-page elpher-render-download "doc" elpher-binary)
105     ((gopher ?P) elpher-get-gopher-page elpher-render-download "doc" elpher-binary)
106     ((gopher ?s) elpher-get-gopher-page elpher-render-download "snd" elpher-binary)
107     ((gopher ?h) elpher-get-gopher-page elpher-render-html "htm" elpher-html)
108     (gemini elpher-get-gemini-page elpher-render-gemini "gem" elpher-gemini)
109     (finger elpher-get-finger-page elpher-render-text "txt" elpher-text)
110     (telnet elpher-get-telnet-page nil "tel" elpher-telnet)
111     (other-url elpher-get-other-url-page nil "url" elpher-other-url)
112     ((special start) elpher-get-start-page nil "E" elpher-index)
113     ((special history) elpher-get-history-page nil "E" elpher-index)
114     ((special visited-pages) elpher-get-visited-pages-page nil "E" elpher-index))
115   "Association list from types to getters, renderers, margin codes and index faces.")
116
117
118 ;;; Declarations to avoid compiler warnings.
119 ;;
120
121 (eval-when-compile
122   (declare-function ansi-color-filter-apply "ansi-color")
123   (declare-function ansi-color-apply "ansi-color")
124   (declare-function bookmark-store "bookmark")
125   (declare-function org-link-store-props "ol")
126   (declare-function org-link-set-parameters "ol")
127   (defvar ansi-color-context)
128   (defvar bookmark-make-record-function)
129   (defvar mu4e~view-beginning-of-url-regexp)
130   (defvar thing-at-point-uri-schemes))
131
132
133 ;;; Customization group
134 ;;
135
136 (defgroup elpher nil
137   "A gopher and gemini client."
138   :group 'applications)
139
140 ;; General appearance and customizations
141
142 (defcustom elpher-open-urls-with-eww nil
143   "If non-nil, open URL selectors using eww.
144 Otherwise, use the system browser via the `browse-url' function."
145   :type '(boolean))
146
147 (defcustom elpher-use-header t
148   "If non-nil, display current page information in buffer header."
149   :type '(boolean))
150
151 (defcustom elpher-auto-disengage-TLS nil
152   "If non-nil, automatically disengage TLS following an unsuccessful connection.
153 While enabling this may seem convenient, it is also potentially
154 dangerous as it allows switching from an encrypted channel back to
155 plain text without user input."
156   :type '(boolean))
157
158 (defcustom elpher-connection-timeout 5
159   "Specifies the number of seconds to wait for a network connection to time out."
160   :type '(integer))
161
162 (defcustom elpher-filter-ansi-from-text nil
163   "If non-nil, filter ANSI escape sequences from text.
164 The default behaviour is to use the ansi-color package to interpret these
165 sequences."
166   :type '(boolean))
167
168 (defcustom elpher-certificate-directory
169   (file-name-as-directory (locate-user-emacs-file "elpher-certificates"))
170   "Specify the name of the directory where client certificates will be stored.
171 These certificates may be used for establishing authenticated TLS connections."
172   :type '(directory))
173
174 (defcustom elpher-openssl-command "openssl"
175   "The command used to launch openssl when generating TLS client certificates."
176   :type '(file))
177
178 (defcustom elpher-default-url-type "gopher"
179   "Default URL type (i.e. scheme) to assume if not explicitly given."
180   :type '(choice (const "gopher")
181                  (const "gemini")))
182
183 (defcustom elpher-gemini-TLS-cert-checks nil
184   "If non-nil, verify gemini server TLS certs using the default security level.
185 Otherwise, certificate verification is disabled.
186
187 This defaults to off because it is standard practice for Gemini servers
188 to use self-signed certificates, meaning that most servers provide what
189 EMACS considers to be an invalid certificate."
190   :type '(boolean))
191
192 (defcustom elpher-gemini-max-fill-width 80
193   "Specify the maximum default width (in columns) of text/gemini documents.
194 The actual width used is the minimum of this value and the window width at
195 the time when the text is rendered."
196   :type '(integer))
197
198 (defcustom elpher-gemini-link-string "→ "
199   "Specify the string used to indicate links when rendering gemini maps.
200 May be empty."
201   :type '(string))
202
203 (defcustom elpher-gemini-bullet-string "•"
204   "Specify the string used for bullets when rendering gemini maps."
205   :type '(string))
206
207 (defcustom elpher-ipv4-always nil
208   "If non-nil, elpher will always use IPv4 to establish network connections.
209 This can be useful when browsing from a computer that supports IPv6, because
210 some servers which do not support IPv6 can take a long time to time-out."
211   :type '(boolean))
212
213 (defcustom elpher-socks-always nil
214   "If non-nil, elpher will establish network connections over a SOCKS proxy.
215 Otherwise, the SOCKS proxy is only used for connections to onion services."
216   :type '(boolean))
217
218 ;; Face customizations
219
220 (defgroup elpher-faces nil
221   "Elpher face customizations."
222   :group 'elpher)
223
224 (defface elpher-index
225   '((t :inherit font-lock-keyword-face))
226   "Face used for directory type directory records.")
227
228 (defface elpher-text
229   '((t :inherit bold))
230   "Face used for text type directory records.")
231
232 (defface elpher-info
233   '((t :inherit default))
234   "Face used for info type directory records.")
235
236 (defface elpher-image
237   '((t :inherit font-lock-string-face))
238   "Face used for image type directory records.")
239
240 (defface elpher-search
241   '((t :inherit warning))
242   "Face used for search type directory records.")
243
244 (defface elpher-html
245   '((t :inherit font-lock-comment-face))
246   "Face used for html type directory records.")
247
248 (defface elpher-gemini
249   '((t :inherit font-lock-constant-face))
250   "Face used for Gemini type directory records.")
251
252 (defface elpher-other-url
253   '((t :inherit font-lock-comment-face))
254   "Face used for other URL type links records.")
255
256 (defface elpher-telnet
257   '((t :inherit font-lock-function-name-face))
258   "Face used for telnet type directory records.")
259
260 (defface elpher-binary
261   '((t :inherit font-lock-doc-face))
262   "Face used for binary type directory records.")
263
264 (defface elpher-unknown
265   '((t :inherit error))
266   "Face used for directory records with unknown/unsupported types.")
267
268 (defface elpher-margin-key
269   '((t :inherit bold))
270   "Face used for directory margin key.")
271
272 (defface elpher-margin-brackets
273   '((t :inherit shadow))
274   "Face used for brackets around directory margin key.")
275
276 (defface elpher-gemini-heading1
277   '((t :inherit bold :height 1.8))
278   "Face used for gemini heading level 1.")
279
280 (defface elpher-gemini-heading2
281   '((t :inherit bold :height 1.5))
282   "Face used for gemini heading level 2.")
283
284 (defface elpher-gemini-heading3
285   '((t :inherit bold :height 1.2))
286   "Face used for gemini heading level 3.")
287
288 (defface elpher-gemini-preformatted
289   '((t :inherit fixed-pitch))
290   "Face used for pre-formatted gemini text blocks.")
291
292 (defface elpher-gemini-quoted
293   '((t :inherit font-lock-doc-face))
294   "Face used for gemini quoted texts.")
295
296 ;;; Model
297 ;;
298
299 ;; Address
300
301 ;; An elpher "address" object is either a url object or a symbol.
302 ;; Symbol addresses are "special", corresponding to pages generated
303 ;; dynamically for and by elpher.  All others represent pages which
304 ;; rely on content retrieved over the network.
305
306 (defun elpher-address-from-url (url-string)
307   "Create a ADDRESS object corresponding to the given URL-STRING."
308   (let ((data (match-data))) ; Prevent parsing clobbering match data
309     (unwind-protect
310         (let ((url (url-generic-parse-url url-string)))
311           (unless (and (not (url-fullness url)) (url-type url))
312             (setf (url-fullness url) t)
313             (unless (url-type url)
314               (setf (url-type url) elpher-default-url-type))
315             (unless (url-host url)
316               (let ((p (split-string (url-filename url) "/" nil nil)))
317                 (setf (url-host url) (car p))
318                 (setf (url-filename url)
319                       (if (cdr p)
320                           (concat "/" (mapconcat #'identity (cdr p) "/"))
321                         ""))))
322             (when (or (equal "gopher" (url-type url))
323                       (equal "gophers" (url-type url)))
324               ;; Gopher defaults
325               (when (or (equal (url-filename url) "")
326                         (equal (url-filename url) "/"))
327                 (setf (url-filename url) "/1")))
328             (when (equal "gemini" (url-type url))
329               ;; Gemini defaults
330               (if (equal (url-filename url) "")
331                   (setf (url-filename url) "/"))))
332           (elpher-remove-redundant-ports url))
333       (set-match-data data))))
334
335 (defun elpher-remove-redundant-ports (address)
336   "Remove redundant port specifiers from ADDRESS.
337 Here 'redundant' means that the specified port matches the default
338 for that protocol, eg 70 for gopher."
339   (if (and (not (elpher-address-special-p address))
340            (eq (url-portspec address) ; (url-port) is too slow!
341                (pcase (url-type address)
342                  ("gemini" 1965)
343                  ((or "gopher" "gophers") 70)
344                  ("finger" 79)
345                  (_ -1))))
346       (setf (url-portspec address) nil))
347   address)
348
349 (defun elpher-make-gopher-address (type selector host port &optional tls)
350   "Create an ADDRESS object using gopher directory record attributes.
351 The basic attributes include: TYPE, SELECTOR, HOST and PORT.
352 If the optional attribute TLS is non-nil, the address will be marked as
353 requiring gopher-over-TLS."
354   (cond
355    ((equal type ?i) nil)
356    ((and (equal type ?h)
357          (string-prefix-p "URL:" selector))
358     (elpher-address-from-url (elt (split-string selector "URL:") 1)))
359    ((equal type ?8)
360     (elpher-address-from-url
361      (concat "telnet"
362              "://" host
363              ":" (number-to-string port))))
364    (t
365     (elpher-address-from-url
366      (concat "gopher" (if tls "s" "")
367              "://" host
368              ":" (number-to-string port)
369              "/" (string type)
370              selector)))))
371
372 (defun elpher-make-special-address (type)
373   "Create an ADDRESS object corresponding to the given special address symbol TYPE."
374   type)
375
376 (defun elpher-address-to-url (address)
377   "Get string representation of ADDRESS, or nil if ADDRESS is special."
378   (if (elpher-address-special-p address)
379       nil
380     (url-encode-url (url-recreate-url address))))
381
382 (defun elpher-address-type (address)
383   "Retrieve type of ADDRESS object.
384 This is used to determine how to retrieve and render the document the
385 address refers to, via the table `elpher-type-map'."
386   (if (symbolp address)
387       (list 'special address)
388     (let ((protocol (url-type address)))
389       (cond ((or (equal protocol "gopher")
390                  (equal protocol "gophers"))
391              (list 'gopher
392                    (if (member (url-filename address) '("" "/"))
393                        ?1
394                      (string-to-char (substring (url-filename address) 1)))))
395             ((equal protocol "gemini")
396              'gemini)
397             ((equal protocol "telnet")
398              'telnet)
399             ((equal protocol "finger")
400              'finger)
401             (t 'other-url)))))
402
403 (defun elpher-address-protocol (address)
404   "Retrieve the transport protocol for ADDRESS.  This is nil for special addresses."
405   (if (symbolp address)
406       nil
407     (url-type address)))
408
409 (defun elpher-address-filename (address)
410   "Retrieve the filename component of ADDRESS.
411 For gopher addresses this is a combination of the selector type and selector."
412   (if (symbolp address)
413       nil
414     (url-unhex-string (url-filename address))))
415
416 (defun elpher-address-host (address)
417   "Retrieve host from ADDRESS object."
418   (url-host address))
419
420 (defun elpher-address-user (address)
421   "Retrieve user from ADDRESS object."
422   (url-user address))
423
424 (defun elpher-address-port (address)
425   "Retrieve port from ADDRESS object.
426 If no address is defined, returns 0.  (This is for compatibility with the URL library.)"
427   (if (symbolp address)
428       0
429     (url-port address)))
430
431 (defun elpher-address-special-p (address)
432   "Return non-nil if ADDRESS object is special (e.g. start page page)."
433   (symbolp address))
434
435 (defun elpher-address-gopher-p (address)
436   "Return non-nill if ADDRESS object is a gopher address."
437   (and (not (elpher-address-special-p address))
438        (member (elpher-address-protocol address) '("gopher" "gophers"))))
439
440 (defun elpher-gopher-address-selector (address)
441   "Retrieve gopher selector from ADDRESS object."
442   (if (member (url-filename address) '("" "/"))
443       ""
444     (url-unhex-string (substring (url-filename address) 2))))
445
446
447 ;; Cache
448
449 (defvar elpher-content-cache (make-hash-table :test 'equal))
450 (defvar elpher-pos-cache (make-hash-table :test 'equal))
451
452 (defun elpher-get-cached-content (address)
453   "Retrieve the cached content for ADDRESS, or nil if none exists."
454   (gethash address elpher-content-cache))
455
456 (defun elpher-cache-content (address content)
457   "Set the content cache for ADDRESS to CONTENT."
458   (puthash address content elpher-content-cache))
459
460 (defun elpher-get-cached-pos (address)
461   "Retrieve the cached cursor position for ADDRESS, or nil if none exists."
462   (gethash address elpher-pos-cache))
463
464 (defun elpher-cache-pos (address pos)
465   "Set the cursor position cache for ADDRESS to POS."
466   (puthash address pos elpher-pos-cache))
467
468
469 ;; Page
470
471 (defun elpher-make-page (display-string address)
472   "Create a page with DISPLAY-STRING and ADDRESS."
473   (list display-string address))
474
475 (defun elpher-make-start-page ()
476   "Create the start page."
477   (elpher-make-page "Elpher Start Page"
478                     (elpher-make-special-address 'start)))
479
480 (defun elpher-page-display-string (page)
481   "Retrieve the display string corresponding to PAGE."
482   (elt page 0))
483
484 (defun elpher-page-address (page)
485   "Retrieve the address corresponding to PAGE."
486   (elt page 1))
487
488 (defun elpher-page-set-address (page new-address)
489   "Set the address corresponding to PAGE to NEW-ADDRESS."
490   (setcar (cdr page) new-address))
491
492 (defvar elpher-current-page nil
493   "The current page for this Elpher buffer.")
494
495 (defvar elpher-history nil
496   "The local history stack for this Elpher buffer.
497 This variable is used by `elpher-back' and
498 `elpher-show-history'.")
499
500 (defvar elpher-visited-pages nil
501   "The global history for all Elpher buffers.
502 This variable is used by `elpher-show-visited-pages'.")
503
504 (defun elpher-visit-page (page &optional renderer no-history)
505   "Visit PAGE using its own renderer or RENDERER, if non-nil.
506 Additionally, push PAGE onto the history stack and the list of
507 previously-visited pages,unless NO-HISTORY is non-nil."
508   (elpher-save-pos)
509   (elpher-process-cleanup)
510   (unless no-history
511     (unless (equal (elpher-page-address elpher-current-page)
512                    (elpher-page-address page))
513       (push elpher-current-page elpher-history)
514       (unless (or (elpher-address-special-p (elpher-page-address page))
515                   (and elpher-visited-pages
516                        (equal page (car elpher-visited-pages))))
517         (push page elpher-visited-pages))))
518   (setq-local elpher-current-page page)
519   (let* ((address (elpher-page-address page))
520          (type (elpher-address-type address))
521          (type-record (cdr (assoc type elpher-type-map))))
522     (if type-record
523         (funcall (car type-record)
524                  (if renderer
525                      renderer
526                    (cadr type-record)))
527       (elpher-visit-previous-page)
528       (pcase type
529         (`(gopher ,type-char)
530          (error "Unsupported gopher selector type '%c' for '%s'"
531                 type-char (elpher-address-to-url address)))
532         (other
533          (error "Unsupported address type '%S' for '%s'"
534                 other (elpher-address-to-url address)))))))
535
536 (defun elpher-visit-previous-page ()
537   "Visit the previous page in the history."
538   (let ((previous-page (pop elpher-history)))
539     (if previous-page
540         (elpher-visit-page previous-page nil t)
541       (error "No previous page"))))
542
543 (defun elpher-reload-current-page ()
544   "Reload the current page, discarding any existing cached content."
545   (elpher-cache-content (elpher-page-address elpher-current-page) nil)
546   (elpher-visit-page elpher-current-page))
547
548 (defun elpher-save-pos ()
549   "Save the current position of point to the current page."
550   (when elpher-current-page
551     (elpher-cache-pos (elpher-page-address elpher-current-page) (point))))
552
553 (defun elpher-restore-pos ()
554   "Restore the position of point to that cached in the current page."
555   (let ((pos (elpher-get-cached-pos (elpher-page-address elpher-current-page))))
556     (if pos
557         (goto-char pos)
558       (goto-char (point-min)))))
559
560
561 ;;; Buffer preparation
562 ;;
563
564 (defvar elpher-buffer-name "*elpher*"
565   "The default name of the Elpher buffer.")
566
567 (defun elpher-update-header ()
568   "If `elpher-use-header' is true, display current page info in window header."
569   (if elpher-use-header
570       (let* ((display-string (elpher-page-display-string elpher-current-page))
571              (address (elpher-page-address elpher-current-page))
572              (tls-string (if (and (not (elpher-address-special-p address))
573                                   (member (elpher-address-protocol address)
574                                           '("gophers" "gemini")))
575                              " [TLS encryption]"
576                            ""))
577              (header (concat display-string
578                              (propertize tls-string 'face 'bold))))
579         (setq header-line-format header))))
580
581 (defmacro elpher-with-clean-buffer (&rest args)
582   "Evaluate ARGS with a clean *elpher* buffer as current."
583   `(with-current-buffer elpher-buffer-name
584      (unless (eq major-mode 'elpher-mode)
585        ;; avoid resetting buffer-local variables
586        (elpher-mode))
587      (let ((inhibit-read-only t)
588            (ansi-color-context nil)) ;; clean ansi interpreter state
589        (setq-local network-security-level
590                    (default-value 'network-security-level))
591        (erase-buffer)
592        (elpher-update-header)
593        ,@args)))
594
595 (defun elpher-buffer-message (string &optional line)
596   "Replace first line in elpher buffer with STRING.
597 If LINE is non-nil, replace that line instead."
598   (with-current-buffer elpher-buffer-name
599     (let ((inhibit-read-only t))
600       (goto-char (point-min))
601       (if line
602           (forward-line line))
603       (let ((data (match-data)))
604         (unwind-protect
605             (progn
606               (re-search-forward "^.*$")
607               (replace-match string))
608           (set-match-data data))))))
609
610
611 ;;; Text Processing
612 ;;
613
614 (defvar elpher-user-coding-system nil
615   "User-specified coding system to use for decoding text responses.")
616
617 (defun elpher-decode (string)
618   "Decode STRING using autodetected or user-specified coding system."
619   (decode-coding-string string
620                         (if elpher-user-coding-system
621                             elpher-user-coding-system
622                           (detect-coding-string string t))))
623
624 (defun elpher-preprocess-text-response (string)
625   "Preprocess text selector response contained in STRING.
626 This involes decoding the character representation, and clearing
627 away CRs and any terminating period."
628   (elpher-decode (replace-regexp-in-string "\n\\.\n$" "\n"
629                                            (replace-regexp-in-string "\r" "" string))))
630
631 ;;; Buttonify urls
632
633 (defconst elpher-url-regex
634   "\\([a-zA-Z]+\\)://\\([a-zA-Z0-9.-]*[a-zA-Z0-9-]\\|\\[[a-zA-Z0-9:]+\\]\\)\\(:[0-9]+\\)?\\(/\\([0-9a-zA-Z_~?/@|:.%#=&-]*[0-9a-zA-Z_~?/@|#-]\\)?\\)?"
635   "Regexp used to locate and buttonify URLs in text files loaded by elpher.")
636
637 (defun elpher-buttonify-urls (string)
638   "Turn substrings which look like urls in STRING into clickable buttons."
639   (with-temp-buffer
640     (insert string)
641     (goto-char (point-min))
642     (while (re-search-forward elpher-url-regex nil t)
643       (let ((page (elpher-make-page (substring-no-properties (match-string 0))
644                                     (elpher-address-from-url (match-string 0)))))
645         (make-text-button (match-beginning 0)
646                           (match-end 0)
647                           'elpher-page  page
648                           'action #'elpher-click-link
649                           'follow-link t
650                           'help-echo #'elpher--page-button-help
651                           'face 'button)))
652     (buffer-string)))
653
654 ;;; ANSI colors or XTerm colors (application and filtering)
655
656 (or (require 'xterm-color nil t)
657     (require 'ansi-color))
658
659 (defalias 'elpher-color-filter-apply
660   (if (fboundp 'xterm-color-filter)
661       (lambda (s)
662         (let ((_xterm-color-render nil))
663           (xterm-color-filter s)))
664     #'ansi-color-filter-apply)
665   "A function to filter out ANSI escape sequences.")
666
667 (defalias 'elpher-color-apply
668   (if (fboundp 'xterm-color-filter)
669       #'xterm-color-filter
670     #'ansi-color-apply)
671   "A function to apply ANSI escape sequences.")
672
673 ;;; Processing text for display
674
675 (defun elpher-process-text-for-display (string)
676   "Perform any desired processing of STRING prior to display as text.
677 Currently includes buttonifying URLs and processing ANSI escape codes."
678   (elpher-buttonify-urls (if elpher-filter-ansi-from-text
679                              (elpher-color-filter-apply string)
680                            (elpher-color-apply string))))
681
682
683 ;;; Network error reporting
684 ;;
685
686 (defun elpher-network-error (address error)
687   "Display ERROR message following unsuccessful negotiation with ADDRESS.
688 ERROR can be either an error object or a string."
689   (elpher-with-clean-buffer
690    (insert (propertize "\n---- ERROR -----\n\n" 'face 'error)
691            "When attempting to retrieve " (elpher-address-to-url address) ":\n"
692            (if (stringp error) error (error-message-string error)) "\n"
693            (propertize "\n----------------\n\n" 'face 'error)
694            "Press 'u' to return to the previous page.")))
695
696
697 ;;; General network communication
698 ;;
699
700 (defvar elpher-network-timer nil
701   "Timer used for network connections.")
702
703 (defvar elpher-use-tls nil
704   "If non-nil, use TLS to communicate with gopher servers.")
705
706 (defvar elpher-client-certificate nil
707   "If non-nil, contains client certificate details to use for TLS connections.")
708
709 (defun elpher-process-cleanup ()
710   "Immediately shut down any extant elpher process and timers."
711   (let ((p (get-process "elpher-process")))
712     (if p (delete-process p)))
713   (if (timerp elpher-network-timer)
714       (cancel-timer elpher-network-timer)))
715
716 (defun elpher-make-network-timer (thunk)
717   "Creates a timer to run the THUNK after `elpher-connection-timeout' seconds.
718 This is just a wraper around `run-at-time' which additionally sets the
719 buffer-local variable `elpher-network-timer' to allow
720 `elpher-process-cleanup' to also clear the timer."
721   (let ((timer (run-at-time elpher-connection-timeout nil thunk)))
722     (setq-local elpher-network-timer timer)
723     timer))
724
725 (defun elpher-get-host-response (address default-port query-string response-processor
726                                          &optional use-tls force-ipv4)
727   "Generic function for retrieving data from ADDRESS.
728
729 When ADDRESS lacks a specific port, DEFAULT-PORT is used instead.
730 QUERY-STRING is a string sent to the host specified by ADDRESS to
731 illicet a response.  This response is passed as an argument to the
732 function RESPONSE-PROCESSOR.
733
734 If non-nil, USE-TLS specifies that the connection is to be made over
735 TLS.  If set to gemini, the certificate verification will be disabled
736 unless `elpher-gemini-TLS-cert-checks' is non-nil.
737
738 If non-nil, FORCE-IPV4 causes the network connection to be made over
739 ipv4 only.  (The default behaviour when this is not set depends on
740 the host operating system and the local network capabilities.)"
741   (if (and use-tls (not (gnutls-available-p)))
742       (error "Use of TLS requires Emacs to be compiled with GNU TLS support")
743     (unless (< (elpher-address-port address) 65536)
744       (error "Cannot establish network connection: port number > 65536"))
745     (when (and (eq use-tls 'gemini) (not elpher-gemini-TLS-cert-checks))
746       (setq-local network-security-level 'low)
747       (setq-local gnutls-verify-error nil))
748     (condition-case nil
749         (let* ((kill-buffer-query-functions nil)
750                (port (elpher-address-port address))
751                (host (elpher-address-host address))
752                (service (if (> port 0) port default-port))
753                (response-string-parts nil)
754                (bytes-received 0)
755                (hkbytes-received 0)
756                (socks (or elpher-socks-always (string-suffix-p ".onion" host)))
757                (gnutls-params (list :type 'gnutls-x509pki
758                                     :hostname host
759                                     :keylist
760                                     (elpher-get-current-keylist address)))
761                (timer (elpher-make-network-timer
762                                    (lambda ()
763                                      (elpher-process-cleanup)
764                                      (cond
765                                         ; Try again with IPv4
766                                       ((not (or force-ipv4 socks))
767                                        (message "Connection timed out.  Retrying with IPv4.")
768                                        (elpher-get-host-response address default-port
769                                                                  query-string
770                                                                  response-processor
771                                                                  use-tls t))
772                                       ((and use-tls
773                                             (not (eq use-tls 'gemini))
774                                             (or elpher-auto-disengage-TLS
775                                                 (y-or-n-p
776                                                  "TLS connetion failed.  Disable TLS mode and retry? ")))
777                                        (setq elpher-use-tls nil)
778                                        (elpher-get-host-response address default-port
779                                                                  query-string
780                                                                  response-processor
781                                                                  nil force-ipv4))
782                                       (t
783                                        (elpher-network-error address "Connection time-out."))))))
784                (proc (if socks (socks-open-network-stream "elpher-process" nil host service)
785                        (make-network-process :name "elpher-process"
786                                              :host host
787                                              :family (and force-ipv4 'ipv4)
788                                              :service service
789                                              :buffer nil
790                                              :nowait t
791                                              :tls-parameters
792                                              (and use-tls
793                                                   (cons 'gnutls-x509pki
794                                                         (apply #'gnutls-boot-parameters
795                                                                gnutls-params)))))))
796           (setq elpher-network-timer timer)
797           (set-process-coding-system proc 'binary 'binary)
798           (set-process-query-on-exit-flag proc nil)
799           (elpher-buffer-message (concat "Connecting to " host "..."
800                                          " (press 'u' to abort)"))
801           (set-process-filter proc
802                               (lambda (_proc string)
803                                 (when timer
804                                   (cancel-timer timer)
805                                   (setq timer nil))
806                                 (setq bytes-received (+ bytes-received (length string)))
807                                 (let ((new-hkbytes-received (/ bytes-received 102400)))
808                                   (when (> new-hkbytes-received hkbytes-received)
809                                     (setq hkbytes-received new-hkbytes-received)
810                                     (elpher-buffer-message
811                                      (concat "("
812                                              (number-to-string (/ hkbytes-received 10.0))
813                                              " MB read)")
814                                      1)))
815                                 (setq response-string-parts
816                                       (cons string response-string-parts))))
817           (set-process-sentinel proc
818                                 (lambda (proc event)
819                                   (when timer
820                                     (cancel-timer timer))
821                                   (condition-case the-error
822                                       (cond
823                                        ((string-prefix-p "open" event)    ; request URL
824                                         (elpher-buffer-message
825                                          (concat "Connected to " host ". Receiving data..."
826                                                  " (press 'u' to abort)"))
827                                         (let ((inhibit-eol-conversion t))
828                                           (process-send-string proc query-string)))
829                                        ((string-prefix-p "deleted" event)) ; do nothing
830                                        ((and (not response-string-parts)
831                                              (not (or elpher-ipv4-always force-ipv4 socks)))
832                                         ; Try again with IPv4
833                                         (message "Connection failed. Retrying with IPv4.")
834                                         (elpher-get-host-response address default-port
835                                                                   query-string
836                                                                   response-processor
837                                                                   use-tls t))
838                                        (response-string-parts
839                                         (elpher-with-clean-buffer
840                                          (insert "Data received.  Rendering..."))
841                                         (funcall response-processor
842                                                  (apply #'concat (reverse response-string-parts)))
843                                         (elpher-restore-pos))
844                                        (t
845                                         (error "No response from server")))
846                                     (error
847                                      (elpher-network-error address the-error)))))
848           (when socks
849             (if use-tls (apply #'gnutls-negotiate :process proc gnutls-params))
850             (funcall (process-sentinel proc) proc "open\n")))
851       (error
852        (elpher-process-cleanup)
853        (error "Error initiating connection to server")))))
854
855
856 ;;; Client-side TLS Certificate Management
857 ;;
858
859 (defun elpher-generate-certificate (common-name key-file cert-file &optional temporary)
860   "Generate a key and a self-signed client TLS certificate using openssl.
861
862 The Common Name field of the certificate is set to COMMON-NAME.  The
863 arguments KEY-FILE and CERT-FILE should contain the absolute paths of
864 the key and certificate files to write.
865
866 If TEMPORARY is non-nil, the certificate will be given an exporation
867 period of one day, and the key and certificate files will be deleted
868 when the certificate is no longer needed for the current session.
869
870 Otherwise, the certificate will be given a 100 year expiration period
871 and the files will not be deleted.
872
873 The function returns a list containing the current host name, the
874 temporary flag, and the key and cert file names in the form required
875 by `gnutls-boot-parameters`."
876   (let ((exp-key-file (expand-file-name key-file))
877         (exp-cert-file (expand-file-name cert-file)))
878     (condition-case nil
879         (progn
880           (call-process elpher-openssl-command nil nil nil
881                         "req" "-x509" "-newkey" "rsa:2048"
882                         "-days" (if temporary "1" "36500")
883                         "-nodes"
884                         "-subj" (concat "/CN=" common-name)
885                         "-keyout" exp-key-file
886                         "-out" exp-cert-file)
887           (list (elpher-address-host (elpher-page-address elpher-current-page))
888                 temporary exp-key-file exp-cert-file))
889       (error
890        (message "Check that openssl is installed, or customize `elpher-openssl-command`.")
891        (error "Program 'openssl', required for certificate generation, not found")))))
892
893 (defun elpher-generate-throwaway-certificate ()
894   "Generate and return details of a throwaway certificate.
895 The key and certificate files will be deleted when they are no
896 longer needed for this session."
897   (let* ((file-base (make-temp-name "elpher"))
898          (key-file (concat temporary-file-directory file-base ".key"))
899          (cert-file (concat temporary-file-directory file-base ".crt")))
900     (elpher-generate-certificate file-base key-file cert-file t)))
901
902 (defun elpher-generate-persistent-certificate (file-base common-name)
903   "Generate and return details of a persistent certificate.
904 The argument FILE-BASE is used as the base for the key and certificate
905 files, while COMMON-NAME specifies the common name field of the
906 certificate.
907
908 The key and certificate files are written to in `elpher-certificate-directory'."
909   (let* ((key-file (concat elpher-certificate-directory file-base ".key"))
910          (cert-file (concat elpher-certificate-directory file-base ".crt")))
911     (elpher-generate-certificate common-name key-file cert-file)))
912
913 (defun elpher-get-existing-certificate (file-base)
914   "Return a certificate object corresponding to an existing certificate.
915 It is assumed that the key files FILE-BASE.key and FILE-BASE.crt exist in
916 the directory `elpher-certificate-directory'."
917   (let* ((key-file (concat elpher-certificate-directory file-base ".key"))
918          (cert-file (concat elpher-certificate-directory file-base ".crt")))
919     (list (elpher-address-host (elpher-page-address elpher-current-page))
920           nil
921           (expand-file-name key-file)
922           (expand-file-name cert-file))))
923
924 (defun elpher-install-and-use-existing-certificate (key-file-src cert-file-src file-base)
925   "Install a key+certificate file pair in `elpher-certificate-directory'.
926 The strings KEY-FILE-SRC and CERT-FILE-SRC are the existing key and
927 certificate files to install.  The argument FILE-BASE is used as the
928 base for the installed key and certificate files."
929   (let* ((key-file (concat elpher-certificate-directory file-base ".key"))
930          (cert-file (concat elpher-certificate-directory file-base ".crt")))
931     (if (or (file-exists-p key-file)
932             (file-exists-p cert-file))
933         (error "A certificate with base name %s is already installed" file-base))
934     (copy-file key-file-src key-file)
935     (copy-file cert-file-src cert-file)
936     (list (elpher-address-host (elpher-page-address elpher-current-page))
937           nil
938           (expand-file-name key-file)
939           (expand-file-name cert-file))))
940
941 (defun elpher-list-existing-certificates ()
942   "Return a list of the persistent certificates in `elpher-certificate-directory'."
943   (unless (file-directory-p elpher-certificate-directory)
944     (make-directory elpher-certificate-directory))
945   (mapcar
946    (lambda (file)
947      (file-name-sans-extension file))
948    (directory-files elpher-certificate-directory nil "\\.key$")))
949
950 (defun elpher-forget-current-certificate ()
951   "Causes any current certificate to be forgotten.)
952 In the case of throwaway certificates, the key and certificate files
953 are also deleted."
954   (interactive)
955   (when elpher-client-certificate
956     (unless (and (called-interactively-p 'any)
957                  (not (y-or-n-p (concat "Really forget client certificate? "
958                                         "(Throwaway certificates will be deleted.)"))))
959       (when (cadr elpher-client-certificate)
960         (delete-file (elt elpher-client-certificate 2))
961         (delete-file (elt elpher-client-certificate 3)))
962       (setq elpher-client-certificate nil)
963       (if (called-interactively-p 'any)
964           (message "Client certificate forgotten.")))))
965
966 (defun elpher-get-current-keylist (address)
967   "Retrieve the `gnutls-boot-parameters'-compatable keylist.
968
969 This is obtained from the client certificate described by
970 `elpher-current-certificate', if one is available and the host for
971 that certificate matches the host in ADDRESS.
972
973 If `elpher-current-certificate' is non-nil, and its host name doesn't
974 match that of ADDRESS, the certificate is forgotten."
975   (if elpher-client-certificate
976       (if (string= (car elpher-client-certificate)
977                    (elpher-address-host address))
978           (list (cddr elpher-client-certificate))
979         (elpher-forget-current-certificate)
980         (message "Disabling client certificate for new host")
981         nil)
982     nil))
983
984
985 ;;; Gopher selector retrieval
986 ;;
987
988 (defun elpher-get-gopher-response (address renderer)
989   "Get response string from gopher server at ADDRESS and render using RENDERER."
990   (elpher-get-host-response address 70
991                             (concat (elpher-gopher-address-selector address) "\r\n")
992                             renderer
993                             (or (string= (elpher-address-protocol address) "gophers")
994                                 elpher-use-tls)))
995
996 (defun elpher-get-gopher-page (renderer)
997   "Getter function for gopher pages.
998 The RENDERER procedure is used to display the contents of the page
999 once they are retrieved from the gopher server."
1000   (let* ((address (elpher-page-address elpher-current-page))
1001          (content (elpher-get-cached-content address)))
1002     (if (and content (funcall renderer nil))
1003         (elpher-with-clean-buffer
1004          (insert content)
1005          (elpher-restore-pos))
1006       (elpher-with-clean-buffer
1007        (insert "LOADING... (use 'u' to cancel)\n"))
1008       (condition-case the-error
1009           (elpher-get-gopher-response address renderer)
1010         (error
1011          (elpher-network-error address the-error))))))
1012
1013 ;; Index rendering
1014
1015 (defun elpher-insert-index (string)
1016   "Insert the index corresponding to STRING into the current buffer."
1017   ;; Should be able to split directly on CRLF, but some non-conformant
1018   ;; LF-only servers sadly exist, hence the following.
1019   (let ((str-processed (elpher-preprocess-text-response string)))
1020     (dolist (line (split-string str-processed "\n"))
1021       (ignore-errors
1022         (unless (= (length line) 0)
1023           (let* ((type (elt line 0))
1024                  (fields (split-string (substring line 1) "\t"))
1025                  (display-string (elt fields 0))
1026                  (selector (elt fields 1))
1027                  (host (elt fields 2))
1028                  (port (if (elt fields 3)
1029                            (string-to-number (elt fields 3))
1030                          nil))
1031                  (address (elpher-make-gopher-address type selector host port)))
1032             (elpher-insert-index-record display-string address)))))))
1033
1034 (defun elpher-insert-margin (&optional type-name)
1035   "Insert index margin, optionally containing the TYPE-NAME, into the current buffer."
1036   (if type-name
1037       (progn
1038         (insert (format (concat "%" (number-to-string (- elpher-margin-width 1)) "s")
1039                         (concat
1040                          (propertize "[" 'face 'elpher-margin-brackets)
1041                          (propertize type-name 'face 'elpher-margin-key)
1042                          (propertize "]" 'face 'elpher-margin-brackets))))
1043         (insert " "))
1044     (insert (make-string elpher-margin-width ?\s))))
1045
1046 (defun elpher--page-button-help (_window buffer pos)
1047   "Function called by Emacs to generate mouse-over text.
1048 The arguments specify the BUFFER and the POS within the buffer of the item
1049 for which help is required.  The function returns the help to be
1050 displayed.  The _WINDOW argument is currently unused."
1051   (with-current-buffer buffer
1052     (let ((button (button-at pos)))
1053       (when button
1054         (let* ((page (button-get button 'elpher-page))
1055                (address (elpher-page-address page)))
1056           (format "mouse-1, RET: open '%s'" (if (elpher-address-special-p address)
1057                                                 address
1058                                               (elpher-address-to-url address))))))))
1059
1060 (defun elpher-insert-index-record (display-string &optional address)
1061   "Function to insert an index record into the current buffer.
1062 The contents of the record are dictated by DISPLAY-STRING and ADDRESS.
1063 If ADDRESS is not supplied or nil the record is rendered as an
1064 'information' line."
1065   (let* ((type (if address (elpher-address-type address) nil))
1066          (type-map-entry (cdr (assoc type elpher-type-map))))
1067     (if type-map-entry
1068         (let* ((margin-code (elt type-map-entry 2))
1069                (face (elt type-map-entry 3))
1070                (filtered-display-string (elpher-color-filter-apply display-string))
1071                (page (elpher-make-page filtered-display-string address)))
1072           (elpher-insert-margin margin-code)
1073           (insert-text-button filtered-display-string
1074                               'face face
1075                               'elpher-page page
1076                               'action #'elpher-click-link
1077                               'follow-link t
1078                               'help-echo #'elpher--page-button-help))
1079       (pcase type
1080         ('nil ;; Information
1081          (elpher-insert-margin)
1082          (let ((propertized-display-string
1083                 (propertize display-string 'face 'elpher-info)))
1084            (insert (elpher-process-text-for-display propertized-display-string))))
1085         (`(gopher ,selector-type) ;; Unknown
1086          (elpher-insert-margin (concat (char-to-string selector-type) "?"))
1087          (insert (propertize display-string
1088                              'face 'elpher-unknown)))))
1089     (insert "\n")))
1090
1091 (defun elpher-click-link (button)
1092   "Function called when the gopher link BUTTON is activated (via mouse or keypress)."
1093   (let ((page (button-get button 'elpher-page)))
1094     (elpher-visit-page page)))
1095
1096 (defun elpher-render-index (data &optional _mime-type-string)
1097   "Render DATA as an index.  MIME-TYPE-STRING is unused."
1098   (elpher-with-clean-buffer
1099    (if (not data)
1100        t
1101      (elpher-insert-index data)
1102      (elpher-cache-content (elpher-page-address elpher-current-page)
1103                            (buffer-string)))))
1104
1105 ;; Text rendering
1106
1107 (defun elpher-render-text (data &optional _mime-type-string)
1108   "Render DATA as text.  MIME-TYPE-STRING is unused."
1109   (elpher-with-clean-buffer
1110    (if (not data)
1111        t
1112      (insert (elpher-process-text-for-display (elpher-preprocess-text-response data)))
1113      (elpher-cache-content
1114       (elpher-page-address elpher-current-page)
1115       (buffer-string)))))
1116
1117 ;; Image retrieval
1118
1119 (defun elpher-render-image (data &optional _mime-type-string)
1120   "Display DATA as image.  MIME-TYPE-STRING is unused."
1121   (if (not data)
1122       nil
1123     (if (display-images-p)
1124         (progn
1125           (let ((image (create-image
1126                         data
1127                         nil t)))
1128             (elpher-with-clean-buffer
1129              (insert-image image)
1130              (elpher-restore-pos))))
1131       (elpher-render-download data))))
1132
1133 ;; Search retrieval and rendering
1134
1135 (defun elpher-get-gopher-query-page (renderer)
1136   "Getter for gopher addresses requiring input.
1137 The response is rendered using the rendering function RENDERER."
1138   (let* ((address (elpher-page-address elpher-current-page))
1139          (content (elpher-get-cached-content address))
1140          (aborted t))
1141     (if (and content (funcall renderer nil))
1142         (elpher-with-clean-buffer
1143          (insert content)
1144          (elpher-restore-pos)
1145          (message "Displaying cached search results.  Reload to perform a new search."))
1146       (unwind-protect
1147           (let* ((query-string (read-string "Query: "))
1148                  (query-selector (concat (elpher-gopher-address-selector address) "\t" query-string))
1149                  (search-address (elpher-make-gopher-address ?1
1150                                                              query-selector
1151                                                              (elpher-address-host address)
1152                                                              (elpher-address-port address)
1153                                                              (equal (elpher-address-type address) "gophers"))))
1154             (setq aborted nil)
1155
1156             (elpher-with-clean-buffer
1157              (insert "LOADING RESULTS... (use 'u' to cancel)"))
1158             (elpher-get-gopher-response search-address renderer))
1159         (if aborted
1160             (elpher-visit-previous-page))))))
1161
1162 ;; Raw server response rendering
1163
1164 (defun elpher-render-raw (data &optional mime-type-string)
1165   "Display raw DATA in buffer.  MIME-TYPE-STRING is also displayed if provided."
1166   (if (not data)
1167       nil
1168     (elpher-with-clean-buffer
1169      (when mime-type-string
1170        (insert "MIME type specified by server: '" mime-type-string "'\n"))
1171      (insert data)
1172      (goto-char (point-min)))
1173     (message "Displaying raw server response.  Reload or redraw to return to standard view.")))
1174
1175 ;; File save "rendering"
1176
1177 (defun elpher-render-download (data &optional _mime-type-string)
1178   "Save DATA to file.  MIME-TYPE-STRING is unused."
1179   (if (not data)
1180       nil
1181     (let* ((address (elpher-page-address elpher-current-page))
1182            (selector (if (elpher-address-gopher-p address)
1183                          (elpher-gopher-address-selector address)
1184                        (elpher-address-filename address))))
1185       (elpher-visit-previous-page) ; Do first in case of non-local exits.
1186       (let* ((filename-proposal (file-name-nondirectory selector))
1187              (filename (read-file-name "Download complete. Save file as: "
1188                                        nil nil nil
1189                                        (if (> (length filename-proposal) 0)
1190                                            filename-proposal
1191                                          "download.file"))))
1192         (let ((coding-system-for-write 'binary))
1193           (with-temp-file filename
1194             (insert data)))
1195         (message (format "Saved to file %s." filename))))))
1196
1197 ;; HTML rendering
1198
1199 (defun elpher-render-html (data &optional _mime-type-string)
1200   "Render DATA as HTML using shr.  MIME-TYPE-STRING is unused."
1201   (elpher-with-clean-buffer
1202    (if (not data)
1203        t
1204      (let ((dom (with-temp-buffer
1205                   (insert data)
1206                   (libxml-parse-html-region (point-min) (point-max)))))
1207        (shr-insert-document dom)))))
1208
1209 ;; Gemini page retrieval
1210
1211 (defvar elpher-gemini-redirect-chain)
1212
1213 (defun elpher-get-gemini-response (address renderer)
1214   "Get response string from gemini server at ADDRESS and render using RENDERER."
1215   (elpher-get-host-response address 1965
1216                             (concat (elpher-address-to-url address) "\r\n")
1217                             (lambda (response-string)
1218                               (elpher-process-gemini-response response-string renderer))
1219                             'gemini))
1220
1221 (defun elpher-parse-gemini-response (response)
1222   "Parse the RESPONSE string and return a list of components.
1223 The list is of the form (code meta body).  A response of nil implies
1224 that the response was malformed."
1225   (let ((header-end-idx (string-match "\r\n" response)))
1226     (if header-end-idx
1227         (let ((header (string-trim (substring response 0 header-end-idx)))
1228               (body (substring response (+ header-end-idx 2))))
1229           (if (>= (length header) 2)
1230               (let ((code (substring header 0 2))
1231                     (meta (string-trim (substring header 2))))
1232                 (list code meta body))
1233             (error "Malformed response: No response status found in header %s" header)))
1234       (error "Malformed response: No CRLF-delimited header found in response %s" response))))
1235
1236 (defun elpher-process-gemini-response (response-string renderer)
1237   "Process the gemini response RESPONSE-STRING and pass the result to RENDERER."
1238   (let ((response-components (elpher-parse-gemini-response response-string)))
1239     (let ((response-code (elt response-components 0))
1240           (response-meta (elt response-components 1))
1241           (response-body (elt response-components 2)))
1242       (pcase (elt response-code 0)
1243         (?1 ; Input required
1244          (elpher-with-clean-buffer
1245           (insert "Gemini server is requesting input."))
1246          (let* ((query-string
1247                  (if (eq (elt response-code 1) ?1)
1248                      (read-passwd (concat response-meta ": "))
1249                    (read-string (concat response-meta ": "))))
1250                 (query-address (seq-copy (elpher-page-address elpher-current-page)))
1251                 (old-fname (url-filename query-address)))
1252            (setf (url-filename query-address)
1253                  (concat old-fname "?" (url-build-query-string `((,query-string)))))
1254            (elpher-get-gemini-response query-address renderer)))
1255         (?2 ; Normal response
1256          (funcall renderer response-body response-meta))
1257         (?3 ; Redirect
1258          (message "Following redirect to %s" response-meta)
1259          (if (>= (length elpher-gemini-redirect-chain) 5)
1260              (error "More than 5 consecutive redirects followed"))
1261          (let ((redirect-address (elpher-address-from-gemini-url response-meta)))
1262            (if (member redirect-address elpher-gemini-redirect-chain)
1263                (error "Redirect loop detected"))
1264            (if (not (string= (elpher-address-protocol redirect-address)
1265                              "gemini"))
1266                (error "Server tried to automatically redirect to non-gemini URL: %s"
1267                       response-meta))
1268            (elpher-page-set-address elpher-current-page redirect-address)
1269            (add-to-list 'elpher-gemini-redirect-chain redirect-address)
1270            (elpher-get-gemini-response redirect-address renderer)))
1271         (?4 ; Temporary failure
1272          (error "Gemini server reports TEMPORARY FAILURE for this request: %s %s"
1273                 response-code response-meta))
1274         (?5 ; Permanent failure
1275          (error "Gemini server reports PERMANENT FAILURE for this request: %s %s"
1276                 response-code response-meta))
1277         (?6 ; Client certificate required
1278          (elpher-with-clean-buffer
1279           (if elpher-client-certificate
1280               (insert "Gemini server does not recognise the provided TLS certificate:\n\n")
1281             (insert "Gemini server is requesting a valid TLS certificate:\n\n"))
1282           (auto-fill-mode 1)
1283           (elpher-gemini-insert-text response-meta))
1284          (let ((chosen-certificate (elpher-choose-client-certificate)))
1285            (unless chosen-certificate
1286              (error "Gemini server requires a client certificate and none was provided"))
1287            (setq elpher-client-certificate chosen-certificate))
1288          (elpher-with-clean-buffer)
1289          (elpher-get-gemini-response (elpher-page-address elpher-current-page) renderer))
1290         (_other
1291          (error "Gemini server response unknown: %s %s"
1292                 response-code response-meta))))))
1293
1294 (defun elpher--read-answer-polyfill (question answers)
1295   "Polyfill for `read-answer' in Emacs 26.1.
1296 QUESTION is a string containing a question, and ANSWERS
1297 is a list of possible answers."
1298     (completing-read question (mapcar 'identity answers)))
1299
1300 (if (fboundp 'read-answer)
1301     (defalias 'elpher-read-answer 'read-answer)
1302   (defalias 'elpher-read-answer 'elpher--read-answer-polyfill))
1303
1304 (defun elpher-choose-client-certificate ()
1305   "Prompt for a client certificate to use to establish a TLS connection."
1306   (let* ((read-answer-short t))
1307     (pcase (read-answer "What do you want to do? "
1308                         '(("throwaway" ?t
1309                            "generate and use throw-away certificate")
1310                           ("persistent" ?p
1311                            "generate new or use existing persistent certificate")
1312                           ("abort" ?a
1313                            "stop immediately")))
1314       ("throwaway"
1315        (setq elpher-client-certificate (elpher-generate-throwaway-certificate)))
1316       ("persistent"
1317        (let* ((existing-certificates (elpher-list-existing-certificates))
1318               (file-base (completing-read
1319                           "Nickname for new or existing certificate (autocompletes, empty response aborts): "
1320                           existing-certificates)))
1321          (if (string-empty-p (string-trim file-base))
1322              nil
1323            (if (member file-base existing-certificates)
1324                (setq elpher-client-certificate
1325                      (elpher-get-existing-certificate file-base))
1326              (pcase (read-answer "Generate new certificate or install externally-generated one? "
1327                                  '(("new" ?n
1328                                     "generate new certificate")
1329                                    ("install" ?i
1330                                     "install existing certificate")
1331                                    ("abort" ?a
1332                                     "stop immediately")))
1333                ("new"
1334                 (let ((common-name (read-string "Common Name field for new certificate: "
1335                                                 file-base)))
1336                   (message "New key and self-signed certificate written to %s"
1337                            elpher-certificate-directory)
1338                   (elpher-generate-persistent-certificate file-base common-name)))
1339                ("install"
1340                 (let* ((cert-file (read-file-name "Certificate file: " nil nil t))
1341                        (key-file (read-file-name "Key file: " nil nil t)))
1342                   (message "Key and certificate installed in %s for future use"
1343                            elpher-certificate-directory)
1344                   (elpher-install-and-use-existing-certificate key-file
1345                                                                cert-file
1346                                                                file-base)))
1347                ("abort" nil))))))
1348       ("abort" nil))))
1349
1350 (defun elpher-get-gemini-page (renderer)
1351   "Getter which retrieves and renders a Gemini page and renders it using RENDERER."
1352   (let* ((address (elpher-page-address elpher-current-page))
1353          (content (elpher-get-cached-content address)))
1354     (condition-case the-error
1355         (if (and content (funcall renderer nil))
1356             (elpher-with-clean-buffer
1357              (insert content)
1358              (elpher-restore-pos))
1359           (elpher-with-clean-buffer
1360            (insert "LOADING GEMINI... (use 'u' to cancel)\n"))
1361           (setq elpher-gemini-redirect-chain nil)
1362           (elpher-get-gemini-response address renderer))
1363       (error
1364        (elpher-network-error address the-error)))))
1365
1366 (defun elpher-render-gemini (body &optional mime-type-string)
1367   "Render gemini response BODY with rendering MIME-TYPE-STRING."
1368   (if (not body)
1369       t
1370     (let* ((mime-type-string* (if (or (not mime-type-string)
1371                                       (string-empty-p mime-type-string))
1372                                   "text/gemini; charset=utf-8"
1373                                 mime-type-string))
1374            (mime-type-split (split-string mime-type-string* ";" t))
1375            (mime-type (string-trim (car mime-type-split)))
1376            (parameters (mapcar (lambda (s)
1377                                  (let ((key-val (split-string s "=")))
1378                                    (list (downcase (string-trim (car key-val)))
1379                                          (downcase (string-trim (cadr key-val))))))
1380                                (cdr mime-type-split))))
1381       (when (string-prefix-p "text/" mime-type)
1382         (setq body (decode-coding-string
1383                     body
1384                     (if (assoc "charset" parameters)
1385                         (intern (cadr (assoc "charset" parameters)))
1386                       'utf-8)))
1387         (setq body (replace-regexp-in-string "\r" "" body)))
1388       (pcase mime-type
1389         ((or "text/gemini" "")
1390          (elpher-render-gemini-map body parameters))
1391         ("text/html"
1392          (elpher-render-html body))
1393         ((pred (string-prefix-p "text/"))
1394          (elpher-render-gemini-plain-text body parameters))
1395         ((pred (string-prefix-p "image/"))
1396          (elpher-render-image body))
1397         (_other
1398          (elpher-render-download body))))))
1399
1400 (defun elpher-gemini-get-link-url (link-line)
1401   "Extract the url portion of LINK-LINE, a gemini map file link line.
1402 Returns nil in the event that the contents of the line following the
1403 => prefix are empty."
1404   (let ((l (split-string (substring link-line 2))))
1405     (if l
1406         (string-trim (elt l 0))
1407       nil)))
1408
1409 (defun elpher-gemini-get-link-display-string (link-line)
1410   "Extract the display string portion of LINK-LINE, a gemini map file link line.
1411 Returns the url portion in the event that the display-string portion is empty."
1412   (let* ((rest (string-trim (elt (split-string link-line "=>") 1)))
1413          (idx (string-match "[ \t]" rest)))
1414     (string-trim (if idx
1415                      (substring rest (+ idx 1))
1416                    rest))))
1417
1418 (defun elpher-collapse-dot-sequences (filename)
1419   "Collapse dot sequences in FILENAME.
1420 For instance, the filename /a/b/../c/./d will reduce to /a/c/d"
1421   (let* ((path (split-string filename "/"))
1422          (path-reversed-normalized
1423           (seq-reduce (lambda (a b)
1424                         (cond ((and a (equal b "..") (cdr a)))
1425                               ((and (not a) (equal b "..")) a) ;leading .. are dropped
1426                               ((equal b ".") a)
1427                               (t (cons b a))))
1428                       path nil)))
1429     (string-join (reverse path-reversed-normalized) "/")))
1430
1431 (defun elpher-address-from-gemini-url (url)
1432   "Extract address from URL with defaults as per gemini map files.
1433 While there's obviously some redundancy here between this function and
1434 `elpher-address-from-url', gemini map file URLs require enough special
1435 treatment that a separate function is warranted."
1436   (let ((address (url-generic-parse-url url))
1437         (current-address (elpher-page-address elpher-current-page)))
1438     (unless (and (url-type address) (not (url-fullness address))) ;avoid mangling mailto: urls
1439       (setf (url-fullness address) t)
1440       (if (url-host address) ;if there is an explicit host, filenames are absolute
1441           (if (string-empty-p (url-filename address))
1442               (setf (url-filename address) "/")) ;ensure empty filename is marked as absolute
1443         (setf (url-host address) (url-host current-address))
1444         (setf (url-port address) (url-port current-address))
1445         (unless (string-prefix-p "/" (url-filename address)) ;deal with relative links
1446           (setf (url-filename address)
1447                 (concat (file-name-directory (url-filename current-address))
1448                         (url-filename address)))))
1449       (unless (url-type address)
1450         (setf (url-type address) "gemini"))
1451       (when (equal (url-type address) "gemini")
1452         (setf (url-filename address)
1453               (elpher-collapse-dot-sequences (url-filename address)))))
1454     (elpher-remove-redundant-ports address)))
1455
1456 (defun elpher-gemini-insert-link (link-line)
1457   "Insert link described by LINK-LINE into a text/gemini document."
1458   (let* ((url (elpher-gemini-get-link-url link-line))
1459          (display-string (elpher-gemini-get-link-display-string link-line))
1460          (address (elpher-address-from-gemini-url url))
1461          (type (if address (elpher-address-type address) nil))
1462          (type-map-entry (cdr (assoc type elpher-type-map))))
1463     (when display-string
1464       (insert elpher-gemini-link-string)
1465       (if type-map-entry
1466           (let* ((face (elt type-map-entry 3))
1467                  (filtered-display-string (elpher-color-filter-apply display-string))
1468                  (page (elpher-make-page filtered-display-string address)))
1469             (insert-text-button filtered-display-string
1470                                 'face face
1471                                 'elpher-page page
1472                                 'action #'elpher-click-link
1473                                 'follow-link t
1474                                 'help-echo #'elpher--page-button-help))
1475         (insert (propertize display-string 'face 'elpher-unknown)))
1476       (insert "\n"))))
1477
1478 (defvar elpher--gemini-page-headings nil
1479   "List of headings on the page.")
1480
1481 (defun elpher-gemini-insert-header (header-line)
1482   "Insert header described by HEADER-LINE into a text/gemini document.
1483 The gemini map file line describing the header is given
1484 by HEADER-LINE."
1485   (when (string-match "^\\(#+\\)[ \t]*" header-line)
1486     (let* ((level (length (match-string 1 header-line)))
1487            (header (substring header-line (match-end 0)))
1488            (face (pcase level
1489                    (1 'elpher-gemini-heading1)
1490                    (2 'elpher-gemini-heading2)
1491                    (3 'elpher-gemini-heading3)
1492                    (_ 'default)))
1493            (fill-column (if (display-graphic-p)
1494                             (/ (* fill-column
1495                                   (font-get (font-spec :name (face-font 'default)) :size))
1496                                (font-get (font-spec :name (face-font face)) :size)) fill-column)))
1497       (setq elpher--gemini-page-headings (cons (cons header (point))
1498                                                elpher--gemini-page-headings))
1499       (unless (display-graphic-p)
1500         (insert (make-string level ?#) " "))
1501       (insert (propertize header 'face face))
1502       (newline))))
1503
1504 (defun elpher-gemini-insert-text (text-line)
1505   "Insert a plain non-preformatted TEXT-LINE into a text/gemini document.
1506 This function uses Emacs' auto-fill to wrap text sensibly to a maximum
1507 width defined by `elpher-gemini-max-fill-width'."
1508   (string-match "\\(^[ \t]*\\)\\(\\*[ \t]+\\|>[ \t]*\\)?" text-line)
1509   (let* ((line-prefix (match-string 2 text-line))
1510          (processed-text-line
1511           (if line-prefix
1512               (cond ((string-prefix-p "*" line-prefix)
1513                      (concat
1514                       (replace-regexp-in-string "\\*"
1515                                                 elpher-gemini-bullet-string
1516                                                 (match-string 0 text-line))
1517                       (substring text-line (match-end 0))))
1518                     ((string-prefix-p ">" line-prefix)
1519                      (propertize text-line 'face 'elpher-gemini-quoted))
1520                     (t text-line))
1521             text-line))
1522          (adaptive-fill-mode t)
1523          ;; fill-prefix is important for adaptive-fill-mode: without
1524          ;; it, multi-line list items are not indented correct
1525          (fill-prefix (if (match-string 2 text-line)
1526                           (replace-regexp-in-string "[>\*]" " " (match-string 0 text-line))
1527                         nil)))
1528     (insert (elpher-process-text-for-display processed-text-line))
1529     (newline)))
1530
1531 (defun elpher-render-gemini-map (data _parameters)
1532   "Render DATA as a gemini map file, PARAMETERS is currently unused."
1533   (elpher-with-clean-buffer
1534    (setq elpher--gemini-page-headings nil)
1535    (let ((preformatted nil))
1536      (auto-fill-mode 1)
1537      (setq-local fill-column (min (window-width) elpher-gemini-max-fill-width))
1538      (dolist (line (split-string data "\n"))
1539        (cond
1540         ((string-prefix-p "```" line) (setq preformatted (not preformatted)))
1541         (preformatted (insert (elpher-process-text-for-display
1542                                (propertize line 'face 'elpher-gemini-preformatted))
1543                               "\n"))
1544         ((string-prefix-p "=>" line)
1545          (elpher-gemini-insert-link line))
1546         ((string-prefix-p "#" line) (elpher-gemini-insert-header line))
1547         (t (elpher-gemini-insert-text line)))))
1548    (setq elpher--gemini-page-headings (nreverse elpher--gemini-page-headings))
1549    (elpher-cache-content
1550     (elpher-page-address elpher-current-page)
1551     (buffer-string))))
1552
1553 (defun elpher-render-gemini-plain-text (data _parameters)
1554   "Render DATA as plain text file.  PARAMETERS is currently unused."
1555   (elpher-with-clean-buffer
1556    (insert (elpher-process-text-for-display data))
1557    (elpher-cache-content
1558     (elpher-page-address elpher-current-page)
1559     (buffer-string))))
1560
1561
1562 ;; Finger page connection
1563
1564 (defun elpher-get-finger-page (renderer)
1565   "Opens a finger connection to the current page address.
1566 The result is rendered using RENDERER."
1567   (let* ((address (elpher-page-address elpher-current-page))
1568          (content (elpher-get-cached-content address)))
1569     (if (and content (funcall renderer nil))
1570         (elpher-with-clean-buffer
1571          (insert content)
1572          (elpher-restore-pos))
1573       (elpher-with-clean-buffer
1574        (insert "LOADING... (use 'u' to cancel)\n"))
1575       (condition-case the-error
1576           (let* ((kill-buffer-query-functions nil)
1577                  (user (let ((filename (elpher-address-filename address)))
1578                          (if (> (length filename) 1)
1579                              (substring filename 1)
1580                            (elpher-address-user address)))))
1581             (elpher-get-host-response address 79
1582                                       (concat user "\r\n")
1583                                       renderer))
1584         (error
1585          (elpher-network-error address the-error))))))
1586
1587
1588 ;; Telnet page connection
1589
1590 (defun elpher-get-telnet-page (renderer)
1591   "Opens a telnet connection to the current page address (RENDERER must be nil)."
1592   (when renderer
1593     (elpher-visit-previous-page)
1594     (error "Command not supported for telnet URLs"))
1595   (let* ((address (elpher-page-address elpher-current-page))
1596          (host (elpher-address-host address))
1597          (port (elpher-address-port address)))
1598     (elpher-visit-previous-page)
1599     (if (> port 0)
1600         (telnet host port)
1601       (telnet host))))
1602
1603
1604 ;; Other URL page opening
1605
1606 (defun elpher-get-other-url-page (renderer)
1607   "Getter which attempts to open the URL specified by the current page (RENDERER must be nil)."
1608   (when renderer
1609     (elpher-visit-previous-page)
1610     (error "Command not supported for general URLs"))
1611   (let* ((address (elpher-page-address elpher-current-page))
1612          (url (elpher-address-to-url address)))
1613     (progn
1614       (elpher-visit-previous-page) ; Do first in case of non-local exits.
1615       (message "Opening URL...")
1616       (if elpher-open-urls-with-eww
1617           (browse-web url)
1618         (browse-url url)))))
1619
1620
1621 ;; Start page retrieval
1622
1623 (defun elpher-get-start-page (renderer)
1624   "Getter which displays the start page (RENDERER must be nil)."
1625   (when renderer
1626     (elpher-visit-previous-page)
1627     (error "Command not supported for start page"))
1628   (elpher-with-clean-buffer
1629    (insert "     --------------------------------------------\n"
1630            "           Elpher Gopher and Gemini Client       \n"
1631            "                   version " elpher-version "\n"
1632            "     --------------------------------------------\n"
1633            "\n"
1634            "Default bindings:\n"
1635            "\n"
1636            " - TAB/Shift-TAB: next/prev item on current page\n"
1637            " - RET/mouse-1: open item under cursor\n"
1638            " - m: select an item on current page by name (autocompletes)\n"
1639            " - u/mouse-3/U: return to previous page or to the start page\n"
1640            " - g: go to a particular address (gopher, gemini, finger)\n"
1641            " - o/O: open a different address selector or the root menu of the current server\n"
1642            " - d/D: download item under cursor or current page\n"
1643            " - i/I: info on item under cursor or current page\n"
1644            " - c/C: copy URL representation of item under cursor or current page\n"
1645            " - a/A: bookmark the item under cursor or current page\n"
1646            " - B: list all bookmarks\n"
1647            " - s/S: show current history stack or all previously visted pages\n"
1648            " - r: redraw current page (using cached contents if available)\n"
1649            " - R: reload current page (regenerates cache)\n"
1650            " - !: set character coding system for gopher (default is to autodetect)\n"
1651            " - T: toggle TLS gopher mode\n"
1652            " - F: forget/discard current TLS client certificate\n"
1653            " - .: display the raw server response for the current page\n"
1654            "\n"
1655            "Start your exploration of gopher space and gemini:\n")
1656    (elpher-insert-index-record "Floodgap Systems Gopher Server"
1657                                (elpher-make-gopher-address ?1 "" "gopher.floodgap.com" 70))
1658    (elpher-insert-index-record "Project Gemini home page"
1659                                (elpher-address-from-url "gemini://gemini.circumlunar.space/"))
1660    (insert "\n"
1661            "Alternatively, select a search engine and enter some search terms:\n")
1662    (elpher-insert-index-record "Gopher Search Engine (Veronica-2)"
1663                                (elpher-make-gopher-address ?7 "/v2/vs" "gopher.floodgap.com" 70))
1664    (elpher-insert-index-record "Gemini Search Engine (geminispace.info)"
1665                                (elpher-address-from-url "gemini://geminispace.info/search"))
1666    (insert "\n"
1667            "Your bookmarks are stored in your ")
1668    (let ((help-string "RET,mouse-1: Open Emacs bookmark list"))
1669      (insert-text-button "Emacs bookmark list"
1670                          'face 'link
1671                          'action (lambda (_)
1672                                    (interactive)
1673                                    (call-interactively #'elpher-open-bookmarks))
1674                          'follow-link t
1675                          'help-echo help-string))
1676    (insert ".\n")
1677    (insert (propertize
1678             "(Bookmarks from legacy elpher-bookmarks files will be automatically imported.)\n"
1679             'face 'shadow))
1680    (insert "\n"
1681            "The gopher home of the Elpher project is here:\n")
1682    (elpher-insert-index-record "The Elpher Project Page"
1683                                (elpher-make-gopher-address ?1
1684                                                            "/projects/elpher/"
1685                                                            "thelambdalab.xyz"
1686                                                            70))
1687    (let ((help-string "RET,mouse-1: Open Elpher info manual (if available)"))
1688      (insert "\n"
1689              "The following info documentation is available:\n"
1690              "   - ")
1691      (insert-text-button "Elpher Manual"
1692                          'face 'link
1693                          'action (lambda (_)
1694                                    (interactive)
1695                                    (info "(elpher)"))
1696                          'follow-link t
1697                          'help-echo help-string)
1698      (insert "\n   - ")
1699      (insert-text-button "Changes introduced by the latest release"
1700                        'face 'link
1701                        'action (lambda (_)
1702                                  (interactive)
1703                                  (info "(elpher)News"))
1704                        'follow-link t
1705                        'help-echo help-string))
1706    (insert "\n")
1707    (insert (propertize
1708             (concat "  (These documents should be available if you have installed Elpher \n"
1709                     "   using MELPA. Otherwise you may have to install the manual yourself.)\n")
1710             'face 'shadow))
1711    (elpher-restore-pos)))
1712
1713 ;; History page retrieval
1714
1715 (defun elpher-show-history ()
1716   "Show the current contents of elpher's history stack.
1717 Use \\[elpher-show-visited-pages] to see the entire history.
1718 This is rendered using `elpher-get-history-page' via `elpher-type-map'."
1719   (interactive)
1720   (elpher-visit-page
1721    (elpher-make-page "Current History Stack"
1722                      (elpher-make-special-address 'history))))
1723
1724 (defun elpher-show-visited-pages ()
1725   "Show the all the pages you've visited using Elpher.
1726 Use \\[elpher-show-history] to see just the current history stack.
1727 This is rendered using `elpher-get-visited-pages-page' via `elpher-type-map'."
1728   (interactive)
1729   (elpher-visit-page
1730    (elpher-make-page "Elpher Visted Pages"
1731                      (elpher-make-special-address 'visited-pages))))
1732
1733 (defun elpher-get-history-page (renderer)
1734   "Getter which displays the history page (RENDERER must be nil)."
1735   (when renderer
1736     (elpher-visit-previous-page)
1737     (error "Command not supported for history page"))
1738   (elpher-display-history-links elpher-history "Current history stack"))
1739
1740 (defun elpher-get-visited-pages-page (renderer)
1741   "Getter which displays the list of visited pages (RENDERER must be nil)."
1742   (when renderer
1743     (elpher-visit-previous-page)
1744     (error "Command not supported for history page"))
1745   (elpher-display-history-links
1746    (seq-filter (lambda (page)
1747                  (not (elpher-address-special-p (elpher-page-address page))))
1748                elpher-visited-pages)
1749    "All visited pages"))
1750
1751 (defun elpher-display-history-links (pages title)
1752   "Show all PAGES in an Elpher buffer with a given TITLE."
1753   (let* ((title-line (concat "---- " title " ----"))
1754          (footer-line (make-string (length title-line) ?-)))
1755     (elpher-with-clean-buffer
1756      (insert title-line "\n\n")
1757      (if pages
1758          (dolist (page pages)
1759            (when page
1760              (let ((display-string (elpher-page-display-string page))
1761                    (address (elpher-page-address page)))
1762                (elpher-insert-index-record display-string address))))
1763        (insert "No history items found.\n"))
1764      (insert "\n" footer-line "\n"
1765              "Select and entry or press 'u' to return to the previous page.")
1766      (elpher-restore-pos))))
1767
1768
1769 ;;; Bookmarks
1770
1771 ;; This code allows Elpher to use the standard Emacs bookmarks: `C-x r
1772 ;; m' to add a bookmark, `C-x r l' to list bookmarks (which is where
1773 ;; you can anotate bookmarks!), `C-x r b' to jump to a bookmark, and
1774 ;; so on. See the Bookmarks section in the Emacs info manual for more.
1775
1776 (defvar elpher-bookmark-link nil
1777   "Prefer bookmarking a link or the current page.
1778 Bind this variable dynamically, or set it to t.
1779 If you set it to t, the commands \\[bookmark-set-no-overwrite]
1780 and \\[elpher-set-bookmark-no-overwrite] do the same thing.")
1781
1782 (defun elpher-bookmark-make-record ()
1783   "Return a bookmark record.
1784 If `elpher-bookmark-link' is non-nil and point is on a link button,
1785 return a bookmark record for that link.  Otherwise, return a bookmark
1786 record for the current elpher page."
1787   (let* ((button (and elpher-bookmark-link (button-at (point))))
1788          (page (if button
1789                    (button-get button 'elpher-page)
1790                  elpher-current-page))
1791          (address (elpher-page-address page))
1792          (url (elpher-address-to-url address))
1793          (display-string (elpher-page-display-string page))
1794          (pos (if button nil (point))))
1795     (if (elpher-address-special-p address)
1796         (error "Cannot bookmark %s" display-string)
1797       `(,display-string
1798         (defaults . (,display-string))
1799         (position . ,pos)
1800         (location . ,url)
1801         (handler . elpher-bookmark-jump)))))
1802
1803 ;;;###autoload
1804 (defun elpher-bookmark-jump (bookmark)
1805   "Handler used to open a bookmark using elpher.
1806 The argument BOOKMARK is a bookmark record passed to the function.
1807 This handler is responsible for loading the bookmark in some buffer,
1808 then making that buffer the current buffer.  It should not switch
1809 to the buffer."
1810   (let* ((url (cdr (assq 'location bookmark)))
1811          (cleaned-url (string-trim url))
1812          (address (elpher-address-from-url cleaned-url))
1813          (page (elpher-make-page cleaned-url address)))
1814     (elpher-with-clean-buffer
1815      (elpher-visit-page page))
1816     (set-buffer (get-buffer elpher-buffer-name))
1817     nil))
1818
1819 (defun elpher-bookmark-link ()
1820   "Bookmark the link at point.
1821 To bookmark the current page, use \\[elpher-bookmark-current]."
1822   (interactive)
1823   (let ((elpher-bookmark-link t))
1824     (bookmark-set-no-overwrite)))
1825
1826 (defun elpher-bookmark-current ()
1827   "Bookmark the current page.
1828 To bookmark the link at point use \\[elpher-bookmark-link]."
1829   (interactive)
1830   (call-interactively #'bookmark-set-no-overwrite))
1831
1832 (defun elpher-bookmark-import (file)
1833   "Import legacy Elpher bookmarks file FILE into Emacs bookmarks."
1834   (interactive (list (if (and (boundp 'elpher-bookmarks-file)
1835                               (file-readable-p elpher-bookmarks-file))
1836                          elpher-bookmarks-file
1837                        (read-file-name "Old Elpher bookmarks: "
1838                                        user-emacs-directory nil t
1839                                        "elpher-bookmarks"))))
1840   (require 'bookmark)
1841   (dolist (bookmark (with-temp-buffer
1842                       (insert-file-contents file)
1843                       (read (current-buffer))))
1844     (let* ((display-string (car bookmark))
1845            (url (cadr bookmark))
1846            (record `(,display-string
1847                      (location . ,url)
1848                      (handler . elpher-bookmark-jump))))
1849       (bookmark-store display-string (cdr record) t)))
1850   (bookmark-save))
1851
1852 (defun elpher-open-bookmarks ()
1853   "Display the current list of elpher bookmarks.
1854 This is just a call to `bookmark-bmenu-list', but we also check for a legacy
1855 bookmark file and offer to import it."
1856   (interactive)
1857   (let ((old-bookmarks-file (or (and (boundp 'elpher-bookmarks-file)
1858                                      elpher-bookmarks-file)
1859                                 (locate-user-emacs-file "elpher-bookmarks"))))
1860     (when (and (file-readable-p old-bookmarks-file)
1861                (y-or-n-p (concat "Legacy elpher-bookmarks file \""
1862                                  old-bookmarks-file
1863                                  "\" found. Import now?")))
1864       (elpher-bookmark-import old-bookmarks-file)
1865       (rename-file old-bookmarks-file (concat old-bookmarks-file "-legacy"))))
1866   (call-interactively #'bookmark-bmenu-list))
1867
1868
1869 ;;; Integrations
1870 ;;
1871
1872 ;;; Org
1873
1874 (defun elpher-org-export-link (link description format protocol)
1875   "Export a LINK with DESCRIPTION for the given PROTOCOL and FORMAT.
1876
1877 FORMAT is an Org export backend.  DESCRIPTION may be nil.  PROTOCOL may be one
1878 of gemini, gopher or finger."
1879   (let* ((url (if (equal protocol "elpher")
1880                   (string-remove-prefix "elpher:" link)
1881                 (format "%s:%s" protocol link)))
1882          (desc (or description url)))
1883     (pcase format
1884       (`gemini (format "=> %s %s" url desc))
1885       (`html (format "<a href=\"%s\">%s</a>" url desc))
1886       (`latex (format "\\href{%s}{%s}" url desc))
1887       (_ (if (not description)
1888              url
1889            (format "%s (%s)" desc url))))))
1890
1891 (defun elpher-org-store-link ()
1892   "Store link to an `elpher' page in Org."
1893   (when (eq major-mode 'elpher-mode)
1894     (let* ((url (elpher-info-current))
1895            (desc (car elpher-current-page))
1896            (protocol (cond
1897                       ((string-prefix-p "gemini:" url) "gemini")
1898                       ((string-prefix-p "gopher:" url) "gopher")
1899                       ((string-prefix-p "finger:" url) "finger")
1900                       (t "elpher"))))
1901       (when (equal "elpher" protocol)
1902         ;; Weird link. Or special inner link?
1903         (setq url (concat "elpher:" url)))
1904       (org-link-store-props :type protocol :link url :description desc)
1905       t)))
1906
1907 (defun elpher-org-follow-link (link protocol)
1908   "Visit a LINK for the given PROTOCOL.
1909
1910 PROTOCOL may be one of gemini, gopher or finger.  This method also
1911 supports the old protocol elpher, where the link is self-contained."
1912   (let ((url (if (equal protocol "elpher")
1913                  (string-remove-prefix "elpher:" link)
1914                (format "%s:%s" protocol link))))
1915     (elpher-go url)))
1916
1917 (defun elpher-org-mode-integration ()
1918   "Set up `elpher' integration for `org-mode'."
1919   (org-link-set-parameters
1920    "elpher"
1921    :store #'elpher-org-store-link
1922    :export (lambda (link description format _plist)
1923              (elpher-org-export-link link description format "elpher"))
1924    :follow (lambda (link _arg) (elpher-org-follow-link link "elpher")))
1925   (org-link-set-parameters
1926    "gemini"
1927    :export (lambda (link description format _plist)
1928              (elpher-org-export-link link description format "gemini"))
1929    :follow (lambda (link _arg) (elpher-org-follow-link link "gemini")))
1930   (org-link-set-parameters
1931    "gopher"
1932    :export (lambda (link description format _plist)
1933              (elpher-org-export-link link description format "gopher"))
1934    :follow (lambda (link _arg) (elpher-org-follow-link link "gopher")))
1935   (org-link-set-parameters
1936    "finger"
1937    :export (lambda (link description format _plist)
1938              (elpher-org-export-link link description format "finger"))
1939    :follow (lambda (link _arg) (elpher-org-follow-link link "finger"))))
1940
1941 (add-hook 'org-mode-hook #'elpher-org-mode-integration)
1942
1943 ;;; Browse URL
1944
1945 ;;;###autoload
1946 (defun elpher-browse-url-elpher (url &rest _args)
1947   "Browse URL using Elpher.  This function is used by `browse-url'."
1948   (interactive (browse-url-interactive-arg "Elpher URL: "))
1949   (elpher-go url))
1950
1951 ;; Use elpher to open gopher, finger and gemini links
1952 ;; For recent version of `browse-url' package
1953 (if (boundp 'browse-url-default-handlers)
1954     (add-to-list
1955      'browse-url-default-handlers
1956      '("^\\(gopher\\|finger\\|gemini\\)://" . elpher-browse-url-elpher))
1957   ;; Patch `browse-url-browser-function' for older ones. The value of
1958   ;; that variable is `browse-url-default-browser' by default, so
1959   ;; that's the function that gets advised.
1960   (advice-add browse-url-browser-function :before-while
1961               (lambda (url &rest _args)
1962                 "Handle gemini, gopher, and finger schemes using Elpher."
1963                 (let ((scheme (downcase (car (split-string url ":" t)))))
1964                   (if (member scheme '("gemini" "gopher" "finger"))
1965                       ;; `elpher-go' always returns nil, which will stop the
1966                       ;; advice chain here in a before-while
1967                       (elpher-go url)
1968                     ;; chain must continue, then return t.
1969                     t)))))
1970
1971 ;; Register "gemini://" as a URI scheme so `browse-url' does the right thing
1972 (with-eval-after-load 'thingatpt
1973   (add-to-list 'thing-at-point-uri-schemes "gemini://"))
1974
1975 ;;; Mu4e:
1976
1977 ;; Make mu4e aware of the gemini world
1978 (setq mu4e~view-beginning-of-url-regexp
1979       "\\(?:https?\\|gopher\\|finger\\|gemini\\)://\\|mailto:")
1980
1981 ;;; Interactive procedures
1982 ;;
1983
1984 (defun elpher-next-link ()
1985   "Move point to the next link on the current page."
1986   (interactive)
1987   (forward-button 1))
1988
1989 (defun elpher-prev-link ()
1990   "Move point to the previous link on the current page."
1991   (interactive)
1992   (backward-button 1))
1993
1994 (defun elpher-follow-current-link ()
1995   "Open the link or url at point."
1996   (interactive)
1997   (push-button))
1998
1999 ;;;###autoload
2000 (defun elpher-go (host-or-url)
2001   "Go to a particular gopher site HOST-OR-URL.
2002 When run interactively HOST-OR-URL is read from the minibuffer."
2003   (interactive "sGopher or Gemini URL: ")
2004   (let* ((cleaned-host-or-url (string-trim host-or-url))
2005          (address (elpher-address-from-url cleaned-host-or-url))
2006          (page (elpher-make-page cleaned-host-or-url address)))
2007     (switch-to-buffer elpher-buffer-name)
2008     (elpher-with-clean-buffer
2009      (elpher-visit-page page))
2010     nil))
2011
2012 (defun elpher-go-current ()
2013   "Go to a particular site read from the minibuffer, initialized with the current URL."
2014   (interactive)
2015   (let ((address (elpher-page-address elpher-current-page)))
2016     (let ((url (read-string "Gopher or Gemini URL: "
2017                             (unless (elpher-address-special-p address)
2018                               (elpher-address-to-url address)))))
2019       (elpher-visit-page (elpher-make-page url (elpher-address-from-url url))))))
2020
2021 (defun elpher-redraw ()
2022   "Redraw current page."
2023   (interactive)
2024   (elpher-visit-page elpher-current-page))
2025
2026 (defun elpher-reload ()
2027   "Reload current page."
2028   (interactive)
2029   (elpher-reload-current-page))
2030
2031 (defun elpher-toggle-tls ()
2032   "Toggle TLS encryption mode for gopher."
2033   (interactive)
2034   (setq elpher-use-tls (not elpher-use-tls))
2035   (if elpher-use-tls
2036       (if (gnutls-available-p)
2037           (message "TLS gopher mode enabled.  (Will not affect current page until reload.)")
2038         (setq elpher-use-tls nil)
2039         (error "Cannot enable TLS gopher mode: GnuTLS not available"))
2040     (message "TLS gopher mode disabled.  (Will not affect current page until reload.)")))
2041
2042 (defun elpher-view-raw ()
2043   "View raw server response for current page."
2044   (interactive)
2045   (if (elpher-address-special-p (elpher-page-address elpher-current-page))
2046       (error "This page was not generated by a server")
2047     (elpher-visit-page elpher-current-page
2048                        #'elpher-render-raw)))
2049
2050 (defun elpher-back ()
2051   "Go to previous site."
2052   (interactive)
2053   (elpher-visit-previous-page))
2054
2055 (defun elpher-back-to-start ()
2056   "Go all the way back to the start page."
2057   (interactive)
2058   (setq-local elpher-current-page nil)
2059   (setq-local elpher-history nil)
2060   (elpher-visit-page (elpher-make-start-page)))
2061
2062 (defun elpher-download ()
2063   "Download the link at point."
2064   (interactive)
2065   (let ((button (button-at (point))))
2066     (if button
2067         (let ((page (button-get button 'elpher-page)))
2068           (if (elpher-address-special-p (elpher-page-address page))
2069               (error "Cannot download %s"
2070                      (elpher-page-display-string page))
2071             (elpher-visit-page (button-get button 'elpher-page)
2072                                #'elpher-render-download)))
2073       (error "No link selected"))))
2074
2075 (defun elpher-download-current ()
2076   "Download the current page."
2077   (interactive)
2078   (if (elpher-address-special-p (elpher-page-address elpher-current-page))
2079       (error "Cannot download %s"
2080              (elpher-page-display-string elpher-current-page))
2081     (elpher-visit-page (elpher-make-page
2082                         (elpher-page-display-string elpher-current-page)
2083                         (elpher-page-address elpher-current-page))
2084                        #'elpher-render-download
2085                        t)))
2086
2087 (defun elpher--build-link-map ()
2088   "Build alist mapping link names to destination pages in current buffer."
2089   (let ((link-map nil)
2090         (b (next-button (point-min) t)))
2091     (while b
2092       (push (cons (button-label b) b) link-map)
2093       (setq b (next-button (button-start b))))
2094     link-map))
2095
2096 (defun elpher-jump ()
2097   "Select a directory entry by name.  Similar to the info browser (m)enu command."
2098   (interactive)
2099   (let* ((link-map (elpher--build-link-map)))
2100     (if link-map
2101         (let ((key (let ((completion-ignore-case t))
2102                      (completing-read "Directory item/link: "
2103                                       link-map nil t))))
2104           (if (and key (> (length key) 0))
2105               (let ((b (cdr (assoc key link-map))))
2106                 (goto-char (button-start b))
2107                 (button-activate b)))))))
2108
2109 (defun elpher-root-dir ()
2110   "Visit root of current server."
2111   (interactive)
2112   (let ((address (elpher-page-address elpher-current-page)))
2113     (if (not (elpher-address-special-p address))
2114         (if (or (member (url-filename address) '("/" ""))
2115                 (and (elpher-address-gopher-p address)
2116                      (= (length (elpher-gopher-address-selector address)) 0)))
2117             (error "Already at root directory of current server")
2118           (let ((address-copy (elpher-address-from-url
2119                                (elpher-address-to-url address))))
2120             (setf (url-filename address-copy) "")
2121             (elpher-go (elpher-address-to-url address-copy))))
2122       (error "Command invalid for %s" (elpher-page-display-string elpher-current-page)))))
2123
2124 (defun elpher-info-page (page)
2125   "Display information on PAGE."
2126   (let ((display-string (elpher-page-display-string page))
2127         (address (elpher-page-address page)))
2128     (if (elpher-address-special-p address)
2129         (message "Special page: %s" display-string)
2130       (message "%s" (elpher-address-to-url address)))))
2131
2132 (defun elpher-info-link ()
2133   "Display information on page corresponding to link at point."
2134   (interactive)
2135   (let ((button (button-at (point))))
2136     (if button
2137         (elpher-info-page (button-get button 'elpher-page))
2138       (error "No item selected"))))
2139
2140 (defun elpher-info-current ()
2141   "Display information on current page."
2142   (interactive)
2143   (elpher-info-page elpher-current-page))
2144
2145 (defun elpher-copy-page-url (page)
2146   "Copy URL representation of address of PAGE to `kill-ring'."
2147   (let ((address (elpher-page-address page)))
2148     (if (elpher-address-special-p address)
2149         (error (format "Cannot represent %s as URL" (elpher-page-display-string page)))
2150       (let ((url (elpher-address-to-url address)))
2151         (message "Copied \"%s\" to kill-ring/clipboard." url)
2152         (kill-new url)))))
2153
2154 (defun elpher-copy-link-url ()
2155   "Copy URL of item at point to `kill-ring'."
2156   (interactive)
2157   (let ((button (button-at (point))))
2158     (if button
2159         (elpher-copy-page-url (button-get button 'elpher-page))
2160       (error "No item selected"))))
2161
2162 (defun elpher-copy-current-url ()
2163   "Copy URL of current page to `kill-ring'."
2164   (interactive)
2165   (elpher-copy-page-url elpher-current-page))
2166
2167 (defun elpher-set-gopher-coding-system ()
2168   "Specify an explicit character coding system for gopher selectors."
2169   (interactive)
2170   (let ((system (read-coding-system "Set coding system to use for gopher (default is to autodetect): " nil)))
2171     (setq elpher-user-coding-system system)
2172     (if system
2173         (message "Gopher coding system fixed to %s. (Reload to see effect)." system)
2174       (message "Gopher coding system set to autodetect. (Reload to see effect)."))))
2175
2176
2177 ;;; Mode and keymap
2178 ;;
2179
2180 (defvar elpher-mode-map
2181   (let ((map (make-sparse-keymap)))
2182     (define-key map (kbd "TAB") 'elpher-next-link)
2183     (define-key map (kbd "<backtab>") 'elpher-prev-link)
2184     (define-key map (kbd "C-M-i") 'elpher-prev-link)
2185     (define-key map (kbd "u") 'elpher-back)
2186     (define-key map (kbd "-") 'elpher-back)
2187     (define-key map (kbd "^") 'elpher-back)
2188     (define-key map [mouse-3] 'elpher-back)
2189     (define-key map (kbd "U") 'elpher-back-to-start)
2190     (define-key map (kbd "g") 'elpher-go)
2191     (define-key map (kbd "o") 'elpher-go-current)
2192     (define-key map (kbd "O") 'elpher-root-dir)
2193     (define-key map (kbd "s") 'elpher-show-history)
2194     (define-key map (kbd "S") 'elpher-show-visited-pages)
2195     (define-key map (kbd "r") 'elpher-redraw)
2196     (define-key map (kbd "R") 'elpher-reload)
2197     (define-key map (kbd "T") 'elpher-toggle-tls)
2198     (define-key map (kbd ".") 'elpher-view-raw)
2199     (define-key map (kbd "d") 'elpher-download)
2200     (define-key map (kbd "D") 'elpher-download-current)
2201     (define-key map (kbd "m") 'elpher-jump)
2202     (define-key map (kbd "i") 'elpher-info-link)
2203     (define-key map (kbd "I") 'elpher-info-current)
2204     (define-key map (kbd "c") 'elpher-copy-link-url)
2205     (define-key map (kbd "C") 'elpher-copy-current-url)
2206     (define-key map (kbd "a") 'elpher-bookmark-link)
2207     (define-key map (kbd "A") 'elpher-bookmark-current)
2208     (define-key map (kbd "B") 'elpher-open-bookmarks)
2209     (define-key map (kbd "!") 'elpher-set-gopher-coding-system)
2210     (define-key map (kbd "F") 'elpher-forget-current-certificate)
2211     (when (fboundp 'evil-define-key*)
2212       (evil-define-key*
2213        'motion map
2214        (kbd "TAB") 'elpher-next-link
2215        (kbd "C-") 'elpher-follow-current-link
2216        (kbd "C-t") 'elpher-back
2217        (kbd "u") 'elpher-back
2218        (kbd "-") 'elpher-back
2219        (kbd "^") 'elpher-back
2220        [mouse-3] 'elpher-back
2221        (kbd "U") 'elpher-back-to-start
2222        (kbd "g") 'elpher-go
2223        (kbd "o") 'elpher-go-current
2224        (kbd "O") 'elpher-root-dir
2225        (kbd "s") 'elpher-show-history
2226        (kbd "S") 'elpher-show-visited-pages
2227        (kbd "r") 'elpher-redraw
2228        (kbd "R") 'elpher-reload
2229        (kbd "T") 'elpher-toggle-tls
2230        (kbd ".") 'elpher-view-raw
2231        (kbd "d") 'elpher-download
2232        (kbd "D") 'elpher-download-current
2233        (kbd "m") 'elpher-jump
2234        (kbd "i") 'elpher-info-link
2235        (kbd "I") 'elpher-info-current
2236        (kbd "c") 'elpher-copy-link-url
2237        (kbd "C") 'elpher-copy-current-url
2238        (kbd "a") 'elpher-bookmark-link
2239        (kbd "A") 'elpher-bookmark-current
2240        (kbd "B") 'elpher-open-bookmarks
2241        (kbd "!") 'elpher-set-gopher-coding-system
2242        (kbd "F") 'elpher-forget-current-certificate))
2243     map)
2244   "Keymap for gopher client.")
2245
2246 (define-derived-mode elpher-mode special-mode "elpher"
2247   "Major mode for elpher, an elisp gopher client.
2248
2249 This mode is automatically enabled by the interactive
2250 functions which initialize the client, namely
2251 `elpher', and `elpher-go'."
2252   (setq-local elpher--gemini-page-headings nil)
2253   (setq-local elpher-current-page nil)
2254   (setq-local elpher-history nil)
2255   (setq-local elpher-buffer-name (buffer-name))
2256   (setq-local bookmark-make-record-function #'elpher-bookmark-make-record)
2257   (setq-local imenu-create-index-function (lambda () elpher--gemini-page-headings))
2258   (setq-local xterm-color-preserve-properties t))
2259
2260 (when (fboundp 'evil-set-initial-state)
2261   (evil-set-initial-state 'elpher-mode 'motion))
2262
2263
2264 ;;; Main start procedure
2265 ;;
2266
2267 ;;;###autoload
2268 (defun elpher (&optional arg)
2269   "Start elpher with default landing page.
2270 The buffer used for Elpher sessions is determined by the value of
2271 ‘elpher-buffer-name’.  If there is already an Elpher session active in
2272 that buffer, Emacs will simply switch to it.  Otherwise, a new session
2273 will begin.  A numeric prefix ARG (as in ‘\\[universal-argument] 42
2274 \\[execute-extended-command] elpher RET’) switches to the session with
2275 that number, creating it if necessary.  A non numeric prefix ARG means
2276 to create a new session.  Returns the buffer selected (or created)."
2277   (interactive "P")
2278   (let* ((name (default-value 'elpher-buffer-name))
2279          (buf (cond ((numberp arg)
2280                      (get-buffer-create (format "%s<%d>" name arg)))
2281                     (arg
2282                      (generate-new-buffer name))
2283                     (t
2284                      (get-buffer-create name)))))
2285     (pop-to-buffer-same-window buf)
2286     (unless (buffer-modified-p)
2287       (elpher-mode)
2288       (elpher-visit-page (elpher-make-start-page))
2289       "Started Elpher."))); Otherwise (elpher) evaluates to start page string.
2290
2291 ;;; elpher.el ends here