Merge main + finalize AI brand-colors toggle

Resolves the AiPostWizard conflict and completes the feature:
- i18n parity: brand_colors_label + brand_colors_description in all 15 locales
  (was en/es/pt-BR only, which broke LocalizationParityTest).
- Reworked the two-button toggle into a Switch with an explanatory description
  (matches the settings Switch/card pattern).
- Only shown for templates that honor the flag: added appliesBrandVisuals() to
  the AiContentTemplate contract (ImageCard=true, tweet cards=false), exposed as
  applies_brand_visuals in the create-page DTO, and gated the toggle on it — so
  it no longer appears (as a no-op) for tweet-card styles.
- Tests: TemplateContractTest covers appliesBrandVisuals for all templates.
This commit is contained in:
Paulo Castellano 2026-07-18 15:12:26 -03:00
commit 2c2ab69e68
530 changed files with 41787 additions and 902 deletions

View file

@ -191,8 +191,11 @@ STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
# Set to false to allow generic signup trial without requiring a card.
REQUIRE_CARD_FOR_TRIAL=true
# Free trial length in days (Stripe Checkout shows one day fewer).
# Free trial length in days, used only for the no-card generic trial above.
CASHIER_TRIAL_DAYS=8
# Stripe Coupon ID (amount_off, duration=once) so the first invoice at
# checkout comes out to $1 instead of the full monthly price.
STRIPE_FIRST_MONTH_COUPON_ID=
# Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder.
STRIPE_WORKSPACE_MONTHLY=

View file

@ -0,0 +1,65 @@
name: 'Setup Laravel test environment'
description: 'Set up PHP, Composer, optional Node, the app key, database, and Passport keys for a test job.'
inputs:
node:
description: 'Also set up Node.js and install npm dependencies.'
default: 'false'
db-port:
description: 'Host port mapped to the Postgres service.'
required: true
redis-port:
description: 'Host port mapped to the Redis service.'
required: true
runs:
using: 'composite'
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis
coverage: none
- name: Setup Node.js
if: inputs.node == 'true'
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Cache Composer dependencies
uses: actions/cache@v6
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Prepare environment
shell: bash
run: cp .env.ci .env
- name: Install Composer dependencies
shell: bash
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Install npm dependencies
if: inputs.node == 'true'
shell: bash
run: npm ci
- name: Generate application key
shell: bash
run: php artisan key:generate
- name: Run migrations
shell: bash
env:
DB_PORT: ${{ inputs.db-port }}
REDIS_PORT: ${{ inputs.redis-port }}
run: php artisan migrate --force
- name: Generate Passport keys
shell: bash
run: php artisan passport:keys --force

View file

@ -17,7 +17,7 @@ jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2

View file

@ -36,7 +36,7 @@ jobs:
platform: ${{ matrix.platform }}
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Docker metadata (labels)
id: meta
@ -76,7 +76,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
@ -92,7 +92,7 @@ jobs:
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View file

@ -6,18 +6,19 @@ on:
pull_request:
branches: [main, develop]
env:
DB_CONNECTION: pgsql
DB_DATABASE: trypost_test
DB_USERNAME: postgres
DB_PASSWORD: password
BROADCAST_CONNECTION: "null"
CACHE_STORE: array
QUEUE_CONNECTION: sync
SESSION_DRIVER: array
jobs:
tests:
backend:
runs-on: ubuntu-latest
env:
DB_CONNECTION: pgsql
DB_DATABASE: trypost_test
DB_USERNAME: postgres
DB_PASSWORD: password
BROADCAST_CONNECTION: "null"
CACHE_STORE: array
QUEUE_CONNECTION: sync
SESSION_DRIVER: array
services:
postgres:
@ -46,54 +47,89 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
- name: Setup test environment
uses: ./.github/actions/setup-laravel
with:
php-version: '8.4'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis
coverage: none
db-port: ${{ job.services.postgres.ports['5432'] }}
redis-port: ${{ job.services.redis.ports['6379'] }}
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Run backend tests
env:
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
run: php artisan test --compact --parallel
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2]
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: trypost_test
ports:
- 5432/tcp
options: >-
--health-cmd="pg_isready"
--health-interval=10s
--health-timeout=5s
--health-retries=3
redis:
image: redis:7
ports:
- 6379/tcp
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup test environment
uses: ./.github/actions/setup-laravel
with:
node-version: '22'
cache: 'npm'
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Prepare environment
run: cp .env.ci .env
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Install npm dependencies
run: npm ci
- name: Generate application key
run: php artisan key:generate
node: 'true'
db-port: ${{ job.services.postgres.ports['5432'] }}
redis-port: ${{ job.services.redis.ports['6379'] }}
- name: Build assets
run: npm run build
- name: Run migrations
run: php artisan migrate --force
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run browser tests
env:
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
run: php artisan test tests/Browser --compact --shard=${{ matrix.shard }}/2
- name: Generate Passport keys
run: php artisan passport:keys --force
- name: Upload Playwright artifacts
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-artifacts-${{ matrix.shard }}
path: tests/Browser/Screenshots
if-no-files-found: ignore
retention-days: 7
- name: Run tests
run: php artisan test --compact --parallel
env:
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
e2e-gate:
if: always()
needs: e2e
runs-on: ubuntu-latest
steps:
- name: Require all e2e shards to pass
run: '[ "${{ needs.e2e.result }}" = "success" ] || exit 1'

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
/.phpunit.cache
/bootstrap/ssr
/node_modules
/tests/Browser/Screenshots
/public/build
/lang/php_*.json
/public/hot

View file

