#!/usr/bin/env bash
# foldrender: publish an HTML file and get a link you can share.
#
# Needs only bash, curl and coreutils, so it runs on macOS's bash 3.2 and on
# Linux. `publish` writes nothing to stdout but the page URL, so an agent can
# capture it; notes and warnings go to stderr. Other commands print to stdout.
#
# FOLDRENDER_TOKEN API token; takes precedence over the saved login.
# FOLDRENDER_API API base URL (default https://a.foldrender.dev).
# FOLDRENDER_NO_BROWSER Set to 1 to print the sign-in link, not open it.
#
# Exit codes: 0 ok, 1 failed, 2 bad usage, 3 not signed in or token rejected,
# 4 free-plan limit reached, 5 page too large, 6 network failure.
export LC_ALL=C
API="${FOLDRENDER_API:-https://a.foldrender.dev}"
API="${API%/}"
PAGE_BASE="https://a.foldrender.dev"
MAX_BYTES=5242880
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/foldrender"
TOKEN_FILE="$CONFIG_DIR/token"
SLUG_MAP="$CONFIG_DIR/slugs"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/foldrender.XXXXXX")" || exit 1
trap 'rm -rf "$TMP"' EXIT
trap 'exit 130' INT TERM
STATUS=""
die() {
local code="$1"
shift
printf 'foldrender: %s\n' "$*" >&2
exit "$code"
}
note() {
printf 'foldrender: %s\n' "$*" >&2
}
# need COUNT FLAG: a flag that takes a value must not be the last argument.
need() {
[ "$1" -ge 2 ] || die 2 "$2 needs a value"
}
read_token() {
if [ -n "${FOLDRENDER_TOKEN:-}" ]; then
printf '%s' "$FOLDRENDER_TOKEN"
elif [ -f "$TOKEN_FILE" ]; then
head -n 1 "$TOKEN_FILE" | tr -d ' \t\r\n'
fi
}
require_token() {
[ -n "$TOKEN" ] || die 3 "not signed in. Run: foldrender login"
}
# api METHOD PATH [curl args...]: sets STATUS and leaves the body in $TMP/body.
# A failed connection exits here. HTTP errors are reported by the caller.
api() {
local method="$1" path="$2" rc
shift 2
STATUS="$(curl -sS -X "$method" --connect-timeout 15 --max-time 300 \
-H "Accept: text/plain" -o "$TMP/body" -w '%{http_code}' "$@" \
"$API$path" 2>"$TMP/err")"
rc=$?
if [ "$rc" -ne 0 ]; then
die 6 "cannot reach $API. Check your network connection and try again. ($(sed 's/^curl: ([0-9]*) //' "$TMP/err" | head -n 1))"
fi
}
api_auth() {
require_token
api "$@" -H "Authorization: Bearer $TOKEN"
}
# The server's message for either "error: text" or {"error":"text"} bodies.
error_text() {
local t
t="$(head -n 1 "$TMP/body" | tr -d '\r')"
case "$t" in
error:*) t="${t#error:}" ;;
*'"error"'*) t="$(printf '%s' "$t" | sed -n 's/.*"error" *: *"\([^"]*\)".*/\1/p')" ;;
esac
t="${t# }"
printf '%s' "${t:-no message}"
}
fail_http() {
local msg
msg="$(error_text)"
case "$STATUS" in
401) die 3 "not signed in, or the token was rejected ($msg). Run: foldrender login" ;;
403)
if grep -q quota_exceeded "$TMP/body"; then
die 4 "free plan limit reached: 10 pages, each kept for 30 days. See your pages with: foldrender list"
fi
die 1 "not allowed ($msg)" ;;
404) die 1 "not found ($msg)" ;;
413) die 5 "the page is larger than 5 MB" ;;
*) die 1 "the server returned HTTP $STATUS ($msg)" ;;
esac
}
expect_2xx() {
case "$STATUS" in
2??) return 0 ;;
esac
fail_http
}
# A page slug is letters, digits, hyphens and underscores. Checking it here
# keeps a typo like "a/b" from becoming a different API path.
check_slug() {
case "$1" in
''|*[!A-Za-z0-9_-]*) die 2 "not a page slug: $1" ;;
esac
}
# GitHub logins: letters, digits and single hyphens, comma-separated.
check_logins() {
case "$1" in
''|-*|*[!A-Za-z0-9,-]*|,*|*,|*,,*) die 2 "not a comma-separated list of GitHub logins: $1" ;;
esac
}
# json_array a,b: the JSON array ["a","b"]. The API wants arrays for share_add
# and share_remove, not strings. Logins were checked by check_logins first.
json_array() {
printf '['
printf '%s' "$1" | awk -F, '{ for (i = 1; i <= NF; i++) printf "%s\"%s\"", (i > 1 ? "," : ""), $i }'
printf ']'
}
# A slug, or a page URL, to the slug.
slug_from() {
local s="${1%%[?#]*}"
s="${s%/}"
printf '%s' "${s##*/}"
}
# Percent-encodes every byte except unreserved characters.
urlencode() {
local s="$1" out="" i=0 c
while [ "$i" -lt "${#s}" ]; do
c="${s:$i:1}"
case "$c" in
[A-Za-z0-9._~-]) out="$out$c" ;;
# bash 3.2 sign-extends bytes above 0x7F, so mask down to one byte.
*) out="$out$(printf '%%%02X' $(( $(printf '%d' "'$c") & 255 )))" ;;
esac
i=$((i + 1))
done
printf '%s' "$out"
}
# The absolute path of FILE, with symlinks in its directory resolved.
abs_path() {
local dir
dir="$(cd "$(dirname "$1")" && pwd -P)" || return 1
printf '%s/%s\n' "$dir" "$(basename "$1")"
}
title_from() {
tr '\n' ' ' < "$1" | sed -n 's/.*
]*>\([^<]*\)<\/title>.*/\1/p' | head -n 1 | sed 's/^ *//; s/ *$//'
}
# The slug named by , if any.
meta_slug() {
local tag
tag="$(grep -o ']*foldrender-slug[^>]*>' "$1" | head -n 1)"
printf '%s' "$tag" | sed -n 's/.*content="\([^"]*\)".*/\1/p'
}
warn_if_secrets() {
if grep -Eq '(^|[^A-Za-z0-9])(sk-|ghp_|frat_)[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|-----BEGIN ' "$1"; then
note "warning: this page looks like it contains a secret (an API key, token or private key). Anyone with the link can read it."
fi
}
# The slug map: one "absolute-pathslug" line per published file.
map_lookup() {
[ -f "$SLUG_MAP" ] || return 0
awk -F'\t' -v k="$1" '($1 "") == (k "") { s = $2 } END { if (s != "") print s }' "$SLUG_MAP"
}
# map_without COLUMN VALUE: the map minus the rows whose COLUMN is VALUE.
# COLUMN 1 is the path, COLUMN 2 is the slug.
map_without() {
awk -F'\t' -v c="$1" -v v="$2" '((c == 1 ? $1 : $2) "") != (v "")' "$SLUG_MAP"
}
map_forget() {
[ -f "$SLUG_MAP" ] || return 0
map_without "$1" "$2" > "$SLUG_MAP.tmp" && mv -f "$SLUG_MAP.tmp" "$SLUG_MAP"
}
map_set() {
mkdir -p "$CONFIG_DIR"
{
[ -f "$SLUG_MAP" ] && map_without 1 "$1"
printf '%s\t%s\n' "$1" "$2"
} > "$SLUG_MAP.tmp" && mv -f "$SLUG_MAP.tmp" "$SLUG_MAP"
}
# upload METHOD PATH FILE QUERY
upload() {
api_auth "$1" "$2${4:+?$4}" -H "Content-Type: text/html" --data-binary "@$3"
}
# Sets PAGE_URL from the body of a successful write. Not run in a subshell,
# because a die inside $(...) would only end the subshell.
read_page_url() {
PAGE_URL="$(head -n 1 "$TMP/body" | tr -d '\r')"
case "$PAGE_URL" in
http*) ;;
*) die 1 "the server did not return a page URL ($(error_text))" ;;
esac
}
open_url() {
[ "${FOLDRENDER_NO_BROWSER:-}" = 1 ] && return 0
case "$(uname -s)" in
Darwin) open "$1" >/dev/null 2>&1 ;;
*) xdg-open "$1" >/dev/null 2>&1 ;;
esac
return 0
}
save_token() {
mkdir -p "$CONFIG_DIR"
rm -f "$TOKEN_FILE.tmp"
( umask 077 && printf '%s\n' "$1" > "$TOKEN_FILE.tmp" )
chmod 600 "$TOKEN_FILE.tmp"
mv -f "$TOKEN_FILE.tmp" "$TOKEN_FILE"
}
warn_env_token() {
[ -z "${FOLDRENDER_TOKEN:-}" ] || note "FOLDRENDER_TOKEN is set, so it is used instead of the saved token"
}
# field KEY: the value of a key=value line in the body.
field() {
sed -n "s/^$1=//p" "$TMP/body" | head -n 1 | tr -d '\r'
}
cmd_login() {
local token=""
while [ $# -gt 0 ]; do
case "$1" in
--token) need $# "$1"; token="$2"; shift 2 ;;
*) die 2 "unknown option: $1" ;;
esac
done
if [ -n "$token" ]; then
TOKEN="$token"
cmd_whoami
save_token "$token"
printf 'Token saved to %s\n' "$TOKEN_FILE"
else
device_login
fi
warn_env_token
}
device_login() {
local device_code user_code link interval expires pause waited=0 token login
api POST /api/cli/start -H "Content-Type: application/json" --data '{}'
expect_2xx
device_code="$(field device_code)"
user_code="$(field user_code)"
link="$(field verification_uri_complete)"
[ -n "$link" ] || link="$(field verification_uri)"
interval="$(field interval)"
expires="$(field expires_in)"
case "$device_code" in
''|*[!A-Za-z0-9_-]*) die 1 "the server did not start a sign-in" ;;
esac
pause="${interval:-5}"
expires="${expires:-900}"
printf '\nTo sign in, open this page:\n %s\nand enter this code: %s\n\n' "$link" "$user_code"
printf 'Waiting for approval...\n'
open_url "$link"
while :; do
[ "$waited" -lt "$expires" ] || die 1 "the code expired before it was approved. Run: foldrender login"
sleep "$pause"
waited=$((waited + pause))
api POST /api/cli/poll -H "Content-Type: application/json" --data "{\"device_code\":\"$device_code\"}"
if grep -q '^token=' "$TMP/body"; then
token="$(field token)"
login="$(field login)"
[ -n "$token" ] || die 1 "the server returned no token"
save_token "$token"
printf 'Signed in as %s. Token saved to %s\n' "${login:-your account}" "$TOKEN_FILE"
return 0
fi
grep -q 'authorization_pending' "$TMP/body" && continue
if grep -q 'slow_down' "$TMP/body"; then
pause=$((pause + 5))
continue
fi
grep -q 'expired_token' "$TMP/body" && die 1 "the code expired before it was approved. Run: foldrender login"
grep -q 'access_denied' "$TMP/body" && die 1 "the sign-in was denied"
die 1 "unexpected answer from the sign-in server ($(error_text))"
done
}
cmd_whoami() {
api_auth GET /api/me
expect_2xx
if grep -q '^login=' "$TMP/body"; then
awk -F= '
$1 == "login" { l = $2 }
$1 == "plan" { p = $2 }
$1 == "used" { u = $2 }
$1 == "limit" { m = $2 }
END { printf "signed in as %s (%s plan), %s of %s pages used\n", l, p, u, m }' "$TMP/body"
else
cat "$TMP/body"
fi
}
cmd_logout() {
rm -f "$TOKEN_FILE"
printf 'Removed the saved token.\n'
[ -z "${FOLDRENDER_TOKEN:-}" ] || note "FOLDRENDER_TOKEN is still set in this shell. Unset it to sign out completely."
}
# json_number KEY: the number under KEY in a JSON body.
json_number() {
sed -n "s/.*\"$1\" *: *\([0-9][0-9]*\).*/\1/p" "$TMP/body" | head -n 1
}
# export [FILE.zip]: every page and a manifest in one zip. The default name is
# foldrender-artifacts--.zip. Refuses to overwrite.
cmd_export() {
[ $# -le 1 ] || die 2 "usage: foldrender export [FILE.zip]"
local target="${1:-}" login=""
require_token
if [ -z "$target" ]; then
api_auth GET /api/me
expect_2xx
login="$(field login)"
[ -n "$login" ] || die 1 "could not read the account login"
target="foldrender-artifacts-$login-$(date +%Y-%m-%d).zip"
fi
[ ! -e "$target" ] || die 1 "$target already exists; choose another name"
api_auth GET /api/me/export
expect_2xx
[ "$(head -c 2 "$TMP/body")" = "PK" ] || die 1 "the server did not return a zip file"
mkdir -p "$(dirname "$target")"
cp "$TMP/body" "$target.part" || die 1 "could not write $target"
mv -f "$target.part" "$target" || die 1 "could not write $target"
printf '%s\n' "$target"
}
# account delete [--confirm LOGIN]: deletes the account with its pages and API
# tokens, then the saved token and slug map on this machine. Without a terminal
# it needs --confirm LOGIN; at a terminal the user types the login.
cmd_account() {
[ "${1:-}" = delete ] || die 2 "usage: foldrender account delete [--confirm LOGIN]"
shift
local confirm="" typed="" login="" pages="" tokens=""
while [ $# -gt 0 ]; do
case "$1" in
--confirm) need $# "$1"; confirm="$2"; shift 2 ;;
*) die 2 "unknown option: $1" ;;
esac
done
if [ -n "$confirm" ]; then
case "$confirm" in
''|*[!A-Za-z0-9-]*) die 2 "not a GitHub login: $confirm" ;;
esac
else
[ -t 0 ] || die 2 "refusing to delete the account without a terminal. Re-run with --confirm if you mean it."
fi
require_token
api_auth GET /api/me
expect_2xx
login="$(field login)"
pages="$(field used)"
[ -n "$login" ] || die 1 "could not read the account login"
printf 'Account %s has %s page(s).\nDeleting it removes every page, every API token and the account. It cannot be undone.\nTo keep a copy of your pages first, run: foldrender export\n' "$login" "${pages:-?}" >&2
if [ -z "$confirm" ]; then
printf 'Type %s to confirm: ' "$login" >&2
read -r typed || die 1 "no answer; nothing was deleted"
confirm="$typed"
fi
confirm="$(printf '%s' "$confirm" | tr -d ' \r\t' | tr '[:upper:]' '[:lower:]')"
[ "$confirm" = "$login" ] || die 1 "the login given does not match $login; nothing was deleted"
api_auth DELETE "/api/me?confirm=$login"
if [ "$STATUS" = 403 ]; then
die 1 "this account cannot be deleted from here: it is the operator's account, or the token is an admin token ($(error_text)). Nothing was deleted."
fi
expect_2xx
pages="$(json_number pages)"
tokens="$(json_number tokens)"
rm -f "$TOKEN_FILE" "$SLUG_MAP" "$SLUG_MAP.tmp"
printf 'Deleted the account %s: %s page(s) and %s API token(s).\n' "$login" "${pages:-?}" "${tokens:-?}"
printf 'Removed the saved token and the slug map from this machine.\n'
[ -z "${FOLDRENDER_TOKEN:-}" ] || note "FOLDRENDER_TOKEN is still set in this shell. Unset it to sign out completely."
}
cmd_publish() {
local file="" title="" slug="" vis="" share="" mode="" rp="" src="" bytes query url
while [ $# -gt 0 ]; do
case "$1" in
--title) need $# "$1"; title="$2"; shift 2 ;;
--slug) need $# "$1"; slug="$2"; shift 2 ;;
--share) need $# "$1"; share="${2// /}"; shift 2 ;;
--private) vis=private; shift ;;
--public) vis=public; shift ;;
-) file="-"; shift ;;
-*) die 2 "unknown option: $1" ;;
*) [ -z "$file" ] || die 2 "publish takes one file"; file="$1"; shift ;;
esac
done
[ -n "$file" ] || die 2 "usage: foldrender publish [--title T] [--slug S] [--private|--public|--share a,b]"
if [ -n "$share" ]; then
[ -z "$vis" ] || die 2 "--share makes the page shared, so drop --private or --public"
check_logins "$share"
vis=shared
fi
require_token
if [ "$file" = "-" ]; then
src="$TMP/stdin.html"
cat > "$src"
else
[ -f "$file" ] || die 2 "no such file: $file"
[ -r "$file" ] || die 2 "cannot read: $file"
src="$file"
rp="$(abs_path "$file")" || die 1 "cannot resolve the path of $file"
fi
bytes="$(wc -c < "$src" | tr -d ' ')"
[ "$bytes" -gt 0 ] || die 2 "the page is empty"
[ "$bytes" -le "$MAX_BYTES" ] || die 5 "the page is $bytes bytes; the limit is 5 MB"
warn_if_secrets "$src"
# Which page is this? --slug, then a foldrender-slug meta tag, then the
# slug this file was published under before.
if [ -n "$slug" ]; then
slug="$(slug_from "$slug")"
mode=explicit
else
slug="$(meta_slug "$src")"
if [ -n "$slug" ]; then
mode=meta
elif [ -n "$rp" ]; then
slug="$(map_lookup "$rp")"
[ -z "$slug" ] || mode=map
fi
fi
[ -z "$slug" ] || check_slug "$slug"
[ -n "$title" ] || title="$(title_from "$src")"
# A new page is private unless told otherwise. A re-publish sends only the
# flags it was given, so the page keeps the visibility and sharing it has.
if [ -z "$slug" ] && [ -z "$vis" ]; then
vis=private
fi
query=""
[ -z "$title" ] || query="title=$(urlencode "$title")"
[ -z "$vis" ] || query="${query:+$query&}visibility=$vis"
[ -z "$share" ] || query="${query:+$query&}share=$share"
if [ -n "$slug" ]; then
upload PUT "/api/artifacts/$slug" "$src" "$query"
else
upload POST /api/artifacts "$src" "$query"
fi
if [ "$STATUS" = 404 ] && [ "$mode" = map ]; then
note "the page $slug was deleted, so this publish made a new one"
map_forget 1 "$rp"
upload POST /api/artifacts "$src" "$query"
fi
if [ "$STATUS" = 404 ] && [ "$mode" = explicit ]; then
die 1 "there is no page named $slug"
fi
if [ "$STATUS" = 404 ] && [ "$mode" = meta ]; then
die 1 "there is no page named $slug (from the foldrender-slug meta tag). Change or remove the tag."
fi
expect_2xx
read_page_url
if [ -n "$rp" ]; then
map_set "$rp" "$(slug_from "$PAGE_URL")"
fi
printf '%s\n' "$PAGE_URL"
}
cmd_share() {
local target="" vis="" adds="" removes="" arg slug json url
[ $# -ge 1 ] || die 2 "usage: foldrender share [+login ...] [-login ...] [--public|--private]"
target="$1"
shift
for arg in "$@"; do
case "$arg" in
--public) vis=public ;;
--private) vis=private ;;
--*) die 2 "unknown option: $arg" ;;
+?*) check_logins "${arg#+}"; adds="${adds:+$adds,}${arg#+}" ;;
-?*) check_logins "${arg#-}"; removes="${removes:+$removes,}${arg#-}" ;;
*) die 2 "expected +login, -login, --public or --private, got: $arg" ;;
esac
done
[ -n "$adds$removes$vis" ] || die 2 "nothing to change: give +login, -login, --public or --private"
slug="$(slug_from "$target")"
check_slug "$slug"
json=""
[ -z "$vis" ] || json="\"visibility\":\"$vis\""
[ -z "$adds" ] || json="${json:+$json,}\"share_add\":$(json_array "$adds")"
[ -z "$removes" ] || json="${json:+$json,}\"share_remove\":$(json_array "$removes")"
api_auth PATCH "/api/artifacts/$slug" -H "Content-Type: application/json" --data "{$json}"
expect_2xx
read_page_url
printf '%s\n' "$PAGE_URL"
}
cmd_list() {
api_auth GET /api/artifacts
expect_2xx
if [ ! -s "$TMP/body" ]; then
printf 'No pages yet.\n'
return 0
fi
printf '%-12s %-8s %-20s %-20s %s\n' SLUG VISIBILITY UPDATED EXPIRES TITLE
awk -F'\t' '{ printf "%-12s %-8s %-20s %-20s %s\n", $1, $2, $3, $4, $5 }' "$TMP/body"
}
cmd_rm() {
[ $# -eq 1 ] || die 2 "usage: foldrender rm "
local slug
slug="$(slug_from "$1")"
check_slug "$slug"
api_auth DELETE "/api/artifacts/$slug"
[ "$STATUS" != 404 ] || map_forget 2 "$slug"
expect_2xx
map_forget 2 "$slug"
printf 'removed %s\n' "$slug"
}
cmd_open() {
[ $# -eq 1 ] || die 2 "usage: foldrender open "
local url slug
case "$1" in
http://*|https://*) url="$1" ;;
*)
slug="$(slug_from "$1")"
check_slug "$slug"
url="$PAGE_BASE/$slug"
;;
esac
printf '%s\n' "$url"
open_url "$url"
}
cmd_help() {
cat <<'EOF'
foldrender publishes an HTML file and prints a link you can share.
Usage:
foldrender login [--token TOKEN] sign in with a device code, or save a token
foldrender whoami show the account and its page usage
foldrender logout remove the saved token from this machine
foldrender export [FILE.zip] save all your pages and a manifest as one zip
foldrender account delete [--confirm LOGIN]
delete your account, pages and tokens. At a
terminal you type your login; without one,
--confirm LOGIN is required
foldrender publish FILE|- [options] publish a page; prints only its URL
--title TEXT page title (default: the file's )
--slug SLUG update that page
--private only you can see it (default for new pages)
--public anyone with the link can see it
--share LOGIN[,LOGIN...] only these GitHub logins can see it
foldrender share SLUG|URL [+LOGIN ...] [-LOGIN ...] [--public|--private]
foldrender list list your pages
foldrender rm SLUG|URL delete a page
foldrender open SLUG|URL open a page in the browser
foldrender help show this text
Examples:
foldrender login
foldrender publish report.html
foldrender publish report.html --share alice,bob
foldrender publish - < diagram.html
foldrender share https://a.foldrender.dev/abc123 +carol -bob
foldrender rm abc123
Publishing the same file again updates the same link. Environment:
FOLDRENDER_TOKEN token to use instead of the saved login
FOLDRENDER_API API base URL, for testing
FOLDRENDER_NO_BROWSER set to 1 to print the sign-in link instead of opening it
Free plan: 10 pages, each kept for 30 days, up to 5 MB per page.
EOF
}
main() {
local cmd="${1:-help}"
[ $# -eq 0 ] || shift
case "$cmd" in
login) cmd_login "$@" ;;
logout) cmd_logout ;;
account) cmd_account "$@" ;;
export) cmd_export "$@" ;;
whoami) cmd_whoami ;;
publish) cmd_publish "$@" ;;
share) cmd_share "$@" ;;
list|ls) cmd_list ;;
rm|delete) cmd_rm "$@" ;;
open) cmd_open "$@" ;;
help|-h|--help) cmd_help ;;
*) die 2 "unknown command: $cmd (try: foldrender help)" ;;
esac
}
TOKEN="$(read_token)"
main "$@"