Add Docker and environment configuration files for AdventureLog

- Introduced .dockerignore to exclude unnecessary files from Docker context.
- Added .env.aio.example for minimal configuration of the AdventureLog All-in-One setup.
- Updated .env.example to include optional SITE_URL and GUNICORN_WORKERS settings.
- Enhanced deploy.sh script for improved deployment flexibility and backup options.
- Updated docker-compose files to use PostGIS 16-3.5 and added health checks for services.
- Created docker-compose.aio.yml for All-in-One deployment configuration.
- Improved health checks and service dependencies in docker-compose.dev.yml and docker-compose.yml.
- Added GitHub workflows for building and pushing Docker images, including smoke tests for AIO setup.
This commit is contained in:
Sean Morley
2026-06-06 18:54:25 -04:00
parent bd6addfab5
commit 7f6bf1390a
102 changed files with 6580 additions and 1940 deletions

42
.dockerignore Normal file
View File

@@ -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

8
.env.aio.example Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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:

125
.github/workflows/_build-image.yml vendored Normal file
View File

@@ -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<<EOF"
printf '%s\n' "${TAGS}"
echo "EOF"
} >> "${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"

126
.github/workflows/_build-images.yml vendored Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

15
.github/workflows/images-beta.yml vendored Normal file
View File

@@ -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

15
.github/workflows/images-latest.yml vendored Normal file
View File

@@ -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

14
.github/workflows/images-release.yml vendored Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

80
adventurelog/.env.example Normal file
View File

@@ -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://<account_id>.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=

69
adventurelog/deploy.sh Executable file
View File

@@ -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

View File

@@ -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

View File

@@ -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:

78
adventurelog/scripts/backup.sh Executable file
View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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/urandom 2>/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}"
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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"
}

107
adventurelog/scripts/restore.sh Executable file
View File

@@ -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 <backup-directory>" >&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."

View File

@@ -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

15
aio/entrypoint.sh Normal file
View File

@@ -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 "$@"

41
aio/env-setup.sh Normal file
View File

@@ -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"

View File

@@ -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"]

16
backend/cron/adventurelog Normal file
View File

@@ -0,0 +1,16 @@
# AdventureLog scheduled management commands.
# Schedules are in UTC (CRON_TZ). Job output appears in Docker logs and
# /var/log/adventurelog/<job-name>.log inside the container.
# Add new jobs on their own line:
# MIN HOUR DOM MON DOW root /code/scripts/run-cron-job.sh <log-name> <command> [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

View File

@@ -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 "$@"
exec "$@"

54
backend/scripts/run-cron-job.sh Executable file
View File

@@ -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 <job-name> <manage.py-command> [command-args...]
#
# Output goes to /var/log/adventurelog/<job-name>.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 $*"

View File

@@ -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

View File

@@ -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')

View File

@@ -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')),

View File

@@ -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):

View File

@@ -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")

73
deploy.sh Normal file → Executable file
View File

@@ -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
"${COMPOSE[@]}" ps
if [[ "$FOLLOW_LOGS" == true ]]; then
docker logs "$LOG_CONTAINER" --follow
fi

View File

@@ -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:

55
docker-compose.aio.yml Normal file
View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

130
docker/Dockerfile Normal file
View File

@@ -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"]

35
docker/README.md Normal file
View File

@@ -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.

31
docker/docker-bake.hcl Normal file
View File

@@ -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"]
}

View File

@@ -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 <<EOF
from django.contrib.auth import get_user_model
from allauth.account.models import EmailAddress
User = get_user_model()
if not User.objects.filter(username='$DJANGO_ADMIN_USERNAME').exists():
superuser = User.objects.create_superuser(
username='$DJANGO_ADMIN_USERNAME',
email='$DJANGO_ADMIN_EMAIL',
password='$DJANGO_ADMIN_PASSWORD'
)
print("Superuser created successfully.")
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
}
run_download_countries() {
if [[ "${SKIP_WORLD_DATA:-}" == "1" ]]; then
>&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
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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. Heres a breakdown of each section:

View File

@@ -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

View File

@@ -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! 🎉

View File

@@ -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).

View File

@@ -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.

View File

@@ -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

View File

@@ -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"]

View File

@@ -1,3 +1,5 @@
/// <reference types="unplugin-icons/types/svelte" />
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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<string, string> {
const result: Record<string, string> = {};
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';
}
}

View File

@@ -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 });

View File

@@ -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');

View File

@@ -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 }
);
};

5
frontend/start.js Normal file
View File

@@ -0,0 +1,5 @@
if (!process.env.ORIGIN && process.env.SITE_URL) {
process.env.ORIGIN = process.env.SITE_URL;
}
await import('./build/index.js');

View File

@@ -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

View File

@@ -9,7 +9,6 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": ["unplugin-icons/types/svelte"] // add this line
"moduleResolution": "bundler"
}
}

View File

@@ -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
});

View File

@@ -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://seanmorley.com>
# 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/urandom 2>/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

164
k8s/base/adventurelog.yaml Normal file
View File

@@ -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

View File

@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- adventurelog.yaml

View File

@@ -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:

78
scripts/backup.sh Executable file
View File

@@ -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"

118
scripts/install/lib/aio-config.sh Executable file
View File

@@ -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
}

182
scripts/install/lib/checks.sh Executable file
View File

@@ -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
}

204
scripts/install/lib/common.sh Executable file
View File

@@ -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/urandom 2>/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}"
}

118
scripts/install/lib/deploy.sh Executable file
View File

@@ -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
}

104
scripts/install/lib/download.sh Executable file
View File

@@ -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
}

153
scripts/install/lib/features.sh Executable file
View File

@@ -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
}

80
scripts/install/lib/main.sh Executable file
View File

@@ -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
}

184
scripts/install/lib/manage.sh Executable file
View File

@@ -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
}

View File

@@ -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
}

433
scripts/install/lib/tui.sh Executable file
View File

@@ -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"
}

129
scripts/install/lib/ui.sh Executable file
View File

@@ -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"
}

Some files were not shown because too many files have changed in this diff Show More