diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..48fa38ba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# Git and editor noise +.git +.gitignore +.cursor +.vscode +**/.DS_Store + +# Local env and secrets +.env +.env.* +!.env.example +!.env.aio.example + +# Documentation and plans (not needed in runtime images) +documentation/ +*.md +!backend/server/**/*.md + +# Dev compose / k8s / scripts (not copied into app layers anyway, but keeps context small) +backups/ + +# Frontend — build stages install fresh; never bake local artifacts +frontend/node_modules +frontend/build +frontend/.svelte-kit +frontend/.env +frontend/.env.* + +# Backend — media and generated files belong on volumes, not in images +backend/server/media/ +backend/server/staticfiles/ +backend/server/scheduler.log +backend/server/**/*.log +backend/server/.venv/ +**/.venv/ +**/__pycache__/ +**/*.py[cod] +**/.pytest_cache/ +**/.mypy_cache/ + +# Root node_modules if present +node_modules diff --git a/.env.aio.example b/.env.aio.example new file mode 100644 index 00000000..70e620b1 --- /dev/null +++ b/.env.aio.example @@ -0,0 +1,8 @@ +# AdventureLog All-in-One — minimal configuration +# +# Required: +POSTGRES_PASSWORD=changeme123 +# +# Optional (defaults shown): +SITE_URL=http://localhost:8015 +HOST_PORT=8015 diff --git a/.env.example b/.env.example index b2f8ae8e..f0c5745d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ # 🌐 Frontend PUBLIC_SERVER_URL=http://server:8000 # PLEASE DON'T CHANGE :) - Should be the service name of the backend with port 8000, even if you change the port in the backend service. Only change if you are using a custom more complex setup. + +# Optional: set ONE public URL when frontend and backend share a domain (reverse proxy). +# When set, derives ORIGIN, FRONTEND_URL, PUBLIC_URL, and CSRF_TRUSTED_ORIGINS unless those are explicitly set. +# SITE_URL=https://adventurelog.example.com + ORIGIN=http://localhost:8015 BODY_SIZE_LIMIT=Infinity FRONTEND_PORT=8015 @@ -18,6 +23,7 @@ DJANGO_ADMIN_EMAIL=admin@example.com PUBLIC_URL=http://localhost:8016 # Match the outward port, used for the creation of image urls CSRF_TRUSTED_ORIGINS=http://localhost:8016,http://localhost:8015 DEBUG=False +# GUNICORN_WORKERS=2 # Optional: gunicorn worker count (default 2). Use 1 on small hosts; (2 x CPU) + 1 on larger servers. FRONTEND_URL=http://localhost:8015 # Used for email generation. This should be the url of the frontend BACKEND_PORT=8016 diff --git a/.github/.docker-compose-database.yml b/.github/.docker-compose-database.yml index aa187bd6..d2febce7 100644 --- a/.github/.docker-compose-database.yml +++ b/.github/.docker-compose-database.yml @@ -1,6 +1,6 @@ services: db: - image: postgis/postgis:15-3.3 + image: postgis/postgis:16-3.5 container_name: adventurelog-db restart: unless-stopped ports: diff --git a/.github/workflows/_build-image.yml b/.github/workflows/_build-image.yml new file mode 100644 index 00000000..be9507c5 --- /dev/null +++ b/.github/workflows/_build-image.yml @@ -0,0 +1,125 @@ +name: Build and push Docker image + +on: + workflow_call: + inputs: + context: + required: true + type: string + image_name: + required: true + type: string + tag: + required: true + type: string + include_latest_tag: + required: false + type: boolean + default: false + dockerfile: + required: false + type: string + default: docker/Dockerfile + target: + required: false + type: string + default: "" + secrets: + ACCESS_TOKEN: + required: true + DOCKERHUB_USERNAME: + required: true + DOCKERHUB_TOKEN: + required: true + +permissions: + contents: read + packages: write + actions: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Prepare image tags + id: tags + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + append_tag() { + TAGS="${TAGS}"$'\n'"$1" + } + + TAGS="" + append_tag "ghcr.io/${{ github.repository_owner }}/${{ inputs.image_name }}:${{ inputs.tag }}" + append_tag "${DOCKERHUB_USERNAME}/${{ inputs.image_name }}:${{ inputs.tag }}" + + if [ "${{ inputs.include_latest_tag }}" = "true" ]; then + append_tag "ghcr.io/${{ github.repository_owner }}/${{ inputs.image_name }}:latest" + append_tag "${DOCKERHUB_USERNAME}/${{ inputs.image_name }}:latest" + fi + + { + echo "value<> "${GITHUB_OUTPUT}" + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.ACCESS_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + id: build + uses: docker/build-push-action@v6 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + target: ${{ inputs.target != '' && inputs.target || null }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.tags.outputs.value }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + cache-from: | + type=gha,scope=${{ inputs.image_name }}-${{ inputs.tag }} + type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ inputs.image_name }}:buildcache + cache-to: | + type=gha,scope=${{ inputs.image_name }}-${{ inputs.tag }},mode=max + type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ inputs.image_name }}:buildcache,mode=max + + - name: Report amd64 image size + env: + DOCKERFILE: ${{ inputs.dockerfile }} + TARGET: ${{ inputs.target }} + CONTEXT: ${{ inputs.context }} + IMAGE: ${{ inputs.image_name }}-size-check + run: | + args=(--platform linux/amd64 --load) + if [[ -n "$TARGET" ]]; then + args+=(--target "$TARGET") + fi + args+=(-f "$DOCKERFILE" -t "$IMAGE" "$CONTEXT") + docker buildx build "${args[@]}" + SIZE=$(docker image inspect "$IMAGE" --format='{{.Size}}') + echo "${{ inputs.image_name }} amd64 size: $(awk "BEGIN {printf \"%.1f MB\", ${SIZE}/1024/1024}")" + docker rmi "$IMAGE" diff --git a/.github/workflows/_build-images.yml b/.github/workflows/_build-images.yml new file mode 100644 index 00000000..6b23c8e4 --- /dev/null +++ b/.github/workflows/_build-images.yml @@ -0,0 +1,126 @@ +name: Build and push image set + +on: + workflow_call: + inputs: + tag: + required: true + type: string + filter_paths: + required: false + type: boolean + default: true + include_latest_tag: + required: false + type: boolean + default: false + +concurrency: + group: images-${{ inputs.tag }} + cancel-in-progress: true + +jobs: + changes: + if: ${{ inputs.filter_paths }} + runs-on: ubuntu-latest + outputs: + frontend: ${{ steps.flags.outputs.frontend }} + backend: ${{ steps.flags.outputs.backend }} + cdn: ${{ steps.flags.outputs.cdn }} + aio: ${{ steps.flags.outputs.aio }} + steps: + - uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + shared: + - '.github/workflows/**' + - 'docker/Dockerfile' + - 'docker/docker-bake.hcl' + frontend: + - 'frontend/**' + - 'docker/Dockerfile' + - 'docker/shared/**' + backend: + - 'backend/**' + - 'docker/Dockerfile' + - 'docker/shared/**' + cdn: + - 'cdn/**' + aio: + - 'aio/**' + - 'frontend/**' + - 'backend/**' + - 'docker/Dockerfile' + - 'docker/shared/**' + + - name: Resolve build targets + id: flags + run: | + shared="${{ steps.filter.outputs.shared }}" + frontend="${{ steps.filter.outputs.frontend }}" + backend="${{ steps.filter.outputs.backend }}" + cdn="${{ steps.filter.outputs.cdn }}" + aio="${{ steps.filter.outputs.aio }}" + + bool_or() { + [[ "$1" == "true" || "$2" == "true" ]] && echo "true" || echo "false" + } + + echo "frontend=$(bool_or "$shared" "$frontend")" >> "$GITHUB_OUTPUT" + echo "backend=$(bool_or "$shared" "$backend")" >> "$GITHUB_OUTPUT" + echo "cdn=$(bool_or "$shared" "$cdn")" >> "$GITHUB_OUTPUT" + echo "aio=$(bool_or "$shared" "$aio")" >> "$GITHUB_OUTPUT" + + build-frontend: + needs: changes + if: ${{ !inputs.filter_paths || needs.changes.outputs.frontend == 'true' }} + uses: ./.github/workflows/_build-image.yml + with: + context: . + dockerfile: docker/Dockerfile + target: frontend + image_name: adventurelog-frontend + tag: ${{ inputs.tag }} + include_latest_tag: ${{ inputs.include_latest_tag }} + secrets: inherit + + build-backend: + needs: changes + if: ${{ !inputs.filter_paths || needs.changes.outputs.backend == 'true' }} + uses: ./.github/workflows/_build-image.yml + with: + context: . + dockerfile: docker/Dockerfile + target: backend + image_name: adventurelog-backend + tag: ${{ inputs.tag }} + include_latest_tag: ${{ inputs.include_latest_tag }} + secrets: inherit + + build-cdn: + needs: changes + if: ${{ !inputs.filter_paths || needs.changes.outputs.cdn == 'true' }} + uses: ./.github/workflows/_build-image.yml + with: + context: ./cdn + dockerfile: cdn/Dockerfile + image_name: adventurelog-cdn + tag: ${{ inputs.tag }} + include_latest_tag: ${{ inputs.include_latest_tag }} + secrets: inherit + + build-aio: + needs: changes + if: ${{ !inputs.filter_paths || needs.changes.outputs.aio == 'true' }} + uses: ./.github/workflows/_build-image.yml + with: + context: . + dockerfile: docker/Dockerfile + target: aio + image_name: adventurelog-aio + tag: ${{ inputs.tag }} + include_latest_tag: ${{ inputs.include_latest_tag }} + secrets: inherit diff --git a/.github/workflows/backend-beta.yml b/.github/workflows/backend-beta.yml deleted file mode 100644 index a9a80979..00000000 --- a/.github/workflows/backend-beta.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Upload beta backend image to GHCR and Docker Hub - -permissions: - contents: read - packages: write - -on: - push: - branches: - - development - paths: - - "backend/**" - -env: - IMAGE_NAME: "adventurelog-backend" - -jobs: - upload: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.ACCESS_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push beta Docker image with BuildKit cache - uses: docker/build-push-action@v5 - with: - context: ./backend - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:beta - ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:beta - cache-from: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache - type=local,src=/tmp/.buildx-cache - cache-to: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max - type=local,dest=/tmp/.buildx-cache,mode=max - env: - DOCKER_BUILDKIT: 1 diff --git a/.github/workflows/backend-latest.yml b/.github/workflows/backend-latest.yml deleted file mode 100644 index dd3b9ce1..00000000 --- a/.github/workflows/backend-latest.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Upload latest backend image to GHCR and Docker Hub - -permissions: - contents: read - packages: write - -on: - push: - branches: - - main - paths: - - "backend/**" - -env: - IMAGE_NAME: "adventurelog-backend" - -jobs: - upload: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.ACCESS_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push latest Docker image with BuildKit cache - uses: docker/build-push-action@v5 - with: - context: ./backend - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest - ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest - cache-from: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache - type=local,src=/tmp/.buildx-cache - cache-to: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max - type=local,dest=/tmp/.buildx-cache,mode=max - env: - DOCKER_BUILDKIT: 1 diff --git a/.github/workflows/backend-release.yml b/.github/workflows/backend-release.yml deleted file mode 100644 index ff1919db..00000000 --- a/.github/workflows/backend-release.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Upload the tagged release backend image to GHCR and Docker Hub - -permissions: - contents: read - packages: write - -on: - release: - types: [released] - -env: - IMAGE_NAME: "adventurelog-backend" - -jobs: - upload: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.ACCESS_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push release Docker image with BuildKit cache - uses: docker/build-push-action@v5 - with: - context: ./backend - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} - ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} - cache-from: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache - type=local,src=/tmp/.buildx-cache - cache-to: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max - type=local,dest=/tmp/.buildx-cache,mode=max - env: - DOCKER_BUILDKIT: 1 diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index a01cd48f..f2ba09c5 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -15,10 +15,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: set up python 3.12 + - name: set up python 3.13 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: install dependencies run: | diff --git a/.github/workflows/cdn-beta.yml b/.github/workflows/cdn-beta.yml deleted file mode 100644 index d229b606..00000000 --- a/.github/workflows/cdn-beta.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Upload beta CDN image to GHCR and Docker Hub - -permissions: - contents: read - packages: write - -on: - push: - branches: - - development - paths: - - "cdn/**" - -env: - IMAGE_NAME: "adventurelog-cdn" - -jobs: - upload: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.ACCESS_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: set lower case owner name - run: | - echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV} - env: - OWNER: "${{ github.repository_owner }}" - - - name: Build Docker images - run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:beta -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:beta ./cdn diff --git a/.github/workflows/cdn-latest.yml b/.github/workflows/cdn-latest.yml deleted file mode 100644 index 65a88dae..00000000 --- a/.github/workflows/cdn-latest.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Upload latest CDN image to GHCR and Docker Hub - -permissions: - contents: read - packages: write - -on: - push: - branches: - - main - paths: - - "cdn/**" - -env: - IMAGE_NAME: "adventurelog-cdn" - -jobs: - upload: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.ACCESS_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: set lower case owner name - run: | - echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV} - env: - OWNER: "${{ github.repository_owner }}" - - - name: Build Docker images - run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:latest -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:latest ./cdn diff --git a/.github/workflows/cdn-release.yml b/.github/workflows/cdn-release.yml deleted file mode 100644 index 11cb258a..00000000 --- a/.github/workflows/cdn-release.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Upload the tagged release CDN image to GHCR and Docker Hub - -permissions: - contents: read - packages: write - -on: - release: - types: [released] - -env: - IMAGE_NAME: "adventurelog-cdn" - -jobs: - upload: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.ACCESS_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: set lower case owner name - run: | - echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV} - env: - OWNER: "${{ github.repository_owner }}" - - - name: Build Docker images - run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:${{ github.event.release.tag_name }} -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:${{ github.event.release.tag_name }} ./cdn diff --git a/.github/workflows/compose-smoke-test.yml b/.github/workflows/compose-smoke-test.yml new file mode 100644 index 00000000..059c0d94 --- /dev/null +++ b/.github/workflows/compose-smoke-test.yml @@ -0,0 +1,65 @@ +name: Compose Smoke Test + +permissions: + contents: read + +on: + pull_request: + paths: + - 'backend/**' + - 'aio/**' + - 'frontend/**' + - 'docker/**' + - 'docker-compose*.yml' + - '.github/workflows/compose-smoke-test.yml' + push: + branches: + - main + - development + paths: + - 'backend/**' + - 'aio/**' + - 'frontend/**' + - 'docker/**' + - 'docker-compose*.yml' + - '.github/workflows/compose-smoke-test.yml' + +jobs: + aio-smoke: + name: AIO compose smoke test + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build AIO image + run: | + docker build -f docker/Dockerfile --target aio -t adventurelog-aio-smoke . + SIZE=$(docker image inspect adventurelog-aio-smoke --format='{{.Size}}') + echo "AIO image size: $(awk "BEGIN {printf \"%.1f MB\", ${SIZE}/1024/1024}")" + + - name: Start AIO stack + run: | + printf '%s\n' 'POSTGRES_PASSWORD=smoke-test-pass' > .env.aio + docker compose -f docker-compose.aio.yml up -d --wait + + - name: Wait for health endpoint + run: | + for i in $(seq 1 60); do + if curl -fsS http://localhost:8015/health; then + exit 0 + fi + sleep 5 + done + echo "Health check failed" + docker compose -f docker-compose.aio.yml ps + docker compose -f docker-compose.aio.yml logs + exit 1 + + - name: Tear down + if: always() + run: docker compose -f docker-compose.aio.yml down -v diff --git a/.github/workflows/frontend-beta.yml b/.github/workflows/frontend-beta.yml deleted file mode 100644 index 4b237367..00000000 --- a/.github/workflows/frontend-beta.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Upload beta frontend image to GHCR and Docker Hub - -permissions: - contents: read - packages: write - -on: - push: - branches: - - development - paths: - - "frontend/**" - -env: - IMAGE_NAME: "adventurelog-frontend" - -jobs: - upload: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.ACCESS_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push beta Docker image with BuildKit cache - uses: docker/build-push-action@v5 - with: - context: ./frontend - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:beta - ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:beta - cache-from: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache - type=local,src=/tmp/.buildx-cache - cache-to: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max - type=local,dest=/tmp/.buildx-cache,mode=max - env: - DOCKER_BUILDKIT: 1 diff --git a/.github/workflows/frontend-latest.yml b/.github/workflows/frontend-latest.yml deleted file mode 100644 index dcb5e8c9..00000000 --- a/.github/workflows/frontend-latest.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Upload latest frontend image to GHCR and Docker Hub - -permissions: - contents: read - packages: write - -on: - push: - branches: - - main - paths: - - "frontend/**" - -env: - IMAGE_NAME: "adventurelog-frontend" - -jobs: - upload: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.ACCESS_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push latest Docker image with BuildKit cache - uses: docker/build-push-action@v5 - with: - context: ./frontend - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest - ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest - cache-from: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache - type=local,src=/tmp/.buildx-cache - cache-to: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max - type=local,dest=/tmp/.buildx-cache,mode=max - env: - DOCKER_BUILDKIT: 1 diff --git a/.github/workflows/frontend-release.yml b/.github/workflows/frontend-release.yml deleted file mode 100644 index 3bb33ce8..00000000 --- a/.github/workflows/frontend-release.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Upload tagged release frontend image to GHCR and Docker Hub - -permissions: - contents: read - packages: write - -on: - release: - types: [released] - -env: - IMAGE_NAME: "adventurelog-frontend" - -jobs: - upload: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.ACCESS_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push release Docker image with BuildKit cache - uses: docker/build-push-action@v5 - with: - context: ./frontend - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} - ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest - ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest - cache-from: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache - type=local,src=/tmp/.buildx-cache - cache-to: | - type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max - type=local,dest=/tmp/.buildx-cache,mode=max - env: - DOCKER_BUILDKIT: 1 diff --git a/.github/workflows/images-beta.yml b/.github/workflows/images-beta.yml new file mode 100644 index 00000000..e9dd2482 --- /dev/null +++ b/.github/workflows/images-beta.yml @@ -0,0 +1,15 @@ +name: Build and push beta images + +on: + push: + branches: + - development + workflow_dispatch: + +jobs: + build: + uses: ./.github/workflows/_build-images.yml + with: + tag: beta + filter_paths: true + secrets: inherit diff --git a/.github/workflows/images-latest.yml b/.github/workflows/images-latest.yml new file mode 100644 index 00000000..a3abf626 --- /dev/null +++ b/.github/workflows/images-latest.yml @@ -0,0 +1,15 @@ +name: Build and push latest images + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + uses: ./.github/workflows/_build-images.yml + with: + tag: latest + filter_paths: true + secrets: inherit diff --git a/.github/workflows/images-release.yml b/.github/workflows/images-release.yml new file mode 100644 index 00000000..0e9a1485 --- /dev/null +++ b/.github/workflows/images-release.yml @@ -0,0 +1,14 @@ +name: Build and push release images + +on: + release: + types: [released] + +jobs: + build: + uses: ./.github/workflows/_build-images.yml + with: + tag: ${{ github.event.release.tag_name }} + filter_paths: false + include_latest_tag: true + secrets: inherit diff --git a/.github/workflows/installer-smoke-test.yml b/.github/workflows/installer-smoke-test.yml new file mode 100644 index 00000000..3c58f60b --- /dev/null +++ b/.github/workflows/installer-smoke-test.yml @@ -0,0 +1,83 @@ +name: Installer Smoke Test + +permissions: + contents: read + +on: + pull_request: + paths: + - 'install_adventurelog.sh' + - 'scripts/install/**' + - 'scripts/validate-env.sh' + - '.github/workflows/installer-smoke-test.yml' + push: + branches: + - main + - development + paths: + - 'install_adventurelog.sh' + - 'scripts/install/**' + - 'scripts/validate-env.sh' + - '.github/workflows/installer-smoke-test.yml' + +jobs: + installer-dry-run: + name: Installer dry-run + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - name: Shellcheck installer scripts + run: | + sudo apt-get update -qq + sudo apt-get install -y shellcheck + shellcheck install_adventurelog.sh + shellcheck scripts/install/lib/*.sh + shellcheck scripts/validate-env.sh + + - name: Dry-run AIO install wizard + env: + ADVENTURELOG_SKIP_GUM: "1" + ADVENTURELOG_REF: main + INSTALL_DIR: adventurelog-installer-test + run: | + rm -rf "$INSTALL_DIR" + bash install_adventurelog.sh --dry-run --force-install + + - name: Validate generated AIO env + run: | + test -f adventurelog-installer-test/.env.aio + bash scripts/validate-env.sh adventurelog-installer-test/.env.aio + rm -rf adventurelog-installer-test + + validate-env-aio: + name: validate-env.sh AIO checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Test AIO validation rules + run: | + cat > /tmp/test.env.aio << 'EOF' + POSTGRES_PASSWORD=secure-pass + SITE_URL=http://localhost:8015 + DJANGO_ADMIN_USERNAME=admin + DJANGO_ADMIN_PASSWORD=admin + MEDIA_STORAGE=s3 + EOF + bash scripts/validate-env.sh /tmp/test.env.aio && exit 1 || true + + cat > /tmp/test2.env.aio << 'EOF' + POSTGRES_PASSWORD=secure-pass + SITE_URL=http://localhost:8015 + EMAIL_BACKEND=email + EOF + bash scripts/validate-env.sh /tmp/test2.env.aio && exit 1 || true + + cat > /tmp/test3.env.aio << 'EOF' + POSTGRES_PASSWORD=secure-pass + SITE_URL=http://localhost:8015 + EOF + bash scripts/validate-env.sh /tmp/test3.env.aio diff --git a/.github/workflows/trivy_security_scans.yml b/.github/workflows/trivy_security_scans.yml index 3e3d01f5..393652f8 100644 --- a/.github/workflows/trivy_security_scans.yml +++ b/.github/workflows/trivy_security_scans.yml @@ -2,8 +2,6 @@ name: Trivy Security Scans permissions: contents: read - # Needed if you later add SARIF upload to GitHub Security - # security-events: write on: push: @@ -15,7 +13,7 @@ on: - main - development schedule: - - cron: "0 8 * * 1" # Weekly on Mondays at 8 AM UTC + - cron: "0 8 * * 1" jobs: filesystem-scan: @@ -35,11 +33,10 @@ jobs: exit-code: 1 ignore-unfixed: true severity: CRITICAL,HIGH - # Use .trivyignore to suppress known false positives trivyignores: .trivyignore image-scan: - name: Trivy Docker Image Scan (Backend & Frontend) + name: Trivy Docker Image Scan runs-on: ubuntu-latest steps: @@ -49,20 +46,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Build images + run: | + docker build -f docker/Dockerfile --target backend -t adventurelog-backend . + docker build -f docker/Dockerfile --target frontend -t adventurelog-frontend . + docker build -f docker/Dockerfile --target aio -t adventurelog-aio . + docker build -t adventurelog-cdn ./cdn - - name: Build backend Docker image - run: docker build -t adventurelog-backend ./backend - - - name: Build frontend Docker image - run: docker build -t adventurelog-frontend ./frontend - - - name: Scan backend Docker image with Trivy + - name: Scan backend image uses: aquasecurity/trivy-action@master with: image-ref: adventurelog-backend @@ -72,7 +63,7 @@ jobs: severity: CRITICAL,HIGH trivyignores: .trivyignore - - name: Scan frontend Docker image with Trivy + - name: Scan frontend image uses: aquasecurity/trivy-action@master with: image-ref: adventurelog-frontend @@ -81,3 +72,23 @@ jobs: ignore-unfixed: true severity: CRITICAL,HIGH trivyignores: .trivyignore + + - name: Scan AIO image + uses: aquasecurity/trivy-action@master + with: + image-ref: adventurelog-aio + format: table + exit-code: 1 + ignore-unfixed: true + severity: CRITICAL,HIGH + trivyignores: .trivyignore + + - name: Scan CDN image + uses: aquasecurity/trivy-action@master + with: + image-ref: adventurelog-cdn + format: table + exit-code: 1 + ignore-unfixed: true + severity: CRITICAL,HIGH + trivyignores: .trivyignore diff --git a/adventurelog/.env.example b/adventurelog/.env.example new file mode 100644 index 00000000..f0c5745d --- /dev/null +++ b/adventurelog/.env.example @@ -0,0 +1,80 @@ +# 🌐 Frontend +PUBLIC_SERVER_URL=http://server:8000 # PLEASE DON'T CHANGE :) - Should be the service name of the backend with port 8000, even if you change the port in the backend service. Only change if you are using a custom more complex setup. + +# Optional: set ONE public URL when frontend and backend share a domain (reverse proxy). +# When set, derives ORIGIN, FRONTEND_URL, PUBLIC_URL, and CSRF_TRUSTED_ORIGINS unless those are explicitly set. +# SITE_URL=https://adventurelog.example.com + +ORIGIN=http://localhost:8015 +BODY_SIZE_LIMIT=Infinity +FRONTEND_PORT=8015 + +# 🐘 PostgreSQL Database +PGHOST=db +POSTGRES_DB=database +POSTGRES_USER=adventure +POSTGRES_PASSWORD=changeme123 + +# 🔒 Django Backend +SECRET_KEY=changeme123 +DJANGO_ADMIN_USERNAME=admin +DJANGO_ADMIN_PASSWORD=admin +DJANGO_ADMIN_EMAIL=admin@example.com +PUBLIC_URL=http://localhost:8016 # Match the outward port, used for the creation of image urls +CSRF_TRUSTED_ORIGINS=http://localhost:8016,http://localhost:8015 +DEBUG=False +# GUNICORN_WORKERS=2 # Optional: gunicorn worker count (default 2). Use 1 on small hosts; (2 x CPU) + 1 on larger servers. +FRONTEND_URL=http://localhost:8015 # Used for email generation. This should be the url of the frontend +BACKEND_PORT=8016 + +# Optional: Use S3-compatible storage for media +# MEDIA_STORAGE=local # local or s3 +# MEDIA_STORAGE_LIMIT_MB=0 # 0 means unlimited +# MEDIA_STORAGE_LIMIT_BYTES=0 # Overrides MB if set > 0 +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# AWS_STORAGE_BUCKET_NAME= +# AWS_S3_ENDPOINT_URL= # For R2: https://.r2.cloudflarestorage.com +# AWS_S3_REGION_NAME=auto +# AWS_S3_ADDRESSING_STYLE=path +# AWS_S3_SIGNATURE_VERSION=s3v4 +# AWS_S3_CUSTOM_DOMAIN= # Optional CDN/custom domain +# AWS_QUERYSTRING_AUTH=true +# AWS_QUERYSTRING_EXPIRE=3600 +# AWS_S3_FILE_OVERWRITE=true + +# Optional: use Google Maps integration +# https://adventurelog.app/docs/configuration/google_maps_integration.html +# GOOGLE_MAPS_API_KEY=your_google_maps_api_key + +# Optional: disable registration +# https://adventurelog.app/docs/configuration/disable_registration.html +DISABLE_REGISTRATION=False +# DISABLE_REGISTRATION_MESSAGE=Registration is disabled for this instance of AdventureLog. + +# SOCIALACCOUNT_ALLOW_SIGNUP=False # When false, social providers cannot be used to create new user accounts when registration is disabled. + +# FORCE_SOCIALACCOUNT_LOGIN=False # When true, only social login is allowed (no password login) and the login page will show only social providers or redirect directly to the first provider if only one is configured. + +# ACCOUNT_EMAIL_VERIFICATION='none' # 'none', 'optional', 'mandatory' # You can change this as needed for your environment + +# Optional: Use email +# https://adventurelog.app/docs/configuration/email.html +# EMAIL_BACKEND=email +# EMAIL_HOST=smtp.gmail.com +# EMAIL_USE_TLS=True +# EMAIL_PORT=587 +# EMAIL_USE_SSL=False +# EMAIL_HOST_USER=user +# EMAIL_HOST_PASSWORD=password +# DEFAULT_FROM_EMAIL=user@example.com + +# Optional: Use Strava integration +# https://adventurelog.app/docs/configuration/strava_integration.html +# STRAVA_CLIENT_ID=your_strava_client_id +# STRAVA_CLIENT_SECRET=your_strava_client_secret + +# Optional: Use Umami for analytics +# https://adventurelog.app/docs/configuration/analytics.html +# PUBLIC_UMAMI_SRC=https://cloud.umami.is/script.js # If you are using the hosted version of Umami +# PUBLIC_UMAMI_WEBSITE_ID= \ No newline at end of file diff --git a/adventurelog/deploy.sh b/adventurelog/deploy.sh new file mode 100755 index 00000000..0fac30e6 --- /dev/null +++ b/adventurelog/deploy.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Deploy the latest AdventureLog Docker images. Safe for cron/automation. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +COMPOSE_FILE="${COMPOSE_FILE:-}" +ENV_FILE="" +LOG_CONTAINER="" +FOLLOW_LOGS=false +DO_BACKUP=false + +for arg in "$@"; do + case "$arg" in + --logs) FOLLOW_LOGS=true ;; + --backup) DO_BACKUP=true ;; + esac +done + +if [[ -z "$COMPOSE_FILE" ]]; then + if [[ -f .env.aio ]] && [[ ! -f .env ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + elif [[ -f .env.aio ]] && [[ -f docker-compose.aio.yml ]] && [[ "${ADVENTURELOG_COMPOSE:-}" == "aio" ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + else + COMPOSE_FILE="docker-compose.yml" + fi +fi + +case "$COMPOSE_FILE" in + *docker-compose.aio.yml*) + ENV_FILE=".env.aio" + LOG_CONTAINER="adventurelog-aio" + ;; + *) + ENV_FILE=".env" + LOG_CONTAINER="adventurelog-backend" + ;; +esac + +COMPOSE=(docker compose -f "$COMPOSE_FILE") +if [[ -f "$ENV_FILE" ]]; then + COMPOSE=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE") +fi + +if [[ -f "$ENV_FILE" ]] && [[ -f scripts/validate-env.sh ]]; then + echo "Validating $ENV_FILE ..." + bash scripts/validate-env.sh "$ENV_FILE" +fi + +if [[ "$DO_BACKUP" == true ]]; then + if [[ -f scripts/backup.sh ]]; then + COMPOSE_FILE="$COMPOSE_FILE" bash scripts/backup.sh + else + echo "WARNING: scripts/backup.sh not found; skipping backup" >&2 + fi +fi + +echo "Deploying latest version of AdventureLog ($COMPOSE_FILE)" +"${COMPOSE[@]}" pull +echo "Starting containers" +"${COMPOSE[@]}" up -d --wait 2>/dev/null || "${COMPOSE[@]}" up -d +echo "All set!" +"${COMPOSE[@]}" ps + +if [[ "$FOLLOW_LOGS" == true ]]; then + docker logs "$LOG_CONTAINER" --follow +fi diff --git a/adventurelog/docker-compose.override.example.yml b/adventurelog/docker-compose.override.example.yml new file mode 100644 index 00000000..72131ae2 --- /dev/null +++ b/adventurelog/docker-compose.override.example.yml @@ -0,0 +1,23 @@ +# Optional Docker Compose overrides for AdventureLog +# +# Copy to docker-compose.override.yml or let the installer generate ARM overrides automatically. +# +# ARM hosts: the default postgis/postgis image may not work — use imresamu/postgis instead. +# services: +# db: +# image: imresamu/postgis:16-3.5-alpine + +services: + app: + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 1G + + db: + deploy: + resources: + limits: + memory: 512M diff --git a/adventurelog/docker-compose.yml b/adventurelog/docker-compose.yml new file mode 100644 index 00000000..6f8149f4 --- /dev/null +++ b/adventurelog/docker-compose.yml @@ -0,0 +1,76 @@ +services: + web: + build: + context: . + dockerfile: docker/Dockerfile + target: frontend + image: ghcr.io/seanmorley15/adventurelog-frontend:latest + container_name: adventurelog-frontend + restart: unless-stopped + env_file: .env + ports: + - "${FRONTEND_PORT:-8015}:3000" + depends_on: + server: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + db: + image: postgis/postgis:16-3.5 + container_name: adventurelog-db + restart: unless-stopped + env_file: .env + volumes: + - postgres_data:/var/lib/postgresql/data/ + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-adventure} -d ${POSTGRES_DB:-database}" + ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + + server: + build: + context: . + dockerfile: docker/Dockerfile + target: backend + image: ghcr.io/seanmorley15/adventurelog-backend:latest + container_name: adventurelog-backend + restart: unless-stopped + env_file: .env + ports: + - "${BACKEND_PORT:-8016}:80" + depends_on: + db: + condition: service_healthy + volumes: + - adventurelog_media:/code/media/ + healthcheck: + test: + [ + "CMD-SHELL", + "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:80/health/')\"" + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + +volumes: + postgres_data: + adventurelog_media: diff --git a/adventurelog/scripts/backup.sh b/adventurelog/scripts/backup.sh new file mode 100755 index 00000000..2c228634 --- /dev/null +++ b/adventurelog/scripts/backup.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Backup AdventureLog database, media volume, and environment file. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$SCRIPT_DIR" + +COMPOSE_FILE="${COMPOSE_FILE:-}" +BACKUP_DIR="${BACKUP_DIR:-backups}" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" + +if [[ -z "$COMPOSE_FILE" ]]; then + if [[ -f .env.aio ]] && [[ ! -f .env ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + elif [[ -f .env.aio ]] && [[ -f docker-compose.aio.yml ]] && [[ "${ADVENTURELOG_COMPOSE:-}" == "aio" ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + else + COMPOSE_FILE="docker-compose.yml" + fi +fi + +case "$COMPOSE_FILE" in + *docker-compose.aio.yml*) + ENV_FILE=".env.aio" + ;; + *) + ENV_FILE=".env" + ;; +esac + +COMPOSE=(docker compose -f "$COMPOSE_FILE") +if [[ -f "$ENV_FILE" ]]; then + COMPOSE=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE") +fi +DEST="$BACKUP_DIR/$TIMESTAMP" +mkdir -p "$DEST" + +resolve_compose_volume() { + local suffix="$1" + "${COMPOSE[@]}" volume ls -q | grep "${suffix}$" | head -n 1 +} + +MEDIA_VOLUME="$(resolve_compose_volume "adventurelog_media")" +if [[ -z "$MEDIA_VOLUME" ]]; then + MEDIA_VOLUME="$(resolve_compose_volume "media")" +fi + +echo "Backing up AdventureLog to $DEST" + +if [[ -f "$ENV_FILE" ]]; then + cp "$ENV_FILE" "$DEST/" +fi + +POSTGRES_USER="${POSTGRES_USER:-adventure}" +POSTGRES_DB="${POSTGRES_DB:-database}" + +if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + set -a + source "$ENV_FILE" + set +a +fi + +echo "Dumping database..." +"${COMPOSE[@]}" exec -T db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > "$DEST/database.sql" + +echo "Archiving media volume..." +if [[ -z "$MEDIA_VOLUME" ]]; then + echo "WARNING: media volume not found; skipping media backup" >&2 +else + docker run --rm \ + -v "${MEDIA_VOLUME}:/data:ro" \ + -v "$DEST:/backup" \ + alpine:3.21 \ + tar czf /backup/media.tar.gz -C /data . +fi + +echo "Backup complete: $DEST" diff --git a/adventurelog/scripts/install/lib/aio-config.sh b/adventurelog/scripts/install/lib/aio-config.sh new file mode 100755 index 00000000..7fec8952 --- /dev/null +++ b/adventurelog/scripts/install/lib/aio-config.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# AIO configuration wizard and .env.aio writer. +set -euo pipefail + +prompt_aio_core_config() { + print_step_header 2 "$WIZARD_TOTAL" "Core configuration (AIO)" + log_info "All-in-One uses a single URL and port." + echo "" + + local default_site="http://localhost:8015" + while true; do + SITE_URL="$(prompt_with_default "Public site URL" "$default_site")" + if validate_url "$SITE_URL"; then + break + fi + log_error "Enter a valid http:// or https:// URL" + done + SITE_URL="${SITE_URL%/}" + HOST_PORT="$(extract_port_from_url "$SITE_URL" "8015")" + local port_override + port_override="$(tui_input "Host port to bind [${HOST_PORT}]" "$HOST_PORT")" + HOST_PORT="${port_override:-$HOST_PORT}" + log_success "Site: $SITE_URL (port $HOST_PORT)" + + POSTGRES_PASSWORD="$(generate_secure_password 32)" + log_success "Generated secure database password" + + if tui_confirm "Change default admin credentials (admin/admin)?" "y"; then + DJANGO_ADMIN_USERNAME="$(prompt_with_default "Admin username" "admin")" + DJANGO_ADMIN_PASSWORD="$(generate_secure_password 24)" + DJANGO_ADMIN_EMAIL="$(prompt_with_default "Admin email" "admin@example.com")" + else + DJANGO_ADMIN_USERNAME="admin" + DJANGO_ADMIN_PASSWORD="admin" + DJANGO_ADMIN_EMAIL="admin@example.com" + log_warning "Using default admin/admin — change after first login" + fi + echo "" +} + +write_env_aio() { + local target="$ENV_FILE" + { + echo "# AdventureLog All-in-One — generated by installer" + echo "POSTGRES_PASSWORD=$(format_env_value "$POSTGRES_PASSWORD")" + echo "SITE_URL=$(format_env_value "$SITE_URL")" + echo "HOST_PORT=$(format_env_value "$HOST_PORT")" + echo "DJANGO_ADMIN_USERNAME=$(format_env_value "$DJANGO_ADMIN_USERNAME")" + echo "DJANGO_ADMIN_PASSWORD=$(format_env_value "$DJANGO_ADMIN_PASSWORD")" + echo "DJANGO_ADMIN_EMAIL=$(format_env_value "$DJANGO_ADMIN_EMAIL")" + } > "$target" + save_optional_env_to_file "$target" + log_success "Wrote $target" +} + +show_aio_review() { + local opt_count=0 + [[ -v OPTIONAL_ENV_LINES ]] && opt_count="${#OPTIONAL_ENV_LINES[@]}" + print_step_header 5 "$WIZARD_TOTAL" "Review configuration" + print_summary_row "Setup" "All-in-One (AIO)" + print_summary_row "Site URL" "$SITE_URL" + print_summary_row "Host port" "$HOST_PORT" + print_summary_row "PostGIS image" "$POSTGIS_IMAGE" + print_summary_row "Admin user" "$DJANGO_ADMIN_USERNAME" + print_summary_row "Optional vars" "$opt_count configured" + echo "" + if ! tui_confirm "Proceed with installation?" "y"; then + log_info "Installation cancelled." + exit 0 + fi +} + +save_credentials_file() { + if [[ "$DRY_RUN" == true ]]; then + return 0 + fi + if ! tui_confirm "Save credentials to credentials.txt (mode 600)?" "y"; then + return 0 + fi + cat > credentials.txt << EOF +AdventureLog credentials — $(date) +Site URL: ${SITE_URL} +Admin username: ${DJANGO_ADMIN_USERNAME} +Admin password: ${DJANGO_ADMIN_PASSWORD} +Database password: ${POSTGRES_PASSWORD} +EOF + chmod 600 credentials.txt + log_success "Saved credentials.txt (keep this secure)" +} + +print_aio_success() { + print_success_banner + log_success "Installation completed!" + echo "" + echo -e "${BOLD}Access:${NC} ${CYAN}${SITE_URL}${NC}" + echo "" + echo -e "${BOLD}Admin:${NC} ${GREEN}${DJANGO_ADMIN_USERNAME}${NC} / ${GREEN}${DJANGO_ADMIN_PASSWORD}${NC}" + echo "" + echo -e "${BOLD}Config:${NC} $(pwd)/${ENV_FILE}" + echo "" + echo -e "${BOLD}Management:${NC}" + echo -e " Re-run installer: curl -sSL https://get.adventurelog.app | bash" + echo -e " Update: bash deploy.sh --backup" + echo -e " Logs: docker logs ${LOG_CONTAINER} -f" + echo -e " Stop: docker compose -f ${COMPOSE_FILE} down" + echo "" +} + +run_aio_install_wizard() { + SETUP_TYPE="aio" + resolve_compose_settings + WIZARD_TOTAL=7 + prompt_aio_core_config + run_port_checks + run_optional_features_wizard + write_postgis_override + show_aio_review +} diff --git a/adventurelog/scripts/install/lib/checks.sh b/adventurelog/scripts/install/lib/checks.sh new file mode 100755 index 00000000..3b5bac62 --- /dev/null +++ b/adventurelog/scripts/install/lib/checks.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# System preflight checks. +set -euo pipefail + +detect_system_arch() { + local raw + raw="$(uname -m)" + case "$raw" in + aarch64|arm64|armv7l|armv8*) + SYSTEM_ARCH="arm" + USE_ARM_POSTGIS=true + POSTGIS_IMAGE="imresamu/postgis:16-3.5-alpine" + ;; + x86_64|amd64) + SYSTEM_ARCH="amd64" + USE_ARM_POSTGIS=false + POSTGIS_IMAGE="postgis/postgis:16-3.5" + ;; + *) + SYSTEM_ARCH="$raw" + USE_ARM_POSTGIS=false + POSTGIS_IMAGE="postgis/postgis:16-3.5" + ;; + esac +} + +verify_postgis_image() { + local image="$1" + if docker manifest inspect "$image" &>/dev/null; then + return 0 + fi + log_warning "Could not verify manifest for $image" + return 1 +} + +write_postgis_override() { + local target="${1:-docker-compose.override.yml}" + if [[ "$USE_ARM_POSTGIS" != true ]]; then + return 0 + fi + log_info "ARM architecture detected — using PostGIS image: $POSTGIS_IMAGE" + cat > "$target" << EOF +# Auto-generated by AdventureLog installer for ARM hosts +services: + db: + image: ${POSTGIS_IMAGE} +EOF + log_success "Wrote $target" +} + +check_dependencies() { + log_info "Checking system dependencies..." + local missing=() + command -v curl &>/dev/null || missing+=("curl") + command -v docker &>/dev/null || missing+=("docker") + if ! docker compose version &>/dev/null 2>&1 && ! command -v docker-compose &>/dev/null; then + missing+=("docker-compose") + fi + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing: ${missing[*]}" + echo "Install Docker: https://docs.docker.com/get-docker/" + exit 1 + fi + log_success "Dependencies OK" +} + +check_docker_status() { + if [[ "${DRY_RUN:-false}" == true ]]; then + log_info "[dry-run] Skipping Docker daemon check" + return 0 + fi + log_info "Checking Docker daemon..." + if ! docker info &>/dev/null; then + log_error "Docker is not running" + exit 1 + fi + if ! docker info 2>/dev/null | grep -q "Username:" && [[ "$(id -u)" -ne 0 ]]; then + if ! groups 2>/dev/null | grep -q docker; then + log_warning "User not in docker group — you may need sudo for docker commands" + fi + fi + log_success "Docker is running" +} + +check_memory() { + local avail_kb avail_mb + if [[ -r /proc/meminfo ]]; then + avail_kb="$(awk '/MemAvailable/ {print $2}' /proc/meminfo)" + if [[ -n "$avail_kb" && "$avail_kb" =~ ^[0-9]+$ ]]; then + avail_mb=$((avail_kb / 1024)) + if (( avail_mb < 2048 )); then + log_warning "Available RAM is ~${avail_mb}MB — first boot needs ~2GB for world data import" + if tui_confirm "Enable SKIP_WORLD_DATA=1 to skip geography import on first boot?" "y"; then + SKIP_WORLD_DATA="1" + append_env_line "SKIP_WORLD_DATA=1" + fi + fi + fi + elif [[ "$OSTYPE" == "darwin"* ]]; then + log_info "Could not auto-detect RAM on macOS — ensure at least 2GB is allocated to Docker" + fi +} + +check_disk_space() { + local target="${1:-.}" + local check_path="$target" + if [[ ! -e "$check_path" ]]; then + check_path="." + fi + local avail + if command -v df &>/dev/null; then + avail="$(df -BG "$check_path" 2>/dev/null | awk 'NR==2 {gsub(/G/,"",$4); print $4}')" || avail="" + if [[ -n "$avail" && "$avail" =~ ^[0-9]+$ ]] && (( avail < 5 )); then + log_warning "Less than 5GB free disk space in $check_path" + fi + fi +} + +check_port_in_use() { + local port="$1" + local name="$2" + local in_use=false + if command -v ss &>/dev/null; then + ss -ln 2>/dev/null | grep -q ":${port} " && in_use=true + elif command -v netstat &>/dev/null; then + netstat -ln 2>/dev/null | grep -q ":${port} " && in_use=true + fi + if [[ "$in_use" == true ]]; then + log_warning "Port $port ($name) appears to be in use" + tui_confirm "Continue anyway?" "n" || exit 0 + fi +} + +detect_existing_install() { + local dir="${1:-$INSTALL_DIR}" + if [[ -f "$dir/.env.aio" ]] || [[ -f "$dir/.env" ]]; then + return 0 + fi + if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qE '^(adventurelog-aio|adventurelog-frontend|adventurelog-backend)$'; then + return 0 + fi + return 1 +} + +detect_setup_type_from_install() { + local dir="${1:-$INSTALL_DIR}" + if [[ -f "$dir/.env.aio" ]]; then + SETUP_TYPE="aio" + elif [[ -f "$dir/.env" ]]; then + SETUP_TYPE="standard" + fi + resolve_compose_settings +} + +run_preflight() { + print_step_header 1 "$WIZARD_TOTAL" "System checks" + check_dependencies + check_docker_status + detect_system_arch + log_info "Architecture: $SYSTEM_ARCH (${POSTGIS_IMAGE})" + check_memory + check_disk_space "$INSTALL_DIR" +} + +run_port_checks() { + if [[ "$SETUP_TYPE" == "aio" ]]; then + check_port_in_use "$HOST_PORT" "AdventureLog" + else + check_port_in_use "$FRONTEND_PORT" "frontend" + check_port_in_use "$BACKEND_PORT" "backend" + fi +} + +prompt_postgis_override() { + if [[ "$USE_ARM_POSTGIS" == true ]]; then + return 0 + fi + if tui_confirm "Use custom PostGIS image? (default: $POSTGIS_IMAGE)" "n"; then + POSTGIS_IMAGE="$(prompt_with_default "PostGIS image" "$POSTGIS_IMAGE")" + USE_ARM_POSTGIS=true + fi +} diff --git a/adventurelog/scripts/install/lib/common.sh b/adventurelog/scripts/install/lib/common.sh new file mode 100755 index 00000000..e609b3aa --- /dev/null +++ b/adventurelog/scripts/install/lib/common.sh @@ -0,0 +1,204 @@ +#!/bin/bash +# Shared globals and utilities for AdventureLog installer. +set -euo pipefail + +APP_NAME="AdventureLog" +INSTALL_DIR="${INSTALL_DIR:-./adventurelog}" +ADVENTURELOG_REF="${ADVENTURELOG_REF:-main}" +GITHUB_RAW="https://raw.githubusercontent.com/seanmorley15/AdventureLog/${ADVENTURELOG_REF}" +DOCS_BASE="https://adventurelog.app/docs/configuration" + +# Setup type: aio | standard +SETUP_TYPE="${SETUP_TYPE:-aio}" +DRY_RUN="${DRY_RUN:-false}" +FORCE_INSTALL="${FORCE_INSTALL:-false}" + +# AIO configuration +declare -g SITE_URL="" +declare -g HOST_PORT="" +declare -g POSTGRES_PASSWORD="" +declare -g DJANGO_ADMIN_USERNAME="admin" +declare -g DJANGO_ADMIN_PASSWORD="" +declare -g DJANGO_ADMIN_EMAIL="admin@example.com" +declare -g SKIP_WORLD_DATA="" + +# Standard configuration +declare -g FRONTEND_ORIGIN="" +declare -g BACKEND_URL="" +declare -g FRONTEND_PORT="" +declare -g BACKEND_PORT="" + +# Platform +declare -g SYSTEM_ARCH="" +declare -g POSTGIS_IMAGE="" +declare -g USE_ARM_POSTGIS=false + +# Wizard state +declare -g WIZARD_STEP=0 +declare -g WIZARD_TOTAL=7 +declare -g REPO_ROOT="${REPO_ROOT:-}" + +# Compose +declare -g COMPOSE_FILE="" +declare -g ENV_FILE="" +declare -g LOG_CONTAINER="" + +resolve_compose_settings() { + if [[ "$SETUP_TYPE" == "aio" ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + ENV_FILE=".env.aio" + LOG_CONTAINER="adventurelog-aio" + else + COMPOSE_FILE="docker-compose.yml" + ENV_FILE=".env" + LOG_CONTAINER="adventurelog-backend" + fi +} + +generate_secure_password() { + local length=${1:-24} + if [[ ! -r "/dev/urandom" ]]; then + echo "ERROR: /dev/urandom not readable" >&2 + return 1 + fi + if command -v tr &>/dev/null; then + LC_ALL=C tr -dc 'A-Za-z0-9!#$%&*+-=?@^_' /dev/null | head -c "$length" 2>/dev/null + return 0 + fi + if command -v openssl &>/dev/null; then + openssl rand -base64 32 | tr -d "=+/" | cut -c1-"$length" + return 0 + fi + echo "ERROR: No suitable random generation method found" >&2 + return 1 +} + +validate_url() { + local url="$1" + [[ $url =~ ^https?://[a-zA-Z0-9.-]+(:[0-9]+)?(/.*)?$ ]] +} + +extract_port_from_url() { + local url="$1" + local default_port="$2" + if [[ $url =~ :([0-9]+)(/|$) ]]; then + echo "${BASH_REMATCH[1]}" + elif [[ $url =~ ^https:// ]]; then + echo "443" + elif [[ $url =~ ^http:// ]]; then + echo "${default_port:-80}" + else + echo "$default_port" + fi +} + +get_compose_cmd() { + if docker compose version &>/dev/null 2>&1; then + echo "docker compose" + else + echo "docker-compose" + fi +} + +format_env_value() { + local val="$1" + if [[ "$val" =~ ^[A-Za-z0-9_.,/@^+-]+$ ]]; then + printf '%s' "$val" + else + local escaped="${val//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + printf '"%s"' "$escaped" + fi +} + +compose_args() { + local cmd + cmd="$(get_compose_cmd)" + if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC2086 + echo $cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" + else + # shellcheck disable=SC2086 + echo $cmd -f "$COMPOSE_FILE" + fi +} + +run_compose() { + local cmd + cmd="$(get_compose_cmd)" + if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC2086 + $cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" + else + # shellcheck disable=SC2086 + $cmd -f "$COMPOSE_FILE" "$@" + fi +} + +detect_install_lib_dir() { + local script_path="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" + local candidate + if [[ -n "${INSTALLER_LIB_DIR:-}" && -f "${INSTALLER_LIB_DIR}/ui.sh" ]]; then + echo "$INSTALLER_LIB_DIR" + return 0 + fi + candidate="$(cd "$(dirname "$script_path")" && pwd)" + if [[ -f "$candidate/ui.sh" ]]; then + echo "$candidate" + return 0 + fi + candidate="$(cd "$(dirname "$script_path")/../.." && pwd)/scripts/install/lib" + if [[ -f "$candidate/ui.sh" ]]; then + echo "$candidate" + return 0 + fi + if [[ -f "./adventurelog/scripts/install/lib/ui.sh" ]]; then + echo "$(cd "./adventurelog/scripts/install/lib" && pwd)" + return 0 + fi + return 1 +} + +append_env_line() { + local line="$1" + local key="${line%%=*}" + local value="${line#*=}" + if [[ "$key" != "$line" ]]; then + OPTIONAL_ENV_LINES+=("${key}=$(format_env_value "$value")") + else + OPTIONAL_ENV_LINES+=("$line") + fi +} + +save_optional_env_to_file() { + local target="$1" + local count=0 + [[ -v OPTIONAL_ENV_LINES ]] && count="${#OPTIONAL_ENV_LINES[@]}" + if (( count == 0 )); then + return 0 + fi + { + echo "" + echo "# Optional features configured by AdventureLog installer" + for line in "${OPTIONAL_ENV_LINES[@]}"; do + echo "$line" + done + } >> "$target" +} + +load_existing_env() { + local file="$1" + if [[ ! -f "$file" ]]; then + return 0 + fi + # shellcheck disable=SC1090 + set -a + source "$file" + set +a + SITE_URL="${SITE_URL:-}" + HOST_PORT="${HOST_PORT:-}" + POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" + DJANGO_ADMIN_USERNAME="${DJANGO_ADMIN_USERNAME:-admin}" + DJANGO_ADMIN_PASSWORD="${DJANGO_ADMIN_PASSWORD:-admin}" + DJANGO_ADMIN_EMAIL="${DJANGO_ADMIN_EMAIL:-admin@example.com}" +} diff --git a/adventurelog/scripts/install/lib/deploy.sh b/adventurelog/scripts/install/lib/deploy.sh new file mode 100755 index 00000000..9ad38048 --- /dev/null +++ b/adventurelog/scripts/install/lib/deploy.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Deploy containers and wait for health. +set -euo pipefail + +validate_env_file() { + if [[ ! -f "scripts/validate-env.sh" ]]; then + log_warning "validate-env.sh not found — skipping validation" + return 0 + fi + log_info "Validating $ENV_FILE ..." + if ! bash scripts/validate-env.sh "$ENV_FILE"; then + log_error "Environment validation failed" + exit 1 + fi + log_success "Environment validated" +} + +print_pull_failure_help() { + echo "" >&2 + log_error "Failed to pull Docker images." + echo "" >&2 + echo "Common fixes:" >&2 + echo " • Check your internet connection" >&2 + echo " • If you see 'denied' from ghcr.io, wait a moment and retry" >&2 + echo " • Try: docker login ghcr.io (only if using a private registry token)" >&2 + echo " • Manual pull: docker compose --env-file ${ENV_FILE} -f ${COMPOSE_FILE} pull" >&2 + echo "" >&2 + echo "To build from source instead, clone the full AdventureLog repo and run:" >&2 + echo " docker compose -f docker-compose.aio.yml build && docker compose -f docker-compose.aio.yml up -d" >&2 + echo "" >&2 +} + +start_services() { + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would run: run_compose pull && run_compose up -d" + return 0 + fi + + log_info "Pulling Docker images (this may take a few minutes)..." + echo "" >&2 + if ! run_compose pull; then + print_pull_failure_help + exit 1 + fi + echo "" >&2 + log_success "Images pulled" + + log_info "Starting containers..." + if run_compose up -d --remove-orphans --wait 2>/dev/null; then + log_success "Containers started" + elif run_compose up -d --remove-orphans; then + log_success "Containers started (without --wait)" + else + log_error "Failed to start containers" + run_compose ps || true + exit 1 + fi +} + +wait_for_health() { + local url="${1:-$SITE_URL}" + local max_attempts="${2:-300}" + local attempt=1 + local health_url="${url%/}/health" + + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would wait for $health_url" + return 0 + fi + + log_info "Waiting for AdventureLog (up to $((max_attempts / 6)) minutes on first boot)..." + while (( attempt <= max_attempts )); do + if curl -fsS -o /dev/null "$health_url" 2>/dev/null; then + echo "" >&2 + log_success "Health check passed: $health_url" + return 0 + fi + local pct=$(( attempt * 100 / max_attempts )) + local frame='⠋' + case $(( attempt % 4 )) in + 1) frame='⠙' ;; + 2) frame='⠹' ;; + 3) frame='⠸' ;; + esac + printf "\r ${CYAN}${frame}${NC} Waiting for ${health_url} ${DIM}(${pct}%%)${NC} " >&2 + sleep 2 + ((attempt++)) || true + done + printf "\r\033[K" >&2 + log_warning "Health check timed out — service may still be starting (first boot imports world data)" + run_compose ps || true + log_info "Recent logs:" + docker logs "$LOG_CONTAINER" --tail 30 2>&1 || true +} + +cleanup_on_failure() { + if [[ -f ".env.aio.backup" ]]; then + mv .env.aio.backup .env.aio 2>/dev/null || true + fi + if [[ -f ".env.backup" ]]; then + mv .env.backup .env 2>/dev/null || true + fi + if [[ "$DRY_RUN" != true ]] && command -v docker &>/dev/null; then + run_compose down --remove-orphans 2>/dev/null || true + fi +} + +run_deploy_phase() { + print_step_header 6 "$WIZARD_TOTAL" "Deploy" + validate_env_file + start_services + print_step_header 7 "$WIZARD_TOTAL" "Health check" + if [[ "$SETUP_TYPE" == "aio" ]]; then + wait_for_health "$SITE_URL" 300 + else + wait_for_health "$FRONTEND_ORIGIN" 300 + fi +} diff --git a/adventurelog/scripts/install/lib/download.sh b/adventurelog/scripts/install/lib/download.sh new file mode 100755 index 00000000..36c2a6bb --- /dev/null +++ b/adventurelog/scripts/install/lib/download.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Download installer files from GitHub. +set -euo pipefail + +INSTALLER_LIB_FILES=( + common.sh ui.sh tui.sh checks.sh download.sh deploy.sh + aio-config.sh standard-config.sh manage.sh features.sh +) + +download_file() { + local url="$1" + local dest="$2" + local relpath="${url#*AdventureLog/${ADVENTURELOG_REF}/}" + if [[ -n "${REPO_ROOT:-}" && -f "${REPO_ROOT}/${relpath}" ]]; then + mkdir -p "$(dirname "$dest")" + cp "${REPO_ROOT}/${relpath}" "$dest" + return 0 + fi + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would download $url -> $dest" + return 0 + fi + mkdir -p "$(dirname "$dest")" + if ! curl -fsSL --connect-timeout 15 --max-time 60 "$url" -o "$dest"; then + log_error "Failed to download $url" + return 1 + fi +} + +bootstrap_installer_libs() { + local dest_dir="$1" + log_info "Downloading installer libraries..." + mkdir -p "$dest_dir" + local f + for f in "${INSTALLER_LIB_FILES[@]}"; do + download_file "${GITHUB_RAW}/scripts/install/lib/${f}" "${dest_dir}/${f}" + done + log_success "Installer libraries ready" +} + +copy_local_installer_libs() { + local src="$1" + local dest="$2" + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would copy installer libs to $dest" + return 0 + fi + mkdir -p "$dest" + local f + for f in "${INSTALLER_LIB_FILES[@]}"; do + if [[ -f "$src/$f" ]]; then + cp "$src/$f" "$dest/$f" + chmod +x "$dest/$f" 2>/dev/null || true + fi + done +} + +download_aio_toolkit() { + log_info "Downloading AIO deployment files..." + download_file "${GITHUB_RAW}/docker-compose.aio.yml" "docker-compose.aio.yml" + download_file "${GITHUB_RAW}/.env.aio.example" ".env.aio.example" + download_file "${GITHUB_RAW}/deploy.sh" "deploy.sh" + download_file "${GITHUB_RAW}/scripts/validate-env.sh" "scripts/validate-env.sh" + download_file "${GITHUB_RAW}/scripts/backup.sh" "scripts/backup.sh" + download_file "${GITHUB_RAW}/scripts/restore.sh" "scripts/restore.sh" + download_file "${GITHUB_RAW}/docker-compose.override.example.yml" "docker-compose.override.example.yml" || true + chmod +x deploy.sh scripts/validate-env.sh scripts/backup.sh scripts/restore.sh 2>/dev/null || true + if [[ -d "$(dirname "${BASH_SOURCE[0]}")" ]]; then + copy_local_installer_libs "$(dirname "${BASH_SOURCE[0]}")" "scripts/install/lib" + fi + log_success "AIO toolkit downloaded" +} + +download_standard_toolkit() { + log_info "Downloading standard deployment files..." + download_file "${GITHUB_RAW}/docker-compose.yml" "docker-compose.yml" + download_file "${GITHUB_RAW}/.env.example" ".env.example" + download_file "${GITHUB_RAW}/deploy.sh" "deploy.sh" + download_file "${GITHUB_RAW}/scripts/validate-env.sh" "scripts/validate-env.sh" + download_file "${GITHUB_RAW}/scripts/backup.sh" "scripts/backup.sh" + download_file "${GITHUB_RAW}/scripts/restore.sh" "scripts/restore.sh" + download_file "${GITHUB_RAW}/docker-compose.override.example.yml" "docker-compose.override.example.yml" || true + chmod +x deploy.sh scripts/validate-env.sh scripts/backup.sh scripts/restore.sh 2>/dev/null || true + if [[ -d "$(dirname "${BASH_SOURCE[0]}")" ]]; then + copy_local_installer_libs "$(dirname "${BASH_SOURCE[0]}")" "scripts/install/lib" + fi + log_success "Standard toolkit downloaded" +} + +ensure_install_directory() { + local dir="$INSTALL_DIR" + log_info "Install directory: $dir" + if [[ -d "$dir" ]] && [[ "$FORCE_INSTALL" != true ]]; then + if detect_existing_install "$dir"; then + return 0 + fi + log_warning "Directory exists" + tui_confirm "Continue and update files in existing directory?" "y" || exit 0 + fi + if [[ "$DRY_RUN" != true ]] || [[ ! -d "$dir" ]]; then + mkdir -p "$dir" + fi + cd "$dir" || exit 1 +} diff --git a/adventurelog/scripts/install/lib/features.sh b/adventurelog/scripts/install/lib/features.sh new file mode 100755 index 00000000..ecab3be0 --- /dev/null +++ b/adventurelog/scripts/install/lib/features.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# Optional feature configuration wizard modules. +set -euo pipefail + +feature_section_header() { + local name="$1" + local doc_slug="$2" + if declare -f tui_print_box &>/dev/null; then + tui_print_box "Configure: ${name}" "Documentation: ${DOCS_BASE}/${doc_slug}.html" + else + echo "" + log_header "Configure: $name" + log_info "Docs: ${DOCS_BASE}/${doc_slug}.html" + echo "" + fi +} + +configure_registration_auth() { + feature_section_header "Registration & Auth" "disable_registration" + if tui_confirm "Disable new user registration?" "n"; then + append_env_line "DISABLE_REGISTRATION=True" + local msg + msg="$(tui_input "Registration disabled message (optional)" "Registration is disabled for this instance of AdventureLog.")" + [[ -n "$msg" ]] && append_env_line "DISABLE_REGISTRATION_MESSAGE=${msg}" + fi + if tui_confirm "Allow social signup when registration is disabled?" "n"; then + append_env_line "SOCIALACCOUNT_ALLOW_SIGNUP=True" + fi + log_warning "Social-only login: do not enable unless SSO is already configured." + log_info "SSO can be set up after AdventureLog starts — Django admin → Social applications." + if tui_confirm "Force social-only login (disable password login)?" "n"; then + append_env_line "FORCE_SOCIALACCOUNT_LOGIN=True" + fi + if tui_confirm "Configure email verification for new accounts?" "n"; then + local verify + if ! verify="$(tui_choose "Email verification level" "optional" "mandatory")"; then + verify="optional" + fi + append_env_line "ACCOUNT_EMAIL_VERIFICATION=${verify}" + fi +} + +configure_email() { + feature_section_header "Email" "email" + append_env_line "EMAIL_BACKEND=email" + append_env_line "EMAIL_HOST=$(prompt_with_default "SMTP host" "smtp.gmail.com")" + if tui_confirm "Use TLS?" "y"; then + append_env_line "EMAIL_USE_TLS=True" + append_env_line "EMAIL_PORT=587" + else + append_env_line "EMAIL_USE_TLS=False" + append_env_line "EMAIL_USE_SSL=True" + append_env_line "EMAIL_PORT=465" + fi + append_env_line "EMAIL_HOST_USER=$(prompt_with_default "SMTP username" "")" + append_env_line "EMAIL_HOST_PASSWORD=$(tui_password "SMTP password")" + append_env_line "DEFAULT_FROM_EMAIL=$(prompt_with_default "From email" "noreply@example.com")" +} + +configure_s3() { + feature_section_header "S3 Media Storage" "s3_storage" + log_warning "Choose storage at install time — migrating later is difficult." + append_env_line "MEDIA_STORAGE=s3" + append_env_line "AWS_ACCESS_KEY_ID=$(prompt_with_default "AWS access key ID" "")" + append_env_line "AWS_SECRET_ACCESS_KEY=$(tui_password "AWS secret access key")" + append_env_line "AWS_STORAGE_BUCKET_NAME=$(prompt_with_default "Bucket name" "")" + local endpoint + endpoint="$(tui_input "S3 endpoint URL (empty for AWS)" "")" + [[ -n "$endpoint" ]] && append_env_line "AWS_S3_ENDPOINT_URL=${endpoint}" + append_env_line "AWS_S3_REGION_NAME=$(prompt_with_default "Region" "auto")" + append_env_line "AWS_S3_ADDRESSING_STYLE=path" + append_env_line "AWS_S3_SIGNATURE_VERSION=s3v4" + local custom_domain + custom_domain="$(tui_input "Custom CDN domain (optional)" "")" + [[ -n "$custom_domain" ]] && append_env_line "AWS_S3_CUSTOM_DOMAIN=${custom_domain}" +} + +configure_google_maps() { + feature_section_header "Google Maps" "google_maps_integration" + local key + key="$(prompt_with_default "Google Maps API key" "")" + [[ -n "$key" ]] && append_env_line "GOOGLE_MAPS_API_KEY=${key}" +} + +configure_strava() { + feature_section_header "Strava Integration" "strava_integration" + append_env_line "STRAVA_CLIENT_ID=$(prompt_with_default "Strava client ID" "")" + append_env_line "STRAVA_CLIENT_SECRET=$(tui_password "Strava client secret")" +} + +configure_analytics() { + feature_section_header "Umami Analytics" "analytics" + append_env_line "PUBLIC_UMAMI_SRC=$(prompt_with_default "Umami script URL" "https://cloud.umami.is/script.js")" + append_env_line "PUBLIC_UMAMI_WEBSITE_ID=$(prompt_with_default "Umami website ID" "")" +} + +configure_performance() { + feature_section_header "Performance & Rate Limits" "advanced_configuration" + local workers + workers="$(prompt_with_default "Gunicorn workers (1 for small hosts)" "2")" + append_env_line "GUNICORN_WORKERS=${workers}" + if tui_confirm "Enable rate limits (recommended for production)?" "n"; then + append_env_line "ENABLE_RATE_LIMITS=True" + fi +} + +configure_debug() { + feature_section_header "Debug mode" "advanced_configuration" + if tui_confirm "Enable DEBUG mode? (not for production)" "n"; then + append_env_line "DEBUG=True" + fi +} + +run_optional_features_wizard() { + print_step_header 4 "$WIZARD_TOTAL" "Optional features" + if [[ "${DRY_RUN:-false}" == true ]]; then + log_info "[dry-run] Skipping optional features" + return 0 + fi + log_info "Configure optional integrations, or pick Done when finished." + echo "" + local groups=( + "Registration & Auth" + "Email" + "S3 Media Storage" + "Google Maps" + "Strava" + "Umami Analytics" + "Performance" + "Debug mode" + "Done — continue" + ) + local choice + while true; do + if ! choice="$(tui_choose "Optional features" "${groups[@]}")"; then + break + fi + case "$choice" in + "Registration & Auth") configure_registration_auth ;; + "Email") configure_email ;; + "S3 Media Storage") configure_s3 ;; + "Google Maps") configure_google_maps ;; + "Strava") configure_strava ;; + "Umami Analytics") configure_analytics ;; + "Performance") configure_performance ;; + "Debug mode") configure_debug ;; + "Done — continue"|*) break ;; + esac + done + if [[ -n "$SKIP_WORLD_DATA" ]]; then + append_env_line "SKIP_WORLD_DATA=1" + fi +} diff --git a/adventurelog/scripts/install/lib/manage.sh b/adventurelog/scripts/install/lib/manage.sh new file mode 100755 index 00000000..e499647e --- /dev/null +++ b/adventurelog/scripts/install/lib/manage.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# Management mode for existing installs. +set -euo pipefail + +enter_install_dir() { + if [[ -d "$INSTALL_DIR" ]]; then + cd "$INSTALL_DIR" || exit 1 + fi + detect_setup_type_from_install "." + resolve_compose_settings + load_existing_env "$ENV_FILE" +} + +mgmt_check_status() { + local compose health_url + compose="$(compose_args)" + echo "" + log_header "Status" + $compose ps 2>/dev/null || docker ps -a --filter "name=adventurelog" + echo "" + if [[ "$SETUP_TYPE" == "aio" ]]; then + health_url="${SITE_URL:-http://localhost:8015}/health" + else + health_url="${FRONTEND_ORIGIN:-http://localhost:8015}/health" + fi + if curl -fsS -o /dev/null "$health_url" 2>/dev/null; then + log_success "Health OK: $health_url" + else + log_warning "Health check failed: $health_url" + fi + tui_press_enter +} + +mgmt_update() { + local backup_flag="" + if tui_confirm "Create backup before update?" "y"; then + backup_flag="--backup" + fi + if [[ -f deploy.sh ]]; then + # shellcheck disable=SC2086 + COMPOSE_FILE="$COMPOSE_FILE" bash deploy.sh $backup_flag + else + local compose + compose="$(compose_args)" + $compose pull + $compose up -d --wait 2>/dev/null || $compose up -d + fi + log_success "Update complete" + tui_press_enter +} + +mgmt_reconfigure() { + log_info "Reconfigure optional features and core settings" + if [[ -f "$ENV_FILE" ]]; then + cp "$ENV_FILE" "${ENV_FILE}.backup.$(date +%Y%m%d-%H%M%S)" + fi + OPTIONAL_ENV_LINES=() + if [[ "$SETUP_TYPE" == "aio" ]]; then + prompt_aio_core_config + run_optional_features_wizard + write_env_aio + else + prompt_standard_core_config + run_optional_features_wizard + write_env_standard + fi + validate_env_file + if tui_confirm "Restart services to apply changes?" "y"; then + local compose + compose="$(compose_args)" + $compose up -d --remove-orphans + fi + log_success "Configuration updated" + tui_press_enter +} + +mgmt_backup() { + if [[ -f scripts/backup.sh ]]; then + COMPOSE_FILE="$COMPOSE_FILE" bash scripts/backup.sh + else + log_error "scripts/backup.sh not found" + fi + tui_press_enter +} + +mgmt_restore() { + local backups dir choice + if [[ ! -d backups ]]; then + log_error "No backups/ directory found" + tui_press_enter + return + fi + mapfile -t backups < <(find backups -mindepth 1 -maxdepth 1 -type d | sort -r) + if [[ ${#backups[@]} -eq 0 ]]; then + log_error "No backups found" + tui_press_enter + return + fi + echo "Available backups:" + local i=1 + for dir in "${backups[@]}"; do + echo " [$i] $dir" + ((i++)) || true + done + read -r -p "Select backup number: " choice + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#backups[@]} )); then + dir="${backups[$((choice - 1))]}" + if tui_confirm "Restore from $dir? This will overwrite current data." "n"; then + COMPOSE_FILE="$COMPOSE_FILE" bash scripts/restore.sh "$dir" + fi + else + log_error "Invalid selection" + fi + tui_press_enter +} + +mgmt_logs() { + if docker logs "$LOG_CONTAINER" --tail 50 2>/dev/null; then + echo "" + if tui_confirm "Follow logs (Ctrl+C to stop)?" "n"; then + docker logs "$LOG_CONTAINER" --follow + fi + else + local compose + compose="$(compose_args)" + $compose logs --tail 50 + fi +} + +mgmt_restart() { + local compose + compose="$(compose_args)" + $compose restart + log_success "Services restarted" + tui_press_enter +} + +mgmt_uninstall() { + if ! tui_confirm "Stop containers and remove volumes? This deletes all data." "n"; then + return + fi + local compose + compose="$(compose_args)" + $compose down -v --remove-orphans + if tui_confirm "Also delete install directory $(pwd)?" "n"; then + local install_path + install_path="$(pwd)" + cd .. || exit 1 + rm -rf "$install_path" + fi + log_success "Uninstalled" +} + +run_management_menu() { + enter_install_dir + print_screen "AdventureLog Manager — $(pwd)" + log_info "Setup: $SETUP_TYPE | Compose: $COMPOSE_FILE" + echo "" + while true; do + local choice + choice="$(tui_choose "What would you like to do?" \ + "Check status & health" \ + "Update to latest images" \ + "Edit configuration" \ + "Backup now" \ + "Restore from backup" \ + "View logs" \ + "Restart services" \ + "Uninstall" \ + "Exit")" || break + case "$choice" in + "Check status & health") mgmt_check_status ;; + "Update to latest images") mgmt_update ;; + "Edit configuration") mgmt_reconfigure ;; + "Backup now") mgmt_backup ;; + "Restore from backup") mgmt_restore ;; + "View logs") mgmt_logs ;; + "Restart services") mgmt_restart ;; + "Uninstall") mgmt_uninstall; break ;; + "Exit"|*) break ;; + esac + print_screen "AdventureLog Manager" + done +} diff --git a/adventurelog/scripts/install/lib/standard-config.sh b/adventurelog/scripts/install/lib/standard-config.sh new file mode 100755 index 00000000..c8ac2da4 --- /dev/null +++ b/adventurelog/scripts/install/lib/standard-config.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Standard multi-container configuration wizard. +set -euo pipefail + +prompt_standard_core_config() { + print_step_header 2 "$WIZARD_TOTAL" "Core configuration (Standard)" + log_info "Standard setup uses separate frontend and backend URLs." + echo "" + + local default_frontend="http://localhost:8015" + while true; do + FRONTEND_ORIGIN="$(prompt_with_default "Frontend URL" "$default_frontend")" + if validate_url "$FRONTEND_ORIGIN"; then + FRONTEND_PORT="$(extract_port_from_url "$FRONTEND_ORIGIN" "8015")" + break + fi + log_error "Invalid frontend URL" + done + + local default_backend="http://localhost:8016" + while true; do + BACKEND_URL="$(prompt_with_default "Backend URL" "$default_backend")" + if validate_url "$BACKEND_URL"; then + BACKEND_PORT="$(extract_port_from_url "$BACKEND_URL" "8016")" + break + fi + log_error "Invalid backend URL" + done + + POSTGRES_PASSWORD="$(generate_secure_password 32)" + if tui_confirm "Change default admin credentials (admin/admin)?" "y"; then + DJANGO_ADMIN_USERNAME="$(prompt_with_default "Admin username" "admin")" + DJANGO_ADMIN_PASSWORD="$(generate_secure_password 24)" + DJANGO_ADMIN_EMAIL="$(prompt_with_default "Admin email" "admin@example.com")" + else + DJANGO_ADMIN_USERNAME="admin" + DJANGO_ADMIN_PASSWORD="admin" + DJANGO_ADMIN_EMAIL="admin@example.com" + fi + log_success "Frontend: $FRONTEND_ORIGIN Backend: $BACKEND_URL" + echo "" +} + +write_env_standard() { + local target="$ENV_FILE" + local secret_key + secret_key="$(generate_secure_password 50)" + if [[ -f .env.example ]]; then + cp .env.example "$target" + local tmp="${target}.tmp" + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + POSTGRES_PASSWORD=*) echo "POSTGRES_PASSWORD=$(format_env_value "$POSTGRES_PASSWORD")" ;; + DJANGO_ADMIN_PASSWORD=*) echo "DJANGO_ADMIN_PASSWORD=$(format_env_value "$DJANGO_ADMIN_PASSWORD")" ;; + DJANGO_ADMIN_USERNAME=*) echo "DJANGO_ADMIN_USERNAME=$(format_env_value "$DJANGO_ADMIN_USERNAME")" ;; + DJANGO_ADMIN_EMAIL=*) echo "DJANGO_ADMIN_EMAIL=$(format_env_value "$DJANGO_ADMIN_EMAIL")" ;; + SECRET_KEY=*) echo "SECRET_KEY=$(format_env_value "$secret_key")" ;; + ORIGIN=*) echo "ORIGIN=$(format_env_value "$FRONTEND_ORIGIN")" ;; + PUBLIC_URL=*) echo "PUBLIC_URL=$(format_env_value "$BACKEND_URL")" ;; + CSRF_TRUSTED_ORIGINS=*) echo "CSRF_TRUSTED_ORIGINS=$(format_env_value "${FRONTEND_ORIGIN},${BACKEND_URL}")" ;; + FRONTEND_URL=*) echo "FRONTEND_URL=$(format_env_value "$FRONTEND_ORIGIN")" ;; + FRONTEND_PORT=*) echo "FRONTEND_PORT=$(format_env_value "$FRONTEND_PORT")" ;; + BACKEND_PORT=*) echo "BACKEND_PORT=$(format_env_value "$BACKEND_PORT")" ;; + *) echo "$line" ;; + esac + done < "$target" > "$tmp" + mv "$tmp" "$target" + else + { + echo "PUBLIC_SERVER_URL=http://server:8000" + echo "ORIGIN=$(format_env_value "$FRONTEND_ORIGIN")" + echo "BODY_SIZE_LIMIT=Infinity" + echo "FRONTEND_PORT=$(format_env_value "$FRONTEND_PORT")" + echo "PGHOST=db" + echo "POSTGRES_DB=database" + echo "POSTGRES_USER=adventure" + echo "POSTGRES_PASSWORD=$(format_env_value "$POSTGRES_PASSWORD")" + echo "SECRET_KEY=$(format_env_value "$secret_key")" + echo "DJANGO_ADMIN_USERNAME=$(format_env_value "$DJANGO_ADMIN_USERNAME")" + echo "DJANGO_ADMIN_PASSWORD=$(format_env_value "$DJANGO_ADMIN_PASSWORD")" + echo "DJANGO_ADMIN_EMAIL=$(format_env_value "$DJANGO_ADMIN_EMAIL")" + echo "PUBLIC_URL=$(format_env_value "$BACKEND_URL")" + echo "CSRF_TRUSTED_ORIGINS=$(format_env_value "${FRONTEND_ORIGIN},${BACKEND_URL}")" + echo "DEBUG=False" + echo "FRONTEND_URL=$(format_env_value "$FRONTEND_ORIGIN")" + echo "BACKEND_PORT=$(format_env_value "$BACKEND_PORT")" + } > "$target" + fi + save_optional_env_to_file "$target" + log_success "Wrote $target" +} + +show_standard_review() { + print_step_header 5 "$WIZARD_TOTAL" "Review configuration" + print_summary_row "Setup" "Standard (multi-container)" + print_summary_row "Frontend" "$FRONTEND_ORIGIN" + print_summary_row "Backend" "$BACKEND_URL" + print_summary_row "PostGIS image" "$POSTGIS_IMAGE" + echo "" + tui_confirm "Proceed with installation?" "y" || exit 0 +} + +print_standard_success() { + print_success_banner + log_success "Installation completed!" + echo -e "${BOLD}Frontend:${NC} ${CYAN}${FRONTEND_ORIGIN}${NC}" + echo -e "${BOLD}Backend:${NC} ${CYAN}${BACKEND_URL}${NC}" + echo -e "${BOLD}Admin:${NC} ${GREEN}${DJANGO_ADMIN_USERNAME}${NC} / ${GREEN}${DJANGO_ADMIN_PASSWORD}${NC}" + echo "" + echo -e "Update: bash deploy.sh --backup" + echo "" +} + +run_standard_install_wizard() { + SETUP_TYPE="standard" + resolve_compose_settings + WIZARD_TOTAL=7 + prompt_standard_core_config + run_port_checks + run_optional_features_wizard + write_postgis_override + show_standard_review +} diff --git a/adventurelog/scripts/install/lib/tui.sh b/adventurelog/scripts/install/lib/tui.sh new file mode 100755 index 00000000..8b554359 --- /dev/null +++ b/adventurelog/scripts/install/lib/tui.sh @@ -0,0 +1,433 @@ +#!/bin/bash +# Cross-distro TUI: full gum (real terminals) | styled bash + gum cosmetics (IDE) | plain fallback. +set -euo pipefail + +TUI_BACKEND="bash" +GUM_STYLE=false + +has_gum() { + command -v gum &>/dev/null +} + +# Full gum choose/confirm/input uses alt-screen — broken in IDE integrated terminals. +terminal_supports_gum_interactive() { + has_gum || return 1 + [[ -t 0 && -t 1 ]] || return 1 + [[ -n "${TERM:-}" && "${TERM:-}" != "dumb" ]] || return 1 + case "${TERM_PROGRAM:-}" in + vscode|Visual\ Studio\ Code) return 1 ;; + esac + [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" ]] && return 1 + [[ -n "${CURSOR_TRACE_ID:-}" ]] && return 1 + [[ -n "${CURSOR_AGENT:-}" ]] && return 1 + return 0 +} + +detect_tui_backend() { + if [[ -n "${ADVENTURELOG_TUI:-}" ]]; then + TUI_BACKEND="$ADVENTURELOG_TUI" + return 0 + fi + if terminal_supports_gum_interactive; then + TUI_BACKEND="gum" + elif has_gum; then + TUI_BACKEND="styled" + elif command -v whiptail &>/dev/null; then + TUI_BACKEND="whiptail" + elif command -v dialog &>/dev/null; then + TUI_BACKEND="dialog" + else + TUI_BACKEND="bash" + fi +} + +offer_gum_install() { + if has_gum; then + return 0 + fi + tui_print_box "Enhanced UI" "Install gum for styled menus, spinners, and progress bars." + if ! tui_confirm_bash "Install gum now?" "y"; then + log_info "Continuing with built-in styled UI." + return 1 + fi + if [[ "$OSTYPE" == "darwin"* ]] && command -v brew &>/dev/null; then + log_info "Installing gum via Homebrew..." + brew install gum + elif command -v apt-get &>/dev/null; then + log_info "Installing gum via apt (may require sudo)..." + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg + echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null + sudo apt-get update -qq + sudo apt-get install -y gum + else + log_warning "Automatic gum install not supported on this OS." + log_info "See https://github.com/charmbracelet/gum#installation" + return 1 + fi + has_gum +} + +init_tui() { + GUM_STYLE=false + TUI_BACKEND="bash" + if has_gum; then + GUM_STYLE=true + fi + if [[ "$DRY_RUN" == true ]] || [[ "${ADVENTURELOG_SKIP_GUM:-}" == "1" ]]; then + TUI_BACKEND="bash" + return 0 + fi + if ! has_gum && [[ "${ADVENTURELOG_SKIP_GUM:-}" != "1" ]]; then + offer_gum_install && GUM_STYLE=true + fi + detect_tui_backend + case "$TUI_BACKEND" in + gum) log_info "UI mode: interactive gum" ;; + styled) log_info "UI mode: styled terminal (IDE-safe)" ;; + *) log_info "UI mode: classic text" ;; + esac +} + +# ── Visual helpers (safe in all terminals) ────────────────────────────────── + +tui_styled() { + local fg="${1:-212}" + shift + if [[ "$GUM_STYLE" == true ]]; then + gum style --foreground "$fg" "$@" + else + echo -e "${PURPLE}${BOLD}$*${NC}" + fi +} + +tui_print_box() { + local title="$1" + local body="$2" + echo "" >&2 + if [[ "$GUM_STYLE" == true ]]; then + gum join --align left --vertical \ + "$(gum style --border rounded --border-foreground 212 --padding "0 1" --bold "$title")" \ + "$(gum style --border rounded --border-foreground 240 --padding "0 1" "$body")" >&2 + else + local width=58 + echo -e "${CYAN}╭$(printf '─%.0s' $(seq 1 "$width"))╮${NC}" >&2 + echo -e "${CYAN}│${NC} ${BOLD}${title}${NC}" >&2 + echo -e "${CYAN}├$(printf '─%.0s' $(seq 1 "$width"))┤${NC}" >&2 + while IFS= read -r line; do + echo -e "${CYAN}│${NC} ${line}" >&2 + done <<< "$body" + echo -e "${CYAN}╰$(printf '─%.0s' $(seq 1 "$width"))╯${NC}" >&2 + fi + echo "" >&2 +} + +tui_progress_bar() { + local step="$1" + local total="$2" + local label="$3" + local width=24 + local filled=$(( step * width / total )) + local empty=$(( width - filled )) + local bar + bar="$(printf '█%.0s' $(seq 1 "$filled" 2>/dev/null || true))$(printf '░%.0s' $(seq 1 "$empty" 2>/dev/null || true))" + if [[ "$GUM_STYLE" == true ]]; then + if ! gum join --horizontal \ + "$(gum style --foreground 212 "[${bar}]")" \ + "$(gum style --bold " Step ${step}/${total}")" \ + "$(gum style --foreground 245 " ${label}")" >&2 2>/dev/null; then + echo -e "${CYAN}[${bar}]${NC} ${BOLD}Step ${step}/${total}${NC} ${DIM}${label}${NC}" >&2 + fi + else + echo -e "${CYAN}[${bar}]${NC} ${BOLD}Step ${step}/${total}${NC} ${DIM}${label}${NC}" >&2 + fi + echo "" >&2 +} + +# ── Prompts ───────────────────────────────────────────────────────────────── + +tui_confirm_bash() { + local prompt="$1" + local default="${2:-n}" + local hint="[y/N]" + [[ "$default" == "y" ]] && hint="[Y/n]" + if [[ "$TUI_BACKEND" == "styled" ]] || [[ "$TUI_BACKEND" == "gum" ]]; then + tui_print_box "Confirm" "$prompt" + fi + echo "" >&2 + read -r -p "$(echo -e "${BOLD}?${NC} ${prompt} ${DIM}${hint}${NC}: ")" reply + reply="${reply:-$default}" + [[ "$reply" =~ ^[Yy] ]] +} + +tui_confirm() { + local prompt="$1" + local default="${2:-n}" + if [[ "${DRY_RUN:-false}" == true ]]; then + [[ "$default" == "y" ]] + return + fi + case "$TUI_BACKEND" in + gum) + local gum_rc=0 + if [[ "$default" == "y" ]]; then + gum confirm "$prompt" --affirmative "Yes" --negative "No" --default=true 2>/dev/null || gum_rc=$? + else + gum confirm "$prompt" --affirmative "Yes" --negative "No" --default=false 2>/dev/null || gum_rc=$? + fi + if [[ $gum_rc -eq 0 ]]; then return 0; fi + if [[ $gum_rc -eq 1 ]]; then return 1; fi + tui_confirm_bash "$prompt" "$default" + ;; + whiptail) + if whiptail --yesno "$prompt" 12 70; then return 0; else return 1; fi + ;; + dialog) + if dialog --yesno "$prompt" 12 70; then return 0; else return 1; fi + ;; + *) + tui_confirm_bash "$prompt" "$default" + ;; + esac +} + +tui_input_bash() { + local prompt="$1" + local default="${2:-}" + local value + if [[ "$TUI_BACKEND" == "styled" ]]; then + tui_print_box "Input" "$prompt" + fi + echo "" >&2 + if [[ -n "$default" ]]; then + read -r -p "$(echo -e "${BOLD}›${NC} ${prompt} ${DIM}[${default}]${NC}: ")" value + echo "${value:-$default}" + else + read -r -p "$(echo -e "${BOLD}›${NC} ${prompt}: ")" value + echo "$value" + fi +} + +tui_input() { + local prompt="$1" + local default="${2:-}" + if [[ "${DRY_RUN:-false}" == true ]]; then + echo "$default" + return 0 + fi + local value + case "$TUI_BACKEND" in + gum) + if [[ -n "$default" ]]; then + value="$(gum input --placeholder "$default" --prompt "$prompt " --value "$default" --width 60 2>/dev/null || true)" + else + value="$(gum input --prompt "$prompt " --width 60 2>/dev/null || true)" + fi + if [[ -n "$value" ]]; then + echo "$value" + return 0 + fi + tui_input_bash "$prompt" "$default" + ;; + whiptail) + whiptail --inputbox "$prompt" 10 70 "$default" 3>&1 1>&2 2>&3 + ;; + dialog) + dialog --inputbox "$prompt" 10 70 "$default" 3>&1 1>&2 2>&3 + ;; + *) + tui_input_bash "$prompt" "$default" + ;; + esac +} + +tui_password() { + local prompt="$1" + if [[ "${DRY_RUN:-false}" == true ]]; then + echo "dry-run-password" + return 0 + fi + local value + case "$TUI_BACKEND" in + gum) + value="$(gum input --password --prompt "$prompt " --width 60 2>/dev/null || true)" + if [[ -n "$value" ]]; then + echo "$value" + return 0 + fi + ;& + *) + if [[ "$TUI_BACKEND" == "styled" ]]; then + tui_print_box "Secret" "$prompt" + fi + echo "" >&2 + read -r -s -p "$(echo -e "${BOLD}🔒${NC} ${prompt}: ")" value + echo "" >&2 + echo "$value" + ;; + esac +} + +tui_choose_styled() { + local prompt="$1" + shift + local options=("$@") + local choice i default=1 + + tui_print_box "$prompt" "Enter the number for your choice." + echo "" >&2 + i=1 + for opt in "${options[@]}"; do + if (( i == default )); then + if [[ "$GUM_STYLE" == true ]]; then + echo " $(gum style --foreground 212 --bold "❯ ${i}") $(gum style --bold "$opt")" >&2 + else + echo -e " ${CYAN}${BOLD}❯ ${i}${NC} ${BOLD}${opt}${NC}" >&2 + fi + else + echo -e " ${DIM}${i}${NC} ${opt}" >&2 + fi + ((i++)) || true + done + echo "" >&2 + read -r -p "$(echo -e "${BOLD}›${NC} Choice ${DIM}[${default}]${NC}: ")" choice + choice="${choice:-$default}" + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#options[@]} )); then + echo "${options[$((choice - 1))]}" + return 0 + fi + return 1 +} + +tui_choose() { + local prompt="$1" + shift + local options=("$@") + local choice result + if [[ "${DRY_RUN:-false}" == true ]]; then + echo "${options[0]}" + return 0 + fi + case "$TUI_BACKEND" in + gum) + if result="$(gum choose --header "$prompt" --cursor "❯ " --selected.foreground "212" "${options[@]}" 2>/dev/null)" && [[ -n "$result" ]]; then + echo "$result" + return 0 + fi + tui_choose_styled "$prompt" "${options[@]}" + ;; + styled|bash) + tui_choose_styled "$prompt" "${options[@]}" + ;; + whiptail|dialog) + local menu_args=() + local idx=1 + for opt in "${options[@]}"; do + menu_args+=("$idx" "$opt") + ((idx++)) || true + done + local cmd="whiptail" + [[ "$TUI_BACKEND" == "dialog" ]] && cmd="dialog" + choice=$($cmd --menu "$prompt" 20 78 12 "${menu_args[@]}" 3>&1 1>&2 2>&3) || return 1 + echo "${options[$((choice - 1))]}" + ;; + esac +} + +tui_spinner_bash() { + local title="$1" + shift + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + local tmp_out tmp_err + tmp_out="$(mktemp)" + tmp_err="$(mktemp)" + ("$@" >"$tmp_out" 2>"$tmp_err") & + local pid=$! + while kill -0 "$pid" 2>/dev/null; do + printf "\r ${CYAN}${frames[$i]}${NC} ${title}" >&2 + i=$(( (i + 1) % ${#frames[@]} )) + sleep 0.1 + done + wait "$pid" + local rc=$? + printf "\r\033[K" >&2 + if [[ $rc -eq 0 ]]; then + log_success "$title" >&2 + else + log_error "$title failed" >&2 + cat "$tmp_err" >&2 + fi + cat "$tmp_out" + rm -f "$tmp_out" "$tmp_err" + return $rc +} + +tui_spinner() { + local title="$1" + shift + case "$TUI_BACKEND" in + gum) + gum spin --spinner dot --spinner.foreground "212" --title "$title" -- "$@" 2>/dev/null || { + tui_spinner_bash "$title" "$@" + } + ;; + styled|bash) + tui_spinner_bash "$title" "$@" + ;; + *) + log_info "$title" + "$@" + ;; + esac +} + +tui_wait_progress() { + local message="$1" + local max="$2" + local attempt=0 + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + while (( attempt < max )); do + local pct=$(( attempt * 100 / max )) + local frame="${frames[$i]}" + printf "\r ${CYAN}${frame}${NC} ${message} ${DIM}(${pct}%%)${NC} " >&2 + i=$(( (i + 1) % ${#frames[@]} )) + sleep 2 + ((attempt++)) || true + done + printf "\r\033[K" >&2 +} + +tui_press_enter() { + local msg="${1:-Press Enter to continue...}" + echo "" >&2 + if [[ "$GUM_STYLE" == true ]]; then + gum style --foreground 245 "$msg" >&2 + else + echo -e "${DIM}${msg}${NC}" >&2 + fi + read -r -p "" _ +} + +prompt_with_default() { + local prompt="$1" + local default="$2" + local validator="${3:-}" + local value + while true; do + value="$(tui_input "$prompt" "$default")" + value="${value:-$default}" + if [[ -z "$validator" ]] || "$validator" "$value"; then + echo "$value" + return 0 + fi + log_error "Invalid value. Please try again." + done +} + +prompt_yes_no() { + local prompt="$1" + local default="${2:-n}" + tui_confirm "$prompt" "$default" +} diff --git a/adventurelog/scripts/install/lib/ui.sh b/adventurelog/scripts/install/lib/ui.sh new file mode 100755 index 00000000..560d19ac --- /dev/null +++ b/adventurelog/scripts/install/lib/ui.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# UI helpers: colors, banners, logging, step headers. +set -euo pipefail + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly PURPLE='\033[0;35m' +readonly CYAN='\033[0;36m' +readonly MAGENTA='\033[0;35m' +readonly BOLD='\033[1m' +readonly DIM='\033[2m' +readonly NC='\033[0m' + +log_info() { echo -e "${BLUE}ℹ $1${NC}"; } +log_success() { echo -e "${GREEN}✔ $1${NC}"; } +log_warning() { echo -e "${YELLOW}⚠ $1${NC}"; } +log_error() { echo -e "${RED}✖ $1${NC}"; } +log_header() { echo -e "${PURPLE}${BOLD}$1${NC}"; } + +print_banner() { + if declare -f has_gum &>/dev/null && has_gum && [[ "${GUM_STYLE:-false}" == true ]]; then + gum style \ + --border double \ + --border-foreground 212 \ + --align center \ + --width 65 \ + --padding "1 2" \ + --bold \ + "ADVENTURELOG" \ + "" \ + "Installer & Manager" \ + "The Ultimate Travel Companion" 2>/dev/null || print_banner_fallback + return + fi + print_banner_fallback +} + +print_banner_fallback() { + cat << 'EOF' +╔═════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ A D V E N T U R E L O G I N S T A L L E R ║ +║ ║ +║ The Ultimate Travel Companion ║ +╚═════════════════════════════════════════════════════════════════════════╝ +EOF +} + +print_manager_banner() { + cat << 'EOF' +╔═════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ A D V E N T U R E L O G M A N A G E R ║ +║ ║ +╚═════════════════════════════════════════════════════════════════════════╝ +EOF +} + +print_screen() { + local title="${1:-}" + if [[ -t 1 ]] && [[ -n "${TERM:-}" ]] && [[ "$TERM" != "dumb" ]]; then + clear 2>/dev/null || true + fi + echo "" + print_banner + echo "" + if [[ -n "$title" ]]; then + log_header "$title" + echo "" + fi +} + +print_step_header() { + local step="$1" + local total="$2" + local label="$3" + WIZARD_STEP="$step" + WIZARD_TOTAL="$total" + echo "" >&2 + if declare -f tui_progress_bar &>/dev/null; then + tui_progress_bar "$step" "$total" "$label" + else + log_header "Step ${step}/${total}: ${label}" + echo -e "${DIM}$(printf '─%.0s' {1..60})${NC}" + echo "" + fi +} + +print_divider() { + echo -e "${DIM}$(printf '─%.0s' {1..60})${NC}" +} + +print_summary_row() { + local key="$1" + local value="$2" + if [[ "${GUM_STYLE:-false}" == true ]] && has_gum; then + gum join --horizontal \ + "$(gum style --width 22 --bold "$key")" \ + "$(gum style "$value")" 2>/dev/null || \ + printf " ${BOLD}%-22s${NC} %s\n" "$key" "$value" + else + printf " ${BOLD}%-22s${NC} %s\n" "$key" "$value" + fi +} + +print_success_banner() { + echo "" + cat << 'EOF' +╔════════════════════════════════════════════════════════════════════════════╗ +║ A D V E N T U R E L O G I S R E A D Y F O R L A U N C H! ║ +╚════════════════════════════════════════════════════════════════════════════╝ +EOF + echo "" +} + +print_failure_message() { + echo "" + log_error "Operation failed!" + echo "" + echo "Troubleshooting:" + echo " 1. docker info" + echo " 2. docker compose -f $COMPOSE_FILE ps" + echo " 3. docker compose -f $COMPOSE_FILE logs" + echo " 4. bash scripts/validate-env.sh $ENV_FILE" + echo "" + echo "Support: https://github.com/seanmorley15/AdventureLog" +} diff --git a/adventurelog/scripts/restore.sh b/adventurelog/scripts/restore.sh new file mode 100755 index 00000000..5cb7d236 --- /dev/null +++ b/adventurelog/scripts/restore.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# Restore AdventureLog from a backup created by scripts/backup.sh. +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + echo "Example: $0 backups/20260101-120000" >&2 + exit 1 +fi + +BACKUP_DIR="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$SCRIPT_DIR" + +if [[ ! -d "$BACKUP_DIR" ]]; then + echo "ERROR: Backup directory not found: $BACKUP_DIR" >&2 + exit 1 +fi + +COMPOSE_FILE="${COMPOSE_FILE:-}" +if [[ -z "$COMPOSE_FILE" ]]; then + if [[ -f .env.aio ]] && [[ ! -f .env ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + elif [[ -f .env.aio ]] && [[ -f docker-compose.aio.yml ]] && [[ "${ADVENTURELOG_COMPOSE:-}" == "aio" ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + else + COMPOSE_FILE="docker-compose.yml" + fi +fi + +ENV_FILE="${ENV_FILE:-}" +case "$COMPOSE_FILE" in + *docker-compose.aio.yml*) + ENV_FILE="${ENV_FILE:-.env.aio}" + ;; + *) + ENV_FILE="${ENV_FILE:-.env}" + ;; +esac + +if [[ -f "$ENV_FILE" ]]; then + COMPOSE=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE") +else + COMPOSE=(docker compose -f "$COMPOSE_FILE") +fi + +resolve_compose_volume() { + local suffix="$1" + "${COMPOSE[@]}" volume ls -q | grep "${suffix}$" | head -n 1 +} + +MEDIA_VOLUME="$(resolve_compose_volume "adventurelog_media")" +if [[ -z "$MEDIA_VOLUME" ]]; then + MEDIA_VOLUME="$(resolve_compose_volume "media")" +fi + +POSTGRES_USER="${POSTGRES_USER:-adventure}" +POSTGRES_DB="${POSTGRES_DB:-database}" + +if [[ -f .env ]]; then + # shellcheck disable=SC1090 + set -a + source .env + set +a +elif [[ -f .env.aio ]]; then + # shellcheck disable=SC1090 + set -a + source .env.aio + set +a +fi + +echo "Stopping AdventureLog containers..." +"${COMPOSE[@]}" down + +if [[ -f "$BACKUP_DIR/.env" ]]; then + cp "$BACKUP_DIR/.env" .env +elif [[ -f "$BACKUP_DIR/.env.aio" ]]; then + cp "$BACKUP_DIR/.env.aio" .env.aio +fi + +echo "Starting database..." +"${COMPOSE[@]}" up -d db +sleep 5 + +if [[ -f "$BACKUP_DIR/database.sql" ]]; then + echo "Restoring database..." + "${COMPOSE[@]}" exec -T db psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" + cat "$BACKUP_DIR/database.sql" | "${COMPOSE[@]}" exec -T db psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" +fi + +if [[ -f "$BACKUP_DIR/media.tar.gz" ]]; then + if [[ -z "$MEDIA_VOLUME" ]]; then + echo "ERROR: media volume not found; cannot restore media" >&2 + exit 1 + fi + echo "Restoring media volume..." + docker run --rm \ + -v "${MEDIA_VOLUME}:/data" \ + -v "$(realpath "$BACKUP_DIR"):/backup:ro" \ + alpine:3.21 \ + sh -c "rm -rf /data/* && tar xzf /backup/media.tar.gz -C /data" +fi + +echo "Starting AdventureLog..." +"${COMPOSE[@]}" up -d + +echo "Restore complete." diff --git a/adventurelog/scripts/validate-env.sh b/adventurelog/scripts/validate-env.sh new file mode 100755 index 00000000..545fe435 --- /dev/null +++ b/adventurelog/scripts/validate-env.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# Validate AdventureLog .env configuration for common self-hosting mistakes. +set -uo pipefail + +ENV_FILE="${1:-.env}" +WARNINGS=0 +ERRORS=0 + +warn() { + echo "WARNING: $1" >&2 + WARNINGS=$((WARNINGS + 1)) +} + +error() { + echo "ERROR: $1" >&2 + ERRORS=$((ERRORS + 1)) +} + +if [[ ! -f "$ENV_FILE" ]]; then + error "Environment file not found: $ENV_FILE" + exit 1 +fi + +# shellcheck disable=SC1090 +set -a +source "$ENV_FILE" +set +a + +echo "Validating $ENV_FILE ..." + +IS_AIO_ENV=false +if [[ "$ENV_FILE" == *".env.aio"* ]] || [[ "$(basename "$ENV_FILE")" == ".env.aio" ]]; then + IS_AIO_ENV=true +fi + +if [[ "$IS_AIO_ENV" == true ]]; then + if [[ -z "${POSTGRES_PASSWORD:-}" ]]; then + error "POSTGRES_PASSWORD is required in $ENV_FILE" + fi + if [[ "${POSTGRES_PASSWORD:-changeme123}" == "changeme123" ]]; then + warn "Default POSTGRES_PASSWORD detected — change this for production" + fi + if [[ -n "${SITE_URL:-}" ]] && [[ "$SITE_URL" != http://* ]] && [[ "$SITE_URL" != https://* ]]; then + warn "SITE_URL should start with http:// or https://" + fi + if [[ "${DJANGO_ADMIN_PASSWORD:-admin}" == "admin" ]] && [[ "${DJANGO_ADMIN_USERNAME:-admin}" == "admin" ]]; then + warn "Default admin credentials (admin/admin) detected — change for production" + fi + if [[ "${MEDIA_STORAGE:-local}" == "s3" ]]; then + if [[ -z "${AWS_ACCESS_KEY_ID:-}" ]] || [[ -z "${AWS_SECRET_ACCESS_KEY:-}" ]] || [[ -z "${AWS_STORAGE_BUCKET_NAME:-}" ]]; then + error "MEDIA_STORAGE=s3 requires AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_STORAGE_BUCKET_NAME" + fi + fi + if [[ "${EMAIL_BACKEND:-}" == "email" ]]; then + if [[ -z "${EMAIL_HOST:-}" ]] || [[ -z "${EMAIL_HOST_USER:-}" ]] || [[ -z "${EMAIL_HOST_PASSWORD:-}" ]]; then + error "EMAIL_BACKEND=email requires EMAIL_HOST, EMAIL_HOST_USER, and EMAIL_HOST_PASSWORD" + fi + fi + if [[ "${DEBUG:-False}" == "True" ]] || [[ "${DEBUG:-False}" == "true" ]]; then + warn "DEBUG is enabled — disable for production" + fi + echo "" + if [[ $ERRORS -gt 0 ]]; then + echo "Validation failed: $ERRORS error(s), $WARNINGS warning(s)" + exit 1 + fi + if [[ $WARNINGS -gt 0 ]]; then + echo "Validation passed with $WARNINGS warning(s)" + exit 0 + fi + echo "Validation passed" + exit 0 +fi + +if [[ -z "${PUBLIC_SERVER_URL:-}" ]]; then + warn "PUBLIC_SERVER_URL is not set (default http://server:8000 is expected in Docker)" +elif [[ "$PUBLIC_SERVER_URL" == *"localhost:8016"* ]] || [[ "$PUBLIC_SERVER_URL" == *"127.0.0.1:8016"* ]]; then + error "PUBLIC_SERVER_URL points at the host-mapped backend port. Use http://server:8000 inside Docker." +elif [[ "$PUBLIC_SERVER_URL" != *":8000"* ]] && [[ "$PUBLIC_SERVER_URL" != "http://server:8000" ]]; then + warn "PUBLIC_SERVER_URL is '$PUBLIC_SERVER_URL' — standard Docker installs should use http://server:8000" +fi + +if [[ -n "${SITE_URL:-}" ]]; then + echo "SITE_URL is set — individual URL vars will be derived when not explicitly set." +fi + +if [[ -z "${CSRF_TRUSTED_ORIGINS:-}" ]] && [[ -z "${SITE_URL:-}" ]]; then + warn "CSRF_TRUSTED_ORIGINS is not set and SITE_URL is not set" +fi + +if [[ -n "${CSRF_TRUSTED_ORIGINS:-}" ]] && [[ -z "${SITE_URL:-}" ]]; then + IFS=',' read -ra ORIGINS <<< "$CSRF_TRUSTED_ORIGINS" + if [[ -n "${ORIGIN:-}" ]] && [[ ! "$CSRF_TRUSTED_ORIGINS" == *"$ORIGIN"* ]]; then + warn "CSRF_TRUSTED_ORIGINS does not include ORIGIN ($ORIGIN)" + fi + if [[ -n "${FRONTEND_URL:-}" ]] && [[ ! "$CSRF_TRUSTED_ORIGINS" == *"$FRONTEND_URL"* ]]; then + warn "CSRF_TRUSTED_ORIGINS does not include FRONTEND_URL ($FRONTEND_URL)" + fi + if [[ -n "${PUBLIC_URL:-}" ]] && [[ ! "$CSRF_TRUSTED_ORIGINS" == *"$PUBLIC_URL"* ]]; then + warn "CSRF_TRUSTED_ORIGINS does not include PUBLIC_URL ($PUBLIC_URL)" + fi +fi + +if [[ -n "${PUBLIC_URL:-}" ]] && [[ -n "${FRONTEND_URL:-}" ]]; then + public_scheme="${PUBLIC_URL%%://*}" + frontend_scheme="${FRONTEND_URL%%://*}" + if [[ "$public_scheme" != "$frontend_scheme" ]]; then + warn "PUBLIC_URL ($public_scheme) and FRONTEND_URL ($frontend_scheme) use different schemes" + fi +fi + +if [[ -n "${FRONTEND_PORT:-}" ]] && [[ -n "${ORIGIN:-}" ]]; then + if [[ "$ORIGIN" == *":$FRONTEND_PORT"* ]] || [[ "$ORIGIN" != *":"* && "$FRONTEND_PORT" == "80" ]]; then + : + else + warn "ORIGIN ($ORIGIN) may not match FRONTEND_PORT ($FRONTEND_PORT)" + fi +fi + +if [[ -n "${BACKEND_PORT:-}" ]] && [[ -n "${PUBLIC_URL:-}" ]]; then + if [[ "$PUBLIC_URL" == *":$BACKEND_PORT"* ]] || [[ "$PUBLIC_URL" != *":"* && "$BACKEND_PORT" == "80" ]]; then + : + else + warn "PUBLIC_URL ($PUBLIC_URL) may not match BACKEND_PORT ($BACKEND_PORT)" + fi +fi + +if [[ "${POSTGRES_PASSWORD:-changeme123}" == "changeme123" ]] || [[ "${SECRET_KEY:-changeme123}" == "changeme123" ]]; then + warn "Default POSTGRES_PASSWORD or SECRET_KEY detected — change these for production" +fi + +echo "" +if [[ $ERRORS -gt 0 ]]; then + echo "Validation failed: $ERRORS error(s), $WARNINGS warning(s)" + exit 1 +fi + +if [[ $WARNINGS -gt 0 ]]; then + echo "Validation passed with $WARNINGS warning(s)" + exit 0 +fi + +echo "Validation passed" +exit 0 diff --git a/aio/entrypoint.sh b/aio/entrypoint.sh new file mode 100644 index 00000000..28bc0660 --- /dev/null +++ b/aio/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -euo pipefail + +source /aio/env-setup.sh + +cd /code +source /code/scripts/entrypoint-common.sh + +wait_for_postgres +run_migrations +create_superuser_if_needed +run_download_countries +finalize_startup + +exec "$@" diff --git a/aio/env-setup.sh b/aio/env-setup.sh new file mode 100644 index 00000000..8c45bdd3 --- /dev/null +++ b/aio/env-setup.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Derive full AdventureLog configuration from minimal AIO environment variables. +set -euo pipefail + +SITE_URL="${SITE_URL:-http://localhost:8015}" +SITE_URL="${SITE_URL%/}" + +export SITE_URL +export ORIGIN="${ORIGIN:-$SITE_URL}" +export FRONTEND_URL="${FRONTEND_URL:-$SITE_URL}" +export PUBLIC_URL="${PUBLIC_URL:-$SITE_URL}" +export CSRF_TRUSTED_ORIGINS="${CSRF_TRUSTED_ORIGINS:-$SITE_URL}" +export PUBLIC_SERVER_URL="${PUBLIC_SERVER_URL:-http://127.0.0.1:8000}" +export BODY_SIZE_LIMIT="${BODY_SIZE_LIMIT:-Infinity}" +export DEBUG="${DEBUG:-False}" +export PORT=3000 + +export PGHOST="${PGHOST:-db}" +export POSTGRES_DB="${POSTGRES_DB:-database}" +export POSTGRES_USER="${POSTGRES_USER:-adventure}" +export PGDATABASE="${PGDATABASE:-$POSTGRES_DB}" +export PGUSER="${PGUSER:-$POSTGRES_USER}" + +if [[ -z "${POSTGRES_PASSWORD:-}" ]]; then + echo "ERROR: POSTGRES_PASSWORD is required for the AIO container." >&2 + exit 1 +fi +export PGPASSWORD="${POSTGRES_PASSWORD}" + +if [[ -z "${SECRET_KEY:-}" ]]; then + export SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')" +fi + +export DJANGO_ADMIN_USERNAME="${DJANGO_ADMIN_USERNAME:-admin}" +export DJANGO_ADMIN_PASSWORD="${DJANGO_ADMIN_PASSWORD:-admin}" +export DJANGO_ADMIN_EMAIL="${DJANGO_ADMIN_EMAIL:-admin@example.com}" + +echo "AIO configuration:" +echo " SITE_URL=$SITE_URL" +echo " PUBLIC_SERVER_URL=$PUBLIC_SERVER_URL" +echo " PGHOST=$PGHOST" diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index be867a9a..00000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,77 +0,0 @@ -# Stage 1: Build stage with dependencies -FROM python:3.13-slim AS builder - -# Metadata labels -LABEL maintainer="Sean Morley" \ - version="0.10.0" \ - description="AdventureLog — the ultimate self-hosted travel companion." \ - org.opencontainers.image.title="AdventureLog" \ - org.opencontainers.image.description="AdventureLog helps you plan, track, and share your adventures." \ - org.opencontainers.image.version="0.10.0" \ - org.opencontainers.image.authors="Sean Morley" \ - org.opencontainers.image.source="https://github.com/seanmorley15/AdventureLog" \ - org.opencontainers.image.vendor="Sean Morley" \ - org.opencontainers.image.licenses="GPL-3.0" - -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 -WORKDIR /code -ENV DEBIAN_FRONTEND=noninteractive - -# Install system dependencies needed for build -RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - postgresql-client \ - gdal-bin \ - libgdal-dev \ - nginx \ - memcached \ - supervisor \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Install Python dependencies -COPY ./server/requirements.txt /code/ -RUN pip install --upgrade pip \ - && pip install --no-cache-dir -r requirements.txt - -# Stage 2: Final image with runtime dependencies -FROM python:3.13-slim -WORKDIR /code -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 -ENV DEBIAN_FRONTEND=noninteractive - -# Install runtime dependencies (including GDAL) -RUN apt-get update && apt-get install -y --no-install-recommends \ - postgresql-client \ - gdal-bin \ - libgdal-dev \ - nginx \ - memcached \ - supervisor \ - fonts-noto-core \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Copy Python packages from builder -COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages -COPY --from=builder /usr/local/bin /usr/local/bin - -# Copy project code and configs -COPY ./server /code/ -COPY ./nginx.conf /etc/nginx/nginx.conf -COPY ./supervisord.conf /etc/supervisor/conf.d/supervisord.conf -COPY ./entrypoint.sh /code/entrypoint.sh -RUN chmod +x /code/entrypoint.sh \ - && mkdir -p /code/static /code/media - -# Collect static files -RUN python3 manage.py collectstatic --noinput --verbosity 2 - -# Expose ports -EXPOSE 80 8000 - -# Start with an entrypoint that runs init tasks then starts supervisord -ENTRYPOINT ["/code/entrypoint.sh"] - -# Start supervisord to manage processes -CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/backend/cron/adventurelog b/backend/cron/adventurelog new file mode 100644 index 00000000..cc139157 --- /dev/null +++ b/backend/cron/adventurelog @@ -0,0 +1,16 @@ +# AdventureLog scheduled management commands. +# Schedules are in UTC (CRON_TZ). Job output appears in Docker logs and +# /var/log/adventurelog/.log inside the container. +# Add new jobs on their own line: +# MIN HOUR DOM MON DOW root /code/scripts/run-cron-job.sh [args...] +# +# Examples: +# 0 4 * * 0 root /code/scripts/run-cron-job.sh image_cleanup image_cleanup --dry-run +# 30 2 * * * root /code/scripts/run-cron-job.sh sync_visited_regions sync_visited_regions --batch-size 50 + +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +CRON_TZ=UTC + +# Sync visited regions and cities from user locations (nightly midnight UTC) +0 0 * * * root /code/scripts/run-cron-job.sh sync_visited_regions sync_visited_regions diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index cb573de9..6f8502a3 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,87 +1,13 @@ #!/bin/bash +set -euo pipefail -# Function to check PostgreSQL availability -# Helper to get the first non-empty environment variable -get_env() { - for var in "$@"; do - value="${!var}" - if [ -n "$value" ]; then - echo "$value" - return - fi - done -} +cd /code +source /code/scripts/entrypoint-common.sh -check_postgres() { - local db_host - local db_user - local db_name - local db_pass +wait_for_postgres +run_migrations +create_superuser_if_needed +run_download_countries +finalize_startup - db_host=$(get_env PGHOST) - db_user=$(get_env PGUSER POSTGRES_USER) - db_name=$(get_env PGDATABASE POSTGRES_DB) - db_pass=$(get_env PGPASSWORD POSTGRES_PASSWORD) - - PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c '\q' >/dev/null 2>&1 -} - - -# Wait for PostgreSQL to become available -until check_postgres; do - >&2 echo "PostgreSQL is unavailable - sleeping" - sleep 1 -done - ->&2 echo "PostgreSQL is up - continuing..." - -# run sql commands -# psql -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" -f /app/backend/init-postgis.sql - -# Apply Django migrations -python manage.py migrate - -# Create superuser if environment variables are set and there are no users present at all. -if [ -n "$DJANGO_ADMIN_USERNAME" ] && [ -n "$DJANGO_ADMIN_PASSWORD" ] && [ -n "$DJANGO_ADMIN_EMAIL" ]; then - echo "Creating superuser..." - python manage.py shell << EOF -from django.contrib.auth import get_user_model -from allauth.account.models import EmailAddress - -User = get_user_model() - -# Check if the user already exists -if not User.objects.filter(username='$DJANGO_ADMIN_USERNAME').exists(): - # Create the superuser - superuser = User.objects.create_superuser( - username='$DJANGO_ADMIN_USERNAME', - email='$DJANGO_ADMIN_EMAIL', - password='$DJANGO_ADMIN_PASSWORD' - ) - print("Superuser created successfully.") - - # Create the EmailAddress object for AllAuth - EmailAddress.objects.create( - user=superuser, - email='$DJANGO_ADMIN_EMAIL', - verified=True, - primary=True - ) - print("EmailAddress object created successfully for AllAuth.") -else: - print("Superuser already exists.") -EOF -fi - - -# Sync the countries and world travel regions -# Sync the countries and world travel regions -python manage.py download-countries -if [ $? -eq 137 ]; then - >&2 echo "WARNING: The download-countries command was interrupted. This is likely due to lack of memory allocated to the container or the host. Please try again with more memory." - exit 1 -fi - -cat /code/adventurelog.txt - -exec "$@" \ No newline at end of file +exec "$@" diff --git a/backend/scripts/run-cron-job.sh b/backend/scripts/run-cron-job.sh new file mode 100755 index 00000000..59e03804 --- /dev/null +++ b/backend/scripts/run-cron-job.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Run a Django management command as a cron job with logging and overlap protection. +# +# Usage: +# run-cron-job.sh [command-args...] +# +# Output goes to /var/log/adventurelog/.log and Docker logs (via PID 1 stdout). + +set -euo pipefail + +JOB_NAME="${1:?job name required}" +shift + +cd /code + +LOCK_FILE="/var/lock/adventurelog-${JOB_NAME}.lock" +LOG_DIR="/var/log/adventurelog" +LOG_FILE="${LOG_DIR}/${JOB_NAME}.log" +mkdir -p "$LOG_DIR" + +log() { + printf '[cron:%s] %s\n' "$JOB_NAME" "$*" >>/proc/1/fd/1 2>/dev/null || \ + printf '[cron:%s] %s\n' "$JOB_NAME" "$*" +} + +# Cron runs with a minimal environment; load the snapshot written at container start. +if [[ -f /etc/adventurelog/cron.env ]]; then + set -a + # shellcheck disable=SC1091 + source /etc/adventurelog/cron.env + set +a +else + log "WARNING: /etc/adventurelog/cron.env missing — Django may fail to connect to the database" +fi + +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + log "Skipped — previous run still in progress" + exit 0 +fi + +log "Starting manage.py $*" + +set +e +/usr/local/bin/python3 manage.py "$@" 2>&1 | tee -a "$LOG_FILE" >>/proc/1/fd/1 +exit_code=${PIPESTATUS[0]} +set -e + +if [[ "$exit_code" -ne 0 ]]; then + log "Failed with exit code ${exit_code}. See ${LOG_FILE}" + exit "$exit_code" +fi + +log "Finished manage.py $*" diff --git a/backend/scripts/write-cron-env.sh b/backend/scripts/write-cron-env.sh new file mode 100644 index 00000000..0bd5bfa8 --- /dev/null +++ b/backend/scripts/write-cron-env.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Snapshot container environment for cron jobs (cron does not inherit Docker env vars). +set -euo pipefail + +mkdir -p /etc/adventurelog /var/log/adventurelog /var/lock +env > /etc/adventurelog/cron.env +chmod 0644 /etc/adventurelog/cron.env diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index 4ff762f4..1454e2a6 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -167,11 +167,21 @@ USE_I18N = True USE_L10N = True USE_TZ = True +# --------------------------------------------------------------------------- +# Site URL (optional convenience for reverse-proxy / single-domain setups) +# --------------------------------------------------------------------------- +SITE_URL = getenv('SITE_URL', '').strip().rstrip('/') + + +def _env_explicit(name: str) -> bool: + return name in os.environ + + # --------------------------------------------------------------------------- # Frontend URL & Cookies # --------------------------------------------------------------------------- # Derive frontend URL from environment and configure cookie behavior. -unParsedFrontenedUrl = getenv('FRONTEND_URL', 'http://localhost:3000') +unParsedFrontenedUrl = getenv('FRONTEND_URL') or (SITE_URL if SITE_URL else 'http://localhost:3000') FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans('', '', '\'"')) SESSION_COOKIE_SAMESITE = 'Lax' @@ -397,8 +407,15 @@ else: # --------------------------------------------------------------------------- # CORS & CSRF # --------------------------------------------------------------------------- -CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()] -CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()] +if _env_explicit('CSRF_TRUSTED_ORIGINS'): + _csrf_origins_raw = getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost') +elif SITE_URL: + _csrf_origins_raw = SITE_URL +else: + _csrf_origins_raw = 'http://localhost' + +CORS_ALLOWED_ORIGINS = [origin.strip() for origin in _csrf_origins_raw.split(',') if origin.strip()] +CSRF_TRUSTED_ORIGINS = CORS_ALLOWED_ORIGINS CORS_ALLOW_CREDENTIALS = True DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' @@ -434,7 +451,7 @@ LOGGING = { # --------------------------------------------------------------------------- # Public URLs & Third-Party Integrations # --------------------------------------------------------------------------- -PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000') +PUBLIC_URL = getenv('PUBLIC_URL') or (SITE_URL if SITE_URL else 'http://localhost:8000') # ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app') diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index 374784fa..3af45392 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.views.generic import RedirectView, TemplateView from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, UserMediaUsageView, EnabledSocialProvidersView, DisablePasswordAuthenticationView, APIKeyListCreateView, APIKeyDetailView, MobileQRCodeView from cloud.views import CurrentUserView -from .views import get_csrf_token, get_public_url, serve_protected_media +from .views import get_csrf_token, get_public_url, health_check, serve_protected_media from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -42,6 +42,7 @@ urlpatterns = [ path('auth/mobile-qr/', MobileQRCodeView.as_view(), name='mobile-qr-code'), path('csrf/', get_csrf_token, name='get_csrf_token'), + path('health/', health_check, name='health_check'), path('public-url/', get_public_url, name='get_public_url'), path("invitations/", include('invitations.urls', namespace='invitations')), diff --git a/backend/server/main/views.py b/backend/server/main/views.py index d7f93a14..e2d46d9c 100644 --- a/backend/server/main/views.py +++ b/backend/server/main/views.py @@ -14,6 +14,14 @@ def get_csrf_token(request): def get_public_url(request): return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')}) +def health_check(request): + from django.db import connection + try: + connection.ensure_connection() + return JsonResponse({'ok': True, 'db': 'connected'}) + except Exception: + return JsonResponse({'ok': False, 'db': 'disconnected'}, status=503) + protected_paths = ['images/', 'attachments/'] def _redirect_storage(path): diff --git a/backend/server/run_periodic_sync.py b/backend/server/run_periodic_sync.py deleted file mode 100644 index abe40dd8..00000000 --- a/backend/server/run_periodic_sync.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -""" -Periodic sync runner for AdventureLog. -Runs sync_visited_regions management command every 60 seconds. -Managed by supervisord to ensure it inherits container environment variables. -""" -import os -import sys -import time -import logging -import signal -import threading -from datetime import datetime, timedelta -from pathlib import Path - -# Setup Django -sys.path.insert(0, str(Path(__file__).parent)) -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') - -import django -django.setup() - -from django.core.management import call_command - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s [%(levelname)s] %(message)s', - handlers=[logging.StreamHandler(sys.stdout)] -) -logger = logging.getLogger(__name__) - -INTERVAL_SECONDS = 60 - -# Event used to signal shutdown from signal handlers -_stop_event = threading.Event() - - -def _seconds_until_next_midnight() -> float: - """Return number of seconds until the next local midnight.""" - now = datetime.now() - next_midnight = (now + timedelta(days=1)).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - return (next_midnight - now).total_seconds() - - -def _handle_termination(signum, frame): - """Signal handler for SIGTERM and SIGINT: request graceful shutdown.""" - logger.info(f"Received signal {signum}; shutting down gracefully...") - _stop_event.set() - - -def run_sync(): - """Run the sync_visited_regions command.""" - try: - logger.info("Running sync_visited_regions...") - call_command('sync_visited_regions') - logger.info("Sync completed successfully") - except Exception as e: - logger.error(f"Sync failed: {e}", exc_info=True) - - -def main(): - """Main loop - run sync every INTERVAL_SECONDS.""" - logger.info(f"Starting periodic sync worker for midnight background jobs...") - - # Install signal handlers so supervisord (or other process managers) - # can request a clean shutdown using SIGTERM/SIGINT. - signal.signal(signal.SIGTERM, _handle_termination) - signal.signal(signal.SIGINT, _handle_termination) - - try: - while not _stop_event.is_set(): - # Wait until the next local midnight (or until shutdown) - wait_seconds = _seconds_until_next_midnight() - hours = wait_seconds / 3600.0 - logger.info( - f"Next sync scheduled in {wait_seconds:.0f}s (~{hours:.2f}h) at UTC midnight" - ) - # Sleep until midnight or until stop event is set - if _stop_event.wait(wait_seconds): - break - - # It's midnight (or we woke up), run the sync once - run_sync() - - # After running at midnight, loop continues to compute next midnight - except Exception: - logger.exception("Unexpected error in periodic sync loop") - finally: - logger.info("Periodic sync worker exiting") - - -if __name__ == '__main__': - try: - main() - except KeyboardInterrupt: - # Fallback in case the signal is delivered as KeyboardInterrupt - logger.info("KeyboardInterrupt received — exiting") - _stop_event.set() - except SystemExit: - logger.info("SystemExit received — exiting") - finally: - logger.info("run_periodic_sync terminated") diff --git a/deploy.sh b/deploy.sh old mode 100644 new mode 100755 index e2372e7c..0fac30e6 --- a/deploy.sh +++ b/deploy.sh @@ -1,8 +1,69 @@ -# This script is used to deploy the latest version of AdventureLog to the server. It pulls the latest version of the Docker images and starts the containers. It is a simple script that can be run on the server, possibly as a cron job, to keep the server up to date with the latest version of the application. +#!/bin/bash +# Deploy the latest AdventureLog Docker images. Safe for cron/automation. +set -euo pipefail -echo "Deploying latest version of AdventureLog" -docker compose pull -echo "Stating containers" -docker compose up -d +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +COMPOSE_FILE="${COMPOSE_FILE:-}" +ENV_FILE="" +LOG_CONTAINER="" +FOLLOW_LOGS=false +DO_BACKUP=false + +for arg in "$@"; do + case "$arg" in + --logs) FOLLOW_LOGS=true ;; + --backup) DO_BACKUP=true ;; + esac +done + +if [[ -z "$COMPOSE_FILE" ]]; then + if [[ -f .env.aio ]] && [[ ! -f .env ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + elif [[ -f .env.aio ]] && [[ -f docker-compose.aio.yml ]] && [[ "${ADVENTURELOG_COMPOSE:-}" == "aio" ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + else + COMPOSE_FILE="docker-compose.yml" + fi +fi + +case "$COMPOSE_FILE" in + *docker-compose.aio.yml*) + ENV_FILE=".env.aio" + LOG_CONTAINER="adventurelog-aio" + ;; + *) + ENV_FILE=".env" + LOG_CONTAINER="adventurelog-backend" + ;; +esac + +COMPOSE=(docker compose -f "$COMPOSE_FILE") +if [[ -f "$ENV_FILE" ]]; then + COMPOSE=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE") +fi + +if [[ -f "$ENV_FILE" ]] && [[ -f scripts/validate-env.sh ]]; then + echo "Validating $ENV_FILE ..." + bash scripts/validate-env.sh "$ENV_FILE" +fi + +if [[ "$DO_BACKUP" == true ]]; then + if [[ -f scripts/backup.sh ]]; then + COMPOSE_FILE="$COMPOSE_FILE" bash scripts/backup.sh + else + echo "WARNING: scripts/backup.sh not found; skipping backup" >&2 + fi +fi + +echo "Deploying latest version of AdventureLog ($COMPOSE_FILE)" +"${COMPOSE[@]}" pull +echo "Starting containers" +"${COMPOSE[@]}" up -d --wait 2>/dev/null || "${COMPOSE[@]}" up -d echo "All set!" -docker logs adventurelog-backend --follow \ No newline at end of file +"${COMPOSE[@]}" ps + +if [[ "$FOLLOW_LOGS" == true ]]; then + docker logs "$LOG_CONTAINER" --follow +fi diff --git a/docker-compose-traefik.yaml b/docker-compose-traefik.yaml index 2ca775a4..4469de1e 100644 --- a/docker-compose-traefik.yaml +++ b/docker-compose-traefik.yaml @@ -1,15 +1,14 @@ -version: "3.9" - services: traefik: image: traefik:v2.11.42 + restart: unless-stopped command: - - "--api.insecure=true" # Enable Traefik dashboard (remove in production) - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - - "--certificatesresolvers.letsencrypt.acme.email=your-email@example.com" # Replace with your email + - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:?Set ACME_EMAIL in .env}" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" ports: - "80:80" @@ -17,60 +16,76 @@ services: volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" - "traefik-letsencrypt:/letsencrypt" - labels: - - "traefik.enable=true" db: - image: postgis/postgis:15-3.3 + image: postgis/postgis:16-3.5 + container_name: adventurelog-traefik-db restart: unless-stopped - environment: - POSTGRES_DB: database - POSTGRES_USER: adventure - POSTGRES_PASSWORD: your_postgres_password # Replace with the actual password + env_file: .env volumes: - postgres-data:/var/lib/postgresql/data/ + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-adventure} -d ${POSTGRES_DB:-database}" + ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s web: image: ghcr.io/seanmorley15/adventurelog-frontend:latest restart: unless-stopped - environment: - PUBLIC_SERVER_URL: "http://server:8000" - BODY_SIZE_LIMIT: "100000" + env_file: .env labels: - "traefik.enable=true" - "traefik.http.routers.adventurelogweb.entrypoints=websecure" - - "traefik.http.routers.adventurelogweb.rule=Host(`yourdomain.com`) && !(PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/accounts`))" # Replace with your domain + - "traefik.http.routers.adventurelogweb.rule=Host(`${TRAEFIK_DOMAIN:?Set TRAEFIK_DOMAIN in .env}`) && !(PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/accounts`))" - "traefik.http.routers.adventurelogweb.tls=true" - "traefik.http.routers.adventurelogweb.tls.certresolver=letsencrypt" depends_on: - - server + server: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s server: image: ghcr.io/seanmorley15/adventurelog-backend:latest restart: unless-stopped - environment: - PGHOST: "db" - PGDATABASE: "database" - PGUSER: "adventure" - PGPASSWORD: your_postgres_password # Replace with the actual password - SECRET_KEY: your_secret_key # Replace with the actual secret key - DJANGO_ADMIN_USERNAME: "admin" - DJANGO_ADMIN_PASSWORD: your_admin_password # Replace with the actual admin password - DJANGO_ADMIN_EMAIL: "adventurelog-admin@yourdomain.com" # Replace with your email - PUBLIC_URL: "https://yourdomain.com" # Replace with your domain - CSRF_TRUSTED_ORIGINS: "https://yourdomain.com" # Replace with your domain - DEBUG: "false" - FRONTEND_URL: "https://yourdomain.com" # Replace with your domain + env_file: .env volumes: - adventurelog-media:/code/media labels: - "traefik.enable=true" - "traefik.http.routers.adventurelogserver.entrypoints=websecure" - - "traefik.http.routers.adventurelogserver.rule=Host(`yourdomain.com`) && (PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/accounts`))" # Replace with your domain + - "traefik.http.routers.adventurelogserver.rule=Host(`${TRAEFIK_DOMAIN}`) && (PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/accounts`))" - "traefik.http.routers.adventurelogserver.tls=true" - "traefik.http.routers.adventurelogserver.tls.certresolver=letsencrypt" depends_on: - - db + db: + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:80/health/')\"" + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s volumes: postgres-data: diff --git a/docker-compose.aio.yml b/docker-compose.aio.yml new file mode 100644 index 00000000..6c5297b0 --- /dev/null +++ b/docker-compose.aio.yml @@ -0,0 +1,55 @@ +services: + app: + build: + context: . + dockerfile: docker/Dockerfile + target: aio + image: ghcr.io/seanmorley15/adventurelog-aio:latest + container_name: adventurelog-aio + restart: unless-stopped + env_file: .env.aio + environment: + SITE_URL: ${SITE_URL:-http://localhost:8015} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.aio or your environment} + PGHOST: db + POSTGRES_DB: database + POSTGRES_USER: adventure + PORT: "3000" + ports: + - "${HOST_PORT:-8015}:80" + volumes: + - adventurelog_media:/code/media/ + depends_on: + db: + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "python3 -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:80/health')\"" + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 120s + + db: + image: postgis/postgis:16-3.5 + container_name: adventurelog-aio-db + restart: unless-stopped + environment: + POSTGRES_DB: database + POSTGRES_USER: adventure + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.aio or your environment} + volumes: + - postgres_data:/var/lib/postgresql/data/ + healthcheck: + test: ["CMD-SHELL", "pg_isready -U adventure -d database"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + postgres_data: + adventurelog_media: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a0da0533..ab68840d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,11 +12,24 @@ services: ports: - "${FRONTEND_PORT:-8015}:3000" depends_on: - - server + server: + condition: service_healthy volumes: - ./frontend:/app - pnpm_store:/pnpm-store command: sh -c "mkdir -p /pnpm-store && chown -R node:node /pnpm-store && su node -c 'pnpm config set store-dir /pnpm-store && pnpm install --frozen-lockfile && pnpm exec vite dev --host 0.0.0.0 --port 3000 --strictPort'" + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 120s db: image: postgis/postgis:16-3.5 @@ -25,6 +38,16 @@ services: env_file: .env volumes: - postgres_data:/var/lib/postgresql/data/ + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-adventure} -d ${POSTGRES_DB:-database}" + ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s server: build: ./backend/ @@ -39,7 +62,8 @@ services: ports: - "${BACKEND_PORT:-8016}:8000" depends_on: - - db + db: + condition: service_healthy volumes: - ./backend/server:/code - adventurelog_media:/code/media/ @@ -52,6 +76,16 @@ services: python manage.py createsuperuser --noinput --username \"$$DJANGO_SUPERUSER_USERNAME\" --email \"$$DJANGO_SUPERUSER_EMAIL\" || true; fi; python manage.py runserver 0.0.0.0:8000" + healthcheck: + test: + [ + "CMD-SHELL", + "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/csrf/')\"" + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 120s volumes: postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index 034ec065..6f8149f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: web: - #build: ./frontend/ + build: + context: . + dockerfile: docker/Dockerfile + target: frontend image: ghcr.io/seanmorley15/adventurelog-frontend:latest container_name: adventurelog-frontend restart: unless-stopped @@ -8,7 +11,20 @@ services: ports: - "${FRONTEND_PORT:-8015}:3000" depends_on: - - server + server: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s db: image: postgis/postgis:16-3.5 @@ -17,9 +33,22 @@ services: env_file: .env volumes: - postgres_data:/var/lib/postgresql/data/ + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-adventure} -d ${POSTGRES_DB:-database}" + ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s server: - #build: ./backend/ + build: + context: . + dockerfile: docker/Dockerfile + target: backend image: ghcr.io/seanmorley15/adventurelog-backend:latest container_name: adventurelog-backend restart: unless-stopped @@ -27,9 +56,20 @@ services: ports: - "${BACKEND_PORT:-8016}:80" depends_on: - - db + db: + condition: service_healthy volumes: - adventurelog_media:/code/media/ + healthcheck: + test: + [ + "CMD-SHELL", + "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:80/health/')\"" + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s volumes: postgres_data: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..daec9f19 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,130 @@ +# syntax=docker/dockerfile:1 +# +# Unified AdventureLog image definitions — build from repository root: +# docker build -f docker/Dockerfile --target frontend -t adventurelog-frontend . +# docker build -f docker/Dockerfile --target backend -t adventurelog-backend . +# docker build -f docker/Dockerfile --target aio -t adventurelog-aio . +# +# Or: docker buildx bake -f docker/docker-bake.hcl + +ARG PNPM_VERSION=10.32.1 + +# --------------------------------------------------------------------------- +# Shared build stages +# --------------------------------------------------------------------------- + +FROM node:22-bookworm-slim AS node-runtime + +FROM cgr.dev/chainguard/node:latest-dev@sha256:6eec455f386d097fc57d8aa73bd06c27ab91faa4f02f9103f94b4c56e0f9a80b AS frontend-build +ARG PNPM_VERSION +USER root +WORKDIR /app +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate +COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml frontend/.npmrc* ./ +RUN pnpm install --frozen-lockfile +COPY frontend/ . +RUN rm -f .env && pnpm run build +ENV CI=true +RUN pnpm prune --prod + +FROM python:3.13-slim AS backend-builder +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV DEBIAN_FRONTEND=noninteractive +WORKDIR /code +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + gdal-bin \ + libgdal-dev \ + && apt-get clean && rm -rf /var/lib/apt/lists/* +COPY backend/server/requirements.txt /code/ +RUN pip install --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +# Shared Django runtime used by backend and AIO images. +FROM python:3.13-slim AS backend-runtime +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV DEBIAN_FRONTEND=noninteractive +WORKDIR /code +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + postgresql-client \ + gdal-bin \ + nginx \ + memcached \ + supervisor \ + cron \ + fonts-noto-core \ + && apt-get clean && rm -rf /var/lib/apt/lists/* +COPY --from=backend-builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages +COPY --from=backend-builder /usr/local/bin /usr/local/bin +COPY backend/server /code/ +COPY docker/shared/entrypoint-common.sh /code/scripts/entrypoint-common.sh +COPY backend/cron/adventurelog /etc/cron.d/adventurelog +COPY backend/scripts/run-cron-job.sh /code/scripts/run-cron-job.sh +COPY backend/scripts/write-cron-env.sh /code/scripts/write-cron-env.sh +RUN chmod +x /code/scripts/entrypoint-common.sh /code/scripts/run-cron-job.sh /code/scripts/write-cron-env.sh \ + && chmod 0644 /etc/cron.d/adventurelog \ + && mkdir -p /code/static /code/media /var/log/adventurelog /var/lock +RUN python3 manage.py collectstatic --noinput --verbosity 2 + +# --------------------------------------------------------------------------- +# Production targets +# --------------------------------------------------------------------------- + +FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:13593b7570658e8477de39e2f4a1dd25db2f836d68a0ba771251572d23bb4f8e AS frontend +LABEL org.opencontainers.image.title="AdventureLog Frontend" \ + org.opencontainers.image.description="AdventureLog SvelteKit frontend (SSR)." \ + org.opencontainers.image.source="https://github.com/seanmorley15/AdventureLog" \ + org.opencontainers.image.licenses="GPL-3.0" +WORKDIR /app +ENV NODE_ENV=production +COPY --from=frontend-build /app/build ./build +COPY --from=frontend-build /app/node_modules ./node_modules +COPY --from=frontend-build /app/package.json ./package.json +COPY frontend/start.js ./start.js +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] +CMD ["start.js"] + +FROM backend-runtime AS backend +LABEL org.opencontainers.image.title="AdventureLog Backend" \ + org.opencontainers.image.description="AdventureLog Django backend with nginx gateway." \ + org.opencontainers.image.source="https://github.com/seanmorley15/AdventureLog" \ + org.opencontainers.image.licenses="GPL-3.0" +COPY docker/shared/nginx-backend.conf /etc/nginx/nginx.conf +COPY docker/shared/supervisord-backend.conf /etc/supervisor/conf.d/supervisord.conf +COPY backend/entrypoint.sh /code/entrypoint.sh +RUN chmod +x /code/entrypoint.sh +EXPOSE 80 8000 +HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:80/health/')" +ENTRYPOINT ["/code/entrypoint.sh"] +CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] + +FROM backend-runtime AS aio +LABEL org.opencontainers.image.title="AdventureLog AIO" \ + org.opencontainers.image.description="All-in-one AdventureLog container with frontend and backend on a single port." \ + org.opencontainers.image.source="https://github.com/seanmorley15/AdventureLog" \ + org.opencontainers.image.licenses="GPL-3.0" +ENV NODE_ENV=production +COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node +RUN ln -sf /usr/local/bin/node /usr/bin/node +COPY --from=frontend-build /app/build /app/build +COPY --from=frontend-build /app/node_modules /app/node_modules +COPY --from=frontend-build /app/package.json /app/package.json +COPY frontend/start.js /app/start.js +COPY docker/shared/nginx-aio.conf /etc/nginx/nginx.conf +COPY docker/shared/supervisord-aio.conf /aio/supervisord.conf +COPY aio/env-setup.sh /aio/env-setup.sh +COPY aio/entrypoint.sh /aio/entrypoint.sh +RUN chmod +x /aio/env-setup.sh /aio/entrypoint.sh +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=5 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:80/health')" +ENTRYPOINT ["/aio/entrypoint.sh"] +CMD ["supervisord", "-c", "/aio/supervisord.conf"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..19126c95 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,35 @@ +# Docker images + +All production images are built from a **single Dockerfile** with shared stages so frontend, backend, and AIO stay in sync. + +## Build commands (from repository root) + +```bash +# Individual images +docker build -f docker/Dockerfile --target frontend -t adventurelog-frontend . +docker build -f docker/Dockerfile --target backend -t adventurelog-backend . +docker build -f docker/Dockerfile --target aio -t adventurelog-aio . + +# All app images +docker buildx bake -f docker/docker-bake.hcl +``` + +## Layout + +| Path | Purpose | +|------|---------| +| [`Dockerfile`](Dockerfile) | Multi-target build (frontend, backend, aio) | +| [`docker-bake.hcl`](docker-bake.hcl) | Optional bake file for all targets | +| [`shared/`](shared/) | nginx, supervisord, and entrypoint scripts shared across images | + +## Shared stages + +- **`frontend-build`** — pnpm install, Vite build, production prune (used by `frontend` and `aio`) +- **`backend-builder`** — pip install with GDAL dev headers +- **`backend-runtime`** — Django app, collectstatic, cron scripts (extended by `backend` and `aio`) + +Image-specific layers only add nginx/supervisord configs, entrypoints, and (for AIO) Node + frontend artifacts. + +## Slimming + +Build context exclusions live in the repository root [`.dockerignore`](../.dockerignore). Never commit local `backend/server/media/`, `.venv/`, or `staticfiles/` — they are mounted or generated at runtime. diff --git a/docker/docker-bake.hcl b/docker/docker-bake.hcl new file mode 100644 index 00000000..07d0c579 --- /dev/null +++ b/docker/docker-bake.hcl @@ -0,0 +1,31 @@ +# Build all AdventureLog app images from the unified Dockerfile. +# Usage (from repository root): +# docker buildx bake -f docker/docker-bake.hcl +# docker buildx bake -f docker/docker-bake.hcl frontend + +group "default" { + targets = ["frontend", "backend", "aio"] +} + +target "_common" { + context = ".." + dockerfile = "docker/Dockerfile" +} + +target "frontend" { + inherits = ["_common"] + target = "frontend" + tags = ["adventurelog-frontend:local"] +} + +target "backend" { + inherits = ["_common"] + target = "backend" + tags = ["adventurelog-backend:local"] +} + +target "aio" { + inherits = ["_common"] + target = "aio" + tags = ["adventurelog-aio:local"] +} diff --git a/docker/shared/entrypoint-common.sh b/docker/shared/entrypoint-common.sh new file mode 100644 index 00000000..f4df854b --- /dev/null +++ b/docker/shared/entrypoint-common.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Shared startup tasks for backend and AIO containers. +# Source from entrypoint.sh after environment variables are configured. + +get_env() { + local var value + for var in "$@"; do + value="$(printenv "$var" 2>/dev/null || true)" + if [[ -n "$value" ]]; then + echo "$value" + return + fi + done +} + +check_postgres() { + local db_host db_user db_name db_pass + + db_host=$(get_env PGHOST) + db_user=$(get_env POSTGRES_USER PGUSER) + db_name=$(get_env POSTGRES_DB PGDATABASE) + db_pass=$(get_env POSTGRES_PASSWORD PGPASSWORD) + + PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c '\q' >/dev/null 2>&1 +} + +wait_for_postgres() { + until check_postgres; do + >&2 echo "PostgreSQL is unavailable - sleeping" + sleep 1 + done + >&2 echo "PostgreSQL is up - continuing..." +} + +run_migrations() { + python manage.py migrate +} + +create_superuser_if_needed() { + if [[ -n "${DJANGO_ADMIN_USERNAME:-}" && -n "${DJANGO_ADMIN_PASSWORD:-}" && -n "${DJANGO_ADMIN_EMAIL:-}" ]]; then + echo "Creating superuser..." + python manage.py shell <&2 echo "SKIP_WORLD_DATA=1 — skipping download-countries" + return 0 + fi + + >&2 echo "Running download-countries (first boot may take several minutes; ensure ~2GB RAM is available)..." + python manage.py download-countries + local download_exit=$? + if [[ "$download_exit" -eq 137 ]]; then + >&2 echo "WARNING: download-countries was interrupted (likely out of memory). Retry with more RAM or set SKIP_WORLD_DATA=1." + exit 1 + elif [[ "$download_exit" -ne 0 ]]; then + >&2 echo "ERROR: download-countries failed." + exit 1 + fi +} + +finalize_startup() { + if [[ -f /code/adventurelog.txt ]]; then + cat /code/adventurelog.txt + fi + + /code/scripts/write-cron-env.sh +} diff --git a/docker/shared/nginx-aio.conf b/docker/shared/nginx-aio.conf new file mode 100644 index 00000000..a3a0d253 --- /dev/null +++ b/docker/shared/nginx-aio.conf @@ -0,0 +1,62 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + client_max_body_size 100M; + + proxy_buffer_size 32k; + proxy_buffers 8 32k; + proxy_busy_buffers_size 64k; + + upstream frontend { + server 127.0.0.1:3000; + } + + upstream django { + server 127.0.0.1:8000; + } + + server { + listen 80; + server_name localhost; + + location /static/ { + alias /code/staticfiles/; + } + + location /protectedMedia/ { + internal; + alias /code/media/; + try_files $uri =404; + add_header Content-Security-Policy "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + } + + location ~ ^/(media|admin|accounts)(/|$) { + proxy_pass http://django; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/backend/nginx.conf b/docker/shared/nginx-backend.conf similarity index 62% rename from backend/nginx.conf rename to docker/shared/nginx-backend.conf index 23dba446..e2ba65cb 100644 --- a/backend/nginx.conf +++ b/docker/shared/nginx-backend.conf @@ -8,30 +8,26 @@ http { sendfile on; keepalive_timeout 65; client_max_body_size 100M; - # The backend is running in the same container, so reference localhost upstream django { - server 127.0.0.1:8000; # Use localhost to point to Gunicorn running internally + server 127.0.0.1:8000; } server { listen 80; server_name localhost; location / { - proxy_pass http://django; # Forward to the upstream block + proxy_pass http://django; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /static/ { - alias /code/staticfiles/; # Serve static files directly + alias /code/staticfiles/; } - # Serve protected media files with X-Accel-Redirect location /protectedMedia/ { - internal; # Only internal requests are allowed - alias /code/media/; # This should match Django MEDIA_ROOT - try_files $uri =404; # Return a 404 if the file doesn't exist - - # Security headers for all protected files + internal; + alias /code/media/; + try_files $uri =404; add_header Content-Security-Policy "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'" always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; @@ -39,4 +35,4 @@ http { add_header Referrer-Policy "strict-origin-when-cross-origin" always; } } -} \ No newline at end of file +} diff --git a/docker/shared/supervisord-aio.conf b/docker/shared/supervisord-aio.conf new file mode 100644 index 00000000..c23e185a --- /dev/null +++ b/docker/shared/supervisord-aio.conf @@ -0,0 +1,45 @@ +[supervisord] +nodaemon=true + +[program:nginx] +command=/usr/sbin/nginx -g "daemon off;" +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 + +[program:gunicorn] +command=/bin/sh -c 'exec /usr/local/bin/gunicorn main.wsgi:application --bind 127.0.0.1:8000 --workers ${GUNICORN_WORKERS:-2} --timeout 120' +directory=/code +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 + +[program:frontend] +command=/usr/local/bin/node /app/start.js +directory=/app +environment=NODE_ENV="production" +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 + +[program:memcached] +command=memcached -u nobody -m 64 -p 11211 +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 + +[program:cron] +command=/usr/sbin/cron -f -L 15 +autorestart=true +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 diff --git a/backend/supervisord.conf b/docker/shared/supervisord-backend.conf similarity index 75% rename from backend/supervisord.conf rename to docker/shared/supervisord-backend.conf index eb72171b..a596ccd6 100644 --- a/backend/supervisord.conf +++ b/docker/shared/supervisord-backend.conf @@ -8,7 +8,7 @@ stdout_logfile=/dev/stdout stderr_logfile=/dev/stderr [program:gunicorn] -command=/usr/local/bin/gunicorn main.wsgi:application --bind [::]:8000 --workers 2 --timeout 120 +command=/bin/sh -c 'exec /usr/local/bin/gunicorn main.wsgi:application --bind [::]:8000 --workers ${GUNICORN_WORKERS:-2} --timeout 120' directory=/code autorestart=true stdout_logfile=/dev/stdout @@ -24,9 +24,8 @@ stderr_logfile=/dev/stderr stdout_logfile_maxbytes=0 stderr_logfile_maxbytes=0 -[program:sync_visited_regions] -command=/usr/local/bin/python3 /code/run_periodic_sync.py -directory=/code +[program:cron] +command=/usr/sbin/cron -f -L 15 autorestart=true stdout_logfile=/dev/stdout stderr_logfile=/dev/stderr diff --git a/documentation/docs/configuration/updating.md b/documentation/docs/configuration/updating.md index 85fec59b..1f3a4e35 100644 --- a/documentation/docs/configuration/updating.md +++ b/documentation/docs/configuration/updating.md @@ -1,12 +1,42 @@ # Updating -Updating AdventureLog when using docker can be quite easy. Run the following commands to pull the latest version and restart the containers. Make sure you backup your instance before updating just in case! +Updating AdventureLog when using Docker is straightforward. **Back up your instance before updating.** -Note: Make sure you are in the same directory as your `docker-compose.yml` file. +### Option 1: Re-run the installer (management menu) ```bash +curl -sSL https://get.adventurelog.app | bash +``` + +Choose **Update to latest images** (optionally with backup). + +### Option 2: deploy.sh (cron-safe) + +Make sure you are in the same directory as your compose file. + +```bash +bash deploy.sh --backup +bash deploy.sh --logs # optional: follow container logs after deploy +``` + +For AIO installs, pass the compose file explicitly or rely on auto-detection when only `.env.aio` exists: + +```bash +COMPOSE_FILE=docker-compose.aio.yml bash deploy.sh --backup +``` + +### Option 3: Manual + +```bash +bash scripts/backup.sh docker compose pull -docker compose up -d +docker compose up -d --wait +``` + +To restore from a backup directory: + +```bash +bash scripts/restore.sh backups/YYYYMMDD-HHMMSS ``` ## Updating the Region Data diff --git a/documentation/docs/install/aio.md b/documentation/docs/install/aio.md new file mode 100644 index 00000000..11031f68 --- /dev/null +++ b/documentation/docs/install/aio.md @@ -0,0 +1,110 @@ +# All-in-One (AIO) Docker + +The AIO setup bundles the SvelteKit frontend and Django backend into **one container** on a **single port**. No separate backend URL, no reverse proxy required for basic use. + +The standard [multi-container Docker setup](docker.md) remains available for users who prefer separate frontend/backend services or advanced configurations. + +## When to use AIO + +| AIO | Standard compose | +|-----|------------------| +| One URL, one port | Two ports (frontend + backend) | +| Two env vars to start | Full `.env` configuration | +| Good for homelabs and quick installs | Good for advanced / split deployments | + +## Quick start + +**Recommended:** use the [quick start installer](quick_start.md): + +```bash +curl -sSL https://get.adventurelog.app | bash +``` + +Or manually: + +```bash +wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.aio.yml +wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/.env.aio.example +cp .env.aio.example .env.aio +docker compose --env-file .env.aio -f docker-compose.aio.yml up -d +``` + +Open **http://localhost:8015** (or your configured `SITE_URL`). + +**Memory:** allocate at least **2 GB RAM** on first boot; about **1 GB** is usually enough afterward. See [`docker-compose.override.example.yml`](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.override.example.yml) for optional resource limits. + +Default admin login: `admin` / `admin` — change this after first login. + +## Configuration + +Only **two variables** are required in `.env.aio`: + +| Variable | Required | Description | Default | +|----------|----------|-------------|---------| +| `POSTGRES_PASSWORD` | Yes | Database password | — | +| `SITE_URL` | No | Public URL for the app | `http://localhost:8015` | + +Optional: + +| Variable | Description | Default | +|----------|-------------|---------| +| `HOST_PORT` | Host port mapped to the container | `8015` | + +Everything else (Django `SECRET_KEY`, `CSRF_TRUSTED_ORIGINS`, `PUBLIC_URL`, `FRONTEND_URL`, `ORIGIN`, internal `PUBLIC_SERVER_URL`, etc.) is derived automatically at startup. + +::: tip Image size +The AIO image is ~1 GB (PostGIS/GDAL + Python + Node). It must **not** include local dev folders such as `backend/server/media/` or `.venv` — those are excluded via [`.dockerignore`](https://github.com/seanmorley15/AdventureLog/blob/main/.dockerignore). If your built image is ~3 GB, rebuild from a clean checkout without baking in local media. +::: + +### Example `.env.aio` + +```env +POSTGRES_PASSWORD=my-secure-db-password +SITE_URL=https://adventurelog.example.com +HOST_PORT=443 +``` + +When using HTTPS behind an external reverse proxy, set `SITE_URL` to your public HTTPS URL and proxy traffic to the AIO container port. + +## How it works + +```mermaid +flowchart LR + Browser -->|":8015"| Gateway[nginx :80] + Gateway -->|"/ pages /api /auth"| Frontend[node :3000] + Gateway -->|"/media /admin /accounts"| Gunicorn[:8000] + Frontend -->|SSR internal| Gunicorn + Gunicorn --> DB[(PostGIS)] +``` + +Inside the AIO container, nginx routes browser traffic: + +- **Frontend** (SvelteKit): all app pages, `/api`, `/auth`, `/health` +- **Backend** (Gunicorn): `/media`, `/admin`, `/accounts`, `/static` + +The database runs in a separate container (same as the standard setup). + +## Updating + +```bash +docker compose --env-file .env.aio -f docker-compose.aio.yml pull +docker compose --env-file .env.aio -f docker-compose.aio.yml up -d --wait +``` + +## Image + +Prebuilt image: `ghcr.io/seanmorley15/adventurelog-aio:latest` + +To build locally from source: + +```bash +docker build -f docker/Dockerfile --target aio -t adventurelog-aio . +# or +docker compose --env-file .env.aio -f docker-compose.aio.yml build +``` + +See [`docker/README.md`](https://github.com/seanmorley15/AdventureLog/blob/main/docker/README.md) for all build targets. + +## Optional settings + +Advanced options (S3 media, OAuth, email, etc.) can still be passed as environment variables on the `app` service in `docker-compose.aio.yml`. See the full [Docker configuration](docker.md) reference for available variables. diff --git a/documentation/docs/install/caddy.md b/documentation/docs/install/caddy.md index d9d088a7..5363fe72 100644 --- a/documentation/docs/install/caddy.md +++ b/documentation/docs/install/caddy.md @@ -35,11 +35,10 @@ Since all ingress traffic to the AdventureLog containsers now travels through Ca ```yaml web: ports: - - "8016:80" -… + - "8015:3000" server: ports: - - "8015:3000" + - "8016:80" ``` That's it for the Docker compose changes. Of course, there are other methods to run Caddy which are equally valid. diff --git a/documentation/docs/install/docker.md b/documentation/docs/install/docker.md index 3e5d4208..6cda23d3 100644 --- a/documentation/docs/install/docker.md +++ b/documentation/docs/install/docker.md @@ -2,11 +2,14 @@ Docker is the preferred way to run AdventureLog on your local machine. It is a lightweight containerization technology that allows you to run applications in isolated environments called containers. +> **Looking for the simplest setup?** See the [All-in-One (AIO)](aio.md) guide — one container, one port, two environment variables. + > **Note**: This guide mainly focuses on installation with a Linux-based host machine, but the steps are similar for other operating systems. ## Prerequisites - Docker installed on your machine/server. You can learn how to download it [here](https://docs.docker.com/engine/install/). +- **Memory:** allocate at least **2 GB RAM** for the first boot (world geography data import). Steady-state use typically needs about **1 GB**. Optional limits are documented in [`docker-compose.override.example.yml`](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.override.example.yml). ## Getting Started @@ -23,10 +26,30 @@ cp .env.example .env ::: tip -If running on an ARM based machine, you will need to use a different PostGIS Image. It is recommended to use the `imresamu/postgis:15-3.3-alpine3.21` image or a custom version found [here](https://hub.docker.com/r/imresamu/postgis/tags). The AdventureLog containers are ARM compatible. +If running on an ARM based machine, you will need to use a different PostGIS image. It is recommended to use the `imresamu/postgis:16-3.5-alpine` image or a custom version found [here](https://hub.docker.com/r/imresamu/postgis/tags). The AdventureLog containers are ARM compatible. ::: +## URL mental model + +AdventureLog uses several URL-related environment variables. Most installs only need to set the public-facing URLs; keep `PUBLIC_SERVER_URL` at its default. + +| Variable | Who uses it | Typical value | +| -------- | ----------- | ------------- | +| `PUBLIC_SERVER_URL` | SvelteKit SSR (container-to-container) | `http://server:8000` — **do not change** for standard Docker installs | +| `ORIGIN` | SvelteKit origin when not using HTTPS | Your frontend URL, e.g. `http://localhost:8015` | +| `PUBLIC_URL` | Django image and OAuth URLs | Your backend URL, e.g. `http://localhost:8016` (or your single domain if using a reverse proxy) | +| `FRONTEND_URL` | Django emails and redirects | Your frontend URL, e.g. `http://localhost:8015` | +| `CSRF_TRUSTED_ORIGINS` | Django CORS and CSRF | Comma-separated list of every browser origin, e.g. `http://localhost:8015,http://localhost:8016` | + +When frontend and backend share one domain behind a reverse proxy, you can set `SITE_URL` instead of configuring `ORIGIN`, `FRONTEND_URL`, `PUBLIC_URL`, and `CSRF_TRUSTED_ORIGINS` individually. See `.env.example` for details. + +To validate your configuration, run: + +```bash +bash scripts/validate-env.sh +``` + ## Configuration The `.env` file contains all the configuration settings for your AdventureLog instance. Here’s a breakdown of each section: diff --git a/documentation/docs/install/getting_started.md b/documentation/docs/install/getting_started.md index 1ed8f697..a2113381 100644 --- a/documentation/docs/install/getting_started.md +++ b/documentation/docs/install/getting_started.md @@ -2,6 +2,15 @@ AdventureLog can be installed in a variety of ways, depending on your platform or preference. +## Which setup should I use? + +| Your situation | Recommended path | +| -------------- | ---------------- | +| New homelab / quick install | [Quick start installer](quick_start.md) — AIO by default, interactive setup | +| Full env control, split containers, or custom integrations | [Standard Docker](docker.md) — full `.env.example` (also available in installer advanced mode) | +| Split domains or path-based reverse proxy (Traefik, NPM, Caddy) | Standard Docker + [reverse proxy docs](#advanced--alternative-setups) | +| Kubernetes cluster | [Kubernetes + Kustomize](kustomize.md) — see `k8s/base/` | + ## 📦 Docker Quick Start ::: tip Quick Start Script @@ -12,7 +21,8 @@ Perfect for Docker beginners. ## 🐳 Popular Installation Methods -- [Docker](docker.md) — Simple containerized setup +- [All-in-One Docker (AIO)](aio.md) — Single container, one port, minimal config +- [Docker](docker.md) — Standard multi-container setup - [Proxmox LXC](proxmox_lxc.md) — Lightweight virtual environment - [Synology NAS](synology_nas.md) — Self-host on your home NAS - [Kubernetes + Kustomize](kustomize.md) — Advanced, scalable deployment diff --git a/documentation/docs/install/kustomize.md b/documentation/docs/install/kustomize.md index 2c6e09bc..716a0ada 100644 --- a/documentation/docs/install/kustomize.md +++ b/documentation/docs/install/kustomize.md @@ -6,13 +6,35 @@ _AdventureLog can be run inside a kubernetes cluster using [kustomize](https://k A working kubernetes cluster. AdventureLog has been tested on k8s, but any Kustomize-capable flavor should be easy to use. -## Cluster Routing +Allocate at least **2 GB RAM** for first boot (world geography import) and **1 GB** steady-state for small clusters. -Because the AdventureLog backend must be reachable by **both** the web browser and the AdventureLog frontend, k8s-internal routing mechanisms traditional for standing up other similar applications **cannot** be used. +## Recommended layout -In order to host AdventureLog in your cluster, you must therefor configure an internally and externally resolvable ingress that routes to your AdventureLog backend container. +The maintained example lives in [`k8s/base/`](https://github.com/seanmorley15/AdventureLog/tree/main/k8s/base): -Once you have made said ingress, set `PUBLIC_SERVER_URL` and `PUBLIC_URL` env variables below to the url of that ingress. +- **PostGIS** runs in its own StatefulSet (not co-located with the app) +- **AIO** runs as a single Deployment on port 80 — simpler ingress than the 3-container split +- **Secrets** for database and Django credentials (no hardcoded admin password) +- **Ingress** for a single public hostname + +Apply it with: + +```bash +kubectl apply -k k8s/base/ +``` + +Edit `SITE_URL`, ingress host, and secret values before applying in production. + +The legacy root [`kustomization.yml`](https://github.com/seanmorley15/AdventureLog/blob/main/kustomization.yml) example is **deprecated** — it colocates frontend, backend, and database in one pod. + +## Standard 3-container on Kubernetes + +If you need separate frontend and backend Deployments, use the [standard Docker env reference](docker.md#configuration) and configure ingress path routing: + +- Frontend: all paths **except** `/media`, `/admin`, `/static`, `/accounts` +- Backend: `/media`, `/admin`, `/static`, `/accounts` + +The backend must be reachable **both** from browsers and from the frontend SSR process. Set `PUBLIC_SERVER_URL` to an internal service URL and configure public URLs separately — see the Docker docs. ## Tailscale and Headscale @@ -23,12 +45,8 @@ but it will fail to resolve internally. You must [expose tailnet IPs to your cluster](https://tailscale.com/kb/1438/kubernetes-operator-cluster-egress#expose-a-tailnet-https-service-to-your-cluster-workloads) so the AdventureLog pods can resolve them. -## Getting Started - -Take a look at the [example config](https://github.com/seanmorley15/AdventureLog/blob/main/kustomization.yml) and modify it for your use case. - ## Environment Variables -Look at the [environment variable summary](docker.md#configuration) in the docker install section to see available and required configuration options. +Look at the [environment variable summary](docker.md#configuration) in the docker install section to see available and required configuration options. AIO installs can use `SITE_URL` and `POSTGRES_PASSWORD` with other values derived at startup. Enjoy AdventureLog! 🎉 diff --git a/documentation/docs/install/quick_start.md b/documentation/docs/install/quick_start.md index 64dbdceb..e500c9aa 100644 --- a/documentation/docs/install/quick_start.md +++ b/documentation/docs/install/quick_start.md @@ -1,6 +1,6 @@ # 🚀 Quick Start Install -Install **AdventureLog** in seconds using our automated script. +Install **AdventureLog** in seconds using our automated installer. The script defaults to the **All-in-One (AIO)** setup — one URL, one port, minimal configuration. ## 🧪 One-Liner Install @@ -10,36 +10,61 @@ curl -sSL https://get.adventurelog.app | bash This will: -- Check dependencies (Docker, Docker Compose) -- Set up project directory -- Download required files -- Prompt for basic configuration (like domain name) -- Start AdventureLog with Docker Compose +- Check dependencies (Docker, Docker Compose, RAM, architecture) +- Detect ARM hosts and configure a compatible PostGIS image automatically +- Set up the `./adventurelog` project directory +- Download `docker-compose.aio.yml`, `.env.aio`, and management scripts +- Walk you through core and optional configuration (S3, email, integrations, etc.) +- Start AdventureLog and wait for the health check ## ✅ Requirements -- Docker + Docker Compose -- Linux server or VPS -- Optional: Domain name for HTTPS +- Docker + Docker Compose v2 +- **2 GB RAM** recommended on first boot (world geography import); ~1 GB afterward +- Linux server, VPS, or macOS with Docker Desktop +- Optional: domain name for HTTPS (set `SITE_URL` to your public URL) + +::: tip ARM / Apple Silicon +The installer automatically uses `imresamu/postgis:16-3.5-alpine` on ARM hosts. AdventureLog AIO images are multi-arch. +::: ## 🔍 What It Does The script automatically: 1. Verifies Docker is installed and running -2. Downloads `docker-compose.yml` and `.env` -3. Prompts you for domain and port settings -4. Waits for services to start -5. Prints success info with next steps +2. Offers **All-in-One** (recommended) or **Standard** (separate frontend/backend) setup +3. Downloads compose files, `.env.aio` or `.env`, `deploy.sh`, and backup scripts +4. Prompts for site URL, admin credentials, and optional features +5. Waits for `/health` (first boot may take several minutes) +6. Saves optional credentials to `credentials.txt` + +## 🔄 Management (Re-run the installer) + +Re-run the same command to open the **management menu** when an install already exists: + +```bash +curl -sSL https://get.adventurelog.app | bash +# or from the install directory: +bash install_adventurelog.sh --manage +``` + +Management options include: + +- Check status and health +- Update to latest images (`deploy.sh --backup`) +- Edit configuration +- Backup and restore +- View logs, restart, or uninstall ## 🧼 Uninstall -To remove everything: +From the management menu, choose **Uninstall**, or manually: ```bash cd adventurelog -docker compose down -v +docker compose -f docker-compose.aio.yml down -v rm -rf adventurelog ``` -Need more control? Explore other [install options](getting_started.md) like Docker, Proxmox, Synology NAS, and more. +Need more control? See [All-in-One Docker](aio.md), [Standard Docker](docker.md), or other [install options](getting_started.md). diff --git a/documentation/docs/install/unraid.md b/documentation/docs/install/unraid.md index 9e888ce5..e44b20a1 100644 --- a/documentation/docs/install/unraid.md +++ b/documentation/docs/install/unraid.md @@ -19,7 +19,7 @@ docker network create example - Network type should be set to your **custom network**. - There is **no** AdventureLog---Database app, to find the database application search for `PostGIS` on the Unraid App Store then add and fill out the fields as shown below -- Change the repository version to `postgis/postgis:15-3.3` +- Change the repository version to `postgis/postgis:16-3.5` - Ensure that the variables `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` are set in the `PostGIS` container. If not, then add them as custom variables. The template should have `POSTGRES_PASSWORD` already and you will simply have to add `POSTGRES_DB` and `POSTGRES_USER`. - The forwarded port of `5012` is not needed unless you plan to access the database outside of the container's network. diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 9beff9a7..1af9da6b 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1,3 +1,8 @@ +# Frontend builds use the unified Dockerfile from the repository root: +# docker build -f docker/Dockerfile --target frontend . +# +# This file applies only if you run a legacy local build with context ./frontend + node_modules build .svelte-kit diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index 326df83b..00000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -FROM cgr.dev/chainguard/node:latest-dev@sha256:6eec455f386d097fc57d8aa73bd06c27ab91faa4f02f9103f94b4c56e0f9a80b AS build -USER root - -WORKDIR /app - -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" - -# Use Corepack so pnpm is managed by Node, not a global npm install. -RUN corepack enable && corepack prepare pnpm@10.32.1 --activate - -# Copy package manager files first for better Docker layer caching. -COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./ - -# Clean install all node modules using pnpm with frozen lockfile. -RUN pnpm install --frozen-lockfile - -# Copy source, then build the frontend assets. -COPY . . -RUN rm -f .env && pnpm run build - -FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:13593b7570658e8477de39e2f4a1dd25db2f836d68a0ba771251572d23bb4f8e AS external-website - -# Metadata labels for the AdventureLog image -LABEL maintainer="Sean Morley" \ - version="v0.12.1" \ - description="AdventureLog - the ultimate self-hosted travel companion." \ - org.opencontainers.image.title="AdventureLog" \ - org.opencontainers.image.description="AdventureLog is a self-hosted travel companion that helps you plan, track, and share your adventures." \ - org.opencontainers.image.version="v0.12.1" \ - org.opencontainers.image.authors="Sean Morley" \ - org.opencontainers.image.url="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/banner.png" \ - org.opencontainers.image.source="https://github.com/seanmorley15/AdventureLog" \ - org.opencontainers.image.vendor="Sean Morley" \ - org.opencontainers.image.licenses="GPL-3.0" - -WORKDIR /app -ENV NODE_ENV=production - -COPY --from=build /app/build ./build -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/package.json ./package.json - -EXPOSE 3000 - -# Distroless node image uses node as entrypoint. -CMD ["build/index.js"] diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 73e4787b..82bd932b 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,3 +1,5 @@ +/// + // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index 3470c950..217d902c 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -1,4 +1,4 @@ -export let appVersion = 'v0.12.1-beta-060326'; +export let appVersion = 'v0.12.1-beta-060626'; export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.12.1'; export let appTitle = 'AdventureLog'; export let copyrightYear = '2023-2026'; diff --git a/frontend/src/lib/index.server.ts b/frontend/src/lib/index.server.ts index 12f9885e..aee1ea0e 100644 --- a/frontend/src/lib/index.server.ts +++ b/frontend/src/lib/index.server.ts @@ -1,12 +1 @@ -const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; -const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; - -export const fetchCSRFToken = async () => { - const csrfTokenFetch = await fetch(`${serverEndpoint}/csrf/`); - if (csrfTokenFetch.ok) { - const csrfToken = await csrfTokenFetch.json(); - return csrfToken.csrfToken; - } else { - return null; - } -}; +export { fetchCSRFToken, getServerEndpoint } from '$lib/server/django-proxy'; diff --git a/frontend/src/lib/server/django-proxy.ts b/frontend/src/lib/server/django-proxy.ts new file mode 100644 index 00000000..5f8cef43 --- /dev/null +++ b/frontend/src/lib/server/django-proxy.ts @@ -0,0 +1,149 @@ +import { json, type RequestEvent } from '@sveltejs/kit'; + +export function getServerEndpoint(): string { + return process.env['PUBLIC_SERVER_URL'] || 'http://localhost:8000'; +} + +export const fetchCSRFToken = async () => { + const csrfTokenFetch = await fetch(`${getServerEndpoint()}/csrf/`); + if (csrfTokenFetch.ok) { + const csrfToken = await csrfTokenFetch.json(); + return csrfToken.csrfToken; + } + return null; +}; + +type ProxyBasePath = 'api' | 'auth'; + +type ProxyOptions = { + requireTrailingSlash?: boolean; + responseType?: 'text' | 'arrayBuffer'; + shouldRequireTrailingSlash?: (path: string) => boolean; + formatSearchParam?: (search: string, method: string) => string; +}; + +const authTrailingSlashPaths = ['disable-password', 'mobile-qr']; +const authTrailingSlashPrefixes = ['api-keys']; + +/** Headers that must not be forwarded to Node fetch (undici rejects hop-by-hop headers). */ +const FORBIDDEN_PROXY_HEADERS = new Set([ + 'connection', + 'content-length', + 'host', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade' +]); + +function sanitizeProxyRequestHeaders(headers: Headers): Record { + const result: Record = {}; + for (const [key, value] of headers) { + if (!FORBIDDEN_PROXY_HEADERS.has(key.toLowerCase())) { + result[key] = value; + } + } + return result; +} + +function defaultFormatSearchParam(search: string, method: string, basePath: ProxyBasePath): string { + if (basePath === 'api') { + if (method === 'GET' || method === 'PATCH' || method === 'PUT' || method === 'DELETE') { + return search ? `${search}&format=json` : '?format=json'; + } + return search; + } + return search; +} + +function authRequiresTrailingSlash(path: string, requireTrailingSlash: boolean): boolean { + return ( + requireTrailingSlash || + authTrailingSlashPaths.includes(path) || + authTrailingSlashPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)) + ); +} + +export async function proxyToDjango( + event: RequestEvent, + basePath: ProxyBasePath, + options: ProxyOptions = {} +) { + const { url, params, request, fetch, cookies } = event; + const path = params.path as string; + const endpoint = getServerEndpoint(); + let targetUrl = `${endpoint}/${basePath}/${path}`; + + const requireTrailingSlash = + basePath === 'auth' + ? authRequiresTrailingSlash(path, options.requireTrailingSlash ?? false) + : (options.requireTrailingSlash ?? false); + + if (requireTrailingSlash && !targetUrl.endsWith('/')) { + targetUrl += '/'; + } + + const searchParam = + options.formatSearchParam?.(url.search, request.method) ?? + defaultFormatSearchParam(url.search, request.method, basePath); + targetUrl += searchParam; + + const headers = new Headers(request.headers); + cookies.delete('csrftoken', { path: '/' }); + + const csrfToken = await fetchCSRFToken(); + if (!csrfToken) { + return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); + } + + const sessionId = cookies.get('sessionid'); + const cookieHeader = `csrftoken=${csrfToken}${sessionId ? `; sessionid=${sessionId}` : ''}`; + + try { + const response = await fetch(targetUrl, { + method: request.method, + headers: { + ...sanitizeProxyRequestHeaders(headers), + 'X-CSRFToken': csrfToken, + Cookie: cookieHeader + }, + body: + request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, + credentials: 'include' + }); + + if (response.status === 204) { + return new Response(null, { + status: 204, + headers: response.headers + }); + } + + const responseType = options.responseType ?? (basePath === 'api' ? 'arrayBuffer' : 'text'); + const responseData = + responseType === 'arrayBuffer' ? await response.arrayBuffer() : await response.text(); + + const cleanHeaders = new Headers(response.headers); + cleanHeaders.delete('set-cookie'); + + return new Response(responseData, { + status: response.status, + headers: cleanHeaders + }); + } catch (error) { + console.error('Error forwarding request:', error); + return json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function checkBackendHealth(): Promise<'reachable' | 'unreachable'> { + try { + const response = await fetch(`${getServerEndpoint()}/health/`, { method: 'GET' }); + return response.ok ? 'reachable' : 'unreachable'; + } catch { + return 'unreachable'; + } +} diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index f267b252..b0029013 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -1,102 +1,17 @@ -const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; -const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; -import { fetchCSRFToken } from '$lib/index.server'; -import { json } from '@sveltejs/kit'; +import { proxyToDjango } from '$lib/server/django-proxy'; +import type { RequestHandler } from './$types'; -/** @type {import('./$types').RequestHandler} */ -export async function GET(event) { - const { url, params, request, fetch, cookies } = event; - const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; - return handleRequest(url, params, request, fetch, cookies, searchParam); -} +export const GET: RequestHandler = (event) => + proxyToDjango(event, 'api', { requireTrailingSlash: false }); -/** @type {import('./$types').RequestHandler} */ -export async function POST({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, true); -} +export const POST: RequestHandler = (event) => + proxyToDjango(event, 'api', { requireTrailingSlash: true }); -export async function PATCH({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; - return handleRequest(url, params, request, fetch, cookies, searchParam, true); -} +export const PATCH: RequestHandler = (event) => + proxyToDjango(event, 'api', { requireTrailingSlash: true }); -export async function PUT({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; - return handleRequest(url, params, request, fetch, cookies, searchParam, true); -} +export const PUT: RequestHandler = (event) => + proxyToDjango(event, 'api', { requireTrailingSlash: true }); -export async function DELETE({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; - return handleRequest(url, params, request, fetch, cookies, searchParam, true); -} - -async function handleRequest( - url: any, - params: any, - request: any, - fetch: any, - cookies: any, - searchParam: string, - requreTrailingSlash: boolean | undefined = false -) { - const path = params.path; - let targetUrl = `${endpoint}/api/${path}`; - - // Ensure the path ends with a trailing slash - if (requreTrailingSlash && !targetUrl.endsWith('/')) { - targetUrl += '/'; - } - - // Append query parameters to the path correctly - targetUrl += searchParam; // This will add ?format=json or &format=json to the URL - - const headers = new Headers(request.headers); - - // Delete existing csrf cookie by setting an expired date - cookies.delete('csrftoken', { path: '/' }); - - // Generate a new csrf token (using your existing fetchCSRFToken function) - const csrfToken = await fetchCSRFToken(); - if (!csrfToken) { - return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); - } - - // Set the new csrf token in both headers and cookies - const sessionId = cookies.get('sessionid'); - const cookieHeader = `csrftoken=${csrfToken}` + (sessionId ? `; sessionid=${sessionId}` : ''); - - try { - const response = await fetch(targetUrl, { - method: request.method, - headers: { - ...Object.fromEntries(headers), - 'X-CSRFToken': csrfToken, - Cookie: cookieHeader - }, - body: - request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, - credentials: 'include' // This line ensures cookies are sent with the request - }); - - if (response.status === 204) { - return new Response(null, { - status: 204, - headers: response.headers - }); - } - - const responseData = await response.arrayBuffer(); - // Create a new Headers object without the 'set-cookie' header - const cleanHeaders = new Headers(response.headers); - cleanHeaders.delete('set-cookie'); - - return new Response(responseData, { - status: response.status, - headers: cleanHeaders - }); - } catch (error) { - console.error('Error forwarding request:', error); - return json({ error: 'Internal Server Error' }, { status: 500 }); - } -} +export const DELETE: RequestHandler = (event) => + proxyToDjango(event, 'api', { requireTrailingSlash: true }); diff --git a/frontend/src/routes/auth/[...path]/+server.ts b/frontend/src/routes/auth/[...path]/+server.ts index 8f0c546c..23c03c7f 100644 --- a/frontend/src/routes/auth/[...path]/+server.ts +++ b/frontend/src/routes/auth/[...path]/+server.ts @@ -1,109 +1,12 @@ -const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; -const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; -import { fetchCSRFToken } from '$lib/index.server'; -import { json } from '@sveltejs/kit'; +import { proxyToDjango } from '$lib/server/django-proxy'; +import type { RequestHandler } from './$types'; -/** @type {import('./$types').RequestHandler} */ -export async function GET(event) { - const { url, params, request, fetch, cookies } = event; - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam); -} +export const GET: RequestHandler = (event) => proxyToDjango(event, 'auth'); -/** @type {import('./$types').RequestHandler} */ -export async function POST({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); -} +export const POST: RequestHandler = (event) => proxyToDjango(event, 'auth'); -export async function PATCH({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); -} +export const PATCH: RequestHandler = (event) => proxyToDjango(event, 'auth'); -export async function PUT({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); -} +export const PUT: RequestHandler = (event) => proxyToDjango(event, 'auth'); -export async function DELETE({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); -} - -async function handleRequest( - url: any, - params: any, - request: any, - fetch: any, - cookies: any, - searchParam: string, - requreTrailingSlash: boolean | undefined = false -) { - const path = params.path; - let targetUrl = `${endpoint}/auth/${path}`; - - const add_trailing_slash_list = ['disable-password', 'mobile-qr']; - const add_trailing_slash_prefixes = ['api-keys']; - - // Ensure the path ends with a trailing slash - if ( - (requreTrailingSlash && !targetUrl.endsWith('/')) || - add_trailing_slash_list.includes(path) || - add_trailing_slash_prefixes.some((p) => path === p || path.startsWith(p + '/')) - ) { - if (!targetUrl.endsWith('/')) targetUrl += '/'; - } - - // Append query parameters to the path correctly - targetUrl += searchParam; // This will add or to the URL - - const headers = new Headers(request.headers); - - // Delete existing csrf cookie by setting an expired date - cookies.delete('csrftoken', { path: '/' }); - - // Generate a new csrf token (using your existing fetchCSRFToken function) - const csrfToken = await fetchCSRFToken(); - if (!csrfToken) { - return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); - } - - // Set the new csrf token in both headers and cookies - const sessionId = cookies.get('sessionid'); - const cookieHeader = `csrftoken=${csrfToken}` + (sessionId ? `; sessionid=${sessionId}` : ''); - - try { - const response = await fetch(targetUrl, { - method: request.method, - headers: { - ...Object.fromEntries(headers), - 'X-CSRFToken': csrfToken, - Cookie: cookieHeader - }, - body: - request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, - credentials: 'include' // This line ensures cookies are sent with the request - }); - - if (response.status === 204) { - return new Response(null, { - status: 204, - headers: response.headers - }); - } - - const responseData = await response.text(); - // Create a new Headers object without the 'set-cookie' header - const cleanHeaders = new Headers(response.headers); - cleanHeaders.delete('set-cookie'); - - return new Response(responseData, { - status: response.status, - headers: cleanHeaders - }); - } catch (error) { - console.error('Error forwarding request:', error); - return json({ error: 'Internal Server Error' }, { status: 500 }); - } -} +export const DELETE: RequestHandler = (event) => proxyToDjango(event, 'auth'); diff --git a/frontend/src/routes/health/+server.ts b/frontend/src/routes/health/+server.ts new file mode 100644 index 00000000..a83e1b1a --- /dev/null +++ b/frontend/src/routes/health/+server.ts @@ -0,0 +1,11 @@ +import { checkBackendHealth } from '$lib/server/django-proxy'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + const backend = await checkBackendHealth(); + return json( + { ok: backend === 'reachable', backend }, + { status: backend === 'reachable' ? 200 : 503 } + ); +}; diff --git a/frontend/start.js b/frontend/start.js new file mode 100644 index 00000000..e88d8e6c --- /dev/null +++ b/frontend/start.js @@ -0,0 +1,5 @@ +if (!process.env.ORIGIN && process.env.SITE_URL) { + process.env.ORIGIN = process.env.SITE_URL; +} + +await import('./build/index.js'); diff --git a/frontend/startup.sh b/frontend/startup.sh index 67dbb1eb..9b7fba10 100644 --- a/frontend/startup.sh +++ b/frontend/startup.sh @@ -1,5 +1,8 @@ #!/bin/sh -echo "The origin to be set is: $ORIGIN" -# Start the application -ORIGIN=$ORIGIN exec node build +if [ -z "${ORIGIN:-}" ] && [ -n "${SITE_URL:-}" ]; then + export ORIGIN="$SITE_URL" +fi + +echo "The origin to be set is: ${ORIGIN:-not set}" +ORIGIN="${ORIGIN:-}" exec node build diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 24a99c08..a8f10c8e 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -9,7 +9,6 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler", - "types": ["unplugin-icons/types/svelte"] // add this line + "moduleResolution": "bundler" } } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7b0edb35..126a520f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,11 +3,22 @@ import { defineConfig } from 'vite'; import { sveltekit } from '@sveltejs/kit/vite'; import Icons from 'unplugin-icons/vite'; +const backendTarget = process.env.PUBLIC_SERVER_URL ?? 'http://127.0.0.1:8000'; +const useDevProxy = !process.env.CI && process.env.VITEST !== 'true'; + export default defineConfig({ plugins: [ sveltekit(), Icons({ compiler: 'svelte' }) - ] + ], + server: useDevProxy + ? { + proxy: { + '/api': { target: backendTarget, changeOrigin: true }, + '/auth': { target: backendTarget, changeOrigin: true } + } + } + : undefined }); diff --git a/install_adventurelog.sh b/install_adventurelog.sh index c5611de9..07f922ef 100755 --- a/install_adventurelog.sh +++ b/install_adventurelog.sh @@ -1,796 +1,114 @@ #!/bin/bash +# AdventureLog Installer & Manager +# (c) 2023-2026 Sean Morley — https://adventurelog.app — GPL-3.0 +# +# Usage: +# curl -sSL https://get.adventurelog.app | bash +# bash install_adventurelog.sh [--dry-run] [--manage] [--force-install] set -euo pipefail -# ============================================================================= -# AdventureLog Installer Script -# (c) 2023-2026 Sean Morley -# https://adventurelog.app -# License: GPL-3.0 -# ============================================================================= +ADVENTURELOG_REF="${ADVENTURELOG_REF:-main}" +GITHUB_RAW="https://raw.githubusercontent.com/seanmorley15/AdventureLog/${ADVENTURELOG_REF}" +DRY_RUN=false +FORCE_INSTALL=false +ADVENTURELOG_MANAGE="${ADVENTURELOG_MANAGE:-}" -APP_NAME="AdventureLog" -INSTALL_DIR="./adventurelog" -COMPOSE_FILE_URL="https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml" -ENV_FILE_URL="https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/.env.example" +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --force-install) FORCE_INSTALL=true ;; + --manage) ADVENTURELOG_MANAGE=1 ;; + --help|-h) + echo "Usage: install_adventurelog.sh [--dry-run] [--manage] [--force-install]" + exit 0 + ;; + esac +done -# Global configuration variables -declare -g FRONTEND_ORIGIN="" -declare -g BACKEND_URL="" -declare -g ADMIN_PASSWORD="" -declare -g DB_PASSWORD="" -declare -g FRONTEND_PORT="" -declare -g BACKEND_PORT="" - -# Color codes for beautiful output -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly BLUE='\033[0;34m' -readonly PURPLE='\033[0;35m' -readonly CYAN='\033[0;36m' -readonly MAGENTA='\033[0;35m' -readonly BOLD='\033[1m' -readonly NC='\033[0m' # No Color - -# ============================================================================= -# Utility Functions -# ============================================================================= - -log_info() { - echo -e "${BLUE}ℹ️ $1${NC}" +find_installer_lib_dir() { + local script_dir="" + if [[ "${BASH_SOURCE[0]}" != "bash" && "${BASH_SOURCE[0]}" != "-" && -f "${BASH_SOURCE[0]}" ]]; then + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if [[ -f "$script_dir/scripts/install/lib/ui.sh" ]]; then + echo "$script_dir/scripts/install/lib" + return 0 + fi + fi + if [[ -f "./scripts/install/lib/ui.sh" ]]; then + echo "$(cd "./scripts/install/lib" && pwd)" + return 0 + fi + if [[ -f "./adventurelog/scripts/install/lib/ui.sh" ]]; then + echo "$(cd "./adventurelog/scripts/install/lib" && pwd)" + return 0 + fi + return 1 } -log_success() { - echo -e "${GREEN}✅ $1${NC}" +bootstrap_and_source_libs() { + local lib_dir + if lib_dir="$(find_installer_lib_dir)"; then + INSTALLER_LIB_DIR="$lib_dir" + REPO_ROOT="$(cd "$INSTALLER_LIB_DIR/../../.." && pwd)" + else + local cache_dir + cache_dir="$(mktemp -d 2>/dev/null || echo "/tmp/adventurelog-installer-$$")" + mkdir -p "$cache_dir" + local files=( + common.sh ui.sh tui.sh checks.sh download.sh deploy.sh + aio-config.sh standard-config.sh manage.sh features.sh main.sh + ) + local f + for f in "${files[@]}"; do + curl -fsSL "${GITHUB_RAW}/scripts/install/lib/${f}" -o "${cache_dir}/${f}" || { + echo "ERROR: Failed to download installer library: ${f}" >&2 + exit 1 + } + done + INSTALLER_LIB_DIR="$cache_dir" + fi + + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/common.sh" + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/ui.sh" + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/tui.sh" + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/checks.sh" + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/download.sh" + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/features.sh" + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/aio-config.sh" + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/standard-config.sh" + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/deploy.sh" + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/manage.sh" + # shellcheck source=/dev/null + source "$INSTALLER_LIB_DIR/main.sh" } -log_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" +run_main() { + if [[ "$DRY_RUN" == true ]]; then + export DRY_RUN=true + fi + if [[ "$FORCE_INSTALL" == true ]]; then + export FORCE_INSTALL=true + fi + run_installer_main } -log_error() { - echo -e "${RED}❌ $1${NC}" -} - -log_header() { - echo -e "${PURPLE}$1${NC}" -} - -print_banner() { - cat << 'EOF' -╔═════════════════════════════════════════════════════════════════════════╗ -║ ║ -║ A D V E N T U R E L O G I N S T A L L E R ║ -║ ║ -║ The Ultimate Travel Companion ║ -║ ║ -╚═════════════════════════════════════════════════════════════════════════╝ -EOF -} - -print_header() { - clear - echo "" - print_banner - echo "" - log_header "🚀 Starting installation — $(date)" - echo "" -} - -generate_secure_password() { - # Generate a 24-character password with mixed case, numbers, and safe symbols - local length=${1:-24} - - # Test if /dev/urandom exists - if [[ ! -r "/dev/urandom" ]]; then - echo "ERROR: /dev/urandom not readable" >&2 - return 1 - fi - - # Try the main approach - if command -v tr &>/dev/null; then - LC_ALL=C tr -dc 'A-Za-z0-9!#$%&*+-=?@^_' /dev/null | head -c "$length" 2>/dev/null - return 0 - fi - - # Fallback approach using od - if command -v od &>/dev/null; then - dd if=/dev/urandom bs=1 count=100 2>/dev/null | od -An -tx1 | tr -d ' \n' | cut -c1-"$length" - return 0 - fi - - # Last resort - use openssl if available - if command -v openssl &>/dev/null; then - openssl rand -base64 32 | tr -d "=+/" | cut -c1-"$length" - return 0 - fi - - echo "ERROR: No suitable random generation method found" >&2 - return 1 -} - -check_os() { - if [[ "$OSTYPE" == "darwin"* ]]; then - echo "macos" - elif [[ "$OSTYPE" == "linux-gnu"* ]]; then - echo "linux" - else - echo "unknown" - fi -} - -# ============================================================================= -# Validation Functions -# ============================================================================= - -validate_url() { - local url="$1" - if [[ $url =~ ^https?://[a-zA-Z0-9.-]+(:[0-9]+)?(/.*)?$ ]]; then - return 0 - else - return 1 - fi -} - -extract_port_from_url() { - local url="$1" - local default_port="$2" - - # Extract port from URL using regex - if [[ $url =~ :([0-9]+) ]]; then - echo "${BASH_REMATCH[1]}" - else - # Use default port based on protocol - if [[ $url =~ ^https:// ]]; then - echo "443" - elif [[ $url =~ ^http:// ]]; then - echo "${default_port:-80}" - else - echo "$default_port" - fi - fi -} - -check_port_availability() { - local port="$1" - local service_name="$2" - - # Check if port is in use - if command -v netstat &>/dev/null; then - if netstat -ln 2>/dev/null | grep -q ":$port "; then - log_warning "Port $port is already in use" - echo "" - read -r -p "Do you want to continue anyway? The $service_name service may fail to start. [y/N]: " confirm - if [[ ! $confirm =~ ^[Yy]$ ]]; then - echo "Installation cancelled." - exit 0 - fi - fi - elif command -v ss &>/dev/null; then - if ss -ln 2>/dev/null | grep -q ":$port "; then - log_warning "Port $port is already in use" - echo "" - read -r -p "Do you want to continue anyway? The $service_name service may fail to start. [y/N]: " confirm - if [[ ! $confirm =~ ^[Yy]$ ]]; then - echo "Installation cancelled." - exit 0 - fi - fi - fi -} - -check_dependencies() { - log_info "Checking system dependencies..." - - local missing_deps=() - - if ! command -v curl &>/dev/null; then - missing_deps+=("curl") - fi - - if ! command -v docker &>/dev/null; then - missing_deps+=("docker") - fi - - if ! command -v docker-compose &>/dev/null && ! docker compose version &>/dev/null; then - missing_deps+=("docker-compose") - fi - - if [ ${#missing_deps[@]} -ne 0 ]; then - log_error "Missing dependencies: ${missing_deps[*]}" - echo "" - echo "Please install the missing dependencies:" - for dep in "${missing_deps[@]}"; do - case $dep in - "curl") - echo " • curl: apt-get install curl (Ubuntu/Debian) or brew install curl (macOS)" - ;; - "docker") - echo " • Docker: https://docs.docker.com/get-docker/" - ;; - "docker-compose") - echo " • Docker Compose: https://docs.docker.com/compose/install/" - ;; - esac - done - exit 1 - fi - - log_success "All dependencies are installed" -} - -check_docker_status() { - log_info "Checking Docker daemon status..." - - if ! docker info &>/dev/null; then - log_error "Docker daemon is not running" - echo "" - echo "Please start Docker and try again:" - echo " • On macOS/Windows: Start Docker Desktop" - echo " • On Linux: sudo systemctl start docker" - exit 1 - fi - - log_success "Docker daemon is running" -} - -# ============================================================================= -# Installation Functions -# ============================================================================= - -create_directory() { - log_info "Setting up installation directory: $INSTALL_DIR" - - if [ -d "$INSTALL_DIR" ]; then - log_warning "Directory already exists" - echo "" - read -r -p "Do you want to continue and overwrite existing files? [y/N]: " confirm - if [[ ! $confirm =~ ^[Yy]$ ]]; then - echo "Installation cancelled." - exit 0 - fi - else - mkdir -p "$INSTALL_DIR" - log_success "Created directory: $INSTALL_DIR" - fi - - cd "$INSTALL_DIR" || { - log_error "Failed to change to directory: $INSTALL_DIR" - exit 1 - } -} - -# Check for AdventureLog running as a docker container -check_running_container() { - if docker ps -a --filter "name=adventurelog" --format '{{.Names}}' | grep -q "adventurelog"; then - log_error "AdventureLog is already running as a Docker container (including stopped or restarting states)." - echo "" - echo "Running this installer further can break existing installs." - echo "Please stop and remove the existing AdventureLog container manually before proceeding." - echo " • To stop: docker compose down --remove-orphans" - echo "Installation aborted to prevent data loss." - exit 1 - fi -} - -download_files() { - log_info "Downloading configuration files..." - - # Download with better error handling - if ! curl -fsSL --connect-timeout 10 --max-time 30 "$COMPOSE_FILE_URL" -o docker-compose.yml; then - log_error "Failed to download docker-compose.yml" - exit 1 - fi - log_success "docker-compose.yml downloaded" - - if ! curl -fsSL --connect-timeout 10 --max-time 30 "$ENV_FILE_URL" -o .env; then - log_error "Failed to download .env template" - exit 1 - fi - log_success ".env template downloaded" -} - -prompt_configuration() { - echo "" - log_header "🛠️ Configuration Setup" - echo "" - echo "Configure the URLs where AdventureLog will be accessible." - echo "Press Enter to use the default values shown in brackets." - echo "" - echo "⚠️ Note: The installer will automatically configure Docker ports based on your URLs" - echo "" - - # Frontend URL - local default_frontend="http://localhost:8015" - while true; do - read -r -p "🌐 Frontend URL [$default_frontend]: " input_frontend - FRONTEND_ORIGIN=${input_frontend:-$default_frontend} - - if validate_url "$FRONTEND_ORIGIN"; then - FRONTEND_PORT=$(extract_port_from_url "$FRONTEND_ORIGIN" "8015") - break - else - log_error "Invalid URL format. Please enter a valid URL (e.g., http://localhost:8015)" - fi - done - log_success "Frontend URL: $FRONTEND_ORIGIN (Port: $FRONTEND_PORT)" - - # Backend URL - local default_backend="http://localhost:8016" - while true; do - read -r -p "🔧 Backend URL [$default_backend]: " input_backend - BACKEND_URL=${input_backend:-$default_backend} - - if validate_url "$BACKEND_URL"; then - BACKEND_PORT=$(extract_port_from_url "$BACKEND_URL" "8016") - break - else - log_error "Invalid URL format. Please enter a valid URL (e.g., http://localhost:8016)" - fi - done - log_success "Backend URL: $BACKEND_URL (Port: $BACKEND_PORT)" - - # Check port availability - check_port_availability "$FRONTEND_PORT" "frontend" - check_port_availability "$BACKEND_PORT" "backend" - - echo "" -} - -configure_environment_fallback() { - log_info "Using simple configuration approach..." - - # Generate simple passwords using a basic method - DB_PASSWORD="$(date +%s | sha256sum | base64 | head -c 32)" - ADMIN_PASSWORD="$(date +%s | sha256sum | base64 | head -c 24)" - - log_info "Generated passwords using fallback method" - - # Create backup - cp .env .env.backup - - # Use simple string replacement with perl if available - if command -v perl &>/dev/null; then - log_info "Using perl for configuration..." - # Fix: Update BOTH password variables for database consistency - perl -pi -e "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$DB_PASSWORD/" .env - perl -pi -e "s/^DATABASE_PASSWORD=.*/DATABASE_PASSWORD=$DB_PASSWORD/" .env - perl -pi -e "s/^DJANGO_ADMIN_PASSWORD=.*/DJANGO_ADMIN_PASSWORD=$ADMIN_PASSWORD/" .env - perl -pi -e "s|^ORIGIN=.*|ORIGIN=$FRONTEND_ORIGIN|" .env - perl -pi -e "s|^PUBLIC_URL=.*|PUBLIC_URL=$BACKEND_URL|" .env - perl -pi -e "s|^CSRF_TRUSTED_ORIGINS=.*|CSRF_TRUSTED_ORIGINS=$FRONTEND_ORIGIN,$BACKEND_URL|" .env - perl -pi -e "s|^FRONTEND_URL=.*|FRONTEND_URL=$FRONTEND_ORIGIN|" .env - # Add port configuration - perl -pi -e "s/^FRONTEND_PORT=.*/FRONTEND_PORT=$FRONTEND_PORT/" .env - perl -pi -e "s/^BACKEND_PORT=.*/BACKEND_PORT=$BACKEND_PORT/" .env - - # Add port variables if they don't exist - if ! grep -q "^FRONTEND_PORT=" .env; then - echo "FRONTEND_PORT=$FRONTEND_PORT" >> .env - fi - if ! grep -q "^BACKEND_PORT=" .env; then - echo "BACKEND_PORT=$BACKEND_PORT" >> .env - fi - - if grep -q "POSTGRES_PASSWORD=$DB_PASSWORD" .env; then - log_success "Configuration completed successfully" - return 0 - fi - fi - - # Manual approach - create .env from scratch with key variables - log_info "Creating minimal .env configuration..." - cat > .env << EOF -# Database Configuration -POSTGRES_DB=adventurelog -POSTGRES_USER=adventurelog -POSTGRES_PASSWORD=$DB_PASSWORD -DATABASE_PASSWORD=$DB_PASSWORD - -# Django Configuration -DJANGO_ADMIN_USERNAME=admin -DJANGO_ADMIN_PASSWORD=$ADMIN_PASSWORD -SECRET_KEY=$(openssl rand -base64 32 2>/dev/null || echo "change-this-secret-key-$(date +%s)") - -# URL Configuration -ORIGIN=$FRONTEND_ORIGIN -PUBLIC_URL=$BACKEND_URL -FRONTEND_URL=$FRONTEND_ORIGIN -CSRF_TRUSTED_ORIGINS=$FRONTEND_ORIGIN,$BACKEND_URL - -# Port Configuration -FRONTEND_PORT=$FRONTEND_PORT -BACKEND_PORT=$BACKEND_PORT - -# Additional Settings -DEBUG=False -EOF - - log_success "Created minimal .env configuration" - return 0 -} - -configure_environment() { - log_info "Generating secure configuration..." - - # Debug: Test password generation first - log_info "Testing password generation..." - if ! command -v tr &>/dev/null; then - log_error "tr command not found - required for password generation" - exit 1 - fi - - # Generate secure passwords with error checking - log_info "Generating database password..." - DB_PASSWORD=$(generate_secure_password 32) - if [[ -z "$DB_PASSWORD" ]]; then - log_error "Failed to generate database password" - exit 1 - fi - log_success "Database password generated (${#DB_PASSWORD} characters)" - - log_info "Generating admin password..." - ADMIN_PASSWORD=$(generate_secure_password 24) - if [[ -z "$ADMIN_PASSWORD" ]]; then - log_error "Failed to generate admin password" - exit 1 - fi - log_success "Admin password generated (${#ADMIN_PASSWORD} characters)" - - # Debug: Check if .env file exists and is readable - log_info "Checking .env file..." - if [[ ! -f ".env" ]]; then - log_error ".env file not found" - exit 1 - fi - - if [[ ! -r ".env" ]]; then - log_error ".env file is not readable" - exit 1 - fi - - log_info "File check passed - .env exists and is readable ($(wc -l < .env) lines)" - - # Try fallback method first (simpler and more reliable) - log_info "Attempting configuration..." - if configure_environment_fallback; then - return 0 - fi - - log_warning "Fallback method failed, trying advanced processing..." - - # Fallback to bash processing - # Create backup of original .env - cp .env .env.backup - - # Create a new .env file by processing the original line by line - local temp_file=".env.temp" - local processed_lines=0 - local updated_lines=0 - - while IFS= read -r line || [[ -n "$line" ]]; do - ((processed_lines++)) - case "$line" in - POSTGRES_PASSWORD=*) - echo "POSTGRES_PASSWORD=$DB_PASSWORD" - ((updated_lines++)) - ;; - DATABASE_PASSWORD=*) - echo "DATABASE_PASSWORD=$DB_PASSWORD" - ((updated_lines++)) - ;; - DJANGO_ADMIN_PASSWORD=*) - echo "DJANGO_ADMIN_PASSWORD=$ADMIN_PASSWORD" - ((updated_lines++)) - ;; - ORIGIN=*) - echo "ORIGIN=$FRONTEND_ORIGIN" - ((updated_lines++)) - ;; - PUBLIC_URL=*) - echo "PUBLIC_URL=$BACKEND_URL" - ((updated_lines++)) - ;; - CSRF_TRUSTED_ORIGINS=*) - echo "CSRF_TRUSTED_ORIGINS=$FRONTEND_ORIGIN,$BACKEND_URL" - ((updated_lines++)) - ;; - FRONTEND_URL=*) - echo "FRONTEND_URL=$FRONTEND_ORIGIN" - ((updated_lines++)) - ;; - FRONTEND_PORT=*) - echo "FRONTEND_PORT=$FRONTEND_PORT" - ((updated_lines++)) - ;; - BACKEND_PORT=*) - echo "BACKEND_PORT=$BACKEND_PORT" - ((updated_lines++)) - ;; - *) - echo "$line" - ;; - esac - done < .env > "$temp_file" - - # Add port variables if they weren't found in the original file - if ! grep -q "^FRONTEND_PORT=" "$temp_file"; then - echo "FRONTEND_PORT=$FRONTEND_PORT" >> "$temp_file" - ((updated_lines++)) - fi - if ! grep -q "^BACKEND_PORT=" "$temp_file"; then - echo "BACKEND_PORT=$BACKEND_PORT" >> "$temp_file" - ((updated_lines++)) - fi - - log_info "Processed $processed_lines lines, updated $updated_lines configuration values" - - # Check if temp file was created successfully - if [[ ! -f "$temp_file" ]]; then - log_error "Failed to create temporary configuration file" - exit 1 - fi - - # Replace the original .env with the configured one - if mv "$temp_file" .env; then - log_success "Environment configured with secure passwords and port settings" - else - log_error "Failed to replace .env file" - log_info "Restoring backup and exiting" - mv .env.backup .env - rm -f "$temp_file" - exit 1 - fi - - # Verify critical configuration was applied - if grep -q "POSTGRES_PASSWORD=$DB_PASSWORD" .env && (grep -q "DATABASE_PASSWORD=$DB_PASSWORD" .env || grep -q "POSTGRES_PASSWORD=$DB_PASSWORD" .env); then - log_success "Configuration verification passed - database password variables set" - else - log_error "Configuration verification failed - database passwords not properly configured" - log_info "Showing database-related lines in .env for debugging:" - grep -E "(POSTGRES_PASSWORD|DATABASE_PASSWORD)" .env | while read -r line; do - echo " $line" - done - mv .env.backup .env - exit 1 - fi - - # Verify port configuration - if grep -q "FRONTEND_PORT=$FRONTEND_PORT" .env && grep -q "BACKEND_PORT=$BACKEND_PORT" .env; then - log_success "Port configuration verified - frontend: $FRONTEND_PORT, backend: $BACKEND_PORT" - else - log_warning "Port configuration may not be complete - check .env file manually" - fi -} - -update_docker_compose_ports() { - log_info "Updating Docker Compose port configuration..." - - # Create backup of docker-compose.yml - cp docker-compose.yml docker-compose.yml.backup - - # Update ports in docker-compose.yml using sed - if command -v sed &>/dev/null; then - # For frontend service port mapping - sed -i.tmp "s/\"[0-9]*:3000\"/\"$FRONTEND_PORT:3000\"/g" docker-compose.yml - # For backend service port mapping - sed -i.tmp "s/\"[0-9]*:8000\"/\"$BACKEND_PORT:8000\"/g" docker-compose.yml - - # Clean up temporary files created by sed -i - rm -f docker-compose.yml.tmp - - log_success "Docker Compose ports updated - Frontend: $FRONTEND_PORT, Backend: $BACKEND_PORT" - else - log_warning "sed command not available - Docker Compose ports may need manual configuration" - fi -} - -start_services() { - log_info "Starting AdventureLog services..." - echo "" - - # Use docker compose or docker-compose based on availability - local compose_cmd - if docker compose version &>/dev/null; then - compose_cmd="docker compose" - else - compose_cmd="docker-compose" - fi - - # Pull images first for better progress indication - log_info "Pulling required Docker images..." - $compose_cmd pull - - # Start services - log_info "Starting containers..." - if $compose_cmd up -d --remove-orphans; then - log_success "All services started successfully" - else - log_error "Failed to start services" - echo "" - log_info "Checking service status..." - $compose_cmd ps - exit 1 - fi -} - -wait_for_services() { - log_info "Waiting for services to be ready... (up to 90 seconds, first startup may take longer)" - - local max_attempts=45 # 45 attempts * 2 seconds = 90 seconds total - local attempt=1 - local frontend_ready=false - local backend_ready=false - - while [ $attempt -le $max_attempts ]; do - # Check frontend - if [ "$frontend_ready" = false ]; then - if curl -s -o /dev/null -w "%{http_code}" "$FRONTEND_ORIGIN" | grep -q "200\|404\|302"; then - log_success "Frontend is responding" - frontend_ready=true - fi - fi - - # Check backend - if [ "$backend_ready" = false ]; then - if curl -s -o /dev/null -w "%{http_code}" "$BACKEND_URL" | grep -q "200\|404\|302"; then - log_success "Backend is responding" - backend_ready=true - fi - fi - - # If both are ready, break the loop - if [ "$frontend_ready" = true ] && [ "$backend_ready" = true ]; then - break - fi - - # Check if we've reached max attempts - if [ $attempt -eq $max_attempts ]; then - if [ "$frontend_ready" = false ]; then - log_warning "Frontend may still be starting up (this is normal for first run)" - fi - if [ "$backend_ready" = false ]; then - log_warning "Backend may still be starting up (this is normal for first run)" - fi - break - fi - - # Wait and increment counter - printf "." - sleep 2 - ((attempt++)) - done - echo "" -} - -# ============================================================================= -# Output Functions -# ============================================================================= - -print_success_message() { - local ip_address - ip_address=$(hostname -I 2>/dev/null | cut -d' ' -f1 || echo "localhost") - - echo "" - cat << 'EOF' -╔════════════════════════════════════════════════════════════════════════════╗ -║ ║ -║ A D V E N T U R E L O G I S R E A D Y F O R L A U N C H! ║ -║ ║ -╚════════════════════════════════════════════════════════════════════════════╝ -EOF - echo "" - - log_success "🎉 Installation completed successfully!" - echo "" - - echo -e "${BOLD}🌐 Access Points:${NC}" - echo -e " 🖥️ Frontend: ${CYAN}$FRONTEND_ORIGIN${NC}" - echo -e " ⚙️ Backend: ${CYAN}$BACKEND_URL${NC}" - - echo "" - echo -e "${BOLD}🔐 Admin Credentials:${NC}" - echo -e " 👤 Username: ${GREEN}admin${NC}" - echo -e " 🔑 Password: ${GREEN}$ADMIN_PASSWORD${NC}" - - echo "" - echo -e "${BOLD}📁 Important Locations:${NC}" - echo -e " 🛠️ Config: ${YELLOW}$(pwd)/.env${NC}" - echo -e " 📦 Media Vol: ${YELLOW}adventurelog_media${NC}" - echo -e " 📜 Logs: ${YELLOW}docker compose logs -f${NC}" - - echo "" - echo -e "${BOLD}🧰 Management Commands:${NC}" - echo -e " ⛔ Stop: ${CYAN}docker compose down${NC}" - echo -e " ▶️ Start: ${CYAN}docker compose up -d${NC}" - echo -e " 🔄 Update: ${CYAN}docker compose pull && docker compose up -d${NC}" - echo -e " 📖 Logs: ${CYAN}docker compose logs -f${NC}" - - echo "" - log_info "💾 Save your admin password in a secure location!" - echo "" - - # Show port information - echo -e "${BOLD}🔧 Port Configuration:${NC}" - echo -e " 🖥️ Frontend Port: ${YELLOW}$FRONTEND_PORT${NC}" - echo -e " ⚙️ Backend Port: ${YELLOW}$BACKEND_PORT${NC}" - echo "" - - # Optional donation link - echo -e "${BOLD}❤️ Enjoying AdventureLog?${NC}" - echo -e " Support future development: ${MAGENTA}https://seanmorley.com/sponsor${NC}" - echo "" - - echo -e "${BOLD}🌍 Adventure awaits — your journey starts now with AdventureLog!${NC}" -} - -print_failure_message() { - echo "" - log_error "Installation failed!" - echo "" - echo "Troubleshooting steps:" - echo "1. Check Docker is running: docker info" - echo "2. Check available ports: netstat -an | grep :$FRONTEND_PORT" - echo "3. Check available ports: netstat -an | grep :$BACKEND_PORT" - echo "4. View logs: docker compose logs" - echo "5. Check .env configuration: cat .env" - echo "6. Check docker-compose.yml ports: grep -A5 ports docker-compose.yml" - echo "" - echo "For support, visit: https://github.com/seanmorley15/AdventureLog" -} - -cleanup_on_failure() { - log_info "Cleaning up after failure..." - - if [ -f ".env.backup" ]; then - mv .env.backup .env - log_info "Restored original .env file" - fi - - if [ -f "docker-compose.yml.backup" ]; then - mv docker-compose.yml.backup docker-compose.yml - log_info "Restored original docker-compose.yml file" - fi - - if command -v docker &>/dev/null; then - docker compose down --remove-orphans 2>/dev/null || true - fi -} - -# ============================================================================= -# Main Installation Flow -# ============================================================================= - -main() { - # Set up error handling - trap 'cleanup_on_failure; print_failure_message; exit 1' ERR - - # Installation steps - print_header - check_dependencies - check_docker_status - check_running_container - create_directory - download_files - prompt_configuration - configure_environment - update_docker_compose_ports - start_services - wait_for_services - print_success_message - - # Clean up backup files on success - rm -f .env.backup - rm -f docker-compose.yml.backup -} - -# Script entry point -# Allow interactive install even when piped -if [[ -t 1 ]]; then - # stdout is a terminal → likely interactive - exec < /dev/tty # reconnect stdin to terminal for user input - main "$@" +if [[ -t 1 ]] || [[ "$DRY_RUN" == true ]]; then + if [[ -t 1 ]]; then + exec < /dev/tty + fi + bootstrap_and_source_libs + run_main "$@" else - echo "Error: This script needs an interactive terminal." >&2 - exit 1 + echo "Error: This script needs an interactive terminal (or use --dry-run)." >&2 + exit 1 fi diff --git a/k8s/base/adventurelog.yaml b/k8s/base/adventurelog.yaml new file mode 100644 index 00000000..f0dbceb9 --- /dev/null +++ b/k8s/base/adventurelog.yaml @@ -0,0 +1,164 @@ +apiVersion: v1 +kind: Secret +metadata: + name: adventurelog-secret +type: Opaque +stringData: + postgres-password: change-me + django-secret-key: change-me-to-a-long-random-string +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: adventurelog-db-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: adventurelog-media-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: adventurelog-db +spec: + serviceName: adventurelog-db + replicas: 1 + selector: + matchLabels: + app: adventurelog-db + template: + metadata: + labels: + app: adventurelog-db + spec: + containers: + - name: postgis + image: postgis/postgis:16-3.5 + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: database + - name: POSTGRES_USER + value: adventure + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: adventurelog-secret + key: postgres-password + volumeMounts: + - name: db-data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: ["pg_isready", "-U", "adventure", "-d", "database"] + initialDelaySeconds: 10 + periodSeconds: 5 + volumes: + - name: db-data + persistentVolumeClaim: + claimName: adventurelog-db-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: adventurelog-db +spec: + selector: + app: adventurelog-db + ports: + - name: postgres + port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: adventurelog-aio +spec: + replicas: 1 + selector: + matchLabels: + app: adventurelog-aio + template: + metadata: + labels: + app: adventurelog-aio + spec: + containers: + - name: app + image: ghcr.io/seanmorley15/adventurelog-aio:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + env: + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: adventurelog-secret + key: postgres-password + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: adventurelog-secret + key: django-secret-key + - name: PGHOST + value: adventurelog-db + - name: SITE_URL + value: https://adventurelog.example.com + volumeMounts: + - name: media + mountPath: /code/media + readinessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + volumes: + - name: media + persistentVolumeClaim: + claimName: adventurelog-media-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: adventurelog-aio +spec: + selector: + app: adventurelog-aio + ports: + - name: http + port: 80 + targetPort: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: adventurelog +spec: + rules: + - host: adventurelog.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: adventurelog-aio + port: + number: 80 diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 00000000..f1384a59 --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - adventurelog.yaml diff --git a/kustomization.yml b/kustomization.yml index da7c6e77..65efdb45 100644 --- a/kustomization.yml +++ b/kustomization.yml @@ -1,3 +1,7 @@ +# DEPRECATED: This legacy example colocates frontend, backend, and database in one pod. +# Use `k8s/base/` for a production-oriented layout (separate PostGIS StatefulSet + AIO Deployment). +# See documentation/docs/install/kustomize.md + apiVersion: apps/v1 kind: Deployment metadata: @@ -30,14 +34,14 @@ spec: - containerPort: 3000 env: - name: PUBLIC_SERVER_URL - value: "http://internally-and-externally.reachable.io:80" + value: "http://server:8000" - name: ORIGIN value: "http://url-typed-into-browser.io:80" - name: BODY_SIZE_LIMIT value: "Infinity" - name: adventure-db - image: postgis/postgis:15-3.3 + image: postgis/postgis:16-3.5 imagePullPolicy: IfNotPresent ports: - containerPort: 5432 @@ -82,21 +86,19 @@ spec: valueFrom: secretKeyRef: name: adventurelog-secret - key: adventure-postgres-password + key: adventure-django-secret-key - name: PUBLIC_URL - value: "http://internally-and-externally.reachable.io:80" # Match the outward port, used for the creation of image urls + value: "http://internally-and-externally.reachable.io:80" - name: FRONTEND_URL value: "http://url-typed-into-browser.io:80" - name: CSRF_TRUSTED_ORIGINS - value: "http://url-typed-into-browser.io:80, http://internally-and-externally.reachable.io:80" + value: "http://url-typed-into-browser.io:80,http://internally-and-externally.reachable.io:80" - name: DJANGO_ADMIN_USERNAME value: "admin" - - name: DJANGO_ADMIN_PASSWORD - value: "admin" - name: DJANGO_ADMIN_EMAIL value: "admin@example.com" - name: DEBUG - value: "True" + value: "False" restartPolicy: Always --- apiVersion: v1 @@ -129,7 +131,6 @@ spec: port: 8000 targetPort: 8000 --- -# If you aren't automatically provisioning PVCs (i.e. with Longhorn, you'll need to also create the PV's) apiVersion: v1 kind: PersistentVolumeClaim metadata: diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 00000000..2c228634 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Backup AdventureLog database, media volume, and environment file. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$SCRIPT_DIR" + +COMPOSE_FILE="${COMPOSE_FILE:-}" +BACKUP_DIR="${BACKUP_DIR:-backups}" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" + +if [[ -z "$COMPOSE_FILE" ]]; then + if [[ -f .env.aio ]] && [[ ! -f .env ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + elif [[ -f .env.aio ]] && [[ -f docker-compose.aio.yml ]] && [[ "${ADVENTURELOG_COMPOSE:-}" == "aio" ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + else + COMPOSE_FILE="docker-compose.yml" + fi +fi + +case "$COMPOSE_FILE" in + *docker-compose.aio.yml*) + ENV_FILE=".env.aio" + ;; + *) + ENV_FILE=".env" + ;; +esac + +COMPOSE=(docker compose -f "$COMPOSE_FILE") +if [[ -f "$ENV_FILE" ]]; then + COMPOSE=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE") +fi +DEST="$BACKUP_DIR/$TIMESTAMP" +mkdir -p "$DEST" + +resolve_compose_volume() { + local suffix="$1" + "${COMPOSE[@]}" volume ls -q | grep "${suffix}$" | head -n 1 +} + +MEDIA_VOLUME="$(resolve_compose_volume "adventurelog_media")" +if [[ -z "$MEDIA_VOLUME" ]]; then + MEDIA_VOLUME="$(resolve_compose_volume "media")" +fi + +echo "Backing up AdventureLog to $DEST" + +if [[ -f "$ENV_FILE" ]]; then + cp "$ENV_FILE" "$DEST/" +fi + +POSTGRES_USER="${POSTGRES_USER:-adventure}" +POSTGRES_DB="${POSTGRES_DB:-database}" + +if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + set -a + source "$ENV_FILE" + set +a +fi + +echo "Dumping database..." +"${COMPOSE[@]}" exec -T db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > "$DEST/database.sql" + +echo "Archiving media volume..." +if [[ -z "$MEDIA_VOLUME" ]]; then + echo "WARNING: media volume not found; skipping media backup" >&2 +else + docker run --rm \ + -v "${MEDIA_VOLUME}:/data:ro" \ + -v "$DEST:/backup" \ + alpine:3.21 \ + tar czf /backup/media.tar.gz -C /data . +fi + +echo "Backup complete: $DEST" diff --git a/scripts/install/lib/aio-config.sh b/scripts/install/lib/aio-config.sh new file mode 100755 index 00000000..7fec8952 --- /dev/null +++ b/scripts/install/lib/aio-config.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# AIO configuration wizard and .env.aio writer. +set -euo pipefail + +prompt_aio_core_config() { + print_step_header 2 "$WIZARD_TOTAL" "Core configuration (AIO)" + log_info "All-in-One uses a single URL and port." + echo "" + + local default_site="http://localhost:8015" + while true; do + SITE_URL="$(prompt_with_default "Public site URL" "$default_site")" + if validate_url "$SITE_URL"; then + break + fi + log_error "Enter a valid http:// or https:// URL" + done + SITE_URL="${SITE_URL%/}" + HOST_PORT="$(extract_port_from_url "$SITE_URL" "8015")" + local port_override + port_override="$(tui_input "Host port to bind [${HOST_PORT}]" "$HOST_PORT")" + HOST_PORT="${port_override:-$HOST_PORT}" + log_success "Site: $SITE_URL (port $HOST_PORT)" + + POSTGRES_PASSWORD="$(generate_secure_password 32)" + log_success "Generated secure database password" + + if tui_confirm "Change default admin credentials (admin/admin)?" "y"; then + DJANGO_ADMIN_USERNAME="$(prompt_with_default "Admin username" "admin")" + DJANGO_ADMIN_PASSWORD="$(generate_secure_password 24)" + DJANGO_ADMIN_EMAIL="$(prompt_with_default "Admin email" "admin@example.com")" + else + DJANGO_ADMIN_USERNAME="admin" + DJANGO_ADMIN_PASSWORD="admin" + DJANGO_ADMIN_EMAIL="admin@example.com" + log_warning "Using default admin/admin — change after first login" + fi + echo "" +} + +write_env_aio() { + local target="$ENV_FILE" + { + echo "# AdventureLog All-in-One — generated by installer" + echo "POSTGRES_PASSWORD=$(format_env_value "$POSTGRES_PASSWORD")" + echo "SITE_URL=$(format_env_value "$SITE_URL")" + echo "HOST_PORT=$(format_env_value "$HOST_PORT")" + echo "DJANGO_ADMIN_USERNAME=$(format_env_value "$DJANGO_ADMIN_USERNAME")" + echo "DJANGO_ADMIN_PASSWORD=$(format_env_value "$DJANGO_ADMIN_PASSWORD")" + echo "DJANGO_ADMIN_EMAIL=$(format_env_value "$DJANGO_ADMIN_EMAIL")" + } > "$target" + save_optional_env_to_file "$target" + log_success "Wrote $target" +} + +show_aio_review() { + local opt_count=0 + [[ -v OPTIONAL_ENV_LINES ]] && opt_count="${#OPTIONAL_ENV_LINES[@]}" + print_step_header 5 "$WIZARD_TOTAL" "Review configuration" + print_summary_row "Setup" "All-in-One (AIO)" + print_summary_row "Site URL" "$SITE_URL" + print_summary_row "Host port" "$HOST_PORT" + print_summary_row "PostGIS image" "$POSTGIS_IMAGE" + print_summary_row "Admin user" "$DJANGO_ADMIN_USERNAME" + print_summary_row "Optional vars" "$opt_count configured" + echo "" + if ! tui_confirm "Proceed with installation?" "y"; then + log_info "Installation cancelled." + exit 0 + fi +} + +save_credentials_file() { + if [[ "$DRY_RUN" == true ]]; then + return 0 + fi + if ! tui_confirm "Save credentials to credentials.txt (mode 600)?" "y"; then + return 0 + fi + cat > credentials.txt << EOF +AdventureLog credentials — $(date) +Site URL: ${SITE_URL} +Admin username: ${DJANGO_ADMIN_USERNAME} +Admin password: ${DJANGO_ADMIN_PASSWORD} +Database password: ${POSTGRES_PASSWORD} +EOF + chmod 600 credentials.txt + log_success "Saved credentials.txt (keep this secure)" +} + +print_aio_success() { + print_success_banner + log_success "Installation completed!" + echo "" + echo -e "${BOLD}Access:${NC} ${CYAN}${SITE_URL}${NC}" + echo "" + echo -e "${BOLD}Admin:${NC} ${GREEN}${DJANGO_ADMIN_USERNAME}${NC} / ${GREEN}${DJANGO_ADMIN_PASSWORD}${NC}" + echo "" + echo -e "${BOLD}Config:${NC} $(pwd)/${ENV_FILE}" + echo "" + echo -e "${BOLD}Management:${NC}" + echo -e " Re-run installer: curl -sSL https://get.adventurelog.app | bash" + echo -e " Update: bash deploy.sh --backup" + echo -e " Logs: docker logs ${LOG_CONTAINER} -f" + echo -e " Stop: docker compose -f ${COMPOSE_FILE} down" + echo "" +} + +run_aio_install_wizard() { + SETUP_TYPE="aio" + resolve_compose_settings + WIZARD_TOTAL=7 + prompt_aio_core_config + run_port_checks + run_optional_features_wizard + write_postgis_override + show_aio_review +} diff --git a/scripts/install/lib/checks.sh b/scripts/install/lib/checks.sh new file mode 100755 index 00000000..3b5bac62 --- /dev/null +++ b/scripts/install/lib/checks.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# System preflight checks. +set -euo pipefail + +detect_system_arch() { + local raw + raw="$(uname -m)" + case "$raw" in + aarch64|arm64|armv7l|armv8*) + SYSTEM_ARCH="arm" + USE_ARM_POSTGIS=true + POSTGIS_IMAGE="imresamu/postgis:16-3.5-alpine" + ;; + x86_64|amd64) + SYSTEM_ARCH="amd64" + USE_ARM_POSTGIS=false + POSTGIS_IMAGE="postgis/postgis:16-3.5" + ;; + *) + SYSTEM_ARCH="$raw" + USE_ARM_POSTGIS=false + POSTGIS_IMAGE="postgis/postgis:16-3.5" + ;; + esac +} + +verify_postgis_image() { + local image="$1" + if docker manifest inspect "$image" &>/dev/null; then + return 0 + fi + log_warning "Could not verify manifest for $image" + return 1 +} + +write_postgis_override() { + local target="${1:-docker-compose.override.yml}" + if [[ "$USE_ARM_POSTGIS" != true ]]; then + return 0 + fi + log_info "ARM architecture detected — using PostGIS image: $POSTGIS_IMAGE" + cat > "$target" << EOF +# Auto-generated by AdventureLog installer for ARM hosts +services: + db: + image: ${POSTGIS_IMAGE} +EOF + log_success "Wrote $target" +} + +check_dependencies() { + log_info "Checking system dependencies..." + local missing=() + command -v curl &>/dev/null || missing+=("curl") + command -v docker &>/dev/null || missing+=("docker") + if ! docker compose version &>/dev/null 2>&1 && ! command -v docker-compose &>/dev/null; then + missing+=("docker-compose") + fi + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing: ${missing[*]}" + echo "Install Docker: https://docs.docker.com/get-docker/" + exit 1 + fi + log_success "Dependencies OK" +} + +check_docker_status() { + if [[ "${DRY_RUN:-false}" == true ]]; then + log_info "[dry-run] Skipping Docker daemon check" + return 0 + fi + log_info "Checking Docker daemon..." + if ! docker info &>/dev/null; then + log_error "Docker is not running" + exit 1 + fi + if ! docker info 2>/dev/null | grep -q "Username:" && [[ "$(id -u)" -ne 0 ]]; then + if ! groups 2>/dev/null | grep -q docker; then + log_warning "User not in docker group — you may need sudo for docker commands" + fi + fi + log_success "Docker is running" +} + +check_memory() { + local avail_kb avail_mb + if [[ -r /proc/meminfo ]]; then + avail_kb="$(awk '/MemAvailable/ {print $2}' /proc/meminfo)" + if [[ -n "$avail_kb" && "$avail_kb" =~ ^[0-9]+$ ]]; then + avail_mb=$((avail_kb / 1024)) + if (( avail_mb < 2048 )); then + log_warning "Available RAM is ~${avail_mb}MB — first boot needs ~2GB for world data import" + if tui_confirm "Enable SKIP_WORLD_DATA=1 to skip geography import on first boot?" "y"; then + SKIP_WORLD_DATA="1" + append_env_line "SKIP_WORLD_DATA=1" + fi + fi + fi + elif [[ "$OSTYPE" == "darwin"* ]]; then + log_info "Could not auto-detect RAM on macOS — ensure at least 2GB is allocated to Docker" + fi +} + +check_disk_space() { + local target="${1:-.}" + local check_path="$target" + if [[ ! -e "$check_path" ]]; then + check_path="." + fi + local avail + if command -v df &>/dev/null; then + avail="$(df -BG "$check_path" 2>/dev/null | awk 'NR==2 {gsub(/G/,"",$4); print $4}')" || avail="" + if [[ -n "$avail" && "$avail" =~ ^[0-9]+$ ]] && (( avail < 5 )); then + log_warning "Less than 5GB free disk space in $check_path" + fi + fi +} + +check_port_in_use() { + local port="$1" + local name="$2" + local in_use=false + if command -v ss &>/dev/null; then + ss -ln 2>/dev/null | grep -q ":${port} " && in_use=true + elif command -v netstat &>/dev/null; then + netstat -ln 2>/dev/null | grep -q ":${port} " && in_use=true + fi + if [[ "$in_use" == true ]]; then + log_warning "Port $port ($name) appears to be in use" + tui_confirm "Continue anyway?" "n" || exit 0 + fi +} + +detect_existing_install() { + local dir="${1:-$INSTALL_DIR}" + if [[ -f "$dir/.env.aio" ]] || [[ -f "$dir/.env" ]]; then + return 0 + fi + if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qE '^(adventurelog-aio|adventurelog-frontend|adventurelog-backend)$'; then + return 0 + fi + return 1 +} + +detect_setup_type_from_install() { + local dir="${1:-$INSTALL_DIR}" + if [[ -f "$dir/.env.aio" ]]; then + SETUP_TYPE="aio" + elif [[ -f "$dir/.env" ]]; then + SETUP_TYPE="standard" + fi + resolve_compose_settings +} + +run_preflight() { + print_step_header 1 "$WIZARD_TOTAL" "System checks" + check_dependencies + check_docker_status + detect_system_arch + log_info "Architecture: $SYSTEM_ARCH (${POSTGIS_IMAGE})" + check_memory + check_disk_space "$INSTALL_DIR" +} + +run_port_checks() { + if [[ "$SETUP_TYPE" == "aio" ]]; then + check_port_in_use "$HOST_PORT" "AdventureLog" + else + check_port_in_use "$FRONTEND_PORT" "frontend" + check_port_in_use "$BACKEND_PORT" "backend" + fi +} + +prompt_postgis_override() { + if [[ "$USE_ARM_POSTGIS" == true ]]; then + return 0 + fi + if tui_confirm "Use custom PostGIS image? (default: $POSTGIS_IMAGE)" "n"; then + POSTGIS_IMAGE="$(prompt_with_default "PostGIS image" "$POSTGIS_IMAGE")" + USE_ARM_POSTGIS=true + fi +} diff --git a/scripts/install/lib/common.sh b/scripts/install/lib/common.sh new file mode 100755 index 00000000..e609b3aa --- /dev/null +++ b/scripts/install/lib/common.sh @@ -0,0 +1,204 @@ +#!/bin/bash +# Shared globals and utilities for AdventureLog installer. +set -euo pipefail + +APP_NAME="AdventureLog" +INSTALL_DIR="${INSTALL_DIR:-./adventurelog}" +ADVENTURELOG_REF="${ADVENTURELOG_REF:-main}" +GITHUB_RAW="https://raw.githubusercontent.com/seanmorley15/AdventureLog/${ADVENTURELOG_REF}" +DOCS_BASE="https://adventurelog.app/docs/configuration" + +# Setup type: aio | standard +SETUP_TYPE="${SETUP_TYPE:-aio}" +DRY_RUN="${DRY_RUN:-false}" +FORCE_INSTALL="${FORCE_INSTALL:-false}" + +# AIO configuration +declare -g SITE_URL="" +declare -g HOST_PORT="" +declare -g POSTGRES_PASSWORD="" +declare -g DJANGO_ADMIN_USERNAME="admin" +declare -g DJANGO_ADMIN_PASSWORD="" +declare -g DJANGO_ADMIN_EMAIL="admin@example.com" +declare -g SKIP_WORLD_DATA="" + +# Standard configuration +declare -g FRONTEND_ORIGIN="" +declare -g BACKEND_URL="" +declare -g FRONTEND_PORT="" +declare -g BACKEND_PORT="" + +# Platform +declare -g SYSTEM_ARCH="" +declare -g POSTGIS_IMAGE="" +declare -g USE_ARM_POSTGIS=false + +# Wizard state +declare -g WIZARD_STEP=0 +declare -g WIZARD_TOTAL=7 +declare -g REPO_ROOT="${REPO_ROOT:-}" + +# Compose +declare -g COMPOSE_FILE="" +declare -g ENV_FILE="" +declare -g LOG_CONTAINER="" + +resolve_compose_settings() { + if [[ "$SETUP_TYPE" == "aio" ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + ENV_FILE=".env.aio" + LOG_CONTAINER="adventurelog-aio" + else + COMPOSE_FILE="docker-compose.yml" + ENV_FILE=".env" + LOG_CONTAINER="adventurelog-backend" + fi +} + +generate_secure_password() { + local length=${1:-24} + if [[ ! -r "/dev/urandom" ]]; then + echo "ERROR: /dev/urandom not readable" >&2 + return 1 + fi + if command -v tr &>/dev/null; then + LC_ALL=C tr -dc 'A-Za-z0-9!#$%&*+-=?@^_' /dev/null | head -c "$length" 2>/dev/null + return 0 + fi + if command -v openssl &>/dev/null; then + openssl rand -base64 32 | tr -d "=+/" | cut -c1-"$length" + return 0 + fi + echo "ERROR: No suitable random generation method found" >&2 + return 1 +} + +validate_url() { + local url="$1" + [[ $url =~ ^https?://[a-zA-Z0-9.-]+(:[0-9]+)?(/.*)?$ ]] +} + +extract_port_from_url() { + local url="$1" + local default_port="$2" + if [[ $url =~ :([0-9]+)(/|$) ]]; then + echo "${BASH_REMATCH[1]}" + elif [[ $url =~ ^https:// ]]; then + echo "443" + elif [[ $url =~ ^http:// ]]; then + echo "${default_port:-80}" + else + echo "$default_port" + fi +} + +get_compose_cmd() { + if docker compose version &>/dev/null 2>&1; then + echo "docker compose" + else + echo "docker-compose" + fi +} + +format_env_value() { + local val="$1" + if [[ "$val" =~ ^[A-Za-z0-9_.,/@^+-]+$ ]]; then + printf '%s' "$val" + else + local escaped="${val//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + printf '"%s"' "$escaped" + fi +} + +compose_args() { + local cmd + cmd="$(get_compose_cmd)" + if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC2086 + echo $cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" + else + # shellcheck disable=SC2086 + echo $cmd -f "$COMPOSE_FILE" + fi +} + +run_compose() { + local cmd + cmd="$(get_compose_cmd)" + if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC2086 + $cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" + else + # shellcheck disable=SC2086 + $cmd -f "$COMPOSE_FILE" "$@" + fi +} + +detect_install_lib_dir() { + local script_path="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" + local candidate + if [[ -n "${INSTALLER_LIB_DIR:-}" && -f "${INSTALLER_LIB_DIR}/ui.sh" ]]; then + echo "$INSTALLER_LIB_DIR" + return 0 + fi + candidate="$(cd "$(dirname "$script_path")" && pwd)" + if [[ -f "$candidate/ui.sh" ]]; then + echo "$candidate" + return 0 + fi + candidate="$(cd "$(dirname "$script_path")/../.." && pwd)/scripts/install/lib" + if [[ -f "$candidate/ui.sh" ]]; then + echo "$candidate" + return 0 + fi + if [[ -f "./adventurelog/scripts/install/lib/ui.sh" ]]; then + echo "$(cd "./adventurelog/scripts/install/lib" && pwd)" + return 0 + fi + return 1 +} + +append_env_line() { + local line="$1" + local key="${line%%=*}" + local value="${line#*=}" + if [[ "$key" != "$line" ]]; then + OPTIONAL_ENV_LINES+=("${key}=$(format_env_value "$value")") + else + OPTIONAL_ENV_LINES+=("$line") + fi +} + +save_optional_env_to_file() { + local target="$1" + local count=0 + [[ -v OPTIONAL_ENV_LINES ]] && count="${#OPTIONAL_ENV_LINES[@]}" + if (( count == 0 )); then + return 0 + fi + { + echo "" + echo "# Optional features configured by AdventureLog installer" + for line in "${OPTIONAL_ENV_LINES[@]}"; do + echo "$line" + done + } >> "$target" +} + +load_existing_env() { + local file="$1" + if [[ ! -f "$file" ]]; then + return 0 + fi + # shellcheck disable=SC1090 + set -a + source "$file" + set +a + SITE_URL="${SITE_URL:-}" + HOST_PORT="${HOST_PORT:-}" + POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" + DJANGO_ADMIN_USERNAME="${DJANGO_ADMIN_USERNAME:-admin}" + DJANGO_ADMIN_PASSWORD="${DJANGO_ADMIN_PASSWORD:-admin}" + DJANGO_ADMIN_EMAIL="${DJANGO_ADMIN_EMAIL:-admin@example.com}" +} diff --git a/scripts/install/lib/deploy.sh b/scripts/install/lib/deploy.sh new file mode 100755 index 00000000..9ad38048 --- /dev/null +++ b/scripts/install/lib/deploy.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Deploy containers and wait for health. +set -euo pipefail + +validate_env_file() { + if [[ ! -f "scripts/validate-env.sh" ]]; then + log_warning "validate-env.sh not found — skipping validation" + return 0 + fi + log_info "Validating $ENV_FILE ..." + if ! bash scripts/validate-env.sh "$ENV_FILE"; then + log_error "Environment validation failed" + exit 1 + fi + log_success "Environment validated" +} + +print_pull_failure_help() { + echo "" >&2 + log_error "Failed to pull Docker images." + echo "" >&2 + echo "Common fixes:" >&2 + echo " • Check your internet connection" >&2 + echo " • If you see 'denied' from ghcr.io, wait a moment and retry" >&2 + echo " • Try: docker login ghcr.io (only if using a private registry token)" >&2 + echo " • Manual pull: docker compose --env-file ${ENV_FILE} -f ${COMPOSE_FILE} pull" >&2 + echo "" >&2 + echo "To build from source instead, clone the full AdventureLog repo and run:" >&2 + echo " docker compose -f docker-compose.aio.yml build && docker compose -f docker-compose.aio.yml up -d" >&2 + echo "" >&2 +} + +start_services() { + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would run: run_compose pull && run_compose up -d" + return 0 + fi + + log_info "Pulling Docker images (this may take a few minutes)..." + echo "" >&2 + if ! run_compose pull; then + print_pull_failure_help + exit 1 + fi + echo "" >&2 + log_success "Images pulled" + + log_info "Starting containers..." + if run_compose up -d --remove-orphans --wait 2>/dev/null; then + log_success "Containers started" + elif run_compose up -d --remove-orphans; then + log_success "Containers started (without --wait)" + else + log_error "Failed to start containers" + run_compose ps || true + exit 1 + fi +} + +wait_for_health() { + local url="${1:-$SITE_URL}" + local max_attempts="${2:-300}" + local attempt=1 + local health_url="${url%/}/health" + + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would wait for $health_url" + return 0 + fi + + log_info "Waiting for AdventureLog (up to $((max_attempts / 6)) minutes on first boot)..." + while (( attempt <= max_attempts )); do + if curl -fsS -o /dev/null "$health_url" 2>/dev/null; then + echo "" >&2 + log_success "Health check passed: $health_url" + return 0 + fi + local pct=$(( attempt * 100 / max_attempts )) + local frame='⠋' + case $(( attempt % 4 )) in + 1) frame='⠙' ;; + 2) frame='⠹' ;; + 3) frame='⠸' ;; + esac + printf "\r ${CYAN}${frame}${NC} Waiting for ${health_url} ${DIM}(${pct}%%)${NC} " >&2 + sleep 2 + ((attempt++)) || true + done + printf "\r\033[K" >&2 + log_warning "Health check timed out — service may still be starting (first boot imports world data)" + run_compose ps || true + log_info "Recent logs:" + docker logs "$LOG_CONTAINER" --tail 30 2>&1 || true +} + +cleanup_on_failure() { + if [[ -f ".env.aio.backup" ]]; then + mv .env.aio.backup .env.aio 2>/dev/null || true + fi + if [[ -f ".env.backup" ]]; then + mv .env.backup .env 2>/dev/null || true + fi + if [[ "$DRY_RUN" != true ]] && command -v docker &>/dev/null; then + run_compose down --remove-orphans 2>/dev/null || true + fi +} + +run_deploy_phase() { + print_step_header 6 "$WIZARD_TOTAL" "Deploy" + validate_env_file + start_services + print_step_header 7 "$WIZARD_TOTAL" "Health check" + if [[ "$SETUP_TYPE" == "aio" ]]; then + wait_for_health "$SITE_URL" 300 + else + wait_for_health "$FRONTEND_ORIGIN" 300 + fi +} diff --git a/scripts/install/lib/download.sh b/scripts/install/lib/download.sh new file mode 100755 index 00000000..36c2a6bb --- /dev/null +++ b/scripts/install/lib/download.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Download installer files from GitHub. +set -euo pipefail + +INSTALLER_LIB_FILES=( + common.sh ui.sh tui.sh checks.sh download.sh deploy.sh + aio-config.sh standard-config.sh manage.sh features.sh +) + +download_file() { + local url="$1" + local dest="$2" + local relpath="${url#*AdventureLog/${ADVENTURELOG_REF}/}" + if [[ -n "${REPO_ROOT:-}" && -f "${REPO_ROOT}/${relpath}" ]]; then + mkdir -p "$(dirname "$dest")" + cp "${REPO_ROOT}/${relpath}" "$dest" + return 0 + fi + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would download $url -> $dest" + return 0 + fi + mkdir -p "$(dirname "$dest")" + if ! curl -fsSL --connect-timeout 15 --max-time 60 "$url" -o "$dest"; then + log_error "Failed to download $url" + return 1 + fi +} + +bootstrap_installer_libs() { + local dest_dir="$1" + log_info "Downloading installer libraries..." + mkdir -p "$dest_dir" + local f + for f in "${INSTALLER_LIB_FILES[@]}"; do + download_file "${GITHUB_RAW}/scripts/install/lib/${f}" "${dest_dir}/${f}" + done + log_success "Installer libraries ready" +} + +copy_local_installer_libs() { + local src="$1" + local dest="$2" + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would copy installer libs to $dest" + return 0 + fi + mkdir -p "$dest" + local f + for f in "${INSTALLER_LIB_FILES[@]}"; do + if [[ -f "$src/$f" ]]; then + cp "$src/$f" "$dest/$f" + chmod +x "$dest/$f" 2>/dev/null || true + fi + done +} + +download_aio_toolkit() { + log_info "Downloading AIO deployment files..." + download_file "${GITHUB_RAW}/docker-compose.aio.yml" "docker-compose.aio.yml" + download_file "${GITHUB_RAW}/.env.aio.example" ".env.aio.example" + download_file "${GITHUB_RAW}/deploy.sh" "deploy.sh" + download_file "${GITHUB_RAW}/scripts/validate-env.sh" "scripts/validate-env.sh" + download_file "${GITHUB_RAW}/scripts/backup.sh" "scripts/backup.sh" + download_file "${GITHUB_RAW}/scripts/restore.sh" "scripts/restore.sh" + download_file "${GITHUB_RAW}/docker-compose.override.example.yml" "docker-compose.override.example.yml" || true + chmod +x deploy.sh scripts/validate-env.sh scripts/backup.sh scripts/restore.sh 2>/dev/null || true + if [[ -d "$(dirname "${BASH_SOURCE[0]}")" ]]; then + copy_local_installer_libs "$(dirname "${BASH_SOURCE[0]}")" "scripts/install/lib" + fi + log_success "AIO toolkit downloaded" +} + +download_standard_toolkit() { + log_info "Downloading standard deployment files..." + download_file "${GITHUB_RAW}/docker-compose.yml" "docker-compose.yml" + download_file "${GITHUB_RAW}/.env.example" ".env.example" + download_file "${GITHUB_RAW}/deploy.sh" "deploy.sh" + download_file "${GITHUB_RAW}/scripts/validate-env.sh" "scripts/validate-env.sh" + download_file "${GITHUB_RAW}/scripts/backup.sh" "scripts/backup.sh" + download_file "${GITHUB_RAW}/scripts/restore.sh" "scripts/restore.sh" + download_file "${GITHUB_RAW}/docker-compose.override.example.yml" "docker-compose.override.example.yml" || true + chmod +x deploy.sh scripts/validate-env.sh scripts/backup.sh scripts/restore.sh 2>/dev/null || true + if [[ -d "$(dirname "${BASH_SOURCE[0]}")" ]]; then + copy_local_installer_libs "$(dirname "${BASH_SOURCE[0]}")" "scripts/install/lib" + fi + log_success "Standard toolkit downloaded" +} + +ensure_install_directory() { + local dir="$INSTALL_DIR" + log_info "Install directory: $dir" + if [[ -d "$dir" ]] && [[ "$FORCE_INSTALL" != true ]]; then + if detect_existing_install "$dir"; then + return 0 + fi + log_warning "Directory exists" + tui_confirm "Continue and update files in existing directory?" "y" || exit 0 + fi + if [[ "$DRY_RUN" != true ]] || [[ ! -d "$dir" ]]; then + mkdir -p "$dir" + fi + cd "$dir" || exit 1 +} diff --git a/scripts/install/lib/features.sh b/scripts/install/lib/features.sh new file mode 100755 index 00000000..ecab3be0 --- /dev/null +++ b/scripts/install/lib/features.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# Optional feature configuration wizard modules. +set -euo pipefail + +feature_section_header() { + local name="$1" + local doc_slug="$2" + if declare -f tui_print_box &>/dev/null; then + tui_print_box "Configure: ${name}" "Documentation: ${DOCS_BASE}/${doc_slug}.html" + else + echo "" + log_header "Configure: $name" + log_info "Docs: ${DOCS_BASE}/${doc_slug}.html" + echo "" + fi +} + +configure_registration_auth() { + feature_section_header "Registration & Auth" "disable_registration" + if tui_confirm "Disable new user registration?" "n"; then + append_env_line "DISABLE_REGISTRATION=True" + local msg + msg="$(tui_input "Registration disabled message (optional)" "Registration is disabled for this instance of AdventureLog.")" + [[ -n "$msg" ]] && append_env_line "DISABLE_REGISTRATION_MESSAGE=${msg}" + fi + if tui_confirm "Allow social signup when registration is disabled?" "n"; then + append_env_line "SOCIALACCOUNT_ALLOW_SIGNUP=True" + fi + log_warning "Social-only login: do not enable unless SSO is already configured." + log_info "SSO can be set up after AdventureLog starts — Django admin → Social applications." + if tui_confirm "Force social-only login (disable password login)?" "n"; then + append_env_line "FORCE_SOCIALACCOUNT_LOGIN=True" + fi + if tui_confirm "Configure email verification for new accounts?" "n"; then + local verify + if ! verify="$(tui_choose "Email verification level" "optional" "mandatory")"; then + verify="optional" + fi + append_env_line "ACCOUNT_EMAIL_VERIFICATION=${verify}" + fi +} + +configure_email() { + feature_section_header "Email" "email" + append_env_line "EMAIL_BACKEND=email" + append_env_line "EMAIL_HOST=$(prompt_with_default "SMTP host" "smtp.gmail.com")" + if tui_confirm "Use TLS?" "y"; then + append_env_line "EMAIL_USE_TLS=True" + append_env_line "EMAIL_PORT=587" + else + append_env_line "EMAIL_USE_TLS=False" + append_env_line "EMAIL_USE_SSL=True" + append_env_line "EMAIL_PORT=465" + fi + append_env_line "EMAIL_HOST_USER=$(prompt_with_default "SMTP username" "")" + append_env_line "EMAIL_HOST_PASSWORD=$(tui_password "SMTP password")" + append_env_line "DEFAULT_FROM_EMAIL=$(prompt_with_default "From email" "noreply@example.com")" +} + +configure_s3() { + feature_section_header "S3 Media Storage" "s3_storage" + log_warning "Choose storage at install time — migrating later is difficult." + append_env_line "MEDIA_STORAGE=s3" + append_env_line "AWS_ACCESS_KEY_ID=$(prompt_with_default "AWS access key ID" "")" + append_env_line "AWS_SECRET_ACCESS_KEY=$(tui_password "AWS secret access key")" + append_env_line "AWS_STORAGE_BUCKET_NAME=$(prompt_with_default "Bucket name" "")" + local endpoint + endpoint="$(tui_input "S3 endpoint URL (empty for AWS)" "")" + [[ -n "$endpoint" ]] && append_env_line "AWS_S3_ENDPOINT_URL=${endpoint}" + append_env_line "AWS_S3_REGION_NAME=$(prompt_with_default "Region" "auto")" + append_env_line "AWS_S3_ADDRESSING_STYLE=path" + append_env_line "AWS_S3_SIGNATURE_VERSION=s3v4" + local custom_domain + custom_domain="$(tui_input "Custom CDN domain (optional)" "")" + [[ -n "$custom_domain" ]] && append_env_line "AWS_S3_CUSTOM_DOMAIN=${custom_domain}" +} + +configure_google_maps() { + feature_section_header "Google Maps" "google_maps_integration" + local key + key="$(prompt_with_default "Google Maps API key" "")" + [[ -n "$key" ]] && append_env_line "GOOGLE_MAPS_API_KEY=${key}" +} + +configure_strava() { + feature_section_header "Strava Integration" "strava_integration" + append_env_line "STRAVA_CLIENT_ID=$(prompt_with_default "Strava client ID" "")" + append_env_line "STRAVA_CLIENT_SECRET=$(tui_password "Strava client secret")" +} + +configure_analytics() { + feature_section_header "Umami Analytics" "analytics" + append_env_line "PUBLIC_UMAMI_SRC=$(prompt_with_default "Umami script URL" "https://cloud.umami.is/script.js")" + append_env_line "PUBLIC_UMAMI_WEBSITE_ID=$(prompt_with_default "Umami website ID" "")" +} + +configure_performance() { + feature_section_header "Performance & Rate Limits" "advanced_configuration" + local workers + workers="$(prompt_with_default "Gunicorn workers (1 for small hosts)" "2")" + append_env_line "GUNICORN_WORKERS=${workers}" + if tui_confirm "Enable rate limits (recommended for production)?" "n"; then + append_env_line "ENABLE_RATE_LIMITS=True" + fi +} + +configure_debug() { + feature_section_header "Debug mode" "advanced_configuration" + if tui_confirm "Enable DEBUG mode? (not for production)" "n"; then + append_env_line "DEBUG=True" + fi +} + +run_optional_features_wizard() { + print_step_header 4 "$WIZARD_TOTAL" "Optional features" + if [[ "${DRY_RUN:-false}" == true ]]; then + log_info "[dry-run] Skipping optional features" + return 0 + fi + log_info "Configure optional integrations, or pick Done when finished." + echo "" + local groups=( + "Registration & Auth" + "Email" + "S3 Media Storage" + "Google Maps" + "Strava" + "Umami Analytics" + "Performance" + "Debug mode" + "Done — continue" + ) + local choice + while true; do + if ! choice="$(tui_choose "Optional features" "${groups[@]}")"; then + break + fi + case "$choice" in + "Registration & Auth") configure_registration_auth ;; + "Email") configure_email ;; + "S3 Media Storage") configure_s3 ;; + "Google Maps") configure_google_maps ;; + "Strava") configure_strava ;; + "Umami Analytics") configure_analytics ;; + "Performance") configure_performance ;; + "Debug mode") configure_debug ;; + "Done — continue"|*) break ;; + esac + done + if [[ -n "$SKIP_WORLD_DATA" ]]; then + append_env_line "SKIP_WORLD_DATA=1" + fi +} diff --git a/scripts/install/lib/main.sh b/scripts/install/lib/main.sh new file mode 100755 index 00000000..ca4f009a --- /dev/null +++ b/scripts/install/lib/main.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Main install orchestration. +set -euo pipefail + +choose_setup_type() { + if [[ "${DRY_RUN:-false}" == true ]]; then + SETUP_TYPE="aio" + resolve_compose_settings + return 0 + fi + print_step_header 2 7 "Choose setup type" + local choice + if ! choice="$(tui_choose "Select installation type" \ + "All-in-One (recommended — single URL, minimal config)" \ + "Standard (advanced — separate frontend and backend)")"; then + choice="All-in-One (recommended — single URL, minimal config)" + fi + case "$choice" in + Standard*) + SETUP_TYPE="standard" + ;; + *) + SETUP_TYPE="aio" + ;; + esac + resolve_compose_settings + log_info "Selected: $SETUP_TYPE" +} + +run_fresh_install() { + print_screen "Starting installation — $(date)" + init_tui + run_preflight + + if [[ -z "${SETUP_TYPE:-}" ]] || [[ "$SETUP_TYPE" == "aio" && -z "${SITE_URL:-}" ]]; then + choose_setup_type + fi + + ensure_install_directory + + if [[ "$SETUP_TYPE" == "aio" ]]; then + download_aio_toolkit + run_aio_install_wizard + write_env_aio + else + download_standard_toolkit + run_standard_install_wizard + write_env_standard + fi + + trap 'cleanup_on_failure; print_failure_message; exit 1' ERR + run_deploy_phase + save_credentials_file + + if [[ "$SETUP_TYPE" == "aio" ]]; then + print_aio_success + else + print_standard_success + fi +} + +run_installer_main() { + if [[ "$FORCE_INSTALL" != true ]]; then + if [[ -d "$INSTALL_DIR" ]] && detect_existing_install "$INSTALL_DIR"; then + init_tui + if [[ "${ADVENTURELOG_MANAGE:-}" == "1" ]] || tui_confirm "Existing AdventureLog install detected. Open management menu?" "y"; then + INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" + run_management_menu + return 0 + fi + fi + if [[ "${ADVENTURELOG_MANAGE:-}" == "1" ]]; then + init_tui + run_management_menu + return 0 + fi + fi + + run_fresh_install +} diff --git a/scripts/install/lib/manage.sh b/scripts/install/lib/manage.sh new file mode 100755 index 00000000..e499647e --- /dev/null +++ b/scripts/install/lib/manage.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# Management mode for existing installs. +set -euo pipefail + +enter_install_dir() { + if [[ -d "$INSTALL_DIR" ]]; then + cd "$INSTALL_DIR" || exit 1 + fi + detect_setup_type_from_install "." + resolve_compose_settings + load_existing_env "$ENV_FILE" +} + +mgmt_check_status() { + local compose health_url + compose="$(compose_args)" + echo "" + log_header "Status" + $compose ps 2>/dev/null || docker ps -a --filter "name=adventurelog" + echo "" + if [[ "$SETUP_TYPE" == "aio" ]]; then + health_url="${SITE_URL:-http://localhost:8015}/health" + else + health_url="${FRONTEND_ORIGIN:-http://localhost:8015}/health" + fi + if curl -fsS -o /dev/null "$health_url" 2>/dev/null; then + log_success "Health OK: $health_url" + else + log_warning "Health check failed: $health_url" + fi + tui_press_enter +} + +mgmt_update() { + local backup_flag="" + if tui_confirm "Create backup before update?" "y"; then + backup_flag="--backup" + fi + if [[ -f deploy.sh ]]; then + # shellcheck disable=SC2086 + COMPOSE_FILE="$COMPOSE_FILE" bash deploy.sh $backup_flag + else + local compose + compose="$(compose_args)" + $compose pull + $compose up -d --wait 2>/dev/null || $compose up -d + fi + log_success "Update complete" + tui_press_enter +} + +mgmt_reconfigure() { + log_info "Reconfigure optional features and core settings" + if [[ -f "$ENV_FILE" ]]; then + cp "$ENV_FILE" "${ENV_FILE}.backup.$(date +%Y%m%d-%H%M%S)" + fi + OPTIONAL_ENV_LINES=() + if [[ "$SETUP_TYPE" == "aio" ]]; then + prompt_aio_core_config + run_optional_features_wizard + write_env_aio + else + prompt_standard_core_config + run_optional_features_wizard + write_env_standard + fi + validate_env_file + if tui_confirm "Restart services to apply changes?" "y"; then + local compose + compose="$(compose_args)" + $compose up -d --remove-orphans + fi + log_success "Configuration updated" + tui_press_enter +} + +mgmt_backup() { + if [[ -f scripts/backup.sh ]]; then + COMPOSE_FILE="$COMPOSE_FILE" bash scripts/backup.sh + else + log_error "scripts/backup.sh not found" + fi + tui_press_enter +} + +mgmt_restore() { + local backups dir choice + if [[ ! -d backups ]]; then + log_error "No backups/ directory found" + tui_press_enter + return + fi + mapfile -t backups < <(find backups -mindepth 1 -maxdepth 1 -type d | sort -r) + if [[ ${#backups[@]} -eq 0 ]]; then + log_error "No backups found" + tui_press_enter + return + fi + echo "Available backups:" + local i=1 + for dir in "${backups[@]}"; do + echo " [$i] $dir" + ((i++)) || true + done + read -r -p "Select backup number: " choice + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#backups[@]} )); then + dir="${backups[$((choice - 1))]}" + if tui_confirm "Restore from $dir? This will overwrite current data." "n"; then + COMPOSE_FILE="$COMPOSE_FILE" bash scripts/restore.sh "$dir" + fi + else + log_error "Invalid selection" + fi + tui_press_enter +} + +mgmt_logs() { + if docker logs "$LOG_CONTAINER" --tail 50 2>/dev/null; then + echo "" + if tui_confirm "Follow logs (Ctrl+C to stop)?" "n"; then + docker logs "$LOG_CONTAINER" --follow + fi + else + local compose + compose="$(compose_args)" + $compose logs --tail 50 + fi +} + +mgmt_restart() { + local compose + compose="$(compose_args)" + $compose restart + log_success "Services restarted" + tui_press_enter +} + +mgmt_uninstall() { + if ! tui_confirm "Stop containers and remove volumes? This deletes all data." "n"; then + return + fi + local compose + compose="$(compose_args)" + $compose down -v --remove-orphans + if tui_confirm "Also delete install directory $(pwd)?" "n"; then + local install_path + install_path="$(pwd)" + cd .. || exit 1 + rm -rf "$install_path" + fi + log_success "Uninstalled" +} + +run_management_menu() { + enter_install_dir + print_screen "AdventureLog Manager — $(pwd)" + log_info "Setup: $SETUP_TYPE | Compose: $COMPOSE_FILE" + echo "" + while true; do + local choice + choice="$(tui_choose "What would you like to do?" \ + "Check status & health" \ + "Update to latest images" \ + "Edit configuration" \ + "Backup now" \ + "Restore from backup" \ + "View logs" \ + "Restart services" \ + "Uninstall" \ + "Exit")" || break + case "$choice" in + "Check status & health") mgmt_check_status ;; + "Update to latest images") mgmt_update ;; + "Edit configuration") mgmt_reconfigure ;; + "Backup now") mgmt_backup ;; + "Restore from backup") mgmt_restore ;; + "View logs") mgmt_logs ;; + "Restart services") mgmt_restart ;; + "Uninstall") mgmt_uninstall; break ;; + "Exit"|*) break ;; + esac + print_screen "AdventureLog Manager" + done +} diff --git a/scripts/install/lib/standard-config.sh b/scripts/install/lib/standard-config.sh new file mode 100755 index 00000000..c8ac2da4 --- /dev/null +++ b/scripts/install/lib/standard-config.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Standard multi-container configuration wizard. +set -euo pipefail + +prompt_standard_core_config() { + print_step_header 2 "$WIZARD_TOTAL" "Core configuration (Standard)" + log_info "Standard setup uses separate frontend and backend URLs." + echo "" + + local default_frontend="http://localhost:8015" + while true; do + FRONTEND_ORIGIN="$(prompt_with_default "Frontend URL" "$default_frontend")" + if validate_url "$FRONTEND_ORIGIN"; then + FRONTEND_PORT="$(extract_port_from_url "$FRONTEND_ORIGIN" "8015")" + break + fi + log_error "Invalid frontend URL" + done + + local default_backend="http://localhost:8016" + while true; do + BACKEND_URL="$(prompt_with_default "Backend URL" "$default_backend")" + if validate_url "$BACKEND_URL"; then + BACKEND_PORT="$(extract_port_from_url "$BACKEND_URL" "8016")" + break + fi + log_error "Invalid backend URL" + done + + POSTGRES_PASSWORD="$(generate_secure_password 32)" + if tui_confirm "Change default admin credentials (admin/admin)?" "y"; then + DJANGO_ADMIN_USERNAME="$(prompt_with_default "Admin username" "admin")" + DJANGO_ADMIN_PASSWORD="$(generate_secure_password 24)" + DJANGO_ADMIN_EMAIL="$(prompt_with_default "Admin email" "admin@example.com")" + else + DJANGO_ADMIN_USERNAME="admin" + DJANGO_ADMIN_PASSWORD="admin" + DJANGO_ADMIN_EMAIL="admin@example.com" + fi + log_success "Frontend: $FRONTEND_ORIGIN Backend: $BACKEND_URL" + echo "" +} + +write_env_standard() { + local target="$ENV_FILE" + local secret_key + secret_key="$(generate_secure_password 50)" + if [[ -f .env.example ]]; then + cp .env.example "$target" + local tmp="${target}.tmp" + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + POSTGRES_PASSWORD=*) echo "POSTGRES_PASSWORD=$(format_env_value "$POSTGRES_PASSWORD")" ;; + DJANGO_ADMIN_PASSWORD=*) echo "DJANGO_ADMIN_PASSWORD=$(format_env_value "$DJANGO_ADMIN_PASSWORD")" ;; + DJANGO_ADMIN_USERNAME=*) echo "DJANGO_ADMIN_USERNAME=$(format_env_value "$DJANGO_ADMIN_USERNAME")" ;; + DJANGO_ADMIN_EMAIL=*) echo "DJANGO_ADMIN_EMAIL=$(format_env_value "$DJANGO_ADMIN_EMAIL")" ;; + SECRET_KEY=*) echo "SECRET_KEY=$(format_env_value "$secret_key")" ;; + ORIGIN=*) echo "ORIGIN=$(format_env_value "$FRONTEND_ORIGIN")" ;; + PUBLIC_URL=*) echo "PUBLIC_URL=$(format_env_value "$BACKEND_URL")" ;; + CSRF_TRUSTED_ORIGINS=*) echo "CSRF_TRUSTED_ORIGINS=$(format_env_value "${FRONTEND_ORIGIN},${BACKEND_URL}")" ;; + FRONTEND_URL=*) echo "FRONTEND_URL=$(format_env_value "$FRONTEND_ORIGIN")" ;; + FRONTEND_PORT=*) echo "FRONTEND_PORT=$(format_env_value "$FRONTEND_PORT")" ;; + BACKEND_PORT=*) echo "BACKEND_PORT=$(format_env_value "$BACKEND_PORT")" ;; + *) echo "$line" ;; + esac + done < "$target" > "$tmp" + mv "$tmp" "$target" + else + { + echo "PUBLIC_SERVER_URL=http://server:8000" + echo "ORIGIN=$(format_env_value "$FRONTEND_ORIGIN")" + echo "BODY_SIZE_LIMIT=Infinity" + echo "FRONTEND_PORT=$(format_env_value "$FRONTEND_PORT")" + echo "PGHOST=db" + echo "POSTGRES_DB=database" + echo "POSTGRES_USER=adventure" + echo "POSTGRES_PASSWORD=$(format_env_value "$POSTGRES_PASSWORD")" + echo "SECRET_KEY=$(format_env_value "$secret_key")" + echo "DJANGO_ADMIN_USERNAME=$(format_env_value "$DJANGO_ADMIN_USERNAME")" + echo "DJANGO_ADMIN_PASSWORD=$(format_env_value "$DJANGO_ADMIN_PASSWORD")" + echo "DJANGO_ADMIN_EMAIL=$(format_env_value "$DJANGO_ADMIN_EMAIL")" + echo "PUBLIC_URL=$(format_env_value "$BACKEND_URL")" + echo "CSRF_TRUSTED_ORIGINS=$(format_env_value "${FRONTEND_ORIGIN},${BACKEND_URL}")" + echo "DEBUG=False" + echo "FRONTEND_URL=$(format_env_value "$FRONTEND_ORIGIN")" + echo "BACKEND_PORT=$(format_env_value "$BACKEND_PORT")" + } > "$target" + fi + save_optional_env_to_file "$target" + log_success "Wrote $target" +} + +show_standard_review() { + print_step_header 5 "$WIZARD_TOTAL" "Review configuration" + print_summary_row "Setup" "Standard (multi-container)" + print_summary_row "Frontend" "$FRONTEND_ORIGIN" + print_summary_row "Backend" "$BACKEND_URL" + print_summary_row "PostGIS image" "$POSTGIS_IMAGE" + echo "" + tui_confirm "Proceed with installation?" "y" || exit 0 +} + +print_standard_success() { + print_success_banner + log_success "Installation completed!" + echo -e "${BOLD}Frontend:${NC} ${CYAN}${FRONTEND_ORIGIN}${NC}" + echo -e "${BOLD}Backend:${NC} ${CYAN}${BACKEND_URL}${NC}" + echo -e "${BOLD}Admin:${NC} ${GREEN}${DJANGO_ADMIN_USERNAME}${NC} / ${GREEN}${DJANGO_ADMIN_PASSWORD}${NC}" + echo "" + echo -e "Update: bash deploy.sh --backup" + echo "" +} + +run_standard_install_wizard() { + SETUP_TYPE="standard" + resolve_compose_settings + WIZARD_TOTAL=7 + prompt_standard_core_config + run_port_checks + run_optional_features_wizard + write_postgis_override + show_standard_review +} diff --git a/scripts/install/lib/tui.sh b/scripts/install/lib/tui.sh new file mode 100755 index 00000000..8b554359 --- /dev/null +++ b/scripts/install/lib/tui.sh @@ -0,0 +1,433 @@ +#!/bin/bash +# Cross-distro TUI: full gum (real terminals) | styled bash + gum cosmetics (IDE) | plain fallback. +set -euo pipefail + +TUI_BACKEND="bash" +GUM_STYLE=false + +has_gum() { + command -v gum &>/dev/null +} + +# Full gum choose/confirm/input uses alt-screen — broken in IDE integrated terminals. +terminal_supports_gum_interactive() { + has_gum || return 1 + [[ -t 0 && -t 1 ]] || return 1 + [[ -n "${TERM:-}" && "${TERM:-}" != "dumb" ]] || return 1 + case "${TERM_PROGRAM:-}" in + vscode|Visual\ Studio\ Code) return 1 ;; + esac + [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" ]] && return 1 + [[ -n "${CURSOR_TRACE_ID:-}" ]] && return 1 + [[ -n "${CURSOR_AGENT:-}" ]] && return 1 + return 0 +} + +detect_tui_backend() { + if [[ -n "${ADVENTURELOG_TUI:-}" ]]; then + TUI_BACKEND="$ADVENTURELOG_TUI" + return 0 + fi + if terminal_supports_gum_interactive; then + TUI_BACKEND="gum" + elif has_gum; then + TUI_BACKEND="styled" + elif command -v whiptail &>/dev/null; then + TUI_BACKEND="whiptail" + elif command -v dialog &>/dev/null; then + TUI_BACKEND="dialog" + else + TUI_BACKEND="bash" + fi +} + +offer_gum_install() { + if has_gum; then + return 0 + fi + tui_print_box "Enhanced UI" "Install gum for styled menus, spinners, and progress bars." + if ! tui_confirm_bash "Install gum now?" "y"; then + log_info "Continuing with built-in styled UI." + return 1 + fi + if [[ "$OSTYPE" == "darwin"* ]] && command -v brew &>/dev/null; then + log_info "Installing gum via Homebrew..." + brew install gum + elif command -v apt-get &>/dev/null; then + log_info "Installing gum via apt (may require sudo)..." + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg + echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null + sudo apt-get update -qq + sudo apt-get install -y gum + else + log_warning "Automatic gum install not supported on this OS." + log_info "See https://github.com/charmbracelet/gum#installation" + return 1 + fi + has_gum +} + +init_tui() { + GUM_STYLE=false + TUI_BACKEND="bash" + if has_gum; then + GUM_STYLE=true + fi + if [[ "$DRY_RUN" == true ]] || [[ "${ADVENTURELOG_SKIP_GUM:-}" == "1" ]]; then + TUI_BACKEND="bash" + return 0 + fi + if ! has_gum && [[ "${ADVENTURELOG_SKIP_GUM:-}" != "1" ]]; then + offer_gum_install && GUM_STYLE=true + fi + detect_tui_backend + case "$TUI_BACKEND" in + gum) log_info "UI mode: interactive gum" ;; + styled) log_info "UI mode: styled terminal (IDE-safe)" ;; + *) log_info "UI mode: classic text" ;; + esac +} + +# ── Visual helpers (safe in all terminals) ────────────────────────────────── + +tui_styled() { + local fg="${1:-212}" + shift + if [[ "$GUM_STYLE" == true ]]; then + gum style --foreground "$fg" "$@" + else + echo -e "${PURPLE}${BOLD}$*${NC}" + fi +} + +tui_print_box() { + local title="$1" + local body="$2" + echo "" >&2 + if [[ "$GUM_STYLE" == true ]]; then + gum join --align left --vertical \ + "$(gum style --border rounded --border-foreground 212 --padding "0 1" --bold "$title")" \ + "$(gum style --border rounded --border-foreground 240 --padding "0 1" "$body")" >&2 + else + local width=58 + echo -e "${CYAN}╭$(printf '─%.0s' $(seq 1 "$width"))╮${NC}" >&2 + echo -e "${CYAN}│${NC} ${BOLD}${title}${NC}" >&2 + echo -e "${CYAN}├$(printf '─%.0s' $(seq 1 "$width"))┤${NC}" >&2 + while IFS= read -r line; do + echo -e "${CYAN}│${NC} ${line}" >&2 + done <<< "$body" + echo -e "${CYAN}╰$(printf '─%.0s' $(seq 1 "$width"))╯${NC}" >&2 + fi + echo "" >&2 +} + +tui_progress_bar() { + local step="$1" + local total="$2" + local label="$3" + local width=24 + local filled=$(( step * width / total )) + local empty=$(( width - filled )) + local bar + bar="$(printf '█%.0s' $(seq 1 "$filled" 2>/dev/null || true))$(printf '░%.0s' $(seq 1 "$empty" 2>/dev/null || true))" + if [[ "$GUM_STYLE" == true ]]; then + if ! gum join --horizontal \ + "$(gum style --foreground 212 "[${bar}]")" \ + "$(gum style --bold " Step ${step}/${total}")" \ + "$(gum style --foreground 245 " ${label}")" >&2 2>/dev/null; then + echo -e "${CYAN}[${bar}]${NC} ${BOLD}Step ${step}/${total}${NC} ${DIM}${label}${NC}" >&2 + fi + else + echo -e "${CYAN}[${bar}]${NC} ${BOLD}Step ${step}/${total}${NC} ${DIM}${label}${NC}" >&2 + fi + echo "" >&2 +} + +# ── Prompts ───────────────────────────────────────────────────────────────── + +tui_confirm_bash() { + local prompt="$1" + local default="${2:-n}" + local hint="[y/N]" + [[ "$default" == "y" ]] && hint="[Y/n]" + if [[ "$TUI_BACKEND" == "styled" ]] || [[ "$TUI_BACKEND" == "gum" ]]; then + tui_print_box "Confirm" "$prompt" + fi + echo "" >&2 + read -r -p "$(echo -e "${BOLD}?${NC} ${prompt} ${DIM}${hint}${NC}: ")" reply + reply="${reply:-$default}" + [[ "$reply" =~ ^[Yy] ]] +} + +tui_confirm() { + local prompt="$1" + local default="${2:-n}" + if [[ "${DRY_RUN:-false}" == true ]]; then + [[ "$default" == "y" ]] + return + fi + case "$TUI_BACKEND" in + gum) + local gum_rc=0 + if [[ "$default" == "y" ]]; then + gum confirm "$prompt" --affirmative "Yes" --negative "No" --default=true 2>/dev/null || gum_rc=$? + else + gum confirm "$prompt" --affirmative "Yes" --negative "No" --default=false 2>/dev/null || gum_rc=$? + fi + if [[ $gum_rc -eq 0 ]]; then return 0; fi + if [[ $gum_rc -eq 1 ]]; then return 1; fi + tui_confirm_bash "$prompt" "$default" + ;; + whiptail) + if whiptail --yesno "$prompt" 12 70; then return 0; else return 1; fi + ;; + dialog) + if dialog --yesno "$prompt" 12 70; then return 0; else return 1; fi + ;; + *) + tui_confirm_bash "$prompt" "$default" + ;; + esac +} + +tui_input_bash() { + local prompt="$1" + local default="${2:-}" + local value + if [[ "$TUI_BACKEND" == "styled" ]]; then + tui_print_box "Input" "$prompt" + fi + echo "" >&2 + if [[ -n "$default" ]]; then + read -r -p "$(echo -e "${BOLD}›${NC} ${prompt} ${DIM}[${default}]${NC}: ")" value + echo "${value:-$default}" + else + read -r -p "$(echo -e "${BOLD}›${NC} ${prompt}: ")" value + echo "$value" + fi +} + +tui_input() { + local prompt="$1" + local default="${2:-}" + if [[ "${DRY_RUN:-false}" == true ]]; then + echo "$default" + return 0 + fi + local value + case "$TUI_BACKEND" in + gum) + if [[ -n "$default" ]]; then + value="$(gum input --placeholder "$default" --prompt "$prompt " --value "$default" --width 60 2>/dev/null || true)" + else + value="$(gum input --prompt "$prompt " --width 60 2>/dev/null || true)" + fi + if [[ -n "$value" ]]; then + echo "$value" + return 0 + fi + tui_input_bash "$prompt" "$default" + ;; + whiptail) + whiptail --inputbox "$prompt" 10 70 "$default" 3>&1 1>&2 2>&3 + ;; + dialog) + dialog --inputbox "$prompt" 10 70 "$default" 3>&1 1>&2 2>&3 + ;; + *) + tui_input_bash "$prompt" "$default" + ;; + esac +} + +tui_password() { + local prompt="$1" + if [[ "${DRY_RUN:-false}" == true ]]; then + echo "dry-run-password" + return 0 + fi + local value + case "$TUI_BACKEND" in + gum) + value="$(gum input --password --prompt "$prompt " --width 60 2>/dev/null || true)" + if [[ -n "$value" ]]; then + echo "$value" + return 0 + fi + ;& + *) + if [[ "$TUI_BACKEND" == "styled" ]]; then + tui_print_box "Secret" "$prompt" + fi + echo "" >&2 + read -r -s -p "$(echo -e "${BOLD}🔒${NC} ${prompt}: ")" value + echo "" >&2 + echo "$value" + ;; + esac +} + +tui_choose_styled() { + local prompt="$1" + shift + local options=("$@") + local choice i default=1 + + tui_print_box "$prompt" "Enter the number for your choice." + echo "" >&2 + i=1 + for opt in "${options[@]}"; do + if (( i == default )); then + if [[ "$GUM_STYLE" == true ]]; then + echo " $(gum style --foreground 212 --bold "❯ ${i}") $(gum style --bold "$opt")" >&2 + else + echo -e " ${CYAN}${BOLD}❯ ${i}${NC} ${BOLD}${opt}${NC}" >&2 + fi + else + echo -e " ${DIM}${i}${NC} ${opt}" >&2 + fi + ((i++)) || true + done + echo "" >&2 + read -r -p "$(echo -e "${BOLD}›${NC} Choice ${DIM}[${default}]${NC}: ")" choice + choice="${choice:-$default}" + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#options[@]} )); then + echo "${options[$((choice - 1))]}" + return 0 + fi + return 1 +} + +tui_choose() { + local prompt="$1" + shift + local options=("$@") + local choice result + if [[ "${DRY_RUN:-false}" == true ]]; then + echo "${options[0]}" + return 0 + fi + case "$TUI_BACKEND" in + gum) + if result="$(gum choose --header "$prompt" --cursor "❯ " --selected.foreground "212" "${options[@]}" 2>/dev/null)" && [[ -n "$result" ]]; then + echo "$result" + return 0 + fi + tui_choose_styled "$prompt" "${options[@]}" + ;; + styled|bash) + tui_choose_styled "$prompt" "${options[@]}" + ;; + whiptail|dialog) + local menu_args=() + local idx=1 + for opt in "${options[@]}"; do + menu_args+=("$idx" "$opt") + ((idx++)) || true + done + local cmd="whiptail" + [[ "$TUI_BACKEND" == "dialog" ]] && cmd="dialog" + choice=$($cmd --menu "$prompt" 20 78 12 "${menu_args[@]}" 3>&1 1>&2 2>&3) || return 1 + echo "${options[$((choice - 1))]}" + ;; + esac +} + +tui_spinner_bash() { + local title="$1" + shift + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + local tmp_out tmp_err + tmp_out="$(mktemp)" + tmp_err="$(mktemp)" + ("$@" >"$tmp_out" 2>"$tmp_err") & + local pid=$! + while kill -0 "$pid" 2>/dev/null; do + printf "\r ${CYAN}${frames[$i]}${NC} ${title}" >&2 + i=$(( (i + 1) % ${#frames[@]} )) + sleep 0.1 + done + wait "$pid" + local rc=$? + printf "\r\033[K" >&2 + if [[ $rc -eq 0 ]]; then + log_success "$title" >&2 + else + log_error "$title failed" >&2 + cat "$tmp_err" >&2 + fi + cat "$tmp_out" + rm -f "$tmp_out" "$tmp_err" + return $rc +} + +tui_spinner() { + local title="$1" + shift + case "$TUI_BACKEND" in + gum) + gum spin --spinner dot --spinner.foreground "212" --title "$title" -- "$@" 2>/dev/null || { + tui_spinner_bash "$title" "$@" + } + ;; + styled|bash) + tui_spinner_bash "$title" "$@" + ;; + *) + log_info "$title" + "$@" + ;; + esac +} + +tui_wait_progress() { + local message="$1" + local max="$2" + local attempt=0 + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + while (( attempt < max )); do + local pct=$(( attempt * 100 / max )) + local frame="${frames[$i]}" + printf "\r ${CYAN}${frame}${NC} ${message} ${DIM}(${pct}%%)${NC} " >&2 + i=$(( (i + 1) % ${#frames[@]} )) + sleep 2 + ((attempt++)) || true + done + printf "\r\033[K" >&2 +} + +tui_press_enter() { + local msg="${1:-Press Enter to continue...}" + echo "" >&2 + if [[ "$GUM_STYLE" == true ]]; then + gum style --foreground 245 "$msg" >&2 + else + echo -e "${DIM}${msg}${NC}" >&2 + fi + read -r -p "" _ +} + +prompt_with_default() { + local prompt="$1" + local default="$2" + local validator="${3:-}" + local value + while true; do + value="$(tui_input "$prompt" "$default")" + value="${value:-$default}" + if [[ -z "$validator" ]] || "$validator" "$value"; then + echo "$value" + return 0 + fi + log_error "Invalid value. Please try again." + done +} + +prompt_yes_no() { + local prompt="$1" + local default="${2:-n}" + tui_confirm "$prompt" "$default" +} diff --git a/scripts/install/lib/ui.sh b/scripts/install/lib/ui.sh new file mode 100755 index 00000000..560d19ac --- /dev/null +++ b/scripts/install/lib/ui.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# UI helpers: colors, banners, logging, step headers. +set -euo pipefail + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly PURPLE='\033[0;35m' +readonly CYAN='\033[0;36m' +readonly MAGENTA='\033[0;35m' +readonly BOLD='\033[1m' +readonly DIM='\033[2m' +readonly NC='\033[0m' + +log_info() { echo -e "${BLUE}ℹ $1${NC}"; } +log_success() { echo -e "${GREEN}✔ $1${NC}"; } +log_warning() { echo -e "${YELLOW}⚠ $1${NC}"; } +log_error() { echo -e "${RED}✖ $1${NC}"; } +log_header() { echo -e "${PURPLE}${BOLD}$1${NC}"; } + +print_banner() { + if declare -f has_gum &>/dev/null && has_gum && [[ "${GUM_STYLE:-false}" == true ]]; then + gum style \ + --border double \ + --border-foreground 212 \ + --align center \ + --width 65 \ + --padding "1 2" \ + --bold \ + "ADVENTURELOG" \ + "" \ + "Installer & Manager" \ + "The Ultimate Travel Companion" 2>/dev/null || print_banner_fallback + return + fi + print_banner_fallback +} + +print_banner_fallback() { + cat << 'EOF' +╔═════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ A D V E N T U R E L O G I N S T A L L E R ║ +║ ║ +║ The Ultimate Travel Companion ║ +╚═════════════════════════════════════════════════════════════════════════╝ +EOF +} + +print_manager_banner() { + cat << 'EOF' +╔═════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ A D V E N T U R E L O G M A N A G E R ║ +║ ║ +╚═════════════════════════════════════════════════════════════════════════╝ +EOF +} + +print_screen() { + local title="${1:-}" + if [[ -t 1 ]] && [[ -n "${TERM:-}" ]] && [[ "$TERM" != "dumb" ]]; then + clear 2>/dev/null || true + fi + echo "" + print_banner + echo "" + if [[ -n "$title" ]]; then + log_header "$title" + echo "" + fi +} + +print_step_header() { + local step="$1" + local total="$2" + local label="$3" + WIZARD_STEP="$step" + WIZARD_TOTAL="$total" + echo "" >&2 + if declare -f tui_progress_bar &>/dev/null; then + tui_progress_bar "$step" "$total" "$label" + else + log_header "Step ${step}/${total}: ${label}" + echo -e "${DIM}$(printf '─%.0s' {1..60})${NC}" + echo "" + fi +} + +print_divider() { + echo -e "${DIM}$(printf '─%.0s' {1..60})${NC}" +} + +print_summary_row() { + local key="$1" + local value="$2" + if [[ "${GUM_STYLE:-false}" == true ]] && has_gum; then + gum join --horizontal \ + "$(gum style --width 22 --bold "$key")" \ + "$(gum style "$value")" 2>/dev/null || \ + printf " ${BOLD}%-22s${NC} %s\n" "$key" "$value" + else + printf " ${BOLD}%-22s${NC} %s\n" "$key" "$value" + fi +} + +print_success_banner() { + echo "" + cat << 'EOF' +╔════════════════════════════════════════════════════════════════════════════╗ +║ A D V E N T U R E L O G I S R E A D Y F O R L A U N C H! ║ +╚════════════════════════════════════════════════════════════════════════════╝ +EOF + echo "" +} + +print_failure_message() { + echo "" + log_error "Operation failed!" + echo "" + echo "Troubleshooting:" + echo " 1. docker info" + echo " 2. docker compose -f $COMPOSE_FILE ps" + echo " 3. docker compose -f $COMPOSE_FILE logs" + echo " 4. bash scripts/validate-env.sh $ENV_FILE" + echo "" + echo "Support: https://github.com/seanmorley15/AdventureLog" +} diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 00000000..5cb7d236 --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# Restore AdventureLog from a backup created by scripts/backup.sh. +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + echo "Example: $0 backups/20260101-120000" >&2 + exit 1 +fi + +BACKUP_DIR="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$SCRIPT_DIR" + +if [[ ! -d "$BACKUP_DIR" ]]; then + echo "ERROR: Backup directory not found: $BACKUP_DIR" >&2 + exit 1 +fi + +COMPOSE_FILE="${COMPOSE_FILE:-}" +if [[ -z "$COMPOSE_FILE" ]]; then + if [[ -f .env.aio ]] && [[ ! -f .env ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + elif [[ -f .env.aio ]] && [[ -f docker-compose.aio.yml ]] && [[ "${ADVENTURELOG_COMPOSE:-}" == "aio" ]]; then + COMPOSE_FILE="docker-compose.aio.yml" + else + COMPOSE_FILE="docker-compose.yml" + fi +fi + +ENV_FILE="${ENV_FILE:-}" +case "$COMPOSE_FILE" in + *docker-compose.aio.yml*) + ENV_FILE="${ENV_FILE:-.env.aio}" + ;; + *) + ENV_FILE="${ENV_FILE:-.env}" + ;; +esac + +if [[ -f "$ENV_FILE" ]]; then + COMPOSE=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE") +else + COMPOSE=(docker compose -f "$COMPOSE_FILE") +fi + +resolve_compose_volume() { + local suffix="$1" + "${COMPOSE[@]}" volume ls -q | grep "${suffix}$" | head -n 1 +} + +MEDIA_VOLUME="$(resolve_compose_volume "adventurelog_media")" +if [[ -z "$MEDIA_VOLUME" ]]; then + MEDIA_VOLUME="$(resolve_compose_volume "media")" +fi + +POSTGRES_USER="${POSTGRES_USER:-adventure}" +POSTGRES_DB="${POSTGRES_DB:-database}" + +if [[ -f .env ]]; then + # shellcheck disable=SC1090 + set -a + source .env + set +a +elif [[ -f .env.aio ]]; then + # shellcheck disable=SC1090 + set -a + source .env.aio + set +a +fi + +echo "Stopping AdventureLog containers..." +"${COMPOSE[@]}" down + +if [[ -f "$BACKUP_DIR/.env" ]]; then + cp "$BACKUP_DIR/.env" .env +elif [[ -f "$BACKUP_DIR/.env.aio" ]]; then + cp "$BACKUP_DIR/.env.aio" .env.aio +fi + +echo "Starting database..." +"${COMPOSE[@]}" up -d db +sleep 5 + +if [[ -f "$BACKUP_DIR/database.sql" ]]; then + echo "Restoring database..." + "${COMPOSE[@]}" exec -T db psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" + cat "$BACKUP_DIR/database.sql" | "${COMPOSE[@]}" exec -T db psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" +fi + +if [[ -f "$BACKUP_DIR/media.tar.gz" ]]; then + if [[ -z "$MEDIA_VOLUME" ]]; then + echo "ERROR: media volume not found; cannot restore media" >&2 + exit 1 + fi + echo "Restoring media volume..." + docker run --rm \ + -v "${MEDIA_VOLUME}:/data" \ + -v "$(realpath "$BACKUP_DIR"):/backup:ro" \ + alpine:3.21 \ + sh -c "rm -rf /data/* && tar xzf /backup/media.tar.gz -C /data" +fi + +echo "Starting AdventureLog..." +"${COMPOSE[@]}" up -d + +echo "Restore complete." diff --git a/scripts/validate-env.sh b/scripts/validate-env.sh new file mode 100755 index 00000000..545fe435 --- /dev/null +++ b/scripts/validate-env.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# Validate AdventureLog .env configuration for common self-hosting mistakes. +set -uo pipefail + +ENV_FILE="${1:-.env}" +WARNINGS=0 +ERRORS=0 + +warn() { + echo "WARNING: $1" >&2 + WARNINGS=$((WARNINGS + 1)) +} + +error() { + echo "ERROR: $1" >&2 + ERRORS=$((ERRORS + 1)) +} + +if [[ ! -f "$ENV_FILE" ]]; then + error "Environment file not found: $ENV_FILE" + exit 1 +fi + +# shellcheck disable=SC1090 +set -a +source "$ENV_FILE" +set +a + +echo "Validating $ENV_FILE ..." + +IS_AIO_ENV=false +if [[ "$ENV_FILE" == *".env.aio"* ]] || [[ "$(basename "$ENV_FILE")" == ".env.aio" ]]; then + IS_AIO_ENV=true +fi + +if [[ "$IS_AIO_ENV" == true ]]; then + if [[ -z "${POSTGRES_PASSWORD:-}" ]]; then + error "POSTGRES_PASSWORD is required in $ENV_FILE" + fi + if [[ "${POSTGRES_PASSWORD:-changeme123}" == "changeme123" ]]; then + warn "Default POSTGRES_PASSWORD detected — change this for production" + fi + if [[ -n "${SITE_URL:-}" ]] && [[ "$SITE_URL" != http://* ]] && [[ "$SITE_URL" != https://* ]]; then + warn "SITE_URL should start with http:// or https://" + fi + if [[ "${DJANGO_ADMIN_PASSWORD:-admin}" == "admin" ]] && [[ "${DJANGO_ADMIN_USERNAME:-admin}" == "admin" ]]; then + warn "Default admin credentials (admin/admin) detected — change for production" + fi + if [[ "${MEDIA_STORAGE:-local}" == "s3" ]]; then + if [[ -z "${AWS_ACCESS_KEY_ID:-}" ]] || [[ -z "${AWS_SECRET_ACCESS_KEY:-}" ]] || [[ -z "${AWS_STORAGE_BUCKET_NAME:-}" ]]; then + error "MEDIA_STORAGE=s3 requires AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_STORAGE_BUCKET_NAME" + fi + fi + if [[ "${EMAIL_BACKEND:-}" == "email" ]]; then + if [[ -z "${EMAIL_HOST:-}" ]] || [[ -z "${EMAIL_HOST_USER:-}" ]] || [[ -z "${EMAIL_HOST_PASSWORD:-}" ]]; then + error "EMAIL_BACKEND=email requires EMAIL_HOST, EMAIL_HOST_USER, and EMAIL_HOST_PASSWORD" + fi + fi + if [[ "${DEBUG:-False}" == "True" ]] || [[ "${DEBUG:-False}" == "true" ]]; then + warn "DEBUG is enabled — disable for production" + fi + echo "" + if [[ $ERRORS -gt 0 ]]; then + echo "Validation failed: $ERRORS error(s), $WARNINGS warning(s)" + exit 1 + fi + if [[ $WARNINGS -gt 0 ]]; then + echo "Validation passed with $WARNINGS warning(s)" + exit 0 + fi + echo "Validation passed" + exit 0 +fi + +if [[ -z "${PUBLIC_SERVER_URL:-}" ]]; then + warn "PUBLIC_SERVER_URL is not set (default http://server:8000 is expected in Docker)" +elif [[ "$PUBLIC_SERVER_URL" == *"localhost:8016"* ]] || [[ "$PUBLIC_SERVER_URL" == *"127.0.0.1:8016"* ]]; then + error "PUBLIC_SERVER_URL points at the host-mapped backend port. Use http://server:8000 inside Docker." +elif [[ "$PUBLIC_SERVER_URL" != *":8000"* ]] && [[ "$PUBLIC_SERVER_URL" != "http://server:8000" ]]; then + warn "PUBLIC_SERVER_URL is '$PUBLIC_SERVER_URL' — standard Docker installs should use http://server:8000" +fi + +if [[ -n "${SITE_URL:-}" ]]; then + echo "SITE_URL is set — individual URL vars will be derived when not explicitly set." +fi + +if [[ -z "${CSRF_TRUSTED_ORIGINS:-}" ]] && [[ -z "${SITE_URL:-}" ]]; then + warn "CSRF_TRUSTED_ORIGINS is not set and SITE_URL is not set" +fi + +if [[ -n "${CSRF_TRUSTED_ORIGINS:-}" ]] && [[ -z "${SITE_URL:-}" ]]; then + IFS=',' read -ra ORIGINS <<< "$CSRF_TRUSTED_ORIGINS" + if [[ -n "${ORIGIN:-}" ]] && [[ ! "$CSRF_TRUSTED_ORIGINS" == *"$ORIGIN"* ]]; then + warn "CSRF_TRUSTED_ORIGINS does not include ORIGIN ($ORIGIN)" + fi + if [[ -n "${FRONTEND_URL:-}" ]] && [[ ! "$CSRF_TRUSTED_ORIGINS" == *"$FRONTEND_URL"* ]]; then + warn "CSRF_TRUSTED_ORIGINS does not include FRONTEND_URL ($FRONTEND_URL)" + fi + if [[ -n "${PUBLIC_URL:-}" ]] && [[ ! "$CSRF_TRUSTED_ORIGINS" == *"$PUBLIC_URL"* ]]; then + warn "CSRF_TRUSTED_ORIGINS does not include PUBLIC_URL ($PUBLIC_URL)" + fi +fi + +if [[ -n "${PUBLIC_URL:-}" ]] && [[ -n "${FRONTEND_URL:-}" ]]; then + public_scheme="${PUBLIC_URL%%://*}" + frontend_scheme="${FRONTEND_URL%%://*}" + if [[ "$public_scheme" != "$frontend_scheme" ]]; then + warn "PUBLIC_URL ($public_scheme) and FRONTEND_URL ($frontend_scheme) use different schemes" + fi +fi + +if [[ -n "${FRONTEND_PORT:-}" ]] && [[ -n "${ORIGIN:-}" ]]; then + if [[ "$ORIGIN" == *":$FRONTEND_PORT"* ]] || [[ "$ORIGIN" != *":"* && "$FRONTEND_PORT" == "80" ]]; then + : + else + warn "ORIGIN ($ORIGIN) may not match FRONTEND_PORT ($FRONTEND_PORT)" + fi +fi + +if [[ -n "${BACKEND_PORT:-}" ]] && [[ -n "${PUBLIC_URL:-}" ]]; then + if [[ "$PUBLIC_URL" == *":$BACKEND_PORT"* ]] || [[ "$PUBLIC_URL" != *":"* && "$BACKEND_PORT" == "80" ]]; then + : + else + warn "PUBLIC_URL ($PUBLIC_URL) may not match BACKEND_PORT ($BACKEND_PORT)" + fi +fi + +if [[ "${POSTGRES_PASSWORD:-changeme123}" == "changeme123" ]] || [[ "${SECRET_KEY:-changeme123}" == "changeme123" ]]; then + warn "Default POSTGRES_PASSWORD or SECRET_KEY detected — change these for production" +fi + +echo "" +if [[ $ERRORS -gt 0 ]]; then + echo "Validation failed: $ERRORS error(s), $WARNINGS warning(s)" + exit 1 +fi + +if [[ $WARNINGS -gt 0 ]]; then + echo "Validation passed with $WARNINGS warning(s)" + exit 0 +fi + +echo "Validation passed" +exit 0