))
}
diff --git a/src/data/wizard/docker-compose.yaml b/src/data/wizard/docker-compose.yaml
new file mode 100644
index 00000000..cff871f8
--- /dev/null
+++ b/src/data/wizard/docker-compose.yaml
@@ -0,0 +1,663 @@
+# Docker Compose configuration for Lychee application with FrankenPHP backend
+# Version: 2026-01-10
+
+# You can set up secrets files for sensitive data like database passwords.
+# Create a 'secrets' directory and add files 'db_password' and 'db_master_password'
+# with appropriate permissions (readable only by the owner).
+# Then uncomment the 'secrets' section below.
+
+# secrets:
+# db_password:
+# file: ./secrets/db_password
+# db_master_password:
+# file: ./secrets/db_master_password
+# app_key:
+# file: ./secrets/app_key
+
+##########################################################################################
+# Common settings for Lychee services
+##########################################################################################
+x-base-lychee-setup:
+ # There are two main images: `latest` and `edge`.
+ # `latest` is mapped to the last published version
+ # `edge` is mapped to the last build on master branch (may be unstable)
+ #
+ # There are also other images available which uses nginx as a base instead of FrankenPHP:
+ # - ghcr.io/lycheeorg/lychee:latest-legacy
+ # - ghcr.io/lycheeorg/lychee:edge-legacy
+ &base-lychee-setup
+ image: ghcr.io/lycheeorg/lychee:latest
+
+ # The following lines are for development purposes only.
+ # image: lychee-frankenphp
+ # image: lychee-legacy:latest
+ # build:
+ # context: ./app
+ # dockerfile: Dockerfile
+ # args:
+ # NODE_ENV: "${NODE_ENV:-production}"
+ restart: unless-stopped # Auto-restart at container level (outer layer)
+
+ # Security hardening
+ security_opt:
+ - no-new-privileges:true
+ - seccomp:unconfined # FrankenPHP may need this; consider custom seccomp profile
+ cap_drop:
+ - ALL
+ cap_add:
+ - CHOWN
+ - SETGID
+ - SETUID
+ - DAC_OVERRIDE
+ - NET_BIND_SERVICE
+ read_only: false # Laravel needs write access to storage/cache
+ tmpfs:
+ - /tmp:noexec,nosuid,nodev,size=100m
+
+ # Resource limits
+ deploy:
+ resources:
+ limits:
+ cpus: '2'
+ memory: 2G
+ reservations:
+ cpus: '0.5'
+ memory: 512M
+
+ # To configure Lychee, you can do it via:
+ # - environment variables (see the environment section below)
+ # - .env file (recommended for sensitive data)
+ # - mapping .env file to /app/.env (not necessary anymore if you use env_file)
+ env_file:
+ - path: ./.env
+
+ # If you use docker secrets, uncomment the following lines and
+ # create the secrets files as described at the top of this file.
+ # secrets:
+ # - db_password
+ # - app_key
+ volumes:
+ # Mount local directories for persistent storage
+ # Uploads : where your photos are going to be stored
+ - ./lychee/uploads:/app/public/uploads
+ # Logs: to see what went wrong (or right)
+ - ./lychee/logs:/app/storage/logs
+ # Temporary files: where your uploads are stored temporarily
+ # while waiting for jobs to process them.
+ - ./lychee/tmp:/app/storage/tmp
+
+ networks:
+ - lychee
+
+x-common-env:
+ # User and Group IDs for the www-data user inside the container
+ # Note that this will impact your permissions on mounted volumes.
+ # By default Lychee does not run as root inside the container.
+ #
+ # Ensure that the PUID and PGID match the owner of the mounted volumes
+ # to avoid permission issues.
+ #
+ # You can also set it to 33 (www-data) if your files are owned by www-data.
+ &common-env
+ PUID: "${PUID:-1000}"
+ PGID: "${PGID:-1000}"
+
+ # Or you can activate the following to run as root inside the container. (not recommended)
+ # RUN_AS_ROOT: "yes"
+
+ # Application Key
+ # You can either set it here directly (not recommended for security reasons)
+ # Or load it from .env file
+ # or use Docker secrets as shown above.
+ APP_KEY: "${APP_KEY:-}"
+ # APP_KEY_FILE: "/run/secrets/app_key"
+
+ # Application
+ APP_NAME: "${APP_NAME:-Lychee}"
+ APP_ENV: "${APP_ENV:-production}"
+ APP_DEBUG: "${APP_DEBUG:-false}"
+ APP_TIMEZONE: "${TIMEZONE:-UTC}"
+ APP_URL: "${APP_URL:-http://localhost:8000}"
+ APP_FORCE_HTTPS: "${APP_FORCE_HTTPS:-false}"
+ APP_MAINTENANCE_DRIVER: "${APP_MAINTENANCE_DRIVER:-file}"
+
+ # enable or disable debug bar. By default it is disabled.
+ # Do note that this disable CSP!!
+ # DEBUGBAR_ENABLED: "${DEBUGBAR_ENABLED:-false}"
+
+ # enable or disable log viewer. By default it is enabled.
+ # Unfortunately, log viewer is not available in production due to an upstream bug. :(
+ # you will need to set APP_ENV=local to enable it.
+ LOG_VIEWER_ENABLED: "${LOG_VIEWER_ENABLED:-true}"
+
+ # Sometimes 404 errors are very noisy in the logs.
+ # You can disable logging them by setting this to false.
+ LOG_404_ERRORS: "${LOG_404_ERRORS:-true}"
+
+ # enable or disable clockwork. By default it is disabled (and not provided on non-dev build).
+ CLOCKWORK_ENABLE: "${CLOCKWORK_ENABLE:-false}"
+
+ # enable or disable latency debug: adds a specific amount of time in milliseconds to wait before processing requests.
+ # Always disabled on production environment.
+ # APP_DEBUG_LATENCY: 0
+
+ # All API requests to have the header "content-type: application/json"
+ # or "content-type: multipart/form-data" depending on the type.
+ #
+ # If you want to disable this requirement, set this to false.
+ #
+ # This requirement prevents the use of the API from the API documentation page.
+ # REQUIRE_CONTENT_TYPE_ENABLED: "${REQUIRE_CONTENT_TYPE_ENABLED:-true}"
+
+ # enable s3 bucket (required in addition to needing AWS_ACCESS_KEY_ID)
+ # S3_ENABLED: true
+
+ # If you spread old links of to your albums in your Lychee instance starting with
+ # https://lychee.text/#albumID/PhotoId
+ # Set this value to true to enable redirection.
+ # LEGACY_V4_REDIRECT: "${LEGACY_V4_REDIRECT:-false}"
+
+ ##############################################################################
+ # IMPORTANT: To migrate from Lychee v3 you *MUST* use the same MySQL/MariaDB #
+ # server as v3. #
+ ##############################################################################
+
+ # Table prefix (e.g. lychee_) of a Lychee v3 instance for migration
+ # DB_OLD_LYCHEE_PREFIX:
+
+ # DB_CONNECTION can be sqlite, mysql or pgsql. For sqlite the other entries are
+ # not required, but an existing sqlite3 database may be specified if desired.
+ # In this case, please use an absolute path. DB_DATABASE may be omitted but should
+ # *not* be left blank.
+ #
+ # Note that if DB_PASSWORD includes special characters, it must be enclosed in quotes.
+ # e.g. DB_PASSWORD: "lychee!@#$%^&"
+ DB_CONNECTION: "${DB_CONNECTION:-mysql}"
+ DB_HOST: "${DB_HOST:-lychee_db}"
+ DB_PORT: "${DB_PORT:-3306}"
+ DB_DATABASE: "${DB_DATABASE:-lychee}"
+ DB_USERNAME: "${DB_USERNAME:-lychee}"
+
+ # Use secrets for sensitive data - see Docker secrets or vault integration
+ # DB_PASSWORD should come from .env file only
+ DB_PASSWORD: "${DB_PASSWORD:-password}"
+ #
+ # Or you can uncomment the following line to use DB_PASSWORD_FILE from secrets
+ # DB_PASSWORD_FILE: "/run/secrets/db_password"
+
+ ###################################################################
+ # Keygen License Management #
+ ###################################################################
+
+ # API token obtained from keygen.lycheeorg.dev.
+ # When set, Lychee will automatically rotate an expired license key
+ # on admin login and check the token health on the diagnostics page.
+ KEYGEN_API_KEY: "${KEYGEN_API_KEY:-}"
+ # Or you can uncomment the following line to use KEYGEN_API_KEY_FILE from secrets
+ # KEYGEN_API_KEY_FILE: "/run/secrets/keygen_api_key"
+
+ # Session configuration
+ SESSION_DRIVER: "${SESSION_DRIVER:-file}"
+ SESSION_LIFETIME: "${SESSION_LIFETIME:-120}"
+ # SESSION_DRIVER: "${SESSION_DRIVER:-database}"
+ # SESSION_LIFETIME: "${SESSION_LIFETIME:-120}"
+ # SESSION_ENCRYPT: "${SESSION_ENCRYPT:-false}"
+ # SESSION_PATH: "${SESSION_PATH:-/}"
+ # SESSION_DOMAIN: "${SESSION_DOMAIN:-null}"
+
+ # Cache
+ CACHE_STORE: "${CACHE_STORE:-file}"
+ CACHE_PREFIX: "${CACHE_PREFIX:-lychee_cache}"
+ # REDIS_HOST: "lychee_cache"
+ # REDIS_PASSWORD: "null"
+ # REDIS_PORT: "6379"
+ # REDIS_URL=redis://:@:
+
+ # If you use Redis as cache driver, we strongly recommend
+ # to disable it for your Log Viewer.
+ # Should redis crash, you will no longer be able to access your logs.
+ LOG_VIEWER_CACHE_DRIVER: "file"
+
+ # When booting the application, if you have a lot of photos,
+ # Lychee will check the permissions of all files and folders.
+ # This may take a while (even hours depending on the number of photos...)
+ # If you are sure that your permissions are correct, you can skip these checks
+ # by setting the following to "yes".
+ # SKIP_PERMISSIONS_CHECKS: "yes"
+
+ # Queue
+ # The default value for QUEUE_CONNECTION is 'sync', this means that jobs are
+ # executed immediately (synchronously) when dispatched.
+ #
+ # For improved reactivity of Lychee we recommend to use a worker.
+ # You can use either 'database' or 'redis' as queue driver (but in the later you need to enable redis).
+ QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}"
+
+ # Logging Stuff.
+ # LOG_CHANNEL: "${LOG_CHANNEL:-stack}"
+ # LOG_STACK: "${LOG_STACK:-single}"
+ # LOG_DEPRECATIONS_CHANNEL: "${LOG_DEPRECATIONS_CHANNEL:-null}"
+ # LOG_LEVEL: "${LOG_LEVEL:-debug}"
+ # LOG_STDOUT: "${LOG_STDOUT:-true}"
+
+ # SECURITY_HEADER_HSTS_ENABLE:false
+ # SECURITY_HEADER_CSP_CONNECT_SRC:
+ # SECURITY_HEADER_SCRIPT_SRC_ALLOW:
+ # SECURITY_HEADER_CSP_CHILD_SRC:
+ # SECURITY_HEADER_CSP_FONT_SRC:
+ # SECURITY_HEADER_CSP_FORM_ACTION:
+ # SECURITY_HEADER_CSP_FRAME_ANCESTORS:
+ # SECURITY_HEADER_CSP_FRAME_SRC:
+ # SECURITY_HEADER_CSP_IMG_SRC:
+ # SECURITY_HEADER_CSP_MEDIA_SRC:
+ # SESSION_SECURE_COOKIE:false
+
+ # MAIL_DRIVER:smtp
+ # MAIL_HOST:
+ # MAIL_PORT:
+ # MAIL_USERNAME:
+ # MAIL_PASSWORD:
+ # MAIL_ENCRYPTION:
+ # MAIL_FROM_NAME:
+ # MAIL_FROM_ADDRESS:
+
+ # Trusted proxy IPs (or CIDR ranges) sitting in front of Lychee, so it uses
+ # their X-Forwarded-* headers for the real client IP/scheme instead of the
+ # proxy's own. If you're behind Traefik (or any reverse proxy) on the same
+ # Docker network, set this to "*" to trust all of them.
+ TRUSTED_PROXIES: "${TRUSTED_PROXIES:-null}"
+
+ # Disable Basic Auth. This means that the only way to authenticate is via the API token or Oauth.
+ # This should only be toggled AFTER having set up the admin account and bound the Oauth client.
+ # DISABLE_BASIC_AUTH=false
+
+ # Disable WebAuthn. This means that the only way to authenticate is via the API token, Basic Auth or Oauth.
+ # DISABLE_WEBAUTHN=false
+
+ ###################################################################
+ # LDAP Authentication (enterprise directory integration) #
+ ###################################################################
+
+ # Enable LDAP authentication alongside or instead of basic auth
+ # LDAP_ENABLED=false
+
+ # LDAP Server connection settings
+ # LDAP_HOST=ldap.example.com
+ # LDAP_PORT=389
+ # For LDAPS (LDAP over SSL), use port 636
+ # LDAP_PORT=636
+
+ # Base DN for LDAP searches (e.g., dc=example,dc=com or dc=corp,dc=example,dc=com)
+ # LDAP_BASE_DN=dc=example,dc=com
+
+ # Service account credentials for LDAP bind
+ # This account needs read-only access to user and group attributes
+ # LDAP_BIND_DN=cn=lychee-service,ou=services,dc=example,dc=com
+ # LDAP_BIND_PASSWORD=securepassword
+
+ # LDAP user search filter (%s is replaced with username)
+ # For OpenLDAP:
+ # LDAP_USER_FILTER=(&(objectClass=person)(uid=%s))
+ # For Active Directory:
+ # LDAP_USER_FILTER=(&(objectClass=user)(sAMAccountName=%s))
+
+ # LDAP attribute mapping (maps LDAP attributes to Lychee user fields)
+ # OpenLDAP defaults:
+ # LDAP_ATTR_USERNAME=uid
+ # LDAP_ATTR_EMAIL=mail
+ # LDAP_ATTR_DISPLAY_NAME=displayName
+ # Active Directory alternatives:
+ # LDAP_ATTR_USERNAME=sAMAccountName
+ # LDAP_ATTR_EMAIL=userPrincipalName
+ # LDAP_ATTR_DISPLAY_NAME=displayName
+
+ # Admin role mapping via LDAP group
+ # Users in this group will have may_administrate=true
+ # LDAP_ADMIN_GROUP_DN=cn=lychee-admins,ou=groups,dc=example,dc=com
+
+ # Auto-provision users on first LDAP login
+ # If false, users must be pre-created in Lychee before they can log in via LDAP
+ # LDAP_AUTO_PROVISION=true
+
+ # TLS/SSL settings for secure LDAP connections
+ # LDAP_USE_TLS=true
+ # LDAP_TLS_VERIFY_PEER=true
+
+ # Connection timeout in seconds
+ # LDAP_CONNECTION_TIMEOUT=5
+
+ # Oauth token data
+ # *_REDIRECT_URI should be left as default unless you know exactly what you do.
+
+ AMAZON_SIGNIN_CLIENT_ID: "${AMAZON_SIGNIN_CLIENT_ID:-}"
+ AMAZON_SIGNIN_SECRET: "${AMAZON_SIGNIN_SECRET:-}"
+ AMAZON_SIGNIN_REDIRECT_URI: "${AMAZON_SIGNIN_REDIRECT_URI:-/auth/amazon/redirect}"
+
+ # https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple
+ # Note: the client secret used for "Sign In with Apple" is a JWT token that can have a maximum lifetime of 6 months.
+ # The article above explains how to generate the client secret on demand and you'll need to update this every 6 months.
+ # To generate the client secret for each request, see Generating A Client Secret For Sign In With Apple On Each Request.
+ # https://bannister.me/blog/generating-a-client-secret-for-sign-in-with-apple-on-each-request
+ APPLE_CLIENT_ID: "${APPLE_CLIENT_ID:-}"
+ APPLE_CLIENT_SECRET: "${APPLE_CLIENT_SECRET:-}"
+ APPLE_REDIRECT_URI: "${APPLE_REDIRECT_URI:-/auth/apple/redirect}"
+
+ FACEBOOK_CLIENT_ID: "${FACEBOOK_CLIENT_ID:-}"
+ FACEBOOK_CLIENT_SECRET: "${FACEBOOK_CLIENT_SECRET:-}"
+ FACEBOOK_REDIRECT_URI: "${FACEBOOK_REDIRECT_URI:-/auth/facebook/redirect}"
+
+ GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID:-}"
+ GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET:-}"
+ GITHUB_REDIRECT_URI: "${GITHUB_REDIRECT_URI:-/auth/github/redirect}"
+
+ GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
+ GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
+ GOOGLE_REDIRECT_URI: "${GOOGLE_REDIRECT_URI:-/auth/google/redirect}"
+
+ MASTODON_DOMAIN: "${MASTODON_DOMAIN:-https://mastodon.social}"
+ MASTODON_ID: "${MASTODON_ID:-}"
+ MASTODON_SECRET: "${MASTODON_SECRET:-}"
+ MASTODON_REDIRECT_URI: "${MASTODON_REDIRECT_URI:-/auth/mastodon/redirect}"
+
+ MICROSOFT_CLIENT_ID: "${MICROSOFT_CLIENT_ID:-}"
+ MICROSOFT_CLIENT_SECRET: "${MICROSOFT_CLIENT_SECRET:-}"
+ MICROSOFT_REDIRECT_URI: "${MICROSOFT_REDIRECT_URI:-/auth/microsoft/redirect}"
+ MICROSOFT_TENANT_ID: "${MICROSOFT_TENANT_ID:-}"
+
+ NEXTCLOUD_CLIENT_ID: "${NEXTCLOUD_CLIENT_ID:-}"
+ NEXTCLOUD_CLIENT_SECRET: "${NEXTCLOUD_CLIENT_SECRET:-}"
+ NEXTCLOUD_REDIRECT_URI: "${NEXTCLOUD_REDIRECT_URI:-/auth/nextcloud/redirect}"
+ NEXTCLOUD_BASE_URI: "${NEXTCLOUD_BASE_URI:-}"
+
+ KEYCLOAK_CLIENT_ID: "${KEYCLOAK_CLIENT_ID:-}"
+ KEYCLOAK_CLIENT_SECRET: "${KEYCLOAK_CLIENT_SECRET:-}"
+ KEYCLOAK_REDIRECT_URI: "${KEYCLOAK_REDIRECT_URI:-/auth/keycloak/redirect}"
+ KEYCLOAK_BASE_URL: "${KEYCLOAK_BASE_URL:-}"
+ KEYCLOAK_REALM: "${KEYCLOAK_REALM:-}"
+
+ AUTHENTIK_BASE_URL: "${AUTHENTIK_BASE_URL:-}"
+ AUTHENTIK_CLIENT_ID: "${AUTHENTIK_CLIENT_ID:-}"
+ AUTHENTIK_CLIENT_SECRET: "${AUTHENTIK_CLIENT_SECRET:-}"
+ AUTHENTIK_REDIRECT_URI: "${AUTHENTIK_REDIRECT_URI:-/auth/authentik/redirect}"
+
+ AUTHELIA_BASE_URL: "${AUTHELIA_BASE_URL:-}"
+ AUTHELIA_CLIENT_ID: "${AUTHELIA_CLIENT_ID:-}"
+ AUTHELIA_CLIENT_SECRET: "${AUTHELIA_CLIENT_SECRET:-}"
+ AUTHELIA_REDIRECT_URI: "${AUTHELIA_REDIRECT_URI:-/auth/authelia/redirect}"
+
+ # AWS support data
+
+ # AWS_ACCESS_KEY_ID=
+ # AWS_SECRET_ACCESS_KEY=
+ # AWS_DEFAULT_REGION=
+ # AWS_BUCKET=
+ # AWS_URL=
+ # AWS_ENDPOINT=
+ # AWS_IMAGE_VISIBILITY=
+ # AWS_USE_PATH_STYLE_ENDPOINT=
+
+ # DISABLE_IMPORT_FROM_SERVER=false
+
+ ###################################################################
+ # Payment integration (requires SE) #
+ ###################################################################
+
+ # Enable test mode (Sandbox mode) for payment gateways.
+ # In test mode, no real money transactions are done.
+ # We set it to true by default for safety. Make sure to set it to false
+ # when you go live.
+ # OMNIPAY_TEST_MODE=true
+
+ # Configuration values for Mollie integration
+ # MOLLIE_API_KEY=
+ # MOLLIE_PROFILE_ID=
+
+ # Configuration values for Stripe integration (NOT WORKING YET, MAYBE LATER)
+ # STRIPE_API_KEY=
+ # STRIPE_PUBLISHABLE_KEY=
+
+ # Configuration values for PayPal integration
+ # PAYPAL_CLIENT_ID=
+ # PAYPAL_SECRET=
+
+ ###################################################################
+ # Facial recognition #
+ ###################################################################
+ AI_VISION_ENABLED: "${AI_VISION_ENABLED:-true}"
+ AI_VISION_FACE_API_KEY: "${AI_VISION_FACE_API_KEY:-changeme}"
+ AI_VISION_FACE_URL: "http://lychee_facial_recognition:8000"
+
+services:
+ ##########################################################################################
+ # Lychee API Service and frontend.
+ ##########################################################################################
+ lychee_api:
+ <<: *base-lychee-setup
+ # Set up a container name for easier identification
+ container_name: lychee-api
+ expose:
+ - "${APP_PORT:-8000}"
+ ports:
+ - "${APP_PORT:-8000}:8000"
+ environment:
+ <<: *common-env
+ # Performance tuning for FRANKENPHP
+ # For the legacy setup using the -legacy tags, these values are not used.
+
+ # Increase PHP max execution time (in seconds) for long running operations like imports.
+ # We recommend you leave those as is and use database/redis queue with a worker for long operations.
+ # PHP_MAX_EXECUTION_TIME: 3000
+ # LYCHEE_MAX_EXECUTION_TIME: 30
+
+ depends_on:
+ lychee_db:
+ condition: service_healthy
+
+ healthcheck:
+ test: [ "CMD", "curl", "-f", "http://localhost:8000/up" ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+
+ ##########################################################################################
+ # Queue Worker Service
+ ##########################################################################################
+ #
+ # Processes background jobs (imports, thumbnail generation, etc.) so lychee_api
+ # can respond immediately instead of blocking on them. Requires QUEUE_CONNECTION
+ # to be 'database' or 'redis' above — with the default 'sync' this container
+ # would have nothing to do. Remove this service entirely if you'd rather run
+ # without a worker (and set QUEUE_CONNECTION back to 'sync').
+ #
+ # THIS IS PRETTY MUCH THE SAME CONFIGURATION AS lychee_api WITH A FEW MINOR MODIFICATIONS
+ #
+ # No container_name here (unlike the other services): `scale` starts more than
+ # one container from this same service definition, which requires Compose to
+ # name them itself.
+ lychee_worker:
+ <<: *base-lychee-setup
+ # Number of worker containers to run. Increase for more parallel job
+ # processing on multi-core hosts with a lot of queued work.
+ scale: ${WORKER_REPLICAS:-1}
+ environment:
+ <<: *common-env
+ ##########################################################################################
+ # Enable WORKER MODE
+ ##########################################################################################
+ #
+ # THIS VALUE HERE IS THE MOST IMPORTANT ONE.
+ # IF LYCHEE_MODE IS NOT SET TO WORKER, THE CONTAINER WILL BE A DUPLICATE OF lychee_api.
+ # Set LYCHEE_MODE=worker to enable queue worker mode
+ LYCHEE_MODE: worker
+
+ depends_on:
+ lychee_db:
+ condition: service_healthy
+ lychee_api:
+ condition: service_healthy
+
+ # Worker health check
+ # Verifies queue:work process is running
+ healthcheck:
+ test: [ "CMD-SHELL", "pgrep -f 'queue:work' || exit 1" ]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 60s # Give worker time to start up
+
+ phpmyadmin:
+ image: phpmyadmin
+ restart: always
+ ports:
+ - 8080:80
+ environment:
+ - PMA_HOST=lychee_db
+ - PMA_PORT=3306
+ depends_on:
+ lychee_db:
+ condition: service_healthy
+ networks:
+ - lychee
+ profiles:
+ - phpmyadmin
+
+ lychee_db:
+ image: mariadb:11
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ cap_add:
+ - SETGID
+ - SETUID
+ - DAC_OVERRIDE
+ - CHOWN
+ read_only: false # MariaDB needs write access
+ tmpfs:
+ - /tmp:noexec,nosuid,nodev,size=200m
+ - /var/run/mysqld:noexec,nosuid,nodev,size=10m
+ env_file:
+ - path: ./.env
+ required: false
+ # secrets:
+ # - db_master_password
+ # - db_password
+ environment:
+ - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-rootpassword}
+ # - MYSQL_ROOT_PASSWORD_FILE=/run/secrets/db_master_password
+ - MYSQL_DATABASE=${DB_DATABASE:-lychee}
+ - MYSQL_USER=${DB_USERNAME:-lychee}
+ - MYSQL_PASSWORD=${DB_PASSWORD:-password}
+ # - MYSQL_PASSWORD_FILE=/run/secrets/db_password
+ expose:
+ - 3306
+ # Removed host port binding - only accessible within Docker network
+ # For debugging, temporarily uncomment:
+ # ports:
+ # - 127.0.0.1:33061:3306
+ volumes:
+ - mysql:/var/lib/mysql
+ networks:
+ - lychee
+ restart: unless-stopped
+ healthcheck:
+ test: [ "CMD", "healthcheck.sh", "--connect", "--innodb_initialized" ]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 10s
+
+ lychee_facial_recognition:
+ expose:
+ - "${APP_PORT_AI_FACE:-8001}"
+ ports:
+ - "${APP_PORT_AI_FACE:-8001}:8000"
+ image: ghcr.io/lycheeorg/lychee-facial-recognition:latest
+ restart: unless-stopped
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ environment:
+ # Lychee instance base URL (no trailing slash)
+ VISION_FACE_LYCHEE_API_URL: "http://lychee_api:8000"
+ # Shared API key — must match AI_VISION_FACE_API_KEY in Lychee's .env
+ VISION_FACE_API_KEY: "${AI_VISION_FACE_API_KEY:-changeme}"
+ # Set to false for development environments with self-signed certificates
+ VISION_FACE_VERIFY_SSL: "${AI_VISION_VERIFY_SSL:-true}"
+ # Skip the Lychee connectivity check at startup (useful for local dev)
+ VISION_FACE_SKIP_LYCHEE_CHECK: "${VISION_FACE_SKIP_LYCHEE_CHECK:-false}"
+
+ # --- Logging ---
+ # Uvicorn/application log level (debug, info, warning, error, critical)
+ VISION_FACE_LOG_LEVEL: "info"
+
+ # --- Clustering ---
+ # DBSCAN epsilon (max cosine distance); lower : tighter clusters
+ VISION_FACE_CLUSTER_EPS: "0.6"
+
+ # --- Photo volume ---
+ # Shared Docker-volume mount point for photo files
+ VISION_FACE_PHOTOS_PATH: "/data/photos"
+
+ # --- Embedding storage ---
+ # Storage engine: sqlite or pgvector
+ VISION_FACE_STORAGE_BACKEND: sqlite
+ # SQLite DB directory (used when storage_backend : sqlite)
+ VISION_FACE_STORAGE_PATH: "/data/embeddings"
+
+ # --- Concurrency ---
+ # Number of threads in the ThreadPoolExecutor used for CPU-bound inference
+ VISION_FACE_THREAD_POOL_SIZE: 1
+ # Number of Uvicorn worker processes
+ VISION_FACE_WORKERS: "${AI_VISION_WORKERS:-1}"
+
+ # Check the following for more env variables
+ # https://github.com/LycheeOrg/Lychee-Facial-Recognition/blob/master/.env.example
+
+ VISION_FACE_QUEUE_BACKEND: "${VISION_FACE_QUEUE_BACKEND:-database}"
+
+ # Maximum pending jobs; requests beyond this are rejected with 429, 0 = unlimited
+ VISION_FACE_QUEUE_MAX_SIZE: "${VISION_FACE_QUEUE_MAX_SIZE:-0}"
+
+ # --- Detection thresholds ---
+ # Bounding-box confidence filter (0–1); faces below this score are excluded
+ VISION_FACE_DETECTION_THRESHOLD: 0.5
+ # Cosine-similarity cutoff for selfie match results and suggestion candidates
+ VISION_FACE_MATCH_THRESHOLD: 0.5
+ # IoU threshold for bounding-box matching on re-scan (preserves person_id)
+ VISION_FACE_RESCAN_IOU_THRESHOLD: 0.5
+ # Maximum faces included in a callback payload (top-N by confidence)
+ VISION_FACE_MAX_FACES_PER_PHOTO: 10
+
+ # --- Quality filtering ---
+ # Minimum face size in pixels (longest side of bounding box); 0 : disabled
+ VISION_FACE_MIN_FACE_SIZE_PIXELS: 0
+ # Laplacian variance threshold for blur detection; faces below this are discarded
+ VISION_FACE_BLUR_THRESHOLD: 0.5
+ volumes:
+ - ./lychee/uploads:/data/photos:ro
+ - ai_vision_embeddings:/data/embeddings
+ networks:
+ - lychee
+ depends_on:
+ lychee_api:
+ condition: service_healthy
+ healthcheck:
+ test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 60s
+
+networks:
+ lychee:
+
+volumes:
+ mysql:
+ name: lychee_prod_mysql
+ driver: local
+ ai_vision_embeddings:
+ name: lychee_ai_vision_embeddings
+ driver: local
diff --git a/src/data/wizard/env.example b/src/data/wizard/env.example
new file mode 100644
index 00000000..ac2b6623
--- /dev/null
+++ b/src/data/wizard/env.example
@@ -0,0 +1,371 @@
+APP_NAME=Lychee
+APP_ENV=production
+APP_KEY=
+APP_DEBUG=false
+# This MUST contain the host name up to the Top Level Domain (tld) e.g. .com, .org etc.
+APP_URL=http://localhost
+APP_FORCE_HTTPS=false
+
+# If using Lychee in a sub folder, specify the path after the tld here.
+# For example for https://lychee.test/path/to/lychee
+# Set APP_URL=https://lychee.test
+# and APP_DIR=/path/to/lychee
+# We (LycheeOrg) do not recommend the use of APP_DIR.
+# APP_DIR=
+
+# enable or disable debug bar. By default it is disabled.
+# Do note that this disable CSP!!
+DEBUGBAR_ENABLED=false
+
+# enable or disable log viewer. By default it is disabled
+# Unfortunately, it is not possible to enable Log Viewer in production.
+# If you wish to enable it, also switch your APP_ENV to 'local'
+LOG_VIEWER_ENABLED=false
+
+# disable logging 404 errors
+# LOG_404_ERRORS=false
+
+# enable or disable clockwork. By default it is disabled (and not provided on non-dev build).
+CLOCKWORK_ENABLE=false
+CLOCKWORK_DRIVER=laravel
+CLOCKWORK_STORAGE_FILES_PATH=storage/clockwork
+
+# enable or disable latency debug: adds a specific amount of time in milliseconds to wait before processing requests.
+# Always disabled on production environment.
+# APP_DEBUG_LATENCY=0
+
+# All API requests to have the header "content-type: application/json"
+# or "content-type: multipart/form-data" depending on the type.
+#
+# If you want to disable this requirement, set this to false.
+#
+# This requirement prevents the use of the API from the API documentation page.
+REQUIRE_CONTENT_TYPE_ENABLED=true
+
+# enable s3 bucket (required in addition to needing AWS_ACCESS_KEY_ID)
+# S3_ENABLED=true
+
+# If you spread old links of to your albums in your Lychee instance starting with
+# https://lychee.text/#albumID/PhotoId
+# Set this value to true to enable redirection.
+LEGACY_V4_REDIRECT=false
+
+##############################################################################
+# IMPORTANT: To migrate from Lychee v3 you *MUST* use the same MySQL/MariaDB #
+# server as v3. #
+##############################################################################
+
+# Table prefix (e.g. lychee_) of a Lychee v3 instance for migration
+DB_OLD_LYCHEE_PREFIX=
+
+# DB_CONNECTION can be sqlite, mysql or pgsql. For sqlite the other entries are
+# not required, but an existing sqlite3 database may be specified if desired.
+# In this case, please use an absolute path. DB_DATABASE may be omitted but should
+# *not* be left blank.
+# Note that if DB_PASSWORD includes special characters, it must be enclosed in quotes.
+# e.g. DB_PASSWORD="lychee!@#$%^&"
+DB_CONNECTION=sqlite
+DB_HOST=
+DB_PORT=
+#DB_DATABASE=
+DB_USERNAME=
+DB_PASSWORD=
+DB_LOG_SQL=false
+DB_LOG_SQL_EXPLAIN=false #only for MySQL
+
+# List foreign keys in diagnostic page
+DB_LIST_FOREIGN_KEYS=false
+
+# Application timezone. If not specified, the server's default timezone is used.
+# Requires a named timezone identifier.
+# See https://www.php.net/manual/en/timezones.php for the list of supported timezones.
+# Don't use a timezone offset (like +01:00) or a timezone abbreviation (like CEST)
+# TIMEZONE=Europe/Paris
+
+# Visibility of directories and (media) files in LYCHEE_UPLOADS
+# Possible values are:
+#
+# - private: world group has neither read nor write access
+# - public: world group has read access but no write access (the default)
+# - world: world group has read and write access
+#
+# The default should suffice for most installations.
+# For improved security, change this setting to "private".
+# Some rare setups may require directories and files to be world writeable.
+# In this case, use "world" here.
+# USE WITH PRECAUTIONS: world writeable files and folders may be a SECURITY RISK.
+# LYCHEE_IMAGE_VISIBILITY=public
+
+# folders in which the files will be stored
+# LYCHEE_UPLOADS="/var/www/html/Lychee-Laravel/public/uploads/"
+# LYCHEE_DIST="/var/www/html/Lychee-Laravel/public/dist/"
+# LYCHEE_SYM="/var/www/html/Lychee-Laravel/public/sym/"
+# url to access those files
+# LYCHEE_UPLOADS_URL="uploads/"
+# LYCHEE_DIST_URL="dist/"
+# LYCHEE_SYM_URL="sym/"
+
+# Support for token based authentication used by API requests. Enabled by default.
+# ENABLE_TOKEN_AUTH=true
+
+# Lychee supports both Redis and file caching.
+# To use Redis, set CACHE_DRIVER to redis and configure the Redis connection.
+CACHE_DRIVER=file
+# CACHE_FILE_PATH=storage/framework/cache/data
+REDIS_HOST=127.0.0.1
+REDIS_PASSWORD=null
+REDIS_PORT=6379
+# REDIS_URL=redis://:@:
+
+# If you use Redis as cache driver, we strongly recommend
+# to disable it for your Log Viewer.
+# Should redis crash, you will no longer be able to access your logs.
+LOG_VIEWER_CACHE_DRIVER=file
+LOG_STDOUT=false
+
+# Session configuration
+SESSION_DRIVER=file
+SESSION_LIFETIME=120
+# Duration (in minutes) for the "Remember Me" cookie. Default: 40320 (4 weeks)
+# REMEMBER_LIFETIME=40320
+
+# `sync` if jobs need to be executed live (default) or `database` if they can be deferred.
+QUEUE_CONNECTION=sync
+# Choose this mode only if you have set up a queue worker (strongly recommended though).
+# QUEUE_CONNECTION=database
+
+SECURITY_HEADER_HSTS_ENABLE=false
+SECURITY_HEADER_CSP_CONNECT_SRC=
+SECURITY_HEADER_SCRIPT_SRC_ALLOW=
+SECURITY_HEADER_CSP_CHILD_SRC=
+SECURITY_HEADER_CSP_FONT_SRC=
+SECURITY_HEADER_CSP_FORM_ACTION=
+SECURITY_HEADER_CSP_FRAME_ANCESTORS=
+SECURITY_HEADER_CSP_FRAME_SRC=
+SECURITY_HEADER_CSP_IMG_SRC=
+SECURITY_HEADER_CSP_MEDIA_SRC=
+SESSION_SECURE_COOKIE=false
+
+MAIL_DRIVER=smtp
+MAIL_HOST=
+MAIL_PORT=
+MAIL_USERNAME=
+MAIL_PASSWORD=
+MAIL_ENCRYPTION=
+MAIL_FROM_NAME=
+MAIL_FROM_ADDRESS=
+
+# The trusted proxies if Lychee is behind a reverse proxy
+# Accepted values:
+# - `null`: no proxy
+# - `*`: any proxy
+# - [,]: a comma-seperated list of IP addresses
+TRUSTED_PROXIES=null
+
+# Comma-separated list of class names of diagnostics checks that should be skipped.
+#SKIP_DIAGNOSTICS_CHECKS=
+
+VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
+VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
+
+# Disable Basic Auth. This means that the only way to authenticate is via the API token or Oauth.
+# This should only be toggled AFTER having set up the admin account and bound the Oauth client.
+# DISABLE_BASIC_AUTH=false
+
+# Disable WebAuthn. This means that the only way to authenticate is via the API token, Basic Auth or Oauth.
+# DISABLE_WEBAUTHN=false
+
+# White-label mode — hides all Lychee branding from the UI (footer link, generator meta tag,
+# misconfiguration warning, left-menu "Lychee" section, and login-form branding).
+# NOTE: This setting only takes effect when a valid Lychee Supporter Edition (SE) licence is active.
+# On non-SE installations the flag is ignored and Lychee branding remains visible.
+# WHITE_LABEL_ENABLED=false
+
+###################################################################
+# Keygen License Management #
+###################################################################
+
+# API token obtained from keygen.lycheeorg.dev.
+# When set, Lychee will automatically rotate an expired license key
+# on admin login and check the token health on the diagnostics page.
+# KEYGEN_API_KEY=
+
+###################################################################
+# LDAP Authentication (enterprise directory integration) #
+###################################################################
+
+# Enable LDAP authentication alongside or instead of basic auth
+# LDAP_ENABLED=false
+
+# LDAP Server connection settings
+# LDAP_HOST=ldap.example.com
+# LDAP_PORT=389
+# For LDAPS (LDAP over SSL), use port 636
+# LDAP_PORT=636
+
+# Base DN for LDAP searches (e.g., dc=example,dc=com or dc=corp,dc=example,dc=com)
+# LDAP_BASE_DN=dc=example,dc=com
+
+# Service account credentials for LDAP bind
+# This account needs read-only access to user and group attributes
+# LDAP_BIND_DN=cn=lychee-service,ou=services,dc=example,dc=com
+# LDAP_BIND_PASSWORD=securepassword
+
+# LDAP user search filter (%s is replaced with username)
+# For OpenLDAP:
+# LDAP_USER_FILTER=(&(objectClass=person)(uid=%s))
+# For Active Directory:
+# LDAP_USER_FILTER=(&(objectClass=user)(sAMAccountName=%s))
+
+# LDAP attribute mapping (maps LDAP attributes to Lychee user fields)
+# OpenLDAP defaults:
+# LDAP_ATTR_USERNAME=uid
+# LDAP_ATTR_EMAIL=mail
+# LDAP_ATTR_DISPLAY_NAME=displayName
+# Active Directory alternatives:
+# LDAP_ATTR_USERNAME=sAMAccountName
+# LDAP_ATTR_EMAIL=userPrincipalName
+# LDAP_ATTR_DISPLAY_NAME=displayName
+
+# Admin role mapping via LDAP group
+# Users in this group will have may_administrate=true
+# LDAP_ADMIN_GROUP_DN=cn=lychee-admins,ou=groups,dc=example,dc=com
+
+# Auto-provision users on first LDAP login
+# If false, users must be pre-created in Lychee before they can log in via LDAP
+# LDAP_AUTO_PROVISION=true
+
+# TLS/SSL settings for secure LDAP connections
+# LDAP_USE_TLS=true
+# LDAP_TLS_VERIFY_PEER=true
+
+# Connection timeout in seconds
+# LDAP_CONNECTION_TIMEOUT=5
+
+# Oauth token data
+# XXX_REDIRECT_URI should be left as default unless you know exactly what you do.
+
+# AMAZON_SIGNIN_CLIENT_ID=
+# AMAZON_SIGNIN_SECRET=
+# AMAZON_SIGNIN_REDIRECT_URI=/auth/amazon/redirect
+
+# https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple
+# Note: the client secret used for "Sign In with Apple" is a JWT token that can have a maximum lifetime of 6 months.
+# The article above explains how to generate the client secret on demand and you'll need to update this every 6 months.
+# To generate the client secret for each request, see Generating A Client Secret For Sign In With Apple On Each Request.
+# https://bannister.me/blog/generating-a-client-secret-for-sign-in-with-apple-on-each-request
+# APPLE_CLIENT_ID=
+# APPLE_CLIENT_SECRET=
+# APPLE_REDIRECT_URI=/auth/apple/redirect
+
+# FACEBOOK_CLIENT_ID=
+# FACEBOOK_CLIENT_SECRET=
+# FACEBOOK_REDIRECT_URI=/auth/facebook/redirect
+
+# GITHUB_CLIENT_ID=
+# GITHUB_CLIENT_SECRET=
+# GITHUB_REDIRECT_URI=/auth/github/redirect
+
+# GOOGLE_CLIENT_ID=
+# GOOGLE_CLIENT_SECRET=
+# GOOGLE_REDIRECT_URI=/auth/google/redirect
+
+# MASTODON_DOMAIN=https://mastodon.social
+# MASTODON_ID=
+# MASTODON_SECRET=
+# MASTODON_REDIRECT_URI=/auth/mastodon/redirect
+
+# MICROSOFT_CLIENT_ID=
+# MICROSOFT_CLIENT_SECRET=
+# MICROSOFT_REDIRECT_URI=/auth/microsoft/redirect
+# MICROSOFT_TENANT_ID=
+
+# NEXTCLOUD_CLIENT_ID=
+# NEXTCLOUD_CLIENT_SECRET=
+# NEXTCLOUD_REDIRECT_URI=/auth/nextcloud/redirect
+# NEXTCLOUD_BASE_URI=
+
+# KEYCLOAK_CLIENT_ID=
+# KEYCLOAK_CLIENT_SECRET=
+# KEYCLOAK_REDIRECT_URI=/auth/keycloak/redirect
+# KEYCLOAK_BASE_URL=
+# KEYCLOAK_REALM=
+
+# AUTHENTIK_BASE_URL=
+# AUTHENTIK_CLIENT_ID=
+# AUTHENTIK_CLIENT_SECRET=
+# AUTHENTIK_REDIRECT_URI=/auth/authentik/redirect
+
+# AUTHELIA_BASE_URL=
+# AUTHELIA_CLIENT_ID=
+# AUTHELIA_CLIENT_SECRET=
+# AUTHELIA_REDIRECT_URI=/auth/authelia/redirect
+
+# AWS support data
+
+# AWS_ACCESS_KEY_ID=
+# AWS_SECRET_ACCESS_KEY=
+# AWS_DEFAULT_REGION=
+# AWS_BUCKET=
+# AWS_URL=
+# AWS_ENDPOINT=
+# AWS_IMAGE_VISIBILITY=
+# AWS_USE_PATH_STYLE_ENDPOINT=
+
+###################################################################
+# Vite local development without running a server. #
+# set VITE_LOCAL_DEV to true #
+# set VITE_HTTP_PROXY_TARGET to the rediction for the API calls. #
+###################################################################
+# VITE_LOCAL_DEV=true
+# VITE_HTTP_PROXY_TARGET=http://localhost:8000
+
+# DISABLE_IMPORT_FROM_SERVER=false
+
+# On shared hosting where sys_get_temp_dir() is not readable/writable,
+# set to false to use storage/tmp/uploads_parts instead.
+# USE_SYSTEM_TEMP_DIR=true
+
+# When enabled, the request caching settings (cache_enabled, cache_ttl,
+# cache_event_logging) become visible in the admin settings panel.
+# ENABLE_REQUEST_CACHING=false
+
+###################################################################
+# Payment integration (requires SE) #
+###################################################################
+
+# Enable test mode (Sandbox mode) for payment gateways.
+# In test mode, no real money transactions are done.
+# We set it to true by default for safety. Make sure to set it to false
+# when you go live.
+# OMNIPAY_TEST_MODE=true
+
+# Configuration values for Mollie integration
+# MOLLIE_API_KEY=
+# MOLLIE_PROFILE_ID=
+
+# Configuration values for Stripe integration (NOT WORKING YET, MAYBE LATER)
+# STRIPE_API_KEY=
+# STRIPE_PUBLISHABLE_KEY=
+
+# Configuration values for PayPal integration
+# PAYPAL_CLIENT_ID=
+# PAYPAL_SECRET=
+
+###################################################################
+# AI Vision (facial recognition & NSFW classification) #
+###################################################################
+
+# AI_VISION_FACE_URL=
+# AI_VISION_FACE_API_KEY=
+# AI_VISION_NSFW_URL=
+# AI_VISION_NSFW_API_KEY=
+
+###################################################################
+# Local reverse geo-decoding #
+###################################################################
+
+# Nominatim-API-compatible endpoint (e.g. a self-hosted Nominatim
+# instance). When set, it is used instead of the public
+# nominatim.openstreetmap.org server.
+# LOCAL_GEO_DECODING_URL=
diff --git a/src/navigation.js b/src/navigation.js
index 08e030be..9e573b07 100644
--- a/src/navigation.js
+++ b/src/navigation.js
@@ -57,12 +57,13 @@ export const footerData = {
{ text: 'Release Notes', href: getPermalink('/docs/getting-started/releases/') },
// { text: 'PR Dashboard', href: 'https://pr.lycheeorg.dev/' },
{ text: 'Issue trakcer', href: 'https://github.com/LycheeOrg/Lychee/issues' },
- ]
+ ],
},
{
title: 'Need help?',
links: [
{ text: 'Read the Docs', href: '/docs/' },
+ { text: 'Docker Compose Wizard', href: '/wizard/' },
{ text: 'Community Forum', href: 'https://github.com/LycheeOrg/Lychee/discussions' },
{ text: 'Join our discord', href: 'https://discord.gg/JMPvuRQcTf' },
],
@@ -70,7 +71,10 @@ export const footerData = {
{
title: 'Support Lychee',
links: [
- { text: 'Get Lychee SE', href: 'https://lycheeorg.dev/get-supporter-edition' },
+ {
+ text: 'Get Lychee SE',
+ href: 'https://lycheeorg.dev/get-supporter-edition',
+ },
{ text: 'GitHub sponsor', href: 'https://github.com/sponsors/LycheeOrg' },
{ text: 'Open Collective', href: 'https://opencollective.com/LycheeOrg' },
{ text: 'Translations', href: 'https://weblate.lycheeorg.dev' },
@@ -78,18 +82,16 @@ export const footerData = {
},
{
title: 'Security',
- links: [
- { text: 'Cosign key', href: getAsset('lychee-cosign.pub') },
- ]
- }
+ links: [{ text: 'Cosign key', href: getAsset('lychee-cosign.pub') }],
+ },
],
secondaryLinks: [
{ text: 'License', href: getPermalink('/license') },
{ text: 'Privacy Policy', href: getPermalink('/privacy-policy') },
],
socialLinks: [
- { ariaLabel: 'RSS', icon: 'tabler:rss', href: getAsset('/rss.xml') },
- { ariaLabel: 'Github', icon: 'tabler:brand-github', href: 'https://github.com/LycheeOrg/Lychee' },
+ { ariaLabel: 'RSS', icon: 'tabler:rss', href: getAsset('/rss.xml') },
+ { ariaLabel: 'Github', icon: 'tabler:brand-github', href: 'https://github.com/LycheeOrg/Lychee' },
],
footNote: `Maintained by LycheeOrg — Built with Astro & Tailwind CSS`,
};
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 7cfd54c8..a6dbe11d 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -13,11 +13,14 @@ import show0 from '~/assets/images/showcase/0.jpg';
import show1 from '~/assets/images/showcase/1.jpg';
import show2 from '~/assets/images/showcase/2.jpg';
import show3 from '~/assets/images/showcase/3.jpg';
+import { getRepoStats } from '~/utils/repoStats';
const metadata = {
title: 'LycheeOrg — Self-hosted photo-management done right.',
ignoreTitleTemplate: true,
};
+
+const repoStats = await getRepoStats();
---
@@ -42,9 +45,9 @@ const metadata = {
- Lychee is a free photo-management tool, which runs on your server or web-space.
- Installing is a matter of seconds. Upload, manage and share photos like from a native application. Lychee comes with
- everything you need and all your photos are stored securely.
+ Lychee is a free photo-management tool, which runs on your server or web-space. Installing
+ is a matter of seconds. Upload, manage and share photos like from a native application. Lychee comes with everything
+ you need and all your photos are stored securely.
@@ -81,13 +84,7 @@ const metadata = {
>
-
+
@@ -179,7 +176,6 @@ const metadata = {
]}
/>
-
@@ -229,10 +225,10 @@ const metadata = {
@@ -247,6 +243,12 @@ const metadata = {
target: '_blank',
icon: 'tabler:download',
},
+ {
+ variant: 'secondary',
+ text: 'Docker Compose Wizard',
+ href: '/wizard/',
+ icon: 'tabler:wand',
+ },
]}
>
Lychee on Docker
diff --git a/src/pages/support.astro b/src/pages/support.astro
index a32ee671..c658498e 100644
--- a/src/pages/support.astro
+++ b/src/pages/support.astro
@@ -38,7 +38,7 @@ const metadata = {
stats={[
{ title: 'Started', amount: '2018' },
{ title: 'Devs', amount: '5' },
- { title: 'Lines of Code', amount: '300K' },
+ { title: 'Lines of Code', amount: '380K' },
]}
/>
diff --git a/src/pages/wizard.astro b/src/pages/wizard.astro
new file mode 100644
index 00000000..a4c91d8e
--- /dev/null
+++ b/src/pages/wizard.astro
@@ -0,0 +1,1433 @@
+---
+import Layout from '~/layouts/Layout.astro';
+
+const metadata = {
+ title: 'Docker Compose Wizard',
+ description:
+ 'Generate a ready-to-run docker-compose.yaml and .env for Lychee, right in your browser. Same questions as the Lychee Wizard CLI, no install required.',
+};
+
+const inputClass =
+ 'py-2 px-3 block w-full flex-1 min-w-0 text-xs rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900 focus:border-primary focus:ring-primary disabled:opacity-50';
+const selectClass = inputClass;
+const labelClass = 'text-xs font-medium sm:w-40 sm:shrink-0';
+const fieldRowClass = 'flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4';
+const descClass = 'text-xs text-gray-500 dark:text-gray-400 mt-1';
+const fieldsetClass = 'bg-white dark:bg-slate-900';
+const checkboxRowClass = 'flex items-start gap-3';
+const checkboxClass =
+ 'h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 dark:border-gray-600 text-primary focus:ring-primary';
+const badgeClass =
+ 'ml-2 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200';
+// Service card status badges — like badgeClass, but without its ml-2 (these
+// sit in a flex row with its own gap, not inline after label text).
+const statusOkClass =
+ 'px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200';
+const statusWarnClass =
+ 'px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
+// Small tags next to a service card's title (AI, SE).
+const tagAiClass =
+ 'px-1.5 py-0.5 rounded text-xxs font-semibold bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200';
+const tagSeClass =
+ 'px-1.5 py-0.5 rounded text-xxs font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-200';
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ docker-compose.yaml
+
+
+
+
+
+
+
+
+
+
+ .env
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/types.d.ts b/src/types.d.ts
index 636c93e3..bbdfdb89 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -113,6 +113,7 @@ export interface Stat {
amount?: number | string;
title?: string;
icon?: string;
+ disclaimer?: string;
}
export interface Item {
diff --git a/src/utils/repoStats.ts b/src/utils/repoStats.ts
new file mode 100644
index 00000000..22ad35f1
--- /dev/null
+++ b/src/utils/repoStats.ts
@@ -0,0 +1,129 @@
+// Fetched at build time (site is fully static) to keep the homepage stats
+// widget honest instead of hand-editing numbers on every release.
+
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+
+// process.cwd() (the project root, since Astro always builds from there) rather than
+// import.meta.url — the latter resolves into the transient build bundle, not the source
+// tree, so a path derived from it wouldn't survive between builds.
+const CACHE_FILE = join(process.cwd(), '.cache/repo-stats.json');
+const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
+
+const GITHUB_REPO_URL = 'https://api.github.com/repos/LycheeOrg/Lychee';
+const GITHUB_RELEASES_URL = 'https://api.github.com/repos/LycheeOrg/Lychee/releases?per_page=100';
+// GHCR (ghcr.io/lycheeorg/lychee, ghcr.io/linuxserver/lychee) does not expose pull/download
+// counts through any public or authenticated API, nor on the package page itself — so only
+// the two Docker Hub mirrors, which do report a pull_count, are counted here.
+const DOCKER_HUB_URLS = [
+ 'https://hub.docker.com/v2/repositories/lycheeorg/lychee/',
+ 'https://hub.docker.com/v2/repositories/linuxserver/lychee/',
+];
+
+// Last known-good values, used if a fetch fails (e.g. offline build, rate limiting).
+const FALLBACK = {
+ downloads: 43_000,
+ stars: 4_250,
+ forks: 374,
+ dockerPulls: 4_090_000 + 19_636_000,
+};
+
+export interface RepoStats {
+ downloads: string;
+ stars: string;
+ forks: string;
+ dockerPulls: string;
+}
+
+function formatCount(n: number): string {
+ const format = (value: number, suffix: string) => `${parseFloat(value.toFixed(1))}${suffix}`;
+ if (n >= 1_000_000) return format(n / 1_000_000, 'M');
+ if (n >= 1_000) return format(n / 1_000, 'K');
+ return String(n);
+}
+
+async function fetchGitHubRepo(): Promise<{ stars: number; forks: number }> {
+ const res = await fetch(GITHUB_REPO_URL, { headers: { Accept: 'application/vnd.github+json' } });
+ if (!res.ok) throw new Error(`GitHub repo request failed: ${res.status}`);
+ const data = await res.json();
+ return { stars: data.stargazers_count, forks: data.forks_count };
+}
+
+async function fetchGitHubReleaseDownloads(): Promise {
+ let total = 0;
+ let url: string | null = GITHUB_RELEASES_URL;
+
+ while (url) {
+ const res: Response = await fetch(url, { headers: { Accept: 'application/vnd.github+json' } });
+ if (!res.ok) throw new Error(`GitHub releases request failed: ${res.status}`);
+ const releases: { assets: { download_count: number }[] }[] = await res.json();
+ for (const release of releases) {
+ for (const asset of release.assets) {
+ total += asset.download_count;
+ }
+ }
+
+ const link = res.headers.get('link');
+ const next = link?.split(',').find((part) => part.includes('rel="next"'));
+ url = next ? next.split(';')[0].trim().slice(1, -1) : null;
+ }
+
+ return total;
+}
+
+async function fetchDockerPulls(): Promise {
+ const counts = await Promise.all(
+ DOCKER_HUB_URLS.map(async (url) => {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`Docker Hub request failed for ${url}: ${res.status}`);
+ const data = await res.json();
+ return data.pull_count as number;
+ }),
+ );
+ return counts.reduce((sum, count) => sum + count, 0);
+}
+
+interface Cache {
+ timestamp: number;
+ stats: RepoStats;
+}
+
+function readCache(): RepoStats | null {
+ try {
+ const cache: Cache = JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
+ if (Date.now() - cache.timestamp < CACHE_TTL_MS) return cache.stats;
+ } catch {
+ // no cache yet, or unreadable — fall through to a fresh fetch
+ }
+ return null;
+}
+
+function writeCache(stats: RepoStats): void {
+ try {
+ mkdirSync(dirname(CACHE_FILE), { recursive: true });
+ writeFileSync(CACHE_FILE, JSON.stringify({ timestamp: Date.now(), stats } satisfies Cache));
+ } catch {
+ // best-effort; a failed cache write shouldn't break the build
+ }
+}
+
+export async function getRepoStats(): Promise {
+ const cached = readCache();
+ if (cached) return cached;
+
+ const [repo, downloads, dockerPulls] = await Promise.all([
+ fetchGitHubRepo().catch(() => ({ stars: FALLBACK.stars, forks: FALLBACK.forks })),
+ fetchGitHubReleaseDownloads().catch(() => FALLBACK.downloads),
+ fetchDockerPulls().catch(() => FALLBACK.dockerPulls),
+ ]);
+
+ const stats: RepoStats = {
+ downloads: formatCount(downloads),
+ stars: formatCount(repo.stars),
+ forks: formatCount(repo.forks),
+ dockerPulls: formatCount(dockerPulls),
+ };
+
+ writeCache(stats);
+ return stats;
+}
diff --git a/src/utils/wizard/answers.ts b/src/utils/wizard/answers.ts
new file mode 100644
index 00000000..eab49adb
--- /dev/null
+++ b/src/utils/wizard/answers.ts
@@ -0,0 +1,106 @@
+// Based on github.com/LycheeOrg/Wizard's internal/wizard.Answers and
+// Defaults(), extended with a few web-only options (database engine/location
+// choice, NSFW classification) the CLI doesn't offer.
+export type DbEngine = 'mariadb' | 'pgsql' | 'sqlite';
+export type DbLocation = 'docker' | 'external';
+
+export interface WizardAnswers {
+ // General
+ appName: string;
+ appUrl: string;
+ appPort: string;
+ appForceHttps: boolean;
+ timezone: string;
+
+ // Database
+ dbEngine: DbEngine;
+ dbLocation: DbLocation;
+ dbHost: string;
+ dbPort: string;
+ dbDatabase: string;
+ dbUsername: string;
+ generatePasswords: boolean;
+ dbPassword: string;
+ dbRootPassword: string;
+
+ // Configuration delivery
+ useEnvFile: boolean;
+
+ // Secrets handling
+ useDockerSecrets: boolean;
+
+ // Optional services
+ enablePhpMyAdmin: boolean;
+ enableAiVision: boolean;
+ customAiVisionKey: boolean;
+ aiVisionApiKey: string;
+ enableNsfw: boolean;
+ customNsfwKey: boolean;
+ nsfwApiKey: string;
+
+ // OAuth login providers — ids of providers the user has added a card for
+ // (see oauthProviders.ts), and their filled-in field values, keyed
+ // `${providerId}:${fieldKey}`.
+ activeOAuthProviders: string[];
+ oauthFieldValues: Record;
+
+ // Queue worker
+ useWorker: boolean;
+ workerCount: string;
+
+ // Traefik reverse proxy
+ enableTraefik: boolean;
+ traefikEntrypoint: string;
+ traefikCertResolver: string;
+ traefikNetwork: string;
+
+ // System
+ puid: string;
+ pgid: string;
+}
+
+export function defaultAnswers(): WizardAnswers {
+ return {
+ appName: 'Lychee',
+ appUrl: 'http://localhost',
+ appPort: '8000',
+ appForceHttps: false,
+ timezone: 'UTC',
+ dbEngine: 'mariadb',
+ dbLocation: 'docker',
+ dbHost: '',
+ dbPort: '',
+ dbDatabase: 'lychee',
+ dbUsername: 'lychee',
+ generatePasswords: true,
+ dbPassword: '',
+ dbRootPassword: '',
+ useEnvFile: true,
+ useDockerSecrets: true,
+ enablePhpMyAdmin: false,
+ enableAiVision: false,
+ customAiVisionKey: false,
+ aiVisionApiKey: '',
+ enableNsfw: false,
+ customNsfwKey: false,
+ nsfwApiKey: '',
+ activeOAuthProviders: [],
+ oauthFieldValues: {},
+ useWorker: true,
+ workerCount: '1',
+ enableTraefik: false,
+ traefikEntrypoint: 'websecure',
+ traefikCertResolver: 'letsencrypt',
+ traefikNetwork: 'traefik',
+ puid: '1000',
+ pgid: '1000',
+ };
+}
+
+// needsDbService reports whether the answers require Lychee's own
+// docker-managed database service (currently: MariaDB only — Lychee's
+// official compose file doesn't ship a Postgres container, and SQLite needs
+// no server at all).
+export function needsDbService(a: Pick): boolean {
+ return a.dbEngine === 'mariadb' && a.dbLocation === 'docker';
+}
diff --git a/src/utils/wizard/composeEdit.ts b/src/utils/wizard/composeEdit.ts
new file mode 100644
index 00000000..78ecf587
--- /dev/null
+++ b/src/utils/wizard/composeEdit.ts
@@ -0,0 +1,27 @@
+// Shared structural (indentation-based) YAML editing helper for
+// docker-compose.yaml patches that remove a whole service block by shape
+// rather than literal text match — see dbCompose.ts's module comment for
+// why that's preferable to a literal patch here.
+
+// removeIndentedBlock removes the line matching startLineRegex and every
+// following line that's indented deeper than it, i.e. its whole nested
+// block. A single blank line immediately before the block is swallowed too,
+// so removal doesn't leave a double blank line behind.
+export function removeIndentedBlock(lines: string[], startLineRegex: RegExp): string[] {
+ const start = lines.findIndex((l) => startLineRegex.test(l));
+ if (start === -1) return lines;
+
+ const indent = (/^ */.exec(lines[start]) ?? [''])[0].length;
+ let end = lines.length;
+ for (let i = start + 1; i < lines.length; i++) {
+ const m = /^( *)\S/.exec(lines[i]);
+ if (m && m[1].length <= indent) {
+ end = i;
+ break;
+ }
+ }
+
+ let removeStart = start;
+ if (start > 0 && lines[start - 1].trim() === '') removeStart = start - 1;
+ return [...lines.slice(0, removeStart), ...lines.slice(end)];
+}
diff --git a/src/utils/wizard/dbCompose.ts b/src/utils/wizard/dbCompose.ts
new file mode 100644
index 00000000..669b96e8
--- /dev/null
+++ b/src/utils/wizard/dbCompose.ts
@@ -0,0 +1,85 @@
+// Structural (indent-based) edits to docker-compose.yaml for database engine
+/// location choices that github.com/LycheeOrg/Wizard's CLI doesn't offer
+// (it always ships the bundled MariaDB service). Unlike dockerSecrets.ts,
+// these don't match literal upstream text — they match by YAML indentation
+// shape, so they degrade gracefully (best-effort, no-op if not found) even
+// if upstream reformats comments inside the blocks they touch.
+
+import { removeIndentedBlock } from './composeEdit';
+
+// removeDependsOnEntry removes a `:\n condition: service_healthy`
+// pair from under any `depends_on:` mapping, and removes the now-empty
+// `depends_on:` line itself if that entry was its only child.
+function removeDependsOnEntry(lines: string[], serviceName: string): string[] {
+ const out: string[] = [];
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const keyMatch = new RegExp(`^(\\s+)${serviceName}:\\s*$`).exec(line);
+ const next = lines[i + 1] ?? '';
+ if (keyMatch && /^\s+condition:\s*service_healthy\s*$/.test(next)) {
+ const indent = keyMatch[1].length;
+ const after = lines[i + 2] ?? '';
+ const hasMoreSiblings = new RegExp(`^ {${indent}}\\S`).test(after);
+ const prevPushed = out[out.length - 1];
+ const dependsOnRe = new RegExp(`^ {${Math.max(indent - 2, 0)}}depends_on:\\s*$`);
+ if (!hasMoreSiblings && prevPushed !== undefined && dependsOnRe.test(prevPushed)) {
+ out.pop();
+ }
+ i += 1; // also skip the `condition:` line
+ continue;
+ }
+ out.push(line);
+ }
+ return out;
+}
+
+export interface RemoveDbServiceResult {
+ compose: string;
+ removed: boolean;
+}
+
+// removeDbService strips Lychee's bundled `lychee_db` (MariaDB) service —
+// used when the wizard answers call for SQLite or an externally-managed
+// database — along with every `depends_on: lychee_db: …` reference to it and
+// its now-orphaned `mysql:` named volume, so the resulting compose file
+// stays valid on its own.
+export function removeDbService(compose: string): RemoveDbServiceResult {
+ let lines = compose.split('\n');
+ const before = lines.length;
+
+ lines = removeIndentedBlock(lines, /^ {2}lychee_db:\s*$/);
+ const removed = lines.length !== before;
+
+ lines = removeDependsOnEntry(lines, 'lychee_db');
+ lines = removeIndentedBlock(lines, /^ {2}mysql:\s*$/);
+
+ return { compose: lines.join('\n'), removed };
+}
+
+export interface AddSqliteVolumeResult {
+ compose: string;
+ added: boolean;
+}
+
+// addSqliteVolume mounts the SQLite database file onto /app/database, right
+// after the existing uploads/logs/tmp mounts in the shared
+// x-base-lychee-setup anchor (so both lychee_api and lychee_worker inherit
+// it). Without this, SQLite's database.sqlite (Laravel's database_path()
+// default — see config/database.php) lives only inside the container's
+// writable layer and is lost on `docker compose down` / container
+// recreation. Mounting the file itself (not the whole directory) avoids
+// masking anything else Lychee may keep under /app/database.
+export function addSqliteVolume(compose: string): AddSqliteVolumeResult {
+ const lines = compose.split('\n');
+ const anchor = ' - ./lychee/tmp:/app/storage/tmp';
+ const idx = lines.findIndex((l) => l === anchor);
+ if (idx === -1) return { compose, added: false };
+
+ const insertion = [
+ ' # Database: where the SQLite database file is stored, so it persists',
+ ' # across container restarts/recreation.',
+ ' - ./lychee/database/database.sqlite:/app/database/database.sqlite',
+ ];
+ const newLines = [...lines.slice(0, idx + 1), ...insertion, ...lines.slice(idx + 1)];
+ return { compose: newLines.join('\n'), added: true };
+}
diff --git a/src/utils/wizard/dockerSecrets.ts b/src/utils/wizard/dockerSecrets.ts
new file mode 100644
index 00000000..08bf89e7
--- /dev/null
+++ b/src/utils/wizard/dockerSecrets.ts
@@ -0,0 +1,164 @@
+// Mirrors github.com/LycheeOrg/Wizard's internal/generator/dockersecrets.go:
+// patches docker-compose.yaml to activate the file-based Docker secrets
+// Lychee's compose file already ships in commented-out form.
+
+interface SecretPatch {
+ name: string;
+ lines: string[];
+ // toggle receives the block's original lines (untouched) and must return
+ // the replacement lines, same length.
+ toggle: (block: string[]) => string[];
+}
+
+function splitLeadingWS(line: string): { ws: string; rest: string } {
+ const m = /^[ \t]*/.exec(line);
+ const ws = m ? m[0] : '';
+ return { ws, rest: line.slice(ws.length) };
+}
+
+// uncomment strips a leading "#" (and one following space, if present) from
+// a line, preserving its original leading whitespace.
+function uncomment(line: string): string {
+ const { ws, rest } = splitLeadingWS(line);
+ let r = rest;
+ if (r.startsWith('#')) r = r.slice(1);
+ if (r.startsWith(' ')) r = r.slice(1);
+ return ws + r;
+}
+
+// commentOut prefixes a line with "# " right after its leading whitespace.
+function commentOut(line: string): string {
+ const { ws, rest } = splitLeadingWS(line);
+ if (rest.startsWith('#')) return line;
+ return ws + '# ' + rest;
+}
+
+function uncommentAll(block: string[]): string[] {
+ return block.map(uncomment);
+}
+
+// essentialPatches apply regardless of database engine/location (they live
+// in x-base-lychee-setup / x-common-env, shared by every container) and
+// activate file-based Docker secrets for APP_KEY and DB_PASSWORD. If any of
+// these can't be found, enableDockerSecrets reports failure.
+const essentialPatches: SecretPatch[] = [
+ {
+ name: 'top-level secrets block',
+ lines: [
+ '# secrets:',
+ '# db_password:',
+ '# file: ./secrets/db_password',
+ '# db_master_password:',
+ '# file: ./secrets/db_master_password',
+ '# app_key:',
+ '# file: ./secrets/app_key',
+ ],
+ toggle: uncommentAll,
+ },
+ {
+ name: 'x-base-lychee-setup secrets list',
+ lines: ['# secrets:', '# - db_password', '# - app_key'],
+ toggle: uncommentAll,
+ },
+ {
+ name: 'APP_KEY / APP_KEY_FILE swap',
+ lines: ['APP_KEY: "${APP_KEY:-}"', '# APP_KEY_FILE: "/run/secrets/app_key"'],
+ toggle: (block) => [commentOut(block[0]), uncomment(block[1])],
+ },
+ {
+ name: 'DB_PASSWORD / DB_PASSWORD_FILE swap',
+ lines: [
+ 'DB_PASSWORD: "${DB_PASSWORD:-password}"',
+ '#',
+ '# Or you can uncomment the following line to use DB_PASSWORD_FILE from secrets',
+ '# DB_PASSWORD_FILE: "/run/secrets/db_password"',
+ ],
+ toggle: (block) => [commentOut(block[0]), block[1], block[2], uncomment(block[3])],
+ },
+];
+
+// optionalPatches only exist inside Lychee's bundled MariaDB service
+// (`lychee_db`). They're best-effort: when the wizard answers remove or
+// replace that service (SQLite, an external database, or a non-MariaDB
+// engine), this text simply won't be there, and that's fine — skip silently
+// rather than treating it as a failure.
+const optionalPatches: SecretPatch[] = [
+ {
+ name: 'lychee_db secrets list',
+ lines: ['# secrets:', '# - db_master_password', '# - db_password'],
+ toggle: uncommentAll,
+ },
+ {
+ name: 'MYSQL_ROOT_PASSWORD / MYSQL_ROOT_PASSWORD_FILE swap',
+ lines: [
+ '- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-rootpassword}',
+ '# - MYSQL_ROOT_PASSWORD_FILE=/run/secrets/db_master_password',
+ ],
+ toggle: (block) => [commentOut(block[0]), uncomment(block[1])],
+ },
+ {
+ name: 'MYSQL_PASSWORD / MYSQL_PASSWORD_FILE swap',
+ lines: ['- MYSQL_PASSWORD=${DB_PASSWORD:-password}', '# - MYSQL_PASSWORD_FILE=/run/secrets/db_password'],
+ toggle: (block) => [commentOut(block[0]), uncomment(block[1])],
+ },
+];
+
+// matchBlock finds the first index at or after `from` where the trimmed
+// content of consecutive lines equals expected, in order. Returns -1 if not
+// found.
+function matchBlock(lines: string[], from: number, expected: string[]): number {
+ if (expected.length === 0) return from;
+ for (let i = from; i + expected.length <= lines.length; i++) {
+ let match = true;
+ for (let j = 0; j < expected.length; j++) {
+ if (lines[i + j].trim() !== expected[j]) {
+ match = false;
+ break;
+ }
+ }
+ if (match) return i;
+ }
+ return -1;
+}
+
+export interface EnableDockerSecretsResult {
+ patched: string;
+ ok: boolean;
+ reason?: string;
+}
+
+// enableDockerSecrets patches compose (docker-compose.yaml content) to
+// activate the file-based Docker secrets Lychee's compose file already ships
+// in commented-out form. If the upstream file no longer contains one of the
+// expected blocks (e.g. it was restructured), ok is false and reason
+// explains what wasn't found; compose is returned unmodified in that case.
+function applyPatch(lines: string[], patch: SecretPatch): boolean {
+ const start = matchBlock(lines, 0, patch.lines);
+ if (start === -1) return false;
+ const block = lines.slice(start, start + patch.lines.length);
+ const replacement = patch.toggle(block);
+ for (let j = 0; j < replacement.length; j++) {
+ lines[start + j] = replacement[j];
+ }
+ return true;
+}
+
+export function enableDockerSecrets(compose: string): EnableDockerSecretsResult {
+ const lines = compose.split('\n');
+
+ for (const patch of essentialPatches) {
+ if (!applyPatch(lines, patch)) {
+ return {
+ patched: compose,
+ ok: false,
+ reason: `could not locate "${patch.name}" in docker-compose.yaml`,
+ };
+ }
+ }
+
+ for (const patch of optionalPatches) {
+ applyPatch(lines, patch);
+ }
+
+ return { patched: lines.join('\n'), ok: true };
+}
diff --git a/src/utils/wizard/envFileCompose.ts b/src/utils/wizard/envFileCompose.ts
new file mode 100644
index 00000000..47248af6
--- /dev/null
+++ b/src/utils/wizard/envFileCompose.ts
@@ -0,0 +1,60 @@
+// Patches applied when the wizard answers say not to use a separate .env
+// file — everything the wizard would otherwise have written there gets
+// baked directly into docker-compose.yaml instead, and the env_file
+// references (which would point at a file that no longer exists) are
+// stripped.
+
+import { removeIndentedBlock } from './composeEdit';
+
+export interface RemoveEnvFileReferencesResult {
+ compose: string;
+ removed: boolean;
+}
+
+// removeEnvFileReferences strips both `env_file: [{path: ./.env, ...}]`
+// blocks — the one in x-base-lychee-setup (inherited by lychee_api and
+// lychee_worker) and the one on lychee_db.
+export function removeEnvFileReferences(compose: string): RemoveEnvFileReferencesResult {
+ let lines = compose.split('\n');
+ let removedAny = false;
+
+ // There are exactly two occurrences; removeIndentedBlock only strips the
+ // first match per call, so run it once per occurrence.
+ for (let i = 0; i < 2; i++) {
+ const before = lines.length;
+ lines = removeIndentedBlock(lines, /^\s*env_file:\s*$/);
+ if (lines.length !== before) removedAny = true;
+ }
+
+ return { compose: lines.join('\n'), removed: removedAny };
+}
+
+export interface RemovePhpMyAdminProfileGateResult {
+ compose: string;
+ removed: boolean;
+}
+
+// removePhpMyAdminProfileGate strips phpmyadmin's `profiles: [phpmyadmin]`
+// gate. Normally that's flipped on via COMPOSE_PROFILES in .env; without a
+// .env file there's no clean way to set it, so if the wizard answers asked
+// for phpMyAdmin, it needs to just always start instead.
+export function removePhpMyAdminProfileGate(compose: string): RemovePhpMyAdminProfileGateResult {
+ const lines = compose.split('\n');
+ const patched = removeIndentedBlock(lines, /^\s*profiles:\s*$/);
+ return { compose: patched.join('\n'), removed: patched.length !== lines.length };
+}
+
+const VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}/g;
+
+// inlineEnvVars replaces every `${KEY}` / `${KEY:-default}` left in compose
+// with a literal value: the wizard-computed value if there is one, else the
+// template's own fallback default. Substitution is document-wide by design
+// — e.g. DB_PASSWORD is interpolated both in lychee_api/lychee_worker's
+// environment and in lychee_db's MYSQL_PASSWORD, and both need to end up
+// with the *same* literal value for auth between the containers to work.
+export function inlineEnvVars(compose: string, values: Record): string {
+ return compose.replace(VAR_PATTERN, (_match, key: string, _hasDefault, def: string | undefined) => {
+ if (Object.prototype.hasOwnProperty.call(values, key)) return values[key];
+ return def ?? '';
+ });
+}
diff --git a/src/utils/wizard/generator.ts b/src/utils/wizard/generator.ts
new file mode 100644
index 00000000..dd5c768b
--- /dev/null
+++ b/src/utils/wizard/generator.ts
@@ -0,0 +1,302 @@
+// Based on github.com/LycheeOrg/Wizard's internal/generator/generator.go:
+// turns the fetched/embedded Lychee templates plus the wizard's answers into
+// docker-compose.yaml, .env, and (when requested) Docker secrets file
+// contents. Unlike the Go CLI, nothing is written to disk here — callers
+// get strings back to display, copy, or download. Extended with database
+// engine/location and NSFW classification, which the CLI doesn't offer.
+import { enableDockerSecrets } from './dockerSecrets';
+import { removeDbService, addSqliteVolume } from './dbCompose';
+import { insertNsfwService } from './nsfwService';
+import { removeWorkerService } from './workerCompose';
+import { addTraefikLabels } from './traefikCompose';
+import { removePhpMyAdminService } from './phpMyAdminCompose';
+import { removeEnvFileReferences, removePhpMyAdminProfileGate, inlineEnvVars } from './envFileCompose';
+import { OAUTH_PROVIDERS } from './oauthProviders';
+import { needsDbService, type WizardAnswers } from './answers';
+
+interface KV {
+ key: string;
+ value: string;
+}
+
+// Random values are generated once per page load (or on demand via a
+// "regenerate" action) by the caller, not on every render — otherwise every
+// keystroke elsewhere in the form would silently rotate the displayed
+// secrets. generate() only decides *whether* a given secret is used, based
+// on the answers (e.g. a.generatePasswords), and never generates entropy
+// itself.
+export interface GeneratedSecrets {
+ appKey: string;
+ dbPassword: string;
+ dbRootPassword: string;
+ aiVisionApiKey: string;
+ nsfwApiKey: string;
+}
+
+export interface GenerateResult {
+ env: string;
+ compose: string;
+ // filename -> content, only populated when Docker secrets were enabled
+ secretFiles: KV[];
+ warnings: string[];
+ secretsUsed: boolean;
+ envFileUsed: boolean;
+ appUrl: string;
+}
+
+const DB_CONNECTION_VALUE: Record = {
+ mariadb: 'mysql',
+ pgsql: 'pgsql',
+ sqlite: 'sqlite',
+};
+
+const DB_DEFAULT_PORT: Record = {
+ mariadb: '3306',
+ pgsql: '5432',
+ sqlite: '',
+};
+
+function escapeRegExp(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function formatKV(key: string, value: string): string {
+ if (value !== '' && /[ #"'$]/.test(value)) {
+ value = JSON.stringify(value);
+ }
+ return `${key}=${value}`;
+}
+
+function boolStr(b: boolean): string {
+ return b ? 'true' : 'false';
+}
+
+// extractHost pulls just the hostname out of the wizard's Application URL
+// answer, for use in a Traefik Host() rule (which doesn't want a scheme,
+// port, or path). Falls back to a best-effort strip if the URL doesn't
+// parse (e.g. mid-edit while typing).
+function extractHost(appUrl: string): string {
+ try {
+ return new URL(appUrl).hostname || appUrl;
+ } catch {
+ return appUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/:]/)[0];
+ }
+}
+
+// buildEnv produces the final .env content: envExample with envSets values
+// substituted in place, plus an appended "Docker Compose overrides" section
+// for overrides (and any envSets) that have no line in envExample.
+function buildEnv(envExample: string, envSets: KV[], overrides: KV[]): string {
+ let lines = envExample.split('\n');
+ const matched = new Set();
+
+ for (const set of envSets) {
+ const re = new RegExp('^#?\\s*' + escapeRegExp(set.key) + '=.*$');
+ const newLines: string[] = [];
+ let replacedOnce = false;
+ for (const l of lines) {
+ if (!replacedOnce && re.test(l)) {
+ newLines.push(formatKV(set.key, set.value));
+ replacedOnce = true;
+ matched.add(set.key);
+ continue;
+ }
+ newLines.push(l);
+ }
+ lines = newLines;
+ }
+
+ let out = lines.join('\n');
+ if (!out.endsWith('\n')) out += '\n';
+
+ const extraSets = envSets.filter((s) => !matched.has(s.key));
+ const pending = [...extraSets, ...overrides];
+ if (pending.length > 0) {
+ out += '\n# ---- Docker Compose overrides ----\n';
+ for (const o of pending) {
+ out += formatKV(o.key, o.value) + '\n';
+ }
+ }
+
+ return out;
+}
+
+// generate mirrors generator.Generate, minus the filesystem writes. Secret
+// values are supplied by the caller (see GeneratedSecrets) rather than
+// generated here.
+export function generate(
+ envExample: string,
+ composeTemplate: string,
+ a: WizardAnswers,
+ secrets: GeneratedSecrets
+): GenerateResult {
+ const warnings: string[] = [];
+ let compose = composeTemplate;
+ const needsDb = needsDbService(a);
+
+ if (!needsDb) {
+ const { compose: patched, removed } = removeDbService(compose);
+ compose = patched;
+ if (!removed) {
+ warnings.push('could not remove the bundled database service automatically; please remove `lychee_db` by hand');
+ }
+ }
+ if (a.dbEngine === 'sqlite') {
+ const { compose: patched, added } = addSqliteVolume(compose);
+ compose = patched;
+ if (!added) {
+ warnings.push(
+ 'could not add a persistent volume for the SQLite database automatically; add `./lychee/database/database.sqlite:/app/database/database.sqlite` under lychee_api/lychee_worker by hand, or your data will be lost when the container is recreated'
+ );
+ }
+ }
+ if (a.enableNsfw) {
+ const { compose: patched, inserted } = insertNsfwService(compose);
+ compose = patched;
+ if (!inserted) {
+ warnings.push(
+ 'could not add the NSFW classification service automatically; add it to docker-compose.yaml by hand'
+ );
+ }
+ }
+ if (!a.useWorker) {
+ const { compose: patched, removed } = removeWorkerService(compose);
+ compose = patched;
+ if (!removed) {
+ warnings.push('could not remove the queue worker service automatically; please remove `lychee_worker` by hand');
+ }
+ }
+ if (a.enablePhpMyAdmin && needsDb) {
+ const { compose: patched, removed } = removePhpMyAdminProfileGate(compose);
+ compose = patched;
+ if (!removed) {
+ warnings.push('could not enable phpMyAdmin automatically; remove its `profiles:` entry from docker-compose.yaml by hand, or it will stay off');
+ }
+ } else {
+ const { compose: patched, removed } = removePhpMyAdminService(compose);
+ compose = patched;
+ if (!removed) {
+ warnings.push('could not remove the phpMyAdmin service automatically; please remove `phpmyadmin` by hand');
+ }
+ }
+
+ let traefikAdded = false;
+ if (a.enableTraefik) {
+ const { compose: patched, added } = addTraefikLabels(compose, {
+ hostname: extractHost(a.appUrl),
+ entrypoint: a.traefikEntrypoint,
+ certResolver: a.traefikCertResolver,
+ });
+ compose = patched;
+ traefikAdded = added;
+ if (!added) {
+ warnings.push('could not add Traefik labels automatically; add them to docker-compose.yaml by hand');
+ }
+ }
+
+ let secretsUsed = false;
+ if (a.useDockerSecrets) {
+ const { patched, ok, reason } = enableDockerSecrets(compose);
+ if (ok) {
+ compose = patched;
+ secretsUsed = true;
+ } else {
+ warnings.push(`could not enable Docker secrets automatically (${reason}); falling back to plain .env values`);
+ }
+ }
+
+ // Collapse blank-line runs left behind by removeDbService/insertNsfwService.
+ compose = compose.replace(/\n{3,}/g, '\n\n');
+
+ const appKey = secrets.appKey;
+ const dbPassword = a.generatePasswords ? secrets.dbPassword : a.dbPassword;
+ const dbRootPassword = a.generatePasswords ? secrets.dbRootPassword : a.dbRootPassword;
+ const aiVisionApiKey = a.enableAiVision && !a.customAiVisionKey ? secrets.aiVisionApiKey : a.aiVisionApiKey;
+ const nsfwApiKey = a.enableNsfw && !a.customNsfwKey ? secrets.nsfwApiKey : a.nsfwApiKey;
+
+ const envSets: KV[] = [
+ { key: 'APP_NAME', value: a.appName },
+ { key: 'APP_URL', value: a.appUrl },
+ { key: 'APP_FORCE_HTTPS', value: boolStr(a.appForceHttps) },
+ { key: 'TIMEZONE', value: a.timezone },
+ { key: 'DB_CONNECTION', value: DB_CONNECTION_VALUE[a.dbEngine] },
+ { key: 'QUEUE_CONNECTION', value: a.useWorker ? 'database' : 'sync' },
+ // Traefik sits in front of Lychee on the same Docker network, so its
+ // requests need to be trusted for X-Forwarded-* headers to be honored.
+ { key: 'TRUSTED_PROXIES', value: a.enableTraefik ? '*' : 'null' },
+ ];
+ if (a.dbEngine !== 'sqlite') {
+ envSets.push({ key: 'DB_DATABASE', value: a.dbDatabase }, { key: 'DB_USERNAME', value: a.dbUsername });
+ }
+ for (const providerId of a.activeOAuthProviders) {
+ const provider = OAUTH_PROVIDERS.find((p) => p.id === providerId);
+ if (!provider) continue;
+ for (const field of provider.fields) {
+ envSets.push({ key: field.envKey, value: a.oauthFieldValues[`${providerId}:${field.key}`] ?? '' });
+ }
+ }
+
+ let secretFiles: KV[] = [];
+ if (secretsUsed) {
+ secretFiles = [
+ { key: 'app_key', value: appKey },
+ { key: 'db_password', value: dbPassword },
+ { key: 'db_master_password', value: dbRootPassword },
+ ];
+ } else {
+ envSets.push({ key: 'APP_KEY', value: appKey });
+ if (a.dbEngine !== 'sqlite') {
+ envSets.push({ key: 'DB_PASSWORD', value: dbPassword });
+ }
+ }
+
+ const overrides: KV[] = [{ key: 'APP_PORT', value: a.appPort }];
+ // docker-compose.yaml already falls back to 1000 for both (`${PUID:-1000}`),
+ // so only emit them when the user actually changed the default.
+ if (a.puid !== '1000') overrides.push({ key: 'PUID', value: a.puid });
+ if (a.pgid !== '1000') overrides.push({ key: 'PGID', value: a.pgid });
+ if (needsDb && !secretsUsed) {
+ overrides.push({ key: 'DB_ROOT_PASSWORD', value: dbRootPassword });
+ }
+ if (!needsDb && a.dbEngine !== 'sqlite') {
+ overrides.push(
+ { key: 'DB_HOST', value: a.dbHost },
+ { key: 'DB_PORT', value: a.dbPort || DB_DEFAULT_PORT[a.dbEngine] }
+ );
+ }
+ const aiVisionEnabled = a.enableAiVision || a.enableNsfw;
+ overrides.push({ key: 'AI_VISION_ENABLED', value: boolStr(aiVisionEnabled) });
+ if (a.enableAiVision) {
+ overrides.push({ key: 'AI_VISION_FACE_API_KEY', value: aiVisionApiKey });
+ }
+ if (a.enableNsfw) {
+ overrides.push(
+ { key: 'AI_VISION_NSFW_URL', value: 'http://lychee_nsfw_classification:8000' },
+ { key: 'AI_VISION_NSFW_API_KEY', value: nsfwApiKey }
+ );
+ }
+
+ if (a.useWorker) {
+ overrides.push({ key: 'WORKER_REPLICAS', value: a.workerCount });
+ }
+ if (a.enableTraefik && traefikAdded) {
+ overrides.push({ key: 'TRAEFIK_NETWORK', value: a.traefikNetwork });
+ }
+
+ let env = '';
+ if (a.useEnvFile) {
+ env = buildEnv(envExample, envSets, overrides);
+ } else {
+ const { compose: patched, removed } = removeEnvFileReferences(compose);
+ compose = patched;
+ if (!removed) {
+ warnings.push('could not remove the env_file reference automatically; please remove it from docker-compose.yaml by hand');
+ }
+ const values: Record = {};
+ for (const kv of [...envSets, ...overrides]) values[kv.key] = kv.value;
+ compose = inlineEnvVars(compose, values);
+ compose = compose.replace(/\n{3,}/g, '\n\n');
+ }
+
+ return { env, compose, secretFiles, warnings, secretsUsed, envFileUsed: a.useEnvFile, appUrl: a.appUrl };
+}
diff --git a/src/utils/wizard/nsfwService.ts b/src/utils/wizard/nsfwService.ts
new file mode 100644
index 00000000..4e9a38b9
--- /dev/null
+++ b/src/utils/wizard/nsfwService.ts
@@ -0,0 +1,99 @@
+// Adds the lychee_nsfw_classification service to docker-compose.yaml.
+// Unlike everything in dockerSecrets.ts, this isn't a patch to upstream
+// content — Lychee's own compose file doesn't ship this service (see
+// https://github.com/LycheeOrg/Lychee-NSFW-Classification), so this module
+// synthesizes a block mirroring the shape of the neighbouring
+// lychee_facial_recognition service, using the env vars documented in that
+// repo's README/.env.example.
+
+const NSFW_SERVICE_LINES = [
+ ' lychee_nsfw_classification:',
+ ' expose:',
+ ' - "${APP_PORT_AI_NSFW:-8002}"',
+ ' ports:',
+ ' - "${APP_PORT_AI_NSFW:-8002}:8000"',
+ ' image: ghcr.io/lycheeorg/lychee-nsfw-classification:latest',
+ ' restart: unless-stopped',
+ ' security_opt:',
+ ' - no-new-privileges:true',
+ ' cap_drop:',
+ ' - ALL',
+ ' environment:',
+ ' # Lychee instance base URL (no trailing slash)',
+ ' VISION_NSFW_LYCHEE_API_URL: "http://lychee_api:8000"',
+ " # Shared API key — must match AI_VISION_NSFW_API_KEY in Lychee's .env",
+ ' VISION_NSFW_API_KEY: "${AI_VISION_NSFW_API_KEY:-changeme}"',
+ ' # Set to false for development environments with self-signed certificates',
+ ' VISION_NSFW_VERIFY_SSL: "${AI_VISION_NSFW_VERIFY_SSL:-true}"',
+ ' # Skip the Lychee connectivity check at startup (useful for local dev)',
+ ' VISION_NSFW_SKIP_LYCHEE_CHECK: "${VISION_NSFW_SKIP_LYCHEE_CHECK:-false}"',
+ '',
+ ' # Named preset: strict, moderation, nude_female, permissive, social_media',
+ ' # https://github.com/LycheeOrg/Lychee-NSFW-Classification#quick-start--choose-a-preset',
+ ' VISION_NSFW_PRESET: "${AI_VISION_NSFW_PRESET:-moderation}"',
+ '',
+ ' VISION_NSFW_LOG_LEVEL: "info"',
+ ' VISION_NSFW_QUEUE_BACKEND: "${VISION_NSFW_QUEUE_BACKEND:-database}"',
+ ' # Maximum pending jobs; requests beyond this are rejected with 429, 0 = unlimited',
+ ' VISION_NSFW_QUEUE_MAX_SIZE: "${VISION_NSFW_QUEUE_MAX_SIZE:-0}"',
+ '',
+ ' # Shared Docker-volume mount point for photo files',
+ ' VISION_NSFW_PHOTOS_PATH: "/data/photos"',
+ ' # SQLite queue storage directory (used when queue backend is "database")',
+ ' VISION_NSFW_STORAGE_PATH: "/data/queue"',
+ '',
+ ' # Number of threads for CPU-bound inference',
+ ' VISION_NSFW_THREAD_POOL_SIZE: 1',
+ ' # Number of Uvicorn worker processes',
+ ' VISION_NSFW_WORKERS: "${AI_VISION_NSFW_WORKERS:-1}"',
+ '',
+ ' # Check the following for more env variables',
+ ' # https://github.com/LycheeOrg/Lychee-NSFW-Classification/blob/master/.env.example',
+ ' volumes:',
+ ' - ./lychee/uploads:/data/photos:ro',
+ ' - nsfw_classification_queue:/data/queue',
+ ' networks:',
+ ' - lychee',
+ ' depends_on:',
+ ' lychee_api:',
+ ' condition: service_healthy',
+ ' healthcheck:',
+ ' test: [ "CMD", "curl", "-f", "http://localhost:8000/api/nsfw/health" ]',
+ ' interval: 30s',
+ ' timeout: 10s',
+ ' retries: 3',
+ ' start_period: 60s',
+];
+
+const NSFW_VOLUME_LINES = [
+ ' nsfw_classification_queue:',
+ ' name: lychee_nsfw_classification_queue',
+ ' driver: local',
+];
+
+export interface InsertNsfwServiceResult {
+ compose: string;
+ inserted: boolean;
+}
+
+// insertNsfwService adds the NSFW classification service as the last entry
+// under `services:` (right before the top-level `networks:` key) and its
+// queue-storage volume under `volumes:`. Best-effort: if either anchor line
+// isn't found (upstream restructured), it leaves the compose untouched for
+// that part.
+export function insertNsfwService(compose: string): InsertNsfwServiceResult {
+ let lines = compose.split('\n');
+
+ const networksIdx = lines.findIndex((l) => /^networks:\s*$/.test(l));
+ if (networksIdx === -1) {
+ return { compose, inserted: false };
+ }
+ lines = [...lines.slice(0, networksIdx), ...NSFW_SERVICE_LINES, '', ...lines.slice(networksIdx)];
+
+ const volumesIdx = lines.findIndex((l) => /^volumes:\s*$/.test(l));
+ if (volumesIdx !== -1) {
+ lines = [...lines.slice(0, volumesIdx + 1), ...NSFW_VOLUME_LINES, ...lines.slice(volumesIdx + 1)];
+ }
+
+ return { compose: lines.join('\n'), inserted: true };
+}
diff --git a/src/utils/wizard/oauthProviders.ts b/src/utils/wizard/oauthProviders.ts
new file mode 100644
index 00000000..1cb98084
--- /dev/null
+++ b/src/utils/wizard/oauthProviders.ts
@@ -0,0 +1,150 @@
+// OAuth login providers Lychee supports, sourced from its own .env.example
+// ("Oauth token data" section). Each provider's `*_REDIRECT_URI` var is
+// deliberately not exposed here — the .env.example itself says to leave it
+// at the default "unless you know exactly what you do."
+export interface OAuthFieldDef {
+ // Unique within the provider; combined with the provider id to form the
+ // generated form field's name (oauth__).
+ key: string;
+ envKey: string;
+ label: string;
+ placeholder?: string;
+ required: boolean;
+}
+
+export interface OAuthProviderDef {
+ id: string;
+ label: string;
+ description?: string;
+ fields: OAuthFieldDef[];
+}
+
+export const OAUTH_PROVIDERS: OAuthProviderDef[] = [
+ {
+ id: 'amazon',
+ label: 'Amazon',
+ fields: [
+ { key: 'clientId', envKey: 'AMAZON_SIGNIN_CLIENT_ID', label: 'Client ID', required: true },
+ { key: 'secret', envKey: 'AMAZON_SIGNIN_SECRET', label: 'Secret', required: true },
+ ],
+ },
+ {
+ id: 'apple',
+ label: 'Apple',
+ description:
+ "The client secret is a JWT with a maximum 6-month lifetime — you'll need to regenerate and update it periodically.",
+ fields: [
+ { key: 'clientId', envKey: 'APPLE_CLIENT_ID', label: 'Client ID', required: true },
+ { key: 'clientSecret', envKey: 'APPLE_CLIENT_SECRET', label: 'Client secret', required: true },
+ ],
+ },
+ {
+ id: 'facebook',
+ label: 'Facebook',
+ fields: [
+ { key: 'clientId', envKey: 'FACEBOOK_CLIENT_ID', label: 'Client ID', required: true },
+ { key: 'clientSecret', envKey: 'FACEBOOK_CLIENT_SECRET', label: 'Client secret', required: true },
+ ],
+ },
+ {
+ id: 'github',
+ label: 'GitHub',
+ fields: [
+ { key: 'clientId', envKey: 'GITHUB_CLIENT_ID', label: 'Client ID', required: true },
+ { key: 'clientSecret', envKey: 'GITHUB_CLIENT_SECRET', label: 'Client secret', required: true },
+ ],
+ },
+ {
+ id: 'google',
+ label: 'Google',
+ fields: [
+ { key: 'clientId', envKey: 'GOOGLE_CLIENT_ID', label: 'Client ID', required: true },
+ { key: 'clientSecret', envKey: 'GOOGLE_CLIENT_SECRET', label: 'Client secret', required: true },
+ ],
+ },
+ {
+ id: 'mastodon',
+ label: 'Mastodon',
+ fields: [
+ {
+ key: 'domain',
+ envKey: 'MASTODON_DOMAIN',
+ label: 'Instance domain',
+ placeholder: 'https://mastodon.social',
+ required: true,
+ },
+ { key: 'id', envKey: 'MASTODON_ID', label: 'Client ID', required: true },
+ { key: 'secret', envKey: 'MASTODON_SECRET', label: 'Client secret', required: true },
+ ],
+ },
+ {
+ id: 'microsoft',
+ label: 'Microsoft',
+ fields: [
+ { key: 'clientId', envKey: 'MICROSOFT_CLIENT_ID', label: 'Client ID', required: true },
+ { key: 'clientSecret', envKey: 'MICROSOFT_CLIENT_SECRET', label: 'Client secret', required: true },
+ { key: 'tenantId', envKey: 'MICROSOFT_TENANT_ID', label: 'Tenant ID', required: true },
+ ],
+ },
+ {
+ id: 'nextcloud',
+ label: 'Nextcloud',
+ fields: [
+ { key: 'clientId', envKey: 'NEXTCLOUD_CLIENT_ID', label: 'Client ID', required: true },
+ { key: 'clientSecret', envKey: 'NEXTCLOUD_CLIENT_SECRET', label: 'Client secret', required: true },
+ {
+ key: 'baseUri',
+ envKey: 'NEXTCLOUD_BASE_URI',
+ label: 'Nextcloud URL',
+ placeholder: 'https://cloud.example.com',
+ required: true,
+ },
+ ],
+ },
+ {
+ id: 'keycloak',
+ label: 'Keycloak',
+ fields: [
+ { key: 'clientId', envKey: 'KEYCLOAK_CLIENT_ID', label: 'Client ID', required: true },
+ { key: 'clientSecret', envKey: 'KEYCLOAK_CLIENT_SECRET', label: 'Client secret', required: true },
+ {
+ key: 'baseUrl',
+ envKey: 'KEYCLOAK_BASE_URL',
+ label: 'Base URL',
+ placeholder: 'https://keycloak.example.com',
+ required: true,
+ },
+ { key: 'realm', envKey: 'KEYCLOAK_REALM', label: 'Realm', required: true },
+ ],
+ },
+ {
+ id: 'authentik',
+ label: 'Authentik',
+ fields: [
+ {
+ key: 'baseUrl',
+ envKey: 'AUTHENTIK_BASE_URL',
+ label: 'Base URL',
+ placeholder: 'https://authentik.example.com',
+ required: true,
+ },
+ { key: 'clientId', envKey: 'AUTHENTIK_CLIENT_ID', label: 'Client ID', required: true },
+ { key: 'clientSecret', envKey: 'AUTHENTIK_CLIENT_SECRET', label: 'Client secret', required: true },
+ ],
+ },
+ {
+ id: 'authelia',
+ label: 'Authelia',
+ fields: [
+ {
+ key: 'baseUrl',
+ envKey: 'AUTHELIA_BASE_URL',
+ label: 'Base URL',
+ placeholder: 'https://authelia.example.com',
+ required: true,
+ },
+ { key: 'clientId', envKey: 'AUTHELIA_CLIENT_ID', label: 'Client ID', required: true },
+ { key: 'clientSecret', envKey: 'AUTHELIA_CLIENT_SECRET', label: 'Client secret', required: true },
+ ],
+ },
+];
diff --git a/src/utils/wizard/phpMyAdminCompose.ts b/src/utils/wizard/phpMyAdminCompose.ts
new file mode 100644
index 00000000..c084183f
--- /dev/null
+++ b/src/utils/wizard/phpMyAdminCompose.ts
@@ -0,0 +1,20 @@
+// Removes Lychee's bundled phpMyAdmin service (`phpmyadmin`) — used when the
+// wizard answers say not to run one, or when there's no bundled database for
+// it to manage. Upstream normally toggles it on via Compose profiles
+// (COMPOSE_PROFILES=phpmyadmin in .env), but that only works with a .env
+// file present — removing the service block outright when unwanted makes it
+// behave like every other optional service (NSFW, worker, Traefik)
+// regardless of that setting.
+
+import { removeIndentedBlock } from './composeEdit';
+
+export interface RemovePhpMyAdminServiceResult {
+ compose: string;
+ removed: boolean;
+}
+
+export function removePhpMyAdminService(compose: string): RemovePhpMyAdminServiceResult {
+ const lines = compose.split('\n');
+ const patched = removeIndentedBlock(lines, /^ {2}phpmyadmin:\s*$/);
+ return { compose: patched.join('\n'), removed: patched.length !== lines.length };
+}
diff --git a/src/utils/wizard/secrets.ts b/src/utils/wizard/secrets.ts
new file mode 100644
index 00000000..ba118d13
--- /dev/null
+++ b/src/utils/wizard/secrets.ts
@@ -0,0 +1,31 @@
+// Mirrors github.com/LycheeOrg/Wizard's internal/generator/secrets.go, using
+// the browser's Web Crypto API in place of Go's crypto/rand.
+
+function randomBytes(numBytes: number): Uint8Array {
+ const buf = new Uint8Array(numBytes);
+ crypto.getRandomValues(buf);
+ return buf;
+}
+
+function toBase64(bytes: Uint8Array): string {
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary);
+}
+
+// Matches Go's base64.RawURLEncoding: URL-safe alphabet, no padding.
+function toBase64Url(bytes: Uint8Array): string {
+ return toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+// generateAppKey returns a Laravel-format application key: "base64:" followed
+// by the base64 encoding of 32 random bytes.
+export function generateAppKey(): string {
+ return 'base64:' + toBase64(randomBytes(32));
+}
+
+// generateSecret returns a random URL-safe secret of the given byte length,
+// suitable for passwords and API keys.
+export function generateSecret(numBytes: number): string {
+ return toBase64Url(randomBytes(numBytes));
+}
diff --git a/src/utils/wizard/templates.ts b/src/utils/wizard/templates.ts
new file mode 100644
index 00000000..92ba7247
--- /dev/null
+++ b/src/utils/wizard/templates.ts
@@ -0,0 +1,33 @@
+// Mirrors github.com/LycheeOrg/Wizard's default (non---local) behaviour of
+// fetching the latest templates straight from LycheeOrg/Lychee@master, so
+// the generated setup matches what upstream actually ships. Falls back to a
+// bundled snapshot (mirroring the CLI's --local flag) if the fetch fails,
+// e.g. offline or GitHub is unreachable.
+
+const ENV_EXAMPLE_URL = 'https://raw.githubusercontent.com/LycheeOrg/Lychee/master/.env.example';
+const COMPOSE_URL = 'https://raw.githubusercontent.com/LycheeOrg/Lychee/master/docker-compose.yaml';
+
+export interface Templates {
+ envExample: string;
+ compose: string;
+ fromLive: boolean;
+}
+
+export async function loadTemplates(fallbackEnvExample: string, fallbackCompose: string): Promise {
+ try {
+ const [envRes, composeRes] = await Promise.all([
+ fetch(ENV_EXAMPLE_URL, { cache: 'no-store' }),
+ fetch(COMPOSE_URL, { cache: 'no-store' }),
+ ]);
+ if (!envRes.ok || !composeRes.ok) {
+ throw new Error('non-200 response fetching upstream templates');
+ }
+ const [envExample, compose] = await Promise.all([envRes.text(), composeRes.text()]);
+ if (!envExample.trim() || !compose.trim()) {
+ throw new Error('empty response fetching upstream templates');
+ }
+ return { envExample, compose, fromLive: true };
+ } catch {
+ return { envExample: fallbackEnvExample, compose: fallbackCompose, fromLive: false };
+ }
+}
diff --git a/src/utils/wizard/traefikCompose.ts b/src/utils/wizard/traefikCompose.ts
new file mode 100644
index 00000000..730f37a4
--- /dev/null
+++ b/src/utils/wizard/traefikCompose.ts
@@ -0,0 +1,72 @@
+// Adds Traefik reverse-proxy integration (routing labels + the external
+// network Traefik itself runs on) to the lychee_api service. Unlike
+// dockerSecrets.ts, this isn't a patch to pre-existing upstream text — Lychee's
+// compose file doesn't ship Traefik wiring, so this synthesizes a block,
+// mirroring the approach in nsfwService.ts.
+
+export interface TraefikOptions {
+ // Host() rule value — the hostname the router matches on. Derived by the
+ // caller from the wizard's Application URL answer.
+ hostname: string;
+ entrypoint: string;
+ // Empty string skips the tls.certresolver label entirely (e.g. Traefik
+ // configured with a default resolver, or TLS terminated elsewhere).
+ certResolver: string;
+}
+
+// Static router/service name: safe to hard-code since the wizard only ever
+// configures a single Lychee instance per compose file, and it sidesteps
+// having to slugify an arbitrary, user-editable app name into something
+// Traefik's label syntax accepts.
+const ROUTER = 'lychee';
+
+function buildLabelLines(o: TraefikOptions): string[] {
+ const lines = [
+ ' labels:',
+ ' - "traefik.enable=true"',
+ ` - "traefik.http.routers.${ROUTER}.rule=Host(\`${o.hostname}\`)"`,
+ ` - "traefik.http.routers.${ROUTER}.entrypoints=${o.entrypoint}"`,
+ ];
+ if (o.certResolver.trim() !== '') {
+ lines.push(` - "traefik.http.routers.${ROUTER}.tls.certresolver=${o.certResolver}"`);
+ }
+ lines.push(` - "traefik.http.services.${ROUTER}.loadbalancer.server.port=8000"`);
+ return lines;
+}
+
+// lychee_api inherits `networks: [lychee]` from the x-base-lychee-setup
+// merge anchor; a service-level `networks:` key here overrides that merge
+// rather than extending it, so `lychee` has to be re-listed alongside the
+// Traefik network.
+const API_NETWORKS_LINES = [' networks:', ' - lychee', ' - traefik'];
+
+// The reference key (`traefik`) is fixed since compose doesn't interpolate
+// mapping keys — the actual underlying Docker network name is configurable
+// via TRAEFIK_NETWORK in .env instead.
+const TOP_LEVEL_NETWORK_LINES = [' traefik:', ' name: "${TRAEFIK_NETWORK:-traefik}"', ' external: true'];
+
+export interface AddTraefikResult {
+ compose: string;
+ added: boolean;
+}
+
+export function addTraefikLabels(compose: string, o: TraefikOptions): AddTraefikResult {
+ let lines = compose.split('\n');
+
+ const portsAnchor = ' - "${APP_PORT:-8000}:8000"';
+ const portsIdx = lines.indexOf(portsAnchor);
+ if (portsIdx === -1) return { compose, added: false };
+
+ lines = [
+ ...lines.slice(0, portsIdx + 1),
+ ...buildLabelLines(o),
+ ...API_NETWORKS_LINES,
+ ...lines.slice(portsIdx + 1),
+ ];
+
+ const networksIdx = lines.findIndex((l) => /^networks:\s*$/.test(l));
+ if (networksIdx === -1) return { compose: lines.join('\n'), added: false };
+ lines = [...lines.slice(0, networksIdx + 1), ...TOP_LEVEL_NETWORK_LINES, ...lines.slice(networksIdx + 1)];
+
+ return { compose: lines.join('\n'), added: true };
+}
diff --git a/src/utils/wizard/validate.ts b/src/utils/wizard/validate.ts
new file mode 100644
index 00000000..32f9a5e0
--- /dev/null
+++ b/src/utils/wizard/validate.ts
@@ -0,0 +1,34 @@
+// Mirrors the field validators in github.com/LycheeOrg/Wizard's
+// internal/wizard/forms.go. Returns an error message, or null if valid.
+
+export function validatePort(s: string): string | null {
+ const n = Number(s);
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
+ return 'Must be a valid port number (1-65535).';
+ }
+ return null;
+}
+
+// validateOptionalPort is validatePort, except a blank value is valid — used
+// for the external-database port field, which falls back to the engine's
+// default port when left empty.
+export function validateOptionalPort(s: string): string | null {
+ if (s.trim() === '') return null;
+ return validatePort(s);
+}
+
+export function validateUint(s: string): string | null {
+ const n = Number(s);
+ if (!Number.isInteger(n) || n < 0) {
+ return 'Must be a non-negative integer.';
+ }
+ return null;
+}
+
+export function validatePositiveInt(s: string): string | null {
+ const n = Number(s);
+ if (!Number.isInteger(n) || n < 1) {
+ return 'Must be a positive integer (1 or more).';
+ }
+ return null;
+}
diff --git a/src/utils/wizard/workerCompose.ts b/src/utils/wizard/workerCompose.ts
new file mode 100644
index 00000000..ca9553d9
--- /dev/null
+++ b/src/utils/wizard/workerCompose.ts
@@ -0,0 +1,17 @@
+// Removes Lychee's bundled queue worker service (`lychee_worker`) — used
+// when the wizard answers say not to run one. QUEUE_CONNECTION falls back
+// to `sync` in that case (see generator.ts), so a worker container would
+// just sit idle with nothing to consume.
+
+import { removeIndentedBlock } from './composeEdit';
+
+export interface RemoveWorkerServiceResult {
+ compose: string;
+ removed: boolean;
+}
+
+export function removeWorkerService(compose: string): RemoveWorkerServiceResult {
+ const lines = compose.split('\n');
+ const patched = removeIndentedBlock(lines, /^ {2}lychee_worker:\s*$/);
+ return { compose: patched.join('\n'), removed: patched.length !== lines.length };
+}
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
deleted file mode 100644
index 32b31393..00000000
--- a/tailwind.config.cjs
+++ /dev/null
@@ -1,24 +0,0 @@
-import defaultTheme from 'tailwindcss/defaultTheme';
-import typographyPlugin from '@tailwindcss/typography';
-
-module.exports = {
- content: ['./src/**/*.{astro,html,js,jsx,json,md,mdx,svelte,ts,tsx,vue}'],
- theme: {
- extend: {
- colors: {
- primary: 'var(--aw-color-primary)',
- secondary: 'var(--aw-color-secondary)',
- accent: 'var(--aw-color-accent)',
- default: 'var(--aw-color-text-default)',
- muted: 'var(--aw-color-text-muted)',
- },
- fontFamily: {
- sans: ['var(--aw-font-sans, ui-sans-serif)', ...defaultTheme.fontFamily.sans],
- serif: ['var(--aw-font-serif, ui-serif)', ...defaultTheme.fontFamily.serif],
- heading: ['var(--aw-font-heading, ui-sans-serif)', ...defaultTheme.fontFamily.sans],
- },
- },
- },
- plugins: [typographyPlugin],
- darkMode: 'class',
-};