' . "\n", esc_url( rest_url( REST_NS . '/profile' ) ) ); } /** * Public profile data, served at /og/v1/profile. * * @return array */ public static function profile() { $avatar = Avatar::current(); return array( 'name' => get_bloginfo( 'name' ), 'description' => get_bloginfo( 'description' ), 'pronouns' => (string) get_option( 'stubli_pronouns', '' ), 'url' => home_url( '/' ), 'feed' => get_feed_link( 'rss2' ), 'following' => rest_url( REST_NS . '/following' ), 'guestbook' => Guestbook::url(), 'on_break' => Settings::on_break(), 'avatar' => (string) $avatar, 'cover' => Setup::cover_url(), 'bio' => '' !== (string) get_option( 'stubli_bio', '' ) ? (string) get_option( 'stubli_bio', '' ) : get_bloginfo( 'description' ), 'recent' => self::recent_photos( 3 ), 'prompts' => Prompts::get(), 'generator' => 'stubli/' . VERSION, 'moved_to' => (string) get_option( 'stubli_moved_to', '' ), 'moved_from' => (string) get_option( 'stubli_moved_from', '' ), ); } /** * The newest public photo posts, for Discover cards on other sites. * * @param int $n How many. * @return array List of { url, alt, link, note, date }. */ public static function recent_photos( $n = 3 ) { $out = array(); $query = new \WP_Query( array( 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => (int) $n, 'no_found_rows' => true, 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query array( 'taxonomy' => 'post_format', 'field' => 'slug', 'terms' => array( 'post-format-status' ), 'operator' => 'NOT IN', ), ), ) ); foreach ( $query->posts as $post ) { $ids = self::photo_ids( $post->ID ); if ( ! $ids ) { continue; } $src = wp_get_attachment_image_src( $ids[0], 'medium' ); $out[] = array( 'url' => $src ? $src[0] : wp_get_attachment_url( $ids[0] ), 'alt' => (string) get_post_meta( $ids[0], '_wp_attachment_image_alt', true ), 'link' => get_permalink( $post ), 'note' => (string) get_post_meta( $post->ID, '_stubli_content_note', true ), 'date' => get_post_time( 'c', true, $post ), ); } return $out; } /** * Caption and journal text keep plain text and links only (design Q25). * Bare addresses become links; only http(s) links survive. * * @param string $text Raw text from the owner. * @return string Safe HTML. */ public static function safe_text_html( $text ) { $text = self::drop_code( $text ); $text = wp_kses( (string) $text, array( 'a' => array( 'href' => true ) ), array( 'http', 'https' ) ); $text = make_clickable( $text ); $text = wp_kses( $text, array( 'a' => array( 'href' => true, 'rel' => true ) ), array( 'http', 'https' ) ); return wpautop( $text ); } /** * Remove script and style blocks entirely, contents included, so no * leftover code text ends up in a caption. * * @param string $html Text or HTML. * @return string */ public static function drop_code( $html ) { return (string) preg_replace( '#<(script|style|iframe|noscript)\b[^>]*>.*?#is', '', (string) $html ); } /** * Length of what a reader sees, ignoring markup. * * @param string $text Text that may contain links. * @return int */ public static function visible_length( $text ) { return mb_strlen( trim( wp_strip_all_tags( (string) $text ) ) ); } /** * Post date fields for an original (imported) time. * * @param int $timestamp Unix time. * @return array */ public static function date_fields( $timestamp ) { $ts = (int) $timestamp; if ( $ts < 946684800 || $ts > time() + DAY_IN_SECONDS ) { // Only real dates from 2000 until now. return array(); } return array( 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $ts ), 'post_date' => get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $ts ) ), ); } /** * Create a photo post (one photo, or a carousel of up to ten). * * Extra: 'date' (Unix time, for imports) and 'import_key' (makes the * same import safe to run twice). * * @param array $files Uploaded files. * @param array $alts Descriptions, same order. * @param string $caption Caption. * @param string $collection Collection name. * @param string $note Content note. * @param array $extra Extra options. * @return array|WP_Error */ public static function create_photo_post( array $files, array $alts, $caption, $collection = '', $note = '', array $extra = array() ) { $import_key = isset( $extra['import_key'] ) ? substr( preg_replace( '/[^a-zA-Z0-9_:.-]/', '', (string) $extra['import_key'] ), 0, 120 ) : ''; if ( '' !== $import_key ) { $existing = get_posts( array( 'post_type' => 'post', 'post_status' => 'any', 'meta_key' => '_stubli_import_key', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $import_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'numberposts' => 1, 'fields' => 'ids', ) ); if ( $existing ) { return array( 'id' => (int) $existing[0], 'url' => get_permalink( $existing[0] ), 'duplicate' => true, ); } } if ( ! $files ) { return new WP_Error( 'stubli_no_photo', __( 'Choose at least one photo.', 'stubli' ), array( 'status' => 400 ) ); } if ( count( $files ) > self::MAX_PHOTOS ) { return new WP_Error( 'stubli_too_many_photos', __( 'A post can hold up to 10 photos.', 'stubli' ), array( 'status' => 400 ) ); } $posters = isset( $extra['posters'] ) && is_array( $extra['posters'] ) ? $extra['posters'] : array(); $videos = self::check_media( $files, $posters ); if ( is_wp_error( $videos ) ) { return $videos; } if ( self::visible_length( $caption ) > self::MAX_CAPTION_LENGTH ) { return new WP_Error( 'stubli_caption_too_long', __( 'That caption is a little long. Trim it to 2,200 characters and it will fit.', 'stubli' ), array( 'status' => 400 ) ); } $post_id = wp_insert_post( array( 'post_status' => 'publish', 'post_title' => wp_trim_words( wp_strip_all_tags( $caption ), 8, '…' ), 'post_content' => '' !== trim( $caption ) ? self::safe_text_html( $caption ) : '', 'post_author' => get_current_user_id(), 'post_category' => $collection ? array( self::collection_id( $collection ) ) : array(), ) + self::date_fields( $extra['date'] ?? 0 ), true ); if ( is_wp_error( $post_id ) ) { return $post_id; } $ids = self::sideload_media( $files, $alts, $posters, $videos, $post_id ); if ( is_wp_error( $ids ) ) { wp_delete_post( $post_id, true ); return $ids; } if ( '' !== $import_key ) { update_post_meta( $post_id, '_stubli_import_key', $import_key ); } if ( '' !== trim( (string) $caption ) ) { update_post_meta( $post_id, '_stubli_caption', sanitize_textarea_field( (string) $caption ) ); } set_post_thumbnail( $post_id, $ids[0] ); update_post_meta( $post_id, '_stubli_photos', $ids ); set_post_format( $post_id, count( $ids ) > 1 ? 'gallery' : 'image' ); $note = mb_substr( sanitize_text_field( (string) $note ), 0, self::MAX_NOTE_LENGTH ); if ( '' !== $note ) { update_post_meta( $post_id, '_stubli_content_note', $note ); } if ( 'friends' === ( $extra['audience'] ?? '' ) ) { Friends::set_audience( $post_id, 'friends' ); } return array( 'id' => $post_id, 'url' => get_permalink( $post_id ), 'type' => count( $ids ) > 1 ? 'carousel' : ( $videos ? 'video' : 'photo' ), 'photos' => count( $ids ), 'videos' => count( $videos ), ); } /** * Check uploaded photos and videos before anything is saved. * * @param array $files Uploaded files. * @param array $posters Cover pictures for videos, keyed by position. * @return array|WP_Error Positions that are videos (position => true). */ public static function check_media( array $files, array $posters ) { $videos = array(); foreach ( array_values( $files ) as $i => $file ) { $check = wp_check_filetype_and_ext( $file['tmp_name'] ?? '', $file['name'] ?? '' ); if ( empty( $check['type'] ) && self::looks_like_video( $file ) ) { // The host doesn't accept video files (WordPress.com Personal, for one): say so plainly. return new WP_Error( 'stubli_video_not_allowed', self::no_video_message(), array( 'status' => 400 ) ); } if ( in_array( $check['type'], self::VIDEO_TYPES, true ) ) { if ( empty( $posters[ $i ]['tmp_name'] ) ) { return new WP_Error( 'stubli_no_poster', __( 'That video arrived without its cover picture. Try sharing it again.', 'stubli' ), array( 'status' => 400 ) ); } $pcheck = wp_check_filetype_and_ext( $posters[ $i ]['tmp_name'], $posters[ $i ]['name'] ?? 'poster.jpg' ); if ( ! in_array( $pcheck['type'], self::PHOTO_TYPES, true ) ) { return new WP_Error( 'stubli_bad_poster', __( 'That video\'s cover picture couldn\'t be read. Try sharing it again.', 'stubli' ), array( 'status' => 400 ) ); } $seconds = self::video_seconds( $file['tmp_name'] ); if ( $seconds > self::MAX_VIDEO_SECONDS + 1 ) { return new WP_Error( 'stubli_video_too_long', __( 'Videos can be up to a minute long. Trim it on your phone, then share it again.', 'stubli' ), array( 'status' => 400 ) ); } $videos[ $i ] = true; continue; } if ( ! in_array( $check['type'], self::PHOTO_TYPES, true ) ) { return new WP_Error( 'stubli_not_a_photo', __( 'That file isn\'t a photo or video. Try a JPEG, PNG or HEIC photo, or an MP4 or MOV video.', 'stubli' ), array( 'status' => 400 ) ); } } return $videos; } /** * True while Stubli saves a photo, so the file gets a name of its own. * * @var bool */ public static $fresh = false; /** * Give each new photo a file name that has never been used on this site. * The app sends every photo as "photo-1.jpg", so WordPress would number * them, and once an old photo is deleted its number (and address) is free * again. Hosts with an image cache (WordPress.com among them) then keep * showing the old picture at that address. A date and a few random letters * keep every address new. * * @param array $file Upload, as WordPress hands it over. * @return array */ public static function fresh_name( $file ) { if ( ! self::$fresh || empty( $file['name'] ) || ! is_string( $file['name'] ) ) { return $file; } $ext = strtolower( (string) pathinfo( $file['name'], PATHINFO_EXTENSION ) ); $base = substr( sanitize_title( (string) pathinfo( $file['name'], PATHINFO_FILENAME ) ), 0, 40 ); $base = '' !== $base ? preg_replace( '/-\d+$/', '', $base ) : 'photo'; $file['name'] = $base . '-' . gmdate( 'Ymd-His' ) . '-' . strtolower( wp_generate_password( 5, false, false ) ) . ( '' !== $ext ? '.' . $ext : '' ); return $file; } /** * Save checked photos and videos to a post's media. If one fails, the * ones already saved from this batch are removed again. * * @param array $files Uploaded files. * @param array $alts Descriptions, same order. * @param array $posters Cover pictures for videos, keyed by position. * @param array $videos From check_media(). * @param int $post_id Post ID. * @return int[]|WP_Error Attachment IDs of the photos (video covers), in order. */ public static function sideload_media( array $files, array $alts, array $posters, array $videos, $post_id ) { require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $ids = array(); $extra_ids = array(); self::$fresh = true; foreach ( array_values( $files ) as $i => $file ) { $video_id = 0; if ( isset( $videos[ $i ] ) ) { // The poster stands in for the video everywhere a photo is expected // (grid, Discover, older readers); the video hangs off it. $video_id = media_handle_sideload( $file, $post_id ); $file = $posters[ $i ]; if ( ! is_wp_error( $video_id ) ) { $extra_ids[] = (int) $video_id; } } $attachment_id = is_wp_error( $video_id ) ? $video_id : media_handle_sideload( $file, $post_id ); if ( is_wp_error( $attachment_id ) ) { self::$fresh = false; foreach ( array_merge( $ids, $extra_ids ) as $done ) { wp_delete_attachment( $done, true ); } return new WP_Error( 'stubli_upload_failed', $video_id ? __( 'That video didn\'t upload. It may be too large for your host; try a shorter clip.', 'stubli' ) : __( 'That photo didn\'t upload. Your caption is saved; try again when you\'re ready.', 'stubli' ), array( 'status' => 400 ) ); } if ( $video_id ) { update_post_meta( $attachment_id, '_stubli_video', (int) $video_id ); } $alt = sanitize_text_field( (string) ( $alts[ $i ] ?? '' ) ); if ( '' !== $alt ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt ); } $ids[] = (int) $attachment_id; } self::$fresh = false; return $ids; } /** * Whether a category is WordPress's untouched default ("Uncategorized"): * photos there have no collection and show in All on the page. * * @param \WP_Term|mixed $term Category. * @return bool */ public static function is_unsorted( $term ) { return $term instanceof \WP_Term && (int) $term->term_id === (int) get_option( 'default_category' ) && in_array( strtolower( $term->name ), array( 'uncategorized', 'uncategorised' ), true ); } /** * Category ID for a collection name, creating it if needed. * * @param string $name Collection name. * @return int */ public static function collection_id( $name ) { $name = mb_substr( sanitize_text_field( $name ), 0, 40 ); // "Photos" is how the untouched default category is shown; posting there means that one. $default = get_term( (int) get_option( 'default_category' ), 'category' ); if ( $default && ! is_wp_error( $default ) && __( 'Photos', 'stubli' ) === $name && in_array( strtolower( $default->name ), array( 'uncategorized', 'uncategorised' ), true ) ) { return (int) $default->term_id; } $term = term_exists( $name, 'category' ); if ( ! $term ) { $term = wp_insert_term( $name, 'category' ); } return is_wp_error( $term ) ? (int) get_option( 'default_category' ) : (int) $term['term_id']; } /** * What a collection holds: photo posts, and journal entries the owner * filed into it (marked with Journal::SHELF). * * @return array Tax query. */ public static function tile_tax() { // The OR group sits inside an outer array, so a collection filter // ('cat') that WordPress adds next to it is ANDed, not ORed. return array( array( 'relation' => 'OR', array( 'taxonomy' => 'post_format', 'field' => 'slug', 'terms' => array( 'post-format-image', 'post-format-gallery' ), ), array( 'taxonomy' => Journal::SHELF, 'operator' => 'EXISTS', ), ), ); } /** * The photo(s) a post shows as its tile in a collection: a photo post's * photos, or a filed journal entry's first photo (none for a paper tile). * * @param int $post_id Post ID. * @return int[] */ public static function tile_ids( $post_id ) { if ( Journal::is_filed( $post_id ) ) { $id = Journal::tile_photo( $post_id ); return $id ? array( $id ) : array(); } return self::photo_ids( $post_id ); } /** * The photos of a post, in order. * * @param int $post_id Post ID. * @return int[] Attachment IDs. */ public static function photo_ids( $post_id ) { $ids = array_filter( array_map( 'intval', (array) get_post_meta( $post_id, '_stubli_photos', true ) ) ); if ( ! $ids && get_post_thumbnail_id( $post_id ) ) { $ids = array( (int) get_post_thumbnail_id( $post_id ) ); } return array_values( $ids ); } /** * The video behind a poster, if this photo is a video's cover. * * @param int $photo_id Attachment ID of the photo (poster). * @return int Video attachment ID, or 0. */ public static function video_for( $photo_id ) { $vid = (int) get_post_meta( (int) $photo_id, '_stubli_video', true ); return ( $vid && 'attachment' === get_post_type( $vid ) ) ? $vid : 0; } /** * The video types this site accepts, as WordPress and the host allow them. * Empty when the host takes no video files (WordPress.com's Personal plan, * for example, keeps video uploads for Premium and above). * * @return string[] MIME types from VIDEO_TYPES. */ public static function video_types_allowed() { return array_values( array_intersect( self::VIDEO_TYPES, array_values( get_allowed_mime_types() ) ) ); } /** * Whether this site runs on WordPress.com. * * @return bool */ public static function on_wpcom() { return ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) || ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || defined( 'WPCOMSH_VERSION' ); } /** * What to tell the owner when their host takes no video files. * * @return string */ public static function no_video_message() { return self::on_wpcom() ? __( 'Videos need WordPress.com Premium or above. Photos work on your plan.', 'stubli' ) : __( 'Your host doesn\'t accept video files, so you can share photos here but not videos.', 'stubli' ); } /** * Whether an uploaded file is meant as a video (by its name or the type the phone gave it). * * @param array $file Uploaded file. * @return bool */ private static function looks_like_video( $file ) { return (bool) preg_match( '/\.(mp4|m4v|mov|qt)$/i', (string) ( $file['name'] ?? '' ) ) || 0 === strpos( (string) ( $file['type'] ?? '' ), 'video/' ); } /** * A video's length in seconds (0 when it can't be read). * * @param string $path File path. * @return float */ public static function video_seconds( $path ) { if ( ! function_exists( 'wp_read_video_metadata' ) ) { require_once ABSPATH . 'wp-admin/includes/media.php'; } $meta = wp_read_video_metadata( $path ); return is_array( $meta ) && ! empty( $meta['length'] ) ? (float) $meta['length'] : 0.0; } /** * Remove where a video was filmed, after upload and before anything * else reads it. The app does this too; this catches other uploads. * * @param array $upload Upload result. * @return array */ public static function strip_video_upload( $upload ) { if ( ! empty( $upload['file'] ) && ! empty( $upload['type'] ) && in_array( $upload['type'], self::VIDEO_TYPES, true ) ) { self::strip_video_location( $upload['file'] ); } return $upload; } /** * Blank the location in an MP4 or MOV file, in place. Location atoms are * renamed to "free" (which every player skips), so nothing is re-encoded * and the file keeps its size. * * @param string $path File path. * @return int How many boxes were changed, or -1 if the file couldn't be opened. */ public static function strip_video_location( $path ) { $fh = @fopen( $path, 'r+b' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( ! $fh ) { return -1; } $size = (int) filesize( $path ); $off = 0; $changed = 0; while ( $off + 8 <= $size ) { fseek( $fh, $off ); $h = (string) fread( $fh, 16 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( strlen( $h ) < 8 ) { break; } $len = unpack( 'N', substr( $h, 0, 4 ) )[1]; $type = substr( $h, 4, 4 ); $hl = 8; if ( 1 === $len && strlen( $h ) >= 16 ) { $len = (int) unpack( 'J', substr( $h, 8, 8 ) )[1]; $hl = 16; } elseif ( 0 === $len ) { $len = $size - $off; } if ( $len < $hl ) { break; } if ( in_array( $type, array( 'moov', 'udta', 'meta' ), true ) && $len <= 64 * MB_IN_BYTES ) { fseek( $fh, $off ); $buf = (string) fread( $fh, $len ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread $orig = $buf; if ( 'moov' === $type || 'udta' === $type ) { $changed += self::scrub_boxes( $buf, $hl, strlen( $buf ) ); } else { $changed += self::scrub_meta( $buf, $hl, strlen( $buf ) ); } $buf = self::blank_coordinates( $buf ); if ( $buf !== $orig && strlen( $buf ) === strlen( $orig ) ) { fseek( $fh, $off ); fwrite( $fh, $buf ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite ++$changed; } } $off += $len; } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return $changed; } /** * Read one box header inside a buffer. * * @param string $b Buffer. * @param int $p Offset. * @param int $end End of the parent. * @return array|null { len, type, hl }. */ private static function box_at( $b, $p, $end ) { if ( $p + 8 > $end ) { return null; } $len = unpack( 'N', substr( $b, $p, 4 ) )[1]; $type = substr( $b, $p + 4, 4 ); $hl = 8; if ( 1 === $len ) { if ( $p + 16 > $end ) { return null; } $len = (int) unpack( 'J', substr( $b, $p + 8, 8 ) )[1]; $hl = 16; } elseif ( 0 === $len ) { $len = $end - $p; } if ( $len < $hl || $p + $len > $end ) { return null; } return array( 'len' => $len, 'type' => $type, 'hl' => $hl, ); } /** * Walk boxes and rename location atoms. * * @param string $b Buffer (changed in place). * @param int $start Start of children. * @param int $end End of children. * @return int Changes. */ private static function scrub_boxes( &$b, $start, $end ) { $n = 0; $p = $start; while ( $box = self::box_at( $b, $p, $end ) ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition if ( in_array( $box['type'], self::LOCATION_ATOMS, true ) ) { $b = substr_replace( $b, 'free', $p + 4, 4 ); ++$n; } elseif ( in_array( $box['type'], array( 'moov', 'trak', 'udta', 'mdia', 'minf', 'edts' ), true ) ) { $n += self::scrub_boxes( $b, $p + $box['hl'], $p + $box['len'] ); } elseif ( 'meta' === $box['type'] ) { $n += self::scrub_meta( $b, $p + $box['hl'], $p + $box['len'] ); } $p += $box['len']; } return $n; } /** * A "meta" box: QuickTime keys (com.apple.quicktime.location.ISO6709) * and iTunes-style items. * * @param string $b Buffer (changed in place). * @param int $start Start of the meta payload. * @param int $end End of the meta box. * @return int Changes. */ private static function scrub_meta( &$b, $start, $end ) { // ISO meta boxes carry 4 bytes of version and flags; QuickTime ones don't. if ( 'hdlr' !== substr( $b, $start + 4, 4 ) ) { $start += 4; } $n = 0; $loc = array(); $p = $start; while ( $box = self::box_at( $b, $p, $end ) ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition if ( 'keys' === $box['type'] ) { $q = $p + $box['hl'] + 4; $count = $q + 4 <= $end ? unpack( 'N', substr( $b, $q, 4 ) )[1] : 0; $q += 4; for ( $i = 1; $i <= $count && $q + 8 <= $p + $box['len']; $i++ ) { $ksz = unpack( 'N', substr( $b, $q, 4 ) )[1]; if ( $ksz < 8 ) { break; } if ( false !== stripos( substr( $b, $q + 8, $ksz - 8 ), 'location' ) ) { $loc[ $i ] = true; } $q += $ksz; } } $p += $box['len']; } $p = $start; while ( $box = self::box_at( $b, $p, $end ) ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition if ( 'ilst' === $box['type'] ) { $q = $p + $box['hl']; $qe = $p + $box['len']; while ( $item = self::box_at( $b, $q, $qe ) ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition $idx = unpack( 'N', $item['type'] )[1]; if ( isset( $loc[ $idx ] ) || in_array( $item['type'], self::LOCATION_ATOMS, true ) ) { $b = substr_replace( $b, 'free', $q + 4, 4 ); ++$n; } $q += $item['len']; } } elseif ( in_array( $box['type'], self::LOCATION_ATOMS, true ) ) { $b = substr_replace( $b, 'free', $p + 4, 4 ); ++$n; } $p += $box['len']; } return $n; } /** * Last safety net: any ISO 6709 coordinates left in the metadata become zeros. * * @param string $b Buffer. * @return string */ private static function blank_coordinates( $b ) { return (string) preg_replace_callback( '#[+-]\d{2}\.\d{2,}[+-]\d{3}\.\d{2,}(?:[+-]\d+(?:\.\d+)?)?(?:CRS[^/]*)?/#', static fn( $m ) => preg_replace( '/\d/', '0', $m[0] ), $b ); } /** * Declare the feed namespaces. */ public static function rss_namespace() { echo 'xmlns:media="http://search.yahoo.com/mrss/"' . "\n"; echo 'xmlns:stubli="' . esc_attr( self::NS ) . '"' . "\n"; } /** * Add every photo (with its description) and the content note to the * post's feed item, so readers can show the carousel faithfully. */ public static function rss_item_extras() { $post_id = get_the_ID(); foreach ( self::photo_ids( $post_id ) as $id ) { $src = wp_get_attachment_image_src( $id, 'large' ); if ( ! $src ) { continue; } $alt = (string) get_post_meta( $id, '_wp_attachment_image_alt', true ); printf( "\t\t%s\n", esc_url( $src[0] ), esc_attr( get_post_mime_type( $id ) ), (int) $src[1], (int) $src[2], $alt ? '' . esc_html( $alt ) . '' : '' ); } foreach ( self::photo_ids( $post_id ) as $id ) { $vid = self::video_for( $id ); $src = $vid ? wp_get_attachment_image_src( $id, 'large' ) : false; if ( $vid && $src ) { printf( "\t\t%s\n", esc_url( $src[0] ), esc_attr( get_post_mime_type( $vid ) ), esc_url( wp_get_attachment_url( $vid ) ) ); } } $note = (string) get_post_meta( $post_id, '_stubli_content_note', true ); if ( '' !== $note ) { echo "\t\t" . esc_html( $note ) . "\n"; } if ( Friends::is_friends_post( $post_id ) ) { echo "\t\tfriends\n"; } } /** * Privacy by default: re-encode uploaded JPEGs so EXIF data, including * GPS location, is removed. Orientation is applied first so the photo * doesn't end up sideways. * * @param array $upload Upload result from wp_handle_upload. * @return array */ public static function strip_photo_metadata( $upload ) { if ( empty( $upload['file'] ) || empty( $upload['type'] ) || 'image/jpeg' !== $upload['type'] ) { return $upload; } if ( ! apply_filters( 'stubli_strip_photo_metadata', true, $upload ) ) { return $upload; } $editor = wp_get_image_editor( $upload['file'] ); if ( is_wp_error( $editor ) ) { return $upload; } if ( method_exists( $editor, 'maybe_exif_rotate' ) ) { $editor->maybe_exif_rotate(); } $editor->set_quality( 90 ); $editor->save( $upload['file'], 'image/jpeg' ); return $upload; } }