2026-03-29 22:24:28 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\App;
|
|
|
|
|
|
refactor: organize middleware/requests into App/ subdirs, add Resources, fix auth routes
- Move middleware to App/ subdir (HandleInertiaRequests, HandleAppearance,
EnsureSubscribed, EnsureUserSetupIsComplete) matching Sendkit pattern
- Move all Form Requests into organized subdirs (App/Post, App/Workspace,
App/Media, App/Invite, App/Settings, App/Auth)
- Create AuthUserResource and AuthWorkspaceResource for HandleInertiaRequests
shared data (role inside currentWorkspace, matching Sendkit pattern)
- Split auth.php into 3 route groups (no middleware, guest, auth) matching
Sendkit pattern exactly
- Fix UserFactory to include all nullable attributes (current_workspace_id,
stripe_id, pm_type, pm_last_four, trial_ends_at)
- Fix SocialAccountResource (display_name not name)
- Update frontend for new auth prop structure
- 702 tests passing (2 pre-existing Mastodon failures)
2026-03-30 00:13:30 +00:00
|
|
|
use App\Http\Requests\App\Media\StoreChunkedMediaRequest;
|
|
|
|
|
use App\Http\Requests\App\Media\StoreMediaRequest;
|
2026-03-29 22:24:28 +00:00
|
|
|
use App\Models\Media;
|
2026-03-30 17:58:25 +00:00
|
|
|
use App\Models\Post;
|
|
|
|
|
use App\Models\PostPlatform;
|
2026-03-29 22:24:28 +00:00
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
|
|
|
|
|
class MediaController extends Controller
|
|
|
|
|
{
|
|
|
|
|
public function store(StoreMediaRequest $request): JsonResponse
|
|
|
|
|
{
|
|
|
|
|
$model = $this->resolveModel($request->input('model'), $request->input('model_id'));
|
2026-03-30 17:58:25 +00:00
|
|
|
$this->authorizeModelOwnership($model, $request);
|
2026-03-29 22:24:28 +00:00
|
|
|
$collection = $request->input('collection', 'default');
|
|
|
|
|
|
|
|
|
|
$media = $model->addMedia(
|
|
|
|
|
$request->file('media'),
|
|
|
|
|
$collection
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'id' => $media->id,
|
|
|
|
|
'group_id' => $media->group_id,
|
|
|
|
|
'url' => $media->url,
|
|
|
|
|
'type' => $media->type->value,
|
|
|
|
|
'original_filename' => $media->original_filename,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function storeChunked(StoreChunkedMediaRequest $request): JsonResponse
|
|
|
|
|
{
|
|
|
|
|
$tempFile = $this->chunkTempPath($request->chunkIdentifier());
|
|
|
|
|
|
|
|
|
|
$this->appendChunk($tempFile, $request->getContent(), $request->isFirstChunk());
|
|
|
|
|
|
|
|
|
|
if (! $request->isLastChunk()) {
|
|
|
|
|
return response()->json([
|
|
|
|
|
'done' => false,
|
|
|
|
|
'progress' => $request->progress(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$model = $this->resolveModel($request->input('model'), $request->input('model_id'));
|
2026-03-30 17:58:25 +00:00
|
|
|
$this->authorizeModelOwnership($model, $request);
|
2026-03-29 22:24:28 +00:00
|
|
|
|
|
|
|
|
$media = $model->addMediaFromPath(
|
|
|
|
|
$tempFile,
|
|
|
|
|
$request->input('file_name'),
|
|
|
|
|
$request->input('collection'),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
@unlink($tempFile);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'done' => true,
|
|
|
|
|
'id' => $media->id,
|
|
|
|
|
'group_id' => $media->group_id,
|
|
|
|
|
'url' => $media->url,
|
|
|
|
|
'type' => $media->type->value,
|
|
|
|
|
'original_filename' => $media->original_filename,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 17:58:25 +00:00
|
|
|
public function destroy(string $modelId, Media $media, Request $request): JsonResponse
|
2026-03-29 22:24:28 +00:00
|
|
|
{
|
|
|
|
|
if ($media->mediable_id !== $modelId) {
|
|
|
|
|
abort(403);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 17:58:25 +00:00
|
|
|
$model = $media->mediable;
|
|
|
|
|
$this->authorizeModelOwnership($model, $request);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$media->delete();
|
|
|
|
|
|
|
|
|
|
return response()->json(['success' => true]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function reorder(Request $request): JsonResponse
|
|
|
|
|
{
|
|
|
|
|
$request->validate([
|
|
|
|
|
'media' => 'required|array',
|
|
|
|
|
'media.*.id' => 'required|exists:medias,id',
|
|
|
|
|
'media.*.order' => 'required|integer|min:0',
|
|
|
|
|
]);
|
|
|
|
|
|
feat: PostPlatform enum, failure email, DB indexes, rate limiting, tests
Publishing improvements:
- Create PostPlatformStatus enum (Pending, Publishing, Published, Failed)
- Update PostPlatform model, jobs, factories to use enum
- Add PostPublishFailed email notification when post fails to publish
- Maizzle template + blade for failure email with platform details
- PublishPost job: add $tries=3, $backoff=30, failed() method
- Fix broadcast event to serialize enum status value
Security:
- Add rate limiting (throttle:6,1) on social connect endpoints
- Fix MediaController::reorder IDOR vulnerability
- Fix Connect.vue broken import (storeStep2 -> storeConnect)
- Fix UpdatePost data_get() consistency
Database:
- Add composite index on post_platforms (post_id, enabled)
- Add index on post_platforms (social_account_id)
Tests:
- Add 3 tests for profile photo upload/delete
- Add 2 tests for media reorder (including IDOR check)
- Fix publish tests for PostPlatformStatus enum
- Add Mail::fake() to publish tests
Cleanup:
- Remove unused AppHeader.vue and AppHeaderLayout.vue
- Remove dead BillingController methods
All 733 tests passing.
2026-03-30 19:11:38 +00:00
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
$mediaIds = collect($request->input('media'))->pluck('id');
|
|
|
|
|
|
|
|
|
|
// Verify all media belongs to the current workspace
|
|
|
|
|
$ownedCount = Media::whereIn('id', $mediaIds)
|
|
|
|
|
->whereHasMorph('mediable', [PostPlatform::class], fn ($query) => $query->whereHas('post', fn ($q) => $q->where('workspace_id', $workspace->id))
|
|
|
|
|
)->count();
|
|
|
|
|
|
|
|
|
|
if ($ownedCount !== $mediaIds->count()) {
|
|
|
|
|
abort(403);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
foreach ($request->input('media') as $item) {
|
feat: PostPlatform enum, failure email, DB indexes, rate limiting, tests
Publishing improvements:
- Create PostPlatformStatus enum (Pending, Publishing, Published, Failed)
- Update PostPlatform model, jobs, factories to use enum
- Add PostPublishFailed email notification when post fails to publish
- Maizzle template + blade for failure email with platform details
- PublishPost job: add $tries=3, $backoff=30, failed() method
- Fix broadcast event to serialize enum status value
Security:
- Add rate limiting (throttle:6,1) on social connect endpoints
- Fix MediaController::reorder IDOR vulnerability
- Fix Connect.vue broken import (storeStep2 -> storeConnect)
- Fix UpdatePost data_get() consistency
Database:
- Add composite index on post_platforms (post_id, enabled)
- Add index on post_platforms (social_account_id)
Tests:
- Add 3 tests for profile photo upload/delete
- Add 2 tests for media reorder (including IDOR check)
- Fix publish tests for PostPlatformStatus enum
- Add Mail::fake() to publish tests
Cleanup:
- Remove unused AppHeader.vue and AppHeaderLayout.vue
- Remove dead BillingController methods
All 733 tests passing.
2026-03-30 19:11:38 +00:00
|
|
|
Media::where('id', data_get($item, 'id'))->update(['order' => data_get($item, 'order')]);
|
2026-03-29 22:24:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response()->json(['success' => true]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function duplicate(Media $media, Request $request): JsonResponse
|
|
|
|
|
{
|
2026-03-30 17:58:25 +00:00
|
|
|
$sourceModel = $media->mediable;
|
|
|
|
|
$this->authorizeModelOwnership($sourceModel, $request);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$targets = $request->input('targets', []);
|
|
|
|
|
$duplicates = [];
|
|
|
|
|
|
|
|
|
|
foreach ($targets as $target) {
|
|
|
|
|
$model = $this->resolveModel($target['model'], $target['model_id']);
|
2026-03-30 17:58:25 +00:00
|
|
|
$this->authorizeModelOwnership($model, $request);
|
2026-03-29 22:24:28 +00:00
|
|
|
$collection = $target['collection'] ?? $media->collection;
|
|
|
|
|
|
|
|
|
|
$duplicate = $model->media()->create([
|
|
|
|
|
'group_id' => $media->group_id,
|
|
|
|
|
'collection' => $collection,
|
|
|
|
|
'type' => $media->type,
|
|
|
|
|
'path' => $media->path,
|
|
|
|
|
'original_filename' => $media->original_filename,
|
|
|
|
|
'mime_type' => $media->mime_type,
|
|
|
|
|
'size' => $media->size,
|
|
|
|
|
'order' => $media->order,
|
|
|
|
|
'meta' => $media->meta,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$duplicates[] = [
|
|
|
|
|
'id' => $duplicate->id,
|
|
|
|
|
'group_id' => $duplicate->group_id,
|
|
|
|
|
'mediable_id' => $duplicate->mediable_id,
|
|
|
|
|
'mediable_type' => $duplicate->mediable_type,
|
|
|
|
|
'url' => $duplicate->url,
|
|
|
|
|
'type' => $duplicate->type->value,
|
|
|
|
|
'original_filename' => $duplicate->original_filename,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response()->json($duplicates);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 17:58:25 +00:00
|
|
|
private function authorizeModelOwnership(Model $model, Request $request): void
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if ($model instanceof PostPlatform) {
|
|
|
|
|
if ($model->post->workspace_id !== $workspace->id) {
|
|
|
|
|
abort(403);
|
|
|
|
|
}
|
|
|
|
|
} elseif ($model instanceof Post) {
|
|
|
|
|
if ($model->workspace_id !== $workspace->id) {
|
|
|
|
|
abort(403);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
private function resolveModel(string $alias, string $id): Model
|
|
|
|
|
{
|
|
|
|
|
$modelClass = Relation::getMorphedModel($alias) ?? $alias;
|
|
|
|
|
|
|
|
|
|
return $modelClass::findOrFail($id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function chunkTempPath(string $identifier): string
|
|
|
|
|
{
|
|
|
|
|
return storage_path("app/private/chunks/{$identifier}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function appendChunk(string $path, string $content, bool $isFirst): void
|
|
|
|
|
{
|
|
|
|
|
$directory = dirname($path);
|
|
|
|
|
|
|
|
|
|
if (! is_dir($directory)) {
|
|
|
|
|
mkdir($directory, 0755, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
file_put_contents($path, $content, $isFirst ? 0 : FILE_APPEND);
|
|
|
|
|
}
|
|
|
|
|
}
|