Commit graph

363 commits

Author SHA1 Message Date
Paulo Castellano
e3538df2f0 feat: end-to-end PostHog tracking with reactive group metrics
Wire PostHog identify + dual-group context across the stack so events
land on the right person and account/workspace groups, with counts kept
fresh by Inertia navigations rather than per-domain triggers.

- New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised
  high-level sync — identifies the user and group-identifies their
  account + current workspace using `$account->usage()` so the metrics
  reuse the same source of truth Inertia ships in shared props.
- `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on
  signup instead of calling `PostHogService` inline. Keeps the action
  fast and routes everything through the queue.
- `app/Listeners/StripeEventListener.php`: webhook now captures
  `subscription.created`/`updated`/`cancelled` against the account
  owner profile (with `account` group auto-attached) and re-dispatches
  `SyncUserToPostHog` so plan/has_active_subscription/is_on_trial
  refresh after Stripe state changes.
- `app/Services/PostHogService.php`: `capture()` accepts an optional
  `Account` that auto-attaches `$groups.account`, `account_id`, and
  `plan` properties. Each public method short-circuits when
  `POSTHOG_API_KEY` is unset so self-hosted installs are unaffected.
- `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage
  shape (combined `withCount(['socialAccounts','posts'])` query) so
  posts count is part of the same payload Inertia already ships.
- `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the
  queued PostHog jobs actually drain in production.
- `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls
  it on boot AND on every Inertia navigation, reading the fresh
  `usage` props. This:
  - Refreshes account group counts (workspaces, social accounts,
    posts, members, credits) without per-domain triggers.
  - Resolves the workspace-switch case where `setup()` does not
    re-run but `navigate` fires with the new `auth.currentWorkspace`.
  - Captures the initial `$pageview` so the first page of a session
    is no longer dropped.
- `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on
  logout so a follow-up login on the same browser doesn't keep events
  attributed to the previous user.
- `resources/js/composables/useFeatureAccess.ts`: TS `Usage`
  interface gains `postCount`.
- `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new
  usage shape. Full suite: 1407 passed.

Hierarchy aligned with the domain model: person = User,
group `account` = billing/plan parent, group `workspace` =
collaboration child (carries `account_id` for drill-down).
2026-05-07 09:28:02 -03:00
Paulo Castellano
4241e03753 chore: update .env.example with Anthropic key, AI model configuration, and Stripe billing variables 2026-05-06 19:50:50 -03:00
Paulo Castellano
3953654f89
Merge pull request #19 from trypost-it/feature/site-design-system
feat: design system polish (in-page headers, indies tabs, sidebar collapse removal)
2026-05-06 17:28:58 -03:00
Paulo Castellano
7852ad04d5 ci: enable parallel execution for test suite in GitHub Actions workflow 2026-05-06 17:28:41 -03:00
Paulo Castellano
6a0faf75c4 test: update workspace creation redirects to include openDialog parameter 2026-05-06 17:24:47 -03:00
Paulo Castellano
1d116cfbb2 feat: pass and persist post dates through AI creation wizard and template application flows 2026-05-06 17:21:30 -03:00
Paulo Castellano
3e642ef520 refactor: redesign billing settings + upgrade dialog with indies aesthetic
- Billing settings page restructured around the design system: dropped
  the 280px-label / 1fr-content split for stacked HeadingSmall sections;
  current plan rendered as a hero card with display-font name, price,
  amber sticker tile, and an inline primary "Change plan" CTA; payment
  method consolidated into a single sticker row with a violet card-icon
  tile and the manage CTA on the same line; empty payment-method state
  handled. Invoices keep the sticker-row treatment.
- Upgrade dialog mirrors Subscribe.vue end-to-end: planTones backgrounds
  per slug, ⭐ POPULAR / CURRENT badges absolute-positioned, ink-bordered
  rounded-full CTA pill, sticker check icons, "Everything in {plan}"
  copy, IconInfoCircle tooltip on credits, yearly/monthly toggle pill
  with rotating amber save-months badge. While a request is in flight
  every plan button is disabled and the active one shows IconLoader2
  spinner.
- Account model gains `displayablePaymentMethod()` returning the
  card array used by the UI. Resolves the customer-level default first,
  falls back to the first attached payment method (Stripe Checkout trials
  anchor the card to the subscription rather than the customer, so the
  customer-level lookup returns null even when a card exists).
- i18n: added `billing.subscription.expires_on` and `no_payment_method`
  in en/pt-BR/es.
