X bills a post containing a URL at a much higher rate than a plain post, and its algorithm demotes link posts. The X version of a post now rewrites every URL non-clickable (https://example.com/post becomes example(.)com/post): scheme and www. dropped, every dot of the host replaced with (.). Leaving a single dot intact would still leave a resolvable domain for X to detect, so all of them are broken. A scheme or www. proves a token is a URL on its own; a bare host only counts when its last label is a delegated TLD, which is the one thing telling acme.com apart from Node.js. That check runs against App\Support\LinkTlds, generated from the whole IANA root zone in every form a TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to -- because whatever X links is what X bills, so a hand-picked subset would leave us paying for its gaps. If the regex engine bails out on pathological input the original content is returned instead of crashing the publisher. The transform lives in the Platform::X arm of ContentSanitizer, so it reaches publishing and the app/API/MCP previews from one place and cannot touch any other network. Off by default; opt in with X_DEFUSE_LINKS. The editor counts characters and renders its preview client-side and cannot ask the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP stays the source of truth: a parity test fails if the two TLD sets drift, and a browser test drives the real editor so the mirror is covered rather than assumed. Without it the composer promised text the network never receives. Character limits now measure the text a reader will see: sanitized, then with markup resolved away. Measuring the raw draft blocked saving posts that publish fine and let through posts the network rejects, and counted the editor's HTML toward the limit. Measuring the sanitized form alone would have counted Telegram's escaped entities, rejecting messages Telegram accepts. Empty content is handled once inside the sanitizer instead of by a guard repeated at every call site.
302 lines
9.9 KiB
Text
302 lines
9.9 KiB
Text
APP_NAME="TryPost"
|
||
APP_ENV=local
|
||
APP_KEY=
|
||
APP_DEBUG=true
|
||
APP_URL=http://localhost
|
||
|
||
# Public base URL inbound webhooks (e.g. Telegram) are registered on.
|
||
# Defaults to APP_URL; set a tunnel URL (e.g. ngrok) for local development.
|
||
WEBHOOK_URL=
|
||
|
||
# Self-hosted mode (skips payment requirements)
|
||
SELF_HOSTED=true
|
||
|
||
# Allow more than one connected account per social network in a workspace.
|
||
# Independent of SELF_HOSTED (Cloud default is false). Self-hosted typically wants true.
|
||
ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=true
|
||
|
||
# Rewrite links in the X version of a post as non-clickable (example(.)com), so
|
||
# X does not bill them at the link-post rate. Self-hosted installs publish through
|
||
# their own X app and pay their own bill, so set true only if you want it.
|
||
X_DEFUSE_LINKS=false
|
||
META_PAGE_WALK_SECONDS=20
|
||
|
||
# Passport OAuth keys (API tokens / MCP). Prefer env vars over key files so
|
||
# every node behind a load balancer shares the same key pair. Use literal \n
|
||
# for newlines in the PEM. When unset, Passport falls back to storage/oauth-*.key
|
||
# (generate with: php artisan passport:keys).
|
||
# PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||
# PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||
|
||
TELESCOPE_ENABLED=false
|
||
|
||
APP_LOCALE=en
|
||
APP_FALLBACK_LOCALE=en
|
||
APP_FAKER_LOCALE=en_US
|
||
|
||
APP_MAINTENANCE_DRIVER=file
|
||
|
||
BCRYPT_ROUNDS=12
|
||
|
||
LOG_CHANNEL=stack
|
||
LOG_STACK=single
|
||
LOG_DEPRECATIONS_CHANNEL=null
|
||
LOG_LEVEL=debug
|
||
|
||
# Database (PostgreSQL or MySQL)
|
||
# PostgreSQL: DB_CONNECTION=pgsql, DB_PORT=5432, DB_USERNAME=postgres
|
||
# MySQL: DB_CONNECTION=mysql, DB_PORT=3306, DB_USERNAME=root
|
||
DB_CONNECTION=pgsql
|
||
DB_HOST=127.0.0.1
|
||
DB_PORT=5432
|
||
DB_DATABASE=trypost
|
||
DB_USERNAME=postgres
|
||
DB_PASSWORD=
|
||
|
||
# Session
|
||
SESSION_DRIVER=database
|
||
SESSION_LIFETIME=1440
|
||
SESSION_ENCRYPT=false
|
||
SESSION_PATH=/
|
||
SESSION_DOMAIN=null
|
||
|
||
# Broadcasting, Queue, Cache
|
||
BROADCAST_CONNECTION=reverb
|
||
QUEUE_CONNECTION=redis
|
||
CACHE_STORE=redis
|
||
|
||
# File Storage
|
||
# Options: local, s3, r2 (or any S3-compatible: MinIO, DigitalOcean Spaces, etc.)
|
||
FILESYSTEM_DISK=local
|
||
|
||
# Redis
|
||
REDIS_HOST=127.0.0.1
|
||
REDIS_PASSWORD=null
|
||
REDIS_PORT=6379
|
||
|
||
# Mail
|
||
MAIL_MAILER=smtp
|
||
MAIL_HOST=127.0.0.1
|
||
MAIL_PORT=2525
|
||
MAIL_USERNAME=null
|
||
MAIL_PASSWORD=null
|
||
MAIL_ENCRYPTION=null
|
||
MAIL_FROM_ADDRESS="hello@example.com"
|
||
MAIL_FROM_NAME="${APP_NAME}"
|
||
|
||
# Reverb (WebSockets)
|
||
REVERB_APP_ID=1001
|
||
REVERB_APP_KEY=your-reverb-key
|
||
REVERB_APP_SECRET=your-reverb-secret
|
||
REVERB_HOST="localhost"
|
||
REVERB_PORT=8080
|
||
REVERB_SCHEME=http
|
||
|
||
# AWS S3 (set FILESYSTEM_DISK=s3)
|
||
AWS_ACCESS_KEY_ID=
|
||
AWS_SECRET_ACCESS_KEY=
|
||
AWS_DEFAULT_REGION=us-east-1
|
||
AWS_BUCKET=
|
||
AWS_URL=
|
||
|
||
# Cloudflare R2 (set FILESYSTEM_DISK=r2)
|
||
R2_ACCESS_KEY_ID=
|
||
R2_SECRET_ACCESS_KEY=
|
||
R2_ENDPOINT=
|
||
R2_REGION=auto
|
||
R2_BUCKET=
|
||
R2_URL=
|
||
|
||
# DigitalOcean Spaces (set FILESYSTEM_DISK=spaces)
|
||
SPACES_ACCESS_KEY_ID=
|
||
SPACES_SECRET_ACCESS_KEY=
|
||
SPACES_ENDPOINT=
|
||
SPACES_REGION=
|
||
SPACES_BUCKET=
|
||
|
||
# ============================================
|
||
# Social Platform Credentials
|
||
# ============================================
|
||
# Get your API keys from each platform's developer portal
|
||
|
||
# LinkedIn (https://developer.linkedin.com)
|
||
LINKEDIN_CLIENT_ID=
|
||
LINKEDIN_CLIENT_SECRET=
|
||
LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback"
|
||
# LINKEDIN_SCOPES="openid,profile,email,w_member_social"
|
||
# LINKEDIN_PAGE_SCOPES="openid,profile,email,w_organization_social,r_organization_social,rw_organization_admin,w_member_social"
|
||
|
||
# X / Twitter (https://developer.twitter.com)
|
||
X_CLIENT_ID=
|
||
X_CLIENT_SECRET=
|
||
X_CLIENT_REDIRECT="${APP_URL}/accounts/x/callback"
|
||
|
||
# TikTok (https://developers.tiktok.com)
|
||
TIKTOK_CLIENT_ID=
|
||
TIKTOK_CLIENT_SECRET=
|
||
TIKTOK_CLIENT_REDIRECT="${APP_URL}/accounts/tiktok/callback"
|
||
|
||
# Facebook (https://developers.facebook.com)
|
||
FACEBOOK_CLIENT_ID=
|
||
FACEBOOK_CLIENT_SECRET=
|
||
FACEBOOK_CLIENT_REDIRECT="${APP_URL}/accounts/facebook/callback"
|
||
|
||
# Instagram (https://developers.facebook.com)
|
||
INSTAGRAM_CLIENT_ID=
|
||
INSTAGRAM_CLIENT_SECRET=
|
||
INSTAGRAM_CLIENT_REDIRECT="${APP_URL}/accounts/instagram/callback"
|
||
|
||
# Threads (https://developers.facebook.com)
|
||
THREADS_CLIENT_ID=
|
||
THREADS_CLIENT_SECRET=
|
||
THREADS_CLIENT_REDIRECT="${APP_URL}/accounts/threads/callback"
|
||
|
||
# Google (https://console.cloud.google.com)
|
||
# Used for YouTube social account connection AND Google login/signup
|
||
GOOGLE_AUTH_ENABLED=false
|
||
GOOGLE_CLIENT_ID=
|
||
GOOGLE_CLIENT_SECRET=
|
||
GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback"
|
||
GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback"
|
||
|
||
# GitHub (https://github.com/settings/developers)
|
||
# Used for GitHub login/signup
|
||
GITHUB_AUTH_ENABLED=false
|
||
GITHUB_CLIENT_ID=
|
||
GITHUB_CLIENT_SECRET=
|
||
GITHUB_AUTH_CALLBACK="${APP_URL}/auth/github/callback"
|
||
|
||
# Pinterest (https://developers.pinterest.com)
|
||
PINTEREST_CLIENT_ID=
|
||
PINTEREST_CLIENT_SECRET=
|
||
PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback"
|
||
|
||
# Telegram (single shared bot — create one via https://t.me/BotFather)
|
||
# After setting these, run: php artisan telegram:set-webhook
|
||
TELEGRAM_BOT_TOKEN=
|
||
TELEGRAM_BOT_USERNAME=
|
||
TELEGRAM_WEBHOOK_SECRET=
|
||
|
||
# Discord (single shared app+bot — create one via https://discord.com/developers/applications)
|
||
# OAuth2 → add the redirect below; Bot → enable "Server Members Intent" for mention search.
|
||
DISCORD_CLIENT_ID=
|
||
DISCORD_CLIENT_SECRET=
|
||
DISCORD_BOT_TOKEN=
|
||
DISCORD_CLIENT_REDIRECT="${APP_URL}/accounts/discord/callback"
|
||
|
||
# AI Services
|
||
OPENAI_API_KEY=
|
||
ANTHROPIC_API_KEY=
|
||
GEMINI_API_KEY=
|
||
OPENROUTER_API_KEY=
|
||
ELEVENLABS_API_KEY=
|
||
# Only needed if you point one of the AI_*_PROVIDER vars below at these providers.
|
||
# XAI_API_KEY=
|
||
# GROQ_API_KEY=
|
||
# MISTRAL_API_KEY=
|
||
# DEEPSEEK_API_KEY=
|
||
# Ollama runs locally and needs no key — set the URL only if it isn't on the default port.
|
||
# OLLAMA_URL=http://localhost:11434
|
||
# Any OpenAI-compatible endpoint (LM Studio, vLLM, LocalAI, ...) via AI_TEXT_PROVIDER=openai-compatible.
|
||
# It has no built-in default model, so OPENAI_COMPATIBLE_TEXT_MODEL is required for that provider.
|
||
# OPENAI_COMPATIBLE_URL=
|
||
# OPENAI_COMPATIBLE_API_KEY=
|
||
# OPENAI_COMPATIBLE_TEXT_MODEL=
|
||
|
||
# AI Provider Selection
|
||
# text: openai | anthropic | gemini | openrouter | xai | groq | mistral | deepseek | ollama | ...
|
||
# image: openai | gemini | xai | openrouter | ...
|
||
# audio: openai | elevenlabs | gemini | openrouter | ...
|
||
# OpenRouter is a first-class laravel/ai provider (AI_TEXT_PROVIDER=openrouter + OPENROUTER_API_KEY).
|
||
AI_TEXT_PROVIDER=openai
|
||
AI_IMAGE_PROVIDER=openai
|
||
AI_AUDIO_PROVIDER=elevenlabs
|
||
|
||
# AI Model Overrides (optional)
|
||
# Each provider picks a sensible default model per capability on its own -
|
||
# these are NOT shared across capabilities, so overriding one (e.g. a text
|
||
# model) never affects another (e.g. that same provider's image model).
|
||
# Uncomment only the ones you want to override; every provider in play above
|
||
# (AI_TEXT_PROVIDER / AI_IMAGE_PROVIDER / AI_AUDIO_PROVIDER) reads its own set.
|
||
# The values below are laravel/ai's current defaults, shown as format examples -
|
||
# they move with the package, so don't treat them as a contract.
|
||
# OPENAI_TEXT_MODEL=gpt-5.4
|
||
# OPENAI_IMAGE_MODEL=gpt-image-2
|
||
# OPENAI_AUDIO_MODEL=gpt-4o-mini-tts
|
||
# ANTHROPIC_TEXT_MODEL=claude-sonnet-5
|
||
# GEMINI_TEXT_MODEL=gemini-3.6-flash
|
||
# GEMINI_IMAGE_MODEL=gemini-3.1-flash-image-preview
|
||
# GEMINI_AUDIO_MODEL=gemini-2.5-flash-preview-tts
|
||
# XAI_TEXT_MODEL=grok-4.20-non-reasoning
|
||
# XAI_IMAGE_MODEL=grok-imagine-image
|
||
# OPENROUTER_TEXT_MODEL=anthropic/claude-sonnet-4.6
|
||
# OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
||
# OPENROUTER_AUDIO_MODEL=google/gemini-3.1-flash-tts-preview
|
||
# ELEVENLABS_AUDIO_MODEL=eleven_multilingual_v2
|
||
# GROQ_TEXT_MODEL=openai/gpt-oss-120b
|
||
# MISTRAL_TEXT_MODEL=mistral-medium-latest
|
||
# DEEPSEEK_TEXT_MODEL=deepseek-v4-flash
|
||
# OLLAMA_TEXT_MODEL=llama3.1:8b
|
||
# Ollama has no image/audio-capable driver in laravel/ai - text only.
|
||
|
||
# ============================================
|
||
# Stripe (Cashier — billing)
|
||
# ============================================
|
||
# Required when SELF_HOSTED=false. Get keys at https://dashboard.stripe.com/apikeys
|
||
STRIPE_KEY=
|
||
STRIPE_SECRET=
|
||
STRIPE_WEBHOOK_SECRET=
|
||
# SaaS default (recipe A): 8-day Stripe trial with card, no coupon, no promo field.
|
||
# REQUIRE_CARD_FOR_TRIAL=true forces Checkout before app access (no generic trial).
|
||
REQUIRE_CARD_FOR_TRIAL=true
|
||
# Trial length in days. Card-required Checkout uses trialDays for first-time
|
||
# subscribers when no first-month coupon is applied; re-subscribers skip trial.
|
||
# No-card mode uses this for accounts.trial_ends_at. 0 = off.
|
||
CASHIER_TRIAL_DAYS=8
|
||
# Optional Stripe Coupon ID (amount_off, duration=once). When set for a qualifying
|
||
# first-time single-workspace checkout, applies the coupon and SKIPS trialDays
|
||
# (e.g. TRIAL1USD for a $1 first month). Empty = trial mode above.
|
||
# XOR with CASHIER_ALLOW_PROMOTION_CODES only when the coupon would apply —
|
||
# Stripe forbids both on one session (ConfigureSubscriptionCheckout throws).
|
||
STRIPE_FIRST_MONTH_COUPON_ID=
|
||
# Show Stripe Checkout promotion-code field when no coupon is applied.
|
||
# Defaults to false (recipe A). Must be false when a first-month coupon applies.
|
||
CASHIER_ALLOW_PROMOTION_CODES=false
|
||
|
||
# Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder.
|
||
STRIPE_WORKSPACE_MONTHLY=
|
||
STRIPE_WORKSPACE_YEARLY=
|
||
|
||
# Laravel Nightwatch (production telemetry — disabled by default in dev)
|
||
NIGHTWATCH_ENABLED=false
|
||
NIGHTWATCH_TOKEN=
|
||
|
||
# Platform feature flags (all default to true; set to false to hide a platform)
|
||
# TIKTOK_ENABLED=true
|
||
# PINTEREST_ENABLED=true
|
||
# MASTODON_ENABLED=true
|
||
# BLUESKY_ENABLED=true
|
||
# TELEGRAM_ENABLED=true
|
||
|
||
# Media Services
|
||
UNSPLASH_ACCESS_KEY=
|
||
UNSPLASH_SECRET_KEY=
|
||
GIPHY_API_KEY=
|
||
|
||
# Google Tag Manager (optional - analytics)
|
||
GTM_ID=
|
||
|
||
# PostHog (optional - analytics; off by default, self-hosted installs can ignore)
|
||
POSTHOG_ENABLED=false
|
||
POSTHOG_API_KEY=
|
||
POSTHOG_HOST=https://us.i.posthog.com
|
||
|
||
# Vite
|
||
VITE_APP_NAME="${APP_NAME}"
|
||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||
VITE_REVERB_HOST="${REVERB_HOST}"
|
||
VITE_REVERB_PORT="${REVERB_PORT}"
|
||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||
VITE_POSTHOG_ENABLED="${POSTHOG_ENABLED}"
|
||
VITE_POSTHOG_API_KEY="${POSTHOG_API_KEY}"
|
||
VITE_POSTHOG_HOST="${POSTHOG_HOST}"
|