Media
Media
Shared across every PremiumPress product. 6 classes in inc/Media.
ImageLimits ImageLimits.php
Shrink oversized member photos (Settings ▸ Media ▸ "Shrink large photos"). A phone photo is 4000px and 3–8 MB. WordPress's own big-image handling (5.3+) makes a 2560px "-scaled" copy for display but KEEPS the full original on disk forever, so a site of member listings fills its uploads folder with files nobody ever sees. Two passes, one setting: 1. The browser — assets/js/listing-editor.js resizes the photo on the phone BEFORE it is sent (a 5 MB photo arrives as ~500 KB, and quickly). Its config is printed from here so the 24 fork ListingEditor clones stay untouched. 2. The server — this wp_handle_upload filter caps anything that still arrives over the limit (the browser pass was skipped, an old cached script, another upload form), overwriting the file IN PLACE before WordPress generates sizes or the watermark runs, so no oversized original is ever kept. Same scope gate as Watermark: only front-end member uploads (the admin-ajax action), never the logo or design artwork uploaded in wp-admin. Paid advert banners are left alone — their exact dimensions are the product.
ListingMediaCleanup ListingMediaCleanup.php
Take a listing's own photos and videos with it when it is deleted for good. WordPress does not: wp_delete_post() re-points a deleted post's attachments at its parent and leaves every file — the original, each generated size, the watermark backup — in the uploads folder with nothing left that shows them. On a member site where listings expire and are deleted daily, that is where the disk goes. Fires on before_delete_post, so it covers every route to a permanent delete: the "Delete permanently" expiry action, the Trash being emptied (by hand or WordPress's 30-day sweep), an admin bulk delete, a member deleting their own listing. Moving to the Trash does NOT fire it, so a trashed listing can still be restored with photos. What counts as the listing's own: attachments parented to it (that is how the gallery is stored — see Directory\Media), plus anything parented to THOSE (a video's poster still). An attachment another post still uses as its featured image is left alone.
PrivateStore PrivateStore.php
Private file storage — for files that belong to ONE person and must never be fetchable by anyone else, however they arrive at the URL. The theme already had two weaker things and neither is this: Account\LockedMedia gates the attachment PAGE and the REST response for a media item. Its own header is honest about the limit — "the file itself still sits at a public uploads URL" — so anyone holding or guessing that URL still gets the bytes. Ppt_ph\ProtectedStore is the right shape, and this is modelled on it, but it lives inside a fork. A shipped product ZIP contains exactly ONE fork's folder (themes/build-theme.ps1), so no other fork may call it — which is why this is here in shared code rather than a second copy inside _jb. FOUR LAYERS, because any one of them can fail on somebody's host: 1. Outside the media library. Files stored here are NOT WordPress attachments, so they never appear in the media grid, an attachment page, a sitemap or the REST API — the routes that leak a "private" file most often. 2. A denied directory. uploads/ppt-private/ carries an .htaccess (Apache), a web.config (IIS) and an index.php. 3. An unguessable name. The stored name is 32 random hex characters, never the name the visitor uploaded. This is the layer that still holds on nginx, where .htaccess is ignored — there is simply no URL to guess. The original filename is kept as metadata by the caller, for display. 4. Delivery through code. The caller streams the file from its absolute path after its own permission check; the path is never turned into a URL. nginx: layer 2 does nothing there. self::isReachable() probes it honestly so an admin can be told, and the rule to add is location ^~ /wp-content/uploads/ppt-private/ { deny all; } Layers 1, 3 and 4 do not depend on the web server, so a file stays unreachable in practice even before that rule is added.
VideoSource VideoSource.php
What type to put on a <source> — and when to leave it off. A browser reads the type attribute BEFORE fetching anything and skips a source it believes it cannot play. That is useful when the label is accurate and actively harmful when it is not, because the file never gets a chance. .mov is the case that bites. WordPress stores it as video/quicktime, and Chromium answers canPlayType('video/quicktime') with "" — an outright no — even when the bytes inside are ordinary H.264 it would play happily. Measured: video/mp4 -> "maybe" video/mp4; codecs="avc1.42E01E" -> "probably" video/quicktime -> "" (no) video/quicktime; codecs="avc1.42E01E" -> "" (no) Rows two and four are the same codec in a different wrapper, with opposite answers. So every .mov — not only the HEVC ones — gave a black player in Chrome and Firefox, while Safari played it fine. Omitting type makes the browser sniff the actual bytes instead of trusting the label, and an H.264 .mov then plays. It is only ever a hint, never a requirement: a <source> with no type is valid HTML and always has been. Kept for mp4 and webm, where the label is true and saves a pointless download on a browser that genuinely cannot play it. What this does NOT fix: HEVC. An iPhone recording it cannot be decoded by Chrome or Firefox at any MIME type, so the uploader warns instead — see the movWarn string in assets/js/listing-editor.js.
Watermark Watermark.php
Watermarking for member-uploaded photos. The admin uploads a transparent PNG in Settings ▸ Watermark; every photo a member afterwards adds through a front-end form gets that mark composited onto it — the full-size file and every intermediate size WordPress generates, so no unmarked copy is reachable from a card thumbnail either. WHERE THIS HOOKS, AND WHY IT IS ONE PLACE Member uploads run through twenty-two byte-identical inc/*\/Uploads.php clones (one per fork — only the namespace differs), plus Stories. Editing them would be twenty-odd copies of the same change drifting apart on the next fork. They all funnel through WordPress's own wp_generate_attachment_metadata, so that single filter covers every fork present and future, and it fires late enough that the generated sizes exist on disk and can be stamped in the same pass. SCOPE — front-end member uploads only The filter is global, so it also sees images the admin adds in wp-admin (the company logo, design card art, block imagery). Those must stay clean, so fromMemberUpload() gates on the admin-ajax action that is running: only the theme's own front-end upload endpoints qualify. Avatars and advertiser banners are deliberately NOT in that list — a stamp across a 96px avatar or a paid banner is damage, not protection. ORIGINALS The untouched original is copied to uploads/ppt-originals/ before the first stamp (same web-deny treatment as Ppt_ph\ProtectedStore, plus a random filename component so a guessed URL fails even on nginx, where .htaccess is ignored). Its path is stored on the attachment as _ppt_wm_original. Nothing reads it back yet — there is no one-click restore in this version; the copy exists so changing or removing the watermark later is still possible without members re-uploading. IMAGE LIBRARY GD, which WordPress already requires for thumbnails. WP_Image_Editor has no compositing API, so this drops to raw GD rather than wrapping it. Animated GIFs are skipped — GD would flatten them to a single frame.
ZipStream ZipStream.php
A .zip written straight to the browser: no ZipArchive, no temp file. ZipArchive is an optional PHP extension that plenty of hosts (and this dev box) leave out, and PclZip builds the archive on disk first, which doubles the space a batch of full-size images needs. The files we zip are private-store images and clips, which are already compressed, so entries are STORED (no deflate). That makes the format simple enough to write here: local header + bytes per file, then the central directory. Sizes and CRCs are worked out up front, so the response has an exact Content-Length and the browser shows real download progress. Limits: classic zip (no Zip64), so under 4 GB in total and under 65,535 entries. plan() returns null past either.