2026-05-06 17:00:25 -03:00
Paulo Castellano
d83ac0612f feat: implement workspace management and update localization strings across multiple languages 2026-05-06 16:41:27 -03:00
Paulo Castellano
7733d573da refactor: in-page page headers, indies tabs, and post details polish
- Replace global AppHeader slots with in-page headers on Calendar, Edit,
  and Show pages — full-width canvas with the page-specific controls
  living in their own ink-bordered bar.
- Remove sidebar collapse mechanism: drop Cmd+B keyboard shortcut, force
  defaultOpen=true (ignore persisted cookie), make toggleSidebar a no-op
  on desktop. Sidebar is permanent on desktop, sheet behavior preserved
  on mobile.
- Redesign Tabs to match the design system: each TabsTrigger renders as
  a standalone sticker button (border-2 + rounded-md + shadow-xs), with
  amber-200 active state mirroring the "POST" badge from post-templates.
- Show page layout: cap preview Card to max-w-xl, preserve natural
  aspect for single-image posts (object-contain, max-h-[480px]), drop
  redundant stats grid, swap header sides (Back button left, status +
  date right with date first).
- Assets: bump GalleryBrowser search inputs to h-12 for comfortable
  browsing across uploads, Unsplash, and Giphy tabs.
- Fix PostPlatformMetrics loading-forever bug: hoist useHttp out of the
  onMounted callback into setup, and read the response array directly
  (backend returns the array at root, not under .data).
- i18n: rename posts.show.back_to_posts → posts.show.back; remove unused
  posts.show.summary.* keys.
2026-05-06 16:22:55 -03:00
Paulo Castellano
3ae4a3653a refactor: localize analytics metrics and overhaul UI components and dashboard layouts 2026-05-06 15:39:10 -03:00
Paulo Castellano
acdc78f8bd refactor: standardize UI components, modernize localization structure, and remove legacy appearance management middleware. 2026-05-06 14:26:18 -03:00
Paulo Castellano
768760cdda
Merge pull request #18 from trypost-it/feature/datalayer-context
feat: GTM dataLayer context + checkout/purchase events
2026-05-06 10:24:04 -03:00
Paulo Castellano
96e1aa36b6 feat: GTM dataLayer context + checkout/purchase events
Backend exposes account and plan as proper Inertia resources so any
page can read them off shared props:

