function_exists('imagecreatefromstring'), 'webp' => function_exists('imagewebp'), 'jpeg' => function_exists('imagejpeg')]; } /** EXIF orientation (1-8) of a JPEG, read without the exif extension. 1 when absent or unreadable. */ function raijuAgentJpegOrientation(string $binary): int { if (strlen($binary) < 12 || substr($binary, 0, 2) !== "\xFF\xD8") return 1; $offset = 2; $length = strlen($binary); while ($offset + 4 <= $length && $binary[$offset] === "\xFF") { $marker = ord($binary[$offset + 1]); if ($marker === 0xDA || $marker === 0xD9) break; $size = (ord($binary[$offset + 2]) << 8) | ord($binary[$offset + 3]); if ($marker === 0xE1 && substr($binary, $offset + 4, 6) === "Exif\0\0") { $tiff = substr($binary, $offset + 10, $size - 8); if (strlen($tiff) < 14) return 1; $little = substr($tiff, 0, 2) === 'II'; $u16 = static fn(int $at) => $little ? (ord($tiff[$at + 1]) << 8 | ord($tiff[$at])) : (ord($tiff[$at]) << 8 | ord($tiff[$at + 1])); $u32 = static fn(int $at) => $little ? (ord($tiff[$at + 3]) << 24 | ord($tiff[$at + 2]) << 16 | ord($tiff[$at + 1]) << 8 | ord($tiff[$at])) : (ord($tiff[$at]) << 24 | ord($tiff[$at + 1]) << 16 | ord($tiff[$at + 2]) << 8 | ord($tiff[$at + 3])); $ifd = $u32(4); if ($ifd + 2 > strlen($tiff)) return 1; $entries = $u16($ifd); for ($i = 0; $i < $entries && $ifd + 2 + $i * 12 + 12 <= strlen($tiff); $i++) { $entry = $ifd + 2 + $i * 12; if ($u16($entry) === 0x0112) { $value = $u16($entry + 8); return $value >= 1 && $value <= 8 ? $value : 1; } } return 1; } $offset += 2 + $size; } return 1; } /** Whether decoding this image fits into the memory this request may use. */ function raijuAgentImageFitsMemory(int $width, int $height): bool { $limit = ini_get('memory_limit'); if ($limit === '-1' || $limit === false || $limit === '') return true; $bytes = (int)$limit; $unit = strtolower(substr(trim($limit), -1)); $bytes *= match ($unit) { 'g' => 1073741824, 'm' => 1048576, 'k' => 1024, default => 1 }; // GD keeps about 4 bytes per pixel, and resizing needs a second image. return ($width * $height * 9) < max(0, $bytes - memory_get_usage()) * 0.9; } /** * Check and clean one image file. * * @param array $options maxBytes (default 15 MB), sourceBytes (binary of the original when it is not $file) * @return array{binary:string,ext:string,mime:string,width:int,height:int,ai:string,bytes:int} * @throws RuntimeException with a message that is safe to show */ function raijuAgentIngestImage(string $file, array $options = []): array { $support = raijuAgentImageSupport(); if (!$support['gd'] || (!$support['webp'] && !$support['jpeg'])) { throw new RuntimeException('This server cannot process images (the PHP GD extension is missing), so the agent cannot use images.'); } $maxBytes = (int)($options['maxBytes'] ?? RAIJU_AGENT_IMAGE_MAX_BYTES); $size = @filesize($file); if ($size === false || $size === 0) throw new RuntimeException('The image file is empty or unreadable.'); if ($size > $maxBytes) throw new RuntimeException('The image is too large (maximum ' . round($maxBytes / 1048576) . ' MB).'); $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = $finfo ? (string)finfo_file($finfo, $file) : ''; unset($finfo); if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) { throw new RuntimeException('Only JPEG, PNG and WebP images are accepted.'); } $info = @getimagesize($file); if ($info === false || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) { throw new RuntimeException('The file is not a valid image.'); } [$width, $height] = [(int)$info[0], (int)$info[1]]; if ($width < 1 || $height < 1 || $width > RAIJU_AGENT_IMAGE_MAX_INPUT_SIDE || $height > RAIJU_AGENT_IMAGE_MAX_INPUT_SIDE || $width * $height > RAIJU_AGENT_IMAGE_MAX_PIXELS) { throw new RuntimeException('The image has too many pixels (at most 12,000 px per side and 40 megapixels).'); } if (!raijuAgentImageFitsMemory($width, $height)) { throw new RuntimeException('The image is too large to process on this server.'); } // Provenance lives in the metadata that re-encoding removes: read it first. $ai = raijuDetectAiLabel($file); $binary = (string)file_get_contents($file); $image = @imagecreatefromstring($binary); if (!$image instanceof GdImage) throw new RuntimeException('The image could not be decoded.'); try { if ($mime === 'image/jpeg') { // EXIF orientation: 2 mirror, 3 upside down, 4 flipped, 5 transposed, 6 turned right, 7 transversed, 8 turned left. $orientation = raijuAgentJpegOrientation($binary); $rotate = [3 => 180, 5 => -90, 6 => -90, 7 => 90, 8 => 90][$orientation] ?? 0; if ($rotate !== 0) { $rotated = imagerotate($image, $rotate, 0); if ($rotated instanceof GdImage) $image = $rotated; } if (in_array($orientation, [2, 5, 7], true)) imageflip($image, IMG_FLIP_HORIZONTAL); if ($orientation === 4) imageflip($image, IMG_FLIP_VERTICAL); } $width = imagesx($image); $height = imagesy($image); $longest = max($width, $height); if ($longest > RAIJU_AGENT_IMAGE_MAX_SIDE) { $scaled = imagescale($image, (int)max(1, round($width * RAIJU_AGENT_IMAGE_MAX_SIDE / $longest)), (int)max(1, round($height * RAIJU_AGENT_IMAGE_MAX_SIDE / $longest))); if ($scaled instanceof GdImage) $image = $scaled; $width = imagesx($image); $height = imagesy($image); } imagealphablending($image, false); imagesavealpha($image, true); ob_start(); if ($support['webp']) { imagewebp($image, null, 82); $ext = 'webp'; $outMime = 'image/webp'; } else { // JPEG has no alpha: flatten onto white. $flat = imagecreatetruecolor($width, $height); imagefill($flat, 0, 0, imagecolorallocate($flat, 255, 255, 255)); imagealphablending($flat, true); imagecopy($flat, $image, 0, 0, 0, 0, $width, $height); imagejpeg($flat, null, 85); $ext = 'jpg'; $outMime = 'image/jpeg'; } $out = (string)ob_get_clean(); } finally { unset($image); } if ($out === '' || strlen($out) > $maxBytes) throw new RuntimeException('The image could not be re-encoded.'); return ['binary' => $out, 'ext' => $ext, 'mime' => $outMime, 'width' => $width, 'height' => $height, 'ai' => $ai, 'bytes' => strlen($out)]; } // --------------------------------------------------------------------------- // Staging and registration // --------------------------------------------------------------------------- function raijuAgentMediaStagingDir(string $csId): string { return raijuAgentDir() . '/staging/' . $csId; } function raijuAgentMediaSlug(string $name): string { $name = strtolower(pathinfo($name, PATHINFO_FILENAME)); if (function_exists('iconv')) { $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', strtr($name, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'])); if (is_string($converted) && $converted !== '') $name = $converted; } $slug = trim(preg_replace('/[^a-z0-9]+/', '-', $name) ?? '', '-'); return substr($slug !== '' ? $slug : 'image', 0, 48); } /** Library folder for a source: generated images keep their usual folder, everything else goes to the agent folder. */ function raijuAgentMediaFolder(string $source): string { return $source === 'generated' ? 'generated' : raijuAgentSettings()['mediaFolder']; } /** * Store an ingested image in the staging folder and register it in the change set. * * @param array $record source (upload|stock|generated), name (original name or hint), credit?, ai * @return array the media record, including its reference 'ref' */ function raijuAgentMediaRegister(string $csId, array $image, array $record): array { $registered = null; $error = null; $stored = raijuAgentChangesetMutate($csId, static function (array &$cs) use ($image, $record, &$registered, &$error) { if ($cs['status'] !== 'staging') { $error = 'This change set is ' . $cs['status'] . '.'; return false; } if (count($cs['media']) >= RAIJU_AGENT_MAX_MEDIA_PER_CHANGESET) { $error = 'This run already has the maximum number of images.'; return false; } do { $ref = 'm_' . bin2hex(random_bytes(4)); } while (isset($cs['media'][$ref])); $source = in_array($record['source'] ?? '', ['upload', 'stock', 'generated'], true) ? $record['source'] : 'upload'; $folder = raijuAgentMediaFolder($source); $imagesRoot = raijuAgentSiteRoot() . '/assets/images'; do { $finalName = raijuAgentMediaSlug((string)($record['name'] ?? 'image')) . '-' . bin2hex(random_bytes(2)) . '.' . $image['ext']; $final = '/assets/images/' . $folder . '/' . $finalName; $taken = is_file($imagesRoot . '/' . $folder . '/' . $finalName); foreach ($cs['media'] as $other) $taken = $taken || ($other['finalPath'] ?? '') === $final; } while ($taken); $dir = raijuAgentMediaStagingDir($cs['id']); $stagingFile = $dir . '/' . $ref . '.' . $image['ext']; if (!raijuAgentRawWrite($stagingFile, $image['binary'])) { $error = 'Could not store the image.'; return false; } $entry = [ 'source' => $source, 'name' => mb_substr(preg_replace('/[^\p{L}\p{N}._ -]+/u', '', (string)($record['name'] ?? '')) ?? '', 0, 80, 'UTF-8'), 'stagingFile' => raijuAgentRelative($stagingFile), 'finalPath' => $final, 'width' => $image['width'], 'height' => $image['height'], 'bytes' => $image['bytes'], 'mime' => $image['mime'], 'ai' => in_array($record['ai'] ?? '', ['generated', 'modified'], true) ? $record['ai'] : '', 'credit' => array_intersect_key((array)($record['credit'] ?? []), array_flip(['source', 'author', 'authorUrl', 'sourceUrl', 'license', 'attribution'])), 'importedAt' => date('c'), 'importedBy' => (string)$cs['owner'], ]; // Sidecar next to the image: what it is and where it came from, without the bytes. raijuJsonAtomicWrite($dir . '/' . $ref . '.json', ['ref' => $ref] + $entry); $cs['media'][$ref] = $entry; $registered = ['ref' => $ref] + $entry; return true; }); if ($stored === null) throw new RuntimeException('Unknown change set.'); if ($registered === null) throw new RuntimeException($error ?? 'Could not register the image.'); return $registered; } /** What the model and the browser may know about a staged image. */ function raijuAgentMediaSummary(string $ref, array $media): array { $summary = ['mediaRef' => $ref, 'source' => $media['source'], 'width' => $media['width'], 'height' => $media['height']]; if (($media['name'] ?? '') !== '') $summary['name'] = $media['name']; if (!empty($media['ai'])) $summary['ai'] = $media['ai']; if (!empty($media['credit']['attribution'])) $summary['credit'] = $media['credit']['attribution']; return $summary; } // --------------------------------------------------------------------------- // Credit text // --------------------------------------------------------------------------- /** "Photo: Jane Doe / Pexels" in the language of the page, or '' when the image needs no credit. */ function raijuAgentCreditText(array $credit, string $lang): string { $author = trim(strip_tags((string)($credit['author'] ?? ''))); $source = trim(strip_tags((string)($credit['source'] ?? ''))); if ($author === '' && $source === '') return ''; $label = ['de' => 'Foto', 'fr' => 'Photo', 'es' => 'Foto', 'it' => 'Foto', 'pt' => 'Foto', 'tr' => 'Fotoğraf', 'cs' => 'Foto', 'pl' => 'Zdjęcie'][$lang] ?? 'Photo'; return $label . ': ' . implode(' / ', array_filter([$author, ucfirst($source)])); } // --------------------------------------------------------------------------- // Apply and undo // --------------------------------------------------------------------------- /** Whether a public image path is used by any content file other than the ones in $except. */ function raijuAgentMediaReferenced(string $publicPath, array $except = []): bool { $root = raijuAgentSiteRoot() . '/content'; $files = array_merge(glob($root . '/pages/*.json') ?: [], glob($root . '/news/*.json') ?: [], [$root . '/events.json']); foreach ($files as $file) { if (in_array($file, $except, true) || !is_file($file)) continue; $content = (string)file_get_contents($file); // Older files and some editors write "\/" for "/". if (str_contains($content, $publicPath) || str_contains($content, str_replace('/', '\\/', $publicPath))) return true; } return false; } /** * Move the staged images that the applied documents use into the media library, with their * credit and AI label. All or nothing: on failure everything moved so far goes back. * * @param array $changed file => {after} from raijuAgentChangedFiles() * @return array the moved images * @throws RuntimeException */ function raijuAgentApplyMedia(array $cs, array $changed): array { $json = ''; foreach ($changed as $change) $json .= (string)json_encode($change['after'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $moved = []; try { foreach ($cs['media'] as $ref => $media) { if (!str_contains($json, $media['finalPath'])) continue; // staged but not used: stays out of the library $from = raijuAgentAbsolute((string)($media['stagingFile'] ?? '')); $to = raijuAgentSiteRoot() . $media['finalPath']; if (!is_file($from)) throw new RuntimeException("The staged image {$ref} is missing."); if (is_file($to)) throw new RuntimeException('An image named ' . basename($to) . ' already exists in the library.'); if (!is_dir(dirname($to)) && !@mkdir(dirname($to), 0755, true) && !is_dir(dirname($to))) throw new RuntimeException('Could not create the library folder.'); if (!@rename($from, $to) && !(@copy($from, $to) && @unlink($from))) throw new RuntimeException('Could not move the image into the library.'); $name = substr($media['finalPath'], strlen('/assets/images/')); $moved[] = ['ref' => $ref, 'name' => $name, 'path' => $to, 'staging' => $from]; if (!empty($media['ai']) && !raijuSetMediaAiLabel('image', $name, (string)$media['ai'])) throw new RuntimeException('Could not store the AI label.'); if ($media['credit'] && !raijuSetMediaCredit('image', $name, $media['credit'] + ['importedAt' => $media['importedAt'], 'importedBy' => $media['importedBy']])) { throw new RuntimeException('Could not store the image credit.'); } } } catch (Throwable $error) { raijuAgentRollbackMedia($moved); throw $error instanceof RuntimeException ? $error : new RuntimeException($error->getMessage(), 0, $error); } return $moved; } /** Put moved images back into the staging folder and forget their metadata. */ function raijuAgentRollbackMedia(array $moved): void { foreach (array_reverse($moved) as $entry) { if (is_file($entry['path']) && !is_file($entry['staging'])) { @mkdir(dirname($entry['staging']), 0755, true); @rename($entry['path'], $entry['staging']); } raijuForgetMediaMeta('image', $entry['name']); } } /** * After an undo: images this change set brought into the library go to the media trash, * unless other content uses them now. * * @return string[] names moved to the trash */ function raijuAgentUndoMedia(array $appliedMedia): array { $trashed = []; $trash = raijuAgentSiteRoot() . '/assets/images-trash'; foreach ($appliedMedia as $entry) { $publicPath = '/assets/images/' . $entry['name']; $file = raijuAgentSiteRoot() . $publicPath; if (!is_file($file) || raijuAgentMediaReferenced($publicPath)) continue; $target = $trash . '/' . $entry['name']; if (!is_dir(dirname($target)) && !@mkdir(dirname($target), 0755, true)) continue; if (is_file($target)) $target = dirname($target) . '/' . pathinfo($target, PATHINFO_FILENAME) . '-' . bin2hex(random_bytes(2)) . '.' . pathinfo($target, PATHINFO_EXTENSION); if (@rename($file, $target)) { raijuMoveMediaMeta('image', $entry['name'], 'image-trash', substr($target, strlen($trash) + 1)); $trashed[] = $entry['name']; } } return $trashed; } // --------------------------------------------------------------------------- // Stock photos (Pexels first; the interface is search + fetch) // --------------------------------------------------------------------------- /** Keys come from the raw ai-settings.json, like the other agent settings (the settings UI follows in P8). */ function raijuAgentStockSettings(): array { $stored = raijuAiReadStoredSettings(); $raw = is_array($stored['stock'] ?? null) ? $stored['stock'] : []; $key = is_string($raw['pexels']['apiKey'] ?? null) ? trim($raw['pexels']['apiKey']) : ''; return ['provider' => 'pexels', 'apiKey' => $key, 'available' => $key !== '']; } /** * Network options of the stock provider. Tests may redirect it to a loopback mock through * RAIJU_AGENT_TEST_NET (JSON: apiBase, resolve {host: [ip]}, ports, connectMap); this only * works under the CLI and the built-in server. */ function raijuAgentStockNet(): array { $net = ['apiBase' => 'https://api.pexels.com', 'secure' => true, 'options' => []]; $test = raijuNetTestConfig(); if ($test !== null) { $net['apiBase'] = (string)($test['raw']['apiBase'] ?? $net['apiBase']); $net['secure'] = false; $net['options'] = $test['options']; } return $net; } /** * Search stock photos. * * @return array[] photos: id, alt, width, height, photographer, photographerUrl, pageUrl, downloadUrl, previewUrl * @throws RuntimeException */ function raijuAgentStockSearch(string $query, string $orientation = '', int $count = 6): array { $settings = raijuAgentStockSettings(); if (!$settings['available']) throw new RuntimeException('Stock photos are not set up on this site.'); $net = raijuAgentStockNet(); $params = ['query' => mb_substr($query, 0, 200, 'UTF-8'), 'per_page' => max(1, min(8, $count)), 'size' => 'large']; if (in_array($orientation, ['landscape', 'portrait', 'square'], true)) $params['orientation'] = $orientation; try { $response = raijuNetSafeFetch($net['apiBase'] . '/v1/search?' . http_build_query($params), $net['options'] + [ 'allowedHosts' => ['api.pexels.com'], 'allowedContentTypes' => ['application/json'], 'maxBytes' => 1048576, 'timeout' => 15, 'headers' => ['Authorization: ' . $settings['apiKey']], 'accept' => 'application/json', 'userAgent' => 'raiju-cms/1.0', ]); } catch (RaijuNetException $e) { throw new RuntimeException($e->reason === 'http_status' && in_array($e->httpStatus, [401, 403], true) ? 'The stock photo service refused the API key.' : ($e->httpStatus === 429 ? 'The stock photo service is busy. Try again later.' : 'The stock photo search failed.'), 0, $e); } $data = json_decode($response['body'], true); $photos = []; foreach ((array)($data['photos'] ?? []) as $photo) { if (!is_array($photo) || !isset($photo['id'], $photo['src']) || !is_array($photo['src'])) continue; $download = (string)($photo['src']['large2x'] ?? $photo['src']['large'] ?? ''); if ($download === '') continue; $photos[] = [ 'id' => (string)$photo['id'], 'alt' => mb_substr(trim(strip_tags((string)($photo['alt'] ?? ''))), 0, 200, 'UTF-8'), 'width' => (int)($photo['width'] ?? 0), 'height' => (int)($photo['height'] ?? 0), 'photographer' => mb_substr(trim(strip_tags((string)($photo['photographer'] ?? ''))), 0, 100, 'UTF-8'), 'photographerUrl' => (string)($photo['photographer_url'] ?? ''), 'pageUrl' => (string)($photo['url'] ?? ''), 'downloadUrl' => $download, 'previewUrl' => (string)($photo['src']['medium'] ?? ''), ]; } return $photos; } /** * Download a stock photo found by raijuAgentStockSearch() and ingest it. * * @return array{image:array,credit:array} * @throws RuntimeException */ function raijuAgentStockFetch(array $photo): array { $net = raijuAgentStockNet(); $url = (string)$photo['downloadUrl']; if ($net['secure'] && !str_starts_with($url, 'https://')) throw new RuntimeException('The stock photo address is not secure.'); try { $response = raijuNetSafeFetch($url, $net['options'] + [ 'allowedHosts' => ['images.pexels.com'], 'allowedContentTypes' => ['image/jpeg', 'image/png', 'image/webp'], 'maxBytes' => RAIJU_AGENT_IMAGE_MAX_BYTES, 'timeout' => 25, 'accept' => 'image/jpeg,image/png,image/webp', 'userAgent' => 'raiju-cms/1.0', ]); } catch (RaijuNetException $e) { throw new RuntimeException('The stock photo could not be downloaded.', 0, $e); } $tmp = tempnam(sys_get_temp_dir(), 'raiju-stock-'); if ($tmp === false) throw new RuntimeException('Could not create a temporary file.'); try { file_put_contents($tmp, $response['body']); $image = raijuAgentIngestImage($tmp); } finally { @unlink($tmp); } $credit = [ 'source' => 'pexels', 'author' => $photo['photographer'], 'authorUrl' => $photo['photographerUrl'], 'sourceUrl' => $photo['pageUrl'], 'license' => 'Pexels License', 'attribution' => 'Photo by ' . $photo['photographer'] . ' on Pexels', ]; return ['image' => $image, 'credit' => $credit]; }