bm-sync (8271 bytes)
1 #!/bin/sh 2 3 # bm-sync: keep the bookmark file the same on all your devices, through an 4 # sbm-sync server (https://github.com/equwal/sbm-sync). bm runs it after each 5 # change when it is installed; you can also run it yourself, or from cron. 6 # 7 # The server merges: bookmarks that you add or remove here are added or 8 # removed there, and the other way round. The file stays a plain file. 9 10 DATADIR=${XDG_DATA_HOME:-$HOME/.local/share}/sbm 11 BOOKMARKS=${BOOKMARKS:-$DATADIR/bookmarks} 12 CONFIG=${SBM_SYNC_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/sbm/sync} 13 SERVER=https://sbm.subread.space 14 STATE=$BOOKMARKS.sync # name of the version of the last sync 15 LOCK=$BOOKMARKS.sync.lock 16 WLOCK=$BOOKMARKS.lock # the write lock of bm 17 quiet= 18 19 usage () { 20 cat <<'EOF' 21 usage: bm-sync [-q] [login [server] | logout | status] 22 Without a command: sync the bookmark file now. 23 login sign in to the server (default https://sbm.subread.space) 24 and sync; asks for your email and password 25 logout sign out on this computer 26 status show the server and the time of the last sync 27 -q print nothing but errors 28 EOF 29 } 30 31 die () { 32 printf 'bm-sync: %s\n' "$*" >&2 33 exit 1 34 } 35 36 say () { 37 [ -n "$quiet" ] || printf 'bm-sync: %s\n' "$*" 38 } 39 40 # setting <name>: a value from the config file. 41 setting () { 42 sed -n "s/^$1=//p" "$CONFIG" 2>/dev/null | head -n 1 43 } 44 45 # private <file>: make <file> an empty file that only you can read. umask 46 # sets the mode of a new file. chmod sets the mode of a file that exists 47 # already, for example the config file of an earlier login. 48 private () { 49 (umask 077; : > "$1") && chmod 600 "$1" 50 } 51 52 cleanup () { 53 rm -rf "$work" 54 [ -z "$locked" ] || rm -rf "$LOCK" 55 wunlock 56 } 57 58 # wlock: take the write lock of bm, as bm does. bm holds it while it changes 59 # the file: while bm -e runs, that is until the editor ends. After 60 # $SBM_LOCK_WAIT seconds (default 10) bm-sync stops, and the next change in 61 # bm syncs again. 62 wlock () { 63 waited=0 64 until mkdir "$WLOCK" 2>/dev/null; do 65 pid=$(cat "$WLOCK/pid" 2>/dev/null) 66 if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then 67 [ "$(cat "$WLOCK/pid" 2>/dev/null)" != "$pid" ] || rm -rf "$WLOCK" 68 continue 69 fi 70 [ "$waited" -lt "${SBM_LOCK_WAIT:-10}" ] \ 71 || die "$BOOKMARKS is in use (bm -e?): sync again later" 72 sleep 1 73 waited=$((waited + 1)) 74 done 75 echo $$ > "$WLOCK/pid" 76 wlocked=1 77 } 78 79 wunlock () { 80 [ -z "$wlocked" ] || rm -rf "$WLOCK" 81 wlocked= 82 } 83 84 login () { 85 server=${1:-$SERVER} 86 server=${server%/} 87 printf 'email: ' >&2 88 read -r email || exit 1 89 printf 'password: ' >&2 90 stty -echo 2>/dev/null 91 IFS= read -r password 92 rc=$? 93 stty echo 2>/dev/null 94 printf '\n' >&2 95 [ $rc -eq 0 ] || exit 1 96 97 # The password goes through stdin: on the command line, other users 98 # could see it with ps. 99 code=$(printf '%s' "$password" | curl -sS -o "$work/out" -w '%{http_code}' \ 100 --data-urlencode "email=$email" --data-urlencode 'password@-' \ 101 -- "$server/api/login") || die "cannot reach $server" 102 [ "$code" = 200 ] || die "$(cat "$work/out")" 103 token=$(tr -d '\r\n' < "$work/out") 104 105 mkdir -p "$(dirname "$CONFIG")" || exit 1 106 private "$CONFIG" || exit 1 107 printf 'server=%s\ntoken=%s\n' "$server" "$token" > "$CONFIG" 108 rm -f "$STATE" 109 say "signed in to $server" 110 sync 111 } 112 113 logout () { 114 if [ -e "$CONFIG" ]; then 115 private "$work/auth" 116 printf 'Authorization: Bearer %s\n' "$(setting token)" > "$work/auth" 117 curl -sS -o /dev/null -X POST -H "@$work/auth" -- "$(setting server)/api/logout" \ 118 2>/dev/null 119 rm -f "$CONFIG" "$STATE" 120 fi 121 say 'signed out' 122 } 123 124 status () { 125 if [ ! -e "$CONFIG" ]; then 126 echo 'not signed in: run bm-sync login' 127 return 128 fi 129 echo "server: $(setting server)" 130 if [ -e "$STATE" ]; then 131 echo "last sync: $(ls -l "$STATE" | awk '{ print $6, $7, $8 }')" 132 else 133 echo 'last sync: never' 134 fi 135 } 136 137 # sync: send the file with the name of its last version; the server gives 138 # back the merged file and the name of the new version. 139 sync () { 140 [ -e "$CONFIG" ] || die 'not signed in: run bm-sync login' 141 server=$(setting server) 142 token=$(setting token) 143 [ -n "$server" ] && [ -n "$token" ] || die "$CONFIG is damaged: run bm-sync login" 144 145 # One sync at a time. A second bm-sync leaves a note and exits: the 146 # first one then syncs again, so that no change waits for the next one. 147 if ! mkdir "$LOCK" 2>/dev/null; then 148 pid=$(cat "$LOCK/pid" 2>/dev/null) 149 if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then 150 : > "$LOCK/again" 151 exit 0 152 fi 153 rm -rf "$LOCK" # left by a bm-sync that was killed 154 mkdir "$LOCK" 2>/dev/null || die "cannot lock $LOCK" 155 fi 156 locked=1 157 echo $$ > "$LOCK/pid" 158 159 private "$work/auth" 160 printf 'Authorization: Bearer %s\n' "$token" > "$work/auth" 161 if [ ! -e "$BOOKMARKS" ]; then 162 mkdir -p "$(dirname "$BOOKMARKS")" && : > "$BOOKMARKS" || exit 1 163 fi 164 165 tries=0 166 while :; do 167 rm -f "$LOCK/again" 168 # bm writes a delete as an empty file and then the new text. Without 169 # the lock, bm-sync could send the half-written file, and the server 170 # would delete the lines that it lacks. 171 wlock 172 cat "$BOOKMARKS" > "$work/sent" 173 wunlock 174 # An empty file with a version would delete all bookmarks on the 175 # server, and then on all devices. Without a version, the server 176 # deletes nothing, and sends all bookmarks back. 177 [ -s "$work/sent" ] || rm -f "$STATE" 178 base=$(cat "$STATE" 2>/dev/null) 179 code=$(curl -sS --max-time 60 -o "$work/out" -D "$work/head" -w '%{http_code}' \ 180 -H "@$work/auth" -H 'Content-Type: text/plain; charset=utf-8' \ 181 --data-binary "@$work/sent" -- "$server/api/sync?base=$base") \ 182 || die "cannot reach $server" 183 case $code in 184 200) ;; 185 401) die 'not signed in: run bm-sync login' ;; 186 *) die "$(cat "$work/out")" ;; 187 esac 188 version=$(tr -d '\r' < "$work/head" | sed -n 's/^[Ss][Bb][Mm]-[Vv][Ee][Rr][Ss][Ii][Oo][Nn]: *//p') 189 [ -n "$version" ] || die 'the server gave no version' 190 191 # Hold the lock from the check to the write, so that no change of bm 192 # comes between them. 193 wlock 194 # bm changed the file while it was away: send the new file. The 195 # answer to the old one is of no use. 196 if ! cmp -s "$work/sent" "$BOOKMARKS"; then 197 wunlock 198 tries=$((tries + 1)) 199 [ $tries -lt 5 ] || die 'the file changes all the time: try again later' 200 continue 201 fi 202 wrote= 203 if ! cmp -s "$work/out" "$BOOKMARKS"; then 204 # Forget the version first, and keep the new one only after the 205 # write. If the write stops part way (a full disk), the next sync 206 # sends no version, so the server deletes none of the lost lines. 207 rm -f "$STATE" 208 # Through cat, not mv, as in bm: a symlinked file stays a symlink. 209 cat "$work/out" > "$BOOKMARKS" || die "cannot write $BOOKMARKS" 210 wrote=1 211 fi 212 printf '%s\n' "$version" > "$STATE" 213 wunlock 214 if [ -n "$wrote" ] && command -v bm-commit >/dev/null 2>&1; then 215 bm-commit 'bm-sync: merge' 216 fi 217 [ -e "$LOCK/again" ] || break 218 done 219 say "in sync: $(grep -c -v -e '^#' -e '^[[:space:]]*$' "$BOOKMARKS") bookmarks" 220 } 221 222 while [ $# -gt 0 ]; do 223 case $1 in 224 -q) quiet=1 ;; 225 -h|--help) usage; exit 0 ;; 226 -*) usage >&2; exit 2 ;; 227 *) break ;; 228 esac 229 shift 230 done 231 232 command -v curl >/dev/null 2>&1 || die 'curl is not installed' 233 # mkdir fails when the name exists: a directory planted in a shared /tmp is 234 # refused, not used. 235 work=${TMPDIR:-/tmp}/bm-sync.$$ 236 mkdir -m 700 "$work" || die "cannot create $work" 237 locked= 238 wlocked= 239 trap cleanup EXIT 240 trap 'exit 1' INT TERM 241 242 case ${1:-sync} in 243 login) shift; login "$@" ;; 244 logout) logout ;; 245 status) status ;; 246 sync) sync ;; 247 *) usage >&2; exit 2 ;; 248 esac