Change awk to gawk
[bollux.git/.git] / bollux
1 #!/usr/bin/env bash
2 # bollux: a bash gemini client or whatever
3 # Author: Case Duckworth <acdw@acdw.net>
4 # License: MIT
5 # Version: -0.7
6
7 # set -euo pipefail              # strict mode
8
9 ### constants ###
10 PRGN="${0##*/}"                # program name
11 DLDR="${BOLLUX_DOWNDIR:=.}"    # where to download
12 LOGL="${BOLLUX_LOGLEVEL:=3}"   # log level
13 MAXR="${BOLLUX_MAXREDIR:=5}"   # max redirects
14 PORT="${BOLLUX_PORT:=1965}"    # port number
15 PROT="${BOLLUX_PROTO:=gemini}" # protocol
16 RDRS=0                         # redirects
17 VRSN=-0.7                      # version number
18
19 # shellcheck disable=2120
20 bollux_usage() {
21         cat <<END_USAGE >&2
22         $PRGN ($VRSN): a bash gemini client
23         usage:
24                 $PRGN [-h]
25                 $PRGN [-L LVL] [URL]
26         options:
27                 -h      show this help
28                 -L LVL  set the loglevel to LVL.
29                         Default: $BOLLUX_LOGLEVEL
30                         The loglevel is between 0 and 5, with
31                         lower levels being more dire.
32         parameters:
33                 URL     the URL to navigate view or download
34 END_USAGE
35         exit "${1:-0}"
36 }
37
38 # LOGLEVELS:
39 # 0 - application fatal error
40 # 1 - application warning
41 # 2 - response error
42 # 3 - response logging
43 # 4 - application logging
44 # 5 - diagnostic
45
46 ### utility functions ###
47 # a better echo
48 put() { printf '%s\n' "$*"; }
49
50 # conditionally log events to stderr
51 # lower = more important
52 log() { # log [LEVEL] [<] MESSAGE
53         case "$1" in
54         -)
55                 lvl="-1"
56                 shift
57                 ;;
58         [0-5])
59                 lvl="$1"
60                 shift
61                 ;;
62         *) lvl=4 ;;
63         esac
64
65         output="$*"
66         if ((lvl < LOGL)); then
67                 if (($# == 0)); then
68                         while IFS= read -r line; do
69                                 output="$output${output:+$'\n'}$line"
70                         done
71                 fi
72                 printf '\e[34m%s\e[0m:\t%s\n' "$PRGN" "$output" >&2
73         fi
74 }
75
76 # halt and catch fire
77 die() { # die [EXIT-CODE] MESSAGE
78         case "$1" in
79         [0-9]*)
80                 ec="$1"
81                 shift
82                 ;;
83         *) ec=1 ;;
84         esac
85
86         log 0 "$*"
87         exit "$ec"
88 }
89
90 # ask the user for input
91 ask() { # ask PROMPT [READ_OPT...]
92         prompt="$1"
93         shift
94         read </dev/tty -e -r -p "$prompt> " "$@"
95 }
96
97 # fail if something isn't installed
98 require() { hash "$1" 2>/dev/null || die 127 "Requirement '$1' not found."; }
99
100 # trim a string
101 trim() { sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; }
102
103 # stubs for when things aren't implemented (fully)
104 NOT_IMPLEMENTED() { die 200 "NOT IMPLEMENTED!!!"; }
105 NOT_FULLY_IMPLEMENTED() { log 1 "NOT FULLY IMPLEMENTED!!!"; }
106
107 ### gemini ###
108 # url functions
109 # normalize a path from /../ /./ /
110 normalize_path() { # normalize_path <<< PATH
111         gawk '{
112         if ($0 == "" || $0 ~ /^\/\/[^\/]/) {
113                 return -1
114         }
115         split($0, path, /\//)
116         for (c in path) {
117                 if (path[c] == "" || path[c] == ".") {
118                         continue
119                 }
120                 if (path[c] == "..") {
121                         sub(/[^\/]+$/, "", ret)
122                         continue
123                 }
124                 if (! ret || match(ret, /\/$/)) {
125                         slash = ""
126                 } else {
127                         slash = "/"
128                 }
129                 ret = ret slash path[c]
130         }
131         print ret
132         }'
133 }
134
135 # split a url into the URL array
136 split_url() {
137         gawk '{
138         if (match($0, /^[A-Za-z]+:/)) {
139                 arr["scheme"] = substr($0, RSTART, RLENGTH)
140                 $0 = substr($0, RLENGTH + 1)
141         }
142         if (match($0, /^\/\/[^\/?#]+?/) || (match($0, /^[^\/?#]+?/) && scheme)) {
143                 arr["authority"] = substr($0, RSTART, RLENGTH)
144                 $0 = substr($0, RLENGTH + 1)
145         }
146         if (match($0, /^\/?[^?#]+/)) {
147                 arr["path"] = substr($0, RSTART, RLENGTH)
148                 $0 = substr($0, RLENGTH + 1)
149         }
150         if (match($0, /^\?[^#]+/)) {
151                 arr["query"] = substr($0, RSTART, RLENGTH)
152                 $0 = substr($0, RLENGTH + 1)
153         }
154         if (match($0, /^#.*/)) {
155                 arr["fragment"] = substr($0, RSTART, RLENGTH)
156                 $0 = substr($0, RLENGTH + 1)
157         }
158         for (part in arr) {
159                 printf "URL[\"%s\"]=\"%s\"\n", part, arr[part]
160         }
161         }'
162 }
163
164 # example.com => gemini://example.com/
165 _address() { # _address URL
166         addr="$1"
167
168         [[ "$addr" != *://* ]] && addr="$PROT://$addr"
169         trim <<<"$addr"
170 }
171
172 # return only the server part from an address, with the port added
173 # gemini://example.com/path/to/file => example.com:1965
174 _server() {
175         serv="$(_address "$1")" # normalize first
176         serv="${serv#*://}"
177         serv="${serv%%/*}"
178         if [[ "$serv" != *:* ]]; then
179                 serv="$serv:$PORT"
180         fi
181         trim <<<"$serv"
182 }
183
184 # request a gemini page
185 # by default, extract the server from the url
186 request() { # request [-s SERVER] URL
187         case "$1" in
188         -s)
189                 serv="$(_server "$2")"
190                 addr="$(_address "$3")"
191                 ;;
192         *)
193                 serv="$(_server "$1")"
194                 addr="$(_address "$1")"
195                 ;;
196         esac
197
198         log 5 "serv: $serv"
199         log 5 "addr: $addr"
200
201         sslcmd=(openssl s_client -crlf -ign_eof -quiet -connect "$serv")
202         # use SNI
203         sslcmd+=(-servername "${serv%:*}")
204         log "${sslcmd[@]}"
205         "${sslcmd[@]}" <<<"$addr" 2>/dev/null
206 }
207
208 # handle the response
209 # cf. gemini://gemini.circumlunar.space/docs/spec-spec.txt
210 handle() { # handle URL < RESPONSE
211         URL="$1"
212         while read -d $'\r' -r head; do
213                 break # wait to read the first line
214         done
215         code="$(gawk '{print $1}' <<<"$head")"
216         meta="$(gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$head")"
217
218         log 5 "[$code]  $meta"
219
220         case "$code" in
221         1*) # INPUT
222                 log 3 "Input"
223                 put "$meta"
224                 ask "?"
225                 bollux "$URL?$REPLY"
226                 ;;
227         2*) # SUCCESS
228                 log 3 "Success"
229                 case "$code" in
230                 20) log 5 "- OK" ;;
231                 21) log 5 "- End of client certificate session" ;;
232                 *) log 2 "- Unknown response code: '$code'." ;;
233                 esac
234                 display "$meta"
235                 ;;
236         3*) # REDIRECT
237                 log 3 "Redirecting"
238                 case "$code" in
239                 30) log 5 "- Temporary" ;;
240                 31) log 5 "- Permanent" ;;
241                 *) log 2 "- Unknown response code: '$code'." ;;
242                 esac
243                 ((RDRS += 1))
244                 ((RDRS > MAXR)) && die "$code" "Too many redirects!"
245                 bollux "$meta"
246                 ;;
247         4*) # TEMPORARY FAILURE
248                 log 2 "Temporary failure"
249                 case "$code" in
250                 41) log 5 "- Server unavailable" ;;
251                 42) log 5 "- CGI error" ;;
252                 43) log 5 "- Proxy error" ;;
253                 44) log 5 "- Rate limited" ;;
254                 *) log 2 "- Unknown response code: '$code'." ;;
255                 esac
256                 exit "$code"
257                 ;;
258         5*) # PERMANENT FAILURE
259                 log 2 "Permanent failure"
260                 case "$code" in
261                 51) log 5 "- Not found" ;;
262                 52) log 5 "- No longer available" ;;
263                 53) log 5 "- Proxy request refused" ;;
264                 59) log 5 "- Bad request" ;;
265                 *) log 2 "- Unknown response code: '$code'." ;;
266                 esac
267                 exit "$code"
268                 ;;
269         6*) # CLIENT CERT REQUIRED
270                 log 2 "Client certificate required"
271                 case "$code" in
272                 61) log 5 "- Transient cert requested" ;;
273                 62) log 5 "- Authorized cert required" ;;
274                 63) log 5 "- Cert not accepted" ;;
275                 64) log 5 "- Future cert rejected" ;;
276                 65) log 5 "- Expired cert rejected" ;;
277                 *) log 2 "- Unknown response code: '$code'." ;;
278                 esac
279                 exit "$code"
280                 ;;
281         *) # ???
282                 die "$code" "Unknown response code: '$code'."
283                 ;;
284         esac
285 }
286
287 # display the page
288 display() { # display MIMETYPE < DOCUMENT
289         mimetype="$1"
290         case "$mimetype" in
291         text/*)
292                 # normalize line endings to "\n"
293                 # gawk 'BEGIN{RS=""}{gsub(/\r\n?/,"\n");print}'
294                 cat
295                 # TODO: use less with linking and stuff
296                 # less -R -p'^=>' +g
297                 # lesskey:
298                 # l /=>\n # highlight links
299                 # o pipe \n open_url # open the link on the top line
300                 # u shell select_url % # shows a selection prompt for all urls (on screen? file?)
301                 # Q exit 1  # for one of these, show a selection prompt for urls
302                 # q exit 0  # for the other, just quit
303                 ###
304                 # also look into the prompt, the filename, and input preprocessor
305                 # ($LESSOPEN, $LESSCLOSE)
306                 ;;
307         *)
308                 download "$URL"
309                 ;;
310         esac
311 }
312
313 download() { # download URL < FILE
314         tn="$(mktemp)"
315         dd status=progress >"$tn"
316         fn="$DLDR/${URL##*/}"
317         if [[ -f "$fn" ]]; then
318                 log - "Saved '$tn'."
319         else
320                 if mv "$tn" "$fn"; then
321                         log - "Saved '$fn'."
322                 else
323                         log 0 "Error saving '$fn'."
324                         log - "Saved '$tn'."
325                 fi
326         fi
327 }
328
329 ### main entry point ###
330 bollux() {
331         OPTIND=0
332         process_cmdline "$@"
333         shift $((OPTIND - 1))
334
335         if (($# == 1)); then
336                 URL="$1"
337         else
338                 ask GO URL
339         fi
340
341         log 5 "URL : $URL"
342
343         request "$URL" | handle "$URL"
344 }
345
346 bollux_setup() {
347         mkfifo .resource
348         trap bollux_cleanup INT QUIT TERM EXIT
349 }
350
351 bollux_cleanup() {
352         echo
353         rm -f .resource
354 }
355
356 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
357         set -euo pipefail # strict mode
358         # requirements here -- so they're only checked once
359         require gawk
360         require dd
361         require mv
362         require openssl
363         require sed
364
365         bollux "$@"
366         echo
367 fi