- AuthAccountResource — id, name, created_at
- AuthPlanResource — id, slug, name, interval (derived from the active
  Cashier subscription's stripe_price)

Both wired into HandleInertiaRequests::share so `auth.account` and
`auth.plan` are available everywhere.

Frontend pushes app + identity context to GTM's dataLayer on every
page load via initializeDataLayer (resources/js/datalayer.ts), and
useTracking gets begin_checkout/purchase wired in the billing flow.

The pushes are no-ops without GTM configured (the array still exists
in memory, nothing reads it). Self-hosted instances without GTM_ID
incur zero error noise. The GTM partials in app.blade.php were
already conditional on `config('services.gtm.id')`.

Processing.vue polls `auth` alongside `subscriptionActive` so
auth.plan.interval is fresh once the Stripe webhook creates the local
Subscription row (it doesn't exist yet at the initial render). The
watch fires only on the false → true transition; an onMounted
fallback redirects users that land on the page with an already-active
subscription without re-firing trackPurchase.
2026-05-06 10:14:17 -03:00
Paulo Castellano
6b8a6d536a
Merge pull request #17 from trypost-it/fix/oauth-connect-from-settings
fix: let authenticated users connect Google/GitHub from Settings
2026-05-05 10:34:17 -03:00
Paulo Castellano
e3acd1bb4b revert: keep one OAuth callback URL per provider
Drops the dedicated /settings/authentication/providers/{provider}/callback
route added in the previous refactor — registering a second callback URL
in each OAuth app is more ops cost than the trade is worth.

Back to one callback URL per provider, with a small `Auth::check()`
branch in the auth controllers' callbacks. The check is safe because
the redirects that initiate the round-trip enforce the right
middleware (signup/login is `guest`-only, connect is `auth`-only),
so the auth state at callback time matches the flow's intent.
2026-05-04 19:42:13 -03:00
Paulo Castellano
6b7b12f191 fix: use real anchor for connect button so OAuth redirect leaves the SPA 2026-05-04 19:33:41 -03:00
Paulo Castellano
79f800f858 refactor: dedicated callback URL for connect-from-settings flow
Splits the OAuth connect flow off entirely from signup/login so each
route has a single responsibility.

- routes/auth.php returns to its original state — redirect + callback
  both back inside the `guest` group.
- routes/app.php gains a paired callback route at
  /settings/authentication/providers/{provider}/callback.
- AuthenticationController::connectProvider /
  connectProviderCallback override Socialite's redirectUrl so the
  round-trip stays on the connect-flow URL. The Auth::check() branch
  in the auth controllers is gone.

The OAuth apps in Google Cloud and GitHub Developer Settings need the
new callback URL registered alongside the existing one — documented in
.env.example.
2026-05-04 19:30:16 -03:00
Paulo Castellano
d89f6ceede fix: let authenticated users connect Google/GitHub from Settings
The Connect button on /settings/authentication pointed at the
auth.{provider}.redirect routes that live behind `guest` middleware,
so authenticated users were bounced to /app/home before reaching
Socialite. The OAuth callback also needed to handle two flows
(signup/login vs link to current user) but had no branch for the
second case — meaning a different-email GitHub account would have
been registered as a new user, logging the original session out.

Splits the flows by intent:

- New `app.authentication.connect-provider` route in the auth group,
  handled by the settings controller (where it sits next to
  disconnect-provider). Replaces the OAuth signup link as the
  Connect button's target.
- Auth callbacks moved out of the guest group (still one URL per
  provider, since OAuth apps only register one) and gain a single
  Auth::check() branch that calls connectToCurrentUser().
- connectToCurrentUser() rejects if the provider id already belongs
  to a different user; otherwise sets it on the current user and
  redirects back to settings with a flash message.
2026-05-04 19:22:09 -03:00
Paulo Castellano
c543bec6af
Merge pull request #16 from trypost-it/feature/capture-signup-utms
feat: capture signup UTMs/IP and add GitHub OAuth login
2026-05-04 19:06:50 -03:00
Paulo Castellano
1bf0bbc9ef
Merge branch 'main' into feature/capture-signup-utms 2026-05-04 19:02:49 -03:00
Paulo Castellano
ffb2f673ec docs: document GitHub OAuth env vars in .env.example 2026-05-04 18:53:21 -03:00
Paulo Castellano
0a72a2d547 fix: use GitHub SVG mark instead of Tabler icon for brand consistency 2026-05-04 18:51:06 -03:00
Paulo Castellano
7dc954df78 fix: tighten gap between social login buttons 2026-05-04 18:47:54 -03:00
Paulo Castellano
2e9e0f716b feat: capture signup UTMs/IP and add GitHub OAuth login
Persists marketing attribution and registration metadata for new users
across the three signup paths (email, Google, GitHub):

- 5 utm_* columns + registration_ip on the users table
- PreservesUtmParameters trait stores incoming utm_* query params on
  the register/redirect GET, retrieves them on the POST/callback —
  surviving the OAuth round-trip via session
- request()->ip() captured at the controller layer

Adds GitHub as a second OAuth provider:

- GitHubController mirroring the Google one (now renamed from
  SocialLoginController for symmetry)
- Settings → Authentication can connect/disconnect GitHub like Google
- Single SocialLogin.vue component replaces the per-provider buttons
  on Login/Register, rendering each enabled provider plus a single
  "or continue with" divider

UserFactory gains defaults for the new nullable columns so model
strict-mode access in tests doesn't trip.
2026-05-04 18:42:25 -03:00
Paulo Castellano
d92ee8d0e0
Merge pull request #15 from trypost-it/feature/api-media-multipart-upload
feat: add multipart media upload endpoint and split URL flow
2026-05-04 18:08:03 -03:00
Paulo Castellano
24a8786cba feat: add multipart media upload endpoint and split URL flow
Adds POST /api/posts/{post}/media for direct file (multipart) upload
and renames the existing URL-based flow to /api/posts/{post}/media/from-url
so the path matches HTTP semantics (POST <resource>/media expects a file
body, not JSON URLs).

The multipart action validates type against the post's enabled platforms
(image rejected on TikTok-only posts), enforces per-type size caps, and
reuses Workspace::addMedia + Post::appendMedia. URL-based attaching is
unchanged behaviorally — only the route name and controller method are
renamed for symmetry. The MCP AttachMediaFromUrlTool was already named
correctly and needs no changes; binary upload via MCP is a host-protocol
limitation that no MCP server (including Postiz) supports.
2026-05-04 18:00:03 -03:00
Paulo Castellano
99179b65af
Merge pull request #14 from trypost-it/feature/mcp-rest-create-publish-posts
feat: complete create + publish post flow via MCP and REST API
2026-05-04 15:47:14 -03:00
Paulo Castellano
229862fcae chore: update project license from FSL to AGPL-3.0-only 2026-05-04 15:46:56 -03:00
Paulo Castellano
9cf16ca0c4 refactor: use mime_content_type to reliably detect MIME from file contents instead of relying on unreliable Content-Type headers 2026-05-04 15:22:11 -03:00
Paulo Castellano
335180d7a1 refactor: move post-state concerns out of MediaAttacher
MediaAttacher had grown into a 180-line class with four private
methods doing different things — download, validation, storage,
and post-state mutation. Two of those (lock-and-merge into the
JSON media column, intersection of allowed media types per
platform) are post concerns, not network concerns.

Move them onto the Post model:

  Post::allowedMediaTypes(): array<Type>
      The intersection of media types accepted by every enabled
      platform. Used to be inlined as MediaAttacher::allowedMediaTypesFor.

  Post::appendMedia(array $items): void
      Lock-then-merge into the post's media[] JSON column so
      concurrent writers don't overwrite each other. Used to be
      MediaAttacher::mergeIntoPostMedia.

MediaAttacher now reads cleanly:

  attachFromUrls   — orchestrate (loop URLs, batch the appendMedia)
  attachOne        — download → validate type/size → handoff
  download         — Http::sink + progress abort, returns DTO

Drops to ~120 lines with no nested concerns. The streaming progress
abort uses the same throw-RuntimeException pattern, but now
contained in download() instead of leaking into the orchestrator.
2026-05-04 15:17:48 -03:00
Paulo Castellano
ffdedfe29d feat: keep MOV in video allow-list, drop only WebM
Previous attempt dropped both video/quicktime and video/webm citing
narrow platform support. Re-investigated:

- Modern .mov files (iPhone recordings, screen captures) are ISO BMFF
  containers — the same format MP4 uses. Social platforms decode them
  like MP4 even when PHP's mime_content_type() reports
  'video/quicktime'. Postiz quietly accepts MOV via this same trick:
  their file-type lib detects the magic bytes as 'video/mp4' and
  renames the upload before storage.

- WebM is genuinely incompatible (Matroska + VP8/VP9 stack) — X / IG /
  TikTok / FB / Pinterest / Bluesky / Threads all reject it.

So allow MOV, keep WebM out. Rejecting MOV would force every iPhone
user to transcode before uploading — real UX cost for no gain since
the publishers handle it.
2026-05-04 15:12:32 -03:00
Paulo Castellano
332513e360 refactor: replace regex/preg_match with sscanf + ends_with in chunked upload
Two regex pieces in StoreChunkedAssetRequest, both replaced by more
fitting tools:

1. Parsing 'Content-Range' header — preg_match with capturing groups
   and $matches[1..3] indexing replaced by sscanf, which is
   purpose-built for parsing structured strings with a known format:

       $parsed = sscanf($header, 'bytes %d-%d/%d') ?: [];
       $start = $parsed[0] ?? null;

   No regex, no $matches array, no manual int casts (sscanf returns ints
   for %d).

2. Validating filename suffix — regex:/\.(jpe?g|png|gif|webp|mp4)$/i
   replaced by Laravel's built-in 'ends_with' rule, with extensions
   derived from the MediaType enum:

       $allowedSuffixes = collect([Image, Video])
           ->flatMap(fn (MediaType $t) => $t->extensions())
           ->map(fn ($ext) => '.'.$ext)
           ->all();

   For the case-insensitive part, normalize the filename to lowercase
   in prepareForValidation so 'IMG_1234.JPG' matches '.jpg' naturally.

Added MediaType::extensions() — single source of truth for filename
suffixes (jpg/jpeg/png/gif/webp + mp4/mov/webm). Mirrors
allowedMimeTypes() for callers that validate by name instead of MIME.
2026-05-04 14:59:57 -03:00
Paulo Castellano
770266d36b refactor: centralize media size limits in config + drop Document type
Three things in one move:

1. Centralize per-type size caps in config/trypost.php under media.max_size_mb.
   The MediaType enum now reads from there:

       MediaType::Image->maxSizeInMb()    // 10  (env: MEDIA_IMAGE_MAX_SIZE_MB)
       MediaType::Video->maxSizeInMb()    // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB)

   Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers
   don't have to multiply themselves. StoreAssetRequest now uses
   MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived
   from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked
   validation moved to the new StoreChunkedAssetRequest FormRequest
   (also reads from the enum). MediaAttacher uses
   MediaType::Video->maxSizeInBytes() as the streaming-abort threshold
   and enforces the per-type cap after MIME resolution.

2. Drop MediaType::Document. We never accepted PDFs anywhere — the
   StoreAssetRequest mimes list excluded them, the storeChunked
   extension regex excluded them, MediaAttacher excluded them. The only
   places that referenced Document were:
     - Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared
       but unreachable)
     - HasMedia::getMediaType fallback when MIME wasn't image/video/*
   Both now cleaned up. HasMedia::getMediaType throws
   InvalidArgumentException for unsupported MIMEs instead of silently
   returning a fake 'document' type. Platform::LinkedIn now matches
   every other social platform: [Image, Video].

3. Add MediaType::fromMime($mime): ?self — replaces the inline mime →
   type loop that MediaAttacher used to roll. Returns null for
   unsupported MIMEs (caller decides how to react).

Tests:
- MediaTypeTest rewritten for the new shape (no Document, config-driven
  sizes, fromMime + size-helper coverage).
- PlatformTest no longer asserts Document on LinkedIn.
- HasMediaTest replaces the 'detects document type' case with one that
  asserts the throw on unsupported MIMEs. The 'add media from path'
  test now uses real PNG bytes from the fixture.
- AssetControllerTest chunked tests use real PNG bytes and assert 422
  (FormRequest unprocessable) for malformed Content-Range headers,
  matching the new validation layer.
2026-05-04 14:54:47 -03:00
Paulo Castellano
ac361349da refactor: extract attach-media validation into a FormRequest
Project convention is one FormRequest per endpoint
(Api/Post/StorePostRequest, UpdatePostRequest, etc.) — the inline
$request->validate() in attachMedia was the only outlier in this
controller. Extracted to Api/Post/AttachMediaRequest with the same
rules:

    'urls'   => ['required', 'array', 'min:1', 'max:10'],
    'urls.*' => ['url:http,https', 'active_url'],

Controller signature is now AttachMediaRequest $request — Laravel
binds + validates before the action runs, same pattern as store/update.
2026-05-04 14:37:49 -03:00
Paulo Castellano
4892ee75a5 refactor: move URL validation to the request layer with active_url
The MediaAttacher used to roll its own SSRF guard with DNS resolution
and a static fakeUrlSafety() flag for tests. Validating URLs is a
request-layer concern, not a service-layer one. Laravel ships
'active_url' which does the same DNS resolvability check via
dns_get_record — applying it at the FormRequest / MCP validate() level
catches dead URLs upfront with a proper 422 instead of letting the
download silently fail.

- Replace the inline 'urls.*' => ['url:http,https'] rule with
  ['url:http,https', 'active_url'] in both Api/PostController::attachMedia
  and Mcp/Tools/Post/AttachMediaFromUrlTool.
- Drop isUrlSafe(), fakeUrlSafety(), resetUrlSafety(), $skipUrlSafety
  from MediaAttacher. The remaining defenses (Http::sink streaming +
  progress abort at MAX_BYTES, allow_redirects: false, MIME allowlist)
  cover the operational concerns.
- Restore tests/TestCase to the original setUp — no SSRF bypass needed
  anymore because active_url is satisfied by the test hosts.
- Swap synthetic test hosts (cdn.example.com / evil.example.com) for
  example.com / example.org. Both are RFC-reserved AND have stable A
  records, so active_url accepts them while Http::fake() still
  intercepts the actual request.

For SSRF defense beyond 'active_url' (which doesn't block private IPs),
trypost relies on production network egress controls. Open-source
self-hosters who run without a firewall accept the corresponding risk;
that's a deployment concern, not a request validation concern.
2026-05-04 14:35:28 -03:00
Paulo Castellano
bc581f54f2 refactor: read allowed MIMEs from MediaType enum in MediaAttacher
The enum already has `allowedMimeTypes()` — it's the single source of
truth for what each media type accepts (jpeg/png/gif/webp for image,
mp4/quicktime/webm for video). MediaAttacher was duplicating the same
arrays as ALLOWED_IMAGE_MIMES / ALLOWED_VIDEO_MIMES constants, which
meant any change to the supported MIMEs needed two edits.

Drop the constants. `resolveType()` now walks
`[MediaType::Image, MediaType::Video]` and asks each type whether the
MIME is on its allow-list. Document type is intentionally excluded so
PDFs aren't accepted via URL fetch.

Note on size: kept MediaAttacher's own MAX_BYTES = 50 MB (separate from
the enum's per-type caps). URL fetches have different operational
constraints than direct uploads (bandwidth, timeout, unbounded user
input) — a smaller cap here is intentional.

Out of scope but worth a follow-up: `StoreAssetRequest` and
`storeChunked` are also drifted (web only accepts video/mp4 today,
the enum + MediaAttacher accept mov/webm too). They should also read
from the enum eventually.
2026-05-04 14:26:36 -03:00
Paulo Castellano
6ae48f014f fix: persist morph map alias on AI-generated Media rows
Both PostAiCreateController::createMediaItem and
PostTemplateController::createMediaItem were doing:

    $media = new Media([...]);
    $media->mediable_type = Workspace::class;   // ← FQCN literal
    $media->mediable_id = $workspace->id;
    $media->save();

Assigning Workspace::class directly bypasses the morphMap configured in
AppServiceProvider, so rows ended up with mediable_type =
'App\\Models\\Workspace' instead of the alias 'workspace'. Other queries
that pivot through the morphMap (e.g. $workspace->media) lose those
records on hosts where the FQCN doesn't match the alias.

Switch both to the relationship form:

    $media = $workspace->media()->create([...]);

Laravel fills mediable_type via the morphMap, producing 'workspace'.
This is the same pattern AssetController and the HasMedia trait already
use; these two AI controllers were the only outliers (`grep -rn
'mediable_type =' app/` confirms).

Added tests/Unit/MediaPolymorphTest as a regression — asserts the
created row's mediable_type is the alias and that the relationship
resolves back to the workspace.
2026-05-04 14:14:14 -03:00
Paulo Castellano
563f752866 chore: remove unused language files and update project configuration files 2026-05-04 14:04:03 -03:00
Paulo Castellano
c3b8af7189 refactor: collapse media-attach helpers back into MediaAttacher
The previous split (MediaAttacher + MediaDownloader + UrlSafetyGuard
interface + DnsUrlSafetyGuard impl + provider binding) was over-engineered
for one production implementation. Roll it all back into a single
MediaAttacher class and delegate to the existing `Workspace::addMediaFromPath`
helper for storage + Media row creation (the same path the web upload
flow uses).

What's gone:
- app/Services/Post/MediaDownloader.php
- app/Services/Post/UrlSafetyGuard.php (interface)
- app/Services/Post/DnsUrlSafetyGuard.php (impl)
- tests/Unit/Services/Post/DnsUrlSafetyGuardTest.php
- The provider bind in AppServiceProvider

What's still defended:
- Streaming download via Http::sink with a Guzzle progress callback that
  aborts mid-stream once MAX_BYTES is exceeded (no body buffered in PHP).
- Redirects disabled so a 200→302 trick can't pivot to internal hosts.
- IP-literal SSRF guard: rejects loopback / private / link-local /
  reserved ranges via FILTER_FLAG_NO_PRIV_RANGE | NO_RES_RANGE. DNS
  hostnames are accepted — finer SSRF (DNS rebinding etc.) is left to
  network-level egress controls. This is a deliberate simplification:
  the prior DNS resolution defense added a class + interface + provider
  binding for marginal gain when the realistic attack surface is
  hard-coded internal IPs.
- Strict MIME allowlist (no SVG, no PDF, no application/*).
- Lock-then-merge into post.media[] to avoid lost-update races.

Tests bypass the SSRF check via the static `MediaAttacher::fakeUrlSafety()`
called once in tests/TestCase::setUp — same idiom as Mail::fake / Bus::fake.
2026-05-04 14:02:50 -03:00
Paulo Castellano
c871edf144 refactor: split MediaAttacher into orchestrator + downloader + safety guard
The previous MediaAttacher had ~5 responsibilities crammed in one class
(URL validation, HTTP fetch, streaming, MIME check, Storage write,
post.media merge) plus an `if ($app->runningUnitTests()) return true`
inside the SSRF guard — production code that knew about test mode.

Split into three:

- UrlSafetyGuard (interface) + DnsUrlSafetyGuard (default impl). The
  full SSRF check is now isolated and unit-testable in
  tests/Unit/Services/Post/DnsUrlSafetyGuardTest. Tests bind a permissive
  guard in tests/TestCase::setUp so feature tests using Http::fake() with
  synthetic hosts (cdn.example.com) still work — the runningUnitTests()
  check inside production code is gone.

- MediaDownloader: takes a URL, returns a temp file + MIME or null. Uses
  Http::sink with a Guzzle progress callback that throws once maxBytes
  is exceeded, so we abort mid-stream without buffering the body in PHP
  memory (the previous chunk loop was append-to-string which defeated
  the whole point of streaming).

- MediaAttacher: pure orchestrator. Calls MediaDownloader, validates the
  MIME against the post's enabled platforms, persists via Storage::putFileAs,
  appends the Media record under a row lock. Drops from 275 to ~190 lines
  with zero responsibility overlap.

The IO contract (`null` on failure, `['path','mime','bytes']` on success)
keeps MediaAttacher free of the temp-file lifecycle on the happy path —
ownership is documented in MediaDownloader's PHPDoc and the orchestrator
unlinks via try/finally in processOne().

Bind the interface in AppServiceProvider so production resolves
DnsUrlSafetyGuard automatically; tests override it.

7 new unit tests cover the SSRF cases (loopback, RFC1918, link-local,
zero/broadcast, IPv6 loopback/ULA/link-local, plus a public-IP positive
test). The 14 existing AttachMedia feature tests stay green.
2026-05-04 13:45:43 -03:00
Paulo Castellano
a2f98d551c test: add coverage for validation rules across REST + MCP + custom rules
The previous suite asserted happy paths and a couple of basic field
omissions but didn't probe the rules themselves. Adds 26 tests
across 5 files:

REST API (tests/Feature/Api/PostApiTest.php) — 9 new:
- content_type not in the enum
- content_type mismatched with the social account's platform
- label_id from another workspace
- platforms[].id from another post on update (cross-post leak)
- content_type mismatched with the post_platform on update
- status=scheduled requires future scheduled_at
- status=draft works with no scheduled_at
- past scheduled_at on store

MCP create-post-tool (tests/Feature/Mcp/PostToolTest.php) — 5 new:
- inactive social account
- content_type not in the enum
- content_type mismatched with the social account's platform
- label_id from another workspace
- already had: scheduled_at past

MCP update-post-tool (tests/Feature/Mcp/PostPublishToolTest.php) — 2 new:
- platforms[].id from another post (regression for the new
  Rule::exists scoping)
- content_type mismatched with the post_platform

MCP attach-media-from-url-tool (tests/Feature/Mcp/AttachMediaFromUrlToolTest.php) — 3 new:
- non-http(s) scheme (ftp://...)
- malformed url string
- more than 10 URLs per call

Custom rules unit tests — 2 new files:
- ContentTypeMatchesPlatformTest covers happy path,
  cross-platform mismatch, the Instagram + InstagramFacebook
  compatibility bridge, and the no-op cases (missing account_id,
  unknown content_type — those are caught by Rule::in elsewhere).
- ContentTypeMatchesPostPlatformTest covers the equivalent shape
  for the update flow that pivots through post_platform.id.
2026-05-04 13:31:44 -03:00
Paulo Castellano
3b96a9ebdb refactor: move workspace tenancy check on Post into PostPolicy
The same "is this post in the user's current workspace?" check was
duplicated across every Post-related endpoint (5 in Api/PostController
via the ensurePostInCurrentWorkspace helper, 5 in App/PostController
inline). PostPolicy already had a duplicate() method following this
exact pattern — extending it with view/update/delete unifies the
tenancy guard in one place.

- Add view/update/delete to PostPolicy. Each returns
  Response::denyAsNotFound() when the post belongs to a different
  workspace, so we keep the existing 404 behavior (don't leak
  cross-tenant existence) instead of switching to the default 403.

- Update duplicate() to also use denyAsNotFound() for the workspace
  mismatch path. The createPost role check still returns bool/403.

- Replace ensurePostInCurrentWorkspace() calls in Api/PostController
  with $this->authorize('view'|'update'|'delete', $post). Helper deleted.

- Replace inline workspace_id !== $workspace->id checks in
  App/PostController (show/edit/update/destroy/platformMetrics) with
  the same authorize calls. The PostPolicy guard now subsumes both
  the workspace-tenancy check and the role-permission check that was
  previously delegated through Workspace::createPost.
2026-05-04 13:20:52 -03:00
Paulo Castellano
6949ee4ec3 refactor: derive sibling key in content-type rules without regex
Both ContentTypeMatchesPlatform and ContentTypeMatchesPostPlatform
were using preg_replace to swap the leaf segment of a dotted attribute
path ("platforms.0.content_type" -> "platforms.0.social_account_id").
Cleaner with Str::beforeLast: derive the parent path once, then look
up the sibling under it. Same intent, no regex, and the rule no longer
encodes its own attribute name.
2026-05-04 13:06:13 -03:00
Paulo Castellano
c1418c9d21 fix: address PR review findings — publish, REST store, SSRF, race
Code-review surfaced two correctness bugs and a security gap that
needed to land before merging.

- UpdatePost::execute disabled every platform when called without
  a `platforms` key. PublishPostTool relied on that path, so every
  publish-via-MCP queued a job whose handler then found nothing
  enabled to publish to. Wrap the platform toggle in
  `Arr::has($data, 'platforms')` (matches the existing label_ids
  guard a few lines up). Add a regression assertion to
  `PostPublishToolTest::publish post immediate dispatches PublishPost
  job` that the previously-enabled platform stays enabled.

- StorePostRequest declared rules for only `platforms`,
  `scheduled_at`, and `status`. `validated()` then stripped
  `content`, `media`, and `label_ids`, so REST `POST /api/posts`
  silently created empty drafts. Added rules for content / media /
  label_ids (with workspace-scoped `Rule::exists` for labels) and
  dropped the unused `status` field — REST callers transition state
  via `PUT /posts/{id}`. Removed the dead `platforms.*.content`
  rule. Added a feature test that asserts content + media + labels
  roundtrip on create, plus a regression that an `is_active=false`
  social_account is rejected at validation.

- CreatePost::execute now syncs label_ids itself so REST and MCP
  share the behavior. Removed the duplicate sync from CreatePostTool.

- MCP UpdatePostTool didn't scope `platforms.*.id` to the post being
  updated, drifting from the REST UpdatePostRequest which adds
  `Rule::exists('post_platforms','id')->where('post_id', ...)`. Now
  it loads the post first (failing fast with `Post not found.` if
  the workspace check rejects), then uses the same Rule::exists.

- MediaAttacher fetched any URL the caller passed, including
  loopback / link-local / private targets — classic SSRF pivot.
  Now `isPublicHttpUrl` rejects non-http(s) schemes, restricted IP
  ranges, and DNS hostnames whose A/AAAA records resolve into those
  ranges (covers DNS rebinding). Bypassed under
  `app()->runningUnitTests()` so `Http::fake()` keeps working.
  Streaming the response body lets us abort early once we exceed
  MAX_BYTES instead of buffering the full payload first; redirects
  are disabled so a 200→302 trick can't bypass the host check.

- The `media[]` JSON column had a lost-update race in
  `attachFromUrls`: read `$post->media`, mutate in PHP, write back.
  Two concurrent calls clobbered each other. Now wrapped in a
  transaction with `lockForUpdate()`.

- ESLint: `resources/js/actions/**` and `resources/js/routes/**`
  are auto-generated by Wayfinder on every build. Their import
  order matches PHP scan order, not alphabetical, so import/order
  fought eslint-fix forever. Added them to ignores.
2026-05-04 12:16:39 -03:00
Paulo Castellano
bec831fec6 ci: fix tests workflow and clear pre-existing lint errors
- Add `php artisan passport:keys --force` step to tests workflow
  so the OAuth2 server keys exist before tests run. Without them
  every API test failed with `LogicException: Invalid key supplied`
  from `vendor/league/oauth2-server/src/CryptKey.php`.

- Clear 4 pre-existing unused-variable lint errors that were
  blocking this PR's CI (auth/GoogleAuthButton, billing/Subscribe,
  posts/editor/CommentsTab, posts/editor/TikTokSettings).

- `eslint --fix` also reordered imports alphabetically in 11 other
  files — pure cosmetic.
2026-05-04 12:03:44 -03:00
Paulo Castellano
9a26e6d802 feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.

MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.

REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.

Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.

Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.

Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).

Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 08:12:28 -03:00
Paulo Castellano
6f3bdb2caa feat: add duplicate post functionality and migrate LinkedIn analytics to the /rest/ API. 2026-05-03 22:37:51 -03:00
Paulo Castellano
5658ea8c8c feat: update Bluesky to use public AppView and remove Mastodon access token from public status requests 2026-05-03 22:16:19 -03:00
Paulo Castellano
dc75e2b381 refactor: standardize post platform data structure and simplify display logic in PostResource 2026-05-03 22:11:58 -03:00
Paulo Castellano
595510812c refactor: implement lazy token refreshing and persist Mastodon scopes 2026-05-03 21:58:25 -03:00