@ -231,7 +231,7 @@ ## Frontend (Vue/TypeScript)
## Dialogs
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col` on mobile (primary on top, cancel at the bottom) and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
- Match sibling dialogs in the same feature area before inventing a new footer layout.
## AI agents (`app/Ai/Agents`)

View file

@ -13,7 +13,6 @@
use App\Services\Automation\FeedParser;
use App\Services\Brand\SafeHttpFetcher;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
@ -51,21 +50,19 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
return NodeRunResult::failed(__('automations.errors.fetch_rss_missing_url'));
}
// SafeHttpFetcher::get() re-validates every redirect hop against the SSRF
// guard (not just the initial URL), so a public feed that 302s to an
// internal host is never followed. It throws on a blocked hop, connection
// failure, non-2xx status, or an excessive redirect chain — all of which
// are legitimate "this feed couldn't be fetched" failures for this node.
try {
$this->safeHttp->guardAgainstSsrf($feedUrl);
} catch (RuntimeException) {
return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [
'reason' => 'url_not_allowed',
'url' => $feedUrl,
$response = $this->safeHttp->get($feedUrl);
} catch (RuntimeException $e) {
return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), [
'message' => $e->getMessage(),
]);
}
$response = Http::timeout(10)->get($feedUrl);
if (! $response->successful()) {
return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), ['status' => $response->status()]);
}
$items = $this->parser->parse($response->body());
if ($items === null) {

View file

@ -364,7 +364,9 @@ private function buildRequest(array $config, array $context): PendingRequest
$request = $request->withHeaders($headers);
}
return $request->withUserAgent(config('trypost.user_agent'));
return $request
->withUserAgent(config('trypost.user_agent'))
->withOptions($this->safeHttp->redirectGuardOptions());
}
/**

View file

@ -75,6 +75,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
try {
$response = Http::withHeaders($headers)
->withUserAgent(config('trypost.user_agent'))
->withOptions(['allow_redirects' => false])
->send($method, $url, ['json' => $payload]);
} catch (Throwable $e) {
return NodeRunResult::failed(__('automations.errors.webhook_request_failed'), [

View file

@ -5,6 +5,7 @@
namespace App\Actions\Billing;
use App\Models\Account;
use App\Support\Billing\FirstMonthCheckoutDiscount;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
@ -12,8 +13,9 @@ class StartSubscriptionCheckout
{
/**
* Create a Stripe Checkout session for the given price and return an Inertia
* redirect to it. Quantity tracks the account's workspace count; a trial is
* attached when the instance requires a card up front.
* redirect to it. Quantity tracks the account's workspace count; the first
* month's coupon is applied when the instance requires a card up front, so
* the first invoice charges $1 instead of running a $0 trial authorization.
*/
public function redirect(Account $account, string $priceId, string $cancelUrl): Response
{
@ -23,12 +25,9 @@ public function redirect(Account $account, string $priceId, string $cancelUrl):
]);
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
->quantity(max(1, $account->workspaces()->count()))
->allowPromotionCodes();
->quantity(max(1, $account->workspaces()->count()));
if ((bool) config('trypost.billing.require_card_for_trial', true)) {
$subscription->trialDays(config('cashier.trial_days'));
}
FirstMonthCheckoutDiscount::apply($subscription, $account);
$session = $subscription->checkout([
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',

View file

@ -24,7 +24,7 @@ public static function execute(User $user, array $data): Workspace
'brand_color' => data_get($data, 'brand_color'),
'background_color' => data_get($data, 'background_color'),
'text_color' => data_get($data, 'text_color'),
'content_language' => data_get($data, 'content_language'),
'content_language' => data_get($data, 'content_language', app()->getLocale()),
], static fn ($value): bool => $value !== null);
$workspace = DB::transaction(function () use ($user, $attributes): Workspace {

View file

@ -5,6 +5,7 @@
namespace App\Ai\Agents;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ContentLanguage;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
@ -20,6 +21,9 @@ public function instructions(): string
return view('prompts.brand_analyzer', [
'voice_groups' => BrandVoiceTrait::grouped(),
'single_select_groups' => BrandVoiceTrait::singleSelectGroups(),
'content_languages' => collect(ContentLanguage::values())
->map(fn (string $code) => "`{$code}`")
->implode(', '),
])->render();
}
@ -47,7 +51,7 @@ public function schema(JsonSchema $schema): array
->description('A concise 2-3 sentence brand description summarizing what the company does, who they serve, and what makes them unique. Written in the detected content language.')
->required(),
'language' => $schema->string()
->enum(['en', 'pt-BR', 'es'])
->enum(ContentLanguage::values())
->description('The primary language of the content.')
->required(),
'brand_color' => $schema->string()

View file

@ -27,6 +27,13 @@ public function previewAsset(): string;
/** Whether a social account must be selected for this template. */
public function needsAccount(): bool;
/**
* Whether this template's generated image honors the workspace brand
* palette (so the "Brand colors" choice is meaningful). Tweet-card styles
* are brand-colored by design and ignore the flag.
*/
public function appliesBrandVisuals(): bool;
/**
* Content types (by value) this template can produce. Empty = all AI formats.
*

View file

@ -44,6 +44,11 @@ public function needsAccount(): bool
return $this->style()->needsAccount();
}
public function appliesBrandVisuals(): bool
{
return true;
}
public function supportedFormats(): array
{
return [];

View file

@ -41,6 +41,11 @@ public function needsAccount(): bool
return $this->style()->needsAccount();
}
public function appliesBrandVisuals(): bool
{
return false;
}
/** @return array<int, string> */
public function supportedFormats(): array
{

View file

@ -41,6 +41,11 @@ public function needsAccount(): bool
return $this->style()->needsAccount();
}
public function appliesBrandVisuals(): bool
{
return false;
}
/** @return array<int, string> */
public function supportedFormats(): array
{

View file

@ -4,17 +4,25 @@
namespace App\Console\Commands;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Jobs\RefreshSocialToken;
use App\Models\SocialAccount;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class RefreshExpiringTokens extends Command
{
protected $signature = 'social:refresh-expiring-tokens';
protected $description = 'Proactively refresh tokens expiring in the next 2 hours (or already expired)';
protected $description = 'Proactively refresh social tokens before they expire';
/**
* Rotating refresh_token platforms only need a short lead: verify() won't
* rotate a still-valid token, so we catch them right before or after expiry.
* Extension-model platforms (Instagram/Threads) can't be refreshed once
* expired, so they get a much wider lead to survive queue backlog.
*/
public function handle(): void
{
$count = 0;
@ -22,7 +30,15 @@ public function handle(): void
SocialAccount::query()
->where('status', Status::Connected)
->whereNotNull('token_expires_at')
->where('token_expires_at', '<=', now()->addHours(2))
->where(function (Builder $query) {
$query->where(function (Builder $extension) {
$extension->whereIn('platform', Platform::accessTokenExtendingPlatformValues())
->where('token_expires_at', '<=', now()->addDay());
})->orWhere(function (Builder $rotating) {
$rotating->whereNotIn('platform', Platform::accessTokenExtendingPlatformValues())
->where('token_expires_at', '<=', now()->addMinutes(30));
});
})
->chunk(50, function ($accounts) use (&$count) {
foreach ($accounts as $account) {
RefreshSocialToken::dispatch($account);

View file

@ -6,6 +6,7 @@
use App\Enums\Media\Source;
use App\Enums\Media\Type;
use App\Enums\SocialAccount\Platform;
class MediaItem
{
@ -59,6 +60,39 @@ public function height(): ?int
return is_numeric($height) ? (int) $height : null;
}
/**
* User-provided accessibility description for this image, when set.
*/
public function altText(): ?string
{
$alt = data_get($this->meta, 'alt_text');
if (! is_string($alt)) {
return null;
}
$alt = trim($alt);
return $alt === '' ? null : $alt;
}
/**
* The alt text truncated to the given platform's cap, or null when no alt
* text is set or the platform doesn't support it. Single place that applies
* the per-platform cap so publishers don't each repeat the truncation.
*/
public function altTextFor(Platform $platform): ?string
{
$alt = $this->altText();
$max = $platform->altTextMaxLength();
if ($alt === null || $max === null) {
return null;
}
return mb_substr($alt, 0, $max);
}
/**
* @param array<string, mixed> $data
*/

View file

@ -127,6 +127,39 @@ public function maxImages(): int
};
}
/**
* Character cap the platform's API accepts for image alt text (accessibility
* description), or null when the platform has no alt-text field. X, LinkedIn,
* Instagram, Pinterest, and Discord use documented API maxes. Facebook,
* Threads, Mastodon, and Bluesky document no limit, so a defensive cap is
* used instead. Single source of truth — publishers truncate to this value,
* never a literal.
*/
public function altTextMaxLength(): ?int
{
return match ($this) {
self::Bluesky => 2000,
self::X => 1000,
self::Mastodon => 1500,
self::LinkedIn, self::LinkedInPage => 4086,
self::Facebook => 1000,
self::Instagram, self::InstagramFacebook => 1000,
self::Threads => 1000,
self::Pinterest => 500,
self::Discord => 1024,
self::TikTok, self::YouTube, self::Telegram => null,
};
}
/**
* Whether the platform's API accepts image alt text (accessibility
* description) on published media.
*/
public function supportsAltText(): bool
{
return $this->altTextMaxLength() !== null;
}
/**
* Hard cap (in characters) the platform's API will accept. Going over this
* means the post can't be published. Values are the documented API maxes:
@ -261,6 +294,60 @@ public function requiresContent(): bool
};
}
/**
* Whether this platform refreshes by extending the access_token itself
* (Instagram/Threads long-lived tokens) instead of exchanging a separate
* refresh_token. Extension-model tokens cannot be refreshed once expired,
* so they must be refreshed proactively while still valid — the opposite
* of rotating refresh_token platforms, which we avoid refreshing until
* they actually expire so we don't rotate a still-valid single-use token.
*/
public function extendsAccessTokenOnRefresh(): bool
{
return match ($this) {
self::Instagram, self::Threads => true,
default => false,
};
}
/**
* The `platform` column values of the platforms that refresh by extending
* their access token in place (Instagram and Threads — see
* extendsAccessTokenOnRefresh), for use in database whereIn/whereNotIn
* filters. Derived from extendsAccessTokenOnRefresh() so the two never drift.
*
* @return array<int, string>
*/
public static function accessTokenExtendingPlatformValues(): array
{
return array_values(array_map(
fn (self $platform): string => $platform->value,
array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()),
));
}
/**
* The token lifetime, in seconds, to assume when the provider's OAuth
* response omits expires_in. Each value is that network's own documented
* default:
*
* - X: a 2-hour access token.
* - Instagram / Threads: Meta's 60-day long-lived token.
*
* Networks that always return expires_in (LinkedIn, TikTok, YouTube,
* Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or
* whose tokens never expire (Facebook, Mastodon, Telegram, Discord) have no
* fallback here and return null.
*/
public function defaultTokenTtlSeconds(): ?int
{
return match ($this) {
self::X => 7200,
self::Instagram, self::Threads => 5184000,
default => null,
};
}
public function queue(): string
{
return 'social-'.$this->value;

View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Enums\Workspace;
/**
* The set of languages the app supports, and the single source of truth for it:
* request validation, the brand analyzer's structured-output enum, homepage
* language detection, the AI image prompt's language name, the content-language
* picker options, and the right-to-left direction of the UI all derive from it.
*
* The string value is the language code stored on the workspace and passed
* straight to the content prompts (`content_language`); the same codes also back
* the application's UI locales, so `direction()` drives the document `dir` attribute.
*/
enum ContentLanguage: string
{
case English = 'en';
case PortugueseBrazil = 'pt-BR';
case Spanish = 'es';
case French = 'fr';
case German = 'de';
case Italian = 'it';
case Dutch = 'nl';
case Polish = 'pl';
case Greek = 'el';
case Japanese = 'ja';
case Korean = 'ko';
case Chinese = 'zh';
case Russian = 'ru';
case Turkish = 'tr';
case Arabic = 'ar';
public const DEFAULT = self::English;
/**
* The language's own name, shown in the content-language picker.
*/
public function label(): string
{
return match ($this) {
self::English => 'English',
self::PortugueseBrazil => 'Português (Brasil)',
self::Spanish => 'Español',
self::French => 'Français',
self::German => 'Deutsch',
self::Italian => 'Italiano',
self::Dutch => 'Nederlands',
self::Polish => 'Polski',
self::Greek => 'Ελληνικά',
self::Japanese => '日本語',
self::Korean => '한국어',
self::Chinese => '中文',
self::Russian => 'Русский',
self::Turkish => 'Türkçe',
self::Arabic => 'العربية',
};
}
/**
* The English name of the language, injected into AI image prompts so the
* in-image text is rendered in the workspace's content language.
*/
public function englishName(): string
{
return match ($this) {
self::English => 'English',
self::PortugueseBrazil => 'Brazilian Portuguese',
self::Spanish => 'Spanish',
self::French => 'French',
self::German => 'German',
self::Italian => 'Italian',
self::Dutch => 'Dutch',
self::Polish => 'Polish',
self::Greek => 'Greek',
self::Japanese => 'Japanese',
self::Korean => 'Korean',
self::Chinese => 'Chinese',
self::Russian => 'Russian',
self::Turkish => 'Turkish',
self::Arabic => 'Arabic',
};
}
/**
* The text direction (`ltr` or `rtl`) for the document root when this is the
* active UI locale — only Arabic is written right to left.
*/
public function direction(): string
{
return $this === self::Arabic ? 'rtl' : 'ltr';
}
/**
* Resolve a raw `<html lang>` value (e.g. "pt-PT", "zh-Hans") to a supported
* language by matching its primary subtag, or null if none is supported.
*/
public static function fromHtmlLang(string $lang): ?self
{
$subtag = self::primarySubtag($lang);
if (strlen($subtag) < 2) {
return null;
}
foreach (self::cases() as $language) {
if (self::primarySubtag($language->value) === $subtag) {
return $language;
}
}
return null;
}
/**
* The lowercased primary subtag of a BCP 47 language tag ("pt-BR" => "pt").
*/
private static function primarySubtag(string $tag): string
{
return strtolower(explode('-', trim($tag), 2)[0]);
}
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(fn (self $language) => $language->value, self::cases());
}
/**
* @return array<int, array{value: string, label: string, englishName: string}>
*/
public static function options(): array
{
return array_map(
fn (self $language) => [
'value' => $language->value,
'label' => $language->label(),
'englishName' => $language->englishName(),
],
self::cases(),
);
}
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
use Illuminate\Support\Facades\Http;
use App\Services\Brand\SafeHttpFetcher;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@ -22,7 +22,7 @@ function uploadFromUrl(?string $url, string $directory = 'social-accounts'): ?st
}
try {
$response = Http::timeout(10)->get($url);
$response = app(SafeHttpFetcher::class)->guardedRequest($url)->timeout(10)->get($url);
if (! $response->successful()) {
Log::warning('uploadFromUrl: Failed to download', [

View file

@ -9,16 +9,17 @@
use App\Http\Requests\App\Asset\StoreChunkedAssetRequest;
use App\Http\Resources\App\MediaResource;
use App\Models\Media;
use App\Services\Brand\SafeHttpFetcher;
use App\Services\UnsplashService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class AssetController extends Controller
@ -115,7 +116,7 @@ public function storeChunked(StoreChunkedAssetRequest $request): JsonResponse
]);
}
public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash): MediaResource
public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash, SafeHttpFetcher $safeHttp): MediaResource
{
$workspace = $request->user()->currentWorkspace;
@ -128,7 +129,13 @@ public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService
$unsplash->trackDownload($downloadLocation);
}
$response = Http::timeout(30)->get(data_get($validated, 'url'));
$url = (string) data_get($validated, 'url');
try {
$response = $safeHttp->guardedRequest($url)->timeout(30)->get($url);
} catch (RuntimeException) {
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL');
}
if ($response->failed()) {
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL');

View file

@ -15,6 +15,7 @@
use App\Actions\Automation\Automation\UpdateAutomation;
use App\Actions\Automation\Run\RetryRunFromNode;
use App\Actions\Automation\Run\TestAutomation;
use App\Enums\Automation\Status;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Automations\ActivateAutomationRequest;
use App\Http\Requests\App\Automations\InspectFeedRequest;
@ -77,7 +78,11 @@ public function show(Automation $automation): RedirectResponse
{
$this->authorize('view', $automation);
return redirect()->route('app.automations.workflow', $automation->id);
// A draft opens on the builder to be set up; a live automation (active
// or paused) opens on its metrics, where the user watches it run.
$tab = $automation->status === Status::Draft ? 'workflow' : 'metrics';
return redirect()->route("app.automations.{$tab}", $automation->id);
}
public function workflow(Automation $automation, GetAutomationEditorData $editorData): Response

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Post\LinkPreviewRequest;
use App\Services\Social\LinkCard\LinkCardFetcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class LinkPreviewController extends Controller
{
public function __invoke(LinkPreviewRequest $request, LinkCardFetcher $fetcher): JsonResponse|Response
{
$card = $fetcher->fetch($request->validated('url'));
if ($card === null) {
return response()->noContent();
}
return response()->json($card->toArray());
}
}

View file

@ -150,9 +150,15 @@ public function connect(Request $request): Response|RedirectResponse
'network' => $platform->network(),
])->values();
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
return Inertia::render('onboarding/Connect', [
'platforms' => $platforms,
'accounts' => SocialAccountResource::collection($accounts)->resolve(),
'plan' => [
'name' => $plan->name,
'interval' => 'monthly',
],
]);
}

View file

@ -153,6 +153,7 @@ public function create(Request $request): Response
'preview' => $t->previewAsset(),
'needs_account' => $t->needsAccount(),
'supported_formats' => $t->supportedFormats(),
'applies_brand_visuals' => $t->appliesBrandVisuals(),
], $registry->all());
return Inertia::render('posts/Create', [

View file

@ -9,23 +9,23 @@
use App\Actions\Workspace\DeleteWorkspace;
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ContentLanguage;
use App\Enums\Workspace\ImageStyle;
use App\Http\Requests\App\Workspace\AutofillBrandRequest;
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
use App\Http\Resources\App\WorkspaceMemberResource;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Brand\LogoAttacher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Throwable;
class WorkspaceController extends Controller
{
@ -67,8 +67,26 @@ public function index(Request $request): Response
public function create(Request $request): Response|RedirectResponse
{
$user = $request->user();
if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($request->user())) {
return $redirect;
}
return Inertia::render('workspaces/Create', [
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
'availableContentLanguages' => ContentLanguage::options(),
]);
}
/**
* Block creating a paid additional workspace without an active subscription.
* Guards both the form (`create`) and the write (`store`) so a direct POST
* can't bootstrap a second billable workspace — which would also inflate the
* checkout quantity past the fixed first-month coupon.
*/
private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?RedirectResponse
{
if (! config('trypost.self_hosted')
&& $user->ownedWorkspacesCount() > 0
&& ! $user->account?->hasActiveSubscription()) {
@ -76,11 +94,7 @@ public function create(Request $request): Response|RedirectResponse
->with('message', 'Subscribe to create more workspaces.');
}
return Inertia::render('workspaces/Create', [
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
]);
return null;
}
public function autofillBrand(AutofillBrandRequest $request, AutofillBrand $autofill): JsonResponse
@ -98,20 +112,16 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
{
$user = $request->user();
if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($user)) {
return $redirect;
}
$validated = $request->validated();
$workspace = CreateWorkspace::execute($user, $validated);
if ($logoUrl = data_get($validated, 'logo_url')) {
try {
$logoAttacher->attach($workspace, $logoUrl);
} catch (Throwable $e) {
Log::warning('Logo attach failed during workspace creation', [
'workspace_id' => $workspace->id,
'logo_url' => $logoUrl,
'error' => $e->getMessage(),
]);
}
$logoAttacher->attach($workspace, $logoUrl);
}
return redirect()->route('app.accounts')
@ -163,6 +173,7 @@ public function brandSettings(Request $request): Response|RedirectResponse
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
'availableContentLanguages' => ContentLanguage::options(),
]);
}
@ -195,7 +206,7 @@ public function deleteLogo(Request $request): RedirectResponse
return back()->with('flash.success', __('settings.flash.logo_deleted'));
}
public function updateSettings(UpdateWorkspaceRequest $request): RedirectResponse
public function updateSettings(UpdateWorkspaceRequest $request, LogoAttacher $logoAttacher): RedirectResponse
{
$user = $request->user();
$workspace = $user->currentWorkspace;
@ -206,7 +217,16 @@ public function updateSettings(UpdateWorkspaceRequest $request): RedirectRespons
$this->authorize('update', $workspace);
$workspace->update($request->validated());
$validated = $request->validated();
$logoUrl = data_get($validated, 'logo_url');
unset($validated['logo_url']);
$workspace->update($validated);
if ($logoUrl) {
$logoAttacher->attach($workspace, $logoUrl);
}
return back()->with('flash.success', __('settings.flash.workspace_updated'));
}

View file

@ -69,7 +69,7 @@ public function callback(Request $request): InertiaResponse
$avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null;
// Calculate token expiration (long-lived tokens last 60 days)
$expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds
$expiresIn = $socialUser->expiresIn ?? $this->platform->defaultTokenTtlSeconds();
$tokenExpiresAt = now()->addSeconds($expiresIn);
$workspace->socialAccounts()->updateOrCreate(

View file

@ -8,6 +8,7 @@
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use App\Services\Social\TokenRedactor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -90,7 +91,7 @@ public function callback(Request $request): InertiaResponse
if ($tokenResponse->failed()) {
Log::error('Threads token exchange failed', [
'status' => $tokenResponse->status(),
'body' => $tokenResponse->body(),
'body' => TokenRedactor::redact($tokenResponse->body()),
]);
throw new \Exception('Failed to exchange token');
}
@ -106,15 +107,18 @@ public function callback(Request $request): InertiaResponse
'access_token' => $shortLivedToken,
]);
$longLivedToken = $shortLivedToken;
$expiresIn = null;
if ($longLivedResponse->successful()) {
$longLivedData = $longLivedResponse->json();
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
$expiresIn = $longLivedData['expires_in'] ?? null;
if ($longLivedResponse->failed()) {
Log::error('Threads long-lived token exchange failed', [
'status' => $longLivedResponse->status(),
'body' => TokenRedactor::redact($longLivedResponse->body()),
]);
throw new \Exception('Failed to exchange long-lived token');
}
$longLivedData = $longLivedResponse->json();
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
$expiresIn = $longLivedData['expires_in'] ?? $this->platform->defaultTokenTtlSeconds();
// Fetch user profile
$profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [
'access_token' => $longLivedToken,
@ -142,7 +146,7 @@ public function callback(Request $request): InertiaResponse
'avatar_url' => $avatarPath,
'access_token' => $longLivedToken,
'refresh_token' => null,
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
'token_expires_at' => now()->addSeconds($expiresIn),
'scopes' => $this->scopes,
'status' => Status::Connected,
'error_message' => null,

View file

@ -4,9 +4,11 @@
namespace App\Http\Middleware\App;
use App\Enums\Workspace\ContentLanguage;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
@ -19,8 +21,11 @@ public function handle(Request $request, Closure $next): Response
$available = config('languages.available');
$locale = $request->cookie('locale');
$isValid = $locale && array_key_exists($locale, $available);
$activeLocale = $isValid ? $locale : config('languages.default');
$language = ContentLanguage::tryFrom($activeLocale) ?? ContentLanguage::DEFAULT;
App::setLocale($isValid ? $locale : config('languages.default'));
App::setLocale($activeLocale);
View::share('htmlDir', $language->direction());
$response = $next($request);

View file

@ -4,6 +4,7 @@
namespace App\Http\Requests\Api\Post;
use App\Support\PostMediaRules;
use Illuminate\Foundation\Http\FormRequest;
class AttachMediaFromUrlRequest extends FormRequest
@ -20,7 +21,8 @@ public function rules(): array
{
return [
'urls' => ['required', 'array', 'min:1', 'max:10'],
'urls.*' => ['url:http,https', 'active_url'],
'urls.*.url' => ['required', 'url:http,https', 'active_url'],
'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
];
}
}

View file

@ -4,6 +4,7 @@
namespace App\Http\Requests\App\Ai;
use App\Support\AiPromptRules;
use Illuminate\Foundation\Http\FormRequest;
class GeneratePostContentRequest extends FormRequest
@ -19,7 +20,7 @@ public function authorize(): bool
public function rules(): array
{
return [
'prompt' => ['required', 'string', 'max:2000'],
'prompt' => ['required', 'string', 'max:'.AiPromptRules::PROMPT_MAX_LENGTH],
'current_content' => ['nullable', 'string', 'max:10000'],
];
}

View file

@ -6,6 +6,7 @@
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use App\Support\AiPromptRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
@ -33,7 +34,7 @@ public function rules(): array
],
'social_account_id' => ['nullable', 'uuid'],
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],
'prompt' => ['required', 'string', 'max:2000'],
'prompt' => AiPromptRules::wizardPromptRule(),
'date' => ['nullable', 'date_format:Y-m-d'],
'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)],
'apply_brand_visuals' => ['sometimes', 'boolean'],

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Post;
use Illuminate\Foundation\Http\FormRequest;
class LinkPreviewRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'url' => ['required', 'string', 'max:2048'],
];
}
}

View file

@ -6,6 +6,7 @@
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ContentLanguage;
use App\Enums\Workspace\ImageStyle;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -32,7 +33,7 @@ public function rules(): array
'text_color' => $hex,
'brand_font' => ['sometimes', 'string', Rule::in(BrandFont::values())],
'image_style' => ['sometimes', 'string', Rule::in(ImageStyle::values())],
'content_language' => ['nullable', 'string', 'in:en,pt-BR,es'],
'content_language' => ['nullable', 'string', Rule::in(ContentLanguage::values())],
'logo_url' => ['nullable', 'url', 'max:1024'],
];
}

View file

@ -6,6 +6,7 @@
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ContentLanguage;
use App\Enums\Workspace\ImageStyle;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -32,7 +33,8 @@ public function rules(): array
'text_color' => $hex,
'brand_font' => ['sometimes', 'required', 'string', Rule::in(BrandFont::values())],
'image_style' => ['sometimes', 'required', 'string', Rule::in(ImageStyle::values())],
'content_language' => ['sometimes', 'string', 'in:en,pt-BR,es'],
'content_language' => ['sometimes', 'string', Rule::in(ContentLanguage::values())],
'logo_url' => ['nullable', 'url', 'max:1024'],
];
}

View file

@ -282,6 +282,10 @@ private function replaceMediaOnPost(
throw new RuntimeException('Media item changed before regeneration completed.');
}
if (($meta = data_get($items->get($currentIndex), 'meta')) !== null) {
$newMediaItem['meta'] = $meta;
}
$items->put($currentIndex, $newMediaItem);
$fresh->update(['media' => $items->values()->all()]);

View file

@ -24,7 +24,14 @@ public function __construct(public SocialAccount $account) {}
public function handle(ConnectionVerifier $verifier): void
{
try {
$verifier->refreshToken($this->account);
if ($this->account->platform->extendsAccessTokenOnRefresh()) {
// Instagram/Threads extend the long-lived token itself and
// can't be refreshed once expired, so extend it while it's
// still valid instead of waiting for it to fail.
$verifier->refreshToken($this->account);
} else {
$verifier->verify($this->account);
}
} catch (PlatformUnavailableException $e) {
Log::warning('Token refresh skipped: platform unavailable', [
'account_id' => $this->account->id,

View file

@ -8,6 +8,7 @@
use App\Models\Media;
use App\Models\Post;
use App\Models\Workspace;
use App\Support\PostMediaRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -23,6 +24,7 @@ public function handle(Request $request): Response|ResponseFactory
$validated = $request->validate([
'post_id' => ['required', 'uuid'],
'upload_token' => ['required', 'uuid'],
'alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
]);
$workspaceId = $request->user()->current_workspace_id;
@ -48,14 +50,20 @@ public function handle(Request $request): Response|ResponseFactory
return Response::error('No enabled platform on this post accepts this media type.');
}
$post->appendMedia([[
$item = [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => $media->type,
'mime_type' => $media->mime_type,
'original_filename' => $media->original_filename,
]]);
];
if (($alt = data_get($validated, 'alt')) !== null && $media->isImage()) {
$item['meta'] = ['alt_text' => $alt];
}
$post->appendMedia([$item]);
$post->refresh()->load(['postPlatforms.socialAccount', 'labels']);
@ -69,6 +77,7 @@ public function schema(JsonSchema $schema): array
return [
'post_id' => $schema->string()->required()->description('UUID of the post to attach the uploaded media to.'),
'upload_token' => $schema->string()->required()->description('upload_token returned by RequestMediaUploadTool, after the user has POSTed the file to the upload_url.'),
'alt' => $schema->string()->description('Optional accessibility alt text for the media (applies to images).'),
];
}
}

View file

@ -7,6 +7,7 @@
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use App\Services\Post\MediaAttacher;
use App\Support\PostMediaRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -22,7 +23,8 @@ public function handle(Request $request): Response|ResponseFactory
$validated = $request->validate([
'post_id' => ['required', 'uuid'],
'urls' => ['required', 'array', 'min:1', 'max:10'],
'urls.*' => ['url:http,https', 'active_url'],
'urls.*.url' => ['required', 'url:http,https', 'active_url'],
'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
]);
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
@ -51,9 +53,12 @@ public function schema(JsonSchema $schema): array
return [
'post_id' => $schema->string()->required()->description('UUID of the post to attach media to.'),
'urls' => $schema->array()
->items($schema->string())
->items($schema->object(fn ($u) => [
'url' => $u->string()->required()->description('Public HTTP/HTTPS URL of an image, video, or PDF.'),
'alt' => $u->string()->description('Optional accessibility alt text for the image (ignored for video/PDF, which have no alt text).'),
]))
->required()
->description('Public HTTP/HTTPS URLs of images, videos, or PDFs. Max 10 URLs per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'),
->description('Media to attach. Max 10 per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'),
];
}
}

View file

@ -93,6 +93,19 @@ protected function isTokenExpiringSoon(): Attribute
);
}
/**
* Whether the token should be refreshed before use. Rotating-refresh-token
* platforms are only refreshed once actually expired, to avoid rotating a
* still-valid single-use refresh_token; extension-model platforms
* (Instagram/Threads) must be refreshed while still valid because their
* token can't be extended once expired.
*/
public function needsProactiveTokenRefresh(): bool
{
return $this->is_token_expired
|| ($this->platform->extendsAccessTokenOnRefresh() && $this->is_token_expiring_soon);
}
protected function avatarUrl(): Attribute
{
return Attribute::make(

View file

@ -4,6 +4,7 @@
namespace App\Services\Ai;
use App\Enums\Workspace\ContentLanguage;
use App\Enums\Workspace\ImageStyle;
use App\Support\HexColorName;
use Illuminate\Support\Facades\Log;
@ -89,11 +90,7 @@ public function generate(
private function languageName(string $code): string
{
return match ($code) {
'pt-BR' => 'Brazilian Portuguese',
'es' => 'Spanish',
default => 'English',
};
return (ContentLanguage::tryFrom($code) ?? ContentLanguage::DEFAULT)->englishName();
}
/**

View file

@ -4,6 +4,7 @@
namespace App\Services\Brand;
use App\Enums\Workspace\ContentLanguage;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\UriResolver;
@ -291,14 +292,7 @@ private function extractLanguage(Crawler $crawler): ?string
return null;
}
$lower = strtolower($lang);
return match (true) {
str_starts_with($lower, 'pt') => 'pt-BR',
str_starts_with($lower, 'es') => 'es',
str_starts_with($lower, 'en') => 'en',
default => null,
};
return ContentLanguage::fromHtmlLang($lang)?->value;
}
private function extractLogoUrl(Crawler $crawler, string $baseUrl): ?string

View file

@ -6,6 +6,7 @@
use App\Models\Workspace;
use Illuminate\Support\Facades\Log;
use Throwable;
class LogoAttacher
{
@ -77,6 +78,10 @@ public function attach(Workspace $workspace, string $logoUrl): bool
);
return true;
} catch (Throwable $e) {
Log::warning('Logo rejected — persistence error', ['url' => $logoUrl, 'error' => $e->getMessage()]);
return false;
} finally {
if (file_exists($tempPath)) {
@unlink($tempPath);

View file

@ -5,9 +5,11 @@
namespace App\Services\Brand;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Symfony\Component\DomCrawler\UriResolver;
/**
* HTTP fetcher with SSRF protection, timeout, redirect cap and a branded user-agent.
@ -36,13 +38,38 @@ public function get(string $url): Response
{
$this->guardAgainstSsrf($url);
try {
$response = Http::timeout(self::TIMEOUT_SECONDS)
->withUserAgent(self::USER_AGENT)
->withOptions(['allow_redirects' => ['max' => self::MAX_REDIRECTS]])
->get($url);
} catch (ConnectionException $e) {
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => $e->getMessage()]));
$currentUrl = $url;
// Redirects are followed manually (allow_redirects disabled) so that every
// hop's Location target is re-validated against the SSRF guard before it is
// ever requested. A public page could otherwise 302 to an internal host and
// Guzzle's built-in redirect following would fetch it without re-checking.
for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
try {
$response = Http::timeout(self::TIMEOUT_SECONDS)
->withUserAgent(self::USER_AGENT)
->withOptions(['allow_redirects' => false])
->get($currentUrl);
} catch (ConnectionException $e) {
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => $e->getMessage()]));
}
if (! $response->redirect() || $hop >= self::MAX_REDIRECTS) {
break;
}
$location = $response->header('Location');
if ($location === '') {
break;
}
$currentUrl = (string) UriResolver::resolve($location, $currentUrl);
$this->guardAgainstSsrf($currentUrl);
}
if ($response->redirect()) {
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => 'too many redirects']));
}
if ($response->failed()) {
@ -65,6 +92,43 @@ public function tryGet(string $url): ?Response
}
}
/**
* Guzzle allow_redirects options that re-run the SSRF guard on every hop.
* For callers that follow redirects on user-supplied URLs with methods/bodies
* that SafeHttpFetcher::get() cannot express.
*
* @return array<string, mixed>
*/
public function redirectGuardOptions(int $max = self::MAX_REDIRECTS): array
{
return [
'allow_redirects' => [
'max' => $max,
'strict' => true,
'protocols' => ['http', 'https'],
'on_redirect' => function ($request, $response, $uri): void {
$this->guardAgainstSsrf((string) $uri);
},
],
];
}
/**
* A PendingRequest with the SSRF guard applied to $url and redirect handling
* pre-configured (per-hop re-guard when following, or no redirects). Callers
* add their own timeout / sink / headers and dispatch to the SAME $url, so a
* user-supplied URL can never be fetched without the guard and redirect
* protection.
*/
public function guardedRequest(string $url, bool $followRedirects = true): PendingRequest
{
$this->guardAgainstSsrf($url);
return Http::withUserAgent(self::USER_AGENT)->withOptions(
$followRedirects ? $this->redirectGuardOptions() : ['allow_redirects' => false],
);
}
public function guardAgainstSsrf(string $url): void
{
$parts = parse_url($url);
@ -80,6 +144,13 @@ public function guardAgainstSsrf(string $url): void
throw new RuntimeException(__('workspaces.create.autofill_errors.missing_host'));
}
// Self-hosted operators can opt into fetching their own internal
// network. Only the private/reserved-IP rejection below is skipped;
// the scheme and host checks above still always apply.
if ((bool) config('trypost.security.allow_private_network')) {
return;
}
$ip = gethostbyname($host);
if ($ip === $host && filter_var($host, FILTER_VALIDATE_IP) === false) {

View file

@ -15,7 +15,7 @@ class GiphyService
public function __construct()
{
$this->apiKey = config('services.giphy.api_key', '');
$this->apiKey = (string) config('services.giphy.api_key', '');
}
/**

View file

@ -6,11 +6,29 @@
use App\Enums\SocialAccount\Platform;
use Illuminate\Support\Facades\Log;
use Intervention\Image\Direction;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\ImageManager;
use Intervention\Image\Interfaces\ImageInterface;
use RuntimeException;
class MediaOptimizer
{
private const MAX_DECODE_MEMORY_BYTES = 256 * 1024 * 1024;
private const FIT_BLUR_SIGMA = 55;
private const FIT_BLUR_GAMMA = 1.3;
private const FIT_QUALITY = 90;
private const FIT_GD_DOWNSCALE = 8;
private const FIT_GD_BLUR = 45;
private const FIT_GD_BRIGHTNESS = 12;
private ImageManager $manager;
public function __construct()
@ -26,29 +44,18 @@ public function optimizeImage(string $filePath, Platform $platform): string
{
$config = $this->getImageConfig($platform);
// Check image dimensions to prevent GD memory overflow
$imageInfo = @getimagesize($filePath);
if ($imageInfo) {
$width = $imageInfo[0];
$height = $imageInfo[1];
$channels = $imageInfo['channels'] ?? 4;
$estimatedMemory = $width * $height * $channels * 1.5; // 1.5x safety margin
if ($imageInfo !== false && $this->estimatedDecodeMemory($imageInfo) > self::MAX_DECODE_MEMORY_BYTES) {
Log::warning('MediaOptimizer: Image too large for GD processing', [
'width' => $imageInfo[0],
'height' => $imageInfo[1],
'platform' => $platform->value,
]);
// If image would use more than 256MB of RAM, skip optimization and return as-is
if ($estimatedMemory > 256 * 1024 * 1024) {
Log::warning('MediaOptimizer: Image too large for GD processing', [
'width' => $width,
'height' => $height,
'estimated_memory' => $estimatedMemory,
'platform' => $platform->value,
]);
$tempFile = tempnam(sys_get_temp_dir(), 'media_opt_');
copy($filePath, $tempFile);
// Copy original file to temp location and return
$tempFile = tempnam(sys_get_temp_dir(), 'media_opt_');
copy($filePath, $tempFile);
return $tempFile;
}
return $tempFile;
}
$image = $this->manager->decodePath($filePath);
@ -113,6 +120,8 @@ public function maxWidthForPlatform(Platform $platform): ?int
*/
public function cropToAspectRatio(string $filePath, float $ratio): string
{
$this->assertWithinMemoryBudget($filePath);
$image = $this->manager->decodePath($filePath);
$width = $image->width();
@ -148,6 +157,131 @@ public function cropToAspectRatio(string $filePath, float $ratio): string
return $tempFile;
}
/**
* Fit an image inside a width×height canvas without cropping: the image is
* scaled to fit and centered, and the empty space is filled with a blurred,
* slightly darkened copy of the image. When the image already matches the
* canvas ratio it's just scaled down (no background). Returns a temp file.
*/
public function fitToCanvas(string $filePath, int $width, int $height): string
{
$this->assertWithinMemoryBudget($filePath);
$foreground = $this->manager->decodePath($filePath);
$canvasRatio = $width / $height;
$imageRatio = $foreground->width() / $foreground->height();
$tempFile = tempnam(sys_get_temp_dir(), 'media_fit_');
try {
if (abs($imageRatio - $canvasRatio) < 0.01) {
$sized = $foreground->scaleDown($width, $height);
file_put_contents($tempFile, (string) $sized->encodeUsingMediaType('image/jpeg', quality: self::FIT_QUALITY));
return $tempFile;
}
$canvas = extension_loaded('imagick')
? $this->fitOntoBlurredBackground($filePath, $width, $height)
: $this->fitOntoBlurredBackgroundGd($filePath, $width, $height);
file_put_contents($tempFile, (string) $canvas->encodeUsingMediaType('image/jpeg', quality: self::FIT_QUALITY));
return $tempFile;
} catch (\Throwable $e) {
@unlink($tempFile);
throw $e;
}
}
/**
* Fit the image onto the canvas over a soft, lightened blurred background
* built from the image itself: the image is scaled to fill the width, heavily
* gaussian-blurred so shapes dissolve into a colour wash, lightened, and the
* top half is mirrored onto the bottom so the background reads symmetrically.
* Imagick only — blurring at full width before stretching avoids the streaks
* and posterisation the GD path is prone to.
*/
private function fitOntoBlurredBackground(string $filePath, int $width, int $height): ImageInterface
{
$manager = new ImageManager(new ImagickDriver);
$foreground = $manager->decodePath($filePath);
$sourceWidth = $foreground->width();
$sourceHeight = $foreground->height();
$scale = min($width / $sourceWidth, $height / $sourceHeight);
$foreground->resize(max(1, (int) round($sourceWidth * $scale)), max(1, (int) round($sourceHeight * $scale)));
$scaledHeight = max(1, (int) round($sourceHeight * ($width / $sourceWidth)));
$topHalf = $manager->decodePath($filePath)->resize($width, $scaledHeight);
$core = $topHalf->core()->native();
$core->blurImage(0, self::FIT_BLUR_SIGMA);
$core->gammaImage(self::FIT_BLUR_GAMMA);
$topHalf->resize($width, intdiv($height, 2));
$canvas = $manager->createImage($width, $height)->fill('000000');
$canvas->insert($topHalf, 0, 0, 'top-left');
$topHalf->flip(Direction::VERTICAL);
$canvas->insert($topHalf, 0, intdiv($height, 2), 'top-left');
$canvas->insert($foreground, 0, 0, 'center');
return $canvas;
}
/**
* GD fallback for hosts without ext-imagick: a downscale→blur→upscale
* background. Less refined than the Imagick path (no large smooth gaussian),
* but artefact-free enough for a story background.
*/
private function fitOntoBlurredBackgroundGd(string $filePath, int $width, int $height): ImageInterface
{
$foreground = $this->manager->decodePath($filePath);
$scale = min($width / $foreground->width(), $height / $foreground->height());
$foreground->resize(max(1, (int) round($foreground->width() * $scale)), max(1, (int) round($foreground->height() * $scale)));
$canvas = $this->manager->decodePath($filePath)
->cover($width, $height)
->resize(intdiv($width, self::FIT_GD_DOWNSCALE), intdiv($height, self::FIT_GD_DOWNSCALE))
->blur(self::FIT_GD_BLUR)
->resize($width, $height)
->brightness(self::FIT_GD_BRIGHTNESS);
$canvas->insert($foreground, 0, 0, 'center');
return $canvas;
}
/**
* Estimated GD memory (bytes) needed to decode an image, from its
* getimagesize() metadata.
*
* @param array{0: int, 1: int, channels?: int} $imageInfo
*/
private function estimatedDecodeMemory(array $imageInfo): float
{
return $imageInfo[0] * $imageInfo[1] * ($imageInfo['channels'] ?? 4) * 1.5;
}
/**
* Reject a source whose pixel dimensions would blow the GD memory budget,
* before it is decoded — a small-byte, huge-dimension image would otherwise
* exhaust memory with an uncatchable fatal. Transforms that can't fall back
* to the original (crop, fit) call this; `optimizeImage` skips instead.
*/
private function assertWithinMemoryBudget(string $filePath): void
{
$imageInfo = @getimagesize($filePath);
if ($imageInfo === false) {
return;
}
if ($this->estimatedDecodeMemory($imageInfo) > self::MAX_DECODE_MEMORY_BYTES) {
throw new RuntimeException("Image dimensions ({$imageInfo[0]}x{$imageInfo[1]}) exceed the safe processing budget.");
}
}
/**
* @return array{max_width: int, max_size: int, format: string, quality: int}
*/

View file

@ -8,7 +8,7 @@
use App\Models\Media;
use App\Models\Post;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use App\Services\Brand\SafeHttpFetcher;
use RuntimeException;
use Throwable;
@ -24,8 +24,10 @@
*/
class MediaAttacher
{
public function __construct(private readonly SafeHttpFetcher $safeHttp) {}
/**
* @param array<int, string> $urls
* @param array<int, array{url: string, alt?: ?string}> $urls
* @return array{attached: array<int, array<string, mixed>>, failed: array<int, string>}
*/
public function attachFromUrls(Post $post, array $urls): array
@ -33,10 +35,22 @@ public function attachFromUrls(Post $post, array $urls): array
$attached = [];
$failed = [];
foreach ($urls as $url) {
($item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url)) === null
? $failed[] = $url
: $attached[] = $item;
foreach ($urls as $entry) {
$url = (string) data_get($entry, 'url', '');
$item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url);
if ($item === null) {
$failed[] = $url;
continue;
}
if (($alt = data_get($entry, 'alt')) !== null
&& MediaType::classify(data_get($item, 'mime_type'), data_get($item, 'path')) === MediaType::Image) {
$item['meta'] = ['alt_text' => $alt];
}
$attached[] = $item;
}
if ($attached !== []) {
@ -77,6 +91,10 @@ public function resolveInlineMedia(Workspace $workspace, array $allowedTypes, ar
continue;
}
if (($meta = data_get($item, 'meta')) !== null) {
$hosted['meta'] = $meta;
}
$media[] = $hosted;
$hostedIds[] = data_get($hosted, 'id');
}
@ -143,10 +161,10 @@ private function download(string $url): ?array
$temp = tempnam(sys_get_temp_dir(), 'media_');
try {
$response = Http::timeout(20)
$response = $this->safeHttp->guardedRequest($url, followRedirects: false)
->timeout(20)
->sink($temp)
->withOptions([
'allow_redirects' => false,
'progress' => static function ($total, $downloaded) use ($cap): void {
if ($downloaded > $cap) {
throw new RuntimeException('exceeded max bytes');

View file

@ -66,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array
$this->account = $postPlatform->socialAccount;
$this->hasRetried = false;
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
if ($this->account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($this->account);
}
@ -121,10 +121,14 @@ private function publishPost(?string $content, $media): array
$payload = $this->basePayload($content);
if ($media->isNotEmpty()) {
$mediaUrn = $this->uploadMedia($media->first());
$item = $media->first();
$mediaUrn = $this->uploadMedia($item);
if ($mediaUrn) {
$payload['content'] = ['media' => ['id' => $mediaUrn]];
$payload['content'] = ['media' => array_filter([
'id' => $mediaUrn,
'altText' => $item->isImage() ? $item->altTextFor($this->platform()) : null,
], fn ($v) => $v !== null)];
}
}
@ -143,10 +147,10 @@ private function publishCarousel(?string $content, $media): array
$imageUrn = $this->uploadImage($item);
if ($imageUrn) {
$images[] = [
$images[] = array_filter([
'id' => $imageUrn,
'altText' => $item->original_filename ?? 'Carousel image',
];
'altText' => $item->altTextFor($this->platform()),
], fn ($v) => $v !== null);
}
}

View file

@ -37,6 +37,8 @@ final class BlueskyLexicon
public const EMBED_VIDEO = 'app.bsky.embed.video';
public const EMBED_EXTERNAL = 'app.bsky.embed.external';
public const FACET_LINK = 'app.bsky.richtext.facet#link';
public const FACET_MENTION = 'app.bsky.richtext.facet#mention';

View file

@ -9,13 +9,18 @@
use App\Exceptions\Social\BlueskyPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Brand\SafeHttpFetcher;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use App\Services\Social\LinkCard\LinkCardFetcher;
use App\Services\Social\LinkCard\LinkCardMetadata;
use App\Support\UrlDetector;
use Carbon\CarbonInterface;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class BlueskyPublisher
@ -25,6 +30,9 @@ class BlueskyPublisher
/** Seconds allowed for a remote media download (large videos need time). */
private const DOWNLOAD_TIMEOUT = 600;
/** Short timeout for the card thumbnail download (attacker-influenceable og:image, not a large media upload). */
private const THUMB_DOWNLOAD_TIMEOUT = 15;
/** Re-upload a transiently-failing transcode this many times before giving up. */
private const VIDEO_UPLOAD_ATTEMPTS = 3;
@ -48,7 +56,7 @@ public function publish(PostPlatform $postPlatform): array
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
// Refresh token if needed
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -63,7 +71,7 @@ public function publish(PostPlatform $postPlatform): array
$blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type);
if ($blob) {
$images[] = [
'alt' => '',
'alt' => $media->altTextFor(Platform::Bluesky) ?? '',
'image' => $blob,
];
}
@ -95,6 +103,13 @@ public function publish(PostPlatform $postPlatform): array
}
}
// No image or video embed, so a bare link can carry a preview card.
// Bluesky does not hydrate cards server-side: the client must attach an
// app.bsky.embed.external built from the page's OpenGraph metadata.
if ($embed === null && $medias->isEmpty() && $content !== null) {
$embed = $this->buildExternalEmbed($postPlatform->socialAccount, $service, $content);
}
// Parse facets (links, mentions, hashtags) from text
$text = $content ?? '';
$facets = $this->parseFacets($text);
@ -142,9 +157,63 @@ public function publish(PostPlatform $postPlatform): array
];
}
private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array
/**
* Build an app.bsky.embed.external card for the first link in the text, or
* null when there is no link, the scrape fails, or the page has no metadata.
* The og:image is re-uploaded as the card thumb because Bluesky's `thumb`
* only accepts a blob, never an external URL. Any failure degrades to null
* so the post still publishes with just the link facet.
*/
private function buildExternalEmbed(SocialAccount $account, string $service, string $content): ?array
{
$tempFile = $this->downloadToTempFile($url, 'bsky_blob_');
$card = app(LinkCardFetcher::class)->fetch($content);
if ($card === null) {
return null;
}
$external = [
'uri' => $card->uri,
'title' => $card->title,
'description' => $card->description,
];
$thumb = $this->uploadCardThumb($account, $service, $card);
if ($thumb !== null) {
$external['thumb'] = $thumb;
}
return [
'$type' => BlueskyLexicon::EMBED_EXTERNAL,
'external' => $external,
];
}
/**
* Download the card's og:image and upload it as a blob for the thumb.
* Returns null (card renders without a thumbnail) when there is no image or
* the upload fails. A JPEG hint routes it through the image optimizer, which
* re-encodes any static image and enforces Bluesky's 1MB blob limit.
*/
private function uploadCardThumb(SocialAccount $account, string $service, LinkCardMetadata $card): ?array
{
if ($card->imageUrl === null) {
return null;
}
try {
app(SafeHttpFetcher::class)->guardAgainstSsrf($card->imageUrl);
} catch (RuntimeException) {
return null;
}
return $this->uploadBlob($account, $service, $card->imageUrl, 'image/jpeg', self::THUMB_DOWNLOAD_TIMEOUT, followRedirects: false);
}
private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType, int $downloadTimeout = self::DOWNLOAD_TIMEOUT, bool $followRedirects = true): ?array
{
$tempFile = $this->downloadToTempFile($url, 'bsky_blob_', $downloadTimeout, $followRedirects);
if ($tempFile === null) {
return null;
@ -202,8 +271,15 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
* Download a remote media file to a temp file. Returns the temp path, or
* null (after cleaning up) if the temp file can't be created, the download
* fails, or the downloaded file is empty.
*
* $followRedirects defaults to true for the media/video paths, which
* download from our own storage/CDN URLs. The card thumb path passes
* false because the source is an attacker-influenceable og:image that
* was only guarded against SSRF on its original URL — a redirect on that
* hop must not be followed without re-guarding, so it is simply not
* followed at all (the thumb degrades to null instead).
*/
private function downloadToTempFile(string $url, string $prefix): ?string
private function downloadToTempFile(string $url, string $prefix, int $timeoutSeconds = self::DOWNLOAD_TIMEOUT, bool $followRedirects = true): ?string
{
$tempFile = tempnam(sys_get_temp_dir(), $prefix);
@ -214,7 +290,13 @@ private function downloadToTempFile(string $url, string $prefix): ?string
}
try {
$response = Http::withOptions(['sink' => $tempFile])->timeout(self::DOWNLOAD_TIMEOUT)->get($url);
$options = ['sink' => $tempFile];
if (! $followRedirects) {
$options['allow_redirects'] = false;
}
$response = Http::withOptions($options)->timeout($timeoutSeconds)->get($url);
if ($response->failed()) {
throw new Exception('HTTP '.$response->status());
@ -555,14 +637,14 @@ private function parseFacets(string $text): array
// Parse URLs
preg_match_all(
'/(https?:\/\/[^\s]+)/u',
UrlDetector::URL_PATTERN,
$text,
$urlMatches,
PREG_OFFSET_CAPTURE
);
foreach ($urlMatches[0] as $match) {
$url = $this->trimTrailingUrlPunctuation($match[0]);
$url = UrlDetector::trimTrailingPunctuation($match[0]);
$start = (int) $match[1];
$end = $start + strlen($url);
@ -676,24 +758,6 @@ private function resolveHandleToDid(string $handle): ?string
}
}
/**
* Trailing sentence punctuation and an unmatched closing paren are almost
* never part of a URL (e.g. "see https://x.com)."). Mirrors the official
* atproto link tokenizer so the link facet doesn't over-extend past the URL.
*/
private function trimTrailingUrlPunctuation(string $url): string
{
if (preg_match('/[.,;:!?]$/', $url)) {
$url = substr($url, 0, -1);
}
if (str_ends_with($url, ')') && ! str_contains($url, '(')) {
$url = substr($url, 0, -1);
}
return $url;
}
private function buildPostUrl(string $handle, string $postId): string
{
$webApp = (string) config('trypost.platforms.bluesky.web_app');

View file

@ -37,14 +37,55 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati
throw $this->cropFailureException('Failed to download image for cropping');
}
$cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio);
try {
$cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio);
} catch (\Throwable) {
throw $this->cropFailureException('Failed to process image for cropping');
}
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($cropped));
try {
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($cropped));
@unlink($cropped);
return Storage::url($path);
} finally {
@unlink($cropped);
}
} finally {
@unlink($tempInput);
}
}
return Storage::url($path);
/**
* Fit the image inside a width×height canvas with a blurred-background
* extension (no cropping), host it, and return a public URL. Used for
* stories so an off-ratio image isn't clipped by the platform.
*/
protected function fitImageToCanvas(string $imageUrl, int $width, int $height): string
{
$tempInput = tempnam(sys_get_temp_dir(), 'fit_in_');
try {
$download = Http::sink($tempInput)->timeout(120)->get($imageUrl);
if ($download->failed()) {
throw $this->cropFailureException('Failed to download image for story fitting');
}
try {
$fitted = app(MediaOptimizer::class)->fitToCanvas($tempInput, $width, $height);
} catch (\Throwable) {
throw $this->cropFailureException('Failed to process image for story fitting');
}
try {
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($fitted));
return Storage::url($path);
} finally {
@unlink($fitted);
}
} finally {
@unlink($tempInput);
}
@ -56,8 +97,8 @@ protected function aspectRatioToFloat(string $ratio): float
}
/**
* The platform-specific exception thrown when the source image cannot be
* downloaded for cropping.
* The platform-specific exception thrown when an image can't be prepared for
* publishing — a download, crop, or story-fit failure.
*/
abstract protected function cropFailureException(string $message): SocialPublishException;
}

View file

@ -9,6 +9,7 @@
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Discord\DiscordClient;
use App\Services\Social\Meta\GraphError;
use App\Services\Social\Telegram\TelegramApi;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
@ -31,9 +32,7 @@ public function verify(SocialAccount $account): bool
// refresh, so proactive refreshes during races cause false-positive
// disconnects even though the access_token still works fine.
if ($account->is_token_expired) {
$this->refreshToken($account);
return $this->callVerifyEndpoint($account);
return $this->refreshThenVerify($account);
}
try {
@ -41,13 +40,40 @@ public function verify(SocialAccount $account): bool
} catch (TokenExpiredException $e) {
// Verify returned 401: the access_token is actually invalid.
// Refresh and retry once with the new token.
try {
$this->refreshToken($account);
} catch (TokenExpiredException) {
throw $e;
}
return $this->refreshThenVerify($account, $e);
}
}
/**
* Refresh the token, then verify with the new one.
*
* If either the refresh is rejected (4xx) or the verify that follows it hits
* a token a concurrent refresh has already rotated — including the sub-commit
* window where a lock-skipped refresh reloads a not-yet-persisted token —
* reload and, when another process has since persisted a fresh access_token,
* verify with that instead of giving up. X (and other providers that
* single-use their refresh_token) would otherwise disconnect a still-usable
* account whenever two refreshes race and one loses.
*
* @throws TokenExpiredException
* @throws PlatformUnavailableException
*/
private function refreshThenVerify(SocialAccount $account, ?TokenExpiredException $original = null): bool
{
$accessTokenBeforeRefresh = $account->access_token;
try {
$this->refreshToken($account);
return $this->callVerifyEndpoint($account);
} catch (TokenExpiredException $e) {
$account->refresh();
if ($account->access_token !== $accessTokenBeforeRefresh) {
return $this->callVerifyEndpoint($account);
}
throw $original ?? $e;
}
}
@ -155,7 +181,7 @@ private function refreshXToken(SocialAccount $account): void
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
]);
$account->refresh();
@ -288,10 +314,13 @@ private function refreshPinterestToken(SocialAccount $account): void
private function refreshThreadsToken(SocialAccount $account): void
{
// Threads uses long-lived tokens that can be refreshed
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]));
$response = TokenRefreshClient::for(Platform::Threads)->send(
fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]),
GraphError::indicatesInvalidToken(...),
);
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -299,7 +328,7 @@ private function refreshThreadsToken(SocialAccount $account): void
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
]);
$account->refresh();
@ -307,10 +336,13 @@ private function refreshThreadsToken(SocialAccount $account): void
private function refreshInstagramToken(SocialAccount $account): void
{
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]));
$response = TokenRefreshClient::for(Platform::Instagram)->send(
fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]),
GraphError::indicatesInvalidToken(...),
);
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -318,7 +350,7 @@ private function refreshInstagramToken(SocialAccount $account): void
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
]);
$account->refresh();
@ -385,13 +417,8 @@ private function verifyInstagram(SocialAccount $account): bool
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Instagram access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Instagram access token is invalid or expired');
}
return $response->successful();
@ -406,13 +433,8 @@ private function verifyFacebook(SocialAccount $account): bool
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Facebook access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Facebook access token is invalid or expired');
}
return $response->successful();
@ -427,13 +449,8 @@ private function verifyThreads(SocialAccount $account): bool
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Threads access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Threads access token is invalid or expired');
}
return $response->successful();

View file

@ -126,7 +126,16 @@ private function sendWithMedia(string $channelId, array $payload, Collection $me
$filename = $item->original_filename ?: (basename($item->path) ?: "media-{$index}");
$request = $request->attach("files[{$index}]", file_get_contents($tempFile), $filename);
$attachments[] = ['id' => $index, 'filename' => $filename];
$attachment = ['id' => $index, 'filename' => $filename];
$alt = $item->isImage() ? $item->altTextFor(Platform::Discord) : null;
if ($alt !== null) {
$attachment['description'] = $alt;
}
$attachments[] = $attachment;
}
$payload['attachments'] = $attachments;

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\FacebookPublishException;
use App\Exceptions\Social\SocialPublishException;
@ -134,6 +135,12 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st
$payload['message'] = $content;
}
$alt = $media->altTextFor(Platform::Facebook);
if ($alt !== null) {
$payload['alt_text_custom'] = $alt;
}
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);
if ($response->failed()) {
@ -163,11 +170,19 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
continue;
}
$uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
$uploadPayload = [
'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio),
'published' => 'false',
'access_token' => $accessToken,
]);
];
$alt = $media->altTextFor(Platform::Facebook);
if ($alt !== null) {
$uploadPayload['alt_text_custom'] = $alt;
}
$uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $uploadPayload);
if ($uploadResponse->failed()) {
Log::error('Facebook image upload failed', [

View file

@ -44,7 +44,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -87,7 +87,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
{
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\InstagramPublishException;
use App\Exceptions\Social\SocialPublishException;
@ -28,7 +29,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -81,12 +82,20 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s
{
$imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio);
// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
$params = [
'image_url' => $imageUrl,
'caption' => $content,
'access_token' => $accessToken,
]);
];
$alt = $media->altTextFor(Platform::Instagram);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);
if ($containerResponse->failed()) {
Log::error('Instagram container creation failed', [
@ -158,7 +167,8 @@ private function publishStory(string $instagramId, string $accessToken, $media):
if ($isVideo) {
$params['video_url'] = $media->url;
} else {
$params['image_url'] = $media->url;
$dimensions = ContentType::InstagramStory->aiImageDimensions();
$params['image_url'] = $this->fitImageToCanvas($media->url, data_get($dimensions, 'width'), data_get($dimensions, 'height'));
}
// Step 1: Create story container
@ -206,6 +216,12 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
$params['media_type'] = 'VIDEO';
} else {
$params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio);
$alt = $media->altTextFor(Platform::Instagram);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\LinkCard;
use App\Services\Brand\SafeHttpFetcher;
use App\Support\UrlDetector;
use Illuminate\Support\Facades\Cache;
/**
* Builds a link preview card (OpenGraph metadata) for the first URL in a piece
* of text. Shared by BlueskyPublisher (publish-time embed) and the editor
* preview endpoint. Returns null whenever a card cannot or should not be built,
* so callers degrade gracefully.
*/
class LinkCardFetcher
{
/** Cards for a public URL are identical for everyone; cache briefly and globally. */
private const int CACHE_MINUTES = 10;
public function __construct(
private readonly SafeHttpFetcher $http = new SafeHttpFetcher,
private readonly OpenGraphExtractor $extractor = new OpenGraphExtractor,
) {}
public function fetch(string $text): ?LinkCardMetadata
{
$url = UrlDetector::firstUrl($text);
if ($url === null) {
return null;
}
// Cache a plain array, never the DTO: a rich object does not round-trip
// cleanly through every cache driver (it comes back as an incomplete
// class), whereas primitives always do.
$data = Cache::remember(
'link_card:'.sha1($url),
now()->addMinutes(self::CACHE_MINUTES),
fn (): ?array => $this->build($url)?->toArray(),
);
return $data === null ? null : LinkCardMetadata::fromArray($data);
}
private function build(string $url): ?LinkCardMetadata
{
$response = $this->http->tryGet($url);
if ($response === null) {
return null;
}
$meta = $this->extractor->extract($response->body(), $url);
$title = data_get($meta, 'title');
$description = data_get($meta, 'description');
if ($title === null && $description === null) {
return null;
}
return new LinkCardMetadata(
uri: $url,
title: $title ?? '',
description: $description ?? '',
imageUrl: data_get($meta, 'image'),
);
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\LinkCard;
use Illuminate\Support\Str;
use Illuminate\Support\Uri;
final readonly class LinkCardMetadata
{
public function __construct(
public string $uri,
public string $title,
public string $description,
public ?string $imageUrl,
) {}
/**
* Rebuild the DTO from its cached array form (the inverse of toArray, minus
* the derived `domain`, which toArray recomputes).
*
* @param array{uri?: string, title?: string, description?: string, image?: ?string} $data
*/
public static function fromArray(array $data): self
{
return new self(
uri: (string) data_get($data, 'uri', ''),
title: (string) data_get($data, 'title', ''),
description: (string) data_get($data, 'description', ''),
imageUrl: data_get($data, 'image'),
);
}
/**
* @return array{uri: string, domain: string, title: string, description: string, image: ?string}
*/
public function toArray(): array
{
return [
'uri' => $this->uri,
'domain' => $this->domain(),
'title' => $this->title,
'description' => $this->description,
'image' => $this->imageUrl,
];
}
/**
* The bare display host for the card (e.g. "nyt.com" from
* "https://www.nyt.com/x"), so the frontend renders it without parsing URLs.
*/
private function domain(): string
{
$host = Uri::of($this->uri)->host();
return $host === null || $host === ''
? $this->uri
: Str::chopStart($host, 'www.');
}
}

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\LinkCard;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\UriResolver;
/**
* Deterministic OpenGraph reader for link preview cards. Pulls og:title /
* og:description / og:image with <title> and meta-description fallbacks.
* Deliberately separate from HomepageMetaExtractor, which is brand-tuned and
* intentionally excludes og:image.
*/
final class OpenGraphExtractor
{
/**
* @return array{title: ?string, description: ?string, image: ?string}
*/
public function extract(string $html, string $baseUrl): array
{
$crawler = new Crawler($html, $baseUrl);
return [
'title' => $this->title($crawler),
'description' => $this->metaContent($crawler, 'property', 'og:description')
?? $this->metaContent($crawler, 'name', 'description'),
'image' => $this->image($crawler, $baseUrl),
];
}
private function title(Crawler $crawler): ?string
{
$ogTitle = $this->metaContent($crawler, 'property', 'og:title');
if ($ogTitle !== null) {
return $ogTitle;
}
$title = $crawler->filter('title')->first();
if ($title->count() === 0) {
return null;
}
$text = trim($title->text(''));
return $text === '' ? null : $text;
}
private function image(Crawler $crawler, string $baseUrl): ?string
{
$image = $this->metaContent($crawler, 'property', 'og:image');
if ($image === null) {
return null;
}
return UriResolver::resolve($image, $baseUrl);
}
private function metaContent(Crawler $crawler, string $attr, string $value): ?string
{
$node = $crawler->filter(sprintf('meta[%s="%s"]', $attr, $value))->first();
if ($node->count() === 0) {
return null;
}
$content = trim((string) $node->attr('content', ''));
return $content === '' ? null : $content;
}
}

View file

@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -79,7 +79,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
// Upload media first (max 4)
foreach ($medias->take(4) as $media) {
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename);
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->isImage() ? $media->altTextFor(Platform::Mastodon) : null);
if ($mediaId) {
$mediaIds[] = $mediaId;
}
@ -68,7 +68,7 @@ public function publish(PostPlatform $postPlatform): array
];
}
private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename): ?string
private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename, ?string $altText): ?string
{
$tempFile = tempnam(sys_get_temp_dir(), 'masto_media_');
@ -101,9 +101,14 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u
$stream = fopen($tempFile, 'r');
$response = $this->socialHttp()->withToken($account->access_token)
->attach('file', $stream, $name)
->post("{$instance}/api/v1/media");
$request = $this->socialHttp()->withToken($account->access_token)
->attach('file', $stream, $name);
if ($altText !== null) {
$request = $request->attach('description', $altText);
}
$response = $request->post("{$instance}/api/v1/media");
if (is_resource($stream)) {
fclose($stream);

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Meta;
/**
* Interprets Meta Graph API (Facebook / Instagram / Threads) error responses.
*
* Meta returns rate-limit and transient failures as an ordinary HTTP 4xx with
* type "OAuthException" (e.g. code 4 / 17 "too many calls", code 1 / 2
* "temporary problem"), so neither the HTTP status nor the error type can tell
* a dead token from a throttle. Only error code 190 means the access token
* itself is invalid or expired — the same signal the platform publish
* exceptions already use to decide a disconnect.
*/
class GraphError
{
/**
* Whether the given Meta Graph error body means the access token is
* genuinely invalid or expired (code 190), as opposed to a rate-limit or
* transient error that must not disconnect a still-valid token.
*
* @param array<string, mixed>|null $body
*/
public static function indicatesInvalidToken(?array $body): bool
{
return data_get($body, 'error.code') === 190;
}
}

View file

@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -90,7 +90,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -119,8 +119,10 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content):
$payload['link'] = data_get($postPlatform->meta, 'link');
}
if (! empty(data_get($postPlatform->meta, 'alt_text'))) {
$payload['alt_text'] = substr(data_get($postPlatform->meta, 'alt_text'), 0, 500);
$alt = $postPlatform->post->mediaItems->first(fn ($m) => $m->isImage())?->altTextFor(Platform::Pinterest);
if ($alt !== null) {
$payload['alt_text'] = $alt;
}
$response = $this->socialHttp()->withToken($account->access_token)
@ -407,7 +409,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
*/
public function getBoards(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -77,7 +77,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\ThreadsPublishException;
use App\Models\PostPlatform;
@ -30,7 +31,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -100,12 +101,20 @@ private function publishTextPost(string $userId, string $accessToken, string $co
private function publishImagePost(string $userId, string $accessToken, ?string $content, $media): array
{
// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
$params = [
'media_type' => 'IMAGE',
'image_url' => $media->url,
'text' => $content,
'access_token' => $accessToken,
]);
];
$alt = $media->altTextFor(Platform::Threads);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);
if ($containerResponse->failed()) {
Log::error('Threads image container creation failed', [
@ -184,6 +193,12 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
} else {
$params['media_type'] = 'IMAGE';
$params['image_url'] = $media->url;
$alt = $media->altTextFor(Platform::Threads);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);

View file

@ -35,7 +35,7 @@ public function getMetrics(SocialAccount $account): array
private function fetchMetricsFromApi(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -58,7 +58,7 @@ public function fetch(SocialAccount $account): array
*/
private function fetchFresh(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -43,7 +43,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -16,13 +16,18 @@
* Normalizes the failure modes of an OAuth token-refresh request:
*
* - ConnectionException (timeout / DNS / refused) → PlatformUnavailableException
* - HTTP 5xx → PlatformUnavailableException
* - HTTP 4xx → TokenExpiredException
* - HTTP 5xx or 429 → PlatformUnavailableException
* - other HTTP 4xx → TokenExpiredException
*
* Providers that return rate-limit or transient errors as an ordinary 4xx
* (Meta: Instagram/Threads) can pass an `$isTokenInvalid` classifier to
* `send()`; only a genuinely dead token then disconnects, while every other
* 4xx is treated as transient (PlatformUnavailableException).
*
* Callers configure the actual HTTP call through the closure passed to
* `send()`, so platform-specific quirks (form vs JSON body, auth headers,
* basic auth, etc.) stay where they belong — in the per-platform refresh
* method — while the failure semantics are uniform across providers.
* method.
*/
class TokenRefreshClient
{
@ -35,11 +40,14 @@ public static function for(Platform $platform): self
/**
* @param Closure():Response $request
* @param (Closure(array<string, mixed>|null):bool)|null $isTokenInvalid Given the parsed
* response body, returns whether a non-5xx/429 failure means the token itself is dead.
* When it returns false, the failure is treated as transient (PlatformUnavailableException).
*
* @throws PlatformUnavailableException
* @throws TokenExpiredException
*/
public function send(Closure $request): Response
public function send(Closure $request, ?Closure $isTokenInvalid = null): Response
{
$name = $this->platform->label();
@ -62,6 +70,14 @@ public function send(Closure $request): Response
]);
$body = $response->json();
if ($isTokenInvalid !== null && ! $isTokenInvalid($body)) {
throw new PlatformUnavailableException(
"{$name} API returned {$response->status()} during token refresh",
$response->status(),
);
}
$message = data_get($body, 'error_description')
?? data_get($body, 'error.message')
?? "Failed to refresh {$name} token";

View file

@ -46,7 +46,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -161,7 +161,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\DataTransferObjects\MediaItem;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
@ -15,6 +16,7 @@
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class XPublisher
{
@ -37,8 +39,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
// Refresh token if expired or expiring soon
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -61,6 +62,7 @@ public function publish(PostPlatform $postPlatform): array
$mediaId = data_get($uploadedMedia, 'data.id', data_get($uploadedMedia, 'media_id'));
if ($mediaId) {
$mediaIds[] = $mediaId;
$this->uploadAltText($mediaId, $mediaItem);
}
}
}
@ -107,6 +109,41 @@ private function getHttpClient(): PendingRequest
]);
}
/**
* Sets the image's accessibility description on X via the v2 media
* metadata endpoint. Best-effort: only images carry alt text, and a failure
* here never blocks the tweet — the media already uploaded and the post
* should still go out without the description.
*/
private function uploadAltText(string $mediaId, MediaItem $mediaItem): void
{
if (! $mediaItem->isImage()) {
return;
}
$alt = $mediaItem->altTextFor(Platform::X);
if ($alt === null) {
return;
}
try {
$this->getHttpClient()->post("{$this->baseUrl}/media/metadata", [
'id' => $mediaId,
'metadata' => [
'alt_text' => [
'text' => $alt,
],
],
]);
} catch (Throwable $e) {
Log::warning('X alt text upload failed; posting the tweet without it', [
'media_id' => $mediaId,
'error' => $e->getMessage(),
]);
}
}
private function uploadMedia($mediaItem): ?array
{
$mimeType = $mediaItem->mime_type;

View file

@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -97,7 +97,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -15,7 +15,7 @@ class UnsplashService
public function __construct()
{
$this->accessKey = config('services.unsplash.access_key', '');
$this->accessKey = (string) config('services.unsplash.access_key', '');
}
/**

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Single source of truth for the user-supplied AI generation prompt length,
* shared by the create wizard (StartPostCreationRequest) and the editor's
* content generation (GeneratePostContentRequest). The frontend counter in
* resources/js/components/posts/create/AiPostWizard.vue mirrors these bounds.
*/
class AiPromptRules
{
/**
* Minimum prompt length (characters) for the create wizard.
*/
public const PROMPT_MIN_LENGTH = 3;
/**
* Maximum prompt length (characters); mirrored by the frontend counter.
*/
public const PROMPT_MAX_LENGTH = 2000;
/**
* Validation rules for the create wizard's generation prompt. The editor's
* content generation reuses only PROMPT_MAX_LENGTH — it has no minimum,
* since it has no character counter to mirror one.
*
* @return array<int, string>
*/
public static function wizardPromptRule(): array
{
return ['required', 'string', 'min:'.self::PROMPT_MIN_LENGTH, 'max:'.self::PROMPT_MAX_LENGTH];
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Support\Billing;
use App\Models\Account;
use Laravel\Cashier\SubscriptionBuilder;
use RuntimeException;
use Stripe\Subscription as StripeSubscription;
final class FirstMonthCheckoutDiscount
{
/**
* Configure a subscription checkout to charge $1 for the first invoice via
* a `duration: once` Stripe coupon, so a real charge validates the card
* instead of a $0 trial authorization. Stripe rejects a Checkout Session
* that sets both `discounts` and `allow_promotion_codes`, so checkouts that
* don't qualify for the paid first month keep the promotion-code field.
*
* @throws RuntimeException when a qualifying checkout has the paid first
* month enabled but no coupon configured — failing
* loudly beats silently charging full price.
*/
public static function apply(SubscriptionBuilder $subscription, Account $account): SubscriptionBuilder
{
if (! self::qualifiesForPaidFirstMonth($account)) {
return $subscription->allowPromotionCodes();
}
$couponId = config('cashier.first_month_coupon_id');
if (! is_string($couponId) || $couponId === '') {
throw new RuntimeException(
'STRIPE_FIRST_MONTH_COUPON_ID must be set when REQUIRE_CARD_FOR_TRIAL is enabled, '
.'otherwise checkout would charge the full price instead of the $1 first month.'
);
}
return $subscription->withCoupon($couponId);
}
/**
* The fixed-amount first-month coupon only applies to a genuinely new
* customer checking out a single workspace: the fixed `amount_off` is only
* correct for a quantity of one, and the $1 offer is for first-time signups
* — not a returning account re-subscribing with workspaces it kept from a
* lapsed subscription. A subscription that never left `incomplete` never
* became real, so a new customer retrying after a failed first attempt
* still qualifies; any started subscription (even canceled) does not.
*/
private static function qualifiesForPaidFirstMonth(Account $account): bool
{
if (! (bool) config('trypost.billing.require_card_for_trial', true)) {
return false;
}
return $account->workspaces()->count() === 1
&& ! $account->subscriptions()
->whereNotIn('stripe_status', [
StripeSubscription::STATUS_INCOMPLETE,
StripeSubscription::STATUS_INCOMPLETE_EXPIRED,
])
->exists();
}
}

View file

@ -5,6 +5,7 @@
namespace App\Support;
use App\Enums\Media\Source;
use Closure;
use Illuminate\Validation\Rule;
/**
@ -15,6 +16,12 @@
*/
class PostMediaRules
{
/**
* Maximum stored length (characters) for a media item's alt text. Publishers
* truncate further to each platform's own cap via Platform::altTextMaxLength().
*/
public const ALT_TEXT_MAX_LENGTH = 2000;
/**
* @param bool $hosted true (web): items must already be hosted (id + path
* required); false (API): a bare external `url` is
@ -34,7 +41,23 @@ public static function rules(bool $hosted): array
'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'],
'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'],
'media.*.size' => ['sometimes', 'nullable', 'integer'],
'media.*.meta' => ['sometimes', 'nullable', 'array'],
'media.*.meta' => ['sometimes', 'nullable', 'array', static function (string $attribute, mixed $value, Closure $fail): void {
$altText = data_get($value, 'alt_text');
if ($altText === null) {
return;
}
if (! is_string($altText)) {
$fail('validation.string')->translate(['attribute' => trans('posts.edit.alt_text.label')]);
return;
}
if (mb_strlen($altText) > self::ALT_TEXT_MAX_LENGTH) {
$fail('validation.max.string')->translate(['attribute' => trans('posts.edit.alt_text.label'), 'max' => self::ALT_TEXT_MAX_LENGTH]);
}
}],
'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))],
'media.*.source_meta' => ['sometimes', 'nullable', 'array'],
];

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Support;
final class UrlDetector
{
/** PCRE matching bare http(s) URLs; shared with Bluesky link-facet parsing. */
public const string URL_PATTERN = '/(https?:\/\/[^\s]+)/u';
public static function firstUrl(string $text): ?string
{
if (preg_match(self::URL_PATTERN, $text, $matches) !== 1) {
return null;
}
return self::trimTrailingPunctuation($matches[0]);
}
/**
* Trailing sentence punctuation and an unmatched closing paren are almost
* never part of a URL (e.g. "see https://x.com)."). Mirrors the official
* atproto link tokenizer.
*/
public static function trimTrailingPunctuation(string $url): string
{
if (preg_match('/[.,;:!?]$/', $url)) {
$url = substr($url, 0, -1);
}
if (str_ends_with($url, ')') && ! str_contains($url, '(')) {
$url = substr($url, 0, -1);
}
return $url;
}
}

View file

@ -74,6 +74,7 @@
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.4",
"pestphp/pest-plugin-browser": "^4.3",
"pestphp/pest-plugin-laravel": "^4.1"
},
"autoload": {
@ -120,6 +121,10 @@
"@test:lint",
"@php artisan test"
],
"test:all": [
"@test",
"@php artisan test tests/Browser"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"

1573
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -137,4 +137,19 @@
'trial_days' => env('CASHIER_TRIAL_DAYS', 8),
/*
|--------------------------------------------------------------------------
| Paid First Month Coupon
|--------------------------------------------------------------------------
|
| Stripe Coupon ID applied at checkout so the first invoice comes out to
| $1 instead of the full monthly price — a real charge validates the
| card up front instead of a $0 trial authorization. Must be an
| `amount_off` coupon with `duration: once`, so it discounts only the
| first invoice and the full price bills automatically afterward.
|
*/
'first_month_coupon_id' => env('STRIPE_FIRST_MONTH_COUPON_ID'),
];

View file

@ -19,6 +19,18 @@
'en' => 'English',
'es' => 'Español',
'pt-BR' => 'Português',
'fr' => 'Français',
'de' => 'Deutsch',
'it' => 'Italiano',
'nl' => 'Nederlands',
'pl' => 'Polski',
'el' => 'Ελληνικά',
'ja' => '日本語',
'ko' => '한국어',
'zh' => '中文',
'ru' => 'Русский',
'tr' => 'Türkçe',
'ar' => 'العربية',
],
/*

View file

@ -16,6 +16,22 @@
'self_hosted' => env('SELF_HOSTED', true),
/*
|--------------------------------------------------------------------------
| Security
|--------------------------------------------------------------------------
|
| SafeHttpFetcher blocks requests to private/reserved IP ranges (SSRF
| protection) by default. Self-hosted operators who need to fetch from
| their own internal network (e.g. an internal RSS feed or webhook) can
| opt in here. Leave disabled unless you understand the SSRF risk.
|
*/
'security' => [
'allow_private_network' => (bool) env('TRYPOST_ALLOW_PRIVATE_NETWORK', false),
],
/*
|--------------------------------------------------------------------------
| Billing

148
lang/ar/accounts.php Normal file
View file

@ -0,0 +1,148 @@
<?php
return [
'title' => 'الاتصالات',
'page_title' => 'الحسابات الاجتماعية',
'description' => 'نظرة عامة على جميع حساباتك الاجتماعية المتصلة',
'connect_cta' => 'ربط',
'not_connected' => 'غير متصل',
'connect' => 'ربط',
'connection_lost' => 'انقطع الاتصال',
'reconnect' => 'إعادة الربط',
'reconnect_account' => 'إعادة ربط الحساب',
'view_profile' => 'عرض الملف الشخصي',
'disconnect' => 'فصل',
'descriptions' => [
'linkedin' => 'اربط ملفك الشخصي أو صفحة شركتك على LinkedIn',
'linkedin-page' => 'اربط صفحة شركة على LinkedIn',
'x' => 'اربط حسابك على X (Twitter)',
'tiktok' => 'اربط حسابك على TikTok',
'youtube' => 'اربط قناة على YouTube',
'facebook' => 'اربط صفحة على Facebook',
'instagram' => 'اربط حساب Instagram احترافيًا',
'instagram-facebook' => 'اربط Instagram عبر صفحة Facebook',
'threads' => 'اربط حسابك على Threads',
'pinterest' => 'اربط حسابك على Pinterest',
'bluesky' => 'اربط حسابك على Bluesky',
'mastodon' => 'اربط حسابك على Mastodon',
'telegram' => 'اربط قناة أو مجموعة على Telegram',
'discord' => 'اربط خادم Discord',
],
'disconnect_modal' => [
'title' => 'فصل الحساب',
'description' => 'هل أنت متأكد من رغبتك في فصل هذا الحساب؟ يمكنك إعادة ربطه في أي وقت.',
'confirm' => 'فصل',
'cancel' => 'إلغاء',
],
'bluesky' => [
'title' => 'ربط Bluesky',
'description' => 'أدخل بيانات اعتمادك للربط',
'email' => 'البريد الإلكتروني',
'email_placeholder' => 'yourhandle.bsky.social',
'app_password' => 'كلمة مرور التطبيق',
'app_password_placeholder' => 'xxxx-xxxx-xxxx-xxxx',
'app_password_hint' => 'استخدم <strong>كلمة مرور التطبيق</strong> لمزيد من الأمان. أنشئ واحدة من <a href="https://bsky.app/settings/app-passwords" target="_blank" class="underline">bsky.app/settings</a>.',
'submit' => 'ربط Bluesky',
'submitting' => 'جارٍ الاتصال...',
],
'mastodon' => [
'title' => 'ربط Mastodon',
'description' => 'أدخل خادم Mastodon الخاص بك',
'instance_url' => 'رابط الخادم',
'instance_placeholder' => 'https://mastodon.social',
'instance_hint' => 'أدخل رابط خادم Mastodon الخاص بك (مثل mastodon.social، techhub.social)',
'submit' => 'المتابعة عبر Mastodon',
'submitting' => 'جارٍ الاتصال...',
],
'telegram' => [
'title' => 'ربط Telegram',
'description' => 'اربط قناة أو مجموعة',
'step_admin' => 'أضِف :bot كمشرف على قناتك أو مجموعتك على Telegram.',
'step_command' => 'انشر هذا الأمر في القناة أو المجموعة:',
'waiting' => 'في انتظار اتصال القناة…',
'connected' => 'تم اتصال القناة!',
'connected_toast' => 'تم ربط قناة Telegram بنجاح!',
'copied_toast' => 'تم نسخ الأمر إلى الحافظة',
'copy_tooltip' => 'نسخ الأمر',
'expired' => 'انتهت صلاحية هذا الرمز. أنشئ رمزًا جديدًا للمحاولة مرة أخرى.',
'new_code' => 'إنشاء رمز جديد',
'retry' => 'إعادة المحاولة',
'error_generic' => 'تعذر بدء الاتصال. يرجى المحاولة مرة أخرى.',
'network_taken' => 'تحتوي مساحة العمل هذه بالفعل على قناة Telegram متصلة. افصلها أولًا.',
],
'facebook' => [
'title' => 'اختيار صفحة Facebook',
'description' => 'اختر الصفحة التي تريد ربطها',
'no_pages' => 'لم يتم العثور على صفحات',
'no_pages_description' => 'أنت لست مشرفًا على أي صفحة Facebook.',
'page_label' => 'صفحة Facebook',
'view' => 'عرض',
'choose' => 'اختيار',
],
'instagram_facebook' => [
'title' => 'اختيار حساب Instagram',
'description' => 'اختر حساب Instagram الذي تريد ربطه',
'no_pages' => 'لم يتم العثور على حسابات Instagram',
'no_pages_description' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram للأعمال.',
'view' => 'عرض',
'choose' => 'اختيار',
],
'linkedin' => [
'title' => 'اختيار صفحة LinkedIn',
'description' => 'اختر الصفحة التي تريد ربطها',
'no_pages' => 'لم يتم العثور على صفحات',
'no_pages_description' => 'أنت لست مشرفًا على أي صفحة LinkedIn.',
'page_label' => 'صفحة LinkedIn',
'select_title' => 'أين تريد النشر؟',
'select_subtitle' => 'انشر باسمك أو اختر صفحة شركة تديرها.',
'person_tag' => 'شخص',
'organization_tag' => 'مؤسسة',
'view' => 'عرض',
'choose' => 'اختيار',
],
'flash' => [
'disconnected' => 'تم فصل الحساب بنجاح!',
'connected' => 'تم ربط الحساب بنجاح!',
'session_expired' => 'انتهت الجلسة. يرجى المحاولة مرة أخرى.',
'workspace_not_found' => 'لم يتم العثور على مساحة العمل.',
'activated' => 'تم تفعيل الحساب!',
'deactivated' => 'تم إلغاء تفعيل الحساب!',
'already_connected' => 'هذه المنصة متصلة بالفعل.',
'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.',
],
'popup_callback' => [
'title_success' => 'تم الاتصال',
'title_error' => 'خطأ',
'closing' => 'ستُغلق هذه النافذة تلقائيًا...',
'manual_close' => 'يمكنك إغلاق هذه النافذة.',
'popup_blocked' => 'تعذر فتح نافذة الاتصال. يرجى السماح بالنوافذ المنبثقة والمحاولة مرة أخرى.',
'connected' => 'تم ربط الحساب!',
'reconnected' => 'تمت إعادة ربط الحساب!',
'error_connecting' => 'خطأ في ربط الحساب. يرجى المحاولة مرة أخرى.',
'network_taken' => 'تحتوي مساحة العمل هذه بالفعل على حساب لهذه الشبكة. افصله أولًا.',
'error_connecting_page' => 'خطأ في ربط الصفحة. يرجى المحاولة مرة أخرى.',
'error_connecting_channel' => 'خطأ في ربط القناة. يرجى المحاولة مرة أخرى.',
'session_expired' => 'انتهت الجلسة. يرجى المحاولة مرة أخرى.',
'workspace_not_found' => 'لم يتم العثور على مساحة العمل.',
'invalid_state' => 'حالة غير صالحة. يرجى المحاولة مرة أخرى.',
'failed_to_authenticate' => 'فشلت المصادقة.',
'failed_to_get_profile' => 'فشل جلب الملف الشخصي.',
'page_not_found' => 'لم يتم العثور على الصفحة.',
'channel_not_found' => 'لم يتم العثور على القناة.',
'no_facebook_pages' => 'لم يتم العثور على صفحات Facebook. يجب أن تكون مشرفًا على صفحة واحدة على الأقل.',
'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.',
'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.',
'not_linkedin_admin' => 'أنت لست مشرفًا على أي صفحة LinkedIn.',
],
];

55
lang/ar/analytics.php Normal file
View file

@ -0,0 +1,55 @@
<?php
return [
'no_accounts' => 'لا توجد حسابات متصلة تحتوي على تحليلات.',
'no_accounts_match' => 'لا توجد حسابات مطابقة.',
'search_account' => 'البحث عن حساب…',
'select_account' => 'اختر حسابًا لعرض التحليلات.',
'no_data' => 'لا تتوفر بيانات تحليلات.',
'metrics' => [
'avg_view_duration' => 'متوسط مدة المشاهدة (ث)',
'avg_view_percentage' => 'متوسط نسبة المشاهدة',
'bookmarks' => 'الإشارات المرجعية',
'clicks' => 'النقرات',
'comments' => 'التعليقات',
'custom_reaction' => 'مخصص',
'engagement' => 'التفاعل',
'favourites' => 'المفضلة',
'followers' => 'المتابِعون',
'following' => 'المتابَعون',
'impressions' => 'مرات الظهور',
'interactions' => 'التفاعلات',
'likes' => 'الإعجابات',
'members' => 'الأعضاء',
'minutes_watched' => 'دقائق المشاهدة',
'organic_followers' => 'المتابِعون العضويون',
'outbound_clicks' => 'النقرات الصادرة',
'page_followers' => 'متابِعو الصفحة',
'page_reach' => 'وصول الصفحة',
'page_views' => 'مشاهدات الصفحة',
'paid_followers' => 'المتابِعون المدفوعون',
'pin_click_rate' => 'معدل النقر على الدبوس',
'pin_clicks' => 'نقرات الدبوس',
'posts_engagement' => 'تفاعل المنشورات',
'posts_reach' => 'وصول المنشورات',
'quotes' => 'الاقتباسات',
'reach' => 'الوصول',
'reblogs' => 'إعادات التدوين',
'recent_comments' => 'أحدث التعليقات',
'recent_likes' => 'أحدث الإعجابات',
'recent_shares' => 'أحدث المشاركات',
'replies' => 'الردود',
'reposts' => 'إعادات النشر',
'retweets' => 'إعادات التغريد',
'saves' => 'مرات الحفظ',
'shares' => 'المشاركات',
'subscribers' => 'المشتركون',
'subscribers_gained' => 'المشتركون الجدد',
'subscribers_lost' => 'المشتركون المفقودون',
'total_likes' => 'إجمالي الإعجابات',
'video_views' => 'مشاهدات الفيديو',
'videos' => 'مقاطع الفيديو',
'views' => 'المشاهدات',
],
];

52
lang/ar/assets.php Normal file
View file

@ -0,0 +1,52 @@
<?php
return [
'title' => 'الوسائط',
'tabs' => [
'my_uploads' => 'ملفاتي المرفوعة',
'stock_photos' => 'صور مجانية',
'gifs' => 'صور GIF',
],
'upload' => [
'drag_drop' => 'اسحب ملفاتك وأفلتها هنا، أو انقر للاختيار',
'formats' => 'JPEG، PNG، GIF، WebP، MP4، PDF',
'uploading' => 'جارٍ الرفع...',
'failed' => 'تعذر رفع :file. يرجى المحاولة مرة أخرى.',
],
'empty' => [
'title' => 'لا توجد وسائط بعد',
'description' => 'ارفع الصور ومقاطع الفيديو لبناء مكتبة الوسائط الخاصة بك.',
],
'save_to_assets' => 'حفظ في الوسائط',
'saved' => 'تم الحفظ في وسائطك!',
'create_post' => 'إنشاء منشور',
'add_to_post' => 'إضافة إلى المنشور',
'search_placeholder' => 'البحث في الوسائط...',
'delete' => [
'title' => 'حذف الوسائط',
'description' => 'هل أنت متأكد من رغبتك في حذف هذا العنصر؟ لا يمكن التراجع عن هذا الإجراء.',
'confirm' => 'حذف',
'cancel' => 'إلغاء',
],
'unsplash' => [
'search_placeholder' => 'البحث عن صور مجانية...',
'no_results' => 'لم يتم العثور على صور',
'no_results_description' => 'جرّب كلمة بحث مختلفة.',
'trending' => 'الرائج على Unsplash',
'start_searching' => 'ابحث عن صور مجانية من Unsplash',
],
'giphy' => [
'trending' => 'الرائج على Giphy',
'search_placeholder' => 'البحث عن صور GIF...',
'no_results' => 'لم يتم العثور على صور GIF',
'no_results_description' => 'جرّب كلمة بحث مختلفة.',
'powered_by' => 'مقدَّم من GIPHY',
],
];

141
lang/ar/auth.php Normal file
View file

@ -0,0 +1,141 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'بيانات الاعتماد هذه لا تطابق سجلاتنا.',
'password' => 'كلمة المرور المُدخلة غير صحيحة.',
'throttle' => 'عدد كبير جدًا من محاولات تسجيل الدخول. يرجى المحاولة مرة أخرى بعد :seconds ثانية.',
'flash' => [
'welcome' => 'مرحبًا بك في TryPost!',
'welcome_trial' => 'مرحبًا بك في TryPost! لقد بدأت فترتك التجريبية.',
],
'legal' => 'بمتابعتك، فإنك توافق على <a href="https://trypost.it/terms" target="_blank">شروط الخدمة</a> و<a href="https://trypost.it/privacy" target="_blank">سياسة الخصوصية</a>.',
'slides' => [
'calendar' => [
'title' => 'تقويم مرئي',
'description' => 'خطّط لمحتواك وجدوِله باستخدام تقويم بديهي يعتمد على السحب والإفلات عبر جميع حساباتك الاجتماعية.',
],
'scheduling' => [
'title' => 'جدولة ذكية',
'description' => 'جدوِل المنشورات عبر LinkedIn وX وInstagram وTikTok وYouTube والمزيد — كل ذلك من مكان واحد.',
],
'media' => [
'title' => 'وسائط غنية',
'description' => 'انشر الصور والعروض الدائرية والقصص ومقاطع الريلز. تحصل كل منصة على التنسيق المناسب تلقائيًا.',
],
'video' => [
'title' => 'نشر الفيديو',
'description' => 'ارفع مقاطع الفيديو مرة واحدة وانشرها على TikTok وYouTube Shorts وInstagram Reels وFacebook Reels.',
],
'team' => [
'title' => 'مساحات عمل جماعية',
'description' => 'ادعُ فريقك، وعيّن الأدوار، وأدِر عدة علامات تجارية من مساحات عمل منفصلة.',
],
'signatures' => [
'title' => 'التوقيعات',
'description' => 'احفظ توقيعات قابلة لإعادة الاستخدام (وسوم، روابط، خواتيم) وأضِفها إلى المنشورات بنقرة واحدة.',
],
],
'or_continue_with' => 'أو تابع باستخدام',
'or_continue_with_email' => 'أو تابع باستخدام البريد الإلكتروني',
'google_login' => 'تسجيل الدخول عبر Google',
'google_signup' => 'التسجيل عبر Google',
'github_login' => 'تسجيل الدخول عبر GitHub',
'github_signup' => 'التسجيل عبر GitHub',
'github_email_unavailable' => 'تعذر جلب بريدك الإلكتروني من GitHub. اجعل بريدك على GitHub عامًا أو امنح نطاق الوصول إلى البريد، ثم حاول مرة أخرى.',
'signup_success' => [
'page_title' => 'مرحبًا',
'title' => 'جارٍ إعداد حسابك',
'description' => 'يستغرق هذا عادةً بضع ثوانٍ فقط...',
],
'login' => [
'title' => 'تسجيل الدخول إلى حسابك',
'description' => 'أدخل بريدك الإلكتروني وكلمة المرور أدناه لتسجيل الدخول',
'page_title' => 'تسجيل الدخول',
'email' => 'البريد الإلكتروني',
'password' => 'كلمة المرور',
'forgot_password' => 'نسيت كلمة المرور؟',
'remember_me' => 'تذكّرني',
'submit' => 'تسجيل الدخول',
'no_account' => 'ليس لديك حساب؟',
'sign_up' => 'إنشاء حساب',
],
'register' => [
'title' => 'تقويمك الاجتماعي بالكامل، في مكان واحد',
'description' => 'أنشئ حسابك وابدأ جدولة المنشورات عبر كل شبكة.',
'page_title' => 'التسجيل',
'signup_with_email' => 'التسجيل بالبريد الإلكتروني',
'name' => 'الاسم',
'name_placeholder' => 'الاسم الكامل',
'email' => 'البريد الإلكتروني',
'password' => 'كلمة المرور',
'show_password' => 'إظهار كلمة المرور',
'hide_password' => 'إخفاء كلمة المرور',
'submit' => 'إنشاء حساب',
'has_account' => 'هل لديك حساب بالفعل؟',
'log_in' => 'تسجيل الدخول',
],
'forgot_password' => [
'title' => 'نسيت كلمة المرور',
'description' => 'أدخل بريدك الإلكتروني لاستلام رابط إعادة تعيين كلمة المرور',
'page_title' => 'نسيت كلمة المرور',
'email' => 'البريد الإلكتروني',
'submit' => 'إرسال رابط إعادة تعيين كلمة المرور',
'return_to' => 'أو، العودة إلى',
'log_in' => 'تسجيل الدخول',
],
'reset_password' => [
'title' => 'إعادة تعيين كلمة المرور',
'description' => 'يرجى إدخال كلمة المرور الجديدة أدناه',
'page_title' => 'إعادة تعيين كلمة المرور',
'email' => 'البريد الإلكتروني',
'password' => 'كلمة المرور',
'confirm_password' => 'تأكيد كلمة المرور',
'confirm_placeholder' => 'تأكيد كلمة المرور',
'submit' => 'إعادة تعيين كلمة المرور',
],
'verify_email' => [
'title' => 'تأكيد البريد الإلكتروني',
'description' => 'يرجى تأكيد بريدك الإلكتروني بالنقر على الرابط الذي أرسلناه إليك للتو.',
'page_title' => 'تأكيد البريد الإلكتروني',
'link_sent' => 'تم إرسال رابط تأكيد جديد إلى البريد الإلكتروني الذي قدّمته أثناء التسجيل.',
'resend' => 'إعادة إرسال بريد التأكيد',
'log_out' => 'تسجيل الخروج',
],
'accept_invite' => [
'page_title' => 'قبول الدعوة',
'title' => 'لقد تمت دعوتك!',
'description' => 'لقد تمت دعوتك للانضمام إلى مساحة العمل :workspace.',
'workspace' => 'مساحة العمل',
'your_role' => 'دورك',
'email' => 'البريد الإلكتروني',
'accept' => 'قبول الدعوة',
'decline' => 'رفض الدعوة',
'login_prompt' => 'سجّل الدخول أو أنشئ حسابًا لقبول هذه الدعوة.',
'log_in' => 'تسجيل الدخول',
'create_account' => 'إنشاء حساب',
],
];

412
lang/ar/automations.php Normal file
View file

@ -0,0 +1,412 @@
<?php
declare(strict_types=1);
return [
'mobile_notice' => 'يعمل محرر الأتمتة بشكل أفضل على شاشة أكبر. افتحه على جهاز كمبيوتر مكتبي لإنشاء سير عملك.',
'title' => 'الأتمتة',
'default_name' => 'أتمتة جديدة',
'actions' => [
'new' => 'أتمتة جديدة',
'edit' => 'تعديل',
'save' => 'حفظ',
'activate' => 'تفعيل',
'pause' => 'إيقاف مؤقت',
'delete' => 'حذف',
'retry' => 'إعادة المحاولة',
'guide' => 'تعرّف على آلية العمل',
],
'tabs' => [
'build' => 'البناء',
'variables' => 'المتغيرات',
'test' => 'اختبار',
],
'nav' => [
'workflow' => 'سير العمل',
'invocations' => 'عمليات التشغيل',
'metrics' => 'المقاييس',
'settings' => 'الإعدادات',
],
'settings' => [
'general' => 'عام',
'general_description' => 'إعادة تسمية هذه الأتمتة.',
'name_label' => 'الاسم',
'name_saved' => 'تمت إعادة تسمية الأتمتة.',
'status_title' => 'الحالة',
'status_description' => 'فعّلها لبدء تشغيلها، أو أوقفها مؤقتًا للتوقف.',
'activated_at' => 'تم التفعيل :date',
'paused_at' => 'تم الإيقاف المؤقت :date',
'created_at' => 'تم الإنشاء :date',
'danger_title' => 'منطقة الخطر',
'danger_description' => 'إجراءات لا يمكن التراجع عنها.',
'delete_title' => 'حذف هذه الأتمتة',
'delete_description' => 'يزيل الأتمتة وسجل تشغيلها نهائيًا.',
],
'status_run' => [
'pending' => 'قيد الانتظار',
'running' => 'قيد التشغيل',
'waiting' => 'في الانتظار',
'completed' => 'مكتمل',
'failed' => 'فشل',
'cancelled' => 'مُلغى',
],
'node_type' => [
'trigger' => 'مُشغّل',
'generate' => 'إنشاء محتوى',
'delay' => 'تأخير',
'condition' => 'شرط',
'publish' => 'نشر',
'webhook' => 'Webhook',
'end' => 'إنهاء',
'fetch_rss' => 'جلب RSS',
'http_request' => 'طلب HTTP',
],
'invocations' => [
'empty' => 'لا توجد عمليات تشغيل بعد.',
'refresh' => 'تحديث',
'search_placeholder' => 'البحث عبر معرّف التشغيل…',
'copied' => 'تم نسخ معرّف التشغيل.',
'loading' => 'جارٍ تحميل الخطوات…',
'no_steps' => 'لم يتم تسجيل أي خطوات.',
'load_error' => 'تعذر تحميل الخطوات.',
'steps' => '{0}لا خطوات|{1}خطوة واحدة|{2}خطوتان|[3,10]:count خطوات|[11,*]:count خطوة',
'filter' => [
'all' => 'جميع الحالات',
],
'columns' => [
'timestamp' => 'الطابع الزمني',
'run' => 'التشغيل',
'status' => 'الحالة',
'message' => 'آخر رسالة',
'duration' => 'المدة',
],
'summary' => [
'completed' => 'اكتمل سير العمل',
'failed' => 'فشل سير العمل',
'running' => 'سير العمل قيد التشغيل',
'cancelled' => 'تم إلغاء سير العمل',
'pending' => 'سير العمل قيد الانتظار',
],
],
'metrics' => [
'overview' => 'نظرة عامة',
'runs_over_time' => 'عمليات التشغيل عبر الزمن',
'posts_by_platform' => 'المنشورات حسب المنصة',
'no_posts' => 'لم يتم نشر أي منشورات في هذه الفترة.',
'cards' => [
'runs' => 'إجمالي عمليات التشغيل',
'completed' => 'مكتملة',
'failed' => 'فاشلة',
'in_progress' => 'قيد التنفيذ',
'success_rate' => 'معدل النجاح',
'avg_duration' => 'متوسط المدة',
'posts_created' => 'المنشورات المُنشأة',
],
'legend' => [
'started' => 'بدأت',
'completed' => 'اكتملت',
'failed' => 'فشلت',
],
],
'categories' => [
'sources' => 'المصادر',
'content' => 'المحتوى',
'flow' => 'التدفق',
'output' => 'الإخراج',
],
'variables' => [
'title' => 'متغيرات سير العمل',
'hint' => 'قيم قابلة لإعادة الاستخدام يُشار إليها في أي مكان عبر {{ variables.KEY }}. تُخزَّن مشفّرة.',
'empty' => 'لا توجد متغيرات بعد.',
'key' => 'المفتاح',
'value' => 'القيمة',
'key_placeholder' => 'API_KEY',
'value_placeholder' => 'القيمة',
'add' => 'متغير جديد',
],
'expr' => [
'trigger_event' => 'اسم حدث المُشغّل',
'trigger_fired_at' => 'وقت إطلاق المُشغّل',
'trigger_post_id' => 'معرّف المنشور المُشغِّل',
'trigger_post_content' => 'محتوى المنشور المُشغِّل',
'trigger_post_status' => 'حالة المنشور المُشغِّل',
'trigger_post_scheduled_at' => 'وقت جدولة المنشور',
'trigger_post_published_at' => 'وقت نشر المنشور',
'fetched_title' => 'عنوان العنصر المجلوب',
'fetched_link' => 'رابط العنصر المجلوب',
'fetched_date' => 'تاريخ نشر العنصر المجلوب',
'fetched_content' => 'المحتوى الكامل للعنصر المجلوب',
'fetched_description' => 'ملخص العنصر المجلوب',
'fetched_author' => 'كاتب العنصر المجلوب',
'fetched_image' => 'رابط صورة العنصر المجلوب',
'fetched_categories' => 'فئات العنصر المجلوب',
'fetched_enclosure' => 'وسائط العنصر المجلوب (صوت/فيديو/ملف)',
'fetched_pubdate' => 'تاريخ نشر العنصر المجلوب',
'fetched_http' => 'عنصر HTTP المجلوب (أضِف حقلًا)',
'generated_content' => 'محتوى المنشور المُنشأ بالذكاء الاصطناعي',
'generated_post_url' => 'رابط المنشور المُنشأ بالذكاء الاصطناعي',
'variable' => 'متغير سير العمل',
'now' => 'التاريخ والوقت الحالي',
],
'test' => [
'description' => 'يشغّل الأتمتة من البداية إلى النهاية باستخدام حمولة مُشغِّل مُصطنعة. مفيد للتحقق من كل عقدة دون انتظار الجدول أو الخلاصة الحقيقية.',
'starting' => 'جارٍ بدء تشغيل الاختبار…',
'in_progress' => 'قيد التنفيذ',
'completed' => 'مكتمل',
'failed' => 'فشل',
'waiting' => 'في الانتظار',
'close' => 'إغلاق',
'no_node_runs' => 'في انتظار بدء العقدة الأولى…',
'node_input' => 'المدخلات',
'node_output' => 'المخرجات',
'node_error' => 'خطأ',
'no_new_items' => 'لا توجد عناصر جديدة — لم يُشغَّل أي شيء لاحق.',
'error_starting' => 'تعذر بدء تشغيل الاختبار.',
'with_real_data' => 'ببيانات حقيقية',
'run' => 'تشغيل الاختبار',
'idle_hint' => 'اضغط على "تشغيل الاختبار" لتنفيذ الأتمتة من البداية إلى النهاية.',
'real_data_hint' => 'سيقوم هذا الاختبار بنشر المنشورات، وتقديم علامات الاستطلاع، وإطلاق تأثيرات جانبية خارجية.',
'dry_badge' => 'تشغيل تجريبي',
],
'status' => [
'draft' => 'مسودة',
'active' => 'نشط',
'paused' => 'متوقف مؤقتًا',
],
'index' => [
'empty_title' => 'لا توجد عمليات أتمتة بعد',
'empty_description' => 'أنشئ أول أتمتة لك لبدء النشر تلقائيًا.',
'columns' => [
'name' => 'الاسم',
'status' => 'الحالة',
'created' => 'تاريخ الإنشاء',
],
],
'form' => [
'activate_error_fallback' => 'تعذر تفعيل الأتمتة.',
'pause_error_fallback' => 'تعذر إيقاف الأتمتة مؤقتًا.',
'save_error_fallback' => 'تعذر حفظ الأتمتة.',
'save_success' => 'تم حفظ الأتمتة.',
'empty_canvas_title' => 'ابدأ ببناء أتمتتك',
'empty_canvas_description' => 'اسحب عقدة من اللوحة الجانبية للبدء.',
'name_placeholder' => 'أتمتة بلا عنوان',
],
'nodes' => [
'trigger' => 'مُشغّل',
'generate' => 'إنشاء',
'delay' => 'تأخير',
'condition' => 'شرط',
'publish' => 'نشر',
'webhook' => 'Webhook',
'end' => 'إنهاء',
'end_summary' => 'يوقف الأتمتة هنا',
'fetch_rss' => 'جلب RSS',
'http_request' => 'طلب HTTP',
'handles' => [
'items' => 'يحتوي على عناصر',
'no_items' => 'لا عناصر',
],
],
'config' => [
'select_placeholder' => 'اختر…',
'invalid_json' => 'هذا ليس JSON صالحًا بعد.',
'expand_editor' => 'توسيع المحرر',
'minimize_editor' => 'تصغير',
'trigger' => [
'type' => 'نوع المُشغّل',
'types' => [
'schedule' => 'جدولة',
'post_published' => 'عند نشر منشور',
'post_scheduled' => 'عند جدولة منشور',
],
'post_published_hint' => 'يعمل كلما تم نشر أي منشور في مساحة العمل هذه. يصبح المنشور المنشور متاحًا في {{ trigger.post }} للعقد اللاحقة.',
'post_scheduled_hint' => 'يعمل كلما تمت جدولة أي منشور في مساحة العمل هذه. يكون المنشور المجدول متاحًا في {{ trigger.post }}.',
'schedule' => [
'field' => 'فاصل المُشغّل',
'fields' => [
'minutes' => 'دقائق',
'hours' => 'ساعات',
'days' => 'أيام',
'weeks' => 'أسابيع',
'months' => 'أشهر',
],
'minutes_interval' => 'الدقائق بين عمليات التشغيل',
'hours_interval' => 'الساعات بين عمليات التشغيل',
'days_interval' => 'الأيام بين عمليات التشغيل',
'hour' => 'التشغيل عند الساعة',
'minute' => 'التشغيل عند الدقيقة',
'weekdays' => 'التشغيل في أيام الأسبوع',
'day_of_month' => 'يوم من الشهر',
'weekday_names' => [
'sun' => 'أحد',
'mon' => 'اثنين',
'tue' => 'ثلاثاء',
'wed' => 'أربعاء',
'thu' => 'خميس',
'fri' => 'جمعة',
'sat' => 'سبت',
],
'summary' => [
'every_n_minutes' => '{1}يعمل كل دقيقة|{2}يعمل كل دقيقتين|[3,10]يعمل كل :count دقائق|[11,*]يعمل كل :count دقيقة',
'every_n_hours' => '{1}يعمل كل ساعة عند الدقيقة :minute|{2}يعمل كل ساعتين عند الدقيقة :minute|[3,10]يعمل كل :count ساعات عند الدقيقة :minute|[11,*]يعمل كل :count ساعة عند الدقيقة :minute',
'every_n_days' => '{1}يعمل كل يوم عند :time|{2}يعمل كل يومين عند :time|[3,10]يعمل كل :count أيام عند :time|[11,*]يعمل كل :count يوم عند :time',
'weekly' => 'يعمل كل :days عند :time',
'monthly' => 'يعمل في اليوم :day من كل شهر عند :time',
],
],
],
'generate' => [
'social_accounts' => 'الحسابات الاجتماعية',
'social_accounts_empty' => 'لا توجد حسابات اجتماعية متصلة. اربط واحدًا أولًا.',
'target_slide_count' => 'الشرائح المراد إنشاؤها',
'prompt_template' => 'قالب الموجّه',
'prompt_template_hint' => 'اكتب {{ لإدراج بيانات من الخطوات السابقة.',
'image_count' => 'الصور المراد إنشاؤها',
'image_count_hint' => '0 = منشور نصي فقط (بلا صورة). 1 = صورة واحدة. 2 أو أكثر = عرض دائري.',
'use_brand_voice' => 'استخدام صوت العلامة التجارية',
'use_brand_voice_hint' => 'طبّق وصف علامتك التجارية وصوتها. أوقفه للتنسيق الأمين لمصادر الجهات الخارجية (الأخبار، RSS).',
'use_brand_visuals' => 'استخدام العناصر المرئية للعلامة التجارية',
'use_brand_visuals_hint' => 'وجّه صور الذكاء الاصطناعي بألوان علامتك التجارية وهويتها. أوقفه للصور المحايدة المدفوعة بموضوع المنشور فقط.',
'style' => 'النمط',
'account_summary' => '{1}حساب واحد · :format|{2}حسابان · :format|[3,10]:count حسابات · :format|[11,*]:count حساب · :format',
'formats' => [
'single' => 'مفرد',
'carousel' => 'عرض دائري',
],
],
'delay' => [
'duration' => 'المدة',
'unit' => 'الوحدة',
'units' => [
'minutes' => 'دقائق',
'hours' => 'ساعات',
'days' => 'أيام',
],
],
'condition' => [
'field' => 'الحقل',
'operator' => 'العامل',
'operators' => [
'contains' => 'يحتوي على',
'not_contains' => 'لا يحتوي على',
'equals' => 'يساوي',
'not_equals' => 'لا يساوي',
'matches' => 'يطابق (تعبير نمطي)',
'greater_than' => 'أكبر من',
'less_than' => 'أصغر من',
],
'value' => 'القيمة',
],
'publish' => [
'mode' => 'الوضع',
'modes' => [
'now' => 'نشر الآن',
'scheduled' => 'جدولة',
'draft' => 'حفظ كمسودة',
],
'scheduled_offset' => 'الإزاحة عن المُشغّل (بالدقائق)',
'offset_summary' => ':mode · +:offset د',
],
'webhook' => [
'url' => 'الرابط',
'method' => 'الطريقة',
'payload_template' => 'قالب الحمولة (JSON)',
],
'end' => [
'reason' => 'السبب (اختياري)',
'reason_placeholder' => 'مثال: تمت تصفيته بواسطة شرط',
],
'fetch_rss' => [
'feed_url' => 'رابط الخلاصة',
'feed_url_hint' => 'في التشغيل الأول، تُضبط العلامة على "الآن" حتى لا تُغرِق العناصر التاريخية العقد اللاحقة. تشاهد عمليات التشغيل اللاحقة فقط العناصر الأحدث من الاستطلاع السابق.',
'inspect' => 'فحص الخلاصة',
'inspecting' => 'جارٍ الفحص…',
'inspect_hint' => 'اجلب عينة لاكتشاف الحقول المتاحة للاستخدام في العقد اللاحقة.',
'inspect_error' => 'تعذرت قراءة هذه الخلاصة. تحقق من الرابط وحاول مرة أخرى.',
'discovered_fields' => 'الحقول المتاحة',
'discovered_empty' => 'لم يتم العثور على حقول في أحدث عنصر.',
],
'http_request' => [
'url' => 'الرابط',
'method' => 'الطريقة',
'auth_type' => 'المصادقة',
'auth' => [
'none' => 'بلا (عام)',
'bearer' => 'رمز Bearer',
'basic' => 'مصادقة أساسية',
'api_key' => 'ترويسة مفتاح API',
],
'bearer_token' => 'رمز Bearer',
'basic_username' => 'اسم المستخدم',
'basic_password' => 'كلمة المرور',
'api_key_header' => 'اسم الترويسة',
'api_key_value' => 'مفتاح API',
'body_template' => 'قالب النص (JSON)',
'headers' => 'الترويسات',
'header_name' => 'اسم الترويسة',
'header_value' => 'القيمة',
'add_header' => 'إضافة ترويسة',
'polling_section' => 'القائمة وإزالة التكرار (اختياري)',
'polling_hint' => 'عندما تكون الاستجابة قائمة، يشغّل كل عنصر سير العمل بشكل منفصل. يشغّل الكائن الواحد مرة واحدة.',
'items_path' => 'مسار العناصر',
'items_path_hint' => 'اتركه فارغًا إذا كانت الاستجابة مصفوفة بالفعل. استخدم مسارًا منقّطًا (مثل data.items) لمصفوفة متداخلة، أو * لكائن مفهرس بالمعرّف.',
'item_key_path' => 'مسار مفتاح العنصر',
'item_key_path_hint' => 'مسار JSON لمعرّف فريد (مثل id). تُتخطى العناصر التي سبقت رؤيتها، لذا تظل الخلاصة بلا تواريخ تُمرّر الإدخالات الجديدة فقط.',
'item_date_path' => 'مسار تاريخ العنصر',
'item_date_path_hint' => 'مسار JSON للطابع الزمني للعنصر (مثل published_at). يُفضَّل على مسار المفتاح عند توفره. يسجّل الاستطلاع الأول الأساس ولا يمرّر شيئًا، لذا لا تُغرِق الخلاصة الموجودة في اليوم الأول.',
],
],
'delete' => [
'title' => 'حذف الأتمتة',
'description' => 'هل أنت متأكد من رغبتك في حذف هذه الأتمتة؟ ستتم إزالة جميع عمليات التشغيل وعناصر التشغيل أيضًا. لا يمكن التراجع عن هذا الإجراء.',
'confirm' => 'حذف',
'cancel' => 'إلغاء',
],
'flash' => [
'deleted' => 'تم حذف الأتمتة بنجاح!',
],
'errors' => [
'no_active_social_accounts' => 'لا توجد حسابات اجتماعية نشطة مُهيّأة لهذه الأتمتة.',
'must_have_one_trigger' => 'يجب أن تحتوي الأتمتة على عقدة مُشغّل واحدة بالضبط.',
'trigger_must_be_connected' => 'يجب أن تكون عقدة المُشغّل متصلة بعقدة واحدة على الأقل.',
'graph_contains_cycle' => 'يحتوي مخطط الأتمتة على حلقة.',
'only_failed_can_retry' => 'يمكن إعادة محاولة عمليات التشغيل الفاشلة فقط.',
'no_generated_post' => 'لم يتم العثور على منشور مُنشأ في التشغيل.',
'webhook_server_error' => 'خطأ في خادم Webhook.',
'webhook_request_failed' => 'تعذر إكمال طلب Webhook.',
'webhook_missing_url' => 'عقدة Webhook تفتقد إلى رابط.',
'webhook_invalid_payload_json' => 'قالب الحمولة ليس JSON صالحًا.',
'url_not_allowed' => 'رابط الطلب يشير إلى عنوان خاص أو غير قابل للوصول وتم حظره.',
'node_no_longer_exists' => 'العقدة :node_id لم تعد موجودة في الأتمتة.',
'no_trigger_connection' => 'لا توجد عقدة متصلة بعقدة المُشغّل.',
'fetch_rss_missing_url' => 'عقدة جلب RSS تفتقد إلى رابط خلاصة.',
'fetch_rss_request_failed' => 'فشل طلب خلاصة RSS.',
'fetch_rss_malformed' => 'خلاصة RSS مشوّهة.',
'http_missing_url' => 'عقدة طلب HTTP تفتقد إلى رابط.',
'http_request_exception' => 'أطلق طلب HTTP استثناءً.',
'http_request_failed' => 'فشل طلب HTTP.',
'http_items_path_not_array' => 'لم يُفضِ مسار العناصر إلى قائمة.',
],
];

76
lang/ar/billing.php Normal file
View file

@ -0,0 +1,76 @@
<?php
return [
'title' => 'الفوترة',
'past_due_notice' => [
'title' => 'دفعة متأخرة',
'description' => 'حدّث طريقة الدفع للحفاظ على اشتراكك نشطًا.',
'cta' => 'تحديث الدفع',
],
'annual_banner' => [
'title' => 'احصل على شهرين مجانًا',
'description' => 'انتقل إلى الفوترة السنوية وادفع أقل كل شهر — الخطة نفسها، لا شيء آخر يتغير.',
'cta' => 'الترقية إلى السنوية',
],
'subscribe' => [
'billed_monthly' => 'فوترة شهرية',
'billed_yearly' => 'فوترة سنوية',
'prices' => [
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
],
],
'plan' => [
'title' => 'الخطة',
'description' => 'إدارة خطة اشتراكك.',
'label' => 'الخطة',
'workspaces' => '{1}مساحة عمل واحدة|{2}مساحتا عمل|[3,10]:count مساحات عمل|[11,*]:count مساحة عمل',
'per_workspace' => 'لكل مساحة عمل',
'price' => 'السعر',
'month' => 'شهر',
'trial' => 'تجريبي',
'active' => 'نشط',
'past_due' => 'متأخر',
'cancelling' => 'قيد الإلغاء',
'trial_ends' => 'تنتهي الفترة التجريبية',
],
'subscription' => [
'title' => 'الاشتراك',
'description' => 'إدارة طريقة الدفع وتفاصيل الفوترة والاشتراك.',
'payment_method' => 'طريقة الدفع',
'no_payment_method' => 'لا توجد طريقة دفع مسجّلة بعد.',
'expires_on' => 'تنتهي في :month/:year',
'manage_label' => 'الاشتراك',
'manage_stripe' => 'الإدارة عبر Stripe',
],
'invoices' => [
'title' => 'الفواتير',
'description' => 'نزّل فواتيرك السابقة.',
'empty' => 'لم يتم العثور على فواتير',
'paid' => 'مدفوعة',
],
'flash' => [
'plan_changed' => 'أنت الآن على خطة :plan.',
'switched_to_yearly' => 'أنت الآن على الفوترة السنوية.',
'cannot_manage' => 'يمكن لمالك الحساب فقط إدارة الفوترة.',
'credits_exhausted' => 'نفد رصيد الذكاء الاصطناعي — تم استخدام مخصصك الشهري البالغ :limit. رقِّ خطتك أو انتظر حتى الشهر المقبل.',
'subscription_required' => 'يلزم وجود اشتراك نشط لاستخدام ميزات الذكاء الاصطناعي.',
],
'processing' => [
'page_title' => 'جارٍ المعالجة...',
'title' => 'جارٍ معالجة اشتراكك',
'description' => 'يرجى الانتظار بينما نُعِدّ حسابك. لن يستغرق هذا سوى لحظة.',
'success_title' => 'كل شيء جاهز!',
'success_description' => 'اشتراكك نشط. جارٍ إعادة توجيهك إلى مساحات عملك...',
'cancelled_title' => 'تم إلغاء الدفع',
'cancelled_description' => 'تم إلغاء عملية الدفع. لم تُجرَ أي رسوم.',
'retry' => 'إعادة المحاولة',
],
];

39
lang/ar/brands.php Normal file
View file

@ -0,0 +1,39 @@
<?php
return [
'new_brand' => 'علامة تجارية جديدة',
'no_brands_yet' => 'لا توجد علامات تجارية بعد',
'no_brands_description' => 'أنشئ علامات تجارية لتنظيم حساباتك الاجتماعية حسب العميل أو المشروع',
'accounts_count' => ':count حساب',
'create' => [
'title' => 'إنشاء علامة تجارية',
'description' => 'امنح علامتك التجارية اسمًا لتجميع الحسابات الاجتماعية',
'name' => 'اسم العلامة التجارية',
'name_placeholder' => 'مثال: Acme Corp، شخصي',
'submit' => 'إنشاء علامة تجارية',
'submitting' => 'جارٍ الإنشاء...',
],
'edit' => [
'title' => 'تعديل العلامة التجارية',
'description' => 'تحديث اسم هذه العلامة التجارية',
'name' => 'اسم العلامة التجارية',
'name_placeholder' => 'مثال: Acme Corp، شخصي',
'submit' => 'حفظ التغييرات',
'submitting' => 'جارٍ الحفظ...',
],
'delete' => [
'title' => 'حذف العلامة التجارية',
'description' => 'هل أنت متأكد من رغبتك في حذف هذه العلامة التجارية؟ سيتم إلغاء تعيين الحسابات الاجتماعية دون حذفها.',
'confirm' => 'حذف',
'cancel' => 'إلغاء',
],
'flash' => [
'created' => 'تم إنشاء العلامة التجارية بنجاح!',
'updated' => 'تم تحديث العلامة التجارية بنجاح!',
'deleted' => 'تم حذف العلامة التجارية بنجاح!',
],
];

12
lang/ar/calendar.php Normal file
View file

@ -0,0 +1,12 @@
<?php
return [
'title' => 'التقويم',
'today' => 'اليوم',
'day' => 'يوم',
'week' => 'أسبوع',
'month' => 'شهر',
'new_post' => 'منشور جديد',
'no_content' => 'لا يوجد محتوى',
'more' => '+:count المزيد',
];

18
lang/ar/comments.php Normal file
View file

@ -0,0 +1,18 @@
<?php
return [
'placeholder' => 'اكتب تعليقًا...',
'reply_placeholder' => 'اكتب ردًا...',
'reply' => 'رد',
'edit' => 'تعديل',
'delete' => 'حذف',
'edited' => 'تم التعديل',
'save' => 'حفظ',
'cancel' => 'إلغاء',
'send' => 'إرسال',
'replying_to' => 'رد على :name',
'empty' => 'لا توجد تعليقات بعد. ابدأ المحادثة.',
'load_more' => 'تحميل التعليقات الأقدم',
'today' => 'اليوم',
'yesterday' => 'أمس',
];

67
lang/ar/common.php Normal file
View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
return [
'back' => 'رجوع',
'beta' => 'تجريبي',
'confirm_modal' => [
'cannot_be_undone' => 'لا يمكن التراجع عن هذا الإجراء.',
'type' => 'اكتب',
'to_confirm' => 'للتأكيد.',
'copy_to_clipboard' => 'نسخ إلى الحافظة',
'delete_keyword' => 'حذف',
],
'photo_upload' => [
'upload' => 'رفع',
'uploading' => 'جارٍ الرفع...',
'remove' => 'إزالة الصورة',
'hint' => 'موصى به: صورة مربعة، بحد أقصى 2 ميغابايت.',
'crop_title' => 'قص الصورة',
'crop_description' => 'حدّد المنطقة التي تريد الاحتفاظ بها.',
'crop_hint' => 'اسحب للتحريك، أو زاوية لتغيير الحجم.',
'crop_save' => 'حفظ',
'crop_cancel' => 'إلغاء',
'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.',
],
'timezone' => [
'select' => 'اختر المنطقة الزمنية',
'search' => 'البحث عن منطقة زمنية...',
'empty' => 'لم يتم العثور على منطقة زمنية',
],
'date_picker' => [
'select' => 'اختر التاريخ',
],
'date_range_picker' => [
'placeholder' => 'اختر نطاقًا زمنيًا',
'today' => 'اليوم',
'yesterday' => 'أمس',
'last_7_days' => 'آخر 7 أيام',
'last_30_days' => 'آخر 30 يومًا',
'last_3_months' => 'آخر 3 أشهر',
'last_6_months' => 'آخر 6 أشهر',
'last_12_months' => 'آخر 12 شهرًا',
'this_month' => 'هذا الشهر',
'last_month' => 'الشهر الماضي',
'year_to_date' => 'منذ بداية العام',
'last_year' => 'العام الماضي',
],
'cancel' => 'إلغاء',
'clear' => 'مسح',
'close' => 'إغلاق',
'loading_more' => 'جارٍ تحميل المزيد...',
'actions' => [
'copy' => 'نسخ',
'copied' => 'تم النسخ',
'copy_failed' => 'فشل النسخ إلى الحافظة',
],
];

54
lang/ar/labels.php Normal file
View file

@ -0,0 +1,54 @@
<?php
return [
'title' => 'التسميات',
'description' => 'أنشئ تسميات لتنظيم منشوراتك وتصنيفها',
'search' => 'البحث في التسميات...',
'new_label' => 'تسمية جديدة',
'no_labels_yet' => 'لا توجد تسميات بعد',
'no_search_results' => 'لا توجد تسميات مطابقة لبحثك',
'try_different_search' => 'جرّب كلمة مختلفة أو امسح البحث.',
'create_first_label' => 'أنشئ أول تسمية لك',
'table' => [
'name' => 'الاسم',
'created_at' => 'تاريخ الإنشاء',
],
'actions' => [
'edit' => 'تعديل التسمية',
'delete' => 'حذف التسمية',
],
'create' => [
'title' => 'إنشاء تسمية',
'description' => 'امنح تسميتك اسمًا واختر لونًا',
'name' => 'الاسم',
'name_placeholder' => 'أدخل اسم التسمية...',
'color' => 'اللون',
'submit' => 'إنشاء تسمية',
'submitting' => 'جارٍ الإنشاء...',
],
'edit' => [
'title' => 'تعديل التسمية',
'description' => 'تحديث اسم ولون هذه التسمية',
'name' => 'الاسم',
'name_placeholder' => 'أدخل اسم التسمية...',
'color' => 'اللون',
'submit' => 'حفظ التغييرات',
'submitting' => 'جارٍ الحفظ...',
],
'delete' => [
'title' => 'حذف التسمية',
'description' => 'هل أنت متأكد من رغبتك في حذف هذه التسمية؟ لا يمكن التراجع عن هذا الإجراء.',
'confirm' => 'حذف',
'cancel' => 'إلغاء',
],
'flash' => [
'created' => 'تم إنشاء التسمية بنجاح!',
'updated' => 'تم تحديث التسمية بنجاح!',
'deleted' => 'تم حذف التسمية بنجاح!',
],
];

22
lang/ar/mail.php Normal file
View file

@ -0,0 +1,22 @@
<?php
return [
'mentioned' => [
'subject' => 'أشار إليك :name على TryPost',
'title' => 'أشار إليك :name',
'intro' => 'أشار إليك :name في تعليق على منشور.',
'cta' => 'عرض التعليق',
],
'workspace_connections_disconnected' => [
'subject' => '{1} حساب واحد يحتاج إلى إعادة الربط في :workspace|{2} حسابان يحتاجان إلى إعادة الربط في :workspace|[3,10] :count حسابات تحتاج إلى إعادة الربط في :workspace|[11,*] :count حسابًا يحتاج إلى إعادة الربط في :workspace',
'title' => 'حسابات تحتاج إلى إعادة الربط',
'intro' => 'تم فصل الحسابات الاجتماعية التالية في مساحة العمل <strong>:workspace</strong> وتحتاج إلى إعادة الربط:',
'reasons_title' => 'قد يكون هذا قد حدث بسبب:',
'reason_expired' => 'انتهاء صلاحية رموز الوصول',
'reason_revoked' => 'قيامك بإلغاء وصول TryPost على المنصة',
'reason_changed' => 'قيام المنصة بتغيير متطلبات المصادقة الخاصة بها',
'reconnect_cta' => 'يرجى إعادة ربط هذه الحسابات لمواصلة جدولة المنشورات ونشرها.',
'button' => 'إعادة ربط الحسابات',
],
];

18
lang/ar/notifications.php Normal file
View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'post_ready' => [
'title' => 'منشورك جاهز',
'body' => 'أنهى الذكاء الاصطناعي عمله للتو. انقر للمراجعة والنشر.',
],
'account_disconnected' => [
'title' => 'تم فصل حساب :platform',
'body' => 'يحتاج :account إلى إعادة الربط',
],
'account_token_expired' => [
'title' => 'يحتاج حساب :platform إلى إعادة الربط',
'body' => 'انتهت جلسة :account — يرجى إعادة الربط لمواصلة النشر',
],
];

41
lang/ar/onboarding.php Normal file
View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
return [
'title' => 'مرحبًا بك في TryPost',
'description' => 'أخبرنا بما يصفك أنت أو نشاطك التجاري على أفضل وجه حتى نتمكن من تخصيص تجربتك.',
'continue' => 'متابعة',
'personas' => [
'creator' => 'صانع محتوى',
'freelancer' => 'مستقل',
'developer' => 'مطوّر',
'startup' => 'شركة ناشئة',
'agency' => 'وكالة',
'small_business' => 'نشاط تجاري صغير',
'marketer' => 'مسوّق',
'online_store' => 'متجر إلكتروني',
'other' => 'أخرى',
],
'goals_title' => 'ما هدفك من استخدام TryPost؟',
'goals_description' => 'اختر كل ما يناسبك وسنقوم بإعداد TryPost من أجلك.',
'goals' => [
'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة',
'ai_content' => 'إنشاء منشورات أسرع بالذكاء الاصطناعي',
'plan_calendar' => 'التخطيط لمنشوراتي على التقويم',
'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية',
'grow_audience' => 'تنمية جمهوري وزيادة التفاعل',
'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات',
'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء',
'team_collaboration' => 'العمل مع فريقي',
'automate_api' => 'أتمتة النشر عبر الواجهة البرمجية أو MCP أو الكود',
'track_performance' => 'معرفة أداء منشوراتي',
'just_exploring' => 'مجرد استكشاف في الوقت الحالي',
'other' => 'شيء آخر',
],
'connect' => [
'title' => 'اربط شبكتك الأولى',
'description' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل لبدء الجدولة. يمكنك إضافة المزيد في أي وقت.',
'must_connect' => 'اربط شبكة واحدة على الأقل للمتابعة.',
],
];

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