Skip to content

Add UploadSourceMaterializer with policy-driven transforms#25620

Open
crazytonyli wants to merge 6 commits into
trunkfrom
task/media-v2-upload-materializer
Open

Add UploadSourceMaterializer with policy-driven transforms#25620
crazytonyli wants to merge 6 commits into
trunkfrom
task/media-v2-upload-materializer

Conversation

@crazytonyli

@crazytonyli crazytonyli commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Description

The main changes is introducing UploadSourceMaterializer which turns an UploadSource into an uploadable temp file plus MediaCreateParams, which is then uploaded to the site via wordpress-rs.

@dangermattic

dangermattic commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator
1 Warning
⚠️ This PR is larger than 500 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.

Generated by 🚫 Danger

@wpmobilebot

Copy link
Copy Markdown
Contributor

🤖 Build Failure Analysis

This build has failures. Claude has analyzed them - check the build annotations for details.

@crazytonyli crazytonyli force-pushed the task/media-v2-upload-foundation branch from 1b5f4d8 to 26d3fb8 Compare June 8, 2026 23:18
@crazytonyli crazytonyli force-pushed the task/media-v2-upload-materializer branch from f85702c to fde43a0 Compare June 8, 2026 23:18
@wpmobilebot

wpmobilebot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
App Icon📲 You can test the changes from this Pull Request in WordPress by scanning the QR code below to install the corresponding build.
App NameWordPress
ConfigurationRelease-Alpha
Build Number33084
VersionPR #25620
Bundle IDorg.wordpress.alpha
Commit18466f5
Installation URL3lnoh3qkfu9io
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@wpmobilebot

wpmobilebot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
App Icon📲 You can test the changes from this Pull Request in Jetpack by scanning the QR code below to install the corresponding build.
App NameJetpack
ConfigurationRelease-Alpha
Build Number33084
VersionPR #25620
Bundle IDcom.jetpack.alpha
Commit18466f5
Installation URL7tltjlrcvs9d0
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@crazytonyli crazytonyli force-pushed the task/media-v2-upload-foundation branch from 26d3fb8 to a4390f8 Compare June 11, 2026 00:32
@crazytonyli crazytonyli force-pushed the task/media-v2-upload-materializer branch 2 times, most recently from 323dddf to 56a1ac7 Compare June 11, 2026 21:30
@crazytonyli crazytonyli force-pushed the task/media-v2-upload-foundation branch from a4390f8 to 4e19015 Compare June 11, 2026 21:30
@crazytonyli crazytonyli force-pushed the task/media-v2-upload-materializer branch from 56a1ac7 to 7e76794 Compare June 12, 2026 00:50
@crazytonyli crazytonyli force-pushed the task/media-v2-upload-foundation branch 2 times, most recently from 486c1bf to de0286a Compare June 16, 2026 03:38
@crazytonyli crazytonyli force-pushed the task/media-v2-upload-materializer branch 4 times, most recently from 610e467 to 0c14222 Compare June 19, 2026 00:20
Base automatically changed from task/media-v2-upload-foundation to trunk July 7, 2026 21:56
The flag is the single Remove Location setting: it strips GPS EXIF from
images and selects the identifying-metadata filter for re-exported video,
so the name should not imply it is image-only.
Turns an upload source into an uploadable file: image normalization,
video re-export, geolocation strip, and remote-URL download, all gated by
the injected MediaUploadPolicy. Images are validated, type-sniffed,
converted, resized, and GPS-stripped in a single ImageIO pass that
preserves the EXIF orientation tag whenever pixels are not resampled. GIF
and SVG never re-encode: one shared raw-passthrough rule covers the photo
library, file importer, and remote sources. Includes the filename
allocator, remote downloader, materializer error type, and their unit
tests.
@crazytonyli crazytonyli force-pushed the task/media-v2-upload-materializer branch from 0c14222 to f548d7a Compare July 7, 2026 22:09
@crazytonyli crazytonyli requested a review from jkmassel July 7, 2026 22:20
@crazytonyli crazytonyli added this to the 27.1 milestone Jul 7, 2026
@crazytonyli crazytonyli marked this pull request as ready for review July 7, 2026 22:21
ProcessInfo.systemUptime measures time since device boot, so the sweep
cutoff spared staging directories orphaned by an earlier run in the same
boot session. Capture a per-run reference date on the first sweep instead,
which the app schedules shortly after launch, before any upload is staged.
URLSession's async download hands back a temp file the caller owns and
must delete. A non-2xx status or a failed move threw without removing it,
leaking the file in the system temp directory on every failed remote pick.
let location: URL
let response: URLResponse
do {
(location, response) = try await URLSession.shared.download(from: url, delegate: delegate)

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.

This should probably use an ephemeral session – we can get really weird behaviour by using shared (like sending login cookies with requests when we don't want to)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

sending login cookies with requests when we don't want to

Do you mean sending WP.com cookies to other sites? I don't think URLSession would do that? It would send WP.com cookies to WP.com URLs, which seems harmless.

Nonetheless I have switched to use ephemeral session in a25f413

)
throw MaterializerError.remoteDownloadFailed(underlyingError: statusError)
}

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.

