Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Actions/Postsyncer/PublishVideoAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ public function handle(Video $video, array $options, ?string $runToken = null):
}

if ($group->mediaUrls !== []) {
$mediaIds = $client->uploadFromUrls(
$mediaIds = $client->uploadFilesFromUrls(
$group->workspaceId,
$group->mediaUrls,
$idempotencyKey.':media',
Expand Down
6 changes: 4 additions & 2 deletions app/Jobs/PublishVideoJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ class PublishVideoJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public const TIMEOUT_SECONDS = 180;
// Video media is downloaded and re-uploaded as files (up to ~300 MB per
// video), so match the post job and the worker's 900-second timeout.
public const TIMEOUT_SECONDS = 900;

public const UNIQUE_FOR_SECONDS = 3600;

public const OVERLAP_EXPIRES_AFTER_SECONDS = 240;
public const OVERLAP_EXPIRES_AFTER_SECONDS = 1020;

public int $timeout = self::TIMEOUT_SECONDS;

Expand Down
233 changes: 230 additions & 3 deletions app/Support/Postsyncer/PostsyncerClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@

namespace App\Support\Postsyncer;

use App\Support\LinkResolution\PublicUrlGuard;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Mime\MimeTypes;
use Throwable;

class PostsyncerClient
{
/** PostSyncer's documented limit for POST /media/upload/file. */
public const MAX_FILE_UPLOAD_BYTES = 500 * 1024 * 1024;

private const MAX_DOWNLOAD_REDIRECTS = 5;

public function __construct(
private readonly PostsyncerConfig $config,
private readonly ?PublicUrlGuard $urlGuard = null,
) {}

/**
Expand Down Expand Up @@ -173,6 +183,201 @@ public function uploadFromUrls(int|string $workspaceId, array $urls, ?string $id
return $ids;
}

/**
* Upload media through PostSyncer's multipart file endpoint, one request
* per file, because its URL import stores no videos (2026-10-26). Every
* URL is downloaded first, so a download failure happens before anything
* reaches PostSyncer.
*
* @param list<string> $urls
* @param string|null $idempotencyKey Stable per-operation/group key; each file gets its own suffix.
* @return list<int|string> Media ids in the same order as $urls.
*/
public function uploadFilesFromUrls(int|string $workspaceId, array $urls, ?string $idempotencyKey = null): array
{
$paths = [];

try {
foreach ($urls as $url) {
$path = tempnam(sys_get_temp_dir(), 'cm-media-');
if ($path === false) {
throw $this->safeUploadFailure('Could not create a temporary file for the media download.');
}
$paths[] = $path;
$this->downloadMedia($url, $path);
}

$ids = [];

foreach ($paths as $index => $path) {
$headers = $idempotencyKey === null || trim($idempotencyKey) === ''
? []
: ['Idempotency-Key' => $idempotencyKey.':'.$index];

try {
$response = $this->request('post', '/media/upload/file', [
'workspace_id' => $workspaceId,
], $headers, $path);
} catch (PostsyncerException $exception) {
// A definitive rejection before any file was stored left
// nothing behind in PostSyncer, so a retry cannot duplicate.
if ($ids === [] && $exception->responseReceived && ! $exception->outcomeUnknown) {
throw new PostsyncerException(
$exception->getMessage(),
$exception->getCode(),
$exception,
responseReceived: true,
safeToRetry: true,
);
}

if ($ids !== []) {
// Name the orphaned media so reconciliation can find it.
throw new PostsyncerException(
$exception->getMessage().' Already stored PostSyncer media ids: '.implode(', ', $ids).'.',
$exception->getCode(),
$exception,
$exception->retryable,
$exception->outcomeUnknown,
$exception->responseReceived,
$exception->safeToRetry,
);
}

throw $exception;
}

$id = $this->decodeResponse($response)['media']['id'] ?? null;

if (! $this->hasMediaId($id)) {
throw new PostsyncerException(
'PostSyncer returned a media item without a valid id. '
.'Refusing to publish with invalid media.'
);
}

$ids[] = $id;
}

return $ids;
} finally {
foreach ($paths as $path) {
@unlink($path);
}
}
}

private function downloadMedia(string $url, string $path): void
{
$guard = $this->urlGuard ?? app(PublicUrlGuard::class);
$overCap = false;
$progress = $this->downloadCapProgress($overCap);

for ($hop = 0; ; $hop++) {
$curlOptions = $this->isDriveDownloadUrl($url) && $guard->isSafe($url)
? $guard->curlResolveOptions($url)
: null;

if ($curlOptions === null) {
throw $this->safeUploadFailure('Refusing to download media from a URL that is not a public Google Drive download.');
}

try {
$response = Http::timeout(300)
->sink($path)
->withOptions([
'allow_redirects' => false,
'progress' => $progress,
...$curlOptions,
])
->get($url);
} catch (Throwable $exception) {
throw $overCap
? $this->oversizeFailure()
: $this->safeUploadFailure('Could not download media before uploading it to PostSyncer: '
.$exception->getMessage(), $exception);
}

if (! $response->redirect()) {
break;
}

$location = $response->header('Location');
if ($location === '' || $hop >= self::MAX_DOWNLOAD_REDIRECTS) {
throw $this->safeUploadFailure('Could not download media before uploading it to PostSyncer: too many redirects.');
}

$url = (string) UriResolver::resolve(new Uri($url), new Uri($location));
}

if (! $response->successful()) {
throw $this->safeUploadFailure('Could not download media before uploading it to PostSyncer: HTTP '
.$response->status().'.');
}

$length = $response->header('Content-Length');
if ($length !== '' && (int) $length > static::MAX_FILE_UPLOAD_BYTES) {
throw $this->oversizeFailure();
}

clearstatcache(true, $path);
$size = (int) filesize($path);

if ($size === 0) {
throw $this->safeUploadFailure('The downloaded media file is empty. Refusing to upload it to PostSyncer.');
}

if ($size > static::MAX_FILE_UPLOAD_BYTES) {
throw $this->oversizeFailure();
}

// Drive serves an HTML page instead of the file when sharing is off.
if (str_starts_with((string) MimeTypes::getDefault()->guessMimeType($path), 'text/html')) {
throw $this->safeUploadFailure('The media URL returned an HTML page instead of a file. '
.'Check that the Drive file is shared publicly.');
}
}

/**
* Guzzle's progress option: a truthy return makes its cURL handler abort
* the transfer (Guzzle 8 rejects raw CURLOPT_PROGRESSFUNCTION), as soon
* as Content-Length or the received bytes pass the cap.
*/
private function downloadCapProgress(bool &$overCap): \Closure
{
return function (int $expected, int $downloaded) use (&$overCap): bool {
$overCap = $expected > static::MAX_FILE_UPLOAD_BYTES || $downloaded > static::MAX_FILE_UPLOAD_BYTES;

return $overCap;
};
}

private function isDriveDownloadUrl(string $url): bool
{
$parts = parse_url($url);
$host = strtolower((string) ($parts['host'] ?? ''));

return strtolower((string) ($parts['scheme'] ?? '')) === 'https'
&& (in_array($host, ['drive.usercontent.google.com', 'drive.google.com'], true)
|| str_ends_with($host, '.googleusercontent.com'));
}

private function oversizeFailure(): PostsyncerException
{
return $this->safeUploadFailure(sprintf(
'The media file is over PostSyncer\'s %d MB upload limit.',
static::MAX_FILE_UPLOAD_BYTES / 1024 / 1024,
));
}

/**
* Nothing was sent to PostSyncer, so retrying cannot duplicate media.
*/
private function safeUploadFailure(string $message, ?Throwable $previous = null): PostsyncerException
{
return new PostsyncerException($message, 0, $previous, responseReceived: true, safeToRetry: true);
}

/**
* @param array<string, mixed> $body
* @param string|null $idempotencyKey Stable per-operation/group key for providers that support replay deduplication.
Expand Down Expand Up @@ -287,15 +492,22 @@ public function getPostAnalytics(int|string $id): array
* @param array<string, mixed> $body
* @param array<string, string> $headers
*/
private function request(string $method, string $path, array $body = [], array $headers = []): Response
{
private function request(
string $method,
string $path,
array $body = [],
array $headers = [],
?string $filePath = null,
): Response {
$apiKey = $this->config->apiKey();

if (! is_string($apiKey) || $apiKey === '') {
throw new PostsyncerException('PostSyncer API key is not configured.');
}

$url = rtrim($this->config->apiBase(), '/').$path;
// File uploads must go to the dedicated upload host, never the API host.
$base = $filePath === null ? $this->config->apiBase() : $this->config->uploadBase();
$url = rtrim($base, '/').$path;

$pending = Http::withToken($apiKey)
// Multi-platform PostSyncer creates can take several minutes.
Expand All @@ -305,6 +517,21 @@ private function request(string $method, string $path, array $body = [], array $
->withOptions(['allow_redirects' => false])
->withHeaders($headers);

if ($filePath !== null) {
// Guzzle closes this stream when the request is released.
$stream = fopen($filePath, 'r');
if ($stream === false) {
throw new PostsyncerException(
'Could not open the downloaded media file for upload.',
responseReceived: true,
safeToRetry: true,
);
}
$mime = MimeTypes::getDefault()->guessMimeType($filePath) ?? 'application/octet-stream';
$extension = MimeTypes::getDefault()->getExtensions($mime)[0] ?? 'bin';
$pending = $pending->attach('file', $stream, 'media.'.$extension, ['Content-Type' => $mime]);
}

try {
$response = match ($method) {
'get' => $pending->get($url),
Expand Down
8 changes: 5 additions & 3 deletions docs/guides/postsyncer.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ queue starts to hurt.
| `postsyncer:sync-scheduled` | every five minutes | Pulls PostSyncer status back onto scheduled records. |

`PublishPostJob` has a 900-second timeout and the dedicated `postsyncer` database
queue connection has a 960-second visibility window. `PublishVideoJob` has a
180-second timeout and uses the same connection, leaving enough time for its
media upload, create, and canonical verification calls. Keep
queue connection has a 960-second visibility window. `PublishVideoJob` also has a
900-second timeout on the same connection: it downloads each video and cover
from Drive and uploads them one by one to PostSyncer's file endpoint
(`upload.postsyncer.com/api/v1/media/upload/file`, 500 MB cap), because the
URL import stores no videos. Keep
`POSTSYNCER_QUEUE_RETRY_AFTER` above the longest worker timeout. Both publish
jobs are unique for the record and use a shared overlap lock whose expiry is
longer than the video timeout. Each queued job also carries the run token it
Expand Down
11 changes: 7 additions & 4 deletions tests/Feature/Api/VideosApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use App\Models\User;
use App\Models\Video;
use App\Models\Workspace;
use App\Support\LinkResolution\PublicUrlGuard;
use App\Support\Postsyncer\PostsyncerConfig;
use App\Support\Postsyncer\PostsyncerException;
use Illuminate\Foundation\Testing\RefreshDatabase;
Expand All @@ -29,6 +30,7 @@ protected function setUp(): void
{
parent::setUp();

$this->app->instance(PublicUrlGuard::class, new PublicUrlGuard(fn (): array => [['ip' => '142.250.0.1']]));
$this->workspace = Workspace::factory()->create();
$this->token = (new CreateWorkspaceApiTokenAction)->handle(
$this->workspace,
Expand Down Expand Up @@ -562,9 +564,9 @@ public function test_reconcile_create_absent_endpoint_makes_verified_empty_creat
]);

Http::fake([
'postsyncer.com/api/v1/media/upload/url' => Http::response([
'media' => [['id' => 915]],
'count_stored' => 1,
'drive.usercontent.google.com/*' => fn () => Http::response('fake-media-bytes', 200),
'upload.postsyncer.com/api/v1/media/upload/file' => Http::response([
'media' => ['id' => 915],
], 200),
'postsyncer.com/api/v1/posts' => Http::response([
'message' => 'temporary outage',
Expand Down Expand Up @@ -626,7 +628,8 @@ public function test_reconcile_media_absent_endpoint_resets_verified_empty_uploa
]);

Http::fake([
'postsyncer.com/api/v1/media/upload/url' => Http::response([
'drive.usercontent.google.com/*' => fn () => Http::response('fake-media-bytes', 200),
'upload.postsyncer.com/api/v1/media/upload/file' => Http::response([
'message' => 'temporary outage',
], 503),
]);
Expand Down
Loading
Loading