diff --git a/README.md b/README.md index 31edfe8..6b48497 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,4 @@ * [LShar](./lshar.sh): create self-extracting archives in the form of shell scripts * [ShellBeat](./shellbeat.sh): a [bytebeat](https://stellartux.github.io/websynth/guide.html) audio formula player in pure shell (also depends on xxd) * [GHMD](./ghmd.sh): a command suite to set up your own self-hosted Git repositories +* [Multicities](./cities.sh): Neocities static site manager with multiple account support diff --git a/cities.sh b/cities.sh new file mode 100755 index 0000000..efd8be1 --- /dev/null +++ b/cities.sh @@ -0,0 +1,140 @@ +#!/bin/sh +# Multicities: Neocities static site manager with multiple account support +# Written in POSIX shell +# Depends on mktemp, basename, realpath, sha1sum, curl and jq +# Run without parameters to get help +# Created by Luxferre in 2025, released into public domain + +REQCMD='curl -sSL' +APIROOT='https://neocities.org/api' +APICONF="${HOME}/.multicities.json" +[ ! -f "$APICONF" ] && echo '{}' > $APICONF + +# Read secret string +read_secret() { + trap 'stty echo' EXIT + stty -echo + read -r "$@" + stty echo + trap - EXIT + printf '\n' +} + +# login [username]: convert password to token +login() { + user="$1" + printf 'Password: ' + read_secret npass + token=$($REQCMD -u "$user:$npass" ${APIROOT}/key | jq -rj '.api_key') + if [ "$token" != 'null' ]; then + tmpfile=$(mktemp -t 'cities.XXXXXX') + cat $APICONF | jq --arg u "$user" --arg t $token '. += {$u:$t}' > $tmpfile && mv $tmpfile $APICONF + echo 'Login successful!' + else + echo 'Cannot login, please check the username and password!' + exit 1 + fi +} + +# auth [username]: get auth header (empty if no username present) +auth() { + token="$(cat $APICONF | jq -rj --arg u "$1" '.[$u]')" + if [ "$token" != 'null' ]; then + printf 'Authorization: Bearer %s' "$token" + else + printf '' + fi +} + +# api call helper +apiget() { + $REQCMD -H "$AUTH_HDR" ${APIROOT}/$1 +} + +# single file upload +upload() { + fpath="$(realpath "$1")" + if [ -z "$2" ]; then + localname="$(basename "$fpath")" + else + localname="$2" + fi + $REQCMD -H "$AUTH_HDR" -F "${localname}=@${fpath}" ${APIROOT}/upload +} + +# site info +cmd_info() { + info_obj="$(apiget info | jq -r '.info | @text')" + printf 'Sitename: %s\n' "$(echo $info_obj | jq -r '.sitename')" + printf 'Views: %s\n' "$(echo $info_obj | jq -r '.views')" + printf 'Hits: %s\n' "$(echo $info_obj | jq -r '.hits')" + printf 'Created at: %s\n' "$(echo $info_obj | jq -r '.created_at')" + printf 'Last updated at: %s\n' "$(echo $info_obj | jq -r '.last_updated')" + printf 'Custom domain: %s\n' "$(echo $info_obj | jq -r '.domain // "None"')" + printf 'Tags: %s\n' "$(echo $info_obj | jq -r '.tags | @csv')" +} + +# site file list +cmd_list() { + apiget list | jq -r '.files[] | [if .is_directory then "dir" else "file" end, .size // 0, .path] | @tsv' +} + +# upload individual files +cmd_upload() { + upload "$1" "$2" +} + +# delete individual remote files +cmd_delete() { + echo -n 'Confirm file deletion (y to confirm)? '; + read -r resp + if [ "$resp" == "y" ]; then + $REQCMD -H "$AUTH_HDR" -d "filenames[]=$1" ${APIROOT}/delete + else + echo 'Operation cancelled' + fi +} + +# upload the entire site directory +cmd_sync() { + echo "Getting file list..." + # shape the path list with path as key and hash as value + flist="$(apiget list | jq -r '[.files[] | [.path, .sha1_hash] | { (.[0]) : .[1] }] | add | @json')" + projdir="$1" + [ -z "$1" ] && projdir="$(pwd)" + # iterate over the project directory + find "$projdir" \( ! -path '*/.*' \) -type f -printf '%P\n' | while read -r f; do + fullpath=$(realpath "${projdir}/$f") + cursha="$(sha1sum -b "$fullpath" | cut -f 1 -d ' ')" + prevsha="$(printf '%s' "$flist" | jq -r --arg p "$f" '.[$p]')" + if [ "$prevsha" != "$cursha" ]; then # do the upload + echo "Updating $f (prev: $prevsha, cur: $cursha)" + upload "$fullpath" "$f" + fi + done + echo "All files synced!" +} + +# entry point +if [ -z "$1" ]; then + echo "Usage: cities.sh sitename command" + echo "where command is one of:" + echo "* login" + echo "* info" + echo "* list" + echo "* upload file_path [remote_path]" + echo "* delete remote_path (will ask for confirmation)" + echo "* sync [local_dir]" +else + username="$1"; shift # fetch and shift out username + cmdname="$1"; shift # fetch and shift out command name + if [ "$cmdname" != "login" ]; then + AUTH_HDR="$(auth $username)" + if [ -z "$AUTH_HDR" ]; then + login "$username" && AUTH_HDR="$(auth $username)" + fi + cmd_${cmdname} $@ || echo "Invalid command!" + else + login "$username" + fi +fi