Potential fun bug – some servers will return an HTML 200 response for an image URL (for instance, to avoid hot linking). It may be worth reading the magic bytes of the top of the file to validate that it's actually an image?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In practice, this downloading code only runs on UploadSource.RemoteURL, which is produced by "Stock Photos" from https://www.pexels.com/ (endpoint: https://public-api.wordpress.com/rest/v1/meta/external-media/pexels?search=cat). It's probably not likely that they'd return a 200 html page when the url is xxx.jpeg?

If they do, the image processing code is likely to catch the issue because the content can be decoded as an image. However, we do have code that skip image processing (like for gif). Still, I think we can trust the "Stock Photos" provider to return a sensible html response if an image url does not return the requested image?

contentType: UTType,
stem: String
) throws -> MaterializedUpload {
let data = try Data(contentsOf: sourceURL)

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.

IIRC, we pass paths to the Rust layer for upload, so do we really need to load the entire image into memory?

parentDir: parentDir
)
} else if contentType.conforms(to: .image) {
let data = try Data(contentsOf: localFile)

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.

Same question about whether we need to load this into memory

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We need to process the image so that the uploaded image matches user settings, like removing GPS, compression rate, etc (see MediaUploadPolicy).

}

// MARK: - end-to-end

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.

Suggested change
@Test("the basename for a very long source name is writable to disk")
func longNameBasenameIsWritable() throws {
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let stem = allocator.stem(
preferred: String(repeating: "a", count: 300),
fallbackPrefix: "Photo",
date: Date()
)
let dest = dir.appendingPathComponent(allocator.basename(stem: stem, ext: "jpg"))
try Data("x".utf8).write(to: dest) // throws ENAMETOOLONG today → test fails
}

Claude suggested that there's no limit to the filename path component size and we should clamp it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I got the same comment in my local review, but decided to ignore it considering it's not likely to have a 300 characters long file name. But it's probably pretty trivia to address the concern and the one suggestedName: "../escaped" below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 18466f5

)
}
}

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.

Claude flagged an issue with path traversal:

Suggested change
@Test("remote dispatch keeps output inside parentDir for a traversing suggestedName")
func remoteDispatchContainsTraversingName() async throws {
let parentDir = try makeTempDir()
let sourceFile = parentDir.appendingPathComponent("download.tmp")
try encodeImage(makeSolidColorImage(size: CGSize(width: 10, height: 10), color: .red), as: .jpeg)
.write(to: sourceFile)
let result = try await makeMaterializer(policy())
.dispatchRemoteDownload(
localFile: sourceFile,
contentType: .jpeg,
suggestedName: "../escaped",
caption: nil,
parentDir: parentDir
)
let parent = parentDir.standardizedFileURL.path
// The finalized file must stay INSIDE parentDir.
#expect(
result.tempFileURL.standardizedFileURL.path.hasPrefix(parent + "/"),
"materialized file escaped parentDir: \(result.tempFileURL.standardizedFileURL.path)"
)
// The documented cleanup target must be parentDir itself, never its parent —
// deleting it must not wipe sibling uploads in the staging root.
#expect(result.stagingDirectory.standardizedFileURL.path == parent)
}

The materialized temp-file name drives the uploaded filename, so an over-long source name could overflow NAME_MAX (ENAMETOOLONG) and a name carrying path separators could escape the staging directory via appendingPathComponent. Move both guards into basename, the single point every source path funnels through, so the .remoteURL and .imagePlayground paths that skip stem are covered too: sanitize separators and NUL, then cap the stem to a byte budget on a grapheme boundary.
@crazytonyli crazytonyli requested a review from jkmassel July 9, 2026 01:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants