#!/usr/bin/env bash # bollux: a bash gemini client or whatever # Author: Case Duckworth # License: MIT # Version: -0.7 # set -euo pipefail # strict mode ### constants ### PRGN="${0##*/}" # program name DLDR="${BOLLUX_DOWNDIR:=.}" # where to download LOGL="${BOLLUX_LOGLEVEL:=3}" # log level MAXR="${BOLLUX_MAXREDIR:=5}" # max redirects PORT="${BOLLUX_PORT:=1965}" # port number PROT="${BOLLUX_PROTO:=gemini}" # protocol RDRS=0 # redirects VRSN=-0.7 # version number # shellcheck disable=2120 bollux_usage() { cat <&2 $PRGN ($VRSN): a bash gemini client usage: $PRGN [-h] $PRGN [-L LVL] [URL] options: -h show this help -L LVL set the loglevel to LVL. Default: $BOLLUX_LOGLEVEL The loglevel is between 0 and 5, with lower levels being more dire. parameters: URL the URL to navigate view or download END_USAGE exit "${1:-0}" } # LOGLEVELS: # 0 - application fatal error # 1 - application warning # 2 - response error # 3 - response logging # 4 - application logging # 5 - diagnostic ### utility functions ### # a better echo put() { printf '%s\n' "$*"; } # conditionally log events to stderr # lower = more important log() { # log [LEVEL] [<] MESSAGE case "$1" in -) lvl="-1" shift ;; [0-5]) lvl="$1" shift ;; *) lvl=4 ;; esac output="$*" if ((lvl < LOGL)); then if (($# == 0)); then while IFS= read -r line; do output="$output${output:+$'\n'}$line" done fi printf '\e[34m%s\e[0m:\t%s\n' "$PRGN" "$output" >&2 fi } # halt and catch fire die() { # die [EXIT-CODE] MESSAGE case "$1" in [0-9]*) ec="$1" shift ;; *) ec=1 ;; esac log 0 "$*" exit "$ec" } # ask the user for input ask() { # ask PROMPT [READ_OPT...] prompt="$1" shift read " "$@" } # fail if something isn't installed require() { hash "$1" 2>/dev/null || die 127 "Requirement '$1' not found."; } # trim a string trim() { sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; } # stubs for when things aren't implemented (fully) NOT_IMPLEMENTED() { die 200 "NOT IMPLEMENTED!!!"; } NOT_FULLY_IMPLEMENTED() { log 1 "NOT FULLY IMPLEMENTED!!!"; } ### gemini ### # url functions # normalize a path from /../ /./ / normalize_path() { # normalize_path <<< PATH gawk '{ if ($0 == "" || $0 ~ /^\/\/[^\/]/) { return -1 } split($0, path, /\//) for (c in path) { if (path[c] == "" || path[c] == ".") { continue } if (path[c] == "..") { sub(/[^\/]+$/, "", ret) continue } if (! ret || match(ret, /\/$/)) { slash = "" } else { slash = "/" } ret = ret slash path[c] } print ret }' } # split a url into the URL array split_url() { gawk '{ if (match($0, /^[A-Za-z]+:/)) { arr["scheme"] = substr($0, RSTART, RLENGTH) $0 = substr($0, RLENGTH + 1) } if (match($0, /^\/\/[^\/?#]+?/) || (match($0, /^[^\/?#]+?/) && scheme)) { arr["authority"] = substr($0, RSTART, RLENGTH) $0 = substr($0, RLENGTH + 1) } if (match($0, /^\/?[^?#]+/)) { arr["path"] = substr($0, RSTART, RLENGTH) $0 = substr($0, RLENGTH + 1) } if (match($0, /^\?[^#]+/)) { arr["query"] = substr($0, RSTART, RLENGTH) $0 = substr($0, RLENGTH + 1) } if (match($0, /^#.*/)) { arr["fragment"] = substr($0, RSTART, RLENGTH) $0 = substr($0, RLENGTH + 1) } for (part in arr) { printf "URL[\"%s\"]=\"%s\"\n", part, arr[part] } }' } # example.com => gemini://example.com/ _address() { # _address URL addr="$1" [[ "$addr" != *://* ]] && addr="$PROT://$addr" trim <<<"$addr" } # return only the server part from an address, with the port added # gemini://example.com/path/to/file => example.com:1965 _server() { serv="$(_address "$1")" # normalize first serv="${serv#*://}" serv="${serv%%/*}" if [[ "$serv" != *:* ]]; then serv="$serv:$PORT" fi trim <<<"$serv" } # request a gemini page # by default, extract the server from the url request() { # request [-s SERVER] URL case "$1" in -s) serv="$(_server "$2")" addr="$(_address "$3")" ;; *) serv="$(_server "$1")" addr="$(_address "$1")" ;; esac log 5 "serv: $serv" log 5 "addr: $addr" sslcmd=(openssl s_client -crlf -ign_eof -quiet -connect "$serv") # use SNI sslcmd+=(-servername "${serv%:*}") log "${sslcmd[@]}" "${sslcmd[@]}" <<<"$addr" 2>/dev/null } # handle the response # cf. gemini://gemini.circumlunar.space/docs/spec-spec.txt handle() { # handle URL < RESPONSE URL="$1" while read -d $'\r' -r head; do break # wait to read the first line done code="$(gawk '{print $1}' <<<"$head")" meta="$(gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$head")" log 5 "[$code] $meta" case "$code" in 1*) # INPUT log 3 "Input" RDRS=0 # this is not a redirect ask "$meta" QUERY bollux "$URL?$QUERY" ;; 2*) # SUCCESS log 3 "Success" RDRS=0 # this is not a redirect case "$code" in 20) log 5 "- OK" ;; 21) log 5 "- End of client certificate session" ;; *) log 2 "- Unknown response code: '$code'." ;; esac display "$meta" ;; 3*) # REDIRECT log 3 "Redirecting" case "$code" in 30) log 5 "- Temporary" ;; 31) log 5 "- Permanent" ;; *) log 2 "- Unknown response code: '$code'." ;; esac ((RDRS += 1)) ((RDRS > MAXR)) && die "$code" "Too many redirects!" bollux "$meta" ;; 4*) # TEMPORARY FAILURE log 2 "Temporary failure" RDRS=0 # this is not a redirect case "$code" in 41) log 5 "- Server unavailable" ;; 42) log 5 "- CGI error" ;; 43) log 5 "- Proxy error" ;; 44) log 5 "- Rate limited" ;; *) log 2 "- Unknown response code: '$code'." ;; esac exit "$code" ;; 5*) # PERMANENT FAILURE log 2 "Permanent failure" RDRS=0 # this is not a redirect case "$code" in 51) log 5 "- Not found" ;; 52) log 5 "- No longer available" ;; 53) log 5 "- Proxy request refused" ;; 59) log 5 "- Bad request" ;; *) log 2 "- Unknown response code: '$code'." ;; esac exit "$code" ;; 6*) # CLIENT CERT REQUIRED log 2 "Client certificate required" RDRS=0 # this is not a redirect case "$code" in 61) log 5 "- Transient cert requested" ;; 62) log 5 "- Authorized cert required" ;; 63) log 5 "- Cert not accepted" ;; 64) log 5 "- Future cert rejected" ;; 65) log 5 "- Expired cert rejected" ;; *) log 2 "- Unknown response code: '$code'." ;; esac exit "$code" ;; *) # ??? die "$code" "Unknown response code: '$code'." ;; esac } # display the page display() { # display MIMETYPE < DOCUMENT mimetype="$1" case "$mimetype" in text/*) # normalize line endings to "\n" # gawk 'BEGIN{RS=""}{gsub(/\r\n?/,"\n");print}' cat # TODO: use less with linking and stuff # less -R -p'^=>' +g # lesskey: # l /=>\n # highlight links # o pipe \n open_url # open the link on the top line # u shell select_url % # shows a selection prompt for all urls (on screen? file?) # Q exit 1 # for one of these, show a selection prompt for urls # q exit 0 # for the other, just quit ### # also look into the prompt, the filename, and input preprocessor # ($LESSOPEN, $LESSCLOSE) ;; *) download "$URL" ;; esac } download() { # download URL < FILE tn="$(mktemp)" dd status=progress >"$tn" fn="$DLDR/${URL##*/}" if [[ -f "$fn" ]]; then log - "Saved '$tn'." else if mv "$tn" "$fn"; then log - "Saved '$fn'." else log 0 "Error saving '$fn'." log - "Saved '$tn'." fi fi } ### main entry point ### bollux() { OPTIND=0 process_cmdline "$@" shift $((OPTIND - 1)) if (($# == 1)); then URL="$1" else ask GO URL fi log 5 "URL : $URL" request "$URL" | handle "$URL" } bollux_setup() { mkfifo .resource trap bollux_cleanup INT QUIT TERM EXIT } bollux_cleanup() { echo rm -f .resource } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then set -euo pipefail # strict mode # requirements here -- so they're only checked once require gawk require dd require mv require openssl require sed bollux "$@" echo fi