diff --git a/.gitignore b/.gitignore index b5d29fdc..1d93af8b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ pnpm-lock.yaml .astro *.old + +# build-time data caches +.cache/ diff --git a/package-lock.json b/package-lock.json index 119ec5da..c3454b21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "astro-seo": "^1.1.0", "limax": "4.2.3", "lodash.merge": "^4.6.2", + "prismjs": "^1.30.0", "unpic": "^4.2.2" }, "devDependencies": { @@ -33,6 +34,7 @@ "@types/js-yaml": "^4.0.9", "@types/lodash.merge": "^4.6.9", "@types/mdx": "^2.0.13", + "@types/prismjs": "^1.26.5", "@typescript-eslint/eslint-plugin": "^8.60.0", "@typescript-eslint/parser": "^8.60.0", "astro-compress": "^2.4.1", @@ -3640,6 +3642,13 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sax": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", diff --git a/package.json b/package.json index 112a99c8..f417544d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "astro-seo": "^1.1.0", "limax": "4.2.3", "lodash.merge": "^4.6.2", + "prismjs": "^1.30.0", "unpic": "^4.2.2" }, "devDependencies": { @@ -47,6 +48,7 @@ "@types/js-yaml": "^4.0.9", "@types/lodash.merge": "^4.6.9", "@types/mdx": "^2.0.13", + "@types/prismjs": "^1.26.5", "@typescript-eslint/eslint-plugin": "^8.60.0", "@typescript-eslint/parser": "^8.60.0", "astro-compress": "^2.4.1", diff --git a/src/assets/styles/tailwind.css b/src/assets/styles/tailwind.css index 05dd7b9a..6e402cb1 100644 --- a/src/assets/styles/tailwind.css +++ b/src/assets/styles/tailwind.css @@ -19,6 +19,9 @@ --font-serif: 'var(--aw-font-serif, ui-serif)', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; --font-heading: 'var(--aw-font-heading, ui-sans-serif)', ui-sans-serif, system-ui, sans-serif; + --text-xxs: 0.65rem; + --text-xxs--line-height: 1rem; + --animate-fade: fadeInUp 1s both; @keyframes fadeInUp { diff --git a/src/components/widgets/Stats.astro b/src/components/widgets/Stats.astro index 709fdd23..2b7b0948 100644 --- a/src/components/widgets/Stats.astro +++ b/src/components/widgets/Stats.astro @@ -22,8 +22,8 @@ const {
{ stats && - stats.map(({ amount, title, icon }) => ( -
+ stats.map(({ amount, title, icon, disclaimer }) => ( +
{icon && (
@@ -39,6 +39,9 @@ const { {title}
)} + { disclaimer && ( +
{disclaimer}
+ )}
)) } diff --git a/src/content/docs/docs/features/facial-recognition.md b/src/content/docs/docs/features/facial-recognition.md index f67c49dc..cc7c0d89 100644 --- a/src/content/docs/docs/features/facial-recognition.md +++ b/src/content/docs/docs/features/facial-recognition.md @@ -115,7 +115,7 @@ The service is configured independently, via its own `.env` file (copy `.env.exa | `VISION_FACE_MAX_FACES_PER_PHOTO` | `10` | Maximum faces included in a callback payload. | | `VISION_FACE_MIN_FACE_SIZE_PIXELS` | `0` | Minimum face size in pixels; `0` disables this filter. | | `VISION_FACE_BLUR_THRESHOLD` | `0.5` | Laplacian variance threshold; blurry faces below this are discarded. | -| `VISION_FACE_CLUSTER_EPS` | `0.6` | DBSCAN epsilon (max cosine distance) used for face clustering. | +| `VISION_FACE_CLUSTER_EPS` | `0.3` | DBSCAN epsilon (max cosine distance) used for face clustering. | ### Storage @@ -193,7 +193,7 @@ The container exposes interactive API docs at `/docs` and a health check at `/he - Try a different `VISION_FACE_DETECTOR_BACKEND` (`retinaface`, `mtcnn`, `opencv`, `ssd`) — detectors vary in recall depending on pose, lighting, and image resolution. **Clustering groups unrelated faces together, or fails to group similar faces:** -- Clustering is controlled by `VISION_FACE_CLUSTER_EPS` (default `0.6`), the DBSCAN epsilon (maximum cosine distance) allowed within a cluster. Lower it if dissimilar people are being grouped together; raise it if the same person is being split across multiple clusters. +- Clustering is controlled by `VISION_FACE_CLUSTER_EPS` (default `0.3`), the DBSCAN epsilon (maximum cosine distance) allowed within a cluster. Lower it if dissimilar people are being grouped together; raise it if the same person is being split across multiple clusters. - To apply a new value: update `VISION_FACE_CLUSTER_EPS` in the microservice's `.env`, restart the microservice so it picks up the change, then re-trigger clustering from _Settings ⇒ Maintenance ⇒ Run Clustering_ in Lychee. This re-clusters every currently unassigned face with the new epsilon — repeat with a different value if the result still isn't right. - The `VISION_FACE_MODEL_NAME` (default `ArcFace`) recognition model determines embedding quality — a different model may produce more consistent embeddings for your photo set, but changing it does not retroactively update existing embeddings. - Poor detections (blurry, low-confidence, or partial faces — see above) produce noisy embeddings that cluster poorly; fixing detection quality often improves clustering as a side effect. diff --git a/src/data/wizard/docker-compose.yaml b/src/data/wizard/docker-compose.yaml new file mode 100644 index 00000000..7e4b21d1 --- /dev/null +++ b/src/data/wizard/docker-compose.yaml @@ -0,0 +1,759 @@ +# 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 + +# More generally, EVERY confidential value backing config/services.php (the +# third-party OAuth/AWS/mail/AI-vision credentials further below) also supports +# the same Docker/Kubernetes secrets convention: set "_FILE" to the path of +# a mounted file instead of setting "" directly, and leave "" itself +# unset. This is resolved by docker/scripts/01-validate-env.sh at container +# startup, before Lychee/PHP ever runs. See the commented "_FILE" examples next +# to each credential below. + +########################################################################################## +# 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) + 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_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 + # XXX_REDIRECT_URI should be left as default unless you know exactly what you do. + + # AMAZON_SIGNIN_CLIENT_ID= + # AMAZON_SIGNIN_SECRET= + # AMAZON_SIGNIN_SECRET_FILE=/run/secrets/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_CLIENT_SECRET_FILE=/run/secrets/apple_client_secret + # APPLE_REDIRECT_URI=/auth/apple/redirect + + # FACEBOOK_CLIENT_ID= + # FACEBOOK_CLIENT_SECRET= + # FACEBOOK_CLIENT_SECRET_FILE=/run/secrets/facebook_client_secret + # FACEBOOK_REDIRECT_URI=/auth/facebook/redirect + + # GITHUB_CLIENT_ID= + # GITHUB_CLIENT_SECRET= + # GITHUB_CLIENT_SECRET_FILE=/run/secrets/github_client_secret + # GITHUB_REDIRECT_URI=/auth/github/redirect + + # GOOGLE_CLIENT_ID= + # GOOGLE_CLIENT_SECRET= + # GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret + # GOOGLE_REDIRECT_URI=/auth/google/redirect + + # MASTODON_DOMAIN=https://mastodon.social + # MASTODON_ID= + # MASTODON_SECRET= + # MASTODON_SECRET_FILE=/run/secrets/mastodon_secret + # MASTODON_REDIRECT_URI=/auth/mastodon/redirect + + # MICROSOFT_CLIENT_ID= + # MICROSOFT_CLIENT_SECRET= + # MICROSOFT_CLIENT_SECRET_FILE=/run/secrets/microsoft_client_secret + # MICROSOFT_REDIRECT_URI=/auth/microsoft/redirect + # MICROSOFT_TENANT_ID= + + # NEXTCLOUD_CLIENT_ID= + # NEXTCLOUD_CLIENT_SECRET= + # NEXTCLOUD_CLIENT_SECRET_FILE=/run/secrets/nextcloud_client_secret + # NEXTCLOUD_REDIRECT_URI=/auth/nextcloud/redirect + # NEXTCLOUD_BASE_URI= + + # KEYCLOAK_CLIENT_ID= + # KEYCLOAK_CLIENT_SECRET= + # KEYCLOAK_CLIENT_SECRET_FILE=/run/secrets/keycloak_client_secret + # KEYCLOAK_REDIRECT_URI=/auth/keycloak/redirect + # KEYCLOAK_BASE_URL= + # KEYCLOAK_REALM= + + # AUTHENTIK_BASE_URL= + # AUTHENTIK_CLIENT_ID= + # AUTHENTIK_CLIENT_SECRET= + # AUTHENTIK_CLIENT_SECRET_FILE=/run/secrets/authentik_client_secret + # AUTHENTIK_REDIRECT_URI=/auth/authentik/redirect + + # AUTHELIA_BASE_URL= + # AUTHELIA_CLIENT_ID= + # AUTHELIA_CLIENT_SECRET= + # AUTHELIA_CLIENT_SECRET_FILE=/run/secrets/authelia_client_secret + # AUTHELIA_REDIRECT_URI=/auth/authelia/redirect + + # AWS support data + + # AWS_ACCESS_KEY_ID= + # AWS_ACCESS_KEY_ID_FILE=/run/secrets/aws_access_key_id + # AWS_SECRET_ACCESS_KEY= + # AWS_SECRET_ACCESS_KEY_FILE=/run/secrets/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_FACE_URL: "http://lychee_facial_recognition:8000" + AI_VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-}" + # AI_VISION_FACE_API_KEY_FILE: "/run/secrets/ai_vision_face_api_key" + + ################################################################### + # NSFW classification (requires SE) + ################################################################### + # AI_VISION_NSFW_URL: "http://lychee_nsfw_classification:8000" + # AI_VISION_NSFW_API_KEY: "${AI_VISION_NSFW_API_KEY:-}" + # AI_VISION_NSFW_API_KEY_FILE: "/run/secrets/ai_vision_nsfw_api_key" + + +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 (disabled by default) + ########################################################################################## + # + # Uncomment to enable horizontal scaling of background job processing + # THIS IS PRETTY MUCH THE SAME CONFIGURATION AS lychee_api WITH A SINGLE MINOR MODIFICATIONS + lychee_worker: + <<: *base-lychee-setup + container_name: lychee-worker + 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: + # 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_API_KEY:-}" + # 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: "${VISION_FACE_CLUSTER_EPS:-0.3}" + + # --- 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: "${VISION_FACE_THREAD_POOL_SIZE:-1}" + # Number of Uvicorn worker processes + VISION_FACE_WORKERS: "${VISION_FACE_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: "${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: "${VISION_FACE_MIN_FACE_SIZE_PIXELS:-0}" + # Laplacian variance threshold for blur detection; faces below this are discarded + VISION_FACE_BLUR_THRESHOLD: "${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 + + ########################################################################################## + # NSFW Classification Service (disabled by default - opt in with --profile nsfw) + ########################################################################################## + # + # Sidecar for https://github.com/LycheeOrg/Lychee-NSFW-Classification, following the same + # REST + webhook architecture as lychee_facial_recognition above. + # Note: unlike every other LycheeOrg repo (MIT), this one is AGPL-3.0-licensed because it + # bundles NudeNet, which is itself AGPL-3.0. + lychee_nsfw_classification: + # 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 + profiles: + - nsfw + environment: + # Lychee instance base URL used for result callbacks (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:-}" + # Set to false for development environments with self-signed certificates + VISION_NSFW_VERIFY_SSL: "${AI_VISION_VERIFY_SSL:-true}" + # Skip the Lychee connectivity check at startup (useful for local dev) + VISION_NSFW_SKIP_LYCHEE_CHECK: "false" + # Shared Docker-volume mount point for photo files + VISION_NSFW_PHOTOS_PATH: "/data/photos" + # SQLite job-queue storage directory (used when queue backend = database, the default) + VISION_NSFW_STORAGE_PATH: "/data/queue" + # Uvicorn/application log level (debug, info, warning, error, critical) + VISION_NSFW_LOG_LEVEL: "info" + + # Check the following for more env variables (presets, per-tier thresholds, etc.) + # https://github.com/LycheeOrg/Lychee-NSFW-Classification/blob/master/.env.example + volumes: + - ./lychee/uploads:/data/photos:ro + - nsfw_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 + + ########################################################################################## + # Local Reverse Geo-Decoding Service (disabled by default - opt in with --profile geo-decoding) + ########################################################################################## + # + # Sidecar for https://github.com/LycheeOrg/Lychee-Reverse-Geo-Coding: a self-contained, + # Nominatim-"/reverse"-compatible service backed by data embedded in the binary (Natural + # Earth country/province/city polygons via rgeo). No API key, no external data download, no + # outbound network calls, no persistent volume - country/province/city resolution only (not + # street-level). config/services.php -> local-geo-decoding.base_url (LOCAL_GEO_DECODING_URL) + # points Lychee at it instead of the public nominatim.openstreetmap.org server. + lychee_geo_decoding: + image: ghcr.io/lycheeorg/lychee-reverse-geo-coding:latest + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + profiles: + - geo-decoding + ports: + - "${APP_PORT_GEO_DECODING:-8003}:8080" + networks: + - lychee + healthcheck: + test: [ "CMD", "wget", "-qO-", "http://localhost:8080/health" ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + +networks: + lychee: + +volumes: + mysql: + name: lychee_prod_mysql + driver: local + ai_vision_embeddings: + name: lychee_ai_vision_embeddings + driver: local + nsfw_queue: + name: lychee_nsfw_queue + driver: local diff --git a/src/data/wizard/env.example b/src/data/wizard/env.example new file mode 100644 index 00000000..fe8e3f7d --- /dev/null +++ b/src/data/wizard/env.example @@ -0,0 +1,397 @@ +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= + +# Only used when MAIL_DRIVER is set to "mailgun" or "postmark" respectively. +# Both secrets also support the "_FILE" convention described above. +# MAILGUN_DOMAIN= +# MAILGUN_SECRET= +# MAILGUN_SECRET_FILE=/run/secrets/mailgun_secret +# MAILGUN_ENDPOINT=api.mailgun.net +# POSTMARK_TOKEN= +# POSTMARK_TOKEN_FILE=/run/secrets/postmark_token + +# 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. +# +# Docker/Kubernetes secrets: when running the official Docker image, every confidential +# value below - as well as AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, MAILGUN_SECRET, +# POSTMARK_TOKEN, AI_VISION_FACE_API_KEY and AI_VISION_NSFW_API_KEY further down - can be +# provided via a file instead of in plaintext here: set "_FILE" to the file's path +# (e.g. AMAZON_SIGNIN_SECRET_FILE=/run/secrets/amazon_signin_secret) and leave "" +# itself unset. This is resolved by docker/scripts/01-validate-env.sh at container +# startup. See docker-compose.yaml for ready-to-use examples. + +# 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_ACCESS_KEY_ID_FILE=/run/secrets/aws_access_key_id +# AWS_SECRET_ACCESS_KEY= +# AWS_SECRET_ACCESS_KEY_FILE=/run/secrets/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_FACE_API_KEY_FILE=/run/secrets/ai_vision_face_api_key +# AI_VISION_NSFW_URL= +# AI_VISION_NSFW_API_KEY= +# AI_VISION_NSFW_API_KEY_FILE=/run/secrets/ai_vision_nsfw_api_key +# +# See the "lychee_facial_recognition" and "lychee_nsfw_classification" services +# in docker-compose.yaml for a ready-to-use sidecar setup for both. + +################################################################### +# 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. +# This is a bring-your-own-service integration - see the commented +# "lychee_geo_decoding" example service in docker-compose.yaml. +# 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 = { >
- Screenshot of Lychee + Screenshot of Lychee @@ -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..8d8bc99e --- /dev/null +++ b/src/pages/wizard.astro @@ -0,0 +1,1843 @@ +--- +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'; + +const wizardStepLabels = [ + 'Welcome', + 'General', + 'Database', + 'Secrets', + 'Services', + 'Workers & Proxy', + 'System', + 'Deploy', +]; +--- + + +
+
+ +
+
    + {wizardStepLabels.map((label, i) => ( + <> +
  1. + +
  2. + {i < wizardStepLabels.length - 1 && ( + + )} + + ))} +
+ +
+
+
+
+

+ Docker Compose Wizard +

+

+ Answer a few questions and we'll generate a ready-to-run docker-compose.yaml and .env for Lychee — entirely in your browser, nothing is sent to any server. Click Next to get started. +

+

+ Loading the latest templates… +

+ +
+ +
+ + + Recommended +
+

+ Keeps configuration in one place, easy to change later without touching docker-compose.yaml. + Turn this off to bake every value directly into docker-compose.yaml instead — only one file + to manage, but changing settings afterwards means editing the compose file itself. +

+
+
+
+ + + + + + + + + + + + + + +
+ +
+ + Step 1 of 8 + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ + +
+
+ docker-compose.yaml +
+ + +
+
+
+
+ + + + +
+
+
+
+ + + + +
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..38a7bfa9 --- /dev/null +++ b/src/utils/repoStats.ts @@ -0,0 +1,135 @@ +// 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; +// A stalled upstream (GitHub/Docker Hub) shouldn't be able to hang the +// static build — one shared deadline covers the whole fetch, including every +// page of fetchGitHubReleaseDownloads' pagination loop, not just each +// individual request. +const FETCH_TIMEOUT_MS = 10_000; + +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(signal: AbortSignal): Promise<{ stars: number; forks: number }> { + const res = await fetch(GITHUB_REPO_URL, { headers: { Accept: 'application/vnd.github+json' }, signal }); + 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(signal: AbortSignal): 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' }, signal }); + 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(signal: AbortSignal): Promise { + const counts = await Promise.all( + DOCKER_HUB_URLS.map(async (url) => { + const res = await fetch(url, { signal }); + 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 signal = AbortSignal.timeout(FETCH_TIMEOUT_MS); + const [repo, downloads, dockerPulls] = await Promise.all([ + fetchGitHubRepo(signal).catch(() => ({ stars: FALLBACK.stars, forks: FALLBACK.forks })), + fetchGitHubReleaseDownloads(signal).catch(() => FALLBACK.downloads), + fetchDockerPulls(signal).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..336c5387 --- /dev/null +++ b/src/utils/wizard/answers.ts @@ -0,0 +1,140 @@ +// 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; + // IPs/CIDR ranges of reverse proxies to trust X-Forwarded-* headers from. + // Blank means "use the automatic default" — see generator.ts's TRUSTED_PROXIES + // envSet, which falls back to '*' when Traefik is enabled, else 'null'. + trustedProxies: 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; + // Facial recognition detection/matching tuning — see + // github.com/LycheeOrg/Lychee-Facial-Recognition's VISION_FACE_* env vars. + aiVisionMaxFacesPerPhoto: string; + aiVisionMinFaceSizePixels: string; + aiVisionBlurThreshold: string; + aiVisionClusterEps: string; + aiVisionQueueMaxSize: string; + aiVisionThreadPoolSize: string; + aiVisionWorkers: string; + enableNsfw: boolean; + customNsfwKey: boolean; + nsfwApiKey: string; + enableGeoDecoding: boolean; + + // 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; + + // Volumes — host-side paths for Lychee's persistent bind mounts. + // volumeDatabasePath only applies when dbEngine is 'sqlite'. + volumeUploadsPath: string; + volumeLogsPath: string; + volumeTmpPath: string; + volumeDatabasePath: string; +} + +export function defaultAnswers(): WizardAnswers { + return { + appName: 'Lychee', + appUrl: 'http://localhost', + appPort: '8000', + appForceHttps: false, + timezone: 'UTC', + trustedProxies: '', + 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: '', + aiVisionMaxFacesPerPhoto: '10', + aiVisionMinFaceSizePixels: '0', + aiVisionBlurThreshold: '0.5', + aiVisionClusterEps: '0.3', + aiVisionQueueMaxSize: '0', + aiVisionThreadPoolSize: '1', + aiVisionWorkers: '1', + enableNsfw: false, + customNsfwKey: false, + nsfwApiKey: '', + enableGeoDecoding: false, + activeOAuthProviders: [], + oauthFieldValues: {}, + useWorker: true, + workerCount: '1', + enableTraefik: false, + traefikEntrypoint: 'websecure', + traefikCertResolver: 'letsencrypt', + traefikNetwork: 'traefik', + puid: '1000', + pgid: '1000', + volumeUploadsPath: './lychee/uploads', + volumeLogsPath: './lychee/logs', + volumeTmpPath: './lychee/tmp', + volumeDatabasePath: './lychee/database/database.sqlite', + }; +} + +// 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..56bb5a13 --- /dev/null +++ b/src/utils/wizard/composeEdit.ts @@ -0,0 +1,90 @@ +// Shared docker-compose.yaml editing primitives used across the wizard's +// compose patches: removing a whole service block by indentation shape +// rather than literal text match (see dbCompose.ts's module comment for why +// that's preferable), and activating an x-common-env line regardless of +// which shape upstream currently ships it in. + +// activateEnvLine ensures `key` is a live x-common-env entry (mutates +// `lines` in place), tolerating whichever of the shapes Lychee's own +// docker-compose.yaml has shipped `key` in: +// - already active YAML: `KEY: "value"` — left untouched. +// - commented YAML: `# KEY: "value"` — uncommented as-is. +// - a bare, non-YAML ".env-style" comment reminding the reader the var +// exists: `# KEY=default` — rewritten into real YAML, +// `KEY: "${KEY:-default}"`, preserving whatever default followed `=`. +// This matters because loadTemplates() normally fetches the *live* template +// from GitHub rather than the bundled fallback snapshot, and upstream ships +// most of these as the inert third form — a wizard answer that depends on +// one of them can't assume any particular shape going in. Returns whether +// `key` ended up active (found in one of the three shapes, or already was). +export function activateEnvLine(lines: string[], key: string): boolean { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + if (lines.some((l) => new RegExp(`^\\s*${escaped}:\\s`).test(l))) return true; + + const yamlRe = new RegExp(`^(\\s*)#\\s*${escaped}:\\s(.*)$`); + const yamlIdx = lines.findIndex((l) => yamlRe.test(l)); + if (yamlIdx !== -1) { + const m = yamlRe.exec(lines[yamlIdx])!; + lines[yamlIdx] = `${m[1]}${key}: ${m[2]}`; + return true; + } + + const bareRe = new RegExp(`^(\\s*)#\\s*${escaped}=(.*)$`); + const bareIdx = lines.findIndex((l) => bareRe.test(l)); + if (bareIdx !== -1) { + const m = bareRe.exec(lines[bareIdx])!; + lines[bareIdx] = `${m[1]}${key}: "\${${key}:-${m[2]}}"`; + return true; + } + + return false; +} + +// activateEnvLines is activateEnvLine for a whole compose string at once — +// the form generator.ts actually wants. Returns the patched compose plus +// whichever of `keys` couldn't be found in any recognized shape. +export function activateEnvLines(compose: string, keys: string[]): { compose: string; missing: string[] } { + const lines = compose.split('\n'); + const missing: string[] = []; + for (const key of keys) { + if (!activateEnvLine(lines, key)) missing.push(key); + } + return { compose: lines.join('\n'), missing }; +} + +// 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. +// +// eatPrecedingComment additionally swallows a contiguous run of same-indent +// `#` comment lines directly above the block (e.g. a banner header +// describing it) — off by default, since not every such comment is actually +// specific to the block being removed. envFileCompose.ts's env_file: +// removal, for instance, sits right under a general "how to configure +// Lychee" comment that should stay even once env_file: is gone. +export function removeIndentedBlock(lines: string[], startLineRegex: RegExp, eatPrecedingComment = false): 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 (eatPrecedingComment) { + const commentRe = new RegExp(`^ {${indent}}#`); + while (removeStart > 0 && commentRe.test(lines[removeStart - 1])) { + removeStart -= 1; + } + } + if (removeStart > 0 && lines[removeStart - 1].trim() === '') removeStart -= 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..a16c5196 --- /dev/null +++ b/src/utils/wizard/dbCompose.ts @@ -0,0 +1,88 @@ +// 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, + hostPath: string = './lychee/database/database.sqlite' +): 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.', + ` - ${hostPath}:/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..fb50bb72 --- /dev/null +++ b/src/utils/wizard/dockerSecrets.ts @@ -0,0 +1,252 @@ +// 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. Beyond the +// essential app_key/db_password/db_root_password secrets, this also wires up +// any of the "additional" secrets (OAuth client secrets, AI-vision API keys — +// see generator.ts) that the wizard answers say to use, following the same +// commented "_FILE" convention documented at the top of the template. + +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; +} + +// applyPatch returns the start index of the matched block (so callers can +// anchor further edits off it), or -1 if the patch's text wasn't found. +function applyPatch(lines: string[], patch: SecretPatch): number { + const start = matchBlock(lines, 0, patch.lines); + if (start === -1) return -1; + 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 start; +} + +// A single extra credential (an OAuth provider's client secret, an +// AI-vision API key, …) to route through a Docker secret instead of a plain +// env var, wherever the wizard answers call for it. +export interface AdditionalSecret { + // Secret's file name under ./secrets/, and the name registered in + // compose's top-level `secrets:` block — matches the "/run/secrets/" + // path in the template's commented "_FILE" line. + name: string; + // The x-common-env key whose value line gets commented out in favor of + // "_FILE" (e.g. "GOOGLE_CLIENT_SECRET"). + composeKey: string; +} + +// swapKeyForFile comments out the (uncommented) `composeKey: value` line and +// uncomments the `composeKey_FILE: ...` line immediately after it — the same +// shape as the essential APP_KEY/DB_PASSWORD patches above, but for a +// dynamic key discovered by name rather than a fixed literal block. The +// `_FILE` companion line itself ships in two different shapes depending on +// which section of docker-compose.yaml it's in: proper YAML for the +// AI-vision keys ("# KEY_FILE: value"), but a bare, non-YAML ".env-style" +// comment for every OAuth provider ("# KEY_FILE=value") — both are handled, +// mirroring activateEnvLine's tolerance in composeEdit.ts. +function swapKeyForFile(lines: string[], composeKey: string): boolean { + const keyRe = new RegExp(`^(\\s*)${composeKey}:\\s`); + const idx = lines.findIndex((l) => keyRe.test(l)); + if (idx === -1) return false; + + const nextLine = lines[idx + 1] ?? ''; + + const yamlFileRe = new RegExp(`^(\\s*)#\\s*${composeKey}_FILE:\\s(.*)$`); + const yamlMatch = yamlFileRe.exec(nextLine); + if (yamlMatch) { + lines[idx] = commentOut(lines[idx]); + lines[idx + 1] = `${yamlMatch[1]}${composeKey}_FILE: ${yamlMatch[2]}`; + return true; + } + + const bareFileRe = new RegExp(`^(\\s*)#\\s*${composeKey}_FILE=(.*)$`); + const bareMatch = bareFileRe.exec(nextLine); + if (bareMatch) { + lines[idx] = commentOut(lines[idx]); + lines[idx + 1] = `${bareMatch[1]}${composeKey}_FILE: "${bareMatch[2]}"`; + return true; + } + + return false; +} + +export interface EnableDockerSecretsResult { + patched: string; + ok: boolean; + reason?: string; + // Which `additional` secrets (by name) were actually wired up — anything + // requested but not found here should fall back to a plain env var. + wired: Set; + // names present in `additional` that could not be located/wired. + failed: 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, for both the essential app_key/db_password/ +// db_root_password secrets and any `additional` ones requested. If the +// upstream file no longer contains one of the essential blocks (e.g. it was +// restructured), ok is false and reason explains what wasn't found; compose +// is returned unmodified in that case. Additional secrets are best-effort — +// individually missing ones are reported via `failed` rather than failing +// the whole operation. +export function enableDockerSecrets(compose: string, additional: AdditionalSecret[] = []): EnableDockerSecretsResult { + const lines = compose.split('\n'); + + let topSecretsEnd = -1; + let baseSecretsEnd = -1; + for (const patch of essentialPatches) { + const start = applyPatch(lines, patch); + if (start === -1) { + return { + patched: compose, + ok: false, + reason: `could not locate "${patch.name}" in docker-compose.yaml`, + wired: new Set(), + failed: additional.map((a) => a.name), + }; + } + if (patch.name === 'top-level secrets block') topSecretsEnd = start + patch.lines.length; + if (patch.name === 'x-base-lychee-setup secrets list') baseSecretsEnd = start + patch.lines.length; + } + + for (const patch of optionalPatches) { + applyPatch(lines, patch); + } + + const wired = new Set(); + const failed: string[] = []; + const topInserts: string[] = []; + const baseInserts: string[] = []; + for (const secret of additional) { + if (swapKeyForFile(lines, secret.composeKey)) { + wired.add(secret.name); + topInserts.push(` ${secret.name}:`, ` file: ./secrets/${secret.name}`); + baseInserts.push(` - ${secret.name}`); + } else { + failed.push(secret.name); + } + } + + // Insert at the later position first so the earlier one's index stays valid. + if (baseInserts.length > 0) lines.splice(baseSecretsEnd, 0, ...baseInserts); + if (topInserts.length > 0) lines.splice(topSecretsEnd, 0, ...topInserts); + + return { patched: lines.join('\n'), ok: true, wired, failed }; +} diff --git a/src/utils/wizard/envFileCompose.ts b/src/utils/wizard/envFileCompose.ts new file mode 100644 index 00000000..34f41b1d --- /dev/null +++ b/src/utils/wizard/envFileCompose.ts @@ -0,0 +1,64 @@ +// 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; + + // removeIndentedBlock only strips the first match per call; keep calling + // it until no more env_file: blocks are found (normally two: the one in + // x-base-lychee-setup and the one on lychee_db, but this stays correct + // even if that count ever changes — this project fetches the *live* + // upstream template by default, not just the bundled snapshot). + while (true) { + const before = lines.length; + lines = removeIndentedBlock(lines, /^\s*env_file:\s*$/); + if (lines.length === before) break; + 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..361f7c3f --- /dev/null +++ b/src/utils/wizard/generator.ts @@ -0,0 +1,435 @@ +// 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 { activateEnvLines } from './composeEdit'; +import { removeDbService, addSqliteVolume } from './dbCompose'; +import { setVolumePaths } from './volumesCompose'; +import { removeNsfwProfileGate, removeNsfwService } from './nsfwCompose'; +import { removeGeoDecodingProfileGate, removeGeoDecodingService, ensureGeoDecodingUrlVar } from './geoDecodingCompose'; +import { removeWorkerService, ensureWorkerScale } 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, a.volumeDatabasePath); + compose = patched; + if (!added) { + warnings.push( + `could not add a persistent volume for the SQLite database automatically; add \`${a.volumeDatabasePath}:/app/database/database.sqlite\` under lychee_api/lychee_worker by hand, or your data will be lost when the container is recreated` + ); + } + } + { + const { compose: patched, found } = setVolumePaths(compose, { + uploads: a.volumeUploadsPath, + logs: a.volumeLogsPath, + tmp: a.volumeTmpPath, + }); + compose = patched; + if (!found.uploads) { + warnings.push('could not find the uploads volume mount in docker-compose.yaml to customize; add it by hand'); + } + if (!found.logs) { + warnings.push('could not find the logs volume mount in docker-compose.yaml to customize; add it by hand'); + } + if (!found.tmp) { + warnings.push('could not find the tmp volume mount in docker-compose.yaml to customize; add it by hand'); + } + } + if (a.enableNsfw) { + const { compose: patched, removed } = removeNsfwProfileGate(compose); + compose = patched; + if (!removed) { + warnings.push('could not enable the NSFW classification service automatically; remove its `profiles:` entry from docker-compose.yaml by hand, or it will stay off'); + } + } else { + const { compose: patched, removed } = removeNsfwService(compose); + compose = patched; + if (!removed) { + warnings.push('could not remove the NSFW classification service automatically; please remove `lychee_nsfw_classification` by hand'); + } + } + if (a.enableGeoDecoding) { + const { compose: patched, removed } = removeGeoDecodingProfileGate(compose); + compose = patched; + if (!removed) { + warnings.push('could not enable the local reverse geo-decoding service automatically; remove its `profiles:` entry from docker-compose.yaml by hand, or it will stay off'); + } + const { compose: withUrlVar, ensured } = ensureGeoDecodingUrlVar(compose); + compose = withUrlVar; + if (!ensured) { + warnings.push('could not add LOCAL_GEO_DECODING_URL to docker-compose.yaml automatically; add it under x-common-env by hand, or the service will run unused'); + } + } else { + const { compose: patched, removed } = removeGeoDecodingService(compose); + compose = patched; + if (!removed) { + warnings.push('could not remove the local reverse geo-decoding service automatically; please remove `lychee_geo_decoding` 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'); + } + } else { + const { compose: patched, ensured } = ensureWorkerScale(compose); + compose = patched; + if (!ensured) { + warnings.push('could not make the queue worker scalable automatically; the WORKER_REPLICAS setting will have no effect until you replace `lychee_worker`\'s `container_name:` with `scale: ${WORKER_REPLICAS:-1}` 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'); + } + } + + // loadTemplates() normally fetches the *live* upstream template rather + // than the bundled fallback snapshot, and upstream ships TRUSTED_PROXIES, + // every OAuth var, and the NSFW AI-vision vars as inert comments (either + // "# KEY: value" or a bare "# KEY=default" reminder) rather than active + // YAML — unlike AI_VISION_FACE_*, which upstream already ships active. + // Activate exactly what this generation actually needs before anything + // below (docker secrets, envSets, inlining) assumes it's live. + const keysToActivate = ['TRUSTED_PROXIES']; + for (const providerId of a.activeOAuthProviders) { + const provider = OAUTH_PROVIDERS.find((p) => p.id === providerId); + if (!provider) continue; + keysToActivate.push(...provider.fields.map((f) => f.envKey)); + } + if (a.enableNsfw) { + keysToActivate.push('AI_VISION_NSFW_URL', 'AI_VISION_NSFW_API_KEY'); + } + const { compose: activated, missing: missingEnvLines } = activateEnvLines(compose, keysToActivate); + compose = activated; + for (const key of missingEnvLines) { + warnings.push(`could not find "${key}" in docker-compose.yaml to activate; add it under x-common-env by hand`); + } + + 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; + + // Every OAuth client secret and AI-vision API key currently in play is a + // candidate for its own Docker secret, alongside the essential app_key/ + // db_password/db_root_password ones — matching the "_FILE" convention + // docker-compose.yaml documents for every credential, not just those + // three. Blank values are skipped: nothing sensitive to protect, and it'd + // otherwise create an empty, pointless secrets file. + const additionalSecrets: (KV & { composeKey: string })[] = []; + for (const providerId of a.activeOAuthProviders) { + const provider = OAUTH_PROVIDERS.find((p) => p.id === providerId); + if (!provider) continue; + for (const field of provider.fields) { + if (!field.secretFile) continue; + const value = a.oauthFieldValues[`${providerId}:${field.key}`] ?? ''; + if (value.trim() === '') continue; + additionalSecrets.push({ key: field.secretFile, composeKey: field.envKey, value }); + } + } + if (a.enableAiVision && aiVisionApiKey.trim() !== '') { + additionalSecrets.push({ key: 'ai_vision_face_api_key', composeKey: 'AI_VISION_FACE_API_KEY', value: aiVisionApiKey }); + } + if (a.enableNsfw && nsfwApiKey.trim() !== '') { + additionalSecrets.push({ key: 'ai_vision_nsfw_api_key', composeKey: 'AI_VISION_NSFW_API_KEY', value: nsfwApiKey }); + } + + let secretsUsed = false; + let wired: Set = new Set(); + if (a.useDockerSecrets) { + const { patched, ok, reason, wired: w, failed } = enableDockerSecrets( + compose, + additionalSecrets.map((s) => ({ name: s.key, composeKey: s.composeKey })) + ); + if (ok) { + compose = patched; + secretsUsed = true; + wired = w; + for (const name of failed) { + warnings.push(`could not enable a Docker secret for "${name}" automatically; it was written to .env in plain text instead`); + } + } else { + warnings.push(`could not enable Docker secrets automatically (${reason}); falling back to plain .env values`); + } + } + + // Collapse blank-line runs left behind by removeDbService/removeNsfwService. + compose = compose.replace(/\n{3,}/g, '\n\n'); + + 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' }, + // An explicit value on the General step always wins. Left blank, fall + // back to the same automatic default as before: 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 once it's enabled. + { key: 'TRUSTED_PROXIES', value: a.trustedProxies.trim() || (a.enableTraefik ? '*' : 'null') }, + ]; + if (a.dbEngine !== 'sqlite') { + envSets.push({ key: 'DB_DATABASE', value: a.dbDatabase }, { key: 'DB_USERNAME', value: a.dbUsername }); + } + if (a.enableGeoDecoding) { + envSets.push({ key: 'LOCAL_GEO_DECODING_URL', value: 'http://lychee_geo_decoding:8080' }); + } + for (const providerId of a.activeOAuthProviders) { + const provider = OAUTH_PROVIDERS.find((p) => p.id === providerId); + if (!provider) continue; + for (const field of provider.fields) { + if (field.secretFile && wired.has(field.secretFile)) continue; + 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 }, + ...additionalSecrets.filter((s) => wired.has(s.key)).map((s) => ({ key: s.key, value: s.value })), + ]; + } 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] } + ); + } + if (a.enableAiVision && !wired.has('ai_vision_face_api_key')) { + overrides.push({ key: 'AI_VISION_API_KEY', value: aiVisionApiKey }); + } + if (a.enableAiVision) { + // docker-compose.yaml already falls back to these same defaults (e.g. + // `${VISION_FACE_MAX_FACES_PER_PHOTO:-10}`), so only emit an override + // when the user actually changed one. + if (a.aiVisionMaxFacesPerPhoto !== '10') { + overrides.push({ key: 'VISION_FACE_MAX_FACES_PER_PHOTO', value: a.aiVisionMaxFacesPerPhoto }); + } + if (a.aiVisionMinFaceSizePixels !== '0') { + overrides.push({ key: 'VISION_FACE_MIN_FACE_SIZE_PIXELS', value: a.aiVisionMinFaceSizePixels }); + } + if (a.aiVisionBlurThreshold !== '0.5') { + overrides.push({ key: 'VISION_FACE_BLUR_THRESHOLD', value: a.aiVisionBlurThreshold }); + } + if (a.aiVisionClusterEps !== '0.3') { + overrides.push({ key: 'VISION_FACE_CLUSTER_EPS', value: a.aiVisionClusterEps }); + } + if (a.aiVisionQueueMaxSize !== '0') { + overrides.push({ key: 'VISION_FACE_QUEUE_MAX_SIZE', value: a.aiVisionQueueMaxSize }); + } + if (a.aiVisionThreadPoolSize !== '1') { + overrides.push({ key: 'VISION_FACE_THREAD_POOL_SIZE', value: a.aiVisionThreadPoolSize }); + } + if (a.aiVisionWorkers !== '1') { + overrides.push({ key: 'VISION_FACE_WORKERS', value: a.aiVisionWorkers }); + } + } + if (a.enableNsfw) { + overrides.push({ key: 'AI_VISION_NSFW_URL', value: 'http://lychee_nsfw_classification:8000' }); + if (!wired.has('ai_vision_nsfw_api_key')) { + overrides.push({ 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/geoDecodingCompose.ts b/src/utils/wizard/geoDecodingCompose.ts new file mode 100644 index 00000000..291b2aeb --- /dev/null +++ b/src/utils/wizard/geoDecodingCompose.ts @@ -0,0 +1,72 @@ +// Toggles Lychee's bundled `lychee_geo_decoding` service, which upstream +// ships profile-gated (`profiles: [geo-decoding]`, off by default). Mirrors +// nsfwCompose.ts / phpMyAdminCompose.ts: when wanted, the `profiles:` gate +// is stripped so it starts unconditionally; when not, the whole service is +// removed outright. Unlike NSFW, it has no companion named volume to clean +// up — the service keeps no persistent data. + +import { removeIndentedBlock } from './composeEdit'; + +const SERVICE_ANCHOR = /^ {2}lychee_geo_decoding:\s*$/; + +export interface RemoveGeoDecodingServiceResult { + compose: string; + removed: boolean; +} + +export function removeGeoDecodingService(compose: string): RemoveGeoDecodingServiceResult { + const lines = compose.split('\n'); + const patched = removeIndentedBlock(lines, SERVICE_ANCHOR); + return { compose: patched.join('\n'), removed: patched.length !== lines.length }; +} + +export interface EnsureGeoDecodingUrlVarResult { + compose: string; + ensured: boolean; +} + +// ensureGeoDecodingUrlVar declares LOCAL_GEO_DECODING_URL in x-common-env. +// Unlike AI_VISION_FACE_URL/AI_VISION_NSFW_URL, upstream doesn't declare +// this one in docker-compose.yaml at all — it's documented in .env.example +// only, as a "bring your own service" var — so there's no existing +// commented line to activate; this inserts a fresh one, right before the +// top-level `services:` key (i.e. at the end of x-common-env). +export function ensureGeoDecodingUrlVar(compose: string): EnsureGeoDecodingUrlVarResult { + const lines = compose.split('\n'); + if (lines.some((l) => /^\s*LOCAL_GEO_DECODING_URL:\s/.test(l))) return { compose, ensured: true }; + + const servicesIdx = lines.findIndex((l) => /^services:\s*$/.test(l)); + if (servicesIdx === -1) return { compose, ensured: false }; + + const insertion = [ + ' ###################################################################', + ' # Local reverse geo-decoding', + ' ###################################################################', + ' LOCAL_GEO_DECODING_URL: "${LOCAL_GEO_DECODING_URL:-}"', + '', + ]; + lines.splice(servicesIdx, 0, ...insertion); + return { compose: lines.join('\n'), ensured: true }; +} + +export interface RemoveGeoDecodingProfileGateResult { + compose: string; + removed: boolean; +} + +// removeGeoDecodingProfileGate strips only the `profiles:` block nested +// under lychee_geo_decoding. Searching for `profiles:` is scoped to start +// after the service anchor line — phpmyadmin and lychee_nsfw_classification +// also have their own `profiles:` gates elsewhere in the file, so an +// unscoped search could hit the wrong one. +export function removeGeoDecodingProfileGate(compose: string): RemoveGeoDecodingProfileGateResult { + const lines = compose.split('\n'); + const serviceIdx = lines.findIndex((l) => SERVICE_ANCHOR.test(l)); + if (serviceIdx === -1) return { compose, removed: false }; + + const head = lines.slice(0, serviceIdx + 1); + const tail = removeIndentedBlock(lines.slice(serviceIdx + 1), /^ {4}profiles:\s*$/); + const removed = tail.length !== lines.length - serviceIdx - 1; + + return { compose: [...head, ...tail].join('\n'), removed }; +} diff --git a/src/utils/wizard/nsfwCompose.ts b/src/utils/wizard/nsfwCompose.ts new file mode 100644 index 00000000..10ad5251 --- /dev/null +++ b/src/utils/wizard/nsfwCompose.ts @@ -0,0 +1,50 @@ +// Toggles Lychee's bundled `lychee_nsfw_classification` service, which +// upstream ships profile-gated (`profiles: [nsfw]`, off by default) rather +// than synthesizing it from scratch the way this module used to. Mirrors +// phpMyAdminCompose.ts / removePhpMyAdminProfileGate: when wanted, the +// `profiles:` gate is stripped so it starts unconditionally regardless of +// COMPOSE_PROFILES/.env; when not, the whole service (and its queue volume) +// is removed outright. + +import { removeIndentedBlock } from './composeEdit'; + +const SERVICE_ANCHOR = /^ {2}lychee_nsfw_classification:\s*$/; + +export interface RemoveNsfwServiceResult { + compose: string; + removed: boolean; +} + +export function removeNsfwService(compose: string): RemoveNsfwServiceResult { + let lines = compose.split('\n'); + const before = lines.length; + + lines = removeIndentedBlock(lines, SERVICE_ANCHOR); + const removed = lines.length !== before; + + lines = removeIndentedBlock(lines, /^ {2}nsfw_queue:\s*$/); + + return { compose: lines.join('\n'), removed }; +} + +export interface RemoveNsfwProfileGateResult { + compose: string; + removed: boolean; +} + +// removeNsfwProfileGate strips only the `profiles:` block nested under +// lychee_nsfw_classification. Searching for `profiles:` is scoped to start +// after the service anchor line — phpmyadmin and lychee_geo_decoding also +// have their own `profiles:` gates elsewhere in the file, so an unscoped +// search could hit the wrong one. +export function removeNsfwProfileGate(compose: string): RemoveNsfwProfileGateResult { + const lines = compose.split('\n'); + const serviceIdx = lines.findIndex((l) => SERVICE_ANCHOR.test(l)); + if (serviceIdx === -1) return { compose, removed: false }; + + const head = lines.slice(0, serviceIdx + 1); + const tail = removeIndentedBlock(lines.slice(serviceIdx + 1), /^ {4}profiles:\s*$/); + const removed = tail.length !== lines.length - serviceIdx - 1; + + return { compose: [...head, ...tail].join('\n'), removed }; +} diff --git a/src/utils/wizard/oauthProviders.ts b/src/utils/wizard/oauthProviders.ts new file mode 100644 index 00000000..c8ad7ea9 --- /dev/null +++ b/src/utils/wizard/oauthProviders.ts @@ -0,0 +1,157 @@ +// 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; + // Set only on the one credential-ish field per provider that + // docker-compose.yaml ships a "_FILE" Docker-secrets variant for + // (the client/app secret — never the client ID or a public base + // URL/realm/tenant). Value is the secret's file name under ./secrets/, + // matching the "/run/secrets/" path in that _FILE line. See + // dockerSecrets.ts's additional-secrets wiring. + secretFile?: string; +} + +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, secretFile: 'amazon_signin_secret' }, + ], + }, + { + 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, secretFile: 'apple_client_secret' }, + ], + }, + { + 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, secretFile: 'facebook_client_secret' }, + ], + }, + { + 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, secretFile: 'github_client_secret' }, + ], + }, + { + 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, secretFile: 'google_client_secret' }, + ], + }, + { + 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, secretFile: 'mastodon_secret' }, + ], + }, + { + 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, secretFile: 'microsoft_client_secret' }, + { 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, secretFile: 'nextcloud_client_secret' }, + { + 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, secretFile: 'keycloak_client_secret' }, + { + 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, secretFile: 'authentik_client_secret' }, + ], + }, + { + 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, secretFile: 'authelia_client_secret' }, + ], + }, +]; 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..fec3c59f --- /dev/null +++ b/src/utils/wizard/templates.ts @@ -0,0 +1,37 @@ +// 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'; +// A slow/hanging GitHub response shouldn't stall the wizard indefinitely — +// bound both requests so the bundled-snapshot fallback kicks in either way. +const FETCH_TIMEOUT_MS = 8_000; + +export interface Templates { + envExample: string; + compose: string; + fromLive: boolean; +} + +export async function loadTemplates(fallbackEnvExample: string, fallbackCompose: string): Promise { + try { + const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS); + const [envRes, composeRes] = await Promise.all([ + fetch(ENV_EXAMPLE_URL, { cache: 'no-store', signal }), + fetch(COMPOSE_URL, { cache: 'no-store', signal }), + ]); + 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..97403513 --- /dev/null +++ b/src/utils/wizard/traefikCompose.ts @@ -0,0 +1,82 @@ +// 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 from +// scratch. + +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 { + const lines = compose.split('\n'); + + const portsAnchor = ' - "${APP_PORT:-8000}:8000"'; + const portsIdx = lines.indexOf(portsAnchor); + if (portsIdx === -1) return { compose, added: false }; + + const withLabels = [ + ...lines.slice(0, portsIdx + 1), + ...buildLabelLines(o), + ...API_NETWORKS_LINES, + ...lines.slice(portsIdx + 1), + ]; + + const networksIdx = withLabels.findIndex((l) => /^networks:\s*$/.test(l)); + // Bail out to the original, untouched compose (not withLabels) when this + // anchor is missing — otherwise the service-level labels/networks: added + // above would reference a `traefik` network that never actually gets + // declared at the top level, silently breaking `docker compose up` even + // though this reports added: false. + if (networksIdx === -1) return { compose, added: false }; + + const withNetwork = [ + ...withLabels.slice(0, networksIdx + 1), + ...TOP_LEVEL_NETWORK_LINES, + ...withLabels.slice(networksIdx + 1), + ]; + + return { compose: withNetwork.join('\n'), added: true }; +} diff --git a/src/utils/wizard/validate.ts b/src/utils/wizard/validate.ts new file mode 100644 index 00000000..954e5cd0 --- /dev/null +++ b/src/utils/wizard/validate.ts @@ -0,0 +1,75 @@ +// Mirrors the field validators in github.com/LycheeOrg/Wizard's +// internal/wizard/forms.go. Returns an error message, or null if valid. + +// Number() also accepts scientific notation ("1e2"), hexadecimal ("0x50"), +// and leading +/whitespace, all of which pass Number.isInteger() too — and +// since it's the raw typed string (not the parsed number) that ends up +// embedded in the generated .env/docker-compose.yaml output, something like +// "0x50" would validate as port 80 but leave the literal text "0x50" behind +// instead. Require plain decimal digits before parsing. +const PLAIN_DIGITS = /^\d+$/; + +export function validatePort(s: string): string | null { + if (!PLAIN_DIGITS.test(s.trim())) { + return 'Must be a valid port number (1-65535).'; + } + 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 { + if (!PLAIN_DIGITS.test(s.trim())) { + return 'Must be a non-negative integer.'; + } + 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 { + if (!PLAIN_DIGITS.test(s.trim())) { + return 'Must be a positive integer (1 or more).'; + } + const n = Number(s); + if (!Number.isInteger(n) || n < 1) { + return 'Must be a positive integer (1 or more).'; + } + return null; +} + +// Same rationale as PLAIN_DIGITS above, extended to allow one optional +// decimal point — still rejects scientific notation and hex. +const PLAIN_DECIMAL = /^\d+(\.\d+)?$/; + +export function validateNonNegativeFloat(s: string): string | null { + if (!PLAIN_DECIMAL.test(s.trim())) { + return 'Must be a non-negative number.'; + } + const n = Number(s); + if (!Number.isFinite(n) || n < 0) { + return 'Must be a non-negative number.'; + } + return null; +} + +// validatePath is deliberately loose — it only rules out a blank value +// (which would produce an invalid, empty bind-mount source in +// docker-compose.yaml) rather than trying to fully validate filesystem path +// syntax across host OSes. +export function validatePath(s: string): string | null { + if (s.trim() === '') return 'Must not be empty.'; + return null; +} diff --git a/src/utils/wizard/volumesCompose.ts b/src/utils/wizard/volumesCompose.ts new file mode 100644 index 00000000..f2092615 --- /dev/null +++ b/src/utils/wizard/volumesCompose.ts @@ -0,0 +1,41 @@ +// Literal-text edits to docker-compose.yaml for customizing the host-side +// paths of Lychee's persistent bind mounts. Same literal-match approach as +// dockerSecrets.ts: replace every occurrence of the default path text. The +// uploads path in particular shows up three times — the main mount in +// x-base-lychee-setup, plus the read-only mounts the AI-vision/NSFW sidecar +// services use for photo access — a single global replace keeps all three in +// sync from one wizard answer. + +export const DEFAULT_UPLOADS_PATH = './lychee/uploads'; +export const DEFAULT_LOGS_PATH = './lychee/logs'; +export const DEFAULT_TMP_PATH = './lychee/tmp'; + +export interface VolumePaths { + uploads: string; + logs: string; + tmp: string; +} + +export interface SetVolumePathsResult { + compose: string; + // Which of the three paths were actually found (and replaced, if changed + // from the default) — false means the anchor text wasn't in the compose + // file at all, which callers should surface as a warning. + found: { uploads: boolean; logs: boolean; tmp: boolean }; +} + +function replaceAll(compose: string, from: string, to: string): { compose: string; found: boolean } { + const found = compose.includes(from); + if (!found || from === to) return { compose, found }; + return { compose: compose.split(from).join(to), found }; +} + +export function setVolumePaths(compose: string, paths: VolumePaths): SetVolumePathsResult { + const uploads = replaceAll(compose, DEFAULT_UPLOADS_PATH, paths.uploads); + const logs = replaceAll(uploads.compose, DEFAULT_LOGS_PATH, paths.logs); + const tmp = replaceAll(logs.compose, DEFAULT_TMP_PATH, paths.tmp); + return { + compose: tmp.compose, + found: { uploads: uploads.found, logs: logs.found, tmp: tmp.found }, + }; +} diff --git a/src/utils/wizard/workerCompose.ts b/src/utils/wizard/workerCompose.ts new file mode 100644 index 00000000..eb9a830d --- /dev/null +++ b/src/utils/wizard/workerCompose.ts @@ -0,0 +1,52 @@ +// 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'); + // eatPrecedingComment: docker-compose.yaml's queue-worker banner comment + // (the "##### Queue Worker Service #####" header) sits directly above + // lychee_worker: and describes only this service, so it should go with it + // — otherwise it's left as an orphaned comment in the generated file. + const patched = removeIndentedBlock(lines, /^ {2}lychee_worker:\s*$/, true); + return { compose: patched.join('\n'), removed: patched.length !== lines.length }; +} + +export interface EnsureWorkerScaleResult { + compose: string; + ensured: boolean; +} + +// ensureWorkerScale lets the wizard's WORKER_REPLICAS setting actually do +// something — Lychee's own compose file runs a single fixed worker +// (`container_name: lychee-worker`), which is incompatible with Compose's +// `scale:` (it requires Compose to name replica containers itself). If +// `scale:` isn't already there, this swaps that line for +// `scale: ${WORKER_REPLICAS:-1}` inside the lychee_worker service +// specifically (never lychee_api, which has its own container_name). +export function ensureWorkerScale(compose: string): EnsureWorkerScaleResult { + const lines = compose.split('\n'); + const serviceIdx = lines.findIndex((l) => /^ {2}lychee_worker:\s*$/.test(l)); + if (serviceIdx === -1) return { compose, ensured: false }; + + for (let i = serviceIdx + 1; i < lines.length; i++) { + const m = /^( *)\S/.exec(lines[i]); + if (m && m[1].length <= 2) break; // left the service's own block + + if (/^ {4}scale:\s/.test(lines[i])) return { compose, ensured: true }; + if (/^ {4}container_name:\s/.test(lines[i])) { + lines[i] = ' scale: ${WORKER_REPLICAS:-1}'; + return { compose: lines.join('\n'), ensured: true }; + } + } + + return { compose, ensured: false }; +} 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', -};