Ctrl K
ComunidadGuías…

classroomio.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
#
# ClassroomIO self-host lifecycle manager.
#
# Run with no arguments for an interactive menu, or pass a subcommand directly:
#
#   ./classroomio.sh                 # interactive menu
#   ./classroomio.sh install         # first-time setup: fetch files, create .env, start
#   ./classroomio.sh start           # non-interactive start (env auto-setup + up -d)
#   ./classroomio.sh stop|restart|upgrade|logs|backup|migrate-storage
#
# The script is standalone: run it in an empty directory and `install` downloads
# docker-compose.images.yaml + .env.example from GitHub. Inside a repo checkout it
# uses the local files. It never overwrites an existing .env or (unless it still uses MinIO) compose file.
#
# The compose file tracks `main` (the stack topology); CIO_VERSION in .env pins the
# app images. Pin an exact release (e.g. 1.4.2) in production.

set -euo pipefail

ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_NAME="classroomio"
ENV_FILE="${ROOT_DIR}/.env"
ENV_EXAMPLE_FILE="${ROOT_DIR}/.env.example"
BUILD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.yaml"
IMAGES_COMPOSE_FILE="${ROOT_DIR}/docker-compose.images.yaml"
RAW_BASE_URL="https://raw.githubusercontent.com/classroomio/classroomio/main"

# Default to PULLING pre-built images (fast). Pass --build to build from source.
USE_IMAGES=true
COMPOSE_FILE="${IMAGES_COMPOSE_FILE}"
# --profile flags live in ONE variable so every command (start, stop, logs, backup)
# sees the same profile state. --no-storage empties it.
PROFILE_ARGS="--profile storage"

# Pre-SeaweedFS installs keep their uploads in this volume unless MINIO_LEGACY_DATA says otherwise.
LEGACY_MINIO_VOLUME="${PROJECT_NAME}_minio-data"
API_IMAGE_REPO="classroomio/api"
# Set on app images whose upload URLs the bundled SeaweedFS accepts.
SEAWEEDFS_READY_LABEL="com.classroomio.storage.seaweedfs-ready"
# Reads the old volume when no MinIO image is cached; override with MINIO_LEGACY_IMAGE.
LEGACY_MINIO_FALLBACK_IMAGE="pgsty/silo:RELEASE.2026-09-16T00-00-00Z"
RCLONE_IMAGE="rclone/rclone:1.75.1"

print_usage() {
  cat <<'USAGE'
Usage: ./classroomio.sh [options] [command] [args]

Commands:
  install     First-time setup: fetch compose file + .env.example (if missing),
              create .env, generate secrets, pull images and start the stack.
  start       Start the stack (auto-generates missing secrets first).
  stop        Stop all containers (data volumes are kept).
  restart     Restart all containers.
  upgrade     Back up first, then pull newer images and restart.
  logs [svc]  Follow logs (all services, or one: api, dashboard, jobs, postgres, ...).
  backup      Dump Postgres and archive the object-storage volume into ./backups/.
  migrate-storage
              Copy uploads from the old bundled MinIO volume into the bundled
              SeaweedFS store. start/restart/upgrade run this automatically when needed.

Run without a command to get an interactive menu.

Options:
  --build       Use docker-compose.yaml and build images from source instead of pulling.
                Requires a local repo checkout (git clone) — not available in a
                standalone directory set up via 'install'.
  --no-storage  Exclude the bundled object storage (requires external S3-compatible
                storage). --no-minio is accepted as a deprecated alias.
  -h, --help    Show this help message.
USAGE
}

# ──────────────────────────────────────────────
# Compose CLI detection: `docker compose` (v2) or legacy `docker-compose`.
# ──────────────────────────────────────────────

COMPOSE_CMD=""

detect_compose() {
  if ! command -v docker >/dev/null 2>&1; then
    echo "Error: docker is not installed or not on PATH."
    exit 1
  fi
  if docker compose version >/dev/null 2>&1; then
    COMPOSE_CMD="docker compose"
  elif command -v docker-compose >/dev/null 2>&1; then
    COMPOSE_CMD="docker-compose"
  else
    echo "Error: neither 'docker compose' nor 'docker-compose' is available."
    exit 1
  fi
}

# All compose invocations go through this so the env file, project name, compose
# file, and profiles are consistent everywhere. Word-splitting of COMPOSE_CMD and
# PROFILE_ARGS is intentional.
compose() {
  # shellcheck disable=SC2086
  ${COMPOSE_CMD} --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${PROFILE_ARGS} "$@"
}

# ──────────────────────────────────────────────
# .env helpers
# ──────────────────────────────────────────────

get_env_value() {
  local key="$1"
  local line
  line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)"
  printf '%s' "${line#*=}"
}

upsert_env_value() {
  local key="$1"
  local value="$2"
  local tmp_file

  tmp_file="$(mktemp)"
  awk -v key="${key}" -v value="${value}" '
    BEGIN { updated = 0 }
    $0 ~ ("^" key "=") {
      if (updated == 0) {
        print key "=" value;
        updated = 1;
      }
      next;
    }
    { print }
    END {
      if (updated == 0) {
        print key "=" value;
      }
    }
  ' "${ENV_FILE}" > "${tmp_file}"

  mv "${tmp_file}" "${ENV_FILE}"
}

