テーマ

守矢諏訪子 <suwako@076.moe>、2026-11-11 01:16
コミット da1e7040fb3c8ea447a8161153cf610513cb24a3 ・親 45df974 ・ファイルを見る

10個のファイル、+367 -48

変更 README-JP.md
@@ -215,7 +215,7 @@
215215 `FEDIINFO['isSingleUser']` を `false` にすると(`AUTH_ENABLED` と `FEDIINFO['domain']` も必要)マルチユーザーモードになります:
216216 * ブログのアクターは今まで通り動きます。
217217 * Fediverse のロールを持つユーザーはそれぞれアクター(`@ユーザー名@ドメイン`)になります。ロールは BAN されたユーザー以外の全員に付与され、管理者はユーザーのプロフィールの編集画面で外せます。
218-* `/microblog` にはホーム・ローカル・連合のタイムライン、投稿(注意書き、公開範囲、画像4枚まで)、返信、いいね、ブースト、フォロー(承認制も可)、ブロック、通報、検索(`@user@host`、投稿の URL、`#タグ`、文字)があります。JavaScript は不要です。
218+* `/microblog` にはホーム・ローカル・連合のタイムライン、投稿(注意書き、公開範囲、画像・動画4つまで)、返信、いいね、ブースト、フォロー(承認制も可)、ブロック、通報、検索(`@user@host`、投稿の URL、`#タグ`、文字)があります。JavaScript は不要です。
219219 * 管理者は `/microblog/admin` でサーバーごとブロックしたり、通報を確認したり出来ます。
220220
221221 配信は待ち行列に入るので、cron のジョブを追加して下さい:
@@ -223,7 +223,8 @@
223223 php cron.php add "* * * * *" apdeliver
224224 php cron.php add "@daily" apcleanup
225225 ```
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"` もお勧めします。
227228 他のサーバーのアイコンや画像は `/microblog/media` 経由で表示するので、CSP の `img-src` は `'self'` のままで構いません(閲覧者の IP アドレスも相手のサーバーに渡りません)。画像は `data/ap/remote/media/` に1週間キャッシュします。\
228229 ユーザーの鍵は初めて使う時に作ります。chroot(OpenBSD の httpd と php-fpm 等)では `openssl.cnf` が見えないので、`data/ap/openssl.cnf`(空)を使います。
229230
@@ -145,6 +145,35 @@
145145 margin: 4px 0;
146146 }
147147
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+
148177 .mb-content .invisible {
149178 display: none;
150179 }
@@ -183,9 +212,9 @@
183212 }
184213
185214 .mb-actions button, .mb-button, .mb-row button, .mb-compose button {
186- background: #232023;
215+ background: #2b012a;
187216 color: #fcfcfc;
188- border: 1px solid #c016c6;
217+ border: 1px solid #ea79d8;
189218 border-radius: 2px;
190219 padding: 2px 8px;
191220 cursor: pointer;
@@ -193,7 +222,7 @@
193222 text-decoration: none;
194223 }
195224
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 {
197226 background: #550f75;
198227 }
199228
@@ -75,6 +75,52 @@
7575 border-radius: 2px;
7676 }
7777
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+
78124 @media screen and (max-width: 768px) {
79125 header, main, footer {
80126 margin: 10px;
@@ -255,7 +255,9 @@
255255 $back = safe_local_path($_POST['back'] ?? '', '/microblog');
256256 $error = '';
257257
258- if (!$this->csrfOk()) {
258+ if (empty($_POST) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
259+ $error = '送信したファイルが大き過ぎます(サーバーの上限を超えました)。';
260+ } else if (!$this->csrfOk()) {
259261 $error = 'セッションの有効期限が切れました。もう一度送信して下さい。';
260262 } else {
261263 // 添付
@@ -266,7 +268,7 @@
266268 if (($files['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue;
267269 if (count($media) >= Fedi::MAX_ATTACHMENTS) break;
268270 $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);
270272 if (!$res->isSuccess) {
271273 $error = $res->message;
272274 break;
@@ -40,7 +40,7 @@
4040 /**
4141 * 投稿の本文のHTML
4242 *
43- * render():ローカルの投稿(プレーンテキスト)をHTMLにする(リンク、メンション、ハッシュタグ)
43+ * render():ローカルの投稿(Markdown)をHTMLにする(リンク、メンション、ハッシュタグ)
4444 * sanitize():リモートの投稿のHTMLを許可したタグだけにする(XSS対策)
4545 */
4646 final class Html {
@@ -56,7 +56,7 @@
5656 private const CLASSES = ['mention', 'hashtag', 'h-card', 'u-url', 'invisible', 'ellipsis'];
5757
5858 // 区切り文字なしの正規表現(組み合わせて使う)
59- private const URL = '\bhttps?://[^\s<>"\' ]+';
59+ private const URL = '\bhttps?://[^\s<>"\' \x00]+';
6060 private const MENTION = '(?<![\w@/])@([A-Za-z0-9_]+)(?:@([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?::\d+)?))?(?![@\w])';
6161 private const TAG = '(?<![\w#&/])#([\p{L}\p{N}_]{1,64})';
6262 private const URL_RE = '~'.self::URL.'~u';
@@ -69,7 +69,7 @@
6969 * @return array [「@user」又は「@user@domain」 => [user, domain|null]]
7070 */
7171 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));
7373 preg_match_all(self::MENTION_RE, $text, $m, PREG_SET_ORDER);
7474 $out = [];
7575 foreach ($m as $x) $out[$x[0]] = [$x[1], isset($x[2]) && $x[2] !== '' ? strtolower($x[2]) : null];
@@ -82,22 +82,88 @@
8282 * @return array タグ(「#」なし、小文字、重複なし)
8383 */
8484 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));
8686 preg_match_all(self::TAG_RE, $text, $m);
8787 return array_values(array_unique(array_map(fn($t) => mb_strtolower($t), $m[1])));
8888 }
8989
9090 /**
91- * プレーンテキストをHTMLにする
91+ * 本文(Markdown)をHTMLにする
9292 *
9393 * @param string $text 本文
9494 * @param array $mentionUrls 「@user@domain」 => アクターのURL(解決出来たメンションだけリンクにする)
9595 * @return string HTML
9696 */
9797 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+ }
100101
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+
101167 // URL・メンション・ハッシュタグを1回で置き換える(URLの中の「#」や「@」を変換しない様に)
102168 // グループ:1=URL、2=ユーザー、3=ドメイン、4=タグ
103169 $re = '~('.self::URL.')|'.self::MENTION.'|'.self::TAG.'~u';
@@ -115,25 +181,39 @@
115181 $pos -= strlen($whole) - strlen($url);
116182 $shown = preg_replace('#^https?://#', '', $url);
117183 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>');
119185 } 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>');
127190 } else {
128191 $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>');
130193 }
131194 }
132- $out .= self::esc(substr($text, $pos));
195+ $out = self::emphasis($out.self::esc(substr($text, $pos)));
133196
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);
137217 }
138218
139219 /**
@@ -40,13 +40,15 @@
4040 use Std\Lib\Image;
4141
4242 /**
43- * アップロードされた画像(投稿の添付とアイコン)
43+ * アップロードされた画像と動画(投稿の添付とアイコン)
4444 *
45- * 種類はファイルの中身で判断し(PNG・JPEG・GIF)、位置情報等が入る部分(JPEGのAPPn、PNGのeXIf・テキスト)を取り除く。
45+ * 種類はファイルの中身で判断し(PNG・JPEG・GIF、動画はMP4・MKV・WebM)、位置情報等が入る部分
46+ * (JPEGのAPPn、PNGのeXIf・テキスト、MP4のudta・meta・uuid)を取り除く。
4647 */
4748 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'];
5052
5153 /**
5254 * $_FILESの1件を保存する
@@ -55,9 +57,10 @@
5557 * @param string $dir 保存先(public/static/の下、例:「media/ユーザー」)
5658 * @param string $prefix ファイル名の接頭辞
5759 * @param bool $uploaded is_uploaded_fileで確認するか(テストではfalse)
60+ * @param bool $video 動画も受け付けるか(アイコンは画像だけ)
5861 * @return \Result data:path(public/からのパス)、url、type、width、height
5962 */
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 {
6164 if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
6265 return \Result::Error(($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE || ($file['error'] ?? 0) === UPLOAD_ERR_FORM_SIZE
6366 ? 'ファイルが大き過ぎます。' : 'ファイルのアップロードに失敗しました。');
@@ -66,27 +69,44 @@
6669 if ($uploaded && !is_uploaded_file($tmp)) return \Result::Error('ファイルのアップロードに失敗しました。');
6770 $size = filesize($tmp);
6871 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));
7073
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));
7583
7684 $rel = '/public/static/'.trim($dir, '/').'/';
7785 $abs = ROOT.$rel;
7886 if (!is_dir($abs) && !@mkdir($abs, 0755, true) && !is_dir($abs)) return \Result::Error('保存先を作れません。');
7987 $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+ }
8199 @chmod($abs.$name, 0644);
82100
83101 // 大きさ(読めなくても保存は成功)
84102 $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+ }
90110 }
91111
92112 return \Result::Success('', [
@@ -106,16 +126,43 @@
106126 if (str_starts_with($data, "\x89PNG\r\n\x1a\n")) return 'image/png';
107127 if (str_starts_with($data, "\xff\xd8\xff")) return 'image/jpeg';
108128 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+ }
109140 return null;
110141 }
111142
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+
112149 /**
113150 * メタデータを取り除く
114151 *
115152 * JPEG:APP1〜APP15(Exif、XMP等)とCOMを消す(APP0のJFIFは残す)
116153 * PNG:eXIf、tEXt、zTXt、iTXt、tIMEを消す
154+ * MP4:udta、meta、uuid(位置、機種、XMP等)を同じ大きさの「free」にし、mvhd・tkhd・mdhdの日時を0にする
117155 */
118156 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+ }
119166 if ($type === 'image/jpeg') {
120167 $out = substr($data, 0, 2);
121168 $pos = 2;
@@ -147,6 +194,67 @@
147194 return $out;
148195 }
149196 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+ }
150258 }
151259
152260 /**
@@ -89,7 +89,7 @@
8989 'type' => 'Note',
9090 'attributedTo' => Fedi::actorUrl($note['author']),
9191 'content' => $note['content'],
92- 'source' => ['content' => $note['source'], 'mediaType' => 'text/plain'],
92+ 'source' => ['content' => $note['source'], 'mediaType' => 'text/markdown'],
9393 'to' => $note['to'],
9494 'cc' => $note['cc'],
9595 'published' => $note['published'],
@@ -85,7 +85,7 @@
8585
8686 $attachments = [];
8787 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)];
8989 if (!empty($m['width'])) $a['width'] = $m['width'];
9090 if (!empty($m['height'])) $a['height'] = $m['height'];
9191 $a['path'] = $m['path'];
@@ -119,6 +119,25 @@
119119 $test->assertEquals('<p>@nobody@x.example</p>', Html::render('@nobody@x.example'));
120120 });
121121
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>&lt;b&gt;*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&lt;script&gt;</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+
122141 $test->it('リモートのHTMLを安全にするはず', function($test): void {
123142 $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>'
124143 .'<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 @@
156175 $test->assertEquals('image/jpeg', Media::detect("\xff\xd8\xff\xe0xxxx"));
157176 $test->assertEquals('image/gif', Media::detect('GIF89axxxx'));
158177 $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));
159212 });
160213
161214 $test->it('JPEGとPNGのメタデータを取り除くはず', function($test): void {
@@ -9,11 +9,11 @@
99 <input type="text" name="cw" maxlength="200" placeholder="注意書き(任意。入力すると本文が隠れます)" value="{{ $draftCw }}" />
1010 <textarea name="content" rows="4" maxlength="{{ $maxLength }}" placeholder="いまどうしてる?">{{ $draft !== '' ? $draft : $replyText }}</textarea>
1111 <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>
1313 {@ for ($i = 0; $i < $maxMedia; $i++) @}
1414 <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="説明(代替テキスト)" />
1717 </div>
1818 {@ endfor @}
1919 </details>