2026-01-18 16:59:43 +00:00
|
|
|
<?php
|
|
|
|
|
|
refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
2026-03-30 03:20:43 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-18 16:59:43 +00:00
|
|
|
namespace App\Http\Controllers\Auth;
|
|
|
|
|
|
|
|
|
|
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
|
|
|
|
use App\Enums\SocialAccount\Status;
|
|
|
|
|
use App\Models\Workspace;
|
|
|
|
|
use Illuminate\Http\RedirectResponse;
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
|
use Illuminate\View\View;
|
|
|
|
|
use Inertia\Inertia;
|
|
|
|
|
use Inertia\Response;
|
|
|
|
|
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
|
|
|
|
|
|
|
|
|
class MastodonController extends SocialController
|
|
|
|
|
{
|
|
|
|
|
protected SocialPlatform $platform = SocialPlatform::Mastodon;
|
|
|
|
|
|
|
|
|
|
private const SCOPES = 'read:accounts write:statuses write:media';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Show form to enter Mastodon instance URL
|
|
|
|
|
*/
|
|
|
|
|
public function connect(Request $request): Response|RedirectResponse
|
|
|
|
|
{
|
|
|
|
|
$this->ensurePlatformEnabled();
|
|
|
|
|
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
2026-03-29 22:24:28 +00:00
|
|
|
return redirect()->route('app.workspaces.create');
|
2026-01-18 16:59:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->authorize('manageAccounts', $workspace);
|
2026-04-14 21:44:47 +00:00
|
|
|
$this->ensureSocialAccountLimit($workspace);
|
2026-01-18 16:59:43 +00:00
|
|
|
|
|
|
|
|
return Inertia::render('accounts/MastodonConnect', [
|
|
|
|
|
'errors' => session('errors')?->getBag('default')?->toArray() ?? [],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register app on instance and redirect to OAuth
|
|
|
|
|
*/
|
|
|
|
|
public function authorizeInstance(Request $request): SymfonyResponse|RedirectResponse
|
|
|
|
|
{
|
|
|
|
|
$this->ensurePlatformEnabled();
|
|
|
|
|
|
|
|
|
|
$request->validate([
|
|
|
|
|
'instance' => 'required|url',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
2026-03-29 22:24:28 +00:00
|
|
|
return redirect()->route('app.workspaces.create');
|
2026-01-18 16:59:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->authorize('manageAccounts', $workspace);
|
|
|
|
|
|
|
|
|
|
$instance = rtrim($request->instance, '/');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Register app on the instance
|
|
|
|
|
$appResponse = Http::post("{$instance}/api/v1/apps", [
|
|
|
|
|
'client_name' => config('app.name'),
|
2026-03-29 22:24:28 +00:00
|
|
|
'redirect_uris' => route('app.social.mastodon.callback'),
|
2026-01-18 16:59:43 +00:00
|
|
|
'scopes' => self::SCOPES,
|
|
|
|
|
'website' => config('app.url'),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($appResponse->failed()) {
|
|
|
|
|
Log::error('Mastodon app registration failed', [
|
|
|
|
|
'instance' => $instance,
|
|
|
|
|
'status' => $appResponse->status(),
|
|
|
|
|
'body' => $appResponse->body(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return back()->withErrors(['instance' => 'Could not connect to this Mastodon instance.']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$app = $appResponse->json();
|
|
|
|
|
|
|
|
|
|
// Store in session for callback
|
|
|
|
|
$state = bin2hex(random_bytes(16));
|
|
|
|
|
session([
|
|
|
|
|
'mastodon_instance' => $instance,
|
|
|
|
|
'mastodon_client_id' => $app['client_id'],
|
|
|
|
|
'mastodon_client_secret' => $app['client_secret'],
|
|
|
|
|
'mastodon_oauth_state' => $state,
|
|
|
|
|
'social_connect_workspace' => $workspace->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Redirect to OAuth
|
|
|
|
|
$params = http_build_query([
|
|
|
|
|
'client_id' => $app['client_id'],
|
|
|
|
|
'response_type' => 'code',
|
2026-03-29 22:24:28 +00:00
|
|
|
'redirect_uri' => route('app.social.mastodon.callback'),
|
2026-01-18 16:59:43 +00:00
|
|
|
'scope' => self::SCOPES,
|
|
|
|
|
'state' => $state,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return Inertia::location("{$instance}/oauth/authorize?{$params}");
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
Log::error('Mastodon connection error', [
|
|
|
|
|
'instance' => $instance,
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return back()->withErrors(['instance' => 'Error connecting to Mastodon instance.']);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle OAuth callback
|
|
|
|
|
*/
|
|
|
|
|
public function callback(Request $request): View
|
|
|
|
|
{
|
|
|
|
|
$workspaceId = session('social_connect_workspace');
|
|
|
|
|
$savedState = session('mastodon_oauth_state');
|
|
|
|
|
$instance = session('mastodon_instance');
|
|
|
|
|
$clientId = session('mastodon_client_id');
|
|
|
|
|
$clientSecret = session('mastodon_client_secret');
|
|
|
|
|
|
|
|
|
|
if (! $workspaceId || ! $instance) {
|
|
|
|
|
$this->clearMastodonSession();
|
|
|
|
|
|
|
|
|
|
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($request->state !== $savedState) {
|
|
|
|
|
$this->clearMastodonSession();
|
|
|
|
|
|
|
|
|
|
return $this->popupCallback(false, 'Invalid state. Please try again.', $this->platform->value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$workspace = Workspace::find($workspaceId);
|
|
|
|
|
|
|
|
|
|
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
|
|
|
|
$this->clearMastodonSession();
|
|
|
|
|
|
|
|
|
|
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Exchange code for token
|
|
|
|
|
$tokenResponse = Http::asForm()->post("{$instance}/oauth/token", [
|
|
|
|
|
'grant_type' => 'authorization_code',
|
|
|
|
|
'client_id' => $clientId,
|
|
|
|
|
'client_secret' => $clientSecret,
|
2026-03-29 22:24:28 +00:00
|
|
|
'redirect_uri' => route('app.social.mastodon.callback'),
|
2026-01-18 16:59:43 +00:00
|
|
|
'code' => $request->code,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($tokenResponse->failed()) {
|
|
|
|
|
Log::error('Mastodon token exchange failed', [
|
|
|
|
|
'status' => $tokenResponse->status(),
|
|
|
|
|
'body' => $tokenResponse->body(),
|
|
|
|
|
]);
|
|
|
|
|
$this->clearMastodonSession();
|
|
|
|
|
|
|
|
|
|
return $this->popupCallback(false, 'Failed to authenticate.', $this->platform->value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$tokenData = $tokenResponse->json();
|
|
|
|
|
$accessToken = $tokenData['access_token'];
|
|
|
|
|
|
|
|
|
|
// Get user profile
|
|
|
|
|
$profileResponse = Http::withToken($accessToken)
|
|
|
|
|
->get("{$instance}/api/v1/accounts/verify_credentials");
|
|
|
|
|
|
|
|
|
|
if ($profileResponse->failed()) {
|
|
|
|
|
$this->clearMastodonSession();
|
|
|
|
|
|
|
|
|
|
return $this->popupCallback(false, 'Failed to get profile.', $this->platform->value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$profile = $profileResponse->json();
|
|
|
|
|
|
feat: social account toggle action, API, MCP + full test coverage
- Extract ToggleSocialAccount action from SocialController
- Add API endpoints: GET /social-accounts, PUT /social-accounts/{id}/toggle
- Add MCP tools: ListSocialAccountsTool, ToggleSocialAccountTool
- Fix all MCP tools: findOrFail → find + Response::error for graceful errors
- Fix MCP tools using $request->validated() without validate() call
- Fix return types to Response|ResponseFactory for error paths
- Add SocialAccountResource is_active/status fields (no tokens exposed)
- Add 43 MCP tests covering all 18 tools (CRUD, validation, cross-workspace)
- Add API response structure tests for posts, hashtags, labels, workspace
- Add API validation tests for post create/update, api-key expiry, label color
- Add API cross-workspace delete tests for hashtags and labels
- Add app validation tests for hashtag/label update, invite fields, password
- Add auth required tests for notifications, profile delete, api-keys index
- Add media reorder validation tests
2026-03-31 04:42:39 +00:00
|
|
|
$avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null;
|
2026-01-18 16:59:43 +00:00
|
|
|
|
2026-04-15 12:46:18 +00:00
|
|
|
$workspace->socialAccounts()->updateOrCreate(
|
|
|
|
|
[
|
|
|
|
|
'platform' => $this->platform->value,
|
|
|
|
|
'platform_user_id' => data_get($profile, 'id'),
|
2026-01-18 16:59:43 +00:00
|
|
|
],
|
2026-04-15 12:46:18 +00:00
|
|
|
[
|
|
|
|
|
'username' => data_get($profile, 'acct'),
|
|
|
|
|
'display_name' => data_get($profile, 'display_name') ?: data_get($profile, 'username'),
|
|
|
|
|
'avatar_url' => $avatarPath,
|
|
|
|
|
'access_token' => $accessToken,
|
|
|
|
|
'refresh_token' => null,
|
|
|
|
|
'token_expires_at' => null,
|
|
|
|
|
'status' => Status::Connected,
|
|
|
|
|
'error_message' => null,
|
|
|
|
|
'disconnected_at' => null,
|
|
|
|
|
'meta' => [
|
|
|
|
|
'instance' => $instance,
|
|
|
|
|
'client_id' => $clientId,
|
|
|
|
|
'client_secret' => $clientSecret,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
);
|
2026-01-18 16:59:43 +00:00
|
|
|
|
|
|
|
|
$this->clearMastodonSession();
|
|
|
|
|
|
|
|
|
|
return $this->popupCallback(true, 'Mastodon account connected!', $this->platform->value);
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
Log::error('Mastodon callback error', [
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
'trace' => $e->getTraceAsString(),
|
|
|
|
|
]);
|
|
|
|
|
$this->clearMastodonSession();
|
|
|
|
|
|
|
|
|
|
return $this->popupCallback(false, 'Error connecting account.', $this->platform->value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function clearMastodonSession(): void
|
|
|
|
|
{
|
|
|
|
|
session()->forget([
|
|
|
|
|
'mastodon_instance',
|
|
|
|
|
'mastodon_client_id',
|
|
|
|
|
'mastodon_client_secret',
|
|
|
|
|
'mastodon_oauth_state',
|
|
|
|
|
'social_connect_workspace',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|