config_error() { local -r message="$1"; shift printf 'ERROR: %s\n' "$message" >&2 return 1 } require_config_var() { local -r name="$1"; shift if [ -z "${!name+x}" ] || [ -z "${!name}" ]; then config_error "$name must be set in shuriken configuration" return 1 fi } validate_positive_integer_config_var() { local -r name="$1"; shift local -r value="${!name}" if [[ ! "$value" =~ ^[0-9]+$ ]] || (( value < 1 )); then config_error "$name must be a positive integer" return 1 fi } validate_optional_positive_integer_config_var() { local -r name="$1"; shift if [ -n "${!name:-}" ]; then validate_positive_integer_config_var "$name" || return fi } # A percentage config var: an integer in the inclusive range 0..100. Unlike # validate_positive_integer_config_var, 0 is allowed (it is the natural "off" # value, e.g. THUMB_SUBDIVIDE_PERCENT=0 disables tile subdivision entirely). validate_percentage_config_var() { local -r name="$1"; shift local -r value="${!name}" if [[ ! "$value" =~ ^[0-9]+$ ]] || (( value > 100 )); then config_error "$name must be an integer between 0 and 100" return 1 fi } validate_yes_no_config_var() { local -r name="$1"; shift local -r value="${!name}" case "$value" in yes|no) ;; *) config_error "$name must be yes or no" return 1 ;; esac } validate_dist_dir() { local existing_parent if [ -e "$DIST_DIR" ]; then if [ ! -d "$DIST_DIR" ]; then config_error "DIST_DIR $DIST_DIR must be a directory" return 1 fi if [[ ! -w "$DIST_DIR" || ! -x "$DIST_DIR" ]]; then config_error "DIST_DIR $DIST_DIR must be writable" return 1 fi return fi existing_parent=$(existing_parent_dir "$DIST_DIR") if [ ! -d "$existing_parent" ]; then config_error "DIST_DIR parent $existing_parent must be a directory" return 1 fi if [[ ! -w "$existing_parent" || ! -x "$existing_parent" ]]; then config_error "DIST_DIR parent $existing_parent must be writable" return 1 fi } # The destructive --clean path guard (resolve_dist_dir_path, # validate_clean_dist_dir, and the dangerous-path blocklist) lives in # config.clean-guard.source.sh (task tr0). It is kept in its own module because # it gates an unconditional rm -rf and warrants an isolated test surface; the # functions there still reuse the generic validators below at runtime. validate_template_dir_access() { if [[ ! -d "$TEMPLATE_DIR" || ! -r "$TEMPLATE_DIR" \ || ! -x "$TEMPLATE_DIR" ]]; then config_error "TEMPLATE_DIR $TEMPLATE_DIR must be a readable directory" return 1 fi } validate_template_file() { local -r template_name="$1"; shift if [ ! -r "$TEMPLATE_DIR/$template_name.tmpl" ]; then config_error \ "template file $TEMPLATE_DIR/$template_name.tmpl must be readable" return 1 fi } validate_template_dir() { local template_name local -a required_templates=() validate_template_dir_access || return # details.tmpl is only needed when DETAILS_PAGE=yes actually renders it # (mirrors the SPLASH_PAGE=yes conditional below), so an album that opts out # of details pages does not need to keep a details.tmpl around at all. Kept # first in the list (as it always was) so the reporting order for the # unconditional templates below is unchanged from before DETAILS_PAGE # existed. if [ "$DETAILS_PAGE" = yes ]; then required_templates+=(details) fi required_templates+=( footer header next prev preview previewpage redirect view ) if [ "$SPLASH_PAGE" = yes ]; then required_templates+=(splash) fi for template_name in "${required_templates[@]}"; do validate_template_file "$template_name" || return done } validate_refresh_splash_config() { local required_var local -a required_vars=( TITLE DIST_DIR TEMPLATE_DIR ) for required_var in "${required_vars[@]}"; do require_config_var "$required_var" || return done validate_yes_no_config_var SPLASH_PAGE || return if [ "$SPLASH_PAGE" != yes ]; then config_error 'SPLASH_PAGE must be yes to refresh the splash page' return 1 fi validate_dist_dir || return validate_template_dir_access || return validate_template_file splash || return if [ ! -d "$DIST_DIR/photos" ]; then config_error "DIST_DIR photos directory $DIST_DIR/photos must exist" return 1 fi if [ ! -d "$DIST_DIR/blurs" ]; then config_error "DIST_DIR blurs directory $DIST_DIR/blurs must exist" return 1 fi } validate_imagemagick() { # Reuse the canonical detection in resolve_imagemagick_command instead of # duplicating the magick/convert probing here. Its own error message is # suppressed so we report the failure through config_error, keeping the # validation output consistent (single "ERROR: ..." line, return code 1). # imagemagick_command is a nameref output filled by the resolver; we only # care about the exit status here, not the resolved command. # shellcheck disable=SC2034 local -a imagemagick_command=() if resolve_imagemagick_command convert imagemagick_command 2>/dev/null; then return fi config_error 'ImageMagick is required; install magick or convert' return 1 } # Look up a config field's validation facet (index 4) from CONFIG_SPECS. Prints # the validation token (e.g. required-posint, percentage, yesno) or empty if the # field is unknown / has no validation rule. The single place validate_* # dispatchers learn which rule a field uses (task mr0). config_spec_validation() { local -r name="$1"; shift local spec local -a fields=() for spec in "${CONFIG_SPECS[@]}"; do config_spec_split "$spec" fields if [ "${fields[0]}" = "$name" ]; then printf '%s\n' "${fields[4]}" return 0 fi done return 1 } # Run a config field's "required" check based on its registry validation facet. # Only required / required-posint fields are required-to-be-set; everything else # is a no-op here. Used by validate_common_config's required pass so the set of # required vars derives from the registry instead of a separate hand-kept list. validate_config_field_required() { local -r name="$1"; shift local validation validation=$(config_spec_validation "$name") case "$validation" in required|required-posint) require_config_var "$name" || return 1 ;; esac } # Run a config field's "kind" check based on its registry validation facet, # dispatching to the matching validator. This is the single source for "which # rule validates which field" (task mr0): validate_common_config calls this per # field in its historical order, so the rule lives in CONFIG_SPECS while the # error-reporting order stays byte-identical. The required-only facet has no kind # check (the required pass covers it); required-posint additionally enforces a # positive integer here. validate_config_field_kind() { local -r name="$1"; shift local validation validation=$(config_spec_validation "$name") case "$validation" in required) ;; required-posint|posint) validate_positive_integer_config_var "$name" || return 1 ;; opt-posint) validate_optional_positive_integer_config_var "$name" || return 1 ;; percentage) validate_percentage_config_var "$name" || return 1 ;; yesno) validate_yes_no_config_var "$name" || return 1 ;; favicon) validate_favicon_config || return 1 ;; esac } # Validate the config fields shared by every action that needs a loaded config. # The set of required vars and each field's validation rule come from CONFIG_SPECS # via the dispatchers above (task mr0). The two phases (all required checks, then # all kind checks) are preserved deliberately: a missing required var must be # reported before a malformed one (test_config_validators_fail_fast_without_errexit # proves an unset TITLE is reported while a bad THUMBHEIGHT is not). The per-field # call order below is the historical reporting order, so error messages for a # config with several problems appear in the same sequence as before. validate_common_config() { local required_var local kind_var local -a required_vars=( TITLE THUMBHEIGHT MAXPREVIEWS IMAGE_JOBS INCOMING_DIR DIST_DIR TEMPLATE_DIR ) local -a kind_vars=( HEIGHT THUMBHEIGHT MAXPREVIEWS THUMB_SUBDIVIDE_PERCENT THUMB_FEATURE_PERCENT IMAGE_JOBS IMAGEMAGICK_TIMEOUT TAR_TIMEOUT SYNC_TIMEOUT CHRONOLOGICAL_ORDER SHUFFLE SPLASH_PAGE DETAILS_PAGE STATS_PAGE TARBALL_INCLUDE FAVICON ) for required_var in "${required_vars[@]}"; do validate_config_field_required "$required_var" || return done for kind_var in "${kind_vars[@]}"; do validate_config_field_kind "$kind_var" || return done } # A custom FAVICON (when set) must be a readable file; empty means the bundled # default favicon is used. validate_favicon_config() { if [ -z "${FAVICON:-}" ]; then return fi if [ ! -f "$FAVICON" ] || [ ! -r "$FAVICON" ]; then config_error "FAVICON file $FAVICON must be a readable file" return 1 fi } validate_generation_config() { local -r require_imagemagick="${1:-yes}" validate_common_config || return if [ ! -d "$INCOMING_DIR" ]; then config_error "You have to create $INCOMING_DIR first" return 1 fi if [[ ! -r "$INCOMING_DIR" || ! -x "$INCOMING_DIR" ]]; then config_error "INCOMING_DIR $INCOMING_DIR must be readable" return 1 fi validate_dist_dir || return validate_template_dir || return if [ "$require_imagemagick" = yes ]; then validate_imagemagick || return fi } validate_print_config() { # Passed by name to resolve_tar_opts. # shellcheck disable=SC2034 local -a tar_opts=() local -a sync_destinations=() validate_common_config || return resolve_tar_opts tar_opts resolve_sync_destinations sync_destinations validate_yes_no_config_var SYNC_DELETE || return } validate_sync_destinations() { local -a sync_destinations=() resolve_sync_destinations sync_destinations if (( ${#sync_destinations[@]} == 0 )); then config_error 'SYNC_DESTINATIONS must contain at least one destination' return 1 fi } validate_rsync() { if command -v rsync >/dev/null 2>&1; then return fi config_error 'rsync is required to sync generated output' return 1 } validate_sync_config() { require_config_var DIST_DIR || return validate_yes_no_config_var SYNC_DELETE || return # SYNC_TIMEOUT bounds each per-destination rsync in sync_dist, so it must be # a positive integer on the sync path too (the generate path validates it via # validate_config). Without this, a bogus value would only surface as a # confusing "timeout: invalid time interval" at run time. validate_positive_integer_config_var SYNC_TIMEOUT || return validate_sync_destinations || return if [[ ! -d "$DIST_DIR" || ! -r "$DIST_DIR" || ! -x "$DIST_DIR" ]]; then config_error "DIST_DIR $DIST_DIR must be a readable directory" return 1 fi validate_rsync || return }