テーマ
守矢諏訪子 <suwako@076.moe>、2026-11-11 01:16
コミット da1e7040fb3c8ea447a8161153cf610513cb24a3
・親 45df974
・ファイルを見る
10個のファイル、+367 -48
変更
README-JP.md
| @@ -215,7 +215,7 @@ | ||
| 215 | 215 | `FEDIINFO['isSingleUser']` を `false` にすると(`AUTH_ENABLED` と `FEDIINFO['domain']` も必要)マルチユーザーモードになります: |
| 216 | 216 | * ブログのアクターは今まで通り動きます。 |
| 217 | 217 | * Fediverse のロールを持つユーザーはそれぞれアクター(`@ユーザー名@ドメイン`)になります。ロールは BAN されたユーザー以外の全員に付与され、管理者はユーザーのプロフィールの編集画面で外せます。 |
| 218 | -* `/microblog` にはホーム・ローカル・連合のタイムライン、投稿(注意書き、公開範囲、画像4枚まで)、返信、いいね、ブースト、フォロー(承認制も可)、ブロック、通報、検索(`@user@host`、投稿の URL、`#タグ`、文字)があります。JavaScript は不要です。 | |
| 218 | +* `/microblog` にはホーム・ローカル・連合のタイムライン、投稿(注意書き、公開範囲、画像・動画4つまで)、返信、いいね、ブースト、フォロー(承認制も可)、ブロック、通報、検索(`@user@host`、投稿の URL、`#タグ`、文字)があります。JavaScript は不要です。 | |
| 219 | 219 | * 管理者は `/microblog/admin` でサーバーごとブロックしたり、通報を確認したり出来ます。 |
| 220 | 220 | |
| 221 | 221 | 配信は待ち行列に入るので、cron のジョブを追加して下さい: |
| @@ -223,7 +223,8 @@ | ||
| 223 | 223 | php cron.php add "* * * * *" apdeliver |
| 224 | 224 | php cron.php add "@daily" apcleanup |
| 225 | 225 | ``` |
| 226 | -アップロードした画像(PNG・JPEG・GIF、4 MiB まで。PHP の `upload_max_filesize` もご確認下さい)からはメタデータ(Exif、テキスト)を取り除きます。 | |
| 226 | +アップロード出来るのは画像(PNG・JPEG・GIF、20 MiB まで)と動画(MP4・MKV・WebM、400 MiB まで)で、メタデータ(Exif、テキスト、MP4 の撮影日時・位置等)を取り除きます。動画は変換しないので、ブラウザで再生出来る形式(H.264 等)でアップロードして下さい。\ | |
| 227 | +大きいファイルを受け付ける様に、PHP の `upload_max_filesize` と `post_max_size`(添付4つ分)、httpd の `connection max request body`(既定は1 MiB、超えると「413 Payload Too Large」)も設定して下さい。動画を再生出来る様に、httpd の `types` に `include "/usr/share/misc/mime.types"` もお勧めします。 | |
| 227 | 228 | 他のサーバーのアイコンや画像は `/microblog/media` 経由で表示するので、CSP の `img-src` は `'self'` のままで構いません(閲覧者の IP アドレスも相手のサーバーに渡りません)。画像は `data/ap/remote/media/` に1週間キャッシュします。\ |
| 228 | 229 | ユーザーの鍵は初めて使う時に作ります。chroot(OpenBSD の httpd と php-fpm 等)では `openssl.cnf` が見えないので、`data/ap/openssl.cnf`(空)を使います。 |
| 229 | 230 |
| @@ -145,6 +145,35 @@ | ||
| 145 | 145 | margin: 4px 0; |
| 146 | 146 | } |
| 147 | 147 | |
| 148 | +.mb-content blockquote { | |
| 149 | + margin: 4px 0; | |
| 150 | + padding-left: 8px; | |
| 151 | + border-left: 3px solid #550f75; | |
| 152 | +} | |
| 153 | + | |
| 154 | +.mb-content pre { | |
| 155 | + overflow-x: auto; | |
| 156 | + margin: 4px 0; | |
| 157 | + padding: 4px 8px; | |
| 158 | + background-color: #2b012a; | |
| 159 | + border-radius: 2px; | |
| 160 | +} | |
| 161 | + | |
| 162 | +.mb-content code { | |
| 163 | + background-color: #2b012a; | |
| 164 | + border-radius: 2px; | |
| 165 | + padding: 0 3px; | |
| 166 | +} | |
| 167 | + | |
| 168 | +.mb-content pre code { | |
| 169 | + padding: 0; | |
| 170 | +} | |
| 171 | + | |
| 172 | +.mb-content ul, .mb-content ol { | |
| 173 | + margin: 4px 0; | |
| 174 | + padding-left: 24px; | |
| 175 | +} | |
| 176 | + | |
| 148 | 177 | .mb-content .invisible { |
| 149 | 178 | display: none; |
| 150 | 179 | } |
| @@ -183,9 +212,9 @@ | ||
| 183 | 212 | } |
| 184 | 213 | |
| 185 | 214 | .mb-actions button, .mb-button, .mb-row button, .mb-compose button { |
| 186 | - background: #232023; | |
| 215 | + background: #2b012a; | |
| 187 | 216 | color: #fcfcfc; |
| 188 | - border: 1px solid #c016c6; | |
| 217 | + border: 1px solid #ea79d8; | |
| 189 | 218 | border-radius: 2px; |
| 190 | 219 | padding: 2px 8px; |
| 191 | 220 | cursor: pointer; |
| @@ -193,7 +222,7 @@ | ||
| 193 | 222 | text-decoration: none; |
| 194 | 223 | } |
| 195 | 224 | |
| 196 | -.mb-actions button.on, .mb-row button.on, .mb-compose button { | |
| 225 | +.mb-actions button.on, .mb-row button.on, .mb-compose button, .mb-button:hover, .mb-actions button:hover, .mb-row button:hover { | |
| 197 | 226 | background: #550f75; |
| 198 | 227 | } |
| 199 | 228 |
| @@ -75,6 +75,52 @@ | ||
| 75 | 75 | border-radius: 2px; |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | +/* 入力欄とボタン(ブラウザの白い既定を使わない。:where()なので、個別の指定が優先される) */ | |
| 79 | +html { | |
| 80 | + color-scheme: dark; | |
| 81 | + accent-color: #c016c6; | |
| 82 | +} | |
| 83 | + | |
| 84 | +:where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="image"]), textarea, select, button) { | |
| 85 | + background-color: #2b012a; | |
| 86 | + color: #fcfcfc; | |
| 87 | + border: 1px solid #ea79d8; | |
| 88 | + border-radius: 2px; | |
| 89 | + padding: 2px 6px; | |
| 90 | +} | |
| 91 | + | |
| 92 | +::file-selector-button { | |
| 93 | + background-color: #2b012a; | |
| 94 | + color: #fcfcfc; | |
| 95 | + border: 1px solid #ea79d8; | |
| 96 | + border-radius: 2px; | |
| 97 | + padding: 2px 6px; | |
| 98 | + cursor: pointer; | |
| 99 | +} | |
| 100 | + | |
| 101 | +:where(button, input[type="submit"], input[type="button"], input[type="reset"]) { | |
| 102 | + cursor: pointer; | |
| 103 | +} | |
| 104 | + | |
| 105 | +:where(button, input[type="submit"], input[type="button"], input[type="reset"]):hover, ::file-selector-button:hover { | |
| 106 | + background-color: #550f75; | |
| 107 | +} | |
| 108 | + | |
| 109 | +:where(input, textarea, select, button):focus-visible { | |
| 110 | + outline: 2px solid #ea79d8; | |
| 111 | + outline-offset: 1px; | |
| 112 | +} | |
| 113 | + | |
| 114 | +:where(input, textarea, select, button):disabled { | |
| 115 | + opacity: 0.6; | |
| 116 | + cursor: default; | |
| 117 | +} | |
| 118 | + | |
| 119 | +::placeholder { | |
| 120 | + color: #b0a0b0; | |
| 121 | + opacity: 1; | |
| 122 | +} | |
| 123 | + | |
| 78 | 124 | @media screen and (max-width: 768px) { |
| 79 | 125 | header, main, footer { |
| 80 | 126 | margin: 10px; |
| @@ -255,7 +255,9 @@ | ||
| 255 | 255 | $back = safe_local_path($_POST['back'] ?? '', '/microblog'); |
| 256 | 256 | $error = ''; |
| 257 | 257 | |
| 258 | - if (!$this->csrfOk()) { | |
| 258 | + if (empty($_POST) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { | |
| 259 | + $error = '送信したファイルが大き過ぎます(サーバーの上限を超えました)。'; | |
| 260 | + } else if (!$this->csrfOk()) { | |
| 259 | 261 | $error = 'セッションの有効期限が切れました。もう一度送信して下さい。'; |
| 260 | 262 | } else { |
| 261 | 263 | // 添付 |
| @@ -266,7 +268,7 @@ | ||
| 266 | 268 | if (($files['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue; |
| 267 | 269 | if (count($media) >= Fedi::MAX_ATTACHMENTS) break; |
| 268 | 270 | $one = ['name' => $name, 'tmp_name' => $files['tmp_name'][$i], 'size' => $files['size'][$i], 'error' => $files['error'][$i]]; |
| 269 | - $res = Media::save($one, 'media/'.$this->me); | |
| 271 | + $res = Media::save($one, 'media/'.$this->me, video: true); | |
| 270 | 272 | if (!$res->isSuccess) { |
| 271 | 273 | $error = $res->message; |
| 272 | 274 | break; |
| @@ -40,7 +40,7 @@ | ||
| 40 | 40 | /** |
| 41 | 41 | * 投稿の本文のHTML |
| 42 | 42 | * |
| 43 | - * render():ローカルの投稿(プレーンテキスト)をHTMLにする(リンク、メンション、ハッシュタグ) | |
| 43 | + * render():ローカルの投稿(Markdown)をHTMLにする(リンク、メンション、ハッシュタグ) | |
| 44 | 44 | * sanitize():リモートの投稿のHTMLを許可したタグだけにする(XSS対策) |
| 45 | 45 | */ |
| 46 | 46 | final class Html { |
| @@ -56,7 +56,7 @@ | ||
| 56 | 56 | private const CLASSES = ['mention', 'hashtag', 'h-card', 'u-url', 'invisible', 'ellipsis']; |
| 57 | 57 | |
| 58 | 58 | // 区切り文字なしの正規表現(組み合わせて使う) |
| 59 | - private const URL = '\bhttps?://[^\s<>"\' ]+'; | |
| 59 | + private const URL = '\bhttps?://[^\s<>"\' \x00]+'; | |
| 60 | 60 | private const MENTION = '(?<![\w@/])@([A-Za-z0-9_]+)(?:@([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?::\d+)?))?(?![@\w])'; |
| 61 | 61 | private const TAG = '(?<![\w#&/])#([\p{L}\p{N}_]{1,64})'; |
| 62 | 62 | private const URL_RE = '~'.self::URL.'~u'; |
| @@ -69,7 +69,7 @@ | ||
| 69 | 69 | * @return array [「@user」又は「@user@domain」 => [user, domain|null]] |
| 70 | 70 | */ |
| 71 | 71 | public static function mentions(string $text): array { |
| 72 | - $text = preg_replace(self::URL_RE, ' ', $text); | |
| 72 | + $text = preg_replace(self::URL_RE, ' ', self::withoutCode($text)); | |
| 73 | 73 | preg_match_all(self::MENTION_RE, $text, $m, PREG_SET_ORDER); |
| 74 | 74 | $out = []; |
| 75 | 75 | foreach ($m as $x) $out[$x[0]] = [$x[1], isset($x[2]) && $x[2] !== '' ? strtolower($x[2]) : null]; |
| @@ -82,22 +82,88 @@ | ||
| 82 | 82 | * @return array タグ(「#」なし、小文字、重複なし) |
| 83 | 83 | */ |
| 84 | 84 | public static function hashtags(string $text): array { |
| 85 | - $text = preg_replace(self::URL_RE, ' ', $text); | |
| 85 | + $text = preg_replace(self::URL_RE, ' ', self::withoutCode($text)); | |
| 86 | 86 | preg_match_all(self::TAG_RE, $text, $m); |
| 87 | 87 | return array_values(array_unique(array_map(fn($t) => mb_strtolower($t), $m[1]))); |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | 90 | /** |
| 91 | - * プレーンテキストをHTMLにする | |
| 91 | + * 本文(Markdown)をHTMLにする | |
| 92 | 92 | * |
| 93 | 93 | * @param string $text 本文 |
| 94 | 94 | * @param array $mentionUrls 「@user@domain」 => アクターのURL(解決出来たメンションだけリンクにする) |
| 95 | 95 | * @return string HTML |
| 96 | 96 | */ |
| 97 | 97 | public static function render(string $text, array $mentionUrls = []): string { |
| 98 | - $text = str_replace(["\r\n", "\r"], "\n", trim($text)); | |
| 99 | - $base = Fedi::base(); | |
| 98 | + $text = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], trim($text)); | |
| 99 | + return self::blocks(explode("\n", $text), $mentionUrls, 0); | |
| 100 | + } | |
| 100 | 101 | |
| 102 | + /** | |
| 103 | + * Markdownのブロック:段落(空行で区切り、改行は<br>)、```コード```、> 引用、- と 1. のリスト | |
| 104 | + * 見出し・表・画像は他のサーバー(Mastodon等)で表示出来ないので扱わない | |
| 105 | + */ | |
| 106 | + private static function blocks(array $lines, array $mentionUrls, int $depth): string { | |
| 107 | + $out = ''; | |
| 108 | + $para = []; | |
| 109 | + $flush = function() use (&$para, &$out, $mentionUrls): void { | |
| 110 | + if ($para) $out .= '<p>'.implode('<br />', array_map(fn($l) => self::inline($l, $mentionUrls), $para)).'</p>'; | |
| 111 | + $para = []; | |
| 112 | + }; | |
| 113 | + $n = count($lines); | |
| 114 | + for ($i = 0; $i < $n; $i++) { | |
| 115 | + $line = $lines[$i]; | |
| 116 | + if (str_starts_with($line, '```')) { | |
| 117 | + $flush(); | |
| 118 | + $code = []; | |
| 119 | + for ($i++; $i < $n && !preg_match('/^```\s*$/', $lines[$i]); $i++) $code[] = $lines[$i]; | |
| 120 | + $out .= '<pre><code>'.self::esc(implode("\n", $code)).'</code></pre>'; | |
| 121 | + continue; | |
| 122 | + } | |
| 123 | + if ($depth < 3 && preg_match('/^>/', $line)) { | |
| 124 | + $flush(); | |
| 125 | + $quote = []; | |
| 126 | + for (; $i < $n && preg_match('/^> ?(.*)$/', $lines[$i], $m); $i++) $quote[] = $m[1]; | |
| 127 | + $i--; | |
| 128 | + $out .= '<blockquote>'.self::blocks($quote, $mentionUrls, $depth + 1).'</blockquote>'; | |
| 129 | + continue; | |
| 130 | + } | |
| 131 | + if (preg_match('/^(?:[-*+]|\d{1,9}[.)])\s+\S/', $line)) { | |
| 132 | + $flush(); | |
| 133 | + $ordered = ctype_digit($line[0]); | |
| 134 | + $item = $ordered ? '/^\d{1,9}[.)]\s+(.*)$/' : '/^[-*+]\s+(.*)$/'; | |
| 135 | + $items = ''; | |
| 136 | + for (; $i < $n && preg_match($item, $lines[$i], $m); $i++) $items .= '<li>'.self::inline($m[1], $mentionUrls).'</li>'; | |
| 137 | + $i--; | |
| 138 | + $out .= $ordered ? "<ol>{$items}</ol>" : "<ul>{$items}</ul>"; | |
| 139 | + continue; | |
| 140 | + } | |
| 141 | + if (trim($line) === '') { | |
| 142 | + $flush(); | |
| 143 | + continue; | |
| 144 | + } | |
| 145 | + $para[] = $line; | |
| 146 | + } | |
| 147 | + $flush(); | |
| 148 | + return $out; | |
| 149 | + } | |
| 150 | + | |
| 151 | + /** | |
| 152 | + * 1行の中:`コード`、[文字](URL)、\記号、URL・メンション・ハッシュタグ、**太字**、*斜体*、~~取り消し線~~ | |
| 153 | + * 変換した部分は「\0番号\0」で預かり、エスケープと強調の後で戻す | |
| 154 | + */ | |
| 155 | + private static function inline(string $text, array $mentionUrls): string { | |
| 156 | + $keep = []; | |
| 157 | + $hold = function(string $html) use (&$keep): string { | |
| 158 | + $keep[] = $html; | |
| 159 | + return "\0".(count($keep) - 1)."\0"; | |
| 160 | + }; | |
| 161 | + | |
| 162 | + $text = preg_replace_callback('/`([^`]+)`/', fn($m) => $hold('<code>'.self::esc($m[1]).'</code>'), $text); | |
| 163 | + $text = preg_replace_callback('~\[([^\]]+)\]\((https?://[^\s()<>"\']+)\)~u', | |
| 164 | + fn($m) => $hold('<a href="'.self::esc($m[2]).'" rel="nofollow noopener noreferrer">'.self::emphasis(self::esc($m[1])).'</a>'), $text); | |
| 165 | + $text = preg_replace_callback('/\\\\([\\\\`*_~\[\]()#@>+-])/', fn($m) => $hold(self::esc($m[1])), $text); | |
| 166 | + | |
| 101 | 167 | // URL・メンション・ハッシュタグを1回で置き換える(URLの中の「#」や「@」を変換しない様に) |
| 102 | 168 | // グループ:1=URL、2=ユーザー、3=ドメイン、4=タグ |
| 103 | 169 | $re = '~('.self::URL.')|'.self::MENTION.'|'.self::TAG.'~u'; |
| @@ -115,25 +181,39 @@ | ||
| 115 | 181 | $pos -= strlen($whole) - strlen($url); |
| 116 | 182 | $shown = preg_replace('#^https?://#', '', $url); |
| 117 | 183 | if (mb_strlen($shown) > 40) $shown = mb_substr($shown, 0, 40).'…'; |
| 118 | - $out .= '<a href="'.self::esc($url).'" rel="nofollow noopener noreferrer">'.self::esc($shown).'</a>'; | |
| 184 | + $out .= $hold('<a href="'.self::esc($url).'" rel="nofollow noopener noreferrer">'.self::esc($shown).'</a>'); | |
| 119 | 185 | } else if (($m[2][0] ?? '') !== '') { |
| 120 | - $key = $whole; | |
| 121 | - $url = $mentionUrls[$key] ?? $mentionUrls[strtolower($key)] ?? null; | |
| 122 | - if ($url === null) { | |
| 123 | - $out .= self::esc($whole); | |
| 124 | - continue; | |
| 125 | - } | |
| 126 | - $out .= '<span class="h-card"><a href="'.self::esc($url).'" class="u-url mention">@<span>'.self::esc($m[2][0]).'</span></a></span>'; | |
| 186 | + $url = $mentionUrls[$whole] ?? $mentionUrls[strtolower($whole)] ?? null; | |
| 187 | + // 解決出来なかったメンションはリンクにしない | |
| 188 | + $out .= $hold($url === null ? self::esc($whole) | |
| 189 | + : '<span class="h-card"><a href="'.self::esc($url).'" class="u-url mention">@<span>'.self::esc($m[2][0]).'</span></a></span>'); | |
| 127 | 190 | } else { |
| 128 | 191 | $tag = $m[4][0]; |
| 129 | - $out .= '<a href="'.self::esc($base.'/microblog/tag/'.rawurlencode(mb_strtolower($tag))).'" class="mention hashtag" rel="tag">#<span>'.self::esc($tag).'</span></a>'; | |
| 192 | + $out .= $hold('<a href="'.self::esc(Fedi::base().'/microblog/tag/'.rawurlencode(mb_strtolower($tag))).'" class="mention hashtag" rel="tag">#<span>'.self::esc($tag).'</span></a>'); | |
| 130 | 193 | } |
| 131 | 194 | } |
| 132 | - $out .= self::esc(substr($text, $pos)); | |
| 195 | + $out = self::emphasis($out.self::esc(substr($text, $pos))); | |
| 133 | 196 | |
| 134 | - // 空行で段落、改行で<br> | |
| 135 | - $paras = preg_split('/\n{2,}/', $out); | |
| 136 | - return implode('', array_map(fn($p) => '<p>'.str_replace("\n", '<br />', $p).'</p>', $paras)); | |
| 197 | + // 預けた部分の中に預けた部分がある(リンクの中のコード等)ので、無くなるまで戻す | |
| 198 | + for ($k = 0; $k < 4 && str_contains($out, "\0"); $k++) { | |
| 199 | + $out = preg_replace_callback('/\0(\d+)\0/', fn($m) => $keep[(int)$m[1]] ?? '', $out); | |
| 200 | + } | |
| 201 | + return $out; | |
| 202 | + } | |
| 203 | + | |
| 204 | + /** **太字**、__太字__、~~取り消し線~~、*斜体*、_斜体_(「_」は単語の中では使わない:snake_case等) */ | |
| 205 | + private static function emphasis(string $s): string { | |
| 206 | + $s = preg_replace('/\*\*(?=\S)(.+?)(?<=\S)\*\*/u', '<strong>$1</strong>', $s); | |
| 207 | + $s = preg_replace('/(?<!\w)__(?=\S)(.+?)(?<=\S)__(?!\w)/u', '<strong>$1</strong>', $s); | |
| 208 | + $s = preg_replace('/~~(?=\S)(.+?)(?<=\S)~~/u', '<del>$1</del>', $s); | |
| 209 | + $s = preg_replace('/(?<!\*)\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?!\*)/u', '<em>$1</em>', $s); | |
| 210 | + return preg_replace('/(?<!\w)_(?=[^\s_])(.+?)(?<=[^\s_])_(?!\w)/u', '<em>$1</em>', $s); | |
| 211 | + } | |
| 212 | + | |
| 213 | + /** コードの中のメンションとハッシュタグは使わない */ | |
| 214 | + private static function withoutCode(string $text): string { | |
| 215 | + $text = preg_replace('/^```.*?(?:^```[ \t]*$|\z)/msu', ' ', $text); | |
| 216 | + return preg_replace('/`[^`]+`/u', ' ', $text); | |
| 137 | 217 | } |
| 138 | 218 | |
| 139 | 219 | /** |
| @@ -40,13 +40,15 @@ | ||
| 40 | 40 | use Std\Lib\Image; |
| 41 | 41 | |
| 42 | 42 | /** |
| 43 | - * アップロードされた画像(投稿の添付とアイコン) | |
| 43 | + * アップロードされた画像と動画(投稿の添付とアイコン) | |
| 44 | 44 | * |
| 45 | - * 種類はファイルの中身で判断し(PNG・JPEG・GIF)、位置情報等が入る部分(JPEGのAPPn、PNGのeXIf・テキスト)を取り除く。 | |
| 45 | + * 種類はファイルの中身で判断し(PNG・JPEG・GIF、動画はMP4・MKV・WebM)、位置情報等が入る部分 | |
| 46 | + * (JPEGのAPPn、PNGのeXIf・テキスト、MP4のudta・meta・uuid)を取り除く。 | |
| 46 | 47 | */ |
| 47 | 48 | final class Media { |
| 48 | - public const MAX_BYTES = 4194304; // 4MiB | |
| 49 | - private const TYPES = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif']; | |
| 49 | + public const MAX_BYTES = 20971520; // 20 MiB | |
| 50 | + public const MAX_VIDEO_BYTES = 419430400; // 400 MiB | |
| 51 | + private const TYPES = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'video/mp4' => 'mp4', 'video/matroska' => 'mkv', 'video/webm' => 'webm']; | |
| 50 | 52 | |
| 51 | 53 | /** |
| 52 | 54 | * $_FILESの1件を保存する |
| @@ -55,9 +57,10 @@ | ||
| 55 | 57 | * @param string $dir 保存先(public/static/の下、例:「media/ユーザー」) |
| 56 | 58 | * @param string $prefix ファイル名の接頭辞 |
| 57 | 59 | * @param bool $uploaded is_uploaded_fileで確認するか(テストではfalse) |
| 60 | + * @param bool $video 動画も受け付けるか(アイコンは画像だけ) | |
| 58 | 61 | * @return \Result data:path(public/からのパス)、url、type、width、height |
| 59 | 62 | */ |
| 60 | - public static function save(array $file, string $dir, string $prefix = '', bool $uploaded = true): \Result { | |
| 63 | + public static function save(array $file, string $dir, string $prefix = '', bool $uploaded = true, bool $video = false): \Result { | |
| 61 | 64 | if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { |
| 62 | 65 | return \Result::Error(($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE || ($file['error'] ?? 0) === UPLOAD_ERR_FORM_SIZE |
| 63 | 66 | ? 'ファイルが大き過ぎます。' : 'ファイルのアップロードに失敗しました。'); |
| @@ -66,27 +69,44 @@ | ||
| 66 | 69 | if ($uploaded && !is_uploaded_file($tmp)) return \Result::Error('ファイルのアップロードに失敗しました。'); |
| 67 | 70 | $size = filesize($tmp); |
| 68 | 71 | if ($size === false || $size === 0) return \Result::Error('ファイルが空です。'); |
| 69 | - if ($size > self::MAX_BYTES) return \Result::Error('ファイルが大き過ぎます('.(self::MAX_BYTES >> 20).'MiBまで)。'); | |
| 72 | + if ($size > ($video ? self::MAX_VIDEO_BYTES : self::MAX_BYTES)) return \Result::Error(self::tooLarge($video)); | |
| 70 | 73 | |
| 71 | - $data = (string)file_get_contents($tmp); | |
| 72 | - $type = self::detect($data); | |
| 73 | - if ($type === null) return \Result::Error('PNG・JPEG・GIFの画像だけアップロード出来ます。'); | |
| 74 | - $data = self::strip($data, $type); | |
| 74 | + // 種類は先頭だけで分かる(動画は数百MiBになるので、全部は読まない) | |
| 75 | + $fh = fopen($tmp, 'rb'); | |
| 76 | + $type = self::detect($fh ? (string)fread($fh, 64) : ''); | |
| 77 | + if ($fh) fclose($fh); | |
| 78 | + $isVideo = $type !== null && str_starts_with($type, 'video/'); | |
| 79 | + if ($type === null || ($isVideo && !$video)) { | |
| 80 | + return \Result::Error($video ? 'PNG・JPEG・GIFの画像と、MP4・MKV・WebMの動画だけアップロード出来ます。' : 'PNG・JPEG・GIFの画像だけアップロード出来ます。'); | |
| 81 | + } | |
| 82 | + if (!$isVideo && $size > self::MAX_BYTES) return \Result::Error(self::tooLarge(false)); | |
| 75 | 83 | |
| 76 | 84 | $rel = '/public/static/'.trim($dir, '/').'/'; |
| 77 | 85 | $abs = ROOT.$rel; |
| 78 | 86 | if (!is_dir($abs) && !@mkdir($abs, 0755, true) && !is_dir($abs)) return \Result::Error('保存先を作れません。'); |
| 79 | 87 | $name = $prefix.bin2hex(random_bytes(12)).'.'.self::TYPES[$type]; |
| 80 | - if (file_put_contents($abs.$name, $data) === false) return \Result::Error('ファイルの保存に失敗しました。'); | |
| 88 | + if ($isVideo) { | |
| 89 | + // 動画はファイルのまま移して、その場でメタデータを消す | |
| 90 | + if (!($uploaded ? move_uploaded_file($tmp, $abs.$name) : copy($tmp, $abs.$name))) return \Result::Error('ファイルの保存に失敗しました。'); | |
| 91 | + if ($type === 'video/mp4' && !self::stripFile($abs.$name)) { | |
| 92 | + @unlink($abs.$name); | |
| 93 | + return \Result::Error('動画を読めません。'); | |
| 94 | + } | |
| 95 | + } else { | |
| 96 | + $data = self::strip((string)file_get_contents($tmp), $type); | |
| 97 | + if (file_put_contents($abs.$name, $data) === false) return \Result::Error('ファイルの保存に失敗しました。'); | |
| 98 | + } | |
| 81 | 99 | @chmod($abs.$name, 0644); |
| 82 | 100 | |
| 83 | 101 | // 大きさ(読めなくても保存は成功) |
| 84 | 102 | $w = $h = 0; |
| 85 | - try { | |
| 86 | - $img = new Image($rel.$name); | |
| 87 | - $w = (int)($img->fullInfo->width ?? 0); | |
| 88 | - $h = (int)($img->fullInfo->height ?? 0); | |
| 89 | - } catch (\Throwable $e) { | |
| 103 | + if (!$isVideo) { | |
| 104 | + try { | |
| 105 | + $img = new Image($rel.$name); | |
| 106 | + $w = (int)($img->fullInfo->width ?? 0); | |
| 107 | + $h = (int)($img->fullInfo->height ?? 0); | |
| 108 | + } catch (\Throwable $e) { | |
| 109 | + } | |
| 90 | 110 | } |
| 91 | 111 | |
| 92 | 112 | return \Result::Success('', [ |
| @@ -106,16 +126,43 @@ | ||
| 106 | 126 | if (str_starts_with($data, "\x89PNG\r\n\x1a\n")) return 'image/png'; |
| 107 | 127 | if (str_starts_with($data, "\xff\xd8\xff")) return 'image/jpeg'; |
| 108 | 128 | if (str_starts_with($data, 'GIF87a') || str_starts_with($data, 'GIF89a')) return 'image/gif'; |
| 129 | + // ISO BMFF:AVIF・HEIC(画像)とQuickTime(ブラウザで再生出来ない事が多い)は除く | |
| 130 | + if (substr($data, 4, 4) === 'ftyp') { | |
| 131 | + $brand = substr($data, 8, 4); | |
| 132 | + return in_array($brand, ['avif', 'avis', 'heic', 'heix', 'mif1', 'msf1', 'qt '], true) ? null : 'video/mp4'; | |
| 133 | + } | |
| 134 | + // Matroska(DocTypeでWebMと分ける) | |
| 135 | + if (str_starts_with($data, "\x1a\x45\xdf\xa3")) { | |
| 136 | + $head = substr($data, 0, 64); | |
| 137 | + if (str_contains($head, "\x42\x82\x84webm")) return 'video/webm'; | |
| 138 | + if (str_contains($head, "\x42\x82\x88matroska")) return 'video/matroska'; | |
| 139 | + } | |
| 109 | 140 | return null; |
| 110 | 141 | } |
| 111 | 142 | |
| 143 | + private static function tooLarge(bool $video): string { | |
| 144 | + return $video | |
| 145 | + ? 'ファイルが大き過ぎます(画像は'.(self::MAX_BYTES >> 20).'MiB、動画は'.(self::MAX_VIDEO_BYTES >> 20).'MiBまで)。' | |
| 146 | + : 'ファイルが大き過ぎます('.(self::MAX_BYTES >> 20).'MiBまで)。'; | |
| 147 | + } | |
| 148 | + | |
| 112 | 149 | /** |
| 113 | 150 | * メタデータを取り除く |
| 114 | 151 | * |
| 115 | 152 | * JPEG:APP1〜APP15(Exif、XMP等)とCOMを消す(APP0のJFIFは残す) |
| 116 | 153 | * PNG:eXIf、tEXt、zTXt、iTXt、tIMEを消す |
| 154 | + * MP4:udta、meta、uuid(位置、機種、XMP等)を同じ大きさの「free」にし、mvhd・tkhd・mdhdの日時を0にする | |
| 117 | 155 | */ |
| 118 | 156 | public static function strip(string $data, string $type): string { |
| 157 | + if ($type === 'video/mp4') { | |
| 158 | + $fh = fopen('php://memory', 'w+b'); | |
| 159 | + fwrite($fh, $data); | |
| 160 | + self::blankBoxes($fh, 0, strlen($data), 0); | |
| 161 | + rewind($fh); | |
| 162 | + $data = (string)stream_get_contents($fh); | |
| 163 | + fclose($fh); | |
| 164 | + return $data; | |
| 165 | + } | |
| 119 | 166 | if ($type === 'image/jpeg') { |
| 120 | 167 | $out = substr($data, 0, 2); |
| 121 | 168 | $pos = 2; |
| @@ -147,6 +194,67 @@ | ||
| 147 | 194 | return $out; |
| 148 | 195 | } |
| 149 | 196 | return $data; |
| 197 | + } | |
| 198 | + | |
| 199 | + /** | |
| 200 | + * 保存したMP4のメタデータを消す(ファイルの中で書き換える) | |
| 201 | + */ | |
| 202 | + public static function stripFile(string $path): bool { | |
| 203 | + $size = filesize($path); | |
| 204 | + $fh = $size ? fopen($path, 'r+b') : false; | |
| 205 | + if (!$fh) return false; | |
| 206 | + self::blankBoxes($fh, 0, $size, 0); | |
| 207 | + return fclose($fh); | |
| 208 | + } | |
| 209 | + | |
| 210 | + /** | |
| 211 | + * MP4の箱を辿り、メタデータの箱の中身を0にして「free」にする | |
| 212 | + * 大きさを変えないので、サンプルの位置(stco・co64)を直さなくて良い | |
| 213 | + * | |
| 214 | + * @param resource $fh 読み書き出来るファイル | |
| 215 | + */ | |
| 216 | + private static function blankBoxes($fh, int $pos, int $end, int $depth): void { | |
| 217 | + while ($pos + 8 <= $end) { | |
| 218 | + fseek($fh, $pos); | |
| 219 | + $hdr = (string)fread($fh, 16); | |
| 220 | + if (strlen($hdr) < 8) return; | |
| 221 | + $size = unpack('N', substr($hdr, 0, 4))[1]; | |
| 222 | + $type = substr($hdr, 4, 4); | |
| 223 | + $head = 8; | |
| 224 | + if ($size === 1) { | |
| 225 | + if (strlen($hdr) < 16) return; | |
| 226 | + $size = unpack('J', substr($hdr, 8, 8))[1]; | |
| 227 | + $head = 16; | |
| 228 | + } else if ($size === 0) { | |
| 229 | + $size = $end - $pos; | |
| 230 | + } | |
| 231 | + if ($size < $head || $pos + $size > $end) return; | |
| 232 | + | |
| 233 | + if (in_array($type, ['udta', 'meta', 'uuid'], true)) { | |
| 234 | + fseek($fh, $pos + 4); | |
| 235 | + fwrite($fh, 'free'); | |
| 236 | + self::zero($fh, $pos + $head, $size - $head); | |
| 237 | + } else if (in_array($type, ['mvhd', 'tkhd', 'mdhd'], true) && $size >= $head + 20) { | |
| 238 | + // 作成・変更日時(バージョン1は64ビット) | |
| 239 | + fseek($fh, $pos + $head); | |
| 240 | + $len = fread($fh, 1) === "\1" ? 16 : 8; | |
| 241 | + self::zero($fh, $pos + $head + 4, $len); | |
| 242 | + } else if ($depth < 4 && in_array($type, ['moov', 'trak', 'mdia', 'minf'], true)) { | |
| 243 | + self::blankBoxes($fh, $pos + $head, $pos + $size, $depth + 1); | |
| 244 | + } | |
| 245 | + $pos += $size; | |
| 246 | + } | |
| 247 | + } | |
| 248 | + | |
| 249 | + /** $posから$len個の0を書く(1MiBずつ) */ | |
| 250 | + private static function zero($fh, int $pos, int $len): void { | |
| 251 | + fseek($fh, $pos); | |
| 252 | + $chunk = str_repeat("\0", min($len, 1048576)); | |
| 253 | + while ($len > 0) { | |
| 254 | + $n = min($len, strlen($chunk)); | |
| 255 | + fwrite($fh, $n === strlen($chunk) ? $chunk : substr($chunk, 0, $n)); | |
| 256 | + $len -= $n; | |
| 257 | + } | |
| 150 | 258 | } |
| 151 | 259 | |
| 152 | 260 | /** |
| @@ -89,7 +89,7 @@ | ||
| 89 | 89 | 'type' => 'Note', |
| 90 | 90 | 'attributedTo' => Fedi::actorUrl($note['author']), |
| 91 | 91 | 'content' => $note['content'], |
| 92 | - 'source' => ['content' => $note['source'], 'mediaType' => 'text/plain'], | |
| 92 | + 'source' => ['content' => $note['source'], 'mediaType' => 'text/markdown'], | |
| 93 | 93 | 'to' => $note['to'], |
| 94 | 94 | 'cc' => $note['cc'], |
| 95 | 95 | 'published' => $note['published'], |
| @@ -85,7 +85,7 @@ | ||
| 85 | 85 | |
| 86 | 86 | $attachments = []; |
| 87 | 87 | foreach (array_values($media) as $i => $m) { |
| 88 | - $a = ['type' => 'Image', 'mediaType' => $m['type'], 'url' => $m['url'], 'name' => mb_substr((string)($opt['alts'][$i] ?? ''), 0, 1500)]; | |
| 88 | + $a = ['type' => str_starts_with($m['type'], 'video/') ? 'Video' : 'Image', 'mediaType' => $m['type'], 'url' => $m['url'], 'name' => mb_substr((string)($opt['alts'][$i] ?? ''), 0, 1500)]; | |
| 89 | 89 | if (!empty($m['width'])) $a['width'] = $m['width']; |
| 90 | 90 | if (!empty($m['height'])) $a['height'] = $m['height']; |
| 91 | 91 | $a['path'] = $m['path']; |
| @@ -119,6 +119,25 @@ | ||
| 119 | 119 | $test->assertEquals('<p>@nobody@x.example</p>', Html::render('@nobody@x.example')); |
| 120 | 120 | }); |
| 121 | 121 | |
| 122 | + $test->it('Markdownを描画するはず', function($test): void { | |
| 123 | + Fedi::$baseUrl = 'https://1.1.1.1'; | |
| 124 | + $r = fn(string $t) => Html::render($t); | |
| 125 | + $test->assertEquals('<p><strong>太字</strong>と<em>斜体</em>と<del>線</del>と<code><b>*x*</code></p>', $r('**太字**と*斜体*と~~線~~と`<b>*x*`')); | |
| 126 | + $test->assertEquals('<p><a href="https://x.example/a" rel="nofollow noopener noreferrer"><strong>リンク</strong></a></p>', $r('[**リンク**](https://x.example/a)')); | |
| 127 | + // javascript:等はリンクにしない | |
| 128 | + $test->assertEquals('<p>[x](javascript:alert(1))</p>', $r('[x](javascript:alert(1))')); | |
| 129 | + $test->assertEquals('<ul><li>a</li><li><em>b</em></li></ul><ol><li>one</li><li>two</li></ol>', $r("- a\n* *b*\n\n1. one\n2. two")); | |
| 130 | + $test->assertEquals('<blockquote><p>引用<br /><strong>強</strong></p></blockquote><p>本文</p>', $r("> 引用\n> **強**\n\n本文")); | |
| 131 | + $test->assertEquals("<pre><code>**そのまま** #tag @bob\n<script></code></pre>", $r("```php\n**そのまま** #tag @bob\n<script>\n```")); | |
| 132 | + // snake_caseや@user_nameは斜体にしない、\*はそのまま | |
| 133 | + $test->assertEquals('<p>snake_case_name @a_b_c 2*3 \\ *x*</p>', $r('snake_case_name @a_b_c 2\*3 \\\\ \*x\*')); | |
| 134 | + // コードの中のメンションとタグは使わない | |
| 135 | + $test->assertEquals([], Html::mentions("`@bob@b.example`\n```\n@carol\n```")); | |
| 136 | + $test->assertEquals(['real'], Html::hashtags("`#code` #real")); | |
| 137 | + // 見出しは扱わない(「# 」はタグでもない) | |
| 138 | + $test->assertEquals('<p># 見出し</p>', $r('# 見出し')); | |
| 139 | + }); | |
| 140 | + | |
| 122 | 141 | $test->it('リモートのHTMLを安全にするはず', function($test): void { |
| 123 | 142 | $out = Html::sanitize('<p onclick="x">Hi <a href="javascript:alert(1)">x</a> <a href="https://e.x/" class="mention evil" target="_blank">@u</a>' |
| 124 | 143 | .'<script>alert(1)</script><img src=x onerror=alert(1)><style>p{}</style><iframe src="https://e.x"></iframe><b>日本語</b></p>'); |
| @@ -156,6 +175,40 @@ | ||
| 156 | 175 | $test->assertEquals('image/jpeg', Media::detect("\xff\xd8\xff\xe0xxxx")); |
| 157 | 176 | $test->assertEquals('image/gif', Media::detect('GIF89axxxx')); |
| 158 | 177 | $test->assertNull(Media::detect('<?php echo 1;')); |
| 178 | + $test->assertEquals('video/mp4', Media::detect("\0\0\0\x20ftypisom\0\0\x02\0")); | |
| 179 | + $test->assertEquals('video/mp4', Media::detect("\0\0\0\x18ftypmp42\0\0\0\0")); | |
| 180 | + $test->assertNull(Media::detect("\0\0\0\x1cftypavif\0\0\0\0")); | |
| 181 | + $test->assertNull(Media::detect("\0\0\0\x14ftypqt \0\0\0\0")); | |
| 182 | + $test->assertEquals('video/webm', Media::detect("\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\x82\x84webm")); | |
| 183 | + $test->assertEquals('video/matroska', Media::detect("\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\x82\x88matroska")); | |
| 184 | + $test->assertNull(Media::detect("\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01")); | |
| 185 | + }); | |
| 186 | + | |
| 187 | + $test->it('アイコンには動画を使えないはず', function($test): void { | |
| 188 | + $tmp = tempnam(sys_get_temp_dir(), 'lbmedia'); | |
| 189 | + file_put_contents($tmp, "\0\0\0\x18ftypmp42\0\0\0\0".str_repeat("\0", 16)); | |
| 190 | + $file = ['name' => 'a.mp4', 'tmp_name' => $tmp, 'size' => filesize($tmp), 'error' => UPLOAD_ERR_OK]; | |
| 191 | + $res = Media::save($file, 'test', '', false); | |
| 192 | + @unlink($tmp); | |
| 193 | + $test->assertFalse($res->isSuccess); | |
| 194 | + $test->assertStringContains('画像だけ', $res->message); | |
| 195 | + }); | |
| 196 | + | |
| 197 | + $test->it('MP4のメタデータを同じ大きさの空きにするはず', function($test): void { | |
| 198 | + $box = fn(string $t, string $d) => pack('N', strlen($d) + 8).$t.$d; | |
| 199 | + $mdat = $box('mdat', 'FRAMES'); | |
| 200 | + $mp4 = $box('ftyp', 'isom'.pack('N', 512).'isomiso2') | |
| 201 | + .$box('moov', $box('mvhd', "\0\0\0\0".'DATEDATE'.str_repeat("\1", 12)) | |
| 202 | + .$box('trak', $box('tkhd', 'TKHD').$box('udta', $box("\xa9xyz", '+35.6+139.7/'))) | |
| 203 | + .$box('udta', $box("\xa9day", '2026-11-11')).$box('meta', 'Apple iPhone')) | |
| 204 | + .$box('uuid', 'XMPxmp:CreatorTool') | |
| 205 | + .$mdat; | |
| 206 | + $out = Media::strip($mp4, 'video/mp4'); | |
| 207 | + $test->assertEquals(strlen($mp4), strlen($out)); | |
| 208 | + foreach (['+35.6+139.7', '2026-11-11', 'iPhone', 'CreatorTool', 'udta', 'meta', 'uuid', 'DATEDATE'] as $s) $test->assertFalse(str_contains($out, $s)); | |
| 209 | + foreach (['mvhd', 'tkhd', 'TKHD', 'mdat', 'FRAMES'] as $s) $test->assertStringContains($s, $out); | |
| 210 | + // 動画のデータの位置は変わらない | |
| 211 | + $test->assertEquals(strpos($mp4, $mdat), strpos($out, $mdat)); | |
| 159 | 212 | }); |
| 160 | 213 | |
| 161 | 214 | $test->it('JPEGとPNGのメタデータを取り除くはず', function($test): void { |
| @@ -9,11 +9,11 @@ | ||
| 9 | 9 | <input type="text" name="cw" maxlength="200" placeholder="注意書き(任意。入力すると本文が隠れます)" value="{{ $draftCw }}" /> |
| 10 | 10 | <textarea name="content" rows="4" maxlength="{{ $maxLength }}" placeholder="いまどうしてる?">{{ $draft !== '' ? $draft : $replyText }}</textarea> |
| 11 | 11 | <details> |
| 12 | - <summary>画像を添付({{ $maxMedia }}枚まで、PNG・JPEG・GIF)</summary> | |
| 12 | + <summary>画像・動画を添付({{ $maxMedia }}つまで。画像はPNG・JPEG・GIFで{{ \Std\Lib\Fedi\Media::MAX_BYTES >> 20 }}MiB、動画はMP4・MKV・WebMで{{ \Std\Lib\Fedi\Media::MAX_VIDEO_BYTES >> 20 }}MiBまで)</summary> | |
| 13 | 13 | {@ for ($i = 0; $i < $maxMedia; $i++) @} |
| 14 | 14 | <div class="mb-file"> |
| 15 | - <input type="file" name="media[]" accept="image/png,image/jpeg,image/gif" /> | |
| 16 | - <input type="text" name="alt[]" maxlength="1500" placeholder="画像の説明(代替テキスト)" /> | |
| 15 | + <input type="file" name="media[]" accept="image/png,image/jpeg,image/gif,video/mp4,video/matroska,video/webm" /> | |
| 16 | + <input type="text" name="alt[]" maxlength="1500" placeholder="説明(代替テキスト)" /> | |
| 17 | 17 | </div> |
| 18 | 18 | {@ endfor @} |
| 19 | 19 | </details> |