'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'video/mp4' => 'mp4', 'video/matroska' => 'mkv', 'video/webm' => 'webm']; /** * $_FILESの1件を保存する * * @param array $file $_FILES['name']の1件(name, tmp_name, size, error) * @param string $dir 保存先(ASS_DOMAINの下、例:「media/ユーザー」) * @param string $prefix ファイル名の接頭辞 * @param bool $uploaded is_uploaded_fileで確認するか(テストではfalse) * @param bool $video 動画も受け付けるか(アイコンは画像だけ) * @return \Result data:path(public/からのパス)、url、type、width、height */ public static function save(array $file, string $dir, string $prefix = '', bool $uploaded = true, bool $video = false): \Result { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { return \Result::Error(($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE || ($file['error'] ?? 0) === UPLOAD_ERR_FORM_SIZE ? 'ファイルが大き過ぎます。' : 'ファイルのアップロードに失敗しました。'); } $tmp = (string)($file['tmp_name'] ?? ''); if ($uploaded && !is_uploaded_file($tmp)) return \Result::Error('ファイルのアップロードに失敗しました。'); $size = filesize($tmp); if ($size === false || $size === 0) return \Result::Error('ファイルが空です。'); if ($size > ($video ? self::MAX_VIDEO_BYTES : self::MAX_BYTES)) return \Result::Error(self::tooLarge($video)); // 種類は先頭だけで分かる(動画は数百MiBになるので、全部は読まない) $fh = fopen($tmp, 'rb'); $type = self::detect($fh ? (string)fread($fh, 64) : ''); if ($fh) fclose($fh); $isVideo = $type !== null && str_starts_with($type, 'video/'); if ($type === null || ($isVideo && !$video)) { return \Result::Error($video ? 'PNG・JPEG・GIF・WebPの画像と、MP4・MKV・WebMの動画だけアップロード出来ます。' : 'PNG・JPEG・GIF・WebPの画像だけアップロード出来ます。'); } if (!$isVideo && $size > self::MAX_BYTES) return \Result::Error(self::tooLarge(false)); $rel = ass_path(trim($dir, '/')).'/'; $abs = ROOT.$rel; if (!is_dir($abs) && !@mkdir($abs, 0755, true) && !is_dir($abs)) return \Result::Error('保存先を作れません。'); $name = $prefix.bin2hex(random_bytes(12)).'.'.self::TYPES[$type]; if ($isVideo) { // 動画はファイルのまま移して、その場でメタデータを消す if (!($uploaded ? move_uploaded_file($tmp, $abs.$name) : copy($tmp, $abs.$name))) return \Result::Error('ファイルの保存に失敗しました。'); if ($type === 'video/mp4' && !self::stripFile($abs.$name)) { @unlink($abs.$name); return \Result::Error('動画を読めません。'); } } else { $data = self::strip((string)file_get_contents($tmp), $type); if (file_put_contents($abs.$name, $data) === false) return \Result::Error('ファイルの保存に失敗しました。'); } @chmod($abs.$name, 0644); // 大きさ(読めなくても保存は成功) $w = $h = 0; if (!$isVideo) { try { $img = new Image($rel.$name); $w = (int)($img->fullInfo->width ?? 0); $h = (int)($img->fullInfo->height ?? 0); } catch (\Throwable $e) { } } return \Result::Success('', [ 'name' => $name, 'path' => $rel.$name, 'url' => ass_url(trim($dir, '/').'/'.$name, Fedi::base()), 'type' => $type, 'width' => $w, 'height' => $h, ]); } /** * ファイルの中身から種類を判断する */ public static function detect(string $data): ?string { if (str_starts_with($data, "\x89PNG\r\n\x1a\n")) return 'image/png'; if (str_starts_with($data, "\xff\xd8\xff")) return 'image/jpeg'; if (str_starts_with($data, 'GIF87a') || str_starts_with($data, 'GIF89a')) return 'image/gif'; if (str_starts_with($data, 'RIFF') && substr($data, 8, 4) === 'WEBP') return 'image/webp'; // ISO BMFF:AVIF・HEIC(画像)とQuickTime(ブラウザで再生出来ない事が多い)は除く if (substr($data, 4, 4) === 'ftyp') { $brand = substr($data, 8, 4); return in_array($brand, ['avif', 'avis', 'heic', 'heix', 'mif1', 'msf1', 'qt '], true) ? null : 'video/mp4'; } // Matroska(DocTypeでWebMと分ける) if (str_starts_with($data, "\x1a\x45\xdf\xa3")) { $head = substr($data, 0, 64); if (str_contains($head, "\x42\x82\x84webm")) return 'video/webm'; if (str_contains($head, "\x42\x82\x88matroska")) return 'video/matroska'; } return null; } private static function tooLarge(bool $video): string { return $video ? 'ファイルが大き過ぎます(画像は'.(self::MAX_BYTES >> 20).'MiB、動画は'.(self::MAX_VIDEO_BYTES >> 20).'MiBまで)。' : 'ファイルが大き過ぎます('.(self::MAX_BYTES >> 20).'MiBまで)。'; } /** * メタデータを取り除く * * JPEG:APP1〜APP15(Exif、XMP等)とCOMを消す(APP0のJFIFは残す) * PNG:eXIf、tEXt、zTXt、iTXt、tIMEを消す * WebP:EXIFとXMPのチャンクを消す(VP8Xの印も外す) * MP4:udta、meta、uuid(位置、機種、XMP等)を同じ大きさの「free」にし、mvhd・tkhd・mdhdの日時を0にする */ public static function strip(string $data, string $type): string { if ($type === 'video/mp4') { $fh = fopen('php://memory', 'w+b'); fwrite($fh, $data); self::blankBoxes($fh, 0, strlen($data), 0); rewind($fh); $data = (string)stream_get_contents($fh); fclose($fh); return $data; } if ($type === 'image/jpeg') { $out = substr($data, 0, 2); $pos = 2; $len = strlen($data); while ($pos + 4 <= $len && $data[$pos] === "\xff") { $marker = ord($data[$pos + 1]); // SOS(画像データの始まり)以降はそのまま if ($marker === 0xda) break; $seg = unpack('n', substr($data, $pos + 2, 2))[1]; if ($seg < 2) break; $isMeta = ($marker >= 0xe1 && $marker <= 0xef) || $marker === 0xfe; if (!$isMeta) $out .= substr($data, $pos, $seg + 2); $pos += $seg + 2; } return $out.substr($data, $pos); } if ($type === 'image/png') { $out = substr($data, 0, 8); $pos = 8; $len = strlen($data); while ($pos + 12 <= $len) { $clen = unpack('N', substr($data, $pos, 4))[1]; $ctype = substr($data, $pos + 4, 4); $total = $clen + 12; if ($pos + $total > $len) break; if (!in_array($ctype, ['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME'], true)) $out .= substr($data, $pos, $total); $pos += $total; } return $out; } if ($type === 'image/webp') { $out = ''; $pos = 12; $len = min(strlen($data), 8 + unpack('V', substr($data, 4, 4) ?: "\0\0\0\0")[1]); while ($pos + 8 <= $len) { $ctype = substr($data, $pos, 4); $total = 8 + unpack('V', substr($data, $pos + 4, 4))[1]; $total += $total & 1; if ($ctype === 'VP8X') { $chunk = substr($data, $pos, $total); if (strlen($chunk) > 8) $chunk[8] = chr(ord($chunk[8]) & ~0x0c); $out .= $chunk; } else if ($ctype !== 'EXIF' && $ctype !== 'XMP ') { $out .= substr($data, $pos, $total); } $pos += $total; } return 'RIFF'.pack('V', 4 + strlen($out)).'WEBP'.$out; } return $data; } /** * 保存したMP4のメタデータを消す(ファイルの中で書き換える) */ public static function stripFile(string $path): bool { $size = filesize($path); $fh = $size ? fopen($path, 'r+b') : false; if (!$fh) return false; self::blankBoxes($fh, 0, $size, 0); return fclose($fh); } /** * MP4の箱を辿り、メタデータの箱の中身を0にして「free」にする * 大きさを変えないので、サンプルの位置(stco・co64)を直さなくて良い * * @param resource $fh 読み書き出来るファイル */ private static function blankBoxes($fh, int $pos, int $end, int $depth): void { while ($pos + 8 <= $end) { fseek($fh, $pos); $hdr = (string)fread($fh, 16); if (strlen($hdr) < 8) return; $size = unpack('N', substr($hdr, 0, 4))[1]; $type = substr($hdr, 4, 4); $head = 8; if ($size === 1) { if (strlen($hdr) < 16) return; $size = unpack('J', substr($hdr, 8, 8))[1]; $head = 16; } else if ($size === 0) { $size = $end - $pos; } if ($size < $head || $pos + $size > $end) return; if (in_array($type, ['udta', 'meta', 'uuid'], true)) { fseek($fh, $pos + 4); fwrite($fh, 'free'); self::zero($fh, $pos + $head, $size - $head); } else if (in_array($type, ['mvhd', 'tkhd', 'mdhd'], true) && $size >= $head + 20) { // 作成・変更日時(バージョン1は64ビット) fseek($fh, $pos + $head); $len = fread($fh, 1) === "\1" ? 16 : 8; self::zero($fh, $pos + $head + 4, $len); } else if ($depth < 4 && in_array($type, ['moov', 'trak', 'mdia', 'minf'], true)) { self::blankBoxes($fh, $pos + $head, $pos + $size, $depth + 1); } $pos += $size; } } /** $posから$len個の0を書く(1MiBずつ) */ private static function zero($fh, int $pos, int $len): void { fseek($fh, $pos); $chunk = str_repeat("\0", min($len, 1048576)); while ($len > 0) { $n = min($len, strlen($chunk)); fwrite($fh, $n === strlen($chunk) ? $chunk : substr($chunk, 0, $n)); $len -= $n; } } /** * 保存した画像を消す(public/からのパス) */ public static function remove(string $path): void { $abs = realpath(ROOT.$path); $root = realpath(ROOT.ass_path()); if ($abs && $root && str_starts_with($abs, $root.DIRECTORY_SEPARATOR) && is_file($abs)) @unlink($abs); } }