is_insecure_token_value() {
  local value="$1"
  local normalized

  normalized="$(printf '%s' "${value}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"

  if [[ "${normalized}" =~ ^\"(.*)\"$ ]]; then
    normalized="${BASH_REMATCH[1]}"
  elif [[ "${normalized}" =~ ^\'(.*)\'$ ]]; then
    normalized="${BASH_REMATCH[1]}"
  fi

  case "${normalized}" in
    "" | "replace-with-a-long-random-token" | "local-dev-api-key" | "changeme" | "replace-me")
      return 0
      ;;
    replace-with-* | *local-dev* | *change-this* | *your-*)
      return 0
      ;;
    *)
      return 1
      ;;
  esac
}

generate_secure_token() {
  if command -v openssl >/dev/null 2>&1; then
    openssl rand -hex 32
    return
  fi

  if command -v node >/dev/null 2>&1; then
    node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
    return
  fi

  if command -v python3 >/dev/null 2>&1; then
    python3 -c "import secrets; print(secrets.token_hex(32))"
    return
  fi

  if [[ -r /dev/urandom ]]; then
    head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'
    echo
    return
  fi

  # Last resort only: no openssl, node, python3, or /dev/urandom. A timestamp-derived
  # value is weak (guessable within the install's time window) but better than failing.
  date +%s%N | shasum | awk '{ print $1 }'
}

ensure_secure_auth_tokens() {
  if [[ ! -f "${ENV_FILE}" ]]; then
    touch "${ENV_FILE}"
  fi

  local private_server_key
  private_server_key="$(get_env_value PRIVATE_SERVER_KEY)"

  # PRIVATE_SERVER_KEY authenticates the dashboard's server-side calls to the API and must be
  # identical in both services. Generate a strong value when it's empty or a known placeholder.
  if is_insecure_token_value "${private_server_key}"; then
    upsert_env_value PRIVATE_SERVER_KEY "$(generate_secure_token)"
    echo "Generated secure PRIVATE_SERVER_KEY in .env"
  fi
}

ensure_secure_betterauth_secret() {
  if [[ ! -f "${ENV_FILE}" ]]; then
    touch "${ENV_FILE}"
  fi

  local secret
  secret="$(get_env_value BETTER_AUTH_SECRET)"

  # Only (re)generate when the value is empty or a known placeholder; never clobber
  # a strong secret the user has set themselves.
  if is_insecure_token_value "${secret}"; then
    upsert_env_value BETTER_AUTH_SECRET "$(generate_secure_token)"
    echo "Generated secure BETTER_AUTH_SECRET in .env"
  fi
}

is_local_origin() {
  local value="$1"
  case "${value}" in
    "" | *localhost* | *127.0.0.1* | *0.0.0.0*)
      return 0
      ;;
    *)
      return 1
      ;;
  esac
}

# Point browser-facing storage URLs at the public domain when DASHBOARD_ORIGIN is not
# localhost. Bundled media is served on :9000 — operators must reverse-proxy /media to it.
# `mode` is "bundled" (ensure_bundled_storage_env) or "external" (ensure_storage_env's
# --no-storage path). For bundled storage, OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL is script-owned, so we
# keep it in sync with DASHBOARD_ORIGIN on every run — otherwise changing DASHBOARD_ORIGIN
# after install would leave media links pointed at the old domain forever. For external
# storage it's operator-owned (their CDN/bucket URL), so we only fill in a default and
# never clobber an explicit value.
derive_public_storage_urls() {
  local mode="$1"
  local dashboard_origin public_endpoint media_base
  dashboard_origin="$(get_env_value DASHBOARD_ORIGIN)"
  public_endpoint="$(get_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT)"
  media_base="$(get_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL)"

  if ! is_local_origin "${dashboard_origin}"; then
    local origin="${dashboard_origin%/}"
    local derived="${origin}/media"
    # Public media is served from the public-download `media` bucket, so deriving its base
    # URL from the domain is what fixes broken media links in served pages. We deliberately
    # do NOT touch OBJECT_STORAGE_PUBLIC_ENDPOINT (the S3 API endpoint) — pointing it at the
    # dashboard origin would break presigned URLs for the non-public videos/documents buckets.
    if [[ "${mode}" == "bundled" ]]; then
      if [[ "${media_base}" != "${derived}" ]]; then
        upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${derived}"
        echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${derived})."
        echo "  -> Ensure your reverse proxy routes ${origin}/media to the storage service (port 9000)."
        echo "  -> For presigned access to the videos/documents buckets behind a domain, expose"
        echo "     the storage S3 API on its own route and set OBJECT_STORAGE_PUBLIC_ENDPOINT explicitly."
      fi
    else
      if is_local_origin "${media_base}"; then
        upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${derived}"
        echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${derived})."
      fi
    fi
    allow_storage_in_csp
  else
    # Local demo: ensure localhost defaults are present.
    if [[ -z "${public_endpoint}" ]]; then
      upsert_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT "http://localhost:9000"
    fi
    if [[ -z "${media_base}" ]]; then
      upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "http://localhost:9000/media"
    fi
  fi
}

url_origin() {
  printf '%s' "$1" | sed -E 's#^(https?://[^/]+).*#\1#'
}

