#!/usr/bin/env bash # bollux: a bash gemini client # Author: Case Duckworth # License: MIT # Version: 0.4.0 # Program information PRGN="${0##*/}" VRSN=0.4.0 bollux_usage() { cat <&2 '\e[%sm%s:%s:\e[0m\t%s\n' "$fmt" "$PRGN" "${FUNCNAME[1]}" "$*" } # main entry point bollux() { run bollux_config # TODO: figure out better config method run bollux_args "$@" # and argument parsing run bollux_init if [[ ! "${BOLLUX_URL:+x}" ]]; then run prompt GO BOLLUX_URL fi log d "BOLLUX_URL='$BOLLUX_URL'" run blastoff "$BOLLUX_URL" } # process command-line arguments bollux_args() { while getopts :hvq OPT; do case "$OPT" in h) bollux_usage exit ;; v) BOLLUX_LOGLEVEL=DEBUG ;; q) BOLLUX_LOGLEVEL=QUIET ;; :) die 1 "Option -$OPTARG requires an argument" ;; *) die 1 "Unknown option: -$OPTARG" ;; esac done shift $((OPTIND - 1)) if (($# == 1)); then BOLLUX_URL="$1" fi } # process config file and set variables bollux_config() { : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}" if [ -f "$BOLLUX_CONFIG" ]; then # shellcheck disable=1090 . "$BOLLUX_CONFIG" else log debug "Can't load config file '$BOLLUX_CONFIG'." fi ## behavior : "${BOLLUX_TIMEOUT:=30}" # connection timeout : "${BOLLUX_MAXREDIR:=5}" # max redirects : "${BOLLUX_PORT:=1965}" # port number : "${BOLLUX_PROTO:=gemini}" # default protocol : "${BOLLUX_URL:=}" # start url : "${BOLLUX_BYEMSG:=See You Space Cowboy...}" # bye message ## files : "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}" : "${BOLLUX_DOWNDIR:=.}" # where to save downloads : "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds : "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save the source BOLLUX_HISTFILE="$BOLLUX_DATADIR/history" # where to save the history ## typesetting : "${T_MARGIN:=4}" # left and right margin : "${T_WIDTH:=0}" # width of the viewport -- 0 = get term width # colors -- these will be wrapped in \e[ __ m C_RESET='\e[0m' # reset : "${C_SIGIL:=35}" # sigil (=>, #, ##, ###, *, ```) : "${C_LINK_NUMBER:=1}" # link number : "${C_LINK_TITLE:=4}" # link title : "${C_LINK_URL:=36}" # link URL : "${C_HEADER1:=1;4}" # header 1 formatting : "${C_HEADER2:=1}" # header 2 formatting : "${C_HEADER3:=3}" # header 3 formatting : "${C_LIST:=0}" # list formatting : "${C_QUOTE:=3}" # quote formatting : "${C_PRE:=0}" # preformatted text formatting } # quit happily bollux_quit() { log x "$BOLLUX_BYEMSG" exit } # set the terminal title set_title() { # set_title STRING printf '\e]2;%s\007' "$*" } # prompt for input prompt() { # prompt [-u] PROMPT [READ_ARGS...] local read_cmd=(read -e -r) if [[ "$1" == "-u" ]]; then read_cmd+=(-i "$BOLLUX_URL") shift fi local prompt="$1" shift read_cmd+=(-p "$prompt> ") "${read_cmd[@]}" /dev/null; then run "${proto}_request" "$url" else log d "No request handler for '$proto'; trying gemini" run gemini_request "$url" fi } | run normalize | { if declare -Fp "${proto}_response" >/dev/null; then run "${proto}_response" "$url" else log d "No response handler for '$proto'; handling raw response" raw_response fi } } # transform a URI according to RFC 3986 sec 5.2.2 transform_resource() { # transform_resource BASE_URL REFERENCE_URL local -A R B T # reference, base url, target eval "$(run parse_url B "$1")" eval "$(run parse_url R "$2")" # A non-strict parser may ignore a scheme in the reference # if it is identical to the base URI's scheme. if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then unset "${R[scheme]}" fi # basically pseudo-code from spec ported to bash if isdefined "R[scheme]"; then T[scheme]="${R[scheme]}" isdefined "R[authority]" && T[authority]="${R[authority]}" isdefined R[path] && T[path]="$(run remove_dot_segments "${R[path]}")" isdefined "R[query]" && T[query]="${R[query]}" else if isdefined "R[authority]"; then T[authority]="${R[authority]}" isdefined "R[authority]" && T[path]="$(remove_dot_segments "${R[path]}")" isdefined R[query] && T[query]="${R[query]}" else if isempty "R[path]"; then T[path]="${B[path]}" if isdefined R[query]; then T[query]="${R[query]}" else T[query]="${B[query]}" fi else if [[ "${R[path]}" == /* ]]; then T[path]="$(remove_dot_segments "${R[path]}")" else T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")" T[path]="$(remove_dot_segments "${T[path]}")" fi isdefined R[query] && T[query]="${R[query]}" fi T[authority]="${B[authority]}" fi T[scheme]="${B[scheme]}" fi isdefined R[fragment] && T[fragment]="${R[fragment]}" # cf. 5.3 -- recomposition local r isdefined "T[scheme]" && r="$r${T[scheme]}:" # remove the port from the authority isdefined "T[authority]" && r="$r//${T[authority]%:*}" r="$r${T[path]}" isdefined T[query] && r="$r?${T[query]}" isdefined T[fragment] && r="$r#${T[fragment]}" printf '%s\n' "$r" } # merge URL paths according to RFC 3986 sec 5.2.3 merge_paths() { # merge_paths BASE_AUTHORITY BASE_PATH REFERENCE_PATH # shellcheck disable=2034 local B_authority="$1" local B_path="$2" local R_path="$3" # if R_path is empty, get rid of // in B_path if [[ -z "$R_path" ]]; then printf '%s\n' "${B_path//\/\//\//}" return fi if isdefined "B_authority" && isempty "B_path"; then printf '/%s\n' "${R_path//\/\//\//}" else if [[ "$B_path" == */* ]]; then B_path="${B_path%/*}/" else B_path="" fi printf '%s/%s\n' "${B_path%/}" "${R_path#/}" fi } # remove dot segments in paths according to RFC 3986 sec 5.2.4 remove_dot_segments() { # remove_dot_segments PATH local input="$1" local output while [[ "$input" ]]; do if [[ "$input" =~ ^\.\.?/ ]]; then input="${input#${BASH_REMATCH[0]}}" elif [[ "$input" =~ ^/\.(/|$) ]]; then input="/${input#${BASH_REMATCH[0]}}" elif [[ "$input" =~ ^/\.\.(/|$) ]]; then input="/${input#${BASH_REMATCH[0]}}" [[ "$output" =~ /?[^/]+$ ]] output="${output%${BASH_REMATCH[0]}}" elif [[ "$input" == . || "$input" == .. ]]; then input= else [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || log debug NOMATCH output="$output${BASH_REMATCH[1]}" input="${BASH_REMATCH[2]}" fi done printf '%s\n' "${output//\/\//\//}" } # parse a url using the reference regex in RFC 3986 appendix B parse_url() { # eval "$(split_url NAME STRING)" => NAME[...] local name="$1" local string="$2" local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?' [[ $string =~ $re ]] || return $? local scheme="${BASH_REMATCH[2]}" local authority="${BASH_REMATCH[4]}" local path="${BASH_REMATCH[5]}" local query="${BASH_REMATCH[7]}" local fragment="${BASH_REMATCH[9]}" for c in scheme authority query fragment; do [[ "${!c}" ]] && run printf '%s[%s]=%q\n' "$name" "$c" "${!c}" done # unclear if the path is always set even if empty but it looks that way run printf '%s[path]=%q\n' "$name" "$path" } # is a NAME defined ('set' in bash)? isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME # is a NAME defined AND empty? isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME # work with URLs # https://github.com/dylanaraps/pure-bash-bible/ urlencode() { # urlencode STRING local LC_ALL=C for ((i = 0; i < ${#1}; i++)); do : "${1:i:1}" case "$_" in [a-zA-Z0-9.~_-]) printf '%s' "$_" ;; *) printf '%%%02X' "'$_" ;; esac done printf '\n' } # https://github.com/dylanaraps/pure-bash-bible/ urldecode() { # urldecode STRING : "${1//+/ }" printf '%b\n' "${_//%/\\x}" } # GEMINI # https://gemini.circumlunar.space/docs/specification.html gemini_request() { # gemini_request URL local url port server local ssl_cmd url="$1" port=1965 server="${url#*://}" server="${server%%/*}" ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port") ssl_cmd+=(-servername "$server") # SNI # disable old TLS/SSL versions ssl_cmd+=(-no_ssl3 -no_tls1 -no_tls1_1) run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null } gemini_response() { # gemini_response URL local url code meta local title url="$1" # we need a loop here so it waits for the first line while read -t "$BOLLUX_TIMEOUT" -r code meta || { (($? > 128)) && die 99 "Timeout."; }; do break done log d "[$code] $meta" case "$code" in 1*) # input REDIRECTS=0 BOLLUX_URL="$url" case "$code" in 10) run prompt "$meta" ;; 11) run prompt "$meta" -s ;; # password input esac run blastoff "?$(urlencode "$REPLY")" ;; 2*) # OK REDIRECTS=0 BOLLUX_URL="$url" # read ahead to find a title local pretitle while read -r; do pretitle="$pretitle$REPLY"$'\n' if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then title="${BASH_REMATCH[1]}" break fi done run history_append "$url" "${title:-}" # read the body out and pipe it to display { printf '%s' "$pretitle" passthru } | run display "$meta" "${title:-}" ;; 3*) # redirect ((REDIRECTS += 1)) if ((REDIRECTS > BOLLUX_MAXREDIR)); then die $((100 + code)) "Too many redirects!" fi run blastoff "$meta" # TODO: confirm redirect ;; 4*) # temporary error REDIRECTS=0 die "$((100 + code))" "Temporary error [$code]: $meta" ;; 5*) # permanent error REDIRECTS=0 die "$((100 + code))" "Permanent error [$code]: $meta" ;; 6*) # certificate error REDIRECTS=0 log d "Not implemented: Client certificates" # TODO: recheck the speck die "$((100 + code))" "[$code] $meta" ;; *) [[ -z "${code-}" ]] && die 100 "Empty response code." die "$((100 + code))" "Unknown response code: $code." ;; esac } # GOPHER # https://tools.ietf.org/html/rfc1436 protocol # https://tools.ietf.org/html/rfc4266 url gopher_request() { # gopher_request URL local url server port type path url="$1" port=70 # RFC 4266 [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]] server="${BASH_REMATCH[1]}" port="${BASH_REMATCH[3]:-70}" type="${BASH_REMATCH[6]:-1}" path="${BASH_REMATCH[7]}" log d "URL='$url' SERVER='$server' TYPE='$type' PATH='$path'" exec 9<>"/dev/tcp/$server/$port" printf '%s\r\n' "$path" >&9 passthru <&9 } gopher_response() { # gopher_response URL local url pre type cur_server pre=false url="$1" # RFC 4266 [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]] cur_server="${BASH_REMATCH[1]}" type="${BASH_REMATCH[6]:-1}" run history_append "$url" "" # TODO: get the title ?? log d "TYPE='$type'" case "$type" in 0) # text run display text/plain ;; 1) # menu run gopher_convert | run display text/gemini ;; 3) # failure die 203 "GOPHER: failed" ;; 7) # search if [[ "$url" =~ $'\t' ]]; then run gopher_convert | run display text/gemini else run prompt 'SEARCH' run blastoff "$url $REPLY" fi ;; *) # something else run download "$url" ;; esac } # 'cat' but in pure bash passthru() { while IFS= read -r; do printf '%s\n' "$REPLY" done } # convert gophermap to text/gemini (probably naive) gopher_convert() { local type label path server port regex # cf. https://github.com/jamestomasino/dotfiles-minimal/blob/master/bin/gophermap2gemini.awk while IFS= read -r; do printf -v regex '(.)([^\t]*)(\t([^\t]*)\t([^\t]*)\t([^\t]*))?' if [[ "$REPLY" =~ $regex ]]; then type="${BASH_REMATCH[1]}" label="${BASH_REMATCH[2]}" path="${BASH_REMATCH[4]:-/}" server="${BASH_REMATCH[5]:-$cur_server}" port="${BASH_REMATCH[6]}" else log e "CAN'T PARSE LINE" printf '%s\n' "$REPLY" continue fi case "$type" in .) # end of file printf '.\n' break ;; i) # label case "$label" in '#'* | '*'[[:space:]]*) if $pre; then printf '%s\n' '```' pre=false fi ;; *) if ! $pre; then printf '%s\n' '```' pre=true fi ;; esac printf '%s\n' "$label" ;; h) # html link if $pre; then printf '%s\n' '```' pre=false fi printf '=> %s %s\n' "${path:4}" "$label" ;; T) # telnet link if $pre; then printf '%s\n' '```' pre=false fi printf '=> telnet://%s:%s/%s%s %s\n' \ "$server" "$port" "$type" "$path" "$label" ;; *) # other type if $pre; then printf '%s\n' '```' pre=false fi printf '=> gopher://%s:%s/%s%s %s\n' \ "$server" "$port" "$type" "$path" "$label" ;; esac done if $pre; then printf '%s\n' '```' fi # close the connection exec 9<&- exec 9>&- } # display the fetched content display() { # display METADATA [TITLE] local -a less_cmd local i mime charset # split header line local -a hdr IFS=';' read -ra hdr <<<"$1" # title is optional but nice looking local title if (($# == 2)); then title="$2" fi mime="$(trim_string "${hdr[0],,}")" for ((i = 1; i <= "${#hdr[@]}"; i++)); do h="${hdr[$i]}" case "$h" in *charset=*) charset="${h#*=}" ;; esac done [[ -z "$mime" ]] && mime="text/gemini" [[ -z "$charset" ]] && charset="utf-8" log debug "mime='$mime'; charset='$charset'" case "$mime" in text/*) set_title "$title${title:+ - }bollux" less_cmd=(less -R) mklesskey "$BOLLUX_LESSKEY" && less_cmd+=(-k "$BOLLUX_LESSKEY") less_cmd+=( -Pm"$(less_prompt_escape "$BOLLUX_URL") - bollux$" -P='o\:open, g\:goto, [\:back, ]\:forward, r\:refresh$' -m ) local typeset local submime="${mime#*/}" if declare -Fp "typeset_$submime" &>/dev/null; then typeset="typeset_$submime" else typeset="passthru" fi { run iconv -f "${charset^^}" -t "UTF-8" | run tee "$BOLLUX_PAGESRC" | run "$typeset" | run "${less_cmd[@]}" && bollux_quit } || run handle_keypress "$?" ;; *) run download "$BOLLUX_URL" ;; esac } # escape strings for the less prompt less_prompt_escape() { # less_prompt_escape STRING local i for ((i = 0; i < ${#1}; i++)); do : "${1:i:1}" case "$_" in [\?:\.%\\]) printf '\%s' "$_" ;; *) printf '%s' "$_" ;; esac done printf '\n' } # generate a lesskey(1) file for custom keybinds mklesskey() { # mklesskey FILENAME lesskey -o "$1" - <<-END #command o quit 0 # 48 open a link g quit 1 # 49 goto a url [ quit 2 # 50 back ] quit 3 # 51 forward r quit 4 # 52 re-request / download G quit 5 # 53 goto a url (pre-filled) # other keybinds \\40 forw-screen-force h left-scroll l right-scroll ? status # 'status' will show a little help thing. = noaction END } # normalize files normalize() { shopt -s extglob while IFS= read -r; do # normalize line endings printf '%s\n' "${REPLY//$'\r'?($'\n')/}" done shopt -u extglob } # typeset a text/gemini document typeset_gemini() { local pre=false local ln=0 # link number if ((T_WIDTH == 0)); then shopt -s checkwinsize ( : : ) # dumb formatting brought to you by shfmt log d "LINES=$LINES; COLUMNS=$COLUMNS" T_WIDTH=$COLUMNS fi WIDTH=$((T_WIDTH - T_MARGIN)) ((WIDTH < 0)) && WIDTH=80 # default if dumb S_MARGIN=$((T_MARGIN - 1)) # spacing log d "T_WIDTH=$T_WIDTH" log d "WIDTH=$WIDTH" while IFS= read -r; do case "$REPLY" in '```'*) if $pre; then pre=false else pre=true fi continue ;; '=>'*) : $((ln += 1)) gemini_link "$REPLY" $pre "$ln" ;; '#'*) gemini_header "$REPLY" $pre ;; '*'[[:space:]]*) gemini_list "$REPLY" $pre ;; '>'*) gemini_quote "$REPLY" $pre ;; *) gemini_text "$REPLY" $pre ;; esac done } gemini_link() { local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)" local s t a l # sigil, text, annotation(url), line if ! ${2-false} && [[ "$1" =~ $re ]]; then s="${BASH_REMATCH[1]}" a="${BASH_REMATCH[2]}" t="${BASH_REMATCH[3]}" if [[ -z "$t" ]]; then t="$a" a= fi printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" printf -v l "\e[${C_LINK_NUMBER}m[%d]${C_RESET} \ \e[${C_LINK_TITLE}m%s${C_RESET} \ \e[${C_LINK_URL}m%s${C_RESET}\n" \ "$3" "$t" "$a" fold_line "$WIDTH" "$l" else gemini_pre "$1" fi } gemini_header() { local re="^(#+)[[:blank:]]*(.*)" local s t a l # sigil, text, annotation(lvl), line if ! ${2-false} && [[ "$1" =~ $re ]]; then s="${BASH_REMATCH[1]}" a="${#BASH_REMATCH[1]}" t="${BASH_REMATCH[2]}" local hdrfmt hdrfmt="$(eval echo "\$C_HEADER$a")" printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" printf -v l "\e[${hdrfmt}m%s${C_RESET}\n" "$t" fold_line "$WIDTH" "$l" else gemini_pre "$1" fi } gemini_list() { local re="^(\*)[[:blank:]]*(.*)" local s t a l # sigil, text, annotation(n/a), line if ! ${2-false} && [[ "$1" =~ $re ]]; then s="${BASH_REMATCH[1]}" t="${BASH_REMATCH[2]}" printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" printf -v l "\e[${C_LIST}m%s${C_RESET}\n" "$t" fold_line "$WIDTH" "$l" else gemini_pre "$1" fi } gemini_quote() { local re="^(>)[[:blank:]]*(.*)" local s t a l if ! ${2-false} && [[ "$1" =~ $re ]]; then s="${BASH_REMATCH[1]}" t="${BASH_REMATCH[2]}" printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" printf -v l "\e[${C_QUOTE}m%s${C_RESET}\n" "$t" fold_line "$WIDTH" "$l" else gemini_pre "$1" fi } gemini_text() { if ! ${2-false}; then printf "%${S_MARGIN}s " ' ' fold_line "$WIDTH" "$1" else gemini_pre "$1" fi } gemini_pre() { printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```' printf "\e[${C_PRE}m%s${C_RESET}\n" "$1" } # wrap lines on words to WIDTH fold_line() { # fold_line WIDTH TEXT local width="$1" local margin="${2%%[![:space:]]*}" if [[ "$margin" ]]; then margin="${#margin}" else margin="$T_MARGIN" fi local ll=0 wl plain # shellcheck disable=2086 set -- $2 # TODO: is this the best way? for word; do shopt -s extglob plain="${word//$'\x1b'\[*([0-9;])m/}" shopt -u extglob wl=$((${#plain} + 1)) if (((ll + wl) >= width)); then printf "\n%${margin}s" ' ' ll=$wl else ll=$((ll + wl)) fi printf '%s ' "$word" done printf '\n' } # use the exit code from less (see mklesskey) to do things handle_keypress() { # handle_keypress CODE case "$1" in 48) # o - open a link -- show a menu of links on the page run select_url "$BOLLUX_PAGESRC" ;; 49) # g - goto a url -- input a new url prompt GO run blastoff -u "$REPLY" ;; 50) # [ - back in the history run history_back || { sleep 0.5 run blastoff "$BOLLUX_URL" } ;; 51) # ] - forward in the history run history_forward || { sleep 0.5 run blastoff "$BOLLUX_URL" } ;; 52) # r - re-request the current resource run blastoff "$BOLLUX_URL" ;; 53) # G - goto a url (pre-filled with current) prompt -u GO run blastoff -u "$REPLY" ;; *) # 54-57 -- still available for binding die "$?" "less(1) error" ;; esac } # select a URL from a text/gemini file select_url() { # select_url FILE run mapfile -t < <(extract_links <"$1") PS3="OPEN> " select u in "${MAPFILE[@]}"; do case "$REPLY" in q) bollux_quit ;; [^0-9]*) run blastoff -u "$REPLY" && break ;; esac run blastoff "${u%%[[:space:]]*}" && break done [[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then url="${BASH_REMATCH[1]}" alt="${BASH_REMATCH[3]}" if [[ "$alt" ]]; then printf '%s \e[34m(%s)\e[0m\n' "$url" "$alt" else printf '%s\n' "$url" fi fi done } # download $BOLLUX_URL download() { tn="$(mktemp)" log x "Downloading: '$BOLLUX_URL' => '$tn'..." dd status=progress >"$tn" fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}" if [[ -f "$fn" ]]; then log x "Saved '$tn'." elif mv "$tn" "$fn"; then log x "Saved '$fn'." else log error "Error saving '$fn': downloaded to '$tn'." fi } # initialize bollux bollux_init() { # Trap cleanup trap bollux_cleanup INT QUIT EXIT # State REDIRECTS=0 set -f # History declare -a HISTORY # history is kept in an array HN=0 # position of history in the array run mkdir -p "${BOLLUX_HISTFILE%/*}" } # clean up on exit bollux_cleanup() { # Stubbed in case of need in future : } # append a URL to history history_append() { # history_append URL TITLE BOLLUX_URL="$1" # date/time, url, title (best guess) run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE" HISTORY[$HN]="$BOLLUX_URL" ((HN += 1)) } # move back in history (session) history_back() { log d "HN=$HN" ((HN -= 2)) if ((HN < 0)); then HN=0 log e "Beginning of history." return 1 fi run blastoff "${HISTORY[$HN]}" } # move forward in history (session) history_forward() { log d "HN=$HN" if ((HN >= ${#HISTORY[@]})); then HN="${#HISTORY[@]}" log e "End of history." return 1 fi run blastoff "${HISTORY[$HN]}" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then run bollux "$@" fi