外部アセットパスを可能に
守矢諏訪子 <suwako@076.moe>、2026-11-12 11:03
コミット fc1e074a921a9c25147cbce58138ba41a729c736
・親 5617365
・ファイルを見る
20個のファイル、+168 -36
変更
README-JP.md
| @@ -200,6 +200,13 @@ | ||
| 200 | 200 | 1つのサイトに複数のブログを置けます。`config/config.php` の `BLOGS` の各ディレクトリ(例:`blog`、`enblog`)がブログになり、それぞれ `/ディレクトリ.atom` の Atom フィードがあります(一覧と検索結果のページの上にリンクがあります)。\ |
| 201 | 201 | `BLOGS` が無ければ、今まで通り `blog` だけです。 |
| 202 | 202 | |
| 203 | +### 静的ファイル(ASS_DOMAIN) | |
| 204 | +`config/config.php` の `ASS_DOMAIN` で静的ファイル(画像、CSS、アイコン、アップロードしたファイル)の場所を決めます。無ければ `/static` です。 | |
| 205 | +* パス(例:`/static`)なら、このサイトの `public/` の下のディレクトリです(`/static` は `public/static/`)。 | |
| 206 | +* URL(例:`https://ass.example.com/static`)なら、別のサーバー(サブドメイン等)です。アップロードしたファイルは今まで通り `public/` の下の同じパス(`public/static/`)に保存するので、そのディレクトリを静的ファイルのサーバーと同期して下さい(又はそのサーバーから配信して下さい)。 | |
| 207 | + | |
| 208 | +テンプレートやコードでは、URLは `ass_url('article/a.png')`、ディスク上のファイルは `ROOT.ass_path('article/a.png')` を使います。 | |
| 209 | + | |
| 203 | 210 | ### コメント |
| 204 | 211 | `config/config.php` で `COMMENTS_ENABLED` を `true` にすると、全部のブログ記事の下にコメント欄が表示されます(書き込みには `AUTH_ENABLED` も必要)。 |
| 205 | 212 | * ログイン中のユーザーはコメント、返信、いいねが出来ます(Markdown:太字、斜体、取り消し線、コード、引用、リスト、リンク)。自分のコメントは削除出来、管理者とスタッフは全部のコメントを削除出来ます。JavaScript は不要です。 |
変更
README.md
| @@ -198,6 +198,13 @@ | ||
| 198 | 198 | A site can have more than one blog: every directory in `BLOGS` in `config/config.php` (for example `blog`, `enblog`) is a blog with its own Atom feed at `/directory.atom`, linked from the top of its index and search result pages.\ |
| 199 | 199 | Without `BLOGS`, only `blog` exists, as before. |
| 200 | 200 | |
| 201 | +### Static files (ASS_DOMAIN) | |
| 202 | +`ASS_DOMAIN` in `config/config.php` sets where static files (images, CSS, avatars, uploads) are served from. Without it, it is `/static`. | |
| 203 | +* A path (for example `/static`) is a directory under `public/` of this site: `/static` is `public/static/`. | |
| 204 | +* A URL (for example `https://ass.example.com/static`) points to another server, such as a subdomain. Uploaded files are still saved under `public/` with the same path (`public/static/`), so sync that directory to the asset server (or serve it from there). | |
| 205 | + | |
| 206 | +In templates and code, use `ass_url('article/a.png')` for the URL and `ROOT.ass_path('article/a.png')` for the file on disk. | |
| 207 | + | |
| 201 | 208 | ### Comments |
| 202 | 209 | Set `COMMENTS_ENABLED` to `true` in `config/config.php` to show a comment section under every blog post (posting needs `AUTH_ENABLED`). |
| 203 | 210 | * Logged-in users can comment, reply and like (Markdown: bold, italics, strikethrough, code, quotes, lists and links). Users can delete their own comments, and admins and staff can delete any comment. No JavaScript is needed. |
| @@ -37,6 +37,9 @@ | ||
| 37 | 37 | */ |
| 38 | 38 | define('REPO_ROOT', '/repos/'); |
| 39 | 39 | define('TWITTER_HANDLE', '@techsuwako'); |
| 40 | +// 静的ファイル(画像・CSS等)の場所。パス(例:「/static」=public/static/)、又は別のサーバーのURL(例:「https://ass.example.com/static」) | |
| 41 | +// URLの場合、アップロードされたファイルはpublic/の下の同じパス(例:public/static/)に保存するので、そのサーバーと同期して下さい | |
| 42 | +define('ASS_DOMAIN', '/static'); | |
| 40 | 43 | define('SITEINFO', [ |
| 41 | 44 | 'title' => 'Little Beast', |
| 42 | 45 | 'description' => 'PHP Framework', |
| @@ -66,7 +69,7 @@ | ||
| 66 | 69 | 'actor' => 'loli', |
| 67 | 70 | 'actorNick' => 'ロリ', |
| 68 | 71 | 'desc' => '', |
| 69 | - 'icon' => '/static/logo.png', | |
| 72 | + 'icon' => ASS_DOMAIN.'/logo.png', | |
| 70 | 73 | 'pubkey' => ROOT.'/public/static/pub.pem', |
| 71 | 74 | 'privkey' => ROOT.'/data/priv.pem', |
| 72 | 75 | ]); |
| @@ -160,7 +160,7 @@ | ||
| 160 | 160 | 'name' => $rname = (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? '')), |
| 161 | 161 | 'color' => htmlspecialchars($rname, ENT_QUOTES, 'UTF-8'), |
| 162 | 162 | 'handle' => Remote::handle($doc), |
| 163 | - 'avatar' => Remote::imageUrl(self::safeUrl($doc['icon']['url'] ?? (is_string($doc['icon'] ?? null) ? $doc['icon'] : ''))) ?: '/static/user/noicon.png', | |
| 163 | + 'avatar' => Remote::imageUrl(self::safeUrl($doc['icon']['url'] ?? (is_string($doc['icon'] ?? null) ? $doc['icon'] : ''))) ?: ass_url('user/noicon.png'), | |
| 164 | 164 | 'bio' => Html::sanitize((string)($doc['summary'] ?? '')), |
| 165 | 165 | 'local' => false, |
| 166 | 166 | 'profilePage' => self::safeUrl(is_string($doc['url'] ?? null) ? $doc['url'] : ''), |
| @@ -191,7 +191,7 @@ | ||
| 191 | 191 | 'name' => $name = (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? '')), |
| 192 | 192 | 'color' => htmlspecialchars($name, ENT_QUOTES, 'UTF-8'), |
| 193 | 193 | 'handle' => Remote::handle($doc), |
| 194 | - 'avatar' => self::safeUrl(is_string($doc['icon']['url'] ?? null) ? $doc['icon']['url'] : '') ?: '/static/user/noicon.png', | |
| 194 | + 'avatar' => self::safeUrl(is_string($doc['icon']['url'] ?? null) ? $doc['icon']['url'] : '') ?: ass_url('user/noicon.png'), | |
| 195 | 195 | 'bio' => Html::sanitize((string)($doc['summary'] ?? '')), |
| 196 | 196 | 'local' => true, |
| 197 | 197 | 'profilePage' => '', |
| @@ -590,7 +590,7 @@ | ||
| 590 | 590 | $u = Fedi::localUser($local); |
| 591 | 591 | return ['url' => $url, 'name' => $u ? ($u->displayname ?: $u->username) : $local, |
| 592 | 592 | 'color' => $u ? namecolor($u) : htmlspecialchars($local, ENT_QUOTES, 'UTF-8'), 'handle' => "@{$local}", |
| 593 | - 'avatar' => $u ? avatar_url($u) : '/static/user/noicon.png', 'page' => "/microblog/user/{$local}"]; | |
| 593 | + 'avatar' => $u ? avatar_url($u) : ass_url('user/noicon.png'), 'page' => "/microblog/user/{$local}"]; | |
| 594 | 594 | } |
| 595 | 595 | $doc = Remote::cachedActor($url); |
| 596 | 596 | $handle = $doc ? Remote::handle($doc) : '@'.basename((string)parse_url($url, PHP_URL_PATH)).'@'.Fedi::domainOf($url); |
| @@ -600,7 +600,7 @@ | ||
| 600 | 600 | 'name' => $name = $doc ? (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? $handle)) : $handle, |
| 601 | 601 | 'color' => htmlspecialchars($name, ENT_QUOTES, 'UTF-8'), |
| 602 | 602 | 'handle' => $handle, |
| 603 | - 'avatar' => Remote::imageUrl(self::safeUrl(is_string($icon) ? $icon : '')) ?: '/static/user/noicon.png', | |
| 603 | + 'avatar' => Remote::imageUrl(self::safeUrl(is_string($icon) ? $icon : '')) ?: ass_url('user/noicon.png'), | |
| 604 | 604 | 'page' => '/microblog/remote?actor='.rawurlencode($url), |
| 605 | 605 | ]; |
| 606 | 606 | } |
| @@ -84,7 +84,8 @@ | ||
| 84 | 84 | $this->actor = FEDIINFO['actor']; |
| 85 | 85 | $this->actorNick = FEDIINFO['actorNick']; |
| 86 | 86 | $this->desc = FEDIINFO['desc']; |
| 87 | - $this->icon = "{$this->domain}".FEDIINFO['icon']; | |
| 87 | + // アイコンはURL(ASS_DOMAINのサーバー等)でも良い | |
| 88 | + $this->icon = preg_match('#^https?://#i', FEDIINFO['icon']) ? FEDIINFO['icon'] : "{$this->domain}".FEDIINFO['icon']; | |
| 88 | 89 | $this->posts = $posts; |
| 89 | 90 | } |
| 90 | 91 | |
| @@ -117,7 +118,7 @@ | ||
| 117 | 118 | ], |
| 118 | 119 | 'image' => [ |
| 119 | 120 | 'type' => 'Image', |
| 120 | - 'url' => "{$this->domain}/static/article/o_53803618dc1691.28179609.jpg", | |
| 121 | + 'url' => ass_url('article/o_53803618dc1691.28179609.jpg', $this->domain), | |
| 121 | 122 | 'mediaType' => 'image/jpeg', |
| 122 | 123 | ], |
| 123 | 124 | 'type' => 'Person', |
| @@ -913,14 +914,14 @@ | ||
| 913 | 914 | } |
| 914 | 915 | |
| 915 | 916 | $thumb = $post['thumbnail'] ?? ''; |
| 916 | - $imgpath = ROOT."/public/static/article/{$thumb}"; | |
| 917 | + $imgpath = ROOT.ass_path("article/{$thumb}"); | |
| 917 | 918 | if ($thumb !== '' && is_file($imgpath)) { |
| 918 | 919 | $note['attachment'] = [ |
| 919 | 920 | [ |
| 920 | 921 | 'digestMultibase' => 'z'.base58btc_encode(hash_file('sha256', $imgpath, true)), |
| 921 | 922 | 'mediaType' => self::imageType($imgpath), |
| 922 | 923 | 'type' => 'Image', |
| 923 | - 'url' => "{$this->domain}/static/article/".rawurlencode($thumb), | |
| 924 | + 'url' => ass_url('article/'.rawurlencode($thumb), $this->domain), | |
| 924 | 925 | ], |
| 925 | 926 | ]; |
| 926 | 927 | } |
| @@ -50,7 +50,8 @@ | ||
| 50 | 50 | private ?string $token; |
| 51 | 51 | |
| 52 | 52 | protected string $dataDir = ROOT."/data/user/"; |
| 53 | - protected string $assDir = ROOT."/public/static/user/"; | |
| 53 | + /** アイコンの保存先(空ならASS_DOMAINのuser/) */ | |
| 54 | + protected string $assDir = ''; | |
| 54 | 55 | protected int $minPassLen = 20; |
| 55 | 56 | protected int $tokenDuration = 31536000; // 1年間 |
| 56 | 57 | protected int $verifyDuration = 86400; // 確認メールのリンクの有効期限(24時間) |
| @@ -62,6 +63,7 @@ | ||
| 62 | 63 | protected string|int|null $algo = PASSWORD_ARGON2ID; |
| 63 | 64 | |
| 64 | 65 | public function __construct(?string $username = null) { |
| 66 | + if ($this->assDir === '') $this->assDir = ROOT.ass_path('user').'/'; | |
| 65 | 67 | if (!AUTH_ENABLED) return; |
| 66 | 68 | $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); |
| 67 | 69 | $this->token = \getcookie('kerozen'); |
| @@ -662,7 +664,7 @@ | ||
| 662 | 664 | } |
| 663 | 665 | |
| 664 | 666 | if (!empty($errors)) { |
| 665 | - if ($newAvatar !== null) \Std\Lib\Fedi\Media::remove('/public/static/user/'.$user->username.'/'.$newAvatar); | |
| 667 | + if ($newAvatar !== null) \Std\Lib\Fedi\Media::remove(ass_path('user/'.$user->username.'/'.$newAvatar)); | |
| 666 | 668 | return Result::Error(implode('<br />', array_map(fn($e) => htmlspecialchars($e, ENT_QUOTES, 'UTF-8'), $errors))); |
| 667 | 669 | } |
| 668 | 670 | |
| @@ -673,7 +675,7 @@ | ||
| 673 | 675 | return Result::Error('エラー:ユーザーデータの保存に失敗。'); |
| 674 | 676 | } |
| 675 | 677 | if ($oldAvatar !== '' && $oldAvatar !== ($user->avatar ?? '')) { |
| 676 | - \Std\Lib\Fedi\Media::remove('/public/static/user/'.$user->username.'/'.$oldAvatar); | |
| 678 | + \Std\Lib\Fedi\Media::remove(ass_path('user/'.$user->username.'/'.$oldAvatar)); | |
| 677 | 679 | } |
| 678 | 680 | |
| 679 | 681 | if ($sendVerify !== null) { |
| @@ -575,18 +575,18 @@ | ||
| 575 | 575 | 'name' => $u ? namecolor($u) : Html::esc($name), |
| 576 | 576 | 'handle' => '', |
| 577 | 577 | 'url' => AUTH_ENABLED ? '/profile/'.rawurlencode($name) : '', |
| 578 | - 'avatar' => $u ? avatar_url($u) : '/static/user/noicon.png', | |
| 578 | + 'avatar' => $u ? avatar_url($u) : ass_url('user/noicon.png'), | |
| 579 | 579 | 'remote' => false, |
| 580 | 580 | ]; |
| 581 | 581 | } |
| 582 | - if (empty($author)) return ['name' => '', 'handle' => '', 'url' => '', 'avatar' => '/static/user/noicon.png', 'remote' => false]; | |
| 582 | + if (empty($author)) return ['name' => '', 'handle' => '', 'url' => '', 'avatar' => ass_url('user/noicon.png'), 'remote' => false]; | |
| 583 | 583 | $icon = (string)($author['icon'] ?? ''); |
| 584 | 584 | return [ |
| 585 | 585 | 'name' => Html::esc((string)($author['name'] ?? '')), |
| 586 | 586 | 'handle' => (string)($author['handle'] ?? ''), |
| 587 | 587 | 'url' => (string)($author['url'] ?? ''), |
| 588 | 588 | // リモートの画像は直接読み込まない(閲覧者のIPアドレスが渡る)。マルチユーザーではこのサイト経由で表示する |
| 589 | - 'avatar' => $icon !== '' && Fedi::enabled() ? Remote::imageUrl($icon) : '/static/user/noicon.png', | |
| 589 | + 'avatar' => $icon !== '' && Fedi::enabled() ? Remote::imageUrl($icon) : ass_url('user/noicon.png'), | |
| 590 | 590 | 'remote' => true, |
| 591 | 591 | ]; |
| 592 | 592 | } |
| @@ -127,7 +127,7 @@ | ||
| 127 | 127 | 'icon' => [ |
| 128 | 128 | 'type' => 'Image', |
| 129 | 129 | 'mediaType' => self::mediaTypeOf($avatar), |
| 130 | - 'url' => Fedi::base().$avatar, | |
| 130 | + 'url' => preg_match('#^https?://#i', $avatar) ? $avatar : Fedi::base().$avatar, | |
| 131 | 131 | ], |
| 132 | 132 | ]; |
| 133 | 133 | $pem = self::publicKeyPem($user->username); |
| @@ -54,7 +54,7 @@ | ||
| 54 | 54 | * $_FILESの1件を保存する |
| 55 | 55 | * |
| 56 | 56 | * @param array $file $_FILES['name']の1件(name, tmp_name, size, error) |
| 57 | - * @param string $dir 保存先(public/static/の下、例:「media/ユーザー」) | |
| 57 | + * @param string $dir 保存先(ASS_DOMAINの下、例:「media/ユーザー」) | |
| 58 | 58 | * @param string $prefix ファイル名の接頭辞 |
| 59 | 59 | * @param bool $uploaded is_uploaded_fileで確認するか(テストではfalse) |
| 60 | 60 | * @param bool $video 動画も受け付けるか(アイコンは画像だけ) |
| @@ -81,7 +81,7 @@ | ||
| 81 | 81 | } |
| 82 | 82 | if (!$isVideo && $size > self::MAX_BYTES) return \Result::Error(self::tooLarge(false)); |
| 83 | 83 | |
| 84 | - $rel = '/public/static/'.trim($dir, '/').'/'; | |
| 84 | + $rel = ass_path(trim($dir, '/')).'/'; | |
| 85 | 85 | $abs = ROOT.$rel; |
| 86 | 86 | if (!is_dir($abs) && !@mkdir($abs, 0755, true) && !is_dir($abs)) return \Result::Error('保存先を作れません。'); |
| 87 | 87 | $name = $prefix.bin2hex(random_bytes(12)).'.'.self::TYPES[$type]; |
| @@ -112,7 +112,7 @@ | ||
| 112 | 112 | return \Result::Success('', [ |
| 113 | 113 | 'name' => $name, |
| 114 | 114 | 'path' => $rel.$name, |
| 115 | - 'url' => Fedi::base().'/static/'.trim($dir, '/').'/'.$name, | |
| 115 | + 'url' => ass_url(trim($dir, '/').'/'.$name, Fedi::base()), | |
| 116 | 116 | 'type' => $type, |
| 117 | 117 | 'width' => $w, |
| 118 | 118 | 'height' => $h, |
| @@ -283,7 +283,7 @@ | ||
| 283 | 283 | */ |
| 284 | 284 | public static function remove(string $path): void { |
| 285 | 285 | $abs = realpath(ROOT.$path); |
| 286 | - $root = realpath(ROOT.'/public/static'); | |
| 286 | + $root = realpath(ROOT.ass_path()); | |
| 287 | 287 | if ($abs && $root && str_starts_with($abs, $root.DIRECTORY_SEPARATOR) && is_file($abs)) @unlink($abs); |
| 288 | 288 | } |
| 289 | 289 | } |
| @@ -310,6 +310,8 @@ | ||
| 310 | 310 | */ |
| 311 | 311 | public static function imageUrl(string $url): string { |
| 312 | 312 | if ($url === '' || !preg_match('#^https?://#i', $url) || Fedi::isLocal($url)) return $url; |
| 313 | + // ASS_DOMAINが別のサーバーの場合、このサイトの静的ファイルは経由しない | |
| 314 | + if (str_starts_with($url, ass_url().'/')) return $url; | |
| 313 | 315 | return '/microblog/media?u='.rawurlencode($url).'&s='.self::imageSig($url); |
| 314 | 316 | } |
| 315 | 317 |
| @@ -81,7 +81,7 @@ | ||
| 81 | 81 | */ |
| 82 | 82 | public function addCss(string $name): void { |
| 83 | 83 | $this->custCss[] = |
| 84 | - "<link rel=\"stylesheet\" type=\"text/css\" href=\"/static/style-{$name}.css\" />\n"; | |
| 84 | + '<link rel="stylesheet" type="text/css" href="'.htmlspecialchars(ass_url("style-{$name}.css"), ENT_QUOTES, 'UTF-8')."\" />\n"; | |
| 85 | 85 | $this->assign('custCss', $this->custCss); |
| 86 | 86 | } |
| 87 | 87 |
| @@ -120,9 +120,9 @@ | ||
| 120 | 120 | $test->assertFalse(isset($json['tag'])); |
| 121 | 121 | $test->assertStringContains('/blog/feature-test', $json['object']['url']); |
| 122 | 122 | |
| 123 | - if (is_file(ROOT.'/public/static/article/o_53803618dc1691.28179609-orig.jpg')) { | |
| 123 | + if (is_file(ROOT.ass_path('article/o_53803618dc1691.28179609-orig.jpg'))) { | |
| 124 | 124 | $test->assertEquals('zBbw7K7L1dEwr6htZX5JevRv3oLtHwXQU4o5jT8jn9RKF', $json['object']['attachment'][0]['digestMultibase']); |
| 125 | - $test->assertEquals("{$domain}/static/article/o_53803618dc1691.28179609-orig.jpg", $json['object']['attachment'][0]['url']); | |
| 125 | + $test->assertEquals(ass_url('article/o_53803618dc1691.28179609-orig.jpg', $domain), $json['object']['attachment'][0]['url']); | |
| 126 | 126 | $test->assertEquals('image/jpeg', $json['object']['attachment'][0]['mediaType']); |
| 127 | 127 | } |
| 128 | 128 | }); |
| @@ -0,0 +1,79 @@ | ||
| 1 | +<?php | |
| 2 | +/************************************************************* | |
| 3 | +# 076 License | |
| 4 | + | |
| 5 | +Copyright (c) テクニカル諏訪子 | |
| 6 | + | |
| 7 | +Permission is hereby granted to any person obtaining a copy of the software | |
| 8 | +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, | |
| 9 | +sublicense, and/or sell copies of the Software, subject to the following conditions: | |
| 10 | + | |
| 11 | + 1. **Origin Attribution**: | |
| 12 | + - You must not misrepresent the origin of the Software; you must not claim | |
| 13 | + you created the original Software. | |
| 14 | + - If the Software is used in a product, you must either: | |
| 15 | + a. Provide clear attribution in the product's documentation, user interface, | |
| 16 | + or other visible areas, **OR** | |
| 17 | + b. Pay the original developers a fee they specify in writing. | |
| 18 | + 2. **Usage Restriction**: | |
| 19 | + - The Software, or any derivative works, dependencies, or libraries | |
| 20 | + incorporating it, must not be used for censorship or to suppress freedom of | |
| 21 | + speech, expression, or creativity. Prohibited uses include, but are not | |
| 22 | + limited to: | |
| 23 | + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, | |
| 24 | + ideas, or objective reality. | |
| 25 | + - Tools or systems designed to restrict access to information or | |
| 26 | + artistic works. | |
| 27 | + 3. **Notice Preservation**: | |
| 28 | + - This license and the above copyright notice must remain intact in all copies | |
| 29 | + of the source code. | |
| 30 | + | |
| 31 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| 32 | +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| 33 | +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| 34 | +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | |
| 35 | +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
| 36 | +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 37 | + */ | |
| 38 | +namespace Std\Test; | |
| 39 | + | |
| 40 | +require_once __DIR__.'/../../../autoload.php'; | |
| 41 | + | |
| 42 | +use Std\Lib\Tester; | |
| 43 | + | |
| 44 | +$test = new Tester([ | |
| 45 | + 'colorOutput' => true, | |
| 46 | + 'verboseOutput' => true | |
| 47 | +]); | |
| 48 | + | |
| 49 | +$test->describe('静的ファイルの場所(ASS_DOMAIN)', function($test): void { | |
| 50 | + $test->it('パスならこのサイトのURLとpublic/の下のディレクトリのはず', function($test): void { | |
| 51 | + $test->assertEquals('/static/article/a.png', ass_url('article/a.png', '', '/static')); | |
| 52 | + $test->assertEquals('/static/article/a.png', ass_url('/article/a.png', '', '/static/')); | |
| 53 | + $test->assertEquals('/static', ass_url('', '', '/static')); | |
| 54 | + $test->assertEquals('https://076.moe/static/logo.png', ass_url('logo.png', 'https://076.moe/', '/static')); | |
| 55 | + $test->assertEquals('/assets/style.css', ass_url('style.css', '', '/assets')); | |
| 56 | + $test->assertEquals('/public/static/article/a.png', ass_path('article/a.png', '/static')); | |
| 57 | + $test->assertEquals('/public/assets', ass_path('', '/assets/')); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + $test->it('URLならそのサーバーで、ファイルはpublic/の下の同じパスのはず', function($test): void { | |
| 61 | + $ass = 'https://ass.technicalsuwako.moe/static'; | |
| 62 | + $test->assertEquals('https://ass.technicalsuwako.moe/static/article/a.png', ass_url('article/a.png', '', $ass)); | |
| 63 | + $test->assertEquals('https://ass.technicalsuwako.moe/static/logo.png', ass_url('logo.png', 'https://technicalsuwako.moe', $ass), 'サイトのURLは付けない'); | |
| 64 | + $test->assertEquals('https://ass.technicalsuwako.moe/static', ass_url('', '', $ass.'/')); | |
| 65 | + $test->assertEquals('/public/static/user/noicon.png', ass_path('user/noicon.png', $ass)); | |
| 66 | + $test->assertEquals('/public/user/noicon.png', ass_path('user/noicon.png', 'https://ass.example.com'), 'パスが無いURL'); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + $test->it('設定が無ければ「/static」のはず', function($test): void { | |
| 70 | + if (defined('ASS_DOMAIN')) { | |
| 71 | + $test->assertEquals(rtrim(ASS_DOMAIN, '/').'/a.png', ass_url('a.png')); | |
| 72 | + return; | |
| 73 | + } | |
| 74 | + $test->assertEquals('/static/a.png', ass_url('a.png')); | |
| 75 | + $test->assertEquals('/public/static/a.png', ass_path('a.png')); | |
| 76 | + }); | |
| 77 | +}); | |
| 78 | + | |
| 79 | +$test->printSummary(); |
| @@ -223,7 +223,7 @@ | ||
| 223 | 223 | $test->assertTrue($view[0]['who']['remote']); |
| 224 | 224 | // リモートの画像は直接読み込まない(マルチユーザーではこのサイト経由) |
| 225 | 225 | if (Fedi::enabled()) $test->assertStringContains('/microblog/media?u=', $view[0]['who']['avatar']); |
| 226 | - else $test->assertEquals('/static/user/noicon.png', $view[0]['who']['avatar']); | |
| 226 | + else $test->assertEquals(ass_url('user/noicon.png'), $view[0]['who']['avatar']); | |
| 227 | 227 | $test->assertTrue($view[0]['canLike'], 'リモートのコメントにもいいね出来る'); |
| 228 | 228 | $test->assertFalse(in_array("reply:{$carol}", $types('alice1234'), true), 'aliceへの返信ではない'); |
| 229 | 229 | $test->assertEquals(["comment:{$carol}", 'comment:alice1234'], array_slice($types('admin1234'), 0, 2), '管理者には全部のコメント'); |
| @@ -269,7 +269,7 @@ | ||
| 269 | 269 | $test->assertEquals("{$alice}/inbox", $doc['inbox']); |
| 270 | 270 | $test->assertEquals('https://1.1.1.1/ap/inbox', $doc['endpoints']['sharedInbox']); |
| 271 | 271 | $test->assertStringContains('I am alice', $doc['summary']); |
| 272 | - $test->assertEquals('https://1.1.1.1/static/user/noicon.png', $doc['icon']['url']); | |
| 272 | + $test->assertEquals(ass_url('user/noicon.png', 'https://1.1.1.1'), $doc['icon']['url']); | |
| 273 | 273 | $test->assertEquals($alice, Actor::webfinger('acct:alice@1.1.1.1')['links'][0]['href']); |
| 274 | 274 | $test->assertNull(Actor::webfinger('acct:alice@other.example')); |
| 275 | 275 | $test->assertNull(Actor::webfinger('acct:nobody@1.1.1.1')); |
| @@ -126,7 +126,7 @@ | ||
| 126 | 126 | file_put_contents("{$viewDir}/css.maron", '{@ foreach ($custCss as $c) @}{{{ $c }}}{@ endforeach @}'); |
| 127 | 127 | $t = new Template('/', $viewDir); |
| 128 | 128 | $t->addCss('code'); |
| 129 | - $test->assertStringContains('href="/static/style-code.css"', $t->fetch('css')); | |
| 129 | + $test->assertStringContains('href="'.ass_url('style-code.css').'"', $t->fetch('css')); | |
| 130 | 130 | }); |
| 131 | 131 | |
| 132 | 132 | $test->it('サブディレクトリ(多言語)のテンプレートを使えるはず', function($test) use ($viewDir): void { |
変更
util.php
| @@ -433,13 +433,44 @@ | ||
| 433 | 433 | } |
| 434 | 434 | |
| 435 | 435 | /** |
| 436 | + * 静的ファイル(画像・CSS等)のURL(config.phpのASS_DOMAIN、既定は「/static」) | |
| 437 | + * ASS_DOMAINがパス(例:「/static」)ならこのサイトのURL、URL(例:「https://ass.example.com/static」)ならそのサーバー | |
| 438 | + * | |
| 439 | + * @param string $path ASS_DOMAINの中のパス(例:「article/a.png」) | |
| 440 | + * @param string $base ASS_DOMAINがパスの場合に前に付けるサイトのURL(Fediverse等、完全なURLが必要な時) | |
| 441 | + * @param string|null $domain ASS_DOMAINの代わり(テスト用) | |
| 442 | + * @return string | |
| 443 | + */ | |
| 444 | +function ass_url(string $path = '', string $base = '', ?string $domain = null): string { | |
| 445 | + $ass = rtrim($domain ?? (defined('ASS_DOMAIN') ? (string)ASS_DOMAIN : '/static'), '/'); | |
| 446 | + $url = $ass.($path !== '' ? '/'.ltrim($path, '/') : ''); | |
| 447 | + if (preg_match('#^https?://#i', $url)) return $url; | |
| 448 | + return rtrim($base, '/').($url !== '' ? $url : '/'); | |
| 449 | +} | |
| 450 | + | |
| 451 | +/** | |
| 452 | + * 静的ファイルのディスク上のパス(ROOTからのパス。例:「/public/static/article/a.png」) | |
| 453 | + * ASS_DOMAINがURLの場合は、そのパスの部分(例:「/static」)をpublic/の下に置く(ASS_DOMAINのサーバーと同じ物を置くか同期する) | |
| 454 | + * | |
| 455 | + * @param string $path ASS_DOMAINの中のパス | |
| 456 | + * @param string|null $domain ASS_DOMAINの代わり(テスト用) | |
| 457 | + * @return string | |
| 458 | + */ | |
| 459 | +function ass_path(string $path = '', ?string $domain = null): string { | |
| 460 | + $ass = $domain ?? (defined('ASS_DOMAIN') ? (string)ASS_DOMAIN : '/static'); | |
| 461 | + if (preg_match('#^https?://#i', $ass)) $ass = (string)parse_url($ass, PHP_URL_PATH); | |
| 462 | + $dir = trim($ass, '/'); | |
| 463 | + return '/public'.($dir !== '' ? "/{$dir}" : '').($path !== '' ? '/'.ltrim($path, '/') : ''); | |
| 464 | +} | |
| 465 | + | |
| 466 | +/** | |
| 436 | 467 | * アイコンのURL(無ければnoicon.png) |
| 437 | 468 | */ |
| 438 | 469 | function avatar_url(\stdClass $user): string { |
| 439 | 470 | $avatar = (string)($user->avatar ?? ''); |
| 440 | 471 | $name = (string)($user->username ?? ''); |
| 441 | - if ($avatar === '' || $name === '' || !is_file(ROOT."/public/static/user/{$name}/{$avatar}")) return '/static/user/noicon.png'; | |
| 442 | - return "/static/user/{$name}/".rawurlencode($avatar); | |
| 472 | + if ($avatar === '' || $name === '' || !is_file(ROOT.ass_path("user/{$name}/{$avatar}"))) return ass_url('user/noicon.png'); | |
| 473 | + return ass_url("user/{$name}/".rawurlencode($avatar)); | |
| 443 | 474 | } |
| 444 | 475 | |
| 445 | 476 | function make_csrf_token(?bool $force = false): string { |
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | <div class="news-article"> |
| 4 | 4 | {@ if (isset($meta->thumbnail) && $meta->thumbnail != '') @} |
| 5 | 5 | <div class="thumbnail{{ isset($meta->thumborient) && $meta->thumborient != 'center' ? ' '.$meta->thumborient : '' }}"> |
| 6 | - <img src="/static/article/{{ $meta->thumbnail }}" alt="" /> | |
| 6 | + <img src="{{ ass_url('article/'.$meta->thumbnail) }}" alt="" /> | |
| 7 | 7 | </div> |
| 8 | 8 | {@ endif @} |
| 9 | 9 | <div class="meta"> |
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | 6 | <link rel="icon" href="/favicon.ico" id="favicon"> |
| 7 | 7 | <title>{{ SITEINFO['title'] }}: {{ $pagetit }}</title> |
| 8 | - <link rel="stylesheet" type="text/css" href="/static/style.css" /> | |
| 8 | + <link rel="stylesheet" type="text/css" href="{{ ass_url('style.css') }}" /> | |
| 9 | 9 | {@ if (isset($custCss) && !empty($custCss) && !is_bool($custCss)) @} |
| 10 | 10 | {@ foreach ($custCss as $css) @} |
| 11 | 11 | {{{ $css }}} |
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | <meta name="author" content="{{ $meta->author }}" /> |
| 17 | 17 | {@ endif @} |
| 18 | 18 | {@ if (isset($meta->thumbnail) && !empty($meta->thumbnail)) @} |
| 19 | - <meta name="thumbnail" content="/static/{{ $meta->thumbnail }}" /> | |
| 19 | + <meta name="thumbnail" content="{{ ass_url('article/'.$meta->thumbnail) }}" /> | |
| 20 | 20 | {@ endif @} |
| 21 | 21 | {@ endif @} |
| 22 | 22 | <meta name="description" content="{{ $description }}" /> |
| @@ -29,9 +29,9 @@ | ||
| 29 | 29 | <meta property="og:type" content="{{ isset($meta) && isset($meta->thumbnail) ? 'article' : 'website' }}" /> |
| 30 | 30 | <meta property="og:url" content="{{ isset($_SERVER['REQUEST_URI']) ? 'https://'.$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'] : 'https://'.$_SERVER['HTTP_HOST'] }}" /> |
| 31 | 31 | {@ if (isset($meta) && isset($meta->thumbnail) && !empty($meta->thumbnail)) @} |
| 32 | - <meta property="og:image" content="https://{{ $_SERVER['HTTP_HOST'] }}/static/article/{{ $meta->thumbnail }}" /> | |
| 33 | - <meta property="og:image:secure_url" content="https://{{ $_SERVER['HTTP_HOST'] }}/static/article/{{ $meta->thumbnail }}" /> | |
| 34 | - {$ $imgspec = getImageInfo('/public/static/article/'.$meta->thumbnail)->fullInfo; $} | |
| 32 | + <meta property="og:image" content="{{ ass_url('article/'.$meta->thumbnail, 'https://'.$_SERVER['HTTP_HOST']) }}" /> | |
| 33 | + <meta property="og:image:secure_url" content="{{ ass_url('article/'.$meta->thumbnail, 'https://'.$_SERVER['HTTP_HOST']) }}" /> | |
| 34 | + {$ $imgspec = getImageInfo(ass_path('article/'.$meta->thumbnail))->fullInfo; $} | |
| 35 | 35 | <meta property="og:image:type" content="{{ $imgspec->type }}" /> |
| 36 | 36 | <meta property="og:image:width" content="{{ $imgspec->width }}" /> |
| 37 | 37 | <meta property="og:image:height" content="{{ $imgspec->height }}" /> |
| @@ -46,7 +46,7 @@ | ||
| 46 | 46 | <meta name="twitter:url" content="{{ isset($_SERVER['REQUEST_URI']) ? 'https://'.$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'] : 'https://'.$_SERVER['HTTP_HOST'] }}" /> |
| 47 | 47 | <meta name="twitter:description" content="{{ $description }}" /> |
| 48 | 48 | {@ if (isset($meta) && isset($meta->thumbnail) && !empty($meta->thumbnail)) @} |
| 49 | - <meta name="twitter:image:src" content="https://{{ $_SERVER['HTTP_HOST'] }}/static/article/{{ $meta->thumbnail }}" /> | |
| 49 | + <meta name="twitter:image:src" content="{{ ass_url('article/'.$meta->thumbnail, 'https://'.$_SERVER['HTTP_HOST']) }}" /> | |
| 50 | 50 | {@ endif @} |
| 51 | 51 | {@ endif @} |
| 52 | 52 | {@ if (ATOM_ENABLED) @} |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {@ if (isset($post['thumbnail']) && $post['thumbnail'] != '') @} |
| 3 | 3 | <div class="news-image"> |
| 4 | 4 | <a href="/{{ $section }}/{{ $post['slug'] }}"> |
| 5 | - <img src="/static/article/{{ $post['thumbnail'] }}" alt="{{ $post['title'] }}" loading="lazy" /> | |
| 5 | + <img src="{{ ass_url('article/'.$post['thumbnail']) }}" alt="{{ $post['title'] }}" loading="lazy" /> | |
| 6 | 6 | </a> |
| 7 | 7 | </div> |
| 8 | 8 | {@ endif @} |