add_csp_domain() {
  local key="$1" origin="$2" current
  current="$(get_env_value "${key}")"
  # A bare host in the list means https://host, so both forms count as already listed.
  if [[ -z "${origin}" ]] || is_local_origin "${origin}" ||
    [[ ",${current// /}," == *",${origin},"* || ",${current// /}," == *",${origin#https://},"* ]]; then
    return 0
  fi
  upsert_env_value "${key}" "${current:+${current},}${origin}"
  echo "Allowed ${origin} in ${key} so browsers can reach object storage."
}

# Browsers upload straight to storage and load media from it, so the dashboard's CSP must allow both.
allow_storage_in_csp() {
  # ALLOWED_EXTERNAL_DOMAINS replaces every per-directive list; it's operator-owned.
  if [[ -n "$(get_env_value ALLOWED_EXTERNAL_DOMAINS)" ]]; then
    return 0
  fi
  local endpoint media
  endpoint="$(url_origin "$(get_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT)")"
  media="$(url_origin "$(get_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL)")"
  add_csp_domain CSP_CONNECT_SRC_DOMAINS "${endpoint}"
  add_csp_domain CSP_MEDIA_SRC_DOMAINS "${endpoint}"
  if [[ "${media}" != "$(url_origin "$(get_env_value DASHBOARD_ORIGIN)")" ]]; then
    add_csp_domain CSP_MEDIA_SRC_DOMAINS "${media}"
  fi
}

is_legacy_minio_endpoint() {
  [[ "$(get_env_value OBJECT_STORAGE_ENDPOINT)" == *"minio:9000"* ]]
}

ensure_bundled_storage_env() {
  if [[ ! -f "${ENV_FILE}" ]]; then
    touch "${ENV_FILE}"
  fi

  local secret
  secret="$(get_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY)"

  # Randomize the key pair while the secret is still empty or a known placeholder.
  if [[ "${secret}" == "minioadmin" ]] || is_insecure_token_value "${secret}"; then
    upsert_env_value OBJECT_STORAGE_ACCESS_KEY_ID "cio-$(generate_secure_token | cut -c1-16)"
    upsert_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY "$(generate_secure_token)"
    upsert_env_value OBJECT_STORAGE_FORCE_PATH_STYLE "true"
    echo "Provisioned bundled object storage with randomized credentials in .env"
  fi

  # A legacy minio:9000 endpoint is left alone: it marks an install that still needs migrating.
  if [[ -z "$(get_env_value OBJECT_STORAGE_ENDPOINT)" ]]; then
    upsert_env_value OBJECT_STORAGE_ENDPOINT "http://storage:9000"
  fi

  derive_public_storage_urls "bundled"
}

# ──────────────────────────────────────────────
# Shared checks
# ──────────────────────────────────────────────

# When bundled storage is excluded the user must supply external object storage, or
# uploads/media silently break. Fail fast instead. The endpoint must be set AND not still
# point at a bundled host (which won't be running with --no-storage).
ensure_storage_env() {
  if [[ "${PROFILE_ARGS}" == *"storage"* ]]; then
    ensure_bundled_storage_env
  else
    local external_endpoint
    external_endpoint="$(get_env_value OBJECT_STORAGE_ENDPOINT)"
    if [[ -z "${external_endpoint}" || "${external_endpoint}" == *"storage:9000"* || "${external_endpoint}" == *"minio:9000"* ]]; then
      echo "Error: --no-storage was passed but no external object storage is configured."
      echo "OBJECT_STORAGE_ENDPOINT is unset or still points at the bundled store (storage:9000)."
      echo "Configure an external S3-compatible store via OBJECT_STORAGE_* in .env,"
      echo "or drop --no-storage to use the bundled store."
      exit 1
    fi
    derive_public_storage_urls "external"
  fi
}

warn_if_unpinned_version() {
  local version
  version="$(get_env_value CIO_VERSION)"
  if [[ -z "${version}" || "${version}" == "latest" ]]; then
    echo
    echo "WARNING: CIO_VERSION is '${version:-unset}' — you are tracking the rolling 'latest' tag."
    echo "  'latest' moves on every merge to main and may include unreleased code."
    echo "  For production, pin an exact release in .env, e.g.:  CIO_VERSION=1.4.2"
    echo "  Available tags: https://hub.docker.com/r/classroomio/api/tags"
    echo
  fi
}

# `docker compose up -d` returns once containers are *started*, not *healthy*. On a fresh
# deployment the API runs DB migrations before it listens, so the endpoints aren't reachable
# immediately. Poll with a retry instead of a single curl — and never let a not-yet-ready
# check abort the script (set -euo pipefail is active), so first runs end cleanly.
check_endpoint() {
  local name="$1" url="$2" curl_flags="$3" attempts="$4"
  local i=0
  while ((i < attempts)); do
    if curl "${curl_flags}" --max-time 5 "${url}" >/dev/null 2>&1; then
      echo "${name} is reachable on ${url}"
      return 0
    fi
    i=$((i + 1))
    sleep 5
  done
  echo "Note: ${name} is not reachable yet at ${url} (waited $((attempts * 5))s)."
  echo "  It may still be starting — the first run migrates the database. Follow logs with:"
  echo "  ./classroomio.sh logs"
  return 0
}

wait_for_endpoints() {
  if command -v curl >/dev/null 2>&1; then
    echo
    echo "Waiting for services to come up (first run can take a minute while the API migrates)..."
    check_endpoint "API" "http://localhost:3081/" "-fsS" 24
    check_endpoint "Dashboard" "http://localhost:3082/" "-fsSI" 12
  else
    echo
    echo "curl not found, skipped endpoint checks."
  fi
}

