2 # bollux: a bash gemini client or whatever
3 # Author: Case Duckworth <acdw@acdw.net>
7 # set -euo pipefail # strict mode
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
17 VRSN=-0.7 # version number
19 # shellcheck disable=2120
22 $PRGN ($VRSN): a bash gemini client
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.
33 URL the URL to navigate view or download
39 # 0 - application fatal error
40 # 1 - application warning
42 # 3 - response logging
43 # 4 - application logging
46 ### utility functions ###
48 put() { printf '%s\n' "$*"; }
50 # conditionally log events to stderr
51 # lower = more important
52 log() { # log [LEVEL] [<] MESSAGE
66 if ((lvl < LOGL)); then
68 while IFS= read -r line; do
69 output="$output${output:+$'\n'}$line"
72 printf '\e[34m%s\e[0m:\t%s\n' "$PRGN" "$output" >&2
77 die() { # die [EXIT-CODE] MESSAGE
90 # ask the user for input
91 ask() { # ask PROMPT [READ_OPT...]
94 read </dev/tty -e -r -p "$prompt> " "$@"
97 # fail if something isn't installed
98 require() { hash "$1" 2>/dev/null || die 127 "Requirement '$1' not found."; }
101 trim() { sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; }
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!!!"; }
109 # normalize a path from /../ /./ /
110 normalize_path() { # normalize_path <<< PATH
112 if ($0 == "" || $0 ~ /^\/\/[^\/]/) {
115 split($0, path, /\//)
117 if (path[c] == "" || path[c] == ".") {
120 if (path[c] == "..") {
121 sub(/[^\/]+$/, "", ret)
124 if (! ret || match(ret, /\/$/)) {
129 ret = ret slash path[c]
135 # split a url into the URL array
138 if (match($0, /^[A-Za-z]+:/)) {
139 arr["scheme"] = substr($0, RSTART, RLENGTH)
140 $0 = substr($0, RLENGTH + 1)
142 if (match($0, /^\/\/[^\/?#]+?/) || (match($0, /^[^\/?#]+?/) && scheme)) {
143 arr["authority"] = substr($0, RSTART, RLENGTH)
144 $0 = substr($0, RLENGTH + 1)
146 if (match($0, /^\/?[^?#]+/)) {
147 arr["path"] = substr($0, RSTART, RLENGTH)
148 $0 = substr($0, RLENGTH + 1)
150 if (match($0, /^\?[^#]+/)) {
151 arr["query"] = substr($0, RSTART, RLENGTH)
152 $0 = substr($0, RLENGTH + 1)
154 if (match($0, /^#.*/)) {
155 arr["fragment"] = substr($0, RSTART, RLENGTH)
156 $0 = substr($0, RLENGTH + 1)
159 printf "URL[\"%s\"]=\"%s\"\n", part, arr[part]
164 # example.com => gemini://example.com/
165 _address() { # _address URL
168 [[ "$addr" != *://* ]] && addr="$PROT://$addr"
172 # return only the server part from an address, with the port added
173 # gemini://example.com/path/to/file => example.com:1965
175 serv="$(_address "$1")" # normalize first
178 if [[ "$serv" != *:* ]]; then
184 # request a gemini page
185 # by default, extract the server from the url
186 request() { # request [-s SERVER] URL
189 serv="$(_server "$2")"
190 addr="$(_address "$3")"
193 serv="$(_server "$1")"
194 addr="$(_address "$1")"
201 sslcmd=(openssl s_client -crlf -ign_eof -quiet -connect "$serv")
203 sslcmd+=(-servername "${serv%:*}")
205 "${sslcmd[@]}" <<<"$addr" 2>/dev/null
208 # handle the response
209 # cf. gemini://gemini.circumlunar.space/docs/spec-spec.txt
210 handle() { # handle URL < RESPONSE
212 while read -d $'\r' -r head; do
213 break # wait to read the first line
215 code="$(gawk '{print $1}' <<<"$head")"
216 meta="$(gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$head")"
218 log 5 "[$code] $meta"
231 21) log 5 "- End of client certificate session" ;;
232 *) log 2 "- Unknown response code: '$code'." ;;
239 30) log 5 "- Temporary" ;;
240 31) log 5 "- Permanent" ;;
241 *) log 2 "- Unknown response code: '$code'." ;;
244 ((RDRS > MAXR)) && die "$code" "Too many redirects!"
247 4*) # TEMPORARY FAILURE
248 log 2 "Temporary failure"
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'." ;;
258 5*) # PERMANENT FAILURE
259 log 2 "Permanent failure"
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'." ;;
269 6*) # CLIENT CERT REQUIRED
270 log 2 "Client certificate required"
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'." ;;
282 die "$code" "Unknown response code: '$code'."
288 display() { # display MIMETYPE < DOCUMENT
292 # normalize line endings to "\n"
293 # gawk 'BEGIN{RS=""}{gsub(/\r\n?/,"\n");print}'
295 # TODO: use less with linking and stuff
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
304 # also look into the prompt, the filename, and input preprocessor
305 # ($LESSOPEN, $LESSCLOSE)
313 download() { # download URL < FILE
315 dd status=progress >"$tn"
316 fn="$DLDR/${URL##*/}"
317 if [[ -f "$fn" ]]; then
320 if mv "$tn" "$fn"; then
323 log 0 "Error saving '$fn'."
329 ### main entry point ###
333 shift $((OPTIND - 1))
343 request "$URL" | handle "$URL"
348 trap bollux_cleanup INT QUIT TERM EXIT
356 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
357 set -euo pipefail # strict mode
358 # requirements here -- so they're only checked once