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 258acfbe..50a72faa 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", @@ -3513,6 +3515,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 5dc5d8a7..f07a7748 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/data/wizard/docker-compose.yaml b/src/data/wizard/docker-compose.yaml new file mode 100644 index 00000000..cff871f8 --- /dev/null +++ b/src/data/wizard/docker-compose.yaml @@ -0,0 +1,663 @@ +# Docker Compose configuration for Lychee application with FrankenPHP backend +# Version: 2026-01-10 + +# You can set up secrets files for sensitive data like database passwords. +# Create a 'secrets' directory and add files 'db_password' and 'db_master_password' +# with appropriate permissions (readable only by the owner). +# Then uncomment the 'secrets' section below. + +# secrets: +# db_password: +# file: ./secrets/db_password +# db_master_password: +# file: ./secrets/db_master_password +# app_key: +# file: ./secrets/app_key + +########################################################################################## +# Common settings for Lychee services +########################################################################################## +x-base-lychee-setup: + # There are two main images: `latest` and `edge`. + # `latest` is mapped to the last published version + # `edge` is mapped to the last build on master branch (may be unstable) + # + # There are also other images available which uses nginx as a base instead of FrankenPHP: + # - ghcr.io/lycheeorg/lychee:latest-legacy + # - ghcr.io/lycheeorg/lychee:edge-legacy + &base-lychee-setup + image: ghcr.io/lycheeorg/lychee:latest + + # The following lines are for development purposes only. + # image: lychee-frankenphp + # image: lychee-legacy:latest + # build: + # context: ./app + # dockerfile: Dockerfile + # args: + # NODE_ENV: "${NODE_ENV:-production}" + restart: unless-stopped # Auto-restart at container level (outer layer) + + # Security hardening + security_opt: + - no-new-privileges:true + - seccomp:unconfined # FrankenPHP may need this; consider custom seccomp profile + cap_drop: + - ALL + cap_add: + - CHOWN + - SETGID + - SETUID + - DAC_OVERRIDE + - NET_BIND_SERVICE + read_only: false # Laravel needs write access to storage/cache + tmpfs: + - /tmp:noexec,nosuid,nodev,size=100m + + # Resource limits + deploy: + resources: + limits: + cpus: '2' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M + + # To configure Lychee, you can do it via: + # - environment variables (see the environment section below) + # - .env file (recommended for sensitive data) + # - mapping .env file to /app/.env (not necessary anymore if you use env_file) + env_file: + - path: ./.env + + # If you use docker secrets, uncomment the following lines and + # create the secrets files as described at the top of this file. + # secrets: + # - db_password + # - app_key + volumes: + # Mount local directories for persistent storage + # Uploads : where your photos are going to be stored + - ./lychee/uploads:/app/public/uploads + # Logs: to see what went wrong (or right) + - ./lychee/logs:/app/storage/logs + # Temporary files: where your uploads are stored temporarily + # while waiting for jobs to process them. + - ./lychee/tmp:/app/storage/tmp + + networks: + - lychee + +x-common-env: + # User and Group IDs for the www-data user inside the container + # Note that this will impact your permissions on mounted volumes. + # By default Lychee does not run as root inside the container. + # + # Ensure that the PUID and PGID match the owner of the mounted volumes + # to avoid permission issues. + # + # You can also set it to 33 (www-data) if your files are owned by www-data. + &common-env + PUID: "${PUID:-1000}" + PGID: "${PGID:-1000}" + + # Or you can activate the following to run as root inside the container. (not recommended) + # RUN_AS_ROOT: "yes" + + # Application Key + # You can either set it here directly (not recommended for security reasons) + # Or load it from .env file + # or use Docker secrets as shown above. + APP_KEY: "${APP_KEY:-}" + # APP_KEY_FILE: "/run/secrets/app_key" + + # Application + APP_NAME: "${APP_NAME:-Lychee}" + APP_ENV: "${APP_ENV:-production}" + APP_DEBUG: "${APP_DEBUG:-false}" + APP_TIMEZONE: "${TIMEZONE:-UTC}" + APP_URL: "${APP_URL:-http://localhost:8000}" + APP_FORCE_HTTPS: "${APP_FORCE_HTTPS:-false}" + APP_MAINTENANCE_DRIVER: "${APP_MAINTENANCE_DRIVER:-file}" + + # enable or disable debug bar. By default it is disabled. + # Do note that this disable CSP!! + # DEBUGBAR_ENABLED: "${DEBUGBAR_ENABLED:-false}" + + # enable or disable log viewer. By default it is enabled. + # Unfortunately, log viewer is not available in production due to an upstream bug. :( + # you will need to set APP_ENV=local to enable it. + LOG_VIEWER_ENABLED: "${LOG_VIEWER_ENABLED:-true}" + + # Sometimes 404 errors are very noisy in the logs. + # You can disable logging them by setting this to false. + LOG_404_ERRORS: "${LOG_404_ERRORS:-true}" + + # enable or disable clockwork. By default it is disabled (and not provided on non-dev build). + CLOCKWORK_ENABLE: "${CLOCKWORK_ENABLE:-false}" + + # enable or disable latency debug: adds a specific amount of time in milliseconds to wait before processing requests. + # Always disabled on production environment. + # APP_DEBUG_LATENCY: 0 + + # All API requests to have the header "content-type: application/json" + # or "content-type: multipart/form-data" depending on the type. + # + # If you want to disable this requirement, set this to false. + # + # This requirement prevents the use of the API from the API documentation page. + # REQUIRE_CONTENT_TYPE_ENABLED: "${REQUIRE_CONTENT_TYPE_ENABLED:-true}" + + # enable s3 bucket (required in addition to needing AWS_ACCESS_KEY_ID) + # S3_ENABLED: true + + # If you spread old links of to your albums in your Lychee instance starting with + # https://lychee.text/#albumID/PhotoId + # Set this value to true to enable redirection. + # LEGACY_V4_REDIRECT: "${LEGACY_V4_REDIRECT:-false}" + + ############################################################################## + # IMPORTANT: To migrate from Lychee v3 you *MUST* use the same MySQL/MariaDB # + # server as v3. # + ############################################################################## + + # Table prefix (e.g. lychee_) of a Lychee v3 instance for migration + # DB_OLD_LYCHEE_PREFIX: + + # DB_CONNECTION can be sqlite, mysql or pgsql. For sqlite the other entries are + # not required, but an existing sqlite3 database may be specified if desired. + # In this case, please use an absolute path. DB_DATABASE may be omitted but should + # *not* be left blank. + # + # Note that if DB_PASSWORD includes special characters, it must be enclosed in quotes. + # e.g. DB_PASSWORD: "lychee!@#$%^&" + DB_CONNECTION: "${DB_CONNECTION:-mysql}" + DB_HOST: "${DB_HOST:-lychee_db}" + DB_PORT: "${DB_PORT:-3306}" + DB_DATABASE: "${DB_DATABASE:-lychee}" + DB_USERNAME: "${DB_USERNAME:-lychee}" + + # Use secrets for sensitive data - see Docker secrets or vault integration + # DB_PASSWORD should come from .env file only + DB_PASSWORD: "${DB_PASSWORD:-password}" + # + # Or you can uncomment the following line to use DB_PASSWORD_FILE from secrets + # DB_PASSWORD_FILE: "/run/secrets/db_password" + + ################################################################### + # Keygen License Management # + ################################################################### + + # API token obtained from keygen.lycheeorg.dev. + # When set, Lychee will automatically rotate an expired license key + # on admin login and check the token health on the diagnostics page. + KEYGEN_API_KEY: "${KEYGEN_API_KEY:-}" + # Or you can uncomment the following line to use KEYGEN_API_KEY_FILE from secrets + # KEYGEN_API_KEY_FILE: "/run/secrets/keygen_api_key" + + # Session configuration + SESSION_DRIVER: "${SESSION_DRIVER:-file}" + SESSION_LIFETIME: "${SESSION_LIFETIME:-120}" + # SESSION_DRIVER: "${SESSION_DRIVER:-database}" + # SESSION_LIFETIME: "${SESSION_LIFETIME:-120}" + # SESSION_ENCRYPT: "${SESSION_ENCRYPT:-false}" + # SESSION_PATH: "${SESSION_PATH:-/}" + # SESSION_DOMAIN: "${SESSION_DOMAIN:-null}" + + # Cache + CACHE_STORE: "${CACHE_STORE:-file}" + CACHE_PREFIX: "${CACHE_PREFIX:-lychee_cache}" + # REDIS_HOST: "lychee_cache" + # REDIS_PASSWORD: "null" + # REDIS_PORT: "6379" + # REDIS_URL=redis://:@: + + # If you use Redis as cache driver, we strongly recommend + # to disable it for your Log Viewer. + # Should redis crash, you will no longer be able to access your logs. + LOG_VIEWER_CACHE_DRIVER: "file" + + # When booting the application, if you have a lot of photos, + # Lychee will check the permissions of all files and folders. + # This may take a while (even hours depending on the number of photos...) + # If you are sure that your permissions are correct, you can skip these checks + # by setting the following to "yes". + # SKIP_PERMISSIONS_CHECKS: "yes" + + # Queue + # The default value for QUEUE_CONNECTION is 'sync', this means that jobs are + # executed immediately (synchronously) when dispatched. + # + # For improved reactivity of Lychee we recommend to use a worker. + # You can use either 'database' or 'redis' as queue driver (but in the later you need to enable redis). + QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}" + + # Logging Stuff. + # LOG_CHANNEL: "${LOG_CHANNEL:-stack}" + # LOG_STACK: "${LOG_STACK:-single}" + # LOG_DEPRECATIONS_CHANNEL: "${LOG_DEPRECATIONS_CHANNEL:-null}" + # LOG_LEVEL: "${LOG_LEVEL:-debug}" + # LOG_STDOUT: "${LOG_STDOUT:-true}" + + # SECURITY_HEADER_HSTS_ENABLE:false + # SECURITY_HEADER_CSP_CONNECT_SRC: + # SECURITY_HEADER_SCRIPT_SRC_ALLOW: + # SECURITY_HEADER_CSP_CHILD_SRC: + # SECURITY_HEADER_CSP_FONT_SRC: + # SECURITY_HEADER_CSP_FORM_ACTION: + # SECURITY_HEADER_CSP_FRAME_ANCESTORS: + # SECURITY_HEADER_CSP_FRAME_SRC: + # SECURITY_HEADER_CSP_IMG_SRC: + # SECURITY_HEADER_CSP_MEDIA_SRC: + # SESSION_SECURE_COOKIE:false + + # MAIL_DRIVER:smtp + # MAIL_HOST: + # MAIL_PORT: + # MAIL_USERNAME: + # MAIL_PASSWORD: + # MAIL_ENCRYPTION: + # MAIL_FROM_NAME: + # MAIL_FROM_ADDRESS: + + # Trusted proxy IPs (or CIDR ranges) sitting in front of Lychee, so it uses + # their X-Forwarded-* headers for the real client IP/scheme instead of the + # proxy's own. If you're behind Traefik (or any reverse proxy) on the same + # Docker network, set this to "*" to trust all of them. + TRUSTED_PROXIES: "${TRUSTED_PROXIES:-null}" + + # Disable Basic Auth. This means that the only way to authenticate is via the API token or Oauth. + # This should only be toggled AFTER having set up the admin account and bound the Oauth client. + # DISABLE_BASIC_AUTH=false + + # Disable WebAuthn. This means that the only way to authenticate is via the API token, Basic Auth or Oauth. + # DISABLE_WEBAUTHN=false + + ################################################################### + # LDAP Authentication (enterprise directory integration) # + ################################################################### + + # Enable LDAP authentication alongside or instead of basic auth + # LDAP_ENABLED=false + + # LDAP Server connection settings + # LDAP_HOST=ldap.example.com + # LDAP_PORT=389 + # For LDAPS (LDAP over SSL), use port 636 + # LDAP_PORT=636 + + # Base DN for LDAP searches (e.g., dc=example,dc=com or dc=corp,dc=example,dc=com) + # LDAP_BASE_DN=dc=example,dc=com + + # Service account credentials for LDAP bind + # This account needs read-only access to user and group attributes + # LDAP_BIND_DN=cn=lychee-service,ou=services,dc=example,dc=com + # LDAP_BIND_PASSWORD=securepassword + + # LDAP user search filter (%s is replaced with username) + # For OpenLDAP: + # LDAP_USER_FILTER=(&(objectClass=person)(uid=%s)) + # For Active Directory: + # LDAP_USER_FILTER=(&(objectClass=user)(sAMAccountName=%s)) + + # LDAP attribute mapping (maps LDAP attributes to Lychee user fields) + # OpenLDAP defaults: + # LDAP_ATTR_USERNAME=uid + # LDAP_ATTR_EMAIL=mail + # LDAP_ATTR_DISPLAY_NAME=displayName + # Active Directory alternatives: + # LDAP_ATTR_USERNAME=sAMAccountName + # LDAP_ATTR_EMAIL=userPrincipalName + # LDAP_ATTR_DISPLAY_NAME=displayName + + # Admin role mapping via LDAP group + # Users in this group will have may_administrate=true + # LDAP_ADMIN_GROUP_DN=cn=lychee-admins,ou=groups,dc=example,dc=com + + # Auto-provision users on first LDAP login + # If false, users must be pre-created in Lychee before they can log in via LDAP + # LDAP_AUTO_PROVISION=true + + # TLS/SSL settings for secure LDAP connections + # LDAP_USE_TLS=true + # LDAP_TLS_VERIFY_PEER=true + + # Connection timeout in seconds + # LDAP_CONNECTION_TIMEOUT=5 + + # Oauth token data + # *_REDIRECT_URI should be left as default unless you know exactly what you do. + + AMAZON_SIGNIN_CLIENT_ID: "${AMAZON_SIGNIN_CLIENT_ID:-}" + AMAZON_SIGNIN_SECRET: "${AMAZON_SIGNIN_SECRET:-}" + AMAZON_SIGNIN_REDIRECT_URI: "${AMAZON_SIGNIN_REDIRECT_URI:-/auth/amazon/redirect}" + + # https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple + # Note: the client secret used for "Sign In with Apple" is a JWT token that can have a maximum lifetime of 6 months. + # The article above explains how to generate the client secret on demand and you'll need to update this every 6 months. + # To generate the client secret for each request, see Generating A Client Secret For Sign In With Apple On Each Request. + # https://bannister.me/blog/generating-a-client-secret-for-sign-in-with-apple-on-each-request + APPLE_CLIENT_ID: "${APPLE_CLIENT_ID:-}" + APPLE_CLIENT_SECRET: "${APPLE_CLIENT_SECRET:-}" + APPLE_REDIRECT_URI: "${APPLE_REDIRECT_URI:-/auth/apple/redirect}" + + FACEBOOK_CLIENT_ID: "${FACEBOOK_CLIENT_ID:-}" + FACEBOOK_CLIENT_SECRET: "${FACEBOOK_CLIENT_SECRET:-}" + FACEBOOK_REDIRECT_URI: "${FACEBOOK_REDIRECT_URI:-/auth/facebook/redirect}" + + GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID:-}" + GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET:-}" + GITHUB_REDIRECT_URI: "${GITHUB_REDIRECT_URI:-/auth/github/redirect}" + + GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}" + GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" + GOOGLE_REDIRECT_URI: "${GOOGLE_REDIRECT_URI:-/auth/google/redirect}" + + MASTODON_DOMAIN: "${MASTODON_DOMAIN:-https://mastodon.social}" + MASTODON_ID: "${MASTODON_ID:-}" + MASTODON_SECRET: "${MASTODON_SECRET:-}" + MASTODON_REDIRECT_URI: "${MASTODON_REDIRECT_URI:-/auth/mastodon/redirect}" + + MICROSOFT_CLIENT_ID: "${MICROSOFT_CLIENT_ID:-}" + MICROSOFT_CLIENT_SECRET: "${MICROSOFT_CLIENT_SECRET:-}" + MICROSOFT_REDIRECT_URI: "${MICROSOFT_REDIRECT_URI:-/auth/microsoft/redirect}" + MICROSOFT_TENANT_ID: "${MICROSOFT_TENANT_ID:-}" + + NEXTCLOUD_CLIENT_ID: "${NEXTCLOUD_CLIENT_ID:-}" + NEXTCLOUD_CLIENT_SECRET: "${NEXTCLOUD_CLIENT_SECRET:-}" + NEXTCLOUD_REDIRECT_URI: "${NEXTCLOUD_REDIRECT_URI:-/auth/nextcloud/redirect}" + NEXTCLOUD_BASE_URI: "${NEXTCLOUD_BASE_URI:-}" + + KEYCLOAK_CLIENT_ID: "${KEYCLOAK_CLIENT_ID:-}" + KEYCLOAK_CLIENT_SECRET: "${KEYCLOAK_CLIENT_SECRET:-}" + KEYCLOAK_REDIRECT_URI: "${KEYCLOAK_REDIRECT_URI:-/auth/keycloak/redirect}" + KEYCLOAK_BASE_URL: "${KEYCLOAK_BASE_URL:-}" + KEYCLOAK_REALM: "${KEYCLOAK_REALM:-}" + + AUTHENTIK_BASE_URL: "${AUTHENTIK_BASE_URL:-}" + AUTHENTIK_CLIENT_ID: "${AUTHENTIK_CLIENT_ID:-}" + AUTHENTIK_CLIENT_SECRET: "${AUTHENTIK_CLIENT_SECRET:-}" + AUTHENTIK_REDIRECT_URI: "${AUTHENTIK_REDIRECT_URI:-/auth/authentik/redirect}" + + AUTHELIA_BASE_URL: "${AUTHELIA_BASE_URL:-}" + AUTHELIA_CLIENT_ID: "${AUTHELIA_CLIENT_ID:-}" + AUTHELIA_CLIENT_SECRET: "${AUTHELIA_CLIENT_SECRET:-}" + AUTHELIA_REDIRECT_URI: "${AUTHELIA_REDIRECT_URI:-/auth/authelia/redirect}" + + # AWS support data + + # AWS_ACCESS_KEY_ID= + # AWS_SECRET_ACCESS_KEY= + # AWS_DEFAULT_REGION= + # AWS_BUCKET= + # AWS_URL= + # AWS_ENDPOINT= + # AWS_IMAGE_VISIBILITY= + # AWS_USE_PATH_STYLE_ENDPOINT= + + # DISABLE_IMPORT_FROM_SERVER=false + + ################################################################### + # Payment integration (requires SE) # + ################################################################### + + # Enable test mode (Sandbox mode) for payment gateways. + # In test mode, no real money transactions are done. + # We set it to true by default for safety. Make sure to set it to false + # when you go live. + # OMNIPAY_TEST_MODE=true + + # Configuration values for Mollie integration + # MOLLIE_API_KEY= + # MOLLIE_PROFILE_ID= + + # Configuration values for Stripe integration (NOT WORKING YET, MAYBE LATER) + # STRIPE_API_KEY= + # STRIPE_PUBLISHABLE_KEY= + + # Configuration values for PayPal integration + # PAYPAL_CLIENT_ID= + # PAYPAL_SECRET= + + ################################################################### + # Facial recognition # + ################################################################### + AI_VISION_ENABLED: "${AI_VISION_ENABLED:-true}" + AI_VISION_FACE_API_KEY: "${AI_VISION_FACE_API_KEY:-changeme}" + AI_VISION_FACE_URL: "http://lychee_facial_recognition:8000" + +services: + ########################################################################################## + # Lychee API Service and frontend. + ########################################################################################## + lychee_api: + <<: *base-lychee-setup + # Set up a container name for easier identification + container_name: lychee-api + expose: + - "${APP_PORT:-8000}" + ports: + - "${APP_PORT:-8000}:8000" + environment: + <<: *common-env + # Performance tuning for FRANKENPHP + # For the legacy setup using the -legacy tags, these values are not used. + + # Increase PHP max execution time (in seconds) for long running operations like imports. + # We recommend you leave those as is and use database/redis queue with a worker for long operations. + # PHP_MAX_EXECUTION_TIME: 3000 + # LYCHEE_MAX_EXECUTION_TIME: 30 + + depends_on: + lychee_db: + condition: service_healthy + + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8000/up" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + ########################################################################################## + # Queue Worker Service + ########################################################################################## + # + # Processes background jobs (imports, thumbnail generation, etc.) so lychee_api + # can respond immediately instead of blocking on them. Requires QUEUE_CONNECTION + # to be 'database' or 'redis' above — with the default 'sync' this container + # would have nothing to do. Remove this service entirely if you'd rather run + # without a worker (and set QUEUE_CONNECTION back to 'sync'). + # + # THIS IS PRETTY MUCH THE SAME CONFIGURATION AS lychee_api WITH A FEW MINOR MODIFICATIONS + # + # No container_name here (unlike the other services): `scale` starts more than + # one container from this same service definition, which requires Compose to + # name them itself. + lychee_worker: + <<: *base-lychee-setup + # Number of worker containers to run. Increase for more parallel job + # processing on multi-core hosts with a lot of queued work. + scale: ${WORKER_REPLICAS:-1} + environment: + <<: *common-env + ########################################################################################## + # Enable WORKER MODE + ########################################################################################## + # + # THIS VALUE HERE IS THE MOST IMPORTANT ONE. + # IF LYCHEE_MODE IS NOT SET TO WORKER, THE CONTAINER WILL BE A DUPLICATE OF lychee_api. + # Set LYCHEE_MODE=worker to enable queue worker mode + LYCHEE_MODE: worker + + depends_on: + lychee_db: + condition: service_healthy + lychee_api: + condition: service_healthy + + # Worker health check + # Verifies queue:work process is running + healthcheck: + test: [ "CMD-SHELL", "pgrep -f 'queue:work' || exit 1" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s # Give worker time to start up + + phpmyadmin: + image: phpmyadmin + restart: always + ports: + - 8080:80 + environment: + - PMA_HOST=lychee_db + - PMA_PORT=3306 + depends_on: + lychee_db: + condition: service_healthy + networks: + - lychee + profiles: + - phpmyadmin + + lychee_db: + image: mariadb:11 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - SETGID + - SETUID + - DAC_OVERRIDE + - CHOWN + read_only: false # MariaDB needs write access + tmpfs: + - /tmp:noexec,nosuid,nodev,size=200m + - /var/run/mysqld:noexec,nosuid,nodev,size=10m + env_file: + - path: ./.env + required: false + # secrets: + # - db_master_password + # - db_password + environment: + - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-rootpassword} + # - MYSQL_ROOT_PASSWORD_FILE=/run/secrets/db_master_password + - MYSQL_DATABASE=${DB_DATABASE:-lychee} + - MYSQL_USER=${DB_USERNAME:-lychee} + - MYSQL_PASSWORD=${DB_PASSWORD:-password} + # - MYSQL_PASSWORD_FILE=/run/secrets/db_password + expose: + - 3306 + # Removed host port binding - only accessible within Docker network + # For debugging, temporarily uncomment: + # ports: + # - 127.0.0.1:33061:3306 + volumes: + - mysql:/var/lib/mysql + networks: + - lychee + restart: unless-stopped + healthcheck: + test: [ "CMD", "healthcheck.sh", "--connect", "--innodb_initialized" ] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + lychee_facial_recognition: + expose: + - "${APP_PORT_AI_FACE:-8001}" + ports: + - "${APP_PORT_AI_FACE:-8001}:8000" + image: ghcr.io/lycheeorg/lychee-facial-recognition:latest + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + # Lychee instance base URL (no trailing slash) + VISION_FACE_LYCHEE_API_URL: "http://lychee_api:8000" + # Shared API key — must match AI_VISION_FACE_API_KEY in Lychee's .env + VISION_FACE_API_KEY: "${AI_VISION_FACE_API_KEY:-changeme}" + # Set to false for development environments with self-signed certificates + VISION_FACE_VERIFY_SSL: "${AI_VISION_VERIFY_SSL:-true}" + # Skip the Lychee connectivity check at startup (useful for local dev) + VISION_FACE_SKIP_LYCHEE_CHECK: "${VISION_FACE_SKIP_LYCHEE_CHECK:-false}" + + # --- Logging --- + # Uvicorn/application log level (debug, info, warning, error, critical) + VISION_FACE_LOG_LEVEL: "info" + + # --- Clustering --- + # DBSCAN epsilon (max cosine distance); lower : tighter clusters + VISION_FACE_CLUSTER_EPS: "0.6" + + # --- Photo volume --- + # Shared Docker-volume mount point for photo files + VISION_FACE_PHOTOS_PATH: "/data/photos" + + # --- Embedding storage --- + # Storage engine: sqlite or pgvector + VISION_FACE_STORAGE_BACKEND: sqlite + # SQLite DB directory (used when storage_backend : sqlite) + VISION_FACE_STORAGE_PATH: "/data/embeddings" + + # --- Concurrency --- + # Number of threads in the ThreadPoolExecutor used for CPU-bound inference + VISION_FACE_THREAD_POOL_SIZE: 1 + # Number of Uvicorn worker processes + VISION_FACE_WORKERS: "${AI_VISION_WORKERS:-1}" + + # Check the following for more env variables + # https://github.com/LycheeOrg/Lychee-Facial-Recognition/blob/master/.env.example + + VISION_FACE_QUEUE_BACKEND: "${VISION_FACE_QUEUE_BACKEND:-database}" + + # Maximum pending jobs; requests beyond this are rejected with 429, 0 = unlimited + VISION_FACE_QUEUE_MAX_SIZE: "${VISION_FACE_QUEUE_MAX_SIZE:-0}" + + # --- Detection thresholds --- + # Bounding-box confidence filter (0–1); faces below this score are excluded + VISION_FACE_DETECTION_THRESHOLD: 0.5 + # Cosine-similarity cutoff for selfie match results and suggestion candidates + VISION_FACE_MATCH_THRESHOLD: 0.5 + # IoU threshold for bounding-box matching on re-scan (preserves person_id) + VISION_FACE_RESCAN_IOU_THRESHOLD: 0.5 + # Maximum faces included in a callback payload (top-N by confidence) + VISION_FACE_MAX_FACES_PER_PHOTO: 10 + + # --- Quality filtering --- + # Minimum face size in pixels (longest side of bounding box); 0 : disabled + VISION_FACE_MIN_FACE_SIZE_PIXELS: 0 + # Laplacian variance threshold for blur detection; faces below this are discarded + VISION_FACE_BLUR_THRESHOLD: 0.5 + volumes: + - ./lychee/uploads:/data/photos:ro + - ai_vision_embeddings:/data/embeddings + networks: + - lychee + depends_on: + lychee_api: + condition: service_healthy + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + +networks: + lychee: + +volumes: + mysql: + name: lychee_prod_mysql + driver: local + ai_vision_embeddings: + name: lychee_ai_vision_embeddings + driver: local diff --git a/src/data/wizard/env.example b/src/data/wizard/env.example new file mode 100644 index 00000000..ac2b6623 --- /dev/null +++ b/src/data/wizard/env.example @@ -0,0 +1,371 @@ +APP_NAME=Lychee +APP_ENV=production +APP_KEY= +APP_DEBUG=false +# This MUST contain the host name up to the Top Level Domain (tld) e.g. .com, .org etc. +APP_URL=http://localhost +APP_FORCE_HTTPS=false + +# If using Lychee in a sub folder, specify the path after the tld here. +# For example for https://lychee.test/path/to/lychee +# Set APP_URL=https://lychee.test +# and APP_DIR=/path/to/lychee +# We (LycheeOrg) do not recommend the use of APP_DIR. +# APP_DIR= + +# enable or disable debug bar. By default it is disabled. +# Do note that this disable CSP!! +DEBUGBAR_ENABLED=false + +# enable or disable log viewer. By default it is disabled +# Unfortunately, it is not possible to enable Log Viewer in production. +# If you wish to enable it, also switch your APP_ENV to 'local' +LOG_VIEWER_ENABLED=false + +# disable logging 404 errors +# LOG_404_ERRORS=false + +# enable or disable clockwork. By default it is disabled (and not provided on non-dev build). +CLOCKWORK_ENABLE=false +CLOCKWORK_DRIVER=laravel +CLOCKWORK_STORAGE_FILES_PATH=storage/clockwork + +# enable or disable latency debug: adds a specific amount of time in milliseconds to wait before processing requests. +# Always disabled on production environment. +# APP_DEBUG_LATENCY=0 + +# All API requests to have the header "content-type: application/json" +# or "content-type: multipart/form-data" depending on the type. +# +# If you want to disable this requirement, set this to false. +# +# This requirement prevents the use of the API from the API documentation page. +REQUIRE_CONTENT_TYPE_ENABLED=true + +# enable s3 bucket (required in addition to needing AWS_ACCESS_KEY_ID) +# S3_ENABLED=true + +# If you spread old links of to your albums in your Lychee instance starting with +# https://lychee.text/#albumID/PhotoId +# Set this value to true to enable redirection. +LEGACY_V4_REDIRECT=false + +############################################################################## +# IMPORTANT: To migrate from Lychee v3 you *MUST* use the same MySQL/MariaDB # +# server as v3. # +############################################################################## + +# Table prefix (e.g. lychee_) of a Lychee v3 instance for migration +DB_OLD_LYCHEE_PREFIX= + +# DB_CONNECTION can be sqlite, mysql or pgsql. For sqlite the other entries are +# not required, but an existing sqlite3 database may be specified if desired. +# In this case, please use an absolute path. DB_DATABASE may be omitted but should +# *not* be left blank. +# Note that if DB_PASSWORD includes special characters, it must be enclosed in quotes. +# e.g. DB_PASSWORD="lychee!@#$%^&" +DB_CONNECTION=sqlite +DB_HOST= +DB_PORT= +#DB_DATABASE= +DB_USERNAME= +DB_PASSWORD= +DB_LOG_SQL=false +DB_LOG_SQL_EXPLAIN=false #only for MySQL + +# List foreign keys in diagnostic page +DB_LIST_FOREIGN_KEYS=false + +# Application timezone. If not specified, the server's default timezone is used. +# Requires a named timezone identifier. +# See https://www.php.net/manual/en/timezones.php for the list of supported timezones. +# Don't use a timezone offset (like +01:00) or a timezone abbreviation (like CEST) +# TIMEZONE=Europe/Paris + +# Visibility of directories and (media) files in LYCHEE_UPLOADS +# Possible values are: +# +# - private: world group has neither read nor write access +# - public: world group has read access but no write access (the default) +# - world: world group has read and write access +# +# The default should suffice for most installations. +# For improved security, change this setting to "private". +# Some rare setups may require directories and files to be world writeable. +# In this case, use "world" here. +# USE WITH PRECAUTIONS: world writeable files and folders may be a SECURITY RISK. +# LYCHEE_IMAGE_VISIBILITY=public + +# folders in which the files will be stored +# LYCHEE_UPLOADS="/var/www/html/Lychee-Laravel/public/uploads/" +# LYCHEE_DIST="/var/www/html/Lychee-Laravel/public/dist/" +# LYCHEE_SYM="/var/www/html/Lychee-Laravel/public/sym/" +# url to access those files +# LYCHEE_UPLOADS_URL="uploads/" +# LYCHEE_DIST_URL="dist/" +# LYCHEE_SYM_URL="sym/" + +# Support for token based authentication used by API requests. Enabled by default. +# ENABLE_TOKEN_AUTH=true + +# Lychee supports both Redis and file caching. +# To use Redis, set CACHE_DRIVER to redis and configure the Redis connection. +CACHE_DRIVER=file +# CACHE_FILE_PATH=storage/framework/cache/data +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 +# REDIS_URL=redis://:@: + +# If you use Redis as cache driver, we strongly recommend +# to disable it for your Log Viewer. +# Should redis crash, you will no longer be able to access your logs. +LOG_VIEWER_CACHE_DRIVER=file +LOG_STDOUT=false + +# Session configuration +SESSION_DRIVER=file +SESSION_LIFETIME=120 +# Duration (in minutes) for the "Remember Me" cookie. Default: 40320 (4 weeks) +# REMEMBER_LIFETIME=40320 + +# `sync` if jobs need to be executed live (default) or `database` if they can be deferred. +QUEUE_CONNECTION=sync +# Choose this mode only if you have set up a queue worker (strongly recommended though). +# QUEUE_CONNECTION=database + +SECURITY_HEADER_HSTS_ENABLE=false +SECURITY_HEADER_CSP_CONNECT_SRC= +SECURITY_HEADER_SCRIPT_SRC_ALLOW= +SECURITY_HEADER_CSP_CHILD_SRC= +SECURITY_HEADER_CSP_FONT_SRC= +SECURITY_HEADER_CSP_FORM_ACTION= +SECURITY_HEADER_CSP_FRAME_ANCESTORS= +SECURITY_HEADER_CSP_FRAME_SRC= +SECURITY_HEADER_CSP_IMG_SRC= +SECURITY_HEADER_CSP_MEDIA_SRC= +SESSION_SECURE_COOKIE=false + +MAIL_DRIVER=smtp +MAIL_HOST= +MAIL_PORT= +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_ENCRYPTION= +MAIL_FROM_NAME= +MAIL_FROM_ADDRESS= + +# The trusted proxies if Lychee is behind a reverse proxy +# Accepted values: +# - `null`: no proxy +# - `*`: any proxy +# - [,]: a comma-seperated list of IP addresses +TRUSTED_PROXIES=null + +# Comma-separated list of class names of diagnostics checks that should be skipped. +#SKIP_DIAGNOSTICS_CHECKS= + +VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" +VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" + +# Disable Basic Auth. This means that the only way to authenticate is via the API token or Oauth. +# This should only be toggled AFTER having set up the admin account and bound the Oauth client. +# DISABLE_BASIC_AUTH=false + +# Disable WebAuthn. This means that the only way to authenticate is via the API token, Basic Auth or Oauth. +# DISABLE_WEBAUTHN=false + +# White-label mode — hides all Lychee branding from the UI (footer link, generator meta tag, +# misconfiguration warning, left-menu "Lychee" section, and login-form branding). +# NOTE: This setting only takes effect when a valid Lychee Supporter Edition (SE) licence is active. +# On non-SE installations the flag is ignored and Lychee branding remains visible. +# WHITE_LABEL_ENABLED=false + +################################################################### +# Keygen License Management # +################################################################### + +# API token obtained from keygen.lycheeorg.dev. +# When set, Lychee will automatically rotate an expired license key +# on admin login and check the token health on the diagnostics page. +# KEYGEN_API_KEY= + +################################################################### +# LDAP Authentication (enterprise directory integration) # +################################################################### + +# Enable LDAP authentication alongside or instead of basic auth +# LDAP_ENABLED=false + +# LDAP Server connection settings +# LDAP_HOST=ldap.example.com +# LDAP_PORT=389 +# For LDAPS (LDAP over SSL), use port 636 +# LDAP_PORT=636 + +# Base DN for LDAP searches (e.g., dc=example,dc=com or dc=corp,dc=example,dc=com) +# LDAP_BASE_DN=dc=example,dc=com + +# Service account credentials for LDAP bind +# This account needs read-only access to user and group attributes +# LDAP_BIND_DN=cn=lychee-service,ou=services,dc=example,dc=com +# LDAP_BIND_PASSWORD=securepassword + +# LDAP user search filter (%s is replaced with username) +# For OpenLDAP: +# LDAP_USER_FILTER=(&(objectClass=person)(uid=%s)) +# For Active Directory: +# LDAP_USER_FILTER=(&(objectClass=user)(sAMAccountName=%s)) + +# LDAP attribute mapping (maps LDAP attributes to Lychee user fields) +# OpenLDAP defaults: +# LDAP_ATTR_USERNAME=uid +# LDAP_ATTR_EMAIL=mail +# LDAP_ATTR_DISPLAY_NAME=displayName +# Active Directory alternatives: +# LDAP_ATTR_USERNAME=sAMAccountName +# LDAP_ATTR_EMAIL=userPrincipalName +# LDAP_ATTR_DISPLAY_NAME=displayName + +# Admin role mapping via LDAP group +# Users in this group will have may_administrate=true +# LDAP_ADMIN_GROUP_DN=cn=lychee-admins,ou=groups,dc=example,dc=com + +# Auto-provision users on first LDAP login +# If false, users must be pre-created in Lychee before they can log in via LDAP +# LDAP_AUTO_PROVISION=true + +# TLS/SSL settings for secure LDAP connections +# LDAP_USE_TLS=true +# LDAP_TLS_VERIFY_PEER=true + +# Connection timeout in seconds +# LDAP_CONNECTION_TIMEOUT=5 + +# Oauth token data +# XXX_REDIRECT_URI should be left as default unless you know exactly what you do. + +# AMAZON_SIGNIN_CLIENT_ID= +# AMAZON_SIGNIN_SECRET= +# AMAZON_SIGNIN_REDIRECT_URI=/auth/amazon/redirect + +# https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple +# Note: the client secret used for "Sign In with Apple" is a JWT token that can have a maximum lifetime of 6 months. +# The article above explains how to generate the client secret on demand and you'll need to update this every 6 months. +# To generate the client secret for each request, see Generating A Client Secret For Sign In With Apple On Each Request. +# https://bannister.me/blog/generating-a-client-secret-for-sign-in-with-apple-on-each-request +# APPLE_CLIENT_ID= +# APPLE_CLIENT_SECRET= +# APPLE_REDIRECT_URI=/auth/apple/redirect + +# FACEBOOK_CLIENT_ID= +# FACEBOOK_CLIENT_SECRET= +# FACEBOOK_REDIRECT_URI=/auth/facebook/redirect + +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= +# GITHUB_REDIRECT_URI=/auth/github/redirect + +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# GOOGLE_REDIRECT_URI=/auth/google/redirect + +# MASTODON_DOMAIN=https://mastodon.social +# MASTODON_ID= +# MASTODON_SECRET= +# MASTODON_REDIRECT_URI=/auth/mastodon/redirect + +# MICROSOFT_CLIENT_ID= +# MICROSOFT_CLIENT_SECRET= +# MICROSOFT_REDIRECT_URI=/auth/microsoft/redirect +# MICROSOFT_TENANT_ID= + +# NEXTCLOUD_CLIENT_ID= +# NEXTCLOUD_CLIENT_SECRET= +# NEXTCLOUD_REDIRECT_URI=/auth/nextcloud/redirect +# NEXTCLOUD_BASE_URI= + +# KEYCLOAK_CLIENT_ID= +# KEYCLOAK_CLIENT_SECRET= +# KEYCLOAK_REDIRECT_URI=/auth/keycloak/redirect +# KEYCLOAK_BASE_URL= +# KEYCLOAK_REALM= + +# AUTHENTIK_BASE_URL= +# AUTHENTIK_CLIENT_ID= +# AUTHENTIK_CLIENT_SECRET= +# AUTHENTIK_REDIRECT_URI=/auth/authentik/redirect + +# AUTHELIA_BASE_URL= +# AUTHELIA_CLIENT_ID= +# AUTHELIA_CLIENT_SECRET= +# AUTHELIA_REDIRECT_URI=/auth/authelia/redirect + +# AWS support data + +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# AWS_DEFAULT_REGION= +# AWS_BUCKET= +# AWS_URL= +# AWS_ENDPOINT= +# AWS_IMAGE_VISIBILITY= +# AWS_USE_PATH_STYLE_ENDPOINT= + +################################################################### +# Vite local development without running a server. # +# set VITE_LOCAL_DEV to true # +# set VITE_HTTP_PROXY_TARGET to the rediction for the API calls. # +################################################################### +# VITE_LOCAL_DEV=true +# VITE_HTTP_PROXY_TARGET=http://localhost:8000 + +# DISABLE_IMPORT_FROM_SERVER=false + +# On shared hosting where sys_get_temp_dir() is not readable/writable, +# set to false to use storage/tmp/uploads_parts instead. +# USE_SYSTEM_TEMP_DIR=true + +# When enabled, the request caching settings (cache_enabled, cache_ttl, +# cache_event_logging) become visible in the admin settings panel. +# ENABLE_REQUEST_CACHING=false + +################################################################### +# Payment integration (requires SE) # +################################################################### + +# Enable test mode (Sandbox mode) for payment gateways. +# In test mode, no real money transactions are done. +# We set it to true by default for safety. Make sure to set it to false +# when you go live. +# OMNIPAY_TEST_MODE=true + +# Configuration values for Mollie integration +# MOLLIE_API_KEY= +# MOLLIE_PROFILE_ID= + +# Configuration values for Stripe integration (NOT WORKING YET, MAYBE LATER) +# STRIPE_API_KEY= +# STRIPE_PUBLISHABLE_KEY= + +# Configuration values for PayPal integration +# PAYPAL_CLIENT_ID= +# PAYPAL_SECRET= + +################################################################### +# AI Vision (facial recognition & NSFW classification) # +################################################################### + +# AI_VISION_FACE_URL= +# AI_VISION_FACE_API_KEY= +# AI_VISION_NSFW_URL= +# AI_VISION_NSFW_API_KEY= + +################################################################### +# Local reverse geo-decoding # +################################################################### + +# Nominatim-API-compatible endpoint (e.g. a self-hosted Nominatim +# instance). When set, it is used instead of the public +# nominatim.openstreetmap.org server. +# LOCAL_GEO_DECODING_URL= diff --git a/src/navigation.js b/src/navigation.js index 08e030be..9e573b07 100644 --- a/src/navigation.js +++ b/src/navigation.js @@ -57,12 +57,13 @@ export const footerData = { { text: 'Release Notes', href: getPermalink('/docs/getting-started/releases/') }, // { text: 'PR Dashboard', href: 'https://pr.lycheeorg.dev/' }, { text: 'Issue trakcer', href: 'https://github.com/LycheeOrg/Lychee/issues' }, - ] + ], }, { title: 'Need help?', links: [ { text: 'Read the Docs', href: '/docs/' }, + { text: 'Docker Compose Wizard', href: '/wizard/' }, { text: 'Community Forum', href: 'https://github.com/LycheeOrg/Lychee/discussions' }, { text: 'Join our discord', href: 'https://discord.gg/JMPvuRQcTf' }, ], @@ -70,7 +71,10 @@ export const footerData = { { title: 'Support Lychee', links: [ - { text: 'Get Lychee SE', href: 'https://lycheeorg.dev/get-supporter-edition' }, + { + text: 'Get Lychee SE', + href: 'https://lycheeorg.dev/get-supporter-edition', + }, { text: 'GitHub sponsor', href: 'https://github.com/sponsors/LycheeOrg' }, { text: 'Open Collective', href: 'https://opencollective.com/LycheeOrg' }, { text: 'Translations', href: 'https://weblate.lycheeorg.dev' }, @@ -78,18 +82,16 @@ export const footerData = { }, { title: 'Security', - links: [ - { text: 'Cosign key', href: getAsset('lychee-cosign.pub') }, - ] - } + links: [{ text: 'Cosign key', href: getAsset('lychee-cosign.pub') }], + }, ], secondaryLinks: [ { text: 'License', href: getPermalink('/license') }, { text: 'Privacy Policy', href: getPermalink('/privacy-policy') }, ], socialLinks: [ - { ariaLabel: 'RSS', icon: 'tabler:rss', href: getAsset('/rss.xml') }, - { ariaLabel: 'Github', icon: 'tabler:brand-github', href: 'https://github.com/LycheeOrg/Lychee' }, + { ariaLabel: 'RSS', icon: 'tabler:rss', href: getAsset('/rss.xml') }, + { ariaLabel: 'Github', icon: 'tabler:brand-github', href: 'https://github.com/LycheeOrg/Lychee' }, ], footNote: `Maintained by LycheeOrg — Built with Astro & Tailwind CSS`, }; diff --git a/src/pages/index.astro b/src/pages/index.astro index 7cfd54c8..a6dbe11d 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -13,11 +13,14 @@ import show0 from '~/assets/images/showcase/0.jpg'; import show1 from '~/assets/images/showcase/1.jpg'; import show2 from '~/assets/images/showcase/2.jpg'; import show3 from '~/assets/images/showcase/3.jpg'; +import { getRepoStats } from '~/utils/repoStats'; const metadata = { title: 'LycheeOrg — Self-hosted photo-management done right.', ignoreTitleTemplate: true, }; + +const repoStats = await getRepoStats(); --- @@ -42,9 +45,9 @@ const metadata = { - Lychee is a free photo-management tool, which runs on your server or web-space. - Installing is a matter of seconds. Upload, manage and share photos like from a native application. Lychee comes with - everything you need and all your photos are stored securely. + Lychee is a free photo-management tool, which runs on your server or web-space. Installing + is a matter of seconds. Upload, manage and share photos like from a native application. Lychee comes with everything + you need and all your photos are stored securely. @@ -81,13 +84,7 @@ const metadata = { >
- 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..a4c91d8e --- /dev/null +++ b/src/pages/wizard.astro @@ -0,0 +1,1433 @@ +--- +import Layout from '~/layouts/Layout.astro'; + +const metadata = { + title: 'Docker Compose Wizard', + description: + 'Generate a ready-to-run docker-compose.yaml and .env for Lychee, right in your browser. Same questions as the Lychee Wizard CLI, no install required.', +}; + +const inputClass = + 'py-2 px-3 block w-full flex-1 min-w-0 text-xs rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900 focus:border-primary focus:ring-primary disabled:opacity-50'; +const selectClass = inputClass; +const labelClass = 'text-xs font-medium sm:w-40 sm:shrink-0'; +const fieldRowClass = 'flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4'; +const descClass = 'text-xs text-gray-500 dark:text-gray-400 mt-1'; +const fieldsetClass = 'bg-white dark:bg-slate-900'; +const checkboxRowClass = 'flex items-start gap-3'; +const checkboxClass = + 'h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 dark:border-gray-600 text-primary focus:ring-primary'; +const badgeClass = + 'ml-2 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'; +// Service card status badges — like badgeClass, but without its ml-2 (these +// sit in a flex row with its own gap, not inline after label text). +const statusOkClass = + 'px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'; +const statusWarnClass = + 'px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; +// Small tags next to a service card's title (AI, SE). +const tagAiClass = + 'px-1.5 py-0.5 rounded text-xxs font-semibold bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200'; +const tagSeClass = + 'px-1.5 py-0.5 rounded text-xxs font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-200'; +--- + + +
+
+ +
+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
  7. +
  8. +
+ +
+
+
+
+

+ 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… +

+
+
+
+ + + + + + + + + + + + + + +
+ +
+ + 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..22ad35f1 --- /dev/null +++ b/src/utils/repoStats.ts @@ -0,0 +1,129 @@ +// Fetched at build time (site is fully static) to keep the homepage stats +// widget honest instead of hand-editing numbers on every release. + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +// process.cwd() (the project root, since Astro always builds from there) rather than +// import.meta.url — the latter resolves into the transient build bundle, not the source +// tree, so a path derived from it wouldn't survive between builds. +const CACHE_FILE = join(process.cwd(), '.cache/repo-stats.json'); +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +const GITHUB_REPO_URL = 'https://api.github.com/repos/LycheeOrg/Lychee'; +const GITHUB_RELEASES_URL = 'https://api.github.com/repos/LycheeOrg/Lychee/releases?per_page=100'; +// GHCR (ghcr.io/lycheeorg/lychee, ghcr.io/linuxserver/lychee) does not expose pull/download +// counts through any public or authenticated API, nor on the package page itself — so only +// the two Docker Hub mirrors, which do report a pull_count, are counted here. +const DOCKER_HUB_URLS = [ + 'https://hub.docker.com/v2/repositories/lycheeorg/lychee/', + 'https://hub.docker.com/v2/repositories/linuxserver/lychee/', +]; + +// Last known-good values, used if a fetch fails (e.g. offline build, rate limiting). +const FALLBACK = { + downloads: 43_000, + stars: 4_250, + forks: 374, + dockerPulls: 4_090_000 + 19_636_000, +}; + +export interface RepoStats { + downloads: string; + stars: string; + forks: string; + dockerPulls: string; +} + +function formatCount(n: number): string { + const format = (value: number, suffix: string) => `${parseFloat(value.toFixed(1))}${suffix}`; + if (n >= 1_000_000) return format(n / 1_000_000, 'M'); + if (n >= 1_000) return format(n / 1_000, 'K'); + return String(n); +} + +async function fetchGitHubRepo(): Promise<{ stars: number; forks: number }> { + const res = await fetch(GITHUB_REPO_URL, { headers: { Accept: 'application/vnd.github+json' } }); + if (!res.ok) throw new Error(`GitHub repo request failed: ${res.status}`); + const data = await res.json(); + return { stars: data.stargazers_count, forks: data.forks_count }; +} + +async function fetchGitHubReleaseDownloads(): Promise { + let total = 0; + let url: string | null = GITHUB_RELEASES_URL; + + while (url) { + const res: Response = await fetch(url, { headers: { Accept: 'application/vnd.github+json' } }); + if (!res.ok) throw new Error(`GitHub releases request failed: ${res.status}`); + const releases: { assets: { download_count: number }[] }[] = await res.json(); + for (const release of releases) { + for (const asset of release.assets) { + total += asset.download_count; + } + } + + const link = res.headers.get('link'); + const next = link?.split(',').find((part) => part.includes('rel="next"')); + url = next ? next.split(';')[0].trim().slice(1, -1) : null; + } + + return total; +} + +async function fetchDockerPulls(): Promise { + const counts = await Promise.all( + DOCKER_HUB_URLS.map(async (url) => { + const res = await fetch(url); + if (!res.ok) throw new Error(`Docker Hub request failed for ${url}: ${res.status}`); + const data = await res.json(); + return data.pull_count as number; + }), + ); + return counts.reduce((sum, count) => sum + count, 0); +} + +interface Cache { + timestamp: number; + stats: RepoStats; +} + +function readCache(): RepoStats | null { + try { + const cache: Cache = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')); + if (Date.now() - cache.timestamp < CACHE_TTL_MS) return cache.stats; + } catch { + // no cache yet, or unreadable — fall through to a fresh fetch + } + return null; +} + +function writeCache(stats: RepoStats): void { + try { + mkdirSync(dirname(CACHE_FILE), { recursive: true }); + writeFileSync(CACHE_FILE, JSON.stringify({ timestamp: Date.now(), stats } satisfies Cache)); + } catch { + // best-effort; a failed cache write shouldn't break the build + } +} + +export async function getRepoStats(): Promise { + const cached = readCache(); + if (cached) return cached; + + const [repo, downloads, dockerPulls] = await Promise.all([ + fetchGitHubRepo().catch(() => ({ stars: FALLBACK.stars, forks: FALLBACK.forks })), + fetchGitHubReleaseDownloads().catch(() => FALLBACK.downloads), + fetchDockerPulls().catch(() => FALLBACK.dockerPulls), + ]); + + const stats: RepoStats = { + downloads: formatCount(downloads), + stars: formatCount(repo.stars), + forks: formatCount(repo.forks), + dockerPulls: formatCount(dockerPulls), + }; + + writeCache(stats); + return stats; +} diff --git a/src/utils/wizard/answers.ts b/src/utils/wizard/answers.ts new file mode 100644 index 00000000..eab49adb --- /dev/null +++ b/src/utils/wizard/answers.ts @@ -0,0 +1,106 @@ +// Based on github.com/LycheeOrg/Wizard's internal/wizard.Answers and +// Defaults(), extended with a few web-only options (database engine/location +// choice, NSFW classification) the CLI doesn't offer. +export type DbEngine = 'mariadb' | 'pgsql' | 'sqlite'; +export type DbLocation = 'docker' | 'external'; + +export interface WizardAnswers { + // General + appName: string; + appUrl: string; + appPort: string; + appForceHttps: boolean; + timezone: string; + + // Database + dbEngine: DbEngine; + dbLocation: DbLocation; + dbHost: string; + dbPort: string; + dbDatabase: string; + dbUsername: string; + generatePasswords: boolean; + dbPassword: string; + dbRootPassword: string; + + // Configuration delivery + useEnvFile: boolean; + + // Secrets handling + useDockerSecrets: boolean; + + // Optional services + enablePhpMyAdmin: boolean; + enableAiVision: boolean; + customAiVisionKey: boolean; + aiVisionApiKey: string; + enableNsfw: boolean; + customNsfwKey: boolean; + nsfwApiKey: string; + + // OAuth login providers — ids of providers the user has added a card for + // (see oauthProviders.ts), and their filled-in field values, keyed + // `${providerId}:${fieldKey}`. + activeOAuthProviders: string[]; + oauthFieldValues: Record; + + // Queue worker + useWorker: boolean; + workerCount: string; + + // Traefik reverse proxy + enableTraefik: boolean; + traefikEntrypoint: string; + traefikCertResolver: string; + traefikNetwork: string; + + // System + puid: string; + pgid: string; +} + +export function defaultAnswers(): WizardAnswers { + return { + appName: 'Lychee', + appUrl: 'http://localhost', + appPort: '8000', + appForceHttps: false, + timezone: 'UTC', + dbEngine: 'mariadb', + dbLocation: 'docker', + dbHost: '', + dbPort: '', + dbDatabase: 'lychee', + dbUsername: 'lychee', + generatePasswords: true, + dbPassword: '', + dbRootPassword: '', + useEnvFile: true, + useDockerSecrets: true, + enablePhpMyAdmin: false, + enableAiVision: false, + customAiVisionKey: false, + aiVisionApiKey: '', + enableNsfw: false, + customNsfwKey: false, + nsfwApiKey: '', + activeOAuthProviders: [], + oauthFieldValues: {}, + useWorker: true, + workerCount: '1', + enableTraefik: false, + traefikEntrypoint: 'websecure', + traefikCertResolver: 'letsencrypt', + traefikNetwork: 'traefik', + puid: '1000', + pgid: '1000', + }; +} + +// needsDbService reports whether the answers require Lychee's own +// docker-managed database service (currently: MariaDB only — Lychee's +// official compose file doesn't ship a Postgres container, and SQLite needs +// no server at all). +export function needsDbService(a: Pick): boolean { + return a.dbEngine === 'mariadb' && a.dbLocation === 'docker'; +} diff --git a/src/utils/wizard/composeEdit.ts b/src/utils/wizard/composeEdit.ts new file mode 100644 index 00000000..78ecf587 --- /dev/null +++ b/src/utils/wizard/composeEdit.ts @@ -0,0 +1,27 @@ +// Shared structural (indentation-based) YAML editing helper for +// docker-compose.yaml patches that remove a whole service block by shape +// rather than literal text match — see dbCompose.ts's module comment for +// why that's preferable to a literal patch here. + +// removeIndentedBlock removes the line matching startLineRegex and every +// following line that's indented deeper than it, i.e. its whole nested +// block. A single blank line immediately before the block is swallowed too, +// so removal doesn't leave a double blank line behind. +export function removeIndentedBlock(lines: string[], startLineRegex: RegExp): string[] { + const start = lines.findIndex((l) => startLineRegex.test(l)); + if (start === -1) return lines; + + const indent = (/^ */.exec(lines[start]) ?? [''])[0].length; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + const m = /^( *)\S/.exec(lines[i]); + if (m && m[1].length <= indent) { + end = i; + break; + } + } + + let removeStart = start; + if (start > 0 && lines[start - 1].trim() === '') removeStart = start - 1; + return [...lines.slice(0, removeStart), ...lines.slice(end)]; +} diff --git a/src/utils/wizard/dbCompose.ts b/src/utils/wizard/dbCompose.ts new file mode 100644 index 00000000..669b96e8 --- /dev/null +++ b/src/utils/wizard/dbCompose.ts @@ -0,0 +1,85 @@ +// Structural (indent-based) edits to docker-compose.yaml for database engine +/// location choices that github.com/LycheeOrg/Wizard's CLI doesn't offer +// (it always ships the bundled MariaDB service). Unlike dockerSecrets.ts, +// these don't match literal upstream text — they match by YAML indentation +// shape, so they degrade gracefully (best-effort, no-op if not found) even +// if upstream reformats comments inside the blocks they touch. + +import { removeIndentedBlock } from './composeEdit'; + +// removeDependsOnEntry removes a `:\n condition: service_healthy` +// pair from under any `depends_on:` mapping, and removes the now-empty +// `depends_on:` line itself if that entry was its only child. +function removeDependsOnEntry(lines: string[], serviceName: string): string[] { + const out: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const keyMatch = new RegExp(`^(\\s+)${serviceName}:\\s*$`).exec(line); + const next = lines[i + 1] ?? ''; + if (keyMatch && /^\s+condition:\s*service_healthy\s*$/.test(next)) { + const indent = keyMatch[1].length; + const after = lines[i + 2] ?? ''; + const hasMoreSiblings = new RegExp(`^ {${indent}}\\S`).test(after); + const prevPushed = out[out.length - 1]; + const dependsOnRe = new RegExp(`^ {${Math.max(indent - 2, 0)}}depends_on:\\s*$`); + if (!hasMoreSiblings && prevPushed !== undefined && dependsOnRe.test(prevPushed)) { + out.pop(); + } + i += 1; // also skip the `condition:` line + continue; + } + out.push(line); + } + return out; +} + +export interface RemoveDbServiceResult { + compose: string; + removed: boolean; +} + +// removeDbService strips Lychee's bundled `lychee_db` (MariaDB) service — +// used when the wizard answers call for SQLite or an externally-managed +// database — along with every `depends_on: lychee_db: …` reference to it and +// its now-orphaned `mysql:` named volume, so the resulting compose file +// stays valid on its own. +export function removeDbService(compose: string): RemoveDbServiceResult { + let lines = compose.split('\n'); + const before = lines.length; + + lines = removeIndentedBlock(lines, /^ {2}lychee_db:\s*$/); + const removed = lines.length !== before; + + lines = removeDependsOnEntry(lines, 'lychee_db'); + lines = removeIndentedBlock(lines, /^ {2}mysql:\s*$/); + + return { compose: lines.join('\n'), removed }; +} + +export interface AddSqliteVolumeResult { + compose: string; + added: boolean; +} + +// addSqliteVolume mounts the SQLite database file onto /app/database, right +// after the existing uploads/logs/tmp mounts in the shared +// x-base-lychee-setup anchor (so both lychee_api and lychee_worker inherit +// it). Without this, SQLite's database.sqlite (Laravel's database_path() +// default — see config/database.php) lives only inside the container's +// writable layer and is lost on `docker compose down` / container +// recreation. Mounting the file itself (not the whole directory) avoids +// masking anything else Lychee may keep under /app/database. +export function addSqliteVolume(compose: string): AddSqliteVolumeResult { + const lines = compose.split('\n'); + const anchor = ' - ./lychee/tmp:/app/storage/tmp'; + const idx = lines.findIndex((l) => l === anchor); + if (idx === -1) return { compose, added: false }; + + const insertion = [ + ' # Database: where the SQLite database file is stored, so it persists', + ' # across container restarts/recreation.', + ' - ./lychee/database/database.sqlite:/app/database/database.sqlite', + ]; + const newLines = [...lines.slice(0, idx + 1), ...insertion, ...lines.slice(idx + 1)]; + return { compose: newLines.join('\n'), added: true }; +} diff --git a/src/utils/wizard/dockerSecrets.ts b/src/utils/wizard/dockerSecrets.ts new file mode 100644 index 00000000..08bf89e7 --- /dev/null +++ b/src/utils/wizard/dockerSecrets.ts @@ -0,0 +1,164 @@ +// Mirrors github.com/LycheeOrg/Wizard's internal/generator/dockersecrets.go: +// patches docker-compose.yaml to activate the file-based Docker secrets +// Lychee's compose file already ships in commented-out form. + +interface SecretPatch { + name: string; + lines: string[]; + // toggle receives the block's original lines (untouched) and must return + // the replacement lines, same length. + toggle: (block: string[]) => string[]; +} + +function splitLeadingWS(line: string): { ws: string; rest: string } { + const m = /^[ \t]*/.exec(line); + const ws = m ? m[0] : ''; + return { ws, rest: line.slice(ws.length) }; +} + +// uncomment strips a leading "#" (and one following space, if present) from +// a line, preserving its original leading whitespace. +function uncomment(line: string): string { + const { ws, rest } = splitLeadingWS(line); + let r = rest; + if (r.startsWith('#')) r = r.slice(1); + if (r.startsWith(' ')) r = r.slice(1); + return ws + r; +} + +// commentOut prefixes a line with "# " right after its leading whitespace. +function commentOut(line: string): string { + const { ws, rest } = splitLeadingWS(line); + if (rest.startsWith('#')) return line; + return ws + '# ' + rest; +} + +function uncommentAll(block: string[]): string[] { + return block.map(uncomment); +} + +// essentialPatches apply regardless of database engine/location (they live +// in x-base-lychee-setup / x-common-env, shared by every container) and +// activate file-based Docker secrets for APP_KEY and DB_PASSWORD. If any of +// these can't be found, enableDockerSecrets reports failure. +const essentialPatches: SecretPatch[] = [ + { + name: 'top-level secrets block', + lines: [ + '# secrets:', + '# db_password:', + '# file: ./secrets/db_password', + '# db_master_password:', + '# file: ./secrets/db_master_password', + '# app_key:', + '# file: ./secrets/app_key', + ], + toggle: uncommentAll, + }, + { + name: 'x-base-lychee-setup secrets list', + lines: ['# secrets:', '# - db_password', '# - app_key'], + toggle: uncommentAll, + }, + { + name: 'APP_KEY / APP_KEY_FILE swap', + lines: ['APP_KEY: "${APP_KEY:-}"', '# APP_KEY_FILE: "/run/secrets/app_key"'], + toggle: (block) => [commentOut(block[0]), uncomment(block[1])], + }, + { + name: 'DB_PASSWORD / DB_PASSWORD_FILE swap', + lines: [ + 'DB_PASSWORD: "${DB_PASSWORD:-password}"', + '#', + '# Or you can uncomment the following line to use DB_PASSWORD_FILE from secrets', + '# DB_PASSWORD_FILE: "/run/secrets/db_password"', + ], + toggle: (block) => [commentOut(block[0]), block[1], block[2], uncomment(block[3])], + }, +]; + +// optionalPatches only exist inside Lychee's bundled MariaDB service +// (`lychee_db`). They're best-effort: when the wizard answers remove or +// replace that service (SQLite, an external database, or a non-MariaDB +// engine), this text simply won't be there, and that's fine — skip silently +// rather than treating it as a failure. +const optionalPatches: SecretPatch[] = [ + { + name: 'lychee_db secrets list', + lines: ['# secrets:', '# - db_master_password', '# - db_password'], + toggle: uncommentAll, + }, + { + name: 'MYSQL_ROOT_PASSWORD / MYSQL_ROOT_PASSWORD_FILE swap', + lines: [ + '- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-rootpassword}', + '# - MYSQL_ROOT_PASSWORD_FILE=/run/secrets/db_master_password', + ], + toggle: (block) => [commentOut(block[0]), uncomment(block[1])], + }, + { + name: 'MYSQL_PASSWORD / MYSQL_PASSWORD_FILE swap', + lines: ['- MYSQL_PASSWORD=${DB_PASSWORD:-password}', '# - MYSQL_PASSWORD_FILE=/run/secrets/db_password'], + toggle: (block) => [commentOut(block[0]), uncomment(block[1])], + }, +]; + +// matchBlock finds the first index at or after `from` where the trimmed +// content of consecutive lines equals expected, in order. Returns -1 if not +// found. +function matchBlock(lines: string[], from: number, expected: string[]): number { + if (expected.length === 0) return from; + for (let i = from; i + expected.length <= lines.length; i++) { + let match = true; + for (let j = 0; j < expected.length; j++) { + if (lines[i + j].trim() !== expected[j]) { + match = false; + break; + } + } + if (match) return i; + } + return -1; +} + +export interface EnableDockerSecretsResult { + patched: string; + ok: boolean; + reason?: string; +} + +// enableDockerSecrets patches compose (docker-compose.yaml content) to +// activate the file-based Docker secrets Lychee's compose file already ships +// in commented-out form. If the upstream file no longer contains one of the +// expected blocks (e.g. it was restructured), ok is false and reason +// explains what wasn't found; compose is returned unmodified in that case. +function applyPatch(lines: string[], patch: SecretPatch): boolean { + const start = matchBlock(lines, 0, patch.lines); + if (start === -1) return false; + const block = lines.slice(start, start + patch.lines.length); + const replacement = patch.toggle(block); + for (let j = 0; j < replacement.length; j++) { + lines[start + j] = replacement[j]; + } + return true; +} + +export function enableDockerSecrets(compose: string): EnableDockerSecretsResult { + const lines = compose.split('\n'); + + for (const patch of essentialPatches) { + if (!applyPatch(lines, patch)) { + return { + patched: compose, + ok: false, + reason: `could not locate "${patch.name}" in docker-compose.yaml`, + }; + } + } + + for (const patch of optionalPatches) { + applyPatch(lines, patch); + } + + return { patched: lines.join('\n'), ok: true }; +} diff --git a/src/utils/wizard/envFileCompose.ts b/src/utils/wizard/envFileCompose.ts new file mode 100644 index 00000000..47248af6 --- /dev/null +++ b/src/utils/wizard/envFileCompose.ts @@ -0,0 +1,60 @@ +// Patches applied when the wizard answers say not to use a separate .env +// file — everything the wizard would otherwise have written there gets +// baked directly into docker-compose.yaml instead, and the env_file +// references (which would point at a file that no longer exists) are +// stripped. + +import { removeIndentedBlock } from './composeEdit'; + +export interface RemoveEnvFileReferencesResult { + compose: string; + removed: boolean; +} + +// removeEnvFileReferences strips both `env_file: [{path: ./.env, ...}]` +// blocks — the one in x-base-lychee-setup (inherited by lychee_api and +// lychee_worker) and the one on lychee_db. +export function removeEnvFileReferences(compose: string): RemoveEnvFileReferencesResult { + let lines = compose.split('\n'); + let removedAny = false; + + // There are exactly two occurrences; removeIndentedBlock only strips the + // first match per call, so run it once per occurrence. + for (let i = 0; i < 2; i++) { + const before = lines.length; + lines = removeIndentedBlock(lines, /^\s*env_file:\s*$/); + if (lines.length !== before) removedAny = true; + } + + return { compose: lines.join('\n'), removed: removedAny }; +} + +export interface RemovePhpMyAdminProfileGateResult { + compose: string; + removed: boolean; +} + +// removePhpMyAdminProfileGate strips phpmyadmin's `profiles: [phpmyadmin]` +// gate. Normally that's flipped on via COMPOSE_PROFILES in .env; without a +// .env file there's no clean way to set it, so if the wizard answers asked +// for phpMyAdmin, it needs to just always start instead. +export function removePhpMyAdminProfileGate(compose: string): RemovePhpMyAdminProfileGateResult { + const lines = compose.split('\n'); + const patched = removeIndentedBlock(lines, /^\s*profiles:\s*$/); + return { compose: patched.join('\n'), removed: patched.length !== lines.length }; +} + +const VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}/g; + +// inlineEnvVars replaces every `${KEY}` / `${KEY:-default}` left in compose +// with a literal value: the wizard-computed value if there is one, else the +// template's own fallback default. Substitution is document-wide by design +// — e.g. DB_PASSWORD is interpolated both in lychee_api/lychee_worker's +// environment and in lychee_db's MYSQL_PASSWORD, and both need to end up +// with the *same* literal value for auth between the containers to work. +export function inlineEnvVars(compose: string, values: Record): string { + return compose.replace(VAR_PATTERN, (_match, key: string, _hasDefault, def: string | undefined) => { + if (Object.prototype.hasOwnProperty.call(values, key)) return values[key]; + return def ?? ''; + }); +} diff --git a/src/utils/wizard/generator.ts b/src/utils/wizard/generator.ts new file mode 100644 index 00000000..dd5c768b --- /dev/null +++ b/src/utils/wizard/generator.ts @@ -0,0 +1,302 @@ +// Based on github.com/LycheeOrg/Wizard's internal/generator/generator.go: +// turns the fetched/embedded Lychee templates plus the wizard's answers into +// docker-compose.yaml, .env, and (when requested) Docker secrets file +// contents. Unlike the Go CLI, nothing is written to disk here — callers +// get strings back to display, copy, or download. Extended with database +// engine/location and NSFW classification, which the CLI doesn't offer. +import { enableDockerSecrets } from './dockerSecrets'; +import { removeDbService, addSqliteVolume } from './dbCompose'; +import { insertNsfwService } from './nsfwService'; +import { removeWorkerService } from './workerCompose'; +import { addTraefikLabels } from './traefikCompose'; +import { removePhpMyAdminService } from './phpMyAdminCompose'; +import { removeEnvFileReferences, removePhpMyAdminProfileGate, inlineEnvVars } from './envFileCompose'; +import { OAUTH_PROVIDERS } from './oauthProviders'; +import { needsDbService, type WizardAnswers } from './answers'; + +interface KV { + key: string; + value: string; +} + +// Random values are generated once per page load (or on demand via a +// "regenerate" action) by the caller, not on every render — otherwise every +// keystroke elsewhere in the form would silently rotate the displayed +// secrets. generate() only decides *whether* a given secret is used, based +// on the answers (e.g. a.generatePasswords), and never generates entropy +// itself. +export interface GeneratedSecrets { + appKey: string; + dbPassword: string; + dbRootPassword: string; + aiVisionApiKey: string; + nsfwApiKey: string; +} + +export interface GenerateResult { + env: string; + compose: string; + // filename -> content, only populated when Docker secrets were enabled + secretFiles: KV[]; + warnings: string[]; + secretsUsed: boolean; + envFileUsed: boolean; + appUrl: string; +} + +const DB_CONNECTION_VALUE: Record = { + mariadb: 'mysql', + pgsql: 'pgsql', + sqlite: 'sqlite', +}; + +const DB_DEFAULT_PORT: Record = { + mariadb: '3306', + pgsql: '5432', + sqlite: '', +}; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function formatKV(key: string, value: string): string { + if (value !== '' && /[ #"'$]/.test(value)) { + value = JSON.stringify(value); + } + return `${key}=${value}`; +} + +function boolStr(b: boolean): string { + return b ? 'true' : 'false'; +} + +// extractHost pulls just the hostname out of the wizard's Application URL +// answer, for use in a Traefik Host() rule (which doesn't want a scheme, +// port, or path). Falls back to a best-effort strip if the URL doesn't +// parse (e.g. mid-edit while typing). +function extractHost(appUrl: string): string { + try { + return new URL(appUrl).hostname || appUrl; + } catch { + return appUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/:]/)[0]; + } +} + +// buildEnv produces the final .env content: envExample with envSets values +// substituted in place, plus an appended "Docker Compose overrides" section +// for overrides (and any envSets) that have no line in envExample. +function buildEnv(envExample: string, envSets: KV[], overrides: KV[]): string { + let lines = envExample.split('\n'); + const matched = new Set(); + + for (const set of envSets) { + const re = new RegExp('^#?\\s*' + escapeRegExp(set.key) + '=.*$'); + const newLines: string[] = []; + let replacedOnce = false; + for (const l of lines) { + if (!replacedOnce && re.test(l)) { + newLines.push(formatKV(set.key, set.value)); + replacedOnce = true; + matched.add(set.key); + continue; + } + newLines.push(l); + } + lines = newLines; + } + + let out = lines.join('\n'); + if (!out.endsWith('\n')) out += '\n'; + + const extraSets = envSets.filter((s) => !matched.has(s.key)); + const pending = [...extraSets, ...overrides]; + if (pending.length > 0) { + out += '\n# ---- Docker Compose overrides ----\n'; + for (const o of pending) { + out += formatKV(o.key, o.value) + '\n'; + } + } + + return out; +} + +// generate mirrors generator.Generate, minus the filesystem writes. Secret +// values are supplied by the caller (see GeneratedSecrets) rather than +// generated here. +export function generate( + envExample: string, + composeTemplate: string, + a: WizardAnswers, + secrets: GeneratedSecrets +): GenerateResult { + const warnings: string[] = []; + let compose = composeTemplate; + const needsDb = needsDbService(a); + + if (!needsDb) { + const { compose: patched, removed } = removeDbService(compose); + compose = patched; + if (!removed) { + warnings.push('could not remove the bundled database service automatically; please remove `lychee_db` by hand'); + } + } + if (a.dbEngine === 'sqlite') { + const { compose: patched, added } = addSqliteVolume(compose); + compose = patched; + if (!added) { + warnings.push( + 'could not add a persistent volume for the SQLite database automatically; add `./lychee/database/database.sqlite:/app/database/database.sqlite` under lychee_api/lychee_worker by hand, or your data will be lost when the container is recreated' + ); + } + } + if (a.enableNsfw) { + const { compose: patched, inserted } = insertNsfwService(compose); + compose = patched; + if (!inserted) { + warnings.push( + 'could not add the NSFW classification service automatically; add it to docker-compose.yaml by hand' + ); + } + } + if (!a.useWorker) { + const { compose: patched, removed } = removeWorkerService(compose); + compose = patched; + if (!removed) { + warnings.push('could not remove the queue worker service automatically; please remove `lychee_worker` by hand'); + } + } + if (a.enablePhpMyAdmin && needsDb) { + const { compose: patched, removed } = removePhpMyAdminProfileGate(compose); + compose = patched; + if (!removed) { + warnings.push('could not enable phpMyAdmin automatically; remove its `profiles:` entry from docker-compose.yaml by hand, or it will stay off'); + } + } else { + const { compose: patched, removed } = removePhpMyAdminService(compose); + compose = patched; + if (!removed) { + warnings.push('could not remove the phpMyAdmin service automatically; please remove `phpmyadmin` by hand'); + } + } + + let traefikAdded = false; + if (a.enableTraefik) { + const { compose: patched, added } = addTraefikLabels(compose, { + hostname: extractHost(a.appUrl), + entrypoint: a.traefikEntrypoint, + certResolver: a.traefikCertResolver, + }); + compose = patched; + traefikAdded = added; + if (!added) { + warnings.push('could not add Traefik labels automatically; add them to docker-compose.yaml by hand'); + } + } + + let secretsUsed = false; + if (a.useDockerSecrets) { + const { patched, ok, reason } = enableDockerSecrets(compose); + if (ok) { + compose = patched; + secretsUsed = true; + } else { + warnings.push(`could not enable Docker secrets automatically (${reason}); falling back to plain .env values`); + } + } + + // Collapse blank-line runs left behind by removeDbService/insertNsfwService. + compose = compose.replace(/\n{3,}/g, '\n\n'); + + const appKey = secrets.appKey; + const dbPassword = a.generatePasswords ? secrets.dbPassword : a.dbPassword; + const dbRootPassword = a.generatePasswords ? secrets.dbRootPassword : a.dbRootPassword; + const aiVisionApiKey = a.enableAiVision && !a.customAiVisionKey ? secrets.aiVisionApiKey : a.aiVisionApiKey; + const nsfwApiKey = a.enableNsfw && !a.customNsfwKey ? secrets.nsfwApiKey : a.nsfwApiKey; + + const envSets: KV[] = [ + { key: 'APP_NAME', value: a.appName }, + { key: 'APP_URL', value: a.appUrl }, + { key: 'APP_FORCE_HTTPS', value: boolStr(a.appForceHttps) }, + { key: 'TIMEZONE', value: a.timezone }, + { key: 'DB_CONNECTION', value: DB_CONNECTION_VALUE[a.dbEngine] }, + { key: 'QUEUE_CONNECTION', value: a.useWorker ? 'database' : 'sync' }, + // Traefik sits in front of Lychee on the same Docker network, so its + // requests need to be trusted for X-Forwarded-* headers to be honored. + { key: 'TRUSTED_PROXIES', value: a.enableTraefik ? '*' : 'null' }, + ]; + if (a.dbEngine !== 'sqlite') { + envSets.push({ key: 'DB_DATABASE', value: a.dbDatabase }, { key: 'DB_USERNAME', value: a.dbUsername }); + } + for (const providerId of a.activeOAuthProviders) { + const provider = OAUTH_PROVIDERS.find((p) => p.id === providerId); + if (!provider) continue; + for (const field of provider.fields) { + envSets.push({ key: field.envKey, value: a.oauthFieldValues[`${providerId}:${field.key}`] ?? '' }); + } + } + + let secretFiles: KV[] = []; + if (secretsUsed) { + secretFiles = [ + { key: 'app_key', value: appKey }, + { key: 'db_password', value: dbPassword }, + { key: 'db_master_password', value: dbRootPassword }, + ]; + } else { + envSets.push({ key: 'APP_KEY', value: appKey }); + if (a.dbEngine !== 'sqlite') { + envSets.push({ key: 'DB_PASSWORD', value: dbPassword }); + } + } + + const overrides: KV[] = [{ key: 'APP_PORT', value: a.appPort }]; + // docker-compose.yaml already falls back to 1000 for both (`${PUID:-1000}`), + // so only emit them when the user actually changed the default. + if (a.puid !== '1000') overrides.push({ key: 'PUID', value: a.puid }); + if (a.pgid !== '1000') overrides.push({ key: 'PGID', value: a.pgid }); + if (needsDb && !secretsUsed) { + overrides.push({ key: 'DB_ROOT_PASSWORD', value: dbRootPassword }); + } + if (!needsDb && a.dbEngine !== 'sqlite') { + overrides.push( + { key: 'DB_HOST', value: a.dbHost }, + { key: 'DB_PORT', value: a.dbPort || DB_DEFAULT_PORT[a.dbEngine] } + ); + } + const aiVisionEnabled = a.enableAiVision || a.enableNsfw; + overrides.push({ key: 'AI_VISION_ENABLED', value: boolStr(aiVisionEnabled) }); + if (a.enableAiVision) { + overrides.push({ key: 'AI_VISION_FACE_API_KEY', value: aiVisionApiKey }); + } + if (a.enableNsfw) { + overrides.push( + { key: 'AI_VISION_NSFW_URL', value: 'http://lychee_nsfw_classification:8000' }, + { key: 'AI_VISION_NSFW_API_KEY', value: nsfwApiKey } + ); + } + + if (a.useWorker) { + overrides.push({ key: 'WORKER_REPLICAS', value: a.workerCount }); + } + if (a.enableTraefik && traefikAdded) { + overrides.push({ key: 'TRAEFIK_NETWORK', value: a.traefikNetwork }); + } + + let env = ''; + if (a.useEnvFile) { + env = buildEnv(envExample, envSets, overrides); + } else { + const { compose: patched, removed } = removeEnvFileReferences(compose); + compose = patched; + if (!removed) { + warnings.push('could not remove the env_file reference automatically; please remove it from docker-compose.yaml by hand'); + } + const values: Record = {}; + for (const kv of [...envSets, ...overrides]) values[kv.key] = kv.value; + compose = inlineEnvVars(compose, values); + compose = compose.replace(/\n{3,}/g, '\n\n'); + } + + return { env, compose, secretFiles, warnings, secretsUsed, envFileUsed: a.useEnvFile, appUrl: a.appUrl }; +} diff --git a/src/utils/wizard/nsfwService.ts b/src/utils/wizard/nsfwService.ts new file mode 100644 index 00000000..4e9a38b9 --- /dev/null +++ b/src/utils/wizard/nsfwService.ts @@ -0,0 +1,99 @@ +// Adds the lychee_nsfw_classification service to docker-compose.yaml. +// Unlike everything in dockerSecrets.ts, this isn't a patch to upstream +// content — Lychee's own compose file doesn't ship this service (see +// https://github.com/LycheeOrg/Lychee-NSFW-Classification), so this module +// synthesizes a block mirroring the shape of the neighbouring +// lychee_facial_recognition service, using the env vars documented in that +// repo's README/.env.example. + +const NSFW_SERVICE_LINES = [ + ' lychee_nsfw_classification:', + ' expose:', + ' - "${APP_PORT_AI_NSFW:-8002}"', + ' ports:', + ' - "${APP_PORT_AI_NSFW:-8002}:8000"', + ' image: ghcr.io/lycheeorg/lychee-nsfw-classification:latest', + ' restart: unless-stopped', + ' security_opt:', + ' - no-new-privileges:true', + ' cap_drop:', + ' - ALL', + ' environment:', + ' # Lychee instance base URL (no trailing slash)', + ' VISION_NSFW_LYCHEE_API_URL: "http://lychee_api:8000"', + " # Shared API key — must match AI_VISION_NSFW_API_KEY in Lychee's .env", + ' VISION_NSFW_API_KEY: "${AI_VISION_NSFW_API_KEY:-changeme}"', + ' # Set to false for development environments with self-signed certificates', + ' VISION_NSFW_VERIFY_SSL: "${AI_VISION_NSFW_VERIFY_SSL:-true}"', + ' # Skip the Lychee connectivity check at startup (useful for local dev)', + ' VISION_NSFW_SKIP_LYCHEE_CHECK: "${VISION_NSFW_SKIP_LYCHEE_CHECK:-false}"', + '', + ' # Named preset: strict, moderation, nude_female, permissive, social_media', + ' # https://github.com/LycheeOrg/Lychee-NSFW-Classification#quick-start--choose-a-preset', + ' VISION_NSFW_PRESET: "${AI_VISION_NSFW_PRESET:-moderation}"', + '', + ' VISION_NSFW_LOG_LEVEL: "info"', + ' VISION_NSFW_QUEUE_BACKEND: "${VISION_NSFW_QUEUE_BACKEND:-database}"', + ' # Maximum pending jobs; requests beyond this are rejected with 429, 0 = unlimited', + ' VISION_NSFW_QUEUE_MAX_SIZE: "${VISION_NSFW_QUEUE_MAX_SIZE:-0}"', + '', + ' # Shared Docker-volume mount point for photo files', + ' VISION_NSFW_PHOTOS_PATH: "/data/photos"', + ' # SQLite queue storage directory (used when queue backend is "database")', + ' VISION_NSFW_STORAGE_PATH: "/data/queue"', + '', + ' # Number of threads for CPU-bound inference', + ' VISION_NSFW_THREAD_POOL_SIZE: 1', + ' # Number of Uvicorn worker processes', + ' VISION_NSFW_WORKERS: "${AI_VISION_NSFW_WORKERS:-1}"', + '', + ' # Check the following for more env variables', + ' # https://github.com/LycheeOrg/Lychee-NSFW-Classification/blob/master/.env.example', + ' volumes:', + ' - ./lychee/uploads:/data/photos:ro', + ' - nsfw_classification_queue:/data/queue', + ' networks:', + ' - lychee', + ' depends_on:', + ' lychee_api:', + ' condition: service_healthy', + ' healthcheck:', + ' test: [ "CMD", "curl", "-f", "http://localhost:8000/api/nsfw/health" ]', + ' interval: 30s', + ' timeout: 10s', + ' retries: 3', + ' start_period: 60s', +]; + +const NSFW_VOLUME_LINES = [ + ' nsfw_classification_queue:', + ' name: lychee_nsfw_classification_queue', + ' driver: local', +]; + +export interface InsertNsfwServiceResult { + compose: string; + inserted: boolean; +} + +// insertNsfwService adds the NSFW classification service as the last entry +// under `services:` (right before the top-level `networks:` key) and its +// queue-storage volume under `volumes:`. Best-effort: if either anchor line +// isn't found (upstream restructured), it leaves the compose untouched for +// that part. +export function insertNsfwService(compose: string): InsertNsfwServiceResult { + let lines = compose.split('\n'); + + const networksIdx = lines.findIndex((l) => /^networks:\s*$/.test(l)); + if (networksIdx === -1) { + return { compose, inserted: false }; + } + lines = [...lines.slice(0, networksIdx), ...NSFW_SERVICE_LINES, '', ...lines.slice(networksIdx)]; + + const volumesIdx = lines.findIndex((l) => /^volumes:\s*$/.test(l)); + if (volumesIdx !== -1) { + lines = [...lines.slice(0, volumesIdx + 1), ...NSFW_VOLUME_LINES, ...lines.slice(volumesIdx + 1)]; + } + + return { compose: lines.join('\n'), inserted: true }; +} diff --git a/src/utils/wizard/oauthProviders.ts b/src/utils/wizard/oauthProviders.ts new file mode 100644 index 00000000..1cb98084 --- /dev/null +++ b/src/utils/wizard/oauthProviders.ts @@ -0,0 +1,150 @@ +// OAuth login providers Lychee supports, sourced from its own .env.example +// ("Oauth token data" section). Each provider's `*_REDIRECT_URI` var is +// deliberately not exposed here — the .env.example itself says to leave it +// at the default "unless you know exactly what you do." +export interface OAuthFieldDef { + // Unique within the provider; combined with the provider id to form the + // generated form field's name (oauth__). + key: string; + envKey: string; + label: string; + placeholder?: string; + required: boolean; +} + +export interface OAuthProviderDef { + id: string; + label: string; + description?: string; + fields: OAuthFieldDef[]; +} + +export const OAUTH_PROVIDERS: OAuthProviderDef[] = [ + { + id: 'amazon', + label: 'Amazon', + fields: [ + { key: 'clientId', envKey: 'AMAZON_SIGNIN_CLIENT_ID', label: 'Client ID', required: true }, + { key: 'secret', envKey: 'AMAZON_SIGNIN_SECRET', label: 'Secret', required: true }, + ], + }, + { + id: 'apple', + label: 'Apple', + description: + "The client secret is a JWT with a maximum 6-month lifetime — you'll need to regenerate and update it periodically.", + fields: [ + { key: 'clientId', envKey: 'APPLE_CLIENT_ID', label: 'Client ID', required: true }, + { key: 'clientSecret', envKey: 'APPLE_CLIENT_SECRET', label: 'Client secret', required: true }, + ], + }, + { + id: 'facebook', + label: 'Facebook', + fields: [ + { key: 'clientId', envKey: 'FACEBOOK_CLIENT_ID', label: 'Client ID', required: true }, + { key: 'clientSecret', envKey: 'FACEBOOK_CLIENT_SECRET', label: 'Client secret', required: true }, + ], + }, + { + id: 'github', + label: 'GitHub', + fields: [ + { key: 'clientId', envKey: 'GITHUB_CLIENT_ID', label: 'Client ID', required: true }, + { key: 'clientSecret', envKey: 'GITHUB_CLIENT_SECRET', label: 'Client secret', required: true }, + ], + }, + { + id: 'google', + label: 'Google', + fields: [ + { key: 'clientId', envKey: 'GOOGLE_CLIENT_ID', label: 'Client ID', required: true }, + { key: 'clientSecret', envKey: 'GOOGLE_CLIENT_SECRET', label: 'Client secret', required: true }, + ], + }, + { + id: 'mastodon', + label: 'Mastodon', + fields: [ + { + key: 'domain', + envKey: 'MASTODON_DOMAIN', + label: 'Instance domain', + placeholder: 'https://mastodon.social', + required: true, + }, + { key: 'id', envKey: 'MASTODON_ID', label: 'Client ID', required: true }, + { key: 'secret', envKey: 'MASTODON_SECRET', label: 'Client secret', required: true }, + ], + }, + { + id: 'microsoft', + label: 'Microsoft', + fields: [ + { key: 'clientId', envKey: 'MICROSOFT_CLIENT_ID', label: 'Client ID', required: true }, + { key: 'clientSecret', envKey: 'MICROSOFT_CLIENT_SECRET', label: 'Client secret', required: true }, + { key: 'tenantId', envKey: 'MICROSOFT_TENANT_ID', label: 'Tenant ID', required: true }, + ], + }, + { + id: 'nextcloud', + label: 'Nextcloud', + fields: [ + { key: 'clientId', envKey: 'NEXTCLOUD_CLIENT_ID', label: 'Client ID', required: true }, + { key: 'clientSecret', envKey: 'NEXTCLOUD_CLIENT_SECRET', label: 'Client secret', required: true }, + { + key: 'baseUri', + envKey: 'NEXTCLOUD_BASE_URI', + label: 'Nextcloud URL', + placeholder: 'https://cloud.example.com', + required: true, + }, + ], + }, + { + id: 'keycloak', + label: 'Keycloak', + fields: [ + { key: 'clientId', envKey: 'KEYCLOAK_CLIENT_ID', label: 'Client ID', required: true }, + { key: 'clientSecret', envKey: 'KEYCLOAK_CLIENT_SECRET', label: 'Client secret', required: true }, + { + key: 'baseUrl', + envKey: 'KEYCLOAK_BASE_URL', + label: 'Base URL', + placeholder: 'https://keycloak.example.com', + required: true, + }, + { key: 'realm', envKey: 'KEYCLOAK_REALM', label: 'Realm', required: true }, + ], + }, + { + id: 'authentik', + label: 'Authentik', + fields: [ + { + key: 'baseUrl', + envKey: 'AUTHENTIK_BASE_URL', + label: 'Base URL', + placeholder: 'https://authentik.example.com', + required: true, + }, + { key: 'clientId', envKey: 'AUTHENTIK_CLIENT_ID', label: 'Client ID', required: true }, + { key: 'clientSecret', envKey: 'AUTHENTIK_CLIENT_SECRET', label: 'Client secret', required: true }, + ], + }, + { + id: 'authelia', + label: 'Authelia', + fields: [ + { + key: 'baseUrl', + envKey: 'AUTHELIA_BASE_URL', + label: 'Base URL', + placeholder: 'https://authelia.example.com', + required: true, + }, + { key: 'clientId', envKey: 'AUTHELIA_CLIENT_ID', label: 'Client ID', required: true }, + { key: 'clientSecret', envKey: 'AUTHELIA_CLIENT_SECRET', label: 'Client secret', required: true }, + ], + }, +]; diff --git a/src/utils/wizard/phpMyAdminCompose.ts b/src/utils/wizard/phpMyAdminCompose.ts new file mode 100644 index 00000000..c084183f --- /dev/null +++ b/src/utils/wizard/phpMyAdminCompose.ts @@ -0,0 +1,20 @@ +// Removes Lychee's bundled phpMyAdmin service (`phpmyadmin`) — used when the +// wizard answers say not to run one, or when there's no bundled database for +// it to manage. Upstream normally toggles it on via Compose profiles +// (COMPOSE_PROFILES=phpmyadmin in .env), but that only works with a .env +// file present — removing the service block outright when unwanted makes it +// behave like every other optional service (NSFW, worker, Traefik) +// regardless of that setting. + +import { removeIndentedBlock } from './composeEdit'; + +export interface RemovePhpMyAdminServiceResult { + compose: string; + removed: boolean; +} + +export function removePhpMyAdminService(compose: string): RemovePhpMyAdminServiceResult { + const lines = compose.split('\n'); + const patched = removeIndentedBlock(lines, /^ {2}phpmyadmin:\s*$/); + return { compose: patched.join('\n'), removed: patched.length !== lines.length }; +} diff --git a/src/utils/wizard/secrets.ts b/src/utils/wizard/secrets.ts new file mode 100644 index 00000000..ba118d13 --- /dev/null +++ b/src/utils/wizard/secrets.ts @@ -0,0 +1,31 @@ +// Mirrors github.com/LycheeOrg/Wizard's internal/generator/secrets.go, using +// the browser's Web Crypto API in place of Go's crypto/rand. + +function randomBytes(numBytes: number): Uint8Array { + const buf = new Uint8Array(numBytes); + crypto.getRandomValues(buf); + return buf; +} + +function toBase64(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +// Matches Go's base64.RawURLEncoding: URL-safe alphabet, no padding. +function toBase64Url(bytes: Uint8Array): string { + return toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +// generateAppKey returns a Laravel-format application key: "base64:" followed +// by the base64 encoding of 32 random bytes. +export function generateAppKey(): string { + return 'base64:' + toBase64(randomBytes(32)); +} + +// generateSecret returns a random URL-safe secret of the given byte length, +// suitable for passwords and API keys. +export function generateSecret(numBytes: number): string { + return toBase64Url(randomBytes(numBytes)); +} diff --git a/src/utils/wizard/templates.ts b/src/utils/wizard/templates.ts new file mode 100644 index 00000000..92ba7247 --- /dev/null +++ b/src/utils/wizard/templates.ts @@ -0,0 +1,33 @@ +// Mirrors github.com/LycheeOrg/Wizard's default (non---local) behaviour of +// fetching the latest templates straight from LycheeOrg/Lychee@master, so +// the generated setup matches what upstream actually ships. Falls back to a +// bundled snapshot (mirroring the CLI's --local flag) if the fetch fails, +// e.g. offline or GitHub is unreachable. + +const ENV_EXAMPLE_URL = 'https://raw.githubusercontent.com/LycheeOrg/Lychee/master/.env.example'; +const COMPOSE_URL = 'https://raw.githubusercontent.com/LycheeOrg/Lychee/master/docker-compose.yaml'; + +export interface Templates { + envExample: string; + compose: string; + fromLive: boolean; +} + +export async function loadTemplates(fallbackEnvExample: string, fallbackCompose: string): Promise { + try { + const [envRes, composeRes] = await Promise.all([ + fetch(ENV_EXAMPLE_URL, { cache: 'no-store' }), + fetch(COMPOSE_URL, { cache: 'no-store' }), + ]); + if (!envRes.ok || !composeRes.ok) { + throw new Error('non-200 response fetching upstream templates'); + } + const [envExample, compose] = await Promise.all([envRes.text(), composeRes.text()]); + if (!envExample.trim() || !compose.trim()) { + throw new Error('empty response fetching upstream templates'); + } + return { envExample, compose, fromLive: true }; + } catch { + return { envExample: fallbackEnvExample, compose: fallbackCompose, fromLive: false }; + } +} diff --git a/src/utils/wizard/traefikCompose.ts b/src/utils/wizard/traefikCompose.ts new file mode 100644 index 00000000..730f37a4 --- /dev/null +++ b/src/utils/wizard/traefikCompose.ts @@ -0,0 +1,72 @@ +// Adds Traefik reverse-proxy integration (routing labels + the external +// network Traefik itself runs on) to the lychee_api service. Unlike +// dockerSecrets.ts, this isn't a patch to pre-existing upstream text — Lychee's +// compose file doesn't ship Traefik wiring, so this synthesizes a block, +// mirroring the approach in nsfwService.ts. + +export interface TraefikOptions { + // Host() rule value — the hostname the router matches on. Derived by the + // caller from the wizard's Application URL answer. + hostname: string; + entrypoint: string; + // Empty string skips the tls.certresolver label entirely (e.g. Traefik + // configured with a default resolver, or TLS terminated elsewhere). + certResolver: string; +} + +// Static router/service name: safe to hard-code since the wizard only ever +// configures a single Lychee instance per compose file, and it sidesteps +// having to slugify an arbitrary, user-editable app name into something +// Traefik's label syntax accepts. +const ROUTER = 'lychee'; + +function buildLabelLines(o: TraefikOptions): string[] { + const lines = [ + ' labels:', + ' - "traefik.enable=true"', + ` - "traefik.http.routers.${ROUTER}.rule=Host(\`${o.hostname}\`)"`, + ` - "traefik.http.routers.${ROUTER}.entrypoints=${o.entrypoint}"`, + ]; + if (o.certResolver.trim() !== '') { + lines.push(` - "traefik.http.routers.${ROUTER}.tls.certresolver=${o.certResolver}"`); + } + lines.push(` - "traefik.http.services.${ROUTER}.loadbalancer.server.port=8000"`); + return lines; +} + +// lychee_api inherits `networks: [lychee]` from the x-base-lychee-setup +// merge anchor; a service-level `networks:` key here overrides that merge +// rather than extending it, so `lychee` has to be re-listed alongside the +// Traefik network. +const API_NETWORKS_LINES = [' networks:', ' - lychee', ' - traefik']; + +// The reference key (`traefik`) is fixed since compose doesn't interpolate +// mapping keys — the actual underlying Docker network name is configurable +// via TRAEFIK_NETWORK in .env instead. +const TOP_LEVEL_NETWORK_LINES = [' traefik:', ' name: "${TRAEFIK_NETWORK:-traefik}"', ' external: true']; + +export interface AddTraefikResult { + compose: string; + added: boolean; +} + +export function addTraefikLabels(compose: string, o: TraefikOptions): AddTraefikResult { + let lines = compose.split('\n'); + + const portsAnchor = ' - "${APP_PORT:-8000}:8000"'; + const portsIdx = lines.indexOf(portsAnchor); + if (portsIdx === -1) return { compose, added: false }; + + lines = [ + ...lines.slice(0, portsIdx + 1), + ...buildLabelLines(o), + ...API_NETWORKS_LINES, + ...lines.slice(portsIdx + 1), + ]; + + const networksIdx = lines.findIndex((l) => /^networks:\s*$/.test(l)); + if (networksIdx === -1) return { compose: lines.join('\n'), added: false }; + lines = [...lines.slice(0, networksIdx + 1), ...TOP_LEVEL_NETWORK_LINES, ...lines.slice(networksIdx + 1)]; + + return { compose: lines.join('\n'), added: true }; +} diff --git a/src/utils/wizard/validate.ts b/src/utils/wizard/validate.ts new file mode 100644 index 00000000..32f9a5e0 --- /dev/null +++ b/src/utils/wizard/validate.ts @@ -0,0 +1,34 @@ +// Mirrors the field validators in github.com/LycheeOrg/Wizard's +// internal/wizard/forms.go. Returns an error message, or null if valid. + +export function validatePort(s: string): string | null { + const n = Number(s); + if (!Number.isInteger(n) || n <= 0 || n > 65535) { + return 'Must be a valid port number (1-65535).'; + } + return null; +} + +// validateOptionalPort is validatePort, except a blank value is valid — used +// for the external-database port field, which falls back to the engine's +// default port when left empty. +export function validateOptionalPort(s: string): string | null { + if (s.trim() === '') return null; + return validatePort(s); +} + +export function validateUint(s: string): string | null { + const n = Number(s); + if (!Number.isInteger(n) || n < 0) { + return 'Must be a non-negative integer.'; + } + return null; +} + +export function validatePositiveInt(s: string): string | null { + const n = Number(s); + if (!Number.isInteger(n) || n < 1) { + return 'Must be a positive integer (1 or more).'; + } + return null; +} diff --git a/src/utils/wizard/workerCompose.ts b/src/utils/wizard/workerCompose.ts new file mode 100644 index 00000000..ca9553d9 --- /dev/null +++ b/src/utils/wizard/workerCompose.ts @@ -0,0 +1,17 @@ +// Removes Lychee's bundled queue worker service (`lychee_worker`) — used +// when the wizard answers say not to run one. QUEUE_CONNECTION falls back +// to `sync` in that case (see generator.ts), so a worker container would +// just sit idle with nothing to consume. + +import { removeIndentedBlock } from './composeEdit'; + +export interface RemoveWorkerServiceResult { + compose: string; + removed: boolean; +} + +export function removeWorkerService(compose: string): RemoveWorkerServiceResult { + const lines = compose.split('\n'); + const patched = removeIndentedBlock(lines, /^ {2}lychee_worker:\s*$/); + return { compose: patched.join('\n'), removed: patched.length !== lines.length }; +} diff --git a/tailwind.config.cjs b/tailwind.config.cjs deleted file mode 100644 index 32b31393..00000000 --- a/tailwind.config.cjs +++ /dev/null @@ -1,24 +0,0 @@ -import defaultTheme from 'tailwindcss/defaultTheme'; -import typographyPlugin from '@tailwindcss/typography'; - -module.exports = { - content: ['./src/**/*.{astro,html,js,jsx,json,md,mdx,svelte,ts,tsx,vue}'], - theme: { - extend: { - colors: { - primary: 'var(--aw-color-primary)', - secondary: 'var(--aw-color-secondary)', - accent: 'var(--aw-color-accent)', - default: 'var(--aw-color-text-default)', - muted: 'var(--aw-color-text-muted)', - }, - fontFamily: { - sans: ['var(--aw-font-sans, ui-sans-serif)', ...defaultTheme.fontFamily.sans], - serif: ['var(--aw-font-serif, ui-serif)', ...defaultTheme.fontFamily.serif], - heading: ['var(--aw-font-heading, ui-sans-serif)', ...defaultTheme.fontFamily.sans], - }, - }, - }, - plugins: [typographyPlugin], - darkMode: 'class', -};