fetch_if_missing() {
  local file="$1" url="$2"
  if [[ -f "${file}" ]]; then
    echo "Keeping existing $(basename "${file}") (not overwritten)."
    return 0
  fi
  echo "Downloading $(basename "${file}")..."
  # Download to a temp name and move into place so a failed transfer can never
  # leave a partial file that a re-run would mistake for a kept local copy.
  local tmp="${file}.download"
  if command -v curl >/dev/null 2>&1; then
    curl -fsSL --retry 3 --retry-delay 2 -o "${tmp}" "${url}" || { rm -f "${tmp}"; echo "Error: failed to download ${url}"; exit 1; }
  elif command -v wget >/dev/null 2>&1; then
    wget -q -O "${tmp}" "${url}" || { rm -f "${tmp}"; echo "Error: failed to download ${url}"; exit 1; }
  else
    echo "Error: need curl or wget to download $(basename "${file}")."
    exit 1
  fi
  mv "${tmp}" "${file}"
}

# A downloaded compose file that still references the removed MinIO images can't be pulled; replace it.
refresh_stale_compose_file() {
  if [[ "${USE_IMAGES}" != "true" || ! -f "${IMAGES_COMPOSE_FILE}" ]]; then
    return 0
  fi
  if ! grep -q 'image: minio/' "${IMAGES_COMPOSE_FILE}"; then
    return 0
  fi
  local backup="${IMAGES_COMPOSE_FILE}.bak"
  mv "${IMAGES_COMPOSE_FILE}" "${backup}"
  echo "$(basename "${IMAGES_COMPOSE_FILE}") still references the removed MinIO images."
  echo "  Kept your copy as $(basename "${backup}") — re-apply any local edits to the new file."
  # Subshell so a failed download can't exit before the old file is restored.
  if ! (fetch_if_missing "${IMAGES_COMPOSE_FILE}" "${RAW_BASE_URL}/docker-compose.images.yaml"); then
    mv "${backup}" "${IMAGES_COMPOSE_FILE}"
    exit 1
  fi
}

# Reported in the anonymous usage ping; kept if the user already set it.
ensure_deployment_method() {
  if [[ -z "$(get_env_value CIO_DEPLOYMENT_METHOD)" ]]; then
    upsert_env_value CIO_DEPLOYMENT_METHOD "docker"
  fi
}

prepare_env_and_secrets() {
  refresh_stale_compose_file
  ensure_secure_auth_tokens
  ensure_secure_betterauth_secret
  ensure_storage_env
  ensure_deployment_method
}

# ──────────────────────────────────────────────
# Legacy MinIO → SeaweedFS migration
# ──────────────────────────────────────────────

# rclone on the compose network: src = old MinIO, dst = new store.
run_rclone() {
  (
    export RCLONE_CONFIG_SRC_TYPE=s3
    export RCLONE_CONFIG_SRC_PROVIDER=Minio
    export RCLONE_CONFIG_SRC_ENDPOINT="http://cio-minio-legacy:9000"
    export RCLONE_CONFIG_SRC_ACCESS_KEY_ID="${LEGACY_ACCESS_KEY}"
    export RCLONE_CONFIG_SRC_SECRET_ACCESS_KEY="${LEGACY_SECRET_KEY}"
    export RCLONE_CONFIG_DST_TYPE=s3
    export RCLONE_CONFIG_DST_PROVIDER=SeaweedFS
    export RCLONE_CONFIG_DST_ENDPOINT="http://storage:9000"
    RCLONE_CONFIG_DST_ACCESS_KEY_ID="$(get_env_value OBJECT_STORAGE_ACCESS_KEY_ID)"
    RCLONE_CONFIG_DST_SECRET_ACCESS_KEY="$(get_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY)"
    export RCLONE_CONFIG_DST_ACCESS_KEY_ID RCLONE_CONFIG_DST_SECRET_ACCESS_KEY
    # "notfound" silences rclone's missing-config notice.
    export RCLONE_CONFIG=notfound
    docker run --rm --network "${PROJECT_NAME}_default" -e RCLONE_CONFIG \
      -e RCLONE_CONFIG_SRC_TYPE -e RCLONE_CONFIG_SRC_PROVIDER -e RCLONE_CONFIG_SRC_ENDPOINT \
      -e RCLONE_CONFIG_SRC_ACCESS_KEY_ID -e RCLONE_CONFIG_SRC_SECRET_ACCESS_KEY \
      -e RCLONE_CONFIG_DST_TYPE -e RCLONE_CONFIG_DST_PROVIDER -e RCLONE_CONFIG_DST_ENDPOINT \
      -e RCLONE_CONFIG_DST_ACCESS_KEY_ID -e RCLONE_CONFIG_DST_SECRET_ACCESS_KEY \
      "${RCLONE_IMAGE}" "$@"
  )
}

pick_legacy_minio_image() {
  if [[ -n "${MINIO_LEGACY_IMAGE:-}" ]]; then
    printf '%s' "${MINIO_LEGACY_IMAGE}"
    return
  fi
  local candidate
  for candidate in minio/minio:latest quay.io/minio/minio:latest; do
    if docker image inspect "${candidate}" >/dev/null 2>&1; then
      printf '%s' "${candidate}"
      return
    fi
  done
  printf '%s' "${LEGACY_MINIO_FALLBACK_IMAGE}"
}

# Prints where the old MinIO data lives (volume name or host path), or nothing if there is none.
find_legacy_minio_data() {
  local data
  data="$(legacy_minio_data_setting)"
  if [[ "${data}" == "none" ]]; then
    return 0
  fi
  if [[ -z "${data}" ]]; then
    # The old container's own mount covers custom volume names and bind mounts.
    data="$(MSYS_NO_PATHCONV=1 docker inspect cio-minio --format \
      '{{range .Mounts}}{{if eq .Destination "/data"}}{{if eq .Type "volume"}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' \
      2>/dev/null || true)"
  fi
  data="${data:-${LEGACY_MINIO_VOLUME}}"
  data="${data//\\//}"
  # It must already exist: mounting a missing volume or path would silently create it empty.
  if [[ "${data}" == */* ]]; then
    [[ -e "${data}" ]] || return 0
  elif ! docker volume inspect "${data}" >/dev/null 2>&1; then
    return 0
  fi
  printf '%s' "${data}"
}

# A volume name, a host path, or "none" to confirm there is nothing to copy.
legacy_minio_data_setting() {
  printf '%s' "${MINIO_LEGACY_DATA:-$(get_env_value MINIO_LEGACY_DATA)}"
}

install_has_run_before() {
  docker volume inspect "${PROJECT_NAME}_postgres-data" >/dev/null 2>&1 ||
    docker inspect cio-postgres >/dev/null 2>&1 || docker inspect cio-minio >/dev/null 2>&1
}

# rclone --combined: "+" missing in the new store, "!" unreadable, "*" differs.
# "*" is only legitimate on a forced re-run, where the app may have changed objects since.
copy_report_ok() {
  local check_ok="$1" report="$2" allow_changed="$3"
  if grep -q '^[+!] ' <<<"${report}"; then
    return 1
  fi
  if grep -q '^\* ' <<<"${report}"; then
    [[ "${allow_changed}" == "true" ]]
    return
  fi
  [[ "${check_ok}" == "true" ]]
}

app_is_seaweedfs_ready() {
  local version image
  version="$(get_env_value CIO_VERSION)"
  image="${API_IMAGE_REPO}:${version:-latest}"
  if ! docker image inspect "${image}" >/dev/null 2>&1; then
    echo "Downloading ${image} to check it supports the new storage (this can take a few minutes)..." >&2
  fi
  docker pull -q "${image}" >/dev/null 2>&1 || true
  [[ "$(docker image inspect "${image}" --format "{{index .Config.Labels \"${SEAWEEDFS_READY_LABEL}\"}}" 2>/dev/null)" == "true" ]]
}

# Older app images sign upload URLs that SeaweedFS rejects, so never switch stores under one.
require_seaweedfs_ready_app() {
  if [[ "${USE_IMAGES}" != "true" ]] || app_is_seaweedfs_ready; then
    return 0
  fi
  echo "Error: CIO_VERSION=$(get_env_value CIO_VERSION) predates the switch to SeaweedFS storage."
  echo "  Browser uploads from that app version fail against the new store (BadDigest)."
  echo "  Set CIO_VERSION in .env to a release that includes the switch, then re-run."
  echo "  Your storage has not been switched."
  exit 1
}

# Copies every bucket out of the old MinIO data over the S3 API; nothing is deleted or overwritten.
migrate_legacy_minio_data() {
  local legacy_data="$1" legacy_image bucket buckets=()
  legacy_image="$(pick_legacy_minio_image)"
  # Saved so a re-run still finds the data after the old container is removed below.
  upsert_env_value MINIO_LEGACY_DATA "${legacy_data}"
  LEGACY_ACCESS_KEY="$(get_env_value MINIO_ROOT_USER)"
  LEGACY_ACCESS_KEY="${LEGACY_ACCESS_KEY:-minioadmin}"
  LEGACY_SECRET_KEY="$(get_env_value MINIO_ROOT_PASSWORD)"
  LEGACY_SECRET_KEY="${LEGACY_SECRET_KEY:-minioadmin}"

  for bucket in OBJECT_STORAGE_BUCKET_VIDEOS:videos OBJECT_STORAGE_BUCKET_DOCUMENTS:documents OBJECT_STORAGE_BUCKET_MEDIA:media; do
    local name
    name="$(get_env_value "${bucket%%:*}")"
    buckets+=("${name:-${bucket##*:}}")
  done

  echo "Migrating uploads from the old bundled MinIO to the new bundled store (SeaweedFS)..."
  echo "  Every object is copied, so this needs free disk space about equal to your current"
  echo "  uploads. The app is stopped while it runs; the old data (${legacy_data}) is left untouched."

  compose stop api dashboard jobs >/dev/null 2>&1 || true
  # The old containers still hold port 9000.
  docker rm -f cio-minio cio-minio-init cio-minio-legacy >/dev/null 2>&1 || true

  compose up -d storage storage-init
  docker wait cio-storage-init >/dev/null

  echo "Reading the old data with ${legacy_image}..."
  MSYS_NO_PATHCONV=1 docker run -d --name cio-minio-legacy \
    --network "${PROJECT_NAME}_default" \
    -e MINIO_ROOT_USER="${LEGACY_ACCESS_KEY}" \
    -e MINIO_ROOT_PASSWORD="${LEGACY_SECRET_KEY}" \
    -v "${legacy_data}:/data" \
    "${legacy_image}" server /data >/dev/null

  local attempt=0 listing=""
  until listing="$(run_rclone lsd src: 2>/dev/null)"; do
    attempt=$((attempt + 1))
    if ((attempt >= 30)); then
      echo "Error: could not read the old MinIO data at ${legacy_data} (see: docker logs cio-minio-legacy)."
      echo "  Check MINIO_ROOT_USER / MINIO_ROOT_PASSWORD in .env, or set MINIO_LEGACY_IMAGE to a"
      echo "  MinIO-compatible image, then re-run: ./classroomio.sh migrate-storage"
      docker rm -f cio-minio-legacy >/dev/null 2>&1 || true
      return 1
    fi
    sleep 2
  done

  for bucket in "${buckets[@]}"; do
    if ! grep -qE "[[:space:]]${bucket}\$" <<<"${listing}"; then
      echo "  ${bucket}: not in the old store — skipping."
      continue
    fi
    echo "  ${bucket}: copying..."
    # --ignore-existing: a repeat run never overwrites or deletes what is already in the new store.
    local report="" check_ok=true changed
    if run_rclone copy --ignore-existing --stats 30s --stats-one-line --stats-log-level NOTICE "src:${bucket}" "dst:${bucket}"; then
      echo "  ${bucket}: verifying every object byte for byte (reads the data once more)..."
      # --download compares contents, so objects without a comparable hash aren't checked by size alone.
      report="$(run_rclone check --one-way --download --combined - "src:${bucket}" "dst:${bucket}" 2>/dev/null)" || check_ok=false
    else
      check_ok=false
    fi
    changed="$(grep -c '^\* ' <<<"${report}" || true)"
    if ! copy_report_ok "${check_ok}" "${report}" "$([[ "${MINIO_MIGRATION_FORCE:-}" == "1" ]] && echo true)"; then
      echo "Error: bucket '${bucket}' did not verify against the old copy. Nothing was deleted or"
      echo "  overwritten. Objects reported as different must be removed from the new store before"
      echo "  re-running: ./classroomio.sh migrate-storage"
      grep -E '^[+!*] ' <<<"${report}" | head -20 | sed 's/^/    /' || true
      docker rm -f cio-minio-legacy >/dev/null 2>&1 || true
      return 1
    fi
    echo "  ${bucket}: $(grep -c '^= ' <<<"${report}" || true) object(s) verified against the old copy."
    if ((changed > 0)); then
      echo "  ${bucket}: ${changed} object(s) already changed in the new store were kept as they are."
    fi
  done

  docker rm -f cio-minio-legacy >/dev/null
  echo "Migration complete. Once you've confirmed your uploads load, you can delete the old copy:"
  echo "  ${legacy_data}"
}

# The endpoint is rewritten last, so an interrupted migration is retried on the next run.
migrate_legacy_storage_if_needed() {
  if [[ "${PROFILE_ARGS}" != *"storage"* ]] || ! is_legacy_minio_endpoint; then
    return 0
  fi
  local legacy_data
  legacy_data="$(find_legacy_minio_data)"
  if [[ -n "${legacy_data}" ]]; then
    require_seaweedfs_ready_app
    migrate_legacy_minio_data "${legacy_data}"
  elif [[ "$(legacy_minio_data_setting)" != "none" ]] && install_has_run_before; then
    echo "Error: this install used the old bundled MinIO, but its uploads could not be located."
    echo "  Set MINIO_LEGACY_DATA in .env to the old MinIO volume name or data path, then re-run."
    echo "  If there are no old uploads to copy, set MINIO_LEGACY_DATA=none instead."
    echo "  Nothing has been changed."
    exit 1
  fi
  upsert_env_value OBJECT_STORAGE_ENDPOINT "http://storage:9000"
  echo "Set OBJECT_STORAGE_ENDPOINT=http://storage:9000 in .env"
}

# ──────────────────────────────────────────────
# Commands
# ──────────────────────────────────────────────

cmd_install() {
  echo "Installing ClassroomIO..."
  echo

  if [[ "${USE_IMAGES}" != "true" && ! -f "${BUILD_COMPOSE_FILE}" ]]; then
    echo "Error: --build install requires a local repo checkout (docker-compose.yaml not found)."
    echo "Clone the repository instead, or run 'install' without --build to pull pre-built images."
    exit 1
  fi

  if [[ "${USE_IMAGES}" == "true" ]]; then
    # The compose file tracks main (stack topology only — CIO_VERSION pins the app
    # images). Never overwritten; refresh manually if you need a newer topology:
    #   curl -fsSLO ${RAW_BASE_URL}/docker-compose.images.yaml
    fetch_if_missing "${IMAGES_COMPOSE_FILE}" "${RAW_BASE_URL}/docker-compose.images.yaml"
  fi
  fetch_if_missing "${ENV_EXAMPLE_FILE}" "${RAW_BASE_URL}/.env.example"

  if [[ -f "${ENV_FILE}" ]]; then
    echo "Keeping existing .env (not overwritten)."
  else
    cp "${ENV_EXAMPLE_FILE}" "${ENV_FILE}"
    echo "Created .env from .env.example"
  fi

  prepare_env_and_secrets
  warn_if_unpinned_version

  echo "Review these values in .env before going to production:"
  echo "  DASHBOARD_ORIGIN   (your public https:// dashboard URL)"
  echo "  SMTP_*             (outgoing email — invites, notifications)"
  echo "  CIO_VERSION        (pin an exact release for production)"
  echo

  cmd_start
}

cmd_start() {
  prepare_env_and_secrets
  migrate_legacy_storage_if_needed
  warn_if_unpinned_version

  echo "Starting ClassroomIO..."
  if [[ "${PROFILE_ARGS}" == *"storage"* ]]; then
    echo "Including bundled object storage (SeaweedFS, default)..."
  fi
  if [[ "${USE_IMAGES}" == "true" ]]; then
    echo "Using pre-built images from Docker Hub (CIO_VERSION=$(get_env_value CIO_VERSION))..."
    compose pull
    if [[ "${PROFILE_ARGS}" == *"storage"* ]] && ! app_is_seaweedfs_ready; then
      echo "WARNING: this CIO_VERSION predates the SeaweedFS storage switch; browser uploads will fail"
      echo "  (BadDigest) until you move CIO_VERSION to a newer release."
    fi
    compose up -d
  else
    compose up --build -d
  fi

  echo
  echo "Current service status:"
  compose ps

  wait_for_endpoints

  echo
  echo "Done. Full stack is running."
}

cmd_stop() {
  echo "Stopping ClassroomIO (data volumes are kept)..."
  compose stop
  echo "Stopped. Start again with: ./classroomio.sh start"
}

cmd_restart() {
  prepare_env_and_secrets
  migrate_legacy_storage_if_needed
  echo "Restarting ClassroomIO..."
  # `compose restart` only bounces the container *process* — it never re-reads .env, so
  # edits to DASHBOARD_ORIGIN/SMTP_*/LICENSE_KEY/etc. were silently ignored. Recreate
  # instead: --force-recreate guarantees an actual restart even when config is
  # unchanged, without pulling new images (that's what `upgrade` is for).
  compose up -d --force-recreate
  compose ps
  wait_for_endpoints
}

cmd_logs() {
  compose logs -f --tail=100 "$@"
}

cmd_backup() {
  local ts backup_dir db_user db_name
  ts="$(date +%Y%m%d-%H%M%S)"
  backup_dir="${ROOT_DIR}/backups"
  mkdir -p "${backup_dir}"

  db_user="$(get_env_value POSTGRES_USER)"
  db_user="${db_user:-postgres}"
  db_name="$(get_env_value POSTGRES_DB)"
  db_name="${db_name:-classroomio}"

  echo "Backing up Postgres database '${db_name}'..."
  local db_file="${backup_dir}/classroomio-db-${ts}.sql.gz"
  # pg_dump failure must fail the backup (and abort an upgrade) — don't let gzip's
  # exit code mask it.
  if ! compose exec -T postgres pg_dump -U "${db_user}" "${db_name}" | gzip > "${db_file}"; then
    rm -f "${db_file}"
    echo "Error: Postgres backup failed. Is the stack running? (./classroomio.sh start)"
    return 1
  fi
  echo "  -> ${db_file}"

  # Volume names are derived from the compose project name — same variable the -p flag
  # uses, so a project rename can't silently desync the backup target.
  local label volume storage_file found_volume=false
  # Git Bash (MSYS) rewrites container paths like /data into Windows paths; disable
  # conversion for the docker run below and pre-convert the host dir with cygpath.
  # On Linux/macOS cygpath doesn't exist and the fallback keeps the path as-is.
  local backup_dir_host
  backup_dir_host="$(cygpath -w "${backup_dir}" 2>/dev/null || printf '%s' "${backup_dir}")"
  for label in storage minio; do
    volume="${PROJECT_NAME}_${label}-data"
    if [[ "${label}" == "minio" ]]; then
      volume="$(find_legacy_minio_data)"
    elif ! docker volume inspect "${volume}" >/dev/null 2>&1; then
      volume=""
    fi
    if [[ -z "${volume}" ]]; then
      continue
    fi
    found_volume=true
    echo "Backing up object-storage volume '${volume}'..."
    storage_file="classroomio-${label}-${ts}.tar.gz"
    if ! MSYS_NO_PATHCONV=1 docker run --rm \
      -v "${volume}:/data:ro" \
      -v "${backup_dir_host}:/backup" \
      alpine tar czf "/backup/${storage_file}" -C /data .; then
      echo "Error: object-storage volume backup failed."
      return 1
    fi
    echo "  -> ${backup_dir}/${storage_file}"
  done
  if [[ "${found_volume}" != "true" ]]; then
    # Not an error: --no-storage installs use external storage and have no local volume.
    echo "No bundled object-storage volume found — skipping object-storage backup."
    echo "  (Using external S3/R2? Back that up with your provider's tools.)"
  fi

  echo "Backup complete: ${backup_dir}"
  echo "Copy backups off this machine — a server failure takes local backups with it."
}

cmd_upgrade() {
  prepare_env_and_secrets
  echo "Upgrading ClassroomIO..."
  echo
  echo "Step 1/3: backing up before touching images..."
  cmd_backup

  echo
  if [[ "${USE_IMAGES}" == "true" ]]; then
    # CIO_VERSION only selects a published image tag — meaningless for --build,
    # which always builds whatever is in the local checkout.
    local current_version
    current_version="$(get_env_value CIO_VERSION)"
    echo "Step 2/3: current CIO_VERSION is '${current_version:-unset}'."
    if [[ -t 0 ]]; then
      read -r -p "Enter a new version tag to pin (or press Enter to keep the current one): " new_version
      if [[ -n "${new_version}" ]]; then
        upsert_env_value CIO_VERSION "${new_version}"
        echo "Set CIO_VERSION=${new_version} in .env"
      fi
    fi
    warn_if_unpinned_version
  else
    echo "Step 2/3: building from source — CIO_VERSION doesn't apply."
  fi

  # After the version prompt, so the app-image check sees the version actually chosen.
  migrate_legacy_storage_if_needed

  if [[ "${USE_IMAGES}" == "true" ]]; then
    echo "Step 3/3: pulling images and restarting..."
    compose pull
    compose up -d
  else
    echo "Step 3/3: rebuilding from source and restarting..."
    compose up --build -d
  fi
  compose ps
  wait_for_endpoints
  echo
  echo "Upgrade complete."
  if [[ "${USE_IMAGES}" == "true" ]]; then
    echo "Roll back by setting CIO_VERSION back in .env and running: ./classroomio.sh upgrade"
  fi
}

cmd_migrate_storage() {
  prepare_env_and_secrets
  if [[ "${PROFILE_ARGS}" != *"storage"* ]]; then
    echo "Error: migrate-storage copies into the bundled store, so it can't be combined with --no-storage."
    exit 1
  fi
  # After cutover the old copy is stale: copying again would bring back files deleted since.
  if ! is_legacy_minio_endpoint && [[ "${MINIO_MIGRATION_FORCE:-}" != "1" ]]; then
    echo "Storage has already been migrated (OBJECT_STORAGE_ENDPOINT no longer points at MinIO)."
    echo "  Copying again can restore files that were deleted since. To do it anyway, run:"
    echo "  MINIO_MIGRATION_FORCE=1 ./classroomio.sh migrate-storage"
    return 0
  fi
  local legacy_data
  legacy_data="$(find_legacy_minio_data)"
  if [[ -z "${legacy_data}" ]]; then
    echo "No old MinIO data found — nothing was copied and .env was not changed."
    echo "  Set MINIO_LEGACY_DATA in .env to its volume name or path, or to 'none' if there is none."
    return 0
  fi
  require_seaweedfs_ready_app
  migrate_legacy_minio_data "${legacy_data}"
  if is_legacy_minio_endpoint; then
    upsert_env_value OBJECT_STORAGE_ENDPOINT "http://storage:9000"
    echo "Set OBJECT_STORAGE_ENDPOINT=http://storage:9000 in .env"
  fi
  compose up -d
  compose ps
}

show_menu() {
  echo "ClassroomIO self-host manager"
  echo
  echo "  1) Install    (first-time setup: fetch files, create .env, start)"
  echo "  2) Start"
  echo "  3) Stop"
  echo "  4) Restart"
  echo "  5) Upgrade    (backs up first, then pulls new images)"
  echo "  6) View logs"
  echo "  7) Backup     (Postgres dump + object-storage volume archive)"
  echo "  8) Migrate storage (copy uploads from the old bundled MinIO)"
  echo "  q) Quit"
  echo
  read -r -p "Pick an option: " choice
  case "${choice}" in
    1) cmd_install ;;
    2) cmd_start ;;
    3) cmd_stop ;;
    4) cmd_restart ;;
    5) cmd_upgrade ;;
    6) cmd_logs ;;
    7) cmd_backup ;;
    8) cmd_migrate_storage ;;
    q|Q) exit 0 ;;
    *)
      echo "Unknown option: ${choice}"
      exit 1
      ;;
  esac
}

# ──────────────────────────────────────────────
# Argument parsing
# ──────────────────────────────────────────────

# Options may appear before or after the command: `./classroomio.sh start --build`
# and `./classroomio.sh --build start` both work.
COMMAND=""
COMMAND_ARGS=()

while [[ $# -gt 0 ]]; do
  case "$1" in
    --build)
      USE_IMAGES=false
      COMPOSE_FILE="${BUILD_COMPOSE_FILE}"
      shift
      ;;
    --no-storage)
      PROFILE_ARGS=""
      shift
      ;;
    --no-minio)
      echo "Note: --no-minio is deprecated; use --no-storage."
      PROFILE_ARGS=""
      shift
      ;;
    -h|--help)
      print_usage
      exit 0
      ;;
    install|start|stop|restart|upgrade|logs|backup|migrate-storage)
      if [[ -n "${COMMAND}" ]]; then
        echo "Unexpected extra command: $1 (already running '${COMMAND}')"
        print_usage
        exit 1
      fi
      COMMAND="$1"
      shift
      ;;
    *)
      if [[ "${COMMAND}" == "logs" ]]; then
        COMMAND_ARGS+=("$1")
        shift
      else
        echo "Unknown option: $1"
        print_usage
        exit 1
      fi
      ;;
  esac
done

detect_compose

case "${COMMAND}" in
  "") show_menu ;;
  install) cmd_install ;;
  start) cmd_start ;;
  stop) cmd_stop ;;
  restart) cmd_restart ;;
  upgrade) cmd_upgrade ;;
  logs) cmd_logs "${COMMAND_ARGS[@]+"${COMMAND_ARGS[@]}"}" ;;
  backup) cmd_backup ;;
  migrate-storage) cmd_migrate_storage ;;
esac