Skip to content

fix: stream provider media uploads instead of buffering files in memory - #1835

Merged
nevo-david merged 5 commits into
mainfrom
fix/stream-provider-media-uploads
Aug 4, 2026
Merged

fix: stream provider media uploads instead of buffering files in memory#1835
nevo-david merged 5 commits into
mainfrom
fix/stream-provider-media-uploads

Conversation

@nevo-david

@nevo-david nevo-david commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Bug fix — memory-leak / OOM fix in the workers.

Why was this change needed?

Workers were running out of memory because most social providers downloaded entire media files into memory (arrayBuffer() / Blob) before uploading them. With several concurrent jobs uploading large videos, the buffers stacked up and OOM-killed the worker (in Whop's case the buffer even stayed resident through the whole ~9-minute status poll).

This PR makes every provider upload media without holding the whole file in memory:

  • social.abstract.ts — shared helpers: mediaSize (HEAD request / statSync), mediaChunk (ranged reads for chunked-upload APIs), and runStreamedUpload (retry + handleErrors classification for streamed bodies that can't be replayed — the whole request is rebuilt per attempt).
  • LinkedIn / X — chunked video uploads now read one 1–2MB ranged part at a time instead of buffering the video; LinkedIn media uploads run sequentially instead of Promise.all (parallel uploads held every file in memory at once).
  • Bluesky / Skool / Whop — size from a HEAD request, body streamed straight from the source with duplex: 'half'.
  • Discord / Mastodon / Reddit / Tumblr — multipart forms stream attachments with known lengths via form-data, rebuilt on every retry attempt.
  • TikTok — moved its media-size helper to the shared abstract implementation.

All user-influenced media fetches keep the SSRF-safe dispatcher, and error classification (retry / refresh-token / bad-body) matches the existing this.fetch behavior so Temporal retry semantics are unchanged. No workflow or activity signatures were touched.

Other information:

Follow-up to the August 2026 worker OOM audit; the Temporal worker configuration findings from that audit are not part of this PR.

Checklist:

  • I have read the CONTRIBUTING guide.
  • I have signed the Contributor License Agreement (CLA) (ICLA for individuals, CCLA for entities).
  • I confirm I have not used AI to submit this PR or generate code for it.
  • I checked that there were no similar issues or PRs already open for this.
  • This PR fixes just ONE issue

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements
    • Social media attachments now stream directly during upload, reducing memory usage and improving reliability for large files.
    • Media uploads use more consistent size detection, sequential processing where applicable, and bounded video transfers.
    • Uploads retry safely and provide clearer errors for unavailable media, invalid metadata, timeouts, and failed responses.
    • Remote media retrieval includes enhanced security protections.

Workers were going OOM because social providers downloaded entire media
files (arrayBuffer/Blob) into memory before uploading them. This moves
all providers to streamed or chunked uploads:

- social.abstract.ts: shared mediaSize (HEAD/stat), mediaChunk (ranged
  reads) and runStreamedUpload (retry/handleErrors classification for
  non-replayable streamed bodies) helpers
- linkedin/x: chunked video uploads read one 1-2MB ranged part at a time;
  LinkedIn media now uploads sequentially instead of Promise.all
- bluesky/skool/whop: size from HEAD, body streamed with duplex: 'half'
- discord/mastodon/reddit/tumblr: multipart forms stream attachments
  with known lengths via form-data, rebuilt per retry attempt
- tiktok: reuse the shared mediaSize helper

All user-influenced fetches keep the SSRF-safe dispatcher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 4, 2026 03:04
@postiz-contribution postiz-contribution Bot added the contribution:approved Approved contributor label Aug 4, 2026
@postiz-agent

postiz-agent Bot commented Aug 4, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Comment on lines +168 to +170
const { data: stream } = await axios.get(item.path, {
responseType: 'stream',
});

This comment was marked as outdated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses worker OOMs by refactoring multiple social-provider media upload paths to avoid buffering entire media files in memory, primarily by streaming uploads and/or uploading chunk-by-chunk.

Changes:

  • Added shared streaming/chunking helpers to SocialAbstract (mediaSize, mediaChunk, runStreamedUpload) and migrated providers to use them.
  • Updated chunked-upload providers (e.g., X, LinkedIn, TikTok) to read ranged chunks on-demand rather than loading full videos.
  • Updated multipart/PUT upload providers (e.g., Discord, Mastodon, Reddit, Tumblr, Skool, Whop, Bluesky) to stream upload bodies and rebuild requests on retry where needed.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
libraries/nestjs-libraries/src/integrations/social.abstract.ts Introduces shared helpers for streaming/chunked uploads and retry/error classification for one-shot streams.
libraries/nestjs-libraries/src/integrations/social/x.provider.ts Switches X video upload to ranged-chunk reads and sequential media uploads to reduce memory pressure.
libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts Streams LinkedIn video uploads in 2MB chunks and uploads media sequentially.
libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts Replaces provider-local size logic with shared mediaSize.
libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts Streams video upload bytes and uses HEAD-derived content-length instead of buffering video.
libraries/nestjs-libraries/src/integrations/social/discord.provider.ts Streams attachments into multipart form and retries by rebuilding the request.
libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts Streams media bytes into multipart form and rebuilds on retry to avoid replaying consumed streams.
libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts Streams the upload file into Reddit’s S3 form with known length rather than buffering.
libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts Streams media parts into multipart upload and rebuilds on retry to avoid replaying streams.
libraries/nestjs-libraries/src/integrations/social/whop.provider.ts Streams PUT uploads and derives sizes via HEAD to avoid holding media in memory during long polling.
libraries/nestjs-libraries/src/integrations/social/skool.provider.ts Streams PUT uploads and derives sizes via HEAD to avoid buffering media.
Suppressed comments (1)

libraries/nestjs-libraries/src/integrations/social/discord.provider.ts:170

  • Use this.fetch (SSRF-safe dispatcher + consistent retry/error semantics) to stream attachment bytes instead of axios.get, which may follow redirects to internal addresses and isn’t protected by getSsrfSafeDispatcher.
        const fileSize = await this.mediaSize(item.path, this.identifier);
        const { data: stream } = await axios.get(item.path, {
          responseType: 'stream',
        });

Comment on lines 451 to 454
{
segment_index: i / chunkSize,
media: await this.videoChunk(path, i, end),
segment_index: i,
media: await this.mediaChunk(path, start, end),
},
Comment on lines +279 to +283
await fetch(createFileResponse.upload_url, {
method: 'PUT',
headers: createFileResponse.upload_headers || {},
body: fileBuffer,
});
headers: {
// Whop's own upload_headers win if they ever include a length.
'Content-Length': String(contentLength),
Comment on lines +259 to +263
await fetch(createFileResponse.write_url, {
method: 'PUT',
headers: {
'Content-Type': createFileResponse.content_type,
'Content-Length': String(contentLength),
Comment on lines +12 to +13
import axios from 'axios';
import FormDataUpload from 'form-data';
Comment thread libraries/nestjs-libraries/src/integrations/social.abstract.ts
@nevo-david

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key: "autopilot"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b6370a3-1ef3-493e-b1b6-1e014503c7a4

📥 Commits

Reviewing files that changed from the base of the PR and between 1b121d0 and c2944e1.

📒 Files selected for processing (3)
  • libraries/nestjs-libraries/src/integrations/social.abstract.ts
  • libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/x.provider.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/x.provider.ts
  • libraries/nestjs-libraries/src/integrations/social.abstract.ts

Walkthrough

Social media providers now stream local and remote media instead of buffering complete files. Shared helpers provide size detection, ranged reads, streams, SSRF-safe requests, retries, and classified errors. LinkedIn and X use bounded chunk processing.

Changes

Streamed social media uploads

Layer / File(s) Summary
Shared media access and retry primitives
libraries/nestjs-libraries/src/integrations/social.abstract.ts, libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts
SocialAbstract provides media size detection, ranged reads, media streams, and retryable streamed uploads. TikTok uses the shared size helper.
Direct streaming provider uploads
libraries/nestjs-libraries/src/integrations/social/{bluesky,skool,whop}.provider.ts
Bluesky, Skool, and Whop validate media metadata and stream media with explicit content lengths.
Multipart streamed provider uploads
libraries/nestjs-libraries/src/integrations/social/{discord,mastodon,reddit,tumblr}.provider.ts
Multipart uploads stream source media and rebuild forms for retry attempts.
Chunked and sequential media workflows
libraries/nestjs-libraries/src/integrations/social/{linkedin,x}.provider.ts
LinkedIn and X process bounded chunks. Media processing runs sequentially where specified. X polls processing for up to 30 minutes.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Provider
  participant SocialAbstract
  participant MediaSource
  participant PlatformUpload
  Provider->>SocialAbstract: request size or stream
  SocialAbstract->>MediaSource: perform SSRF-safe HEAD, range, or GET
  MediaSource-->>SocialAbstract: metadata or readable stream
  SocialAbstract->>PlatformUpload: send streamed media
  PlatformUpload-->>SocialAbstract: upload response
  SocialAbstract-->>Provider: success or classified error
Loading

Possibly related PRs

Suggested reviewers: copilot

Poem

A rabbit sends streams through the night,
With bounded chunks measured right.
Forms rebuild after a retry,
Safe requests pass bytes nearby.
Hop! Each upload takes flight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: streaming provider media uploads to avoid buffering files in memory.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/stream-provider-media-uploads

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

libraries/nestjs-libraries/src/integrations/social.abstract.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

libraries/nestjs-libraries/src/integrations/social/x.provider.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts (1)

95-121: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Check the response body and the upload status before you poll the job.

Line 99 checks only videoResponse.ok, so videoResponse.body can still be null when it is used as the request body on line 118. skool.provider.ts:256 and whop.provider.ts:276 guard both. Line 111 also ignores the upload status: if X uploadResponse returns an error payload, jobStatus.jobId is undefined and the loop calls getJobStatus({ jobId: undefined }), which hides the real cause.

🛡️ Proposed guards
   const videoResponse = await fetch(videoPath, {
     // `@ts-ignore` - undici-only option; blocks SSRF to internal IPs
     dispatcher: getSsrfSafeDispatcher(),
   });
-  if (!videoResponse.ok) {
+  if (!videoResponse.ok || !videoResponse.body) {
     throw new Error(`Failed to fetch video: ${videoResponse.statusText}`);
   }
   const jobStatus = (await uploadResponse.json()) as AppBskyVideoDefs.JobStatus;
+  if (!jobStatus?.jobId && !jobStatus?.blob) {
+    throw new BadBody(
+      'bluesky',
+      JSON.stringify(jobStatus ?? {}),
+      {} as any,
+      'Bluesky rejected the video upload'
+    );
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts`
around lines 95 - 121, In the video upload flow, update the guards after the
initial fetch and upload request to require a non-null videoResponse.body before
using it as the streaming request body, and verify uploadResponse.ok before
parsing or polling the job. Throw descriptive errors containing the relevant
response status information so getJobStatus is never called with an undefined
job ID; follow the equivalent checks in skool.provider.ts and whop.provider.ts.
🧹 Nitpick comments (1)
libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts (1)

196-208: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Wrap the axios upload in runStreamedUpload and guard the <Location> match.

The upload no longer goes through this.fetch, so handleErrors classification and the 429/500 retry no longer apply to Reddit media uploads. discord.provider.ts:149 and tumblr.provider.ts:517 wrap the equivalent axios call in runStreamedUpload, which rebuilds the form per attempt. Reddit sets maxConcurrentJob = 1 because of strict rate limits, so 429 handling matters on this path.

Line 206 also indexes matchAll(...)[0][1] directly. If the response carries no <Location> element, this throws a TypeError instead of a classified error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts` around
lines 196 - 208, The Reddit media upload in the surrounding provider method must
use runStreamedUpload for the axios request so retries and handleErrors
classification apply; rebuild the upload form within each attempt as done by the
equivalent Discord and Tumblr flows. Replace the direct matchAll index access
with a guarded <Location> match, and propagate a classified error when no
location is returned.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libraries/nestjs-libraries/src/integrations/social.abstract.ts`:
- Around line 177-202: Update mediaSize and the related mediaChunk filesystem
access to resolve non-HTTP media paths through process.env.UPLOAD_DIRECTORY,
matching the existing getImageDimensions and updateMedia behavior. Preserve
direct handling for HTTP URLs and ensure statSync and createReadStream receive
the resolved filesystem path rather than the raw provider path.

In `@libraries/nestjs-libraries/src/integrations/social/discord.provider.ts`:
- Around line 166-176: Replace the axios streaming downloads with a shared
SSRF-safe SocialAbstract.mediaStream helper that uses the safe dispatcher and
returns a Node-readable stream, while preserving local-file handling and upload
error behavior. Add the helper next to mediaSize and mediaChunk, then use it in
discord.provider.ts lines 166-176, reddit.provider.ts lines 184-186, and
tumblr.provider.ts lines 527-529, retaining each upload part and knownLength
behavior.

In `@libraries/nestjs-libraries/src/integrations/social/skool.provider.ts`:
- Around line 259-269: Update the streamed PUT handling near the file-upload
flow to retain the fetch response and validate its success status before pushing
createFileResponse.file.id. If the PUT is unsuccessful, stop the attachment flow
and propagate or handle the upload failure so an empty attachment cannot be
published; only append the file ID after a successful response.

In `@libraries/nestjs-libraries/src/integrations/social/whop.provider.ts`:
- Around line 279-289: Update the streamed PUT in the upload flow around
createFileResponse.upload_url to retain the fetch response and immediately
validate that its status is 2xx. On any non-2xx response, fail the operation
with an appropriate error instead of continuing to the status-poll loop.

In `@libraries/nestjs-libraries/src/integrations/social/x.provider.ts`:
- Around line 459-494: Update the polling request in the video-processing loop
after `finalize` to use X’s v2 `/2/media/upload/status` endpoint with `media_id`
as the request parameter. Keep the existing `processing_info` response handling
and retry logic intact so polling continues until processing succeeds or fails.

---

Outside diff comments:
In `@libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts`:
- Around line 95-121: In the video upload flow, update the guards after the
initial fetch and upload request to require a non-null videoResponse.body before
using it as the streaming request body, and verify uploadResponse.ok before
parsing or polling the job. Throw descriptive errors containing the relevant
response status information so getJobStatus is never called with an undefined
job ID; follow the equivalent checks in skool.provider.ts and whop.provider.ts.

---

Nitpick comments:
In `@libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts`:
- Around line 196-208: The Reddit media upload in the surrounding provider
method must use runStreamedUpload for the axios request so retries and
handleErrors classification apply; rebuild the upload form within each attempt
as done by the equivalent Discord and Tumblr flows. Replace the direct matchAll
index access with a guarded <Location> match, and propagate a classified error
when no location is returned.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c34a4234-6f86-46db-8f50-0dfcc1c5a216

📥 Commits

Reviewing files that changed from the base of the PR and between e7ad24a and 9980825.

📒 Files selected for processing (11)
  • libraries/nestjs-libraries/src/integrations/social.abstract.ts
  • libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/discord.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/skool.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/whop.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/x.provider.ts

Comment on lines +177 to +202
// Resolves the total byte size of the media without loading it into memory:
// a HEAD request for remote URLs, statSync for local files.
protected async mediaSize(path: string, identifier = ''): Promise<number> {
if (path.indexOf('http') === 0) {
// the media path is user-influenced, keep the SSRF-safe dispatcher that
// this.fetch applies to every other outbound request
const head = await fetch(path, {
method: 'HEAD',
dispatcher: getSsrfSafeDispatcher(),
} as any);
const length = head.headers.get('content-length');
// A failed HEAD can still carry a content-length (of the error body),
// which would register the media with a garbage size downstream.
if (!head.ok || !length) {
throw new BadBody(
identifier,
'{}',
Buffer.from('{}'),
'Could not determine the media size for upload'
);
}
return Number(length);
}

return statSync(path).size;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how media paths are produced and whether relative paths reach providers.
set -euo pipefail

fd -t f 'social.abstract.ts' libraries | xargs -r rg -n 'readOrFetch|FRONTEND_URL|statSync|mediaSize|mediaChunk'

# Find the readOrFetch helper and its path handling.
rg -n -C 8 'export (const|function|async function) readOrFetch' libraries

# Find where media paths are assigned before reaching providers.
rg -n -C 5 "indexOf\('http'\)" libraries apps

Repository: gitroomhq/postiz-app

Length of output: 19327


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== social.abstract.ts relevant methods =="
sed -n '155,235p' libraries/nestjs-libraries/src/integrations/social.abstract.ts

echo
echo "== call sites for mediaSize/mediaChunk =="
rg -n -C 8 '\.(mediaSize|mediaChunk|getImageDimensions)\(' libraries/nestjs-libraries/src/integrations/social || true

echo
echo "== provider-specific media path handling snippets =="
for f in libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts \
         libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts \
         libraries/nestjs-libraries/src/integrations/social/discord.provider.ts \
         libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts \
         libraries/nestjs-libraries/src/integrations/social/x.provider.ts; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n -C 10 'mediaSize|mediaChunk|getImageDimensions|getMediaUrl|media\.path|m\.path|path.*media' "$f" || true
  fi
done

echo
echo "== media file model / assignments =="
rg -n -C 6 'model (Media|media)|path.*String|upload|multipart|fieldValue|UploadService|uploadTo' libraries apps | head -n 240

Repository: gitroomhq/postiz-app

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate uploads/services/controllers =="
git ls-files | rg 'upload|asset|media|post|posts.*service|controller' | rg '\.ts$' | head -n 200

echo
echo "== focus: media upload path implementations =="
for f in $(git ls-files | rg 'upload|assets*|post' | rg 'providers|services|controllers|repository|schema' | grep '\.ts$' | head -n 80); do
  case "$f" in
    *uploads*|*asset*|*upload*|*post*)
      echo "--- $f ---"
      rg -n -C 4 'upload-directory|uploadDirectory|uploadDir|MEDIA_PATH|path:|filename|originalname|mimetype|files\\(' "$f" || true
      ;;
  esac
done

echo
echo "== PostDetails/media DTO definitions =="
rg -n -C 6 "PostDetails|interface .*Media|type .*Media|path: string|files\\(|express-multer|Multer" libraries apps | head -n 260

Repository: gitroomhq/postiz-app

Length of output: 34687


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== local.storage.ts =="
cat -n libraries/nestjs-libraries/src/upload/local.storage.ts | sed -n '1,220p'

echo
echo "== media.controller.ts =="
cat -n apps/backend/src/api/routes/media.controller.ts | sed -n '1,260p'

echo
echo "== posts.service.ts updateMedia paths =="
cat -n libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts | sed -n '330,430p'

echo
echo "== create media paths in posts.service.ts =="
rg -n -C 4 'create\\(|createMany\\(|create\\(\\{|media' libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts | sed -n '1,220p'

Repository: gitroomhq/postiz-app

Length of output: 16653


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== media.repository.ts =="
cat -n libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts | sed -n '1,220p'

echo
echo "== media.service.ts =="
cat -n libraries/nestjs-libraries/src/database/prisma/media/media.service.ts | sed -n '1,240p'

echo
echo "== posts.service.ts create/update calls around media =="
cat -n libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts | sed -n '620,750p'
cat -n libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts | sed -n '750,830p'

echo
echo "== provider upload functions around raw paths =="
cat -n libraries/nestjs-libraries/src/integrations/social/x.provider.ts | sed -n '490,525p'
cat -n libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts | sed -n '790,830p'
cat -n libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts | sed -n '160,190p'
cat -n libraries/nestjs-libraries/src/integrations/social/discord.provider.ts | sed -n '145,175p'
cat -n libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts | sed -n '255,280p'

Repository: gitroomhq/postiz-app

Length of output: 23667


Resolve non-HTTP media paths before using them with the filesystem.

mediaSize and mediaChunk use statSync / createReadStream for any path that does not start with http, but providers pass raw media paths such as item.path or media.path. Local uploads are stored with URLs like /uploads/year/month/day/r…, not absolute filesystem paths, so these calls can resolve relative to the worker cwd and fail. Resolve these cases like getImageDimensions/updateMedia use process.env.UPLOAD_DIRECTORY for non-HTTP paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social.abstract.ts` around lines
177 - 202, Update mediaSize and the related mediaChunk filesystem access to
resolve non-HTTP media paths through process.env.UPLOAD_DIRECTORY, matching the
existing getImageDimensions and updateMedia behavior. Preserve direct handling
for HTTP URLs and ensure statSync and createReadStream receive the resolved
filesystem path rather than the raw provider path.

Comment thread libraries/nestjs-libraries/src/integrations/social/skool.provider.ts Outdated
Comment thread libraries/nestjs-libraries/src/integrations/social/whop.provider.ts Outdated
Comment on lines +459 to 494
const finalize = await client.v2.post<{
data: {
processing_info?: {
state: string;
check_after_secs?: number;
error?: { message?: string };
};
id: string;
processing_info?: { state: string; check_after_secs?: number };
};
}>('media/upload', { command: 'STATUS', media_id: mediaId });

const info = response.data.processing_info;
if (!info || info.state === 'succeeded') {
return;
}
if (info.state === 'failed') {
throw new BadBody(
'x-error-upload',
JSON.stringify(response.data),
Buffer.from('{}'),
`X media processing failed: ${info.error?.message || 'unknown error'}`
);
}
await timer((info.check_after_secs || 1) * 1000);
await this.waitForMediaProcessing(client, mediaId);
}
}>(`media/upload/${mediaId}/finalize`);

// Resolves the total byte size of the video without loading it into memory:
// a HEAD request for remote URLs, statSync for local files.
private async videoSize(path: string): Promise<number> {
if (path.indexOf('http') === 0) {
const head = await fetch(path, { method: 'HEAD' });
const length = head.headers.get('content-length');
if (!length) {
let processing = finalize.data.processing_info;
let attempts = 0;
const maxAttempts = 100; // X drives the pace via check_after_secs (~1-5s each)
while (processing && processing.state !== 'succeeded') {
if (processing.state === 'failed' || attempts >= maxAttempts) {
throw new BadBody(
'x-error-upload',
'{}',
this.identifier,
JSON.stringify(processing),
Buffer.from('{}'),
'Could not determine the video size for X upload'
`X failed to process the uploaded video${
(processing as any)?.error?.message
? `: ${(processing as any).error.message}`
: ''
}`
);
}
return Number(length);
}

return statSync(path).size;
}

// Returns only the [start, end] byte window of the video as a Buffer (a
// ranged GET for remote URLs, a ranged read for local files), so memory is
// bounded by the segment size.
private async videoChunk(
path: string,
start: number,
end: number
): Promise<Buffer> {
if (path.indexOf('http') === 0) {
const response = await fetch(path, {
headers: { Range: `bytes=${start}-${end}` },
});
// A 200 means the origin ignored the Range header and is sending the
// whole file: uploading it as this segment would silently corrupt the
// video, so fail loudly instead.
if (response.status !== 206) {
throw new BadBody(
'x-error-upload',
'{}',
Buffer.from('{}'),
`Media server ignored the ranged request (status ${response.status}); it must support HTTP Range requests for chunked X uploads`
);
}
return Buffer.from(await response.arrayBuffer());
await timer((processing.check_after_secs || 1) * 1000);
const status = await client.v2.get<{
data: {
processing_info?: { state: string; check_after_secs?: number };
};
}>('media/upload', { command: 'STATUS', media_id: mediaId });
processing = status.data.processing_info;
attempts++;
}

return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
createReadStream(path, { start, end })
.on('data', (chunk) => chunks.push(chunk as Buffer))
.on('end', () => resolve(Buffer.concat(chunks)))
.on('error', reject);
});
return mediaId;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

X API v2 media upload chunked initialize append finalize status endpoint parameters

💡 Result:

The X API v2 uses a four-step chunked upload process to handle large media files such as videos, GIFs, and images. The process is broken down into the following endpoints and operations [1][2]. 1. INITIALIZE (POST /2/media/upload/initialize) This step initiates the upload session and returns a media_id [1][3]. Parameters (Body): - total_bytes: The total size of the media in bytes (up to 17,179,869,184 bytes) [3]. - media_type: The MIME type of the media (e.g., video/mp4, image/jpeg, image/gif) [3]. - media_category: The category of the media (e.g., amplify_video, tweet_video, tweet_image) [3]. - shared: A boolean indicating if the media is shared [3]. 2. APPEND (POST /2/media/upload/{id}/append) After obtaining a media_id, you use this endpoint to upload individual chunks of the file sequentially [1]. - Path Parameter: {id} — The media_id returned during the initialization phase [4]. - Body: The binary chunk of the file being uploaded (typically sent as multipart/form-data) [4]. 3. FINALIZE (POST /2/media/upload/{id}/finalize) Once all chunks are uploaded, call this endpoint to complete the upload process [1][5]. - Path Parameter: {id} — The media_id of the target media [5]. 4. STATUS (GET /2/media/upload?command=STATUS&media_id={id}) For asynchronous processing (common with videos), use this to check the status of the media [1]. - Parameters: - command: Must be set to STATUS [1]. - media_id: The ID of the media to query [1]. - This endpoint returns a processing_info object containing the current state (e.g., pending, in_progress, failed, succeeded) and, if applicable, the check_after_secs field, which advises how long to wait before polling again [1][5]. Once the status indicates the state is succeeded, the media_id can be used to attach the media to other resources, such as posts [1][2].

Citations:


🌐 Web query:

twitter-api-v2 1.29.0 media upload get media upload command STATUS

💡 Result:

In the twitter-api-v2 library, specifically as of version 1.29.0 [1][2], the media upload process has been migrated to use the dedicated X API v2 endpoints [3][4]. Regarding your query about the media upload command STATUS: 1. Migration from V1.1: The traditional V1.1 media upload process utilized a command-based system (e.g., using the command=STATUS query parameter) [4]. However, the twitter-api-v2 library has been updated to align with X's shift away from these legacy V1.1 commands [4]. 2. Current Implementation: The library's.uploadMedia method for V2 now interacts with the updated X API v2 media upload endpoints [3][4]. As indicated in the project's documentation and pull request history (such as PR #584), the library was updated to use these new dedicated endpoints, effectively removing the reliance on the legacy command parameter system [3][4]. 3. Status Checking: If you are using.uploadMedia in version 1.29.0, the library handles the underlying request structure [5]. For checking media processing status, the X API v2 now provides distinct endpoints (e.g., GET media/upload/status) rather than the old command-based query parameters [6]. If you are encountering issues or searching for the status check mechanism, ensure you are utilizing the updated methods provided in the V2 client rather than trying to pass a command parameter manually, as the library abstracts this to comply with the current API architecture [5][4].

Citations:


🌐 Web query:

site:github.com/PLhery/node-twitter-api-v2 "media/upload?command=STATUS" "twitter-api-v2"

💡 Result:

The media/upload endpoint using the command=STATUS query parameter is a legacy mechanism associated with the Twitter API v1.1 [1][2]. It is not used in the modern Twitter API v2 for media uploads [3][4]. In the context of the popular node-twitter-api-v2 library, media uploads are handled differently depending on the API version: 1. API v1.1: Still utilizes the legacy media/upload.json endpoint, which supports the command query parameter (e.g., INIT, APPEND, FINALIZE, STATUS) [1][2]. 2. API v2: X has deprecated the older chunked upload process that relied on the command query parameter [3][4]. Instead, the v2 media upload process now uses dedicated endpoints for each step of the upload process [3]: - POST.../2/media/upload/initialize [3] - POST.../2/media/upload/{id}/append [3] - POST.../2/media/upload/{id}/finalize [3] If you are working with the node-twitter-api-v2 library, you should use the built-in v2 methods (such as client.v2.uploadMedia) which have been updated to use these new dedicated v2 endpoints rather than the legacy command-based system [5][3][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
fd -a 'x\.provider\.ts$|package\.json$|package-lock\.json$|pnpm-lock\.yaml$|yarn\.lock$' . | sed 's#^\./##' | head -100

printf '%s\n' '--- declaration ---'
rg -n '"twitter-api-v2"|twitter-api-v2' package.json package-lock.json pnpm-lock.yaml yarn.lock libraries apps 2>/dev/null || true

printf '%s\n' '--- x.provider outline ---'
ast-grep outline libraries/nestjs-libraries/src/integrations/social/x.provider.ts --view expanded || true

printf '%s\n' '--- uploadVideoInChunks section ---'
sed -n '420,500p' libraries/nestjs-libraries/src/integrations/social/x.provider.ts | cat -n

Repository: gitroomhq/postiz-app

Length of output: 5500


Use X’s v2 media upload status endpoint.

Lines 459-488 call the v2 finalize endpoint, but then poll with client.v2.get('media/upload', { command: 'STATUS', media_id: mediaId }), which is the legacy v1.1 status shape. Use X’s v2 GET /2/media/upload/status path with media_id instead; otherwise processing_info may stay undefined, poll exits immediately, and the tweet can use a media id that has not finished processing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/x.provider.ts` around
lines 459 - 494, Update the polling request in the video-processing loop after
`finalize` to use X’s v2 `/2/media/upload/status` endpoint with `media_id` as
the request parameter. Keep the existing `processing_info` response handling and
retry logic intact so polling continues until processing succeeds or fails.

- Add a shared SSRF-safe mediaStream helper and use it in Discord,
  Reddit and Tumblr: the axios stream downloads bypassed the SSRF-safe
  dispatcher that the matching HEAD requests already used
- Check the streamed PUT response status in Skool and Whop so a
  rejected upload fails fast instead of publishing an empty attachment
  (Skool) or burning the ~9 minute status poll (Whop)
- Thread the provider identifier through mediaChunk so ranged-read
  failures are attributed to the right provider in Temporal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 4, 2026 03:37
@nevo-david

Copy link
Copy Markdown
Contributor Author

Addressed the review findings in 1b121d0:

Fixed

  • SSRF bypass (Sentry critical / Copilot / CodeRabbit) — Discord, Reddit and Tumblr downloaded media with plain axios.get(..., { responseType: 'stream' }), bypassing the SSRF-safe dispatcher used by the HEAD request on the same URL. Added a shared mediaStream helper on SocialAbstract (SSRF-safe fetch → Readable.fromWeb, matching what Mastodon already did) and switched all three providers to it.
  • Unchecked streamed PUT (Copilot / CodeRabbit) — Skool and Whop now check the PUT response status and throw BadBody on failure, instead of publishing an empty attachment (Skool) or waiting out the ~9-minute status poll (Whop).
  • mediaChunk identifier (Copilot) — the helper now accepts an identifier and X/LinkedIn pass theirs, so ranged-read failures are attributed to the right provider.

Not fixed, with reasons

  • CodeRabbit: resolve non-HTTP paths via UPLOAD_DIRECTORY — already handled upstream: posts.service.ts prefixes non-HTTP media paths with process.env.UPLOAD_DIRECTORY before they reach any provider, so statSync/createReadStream always receive absolute paths (same contract the pre-existing TikTok/LinkedIn code relied on).
  • CodeRabbit: X STATUS polling uses the legacy v1.1 shapeclient.v2.get('media/upload', { command: 'STATUS', media_id }) is unchanged from main and proven in production; this PR only restructured the surrounding loop. Happy to migrate to GET /2/media/upload/status in a separate PR if X deprecates the current shape.

@nevo-david

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (4)

libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts:101

  • videoResponse.body can be null (e.g., if the fetch implementation fails to provide a body). Passing a null body into the upload request will throw at runtime; check for body presence alongside ok before streaming the upload.
  if (!videoResponse.ok) {
    throw new Error(`Failed to fetch video: ${videoResponse.statusText}`);
  }

libraries/nestjs-libraries/src/integrations/social.abstract.ts:200

  • mediaSize returns Number(content-length) without validating that the header is a finite, positive integer. If the origin returns content-length: 0 or a non-numeric value, callers will compute chunk counts/sizes incorrectly (e.g., Math.ceil(NaN / chunk)), leading to hard-to-debug upload failures.
          'Could not determine the media size for upload'
        );
      }
      return Number(length);
    }

libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts:186

  • This S3 upload path now uses a one-shot stream but isn't wrapped in runStreamedUpload, so errors won’t be classified/retried like this.fetch and the stream can’t be recreated on retry. This risks losing existing Temporal retry semantics for transient S3/Reddit failures.
    // S3 requires the exact part length, which comes from a HEAD request.
    const fileSize = await this.mediaSize(path, this.identifier);
    const stream = await this.mediaStream(path, this.identifier);

    const upload = (fields as { name: string; value: string }[]).reduce(

libraries/nestjs-libraries/src/integrations/social/x.provider.ts:468

  • maxAttempts = 100 caps processing polling to ~100–500 seconds for typical check_after_secs values (1–5s). That introduces a new timeout compared to the previous behavior and can cause false failures for longer video processing jobs.
    const maxAttempts = 100; // X drives the pace via check_after_secs (~1-5s each)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts (3)

607-619: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Select the streaming path before reusing media.buffer.

If a video item contains both path and buffer, this branch passes the full buffer to uploadPicture and defeats bounded video uploads. Check for MP4 videos first, or explicitly reject buffer for videos.

Proposed branch order
-        if (
+        if (hasExtension(media.path, 'mp4')) {
+          mediaBuffer = { path: media.path };
+        } else if (
           media &&
           typeof media === 'object' &&
           'buffer' in media &&
           Buffer.isBuffer(media.buffer)
         ) {
           mediaBuffer = (media as any).buffer;
-        } else if (hasExtension(media.path, 'mp4')) {
-          mediaBuffer = { path: media.path };
         } else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts`
around lines 607 - 619, Update the mediaBuffer selection logic near the video
handling branch to check hasExtension(media.path, 'mp4') before accepting
media.buffer. Ensure MP4 items always use the streaming { path: media.path }
representation, even when a buffer is also present, while preserving buffered
handling for non-video media.

620-620: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don’t convert unbuffered PDFs to JPEG before Documents upload.

In createMainPost, a PDF path without media.buffer runs through prepareMediaBuffer, whose fallback sends it through sharp(...).toFormat('jpeg'). The returned JPEG bytes are still uploaded on uploadPicture with the .pdf path, so the Documents API receives non-document bytes and the post can fail or publish broken content. Preserve the original PDF bytes, or reject an unbuffered PDF at the input boundary, and add a regression test for a PDF path without buffer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts` at
line 620, Update createMainPost so unbuffered PDF media does not pass through
prepareMediaBuffer’s JPEG conversion before uploadPicture; preserve the original
PDF bytes or reject the PDF at input validation, while keeping existing image
conversion behavior unchanged. Add a regression test covering a PDF path without
media.buffer.

327-350: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use each video upload instruction for its byte range.

The video loop uses only uploadInstructions[0].uploadUrl and creates its own 2 MB byte ranges. LinkedIn's Videos API can return multiple instructions, each with its own uploadUrl, firstByte, and lastByte; send each part using the matching instruction before finalizing uploadedPartIds in instruction order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts`
around lines 327 - 350, Update the video upload loop around uploadInstructions
to iterate each instruction and use its uploadUrl, firstByte, and lastByte for
the corresponding request body. Read or slice exactly the instructed byte range,
then collect uploadedPartIds in the same order as uploadInstructions before
finalizing; remove the fixed 2 MB range generation and uploadInstructions[0]
usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts`:
- Around line 607-619: Update the mediaBuffer selection logic near the video
handling branch to check hasExtension(media.path, 'mp4') before accepting
media.buffer. Ensure MP4 items always use the streaming { path: media.path }
representation, even when a buffer is also present, while preserving buffered
handling for non-video media.
- Line 620: Update createMainPost so unbuffered PDF media does not pass through
prepareMediaBuffer’s JPEG conversion before uploadPicture; preserve the original
PDF bytes or reject the PDF at input validation, while keeping existing image
conversion behavior unchanged. Add a regression test covering a PDF path without
media.buffer.
- Around line 327-350: Update the video upload loop around uploadInstructions to
iterate each instruction and use its uploadUrl, firstByte, and lastByte for the
corresponding request body. Read or slice exactly the instructed byte range,
then collect uploadedPartIds in the same order as uploadInstructions before
finalizing; remove the fixed 2 MB range generation and uploadInstructions[0]
usage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 35662690-22ae-477e-ae05-3de084e06b16

📥 Commits

Reviewing files that changed from the base of the PR and between 9980825 and 1b121d0.

📒 Files selected for processing (8)
  • libraries/nestjs-libraries/src/integrations/social.abstract.ts
  • libraries/nestjs-libraries/src/integrations/social/discord.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/skool.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/whop.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/x.provider.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • libraries/nestjs-libraries/src/integrations/social.abstract.ts
  • libraries/nestjs-libraries/src/integrations/social/skool.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/discord.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/x.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/whop.provider.ts

- X: cap video-processing polling on accumulated wait time (30 min)
  instead of 100 attempts - main polled unbounded, so the attempt cap
  could fail long-processing videos that previously succeeded
- mediaSize: reject zero/NaN content-length so chunk-count math never
  runs on a garbage size
- Bluesky: guard against a null response body before streaming the
  upload

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 4, 2026 03:49
@nevo-david

Copy link
Copy Markdown
Contributor Author

Second round addressed in c2944e1:

Fixed

  • Copilot: maxAttempts = 100 introduces a new timeout — valid; main polled unbounded, so the attempt cap was a regression for long-processing videos. The poll now caps on accumulated wait time (30 min) driven by X's check_after_secs, instead of an attempt count.
  • Copilot: mediaSize doesn't validate content-length — it now rejects zero/NaN/negative sizes so chunk-count math can never run on a garbage value.
  • Copilot: Bluesky videoResponse.body can be null — guarded alongside the ok check, matching Skool/Whop.

Not fixed, with reasons

  • Copilot: Reddit S3 upload not wrapped in runStreamedUpload — on main this POST used plain fetch with no retry/classification either (a failure produced a TypeError on the XML parse). The axios error now propagates to Temporal, whose activity retry re-runs the upload and rebuilds the stream, so retry semantics are no worse than before and the presigned S3 URL has no refresh-token/rate-limit classification to gain.
  • CodeRabbit: LinkedIn buffer-before-mp4 branch order — the branch order is unchanged from main, and media.buffer is only ever set by the PDF-conversion flow, never for videos, so the buffered-video path is unreachable.
  • CodeRabbit: unbuffered PDFs pass through JPEG conversion — pre-existing behavior on main (prepareMediaBuffer fallback is untouched by this PR); worth a separate issue if it bites in practice.
  • CodeRabbit: use each LinkedIn uploadInstructions entryuploadInstructions?.[0]?.uploadUrl plus self-computed 2MB ranges is identical on main and production-proven; restructuring the multipart contract is out of scope for a memory fix.

Comment on lines +204 to +206
return [
...(d.data as string).matchAll(/<Location>(.*?)<\/Location>/g),
][0][1];

This comment was marked as outdated.

@nevo-david

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

An empty or unexpected S3 body made the <Location> parse throw an
opaque TypeError; fail with a clear BadBody instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 4, 2026 03:58
@nevo-david

Copy link
Copy Markdown
Contributor Author

Final round, addressed in b0bb1c8:

  • Sentry: unsafe Reddit S3 response parse — the fragile [0][1] parse predates this PR, but since these lines were touched anyway it now guards the <Location> match and throws a clear BadBody on an empty/unexpected body instead of an opaque TypeError.

Copilot's latest review generated no new comments, and CodeRabbit had nothing new inside the diff. All other outstanding threads were dispositioned in the two comments above.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

A file-by-file comparison with origin/main confirmed no logic was
dropped by the streaming refactor, but surfaced side-effects:

- request identity encoding on every media HEAD/GET whose
  content-length feeds an upload size: fetch transparently
  decompresses encoded bodies, so a compressing origin would make the
  declared Content-Length smaller than the streamed bytes and abort or
  truncate the upload (impossible with the old buffered code)
- tumblr: validate the create-post response shape - axios parses JSON
  silently, so a non-JSON 200 (proxy/WAF page) returned a string and
  published the post as completed with an empty id; main threw here
- linkedin: restore the Buffer.from wrap on the GIF pass-through so
  prepareMediaBuffer always returns a real Buffer (uploadPicture
  branches on Buffer.isBuffer)
- runStreamedUpload: never hand handleErrors an empty string, matching
  this.fetch's '|| {}' guard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 4, 2026 04:12
Comment on lines +306 to +314
totalRetries <= 2 &&
(status === 429 ||
(status === 500 && !handleError) ||
handleError?.type === 'retry' ||
json.includes('rate_limit_exceeded') ||
json.includes('Rate limit'))
) {
await timer(5000);
return this.runStreamedUpload(func, identifier, totalRetries + 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The retry condition in runStreamedUpload (totalRetries <= 2) allows 4 attempts, while this.fetch (totalRetries > 2) allows 3, contradicting the stated goal of matching behavior.
Severity: MEDIUM

Suggested Fix

Change the retry condition in runStreamedUpload at line 306 from totalRetries <= 2 to totalRetries < 2 or a similar condition that results in 3 total attempts, aligning it with the logic in this.fetch. This will make the retry behavior consistent.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: libraries/nestjs-libraries/src/integrations/social.abstract.ts#L306-L314

Potential issue: The retry logic in `runStreamedUpload` uses the condition `totalRetries
<= 2`, which results in 4 total attempts (initial call + 3 retries). This is
inconsistent with the `this.fetch` method, which uses `totalRetries > 2` and allows only
3 total attempts. This discrepancy violates the stated intent in the code comments and
PR description to maintain the "same retry... rules as this.fetch". This can lead to
different behavior for transient errors, adding an extra 5-second delay and an
unnecessary retry attempt for providers using `runStreamedUpload`.

Comment on lines +292 to +295
} catch (err: any) {
if (!err?.response) {
throw err;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Errors thrown when fetching media for runStreamedUpload lack a .response property, which bypasses the function's retry logic and prevents retries on transient network failures.
Severity: HIGH

Suggested Fix

When a media fetch fails with a non-ok status, throw an error object that includes the response property, similar to how axios errors are structured. This will allow the catch block in runStreamedUpload to correctly identify it as a retryable error.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: libraries/nestjs-libraries/src/integrations/social.abstract.ts#L292-L295

Potential issue: When fetching media from a source URL for providers like Mastodon, a
`new Error(...)` is thrown if the response status is not ok. This error object lacks a
`.response` property. The `runStreamedUpload` function, which wraps this logic, is
designed to only retry on errors that contain an `err?.response` property. As a result,
transient failures during media fetching (e.g., 429, 500, 503 status codes) will not be
retried, causing the upload to fail immediately instead of recovering.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (4)

libraries/nestjs-libraries/src/integrations/social/whop.provider.ts:292

  • The media source fetch failure is thrown as a generic Error, which bypasses the SocialAbstract media helpers and yields inconsistent/non-truncated provider failures. Since you already added streaming helpers, prefer using mediaStream() here so SSRF-safe fetching and BadBody formatting are centralized and Temporal won’t waste retries on permanent 4xx media URLs.
        const fileResponse = await fetch(item.path, {
          headers: { 'accept-encoding': 'identity' },
          // @ts-ignore - undici-only option; blocks SSRF to internal IPs
          dispatcher: getSsrfSafeDispatcher(),
        });
        if (!fileResponse.ok || !fileResponse.body) {
          throw new Error(`Failed to fetch media: ${fileResponse.statusText}`);
        }

libraries/nestjs-libraries/src/integrations/social/skool.provider.ts:261

  • If fetching the media fails, this throws a generic Error. Using the shared mediaStream() helper here would keep SSRF protection and error formatting consistent (BadBody) and avoid Temporal retries on permanent media URL failures.
      const fileResponse = await fetch(item.path, {
        headers: { 'accept-encoding': 'identity' },
        // @ts-ignore - undici-only option; blocks SSRF to internal IPs
        dispatcher: getSsrfSafeDispatcher(),
      });
      if (!fileResponse.ok || !fileResponse.body) {
        throw new Error(`Failed to fetch media: ${fileResponse.statusText}`);
      }

libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts:156

  • Inside runStreamedUpload, a failed media fetch currently throws a plain Error, so runStreamedUpload cannot apply its retry/handleErrors classification (it only handles errors with err.response). Throw an axios-style { response: { status, data } } object here so transient 429/5xx can be retried and non-retryable failures are surfaced as BadBody/RefreshToken consistently.
      const fileResponse = await fetch(fileUrl, {
        // identity encoding so content-length matches the streamed bytes
        headers: { 'accept-encoding': 'identity' },
        // @ts-ignore - undici-only option; blocks SSRF to internal IPs
        dispatcher: getSsrfSafeDispatcher(),
      });
      if (!fileResponse.ok || !fileResponse.body) {
        throw new Error(`Failed to fetch media: ${fileResponse.statusText}`);
      }

libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts:104

  • When the video GET fails, this throws a generic Error rather than a provider BadBody. That makes failures less consistent with the other Bluesky upload errors (and can lead to noisy Temporal retries for permanent 4xx media URLs). Prefer throwing BadBody with the HTTP status so the failure is classified and truncated consistently.
  const videoResponse = await fetch(videoPath, {
    headers: { 'accept-encoding': 'identity' },
    // @ts-ignore - undici-only option; blocks SSRF to internal IPs
    dispatcher: getSsrfSafeDispatcher(),
  });
  if (!videoResponse.ok || !videoResponse.body) {
    throw new Error(`Failed to fetch video: ${videoResponse.statusText}`);
  }

@nevo-david
nevo-david added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 60329af Aug 4, 2026
12 checks passed
@nevo-david
nevo-david deleted the fix/stream-provider-media-uploads branch August 4, 2026 04:21
giladresisi added a commit that referenced this pull request Aug 7, 2026
The YouTube and TikTok chunked uploaders read the stored video back from
media storage with their own HEAD/ranged GET helpers, and those helpers
never asked for identity encoding. Every other media read in the project
does: #1835 added `accept-encoding: identity` to SocialAbstract's
mediaSize / mediaChunk / mediaStream and to the providers that read media
themselves, because a transformed (compressed) response loses its
Content-Length - and Cloudflare answers a range request on an object with
no Content-Length by returning the full body with a 200 instead of the
requested 206.

That is very likely what has been failing ~1GB YouTube uploads: a single
200 out of the ~128 ranged reads a 1GB video needs is thrown as BadBody,
which the v1.0.6 workflow treats as a permanent platform rejection, so
the whole post is marked ERROR even though the upload session is still
resumable.

The gap looks like an artifact of merge ordering rather than a decision:
#1813 introduced these two bespoke helpers on Aug 3, and #1835 swept
identity encoding across the project on Aug 4 - by then the sweep had
been written against a tree that did not contain them, so YouTube and
TikTok were missed while X and LinkedIn (bespoke at the time too) were
consolidated onto the shared helpers and fixed.

Behaviour on a non-206 is deliberately left untouched: if the customer
report recurs after this ships, the next step is to reconsider treating
the documented 200-with-full-body answer as transient (a plain Error the
workflow retries and resumes from the committed byte offset) instead of
as a permanent failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contribution:approved Approved contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants