外部アセットパスを可能に

守矢諏訪子 <suwako@076.moe>、2026-11-12 11:03
コミット fc1e074a921a9c25147cbce58138ba41a729c736 ・親 5617365 ・ファイルを見る

20個のファイル、+168 -36

変更 README-JP.md
@@ -200,6 +200,13 @@
200200 1つのサイトに複数のブログを置けます。`config/config.php` の `BLOGS` の各ディレクトリ(例:`blog`、`enblog`)がブログになり、それぞれ `/ディレクトリ.atom` の Atom フィードがあります(一覧と検索結果のページの上にリンクがあります)。\
201201 `BLOGS` が無ければ、今まで通り `blog` だけです。
202202
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+
203210 ### コメント
204211 `config/config.php` で `COMMENTS_ENABLED` を `true` にすると、全部のブログ記事の下にコメント欄が表示されます(書き込みには `AUTH_ENABLED` も必要)。
205212 * ログイン中のユーザーはコメント、返信、いいねが出来ます(Markdown:太字、斜体、取り消し線、コード、引用、リスト、リンク)。自分のコメントは削除出来、管理者とスタッフは全部のコメントを削除出来ます。JavaScript は不要です。
変更 README.md
@@ -198,6 +198,13 @@
198198 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.\
199199 Without `BLOGS`, only `blog` exists, as before.
200200
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+
201208 ### Comments
202209 Set `COMMENTS_ENABLED` to `true` in `config/config.php` to show a comment section under every blog post (posting needs `AUTH_ENABLED`).
203210 * 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 @@
3737 */
3838 define('REPO_ROOT', '/repos/');
3939 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');
4043 define('SITEINFO', [
4144 'title' => 'Little Beast',
4245 'description' => 'PHP Framework',
@@ -66,7 +69,7 @@
6669 'actor' => 'loli',
6770 'actorNick' => 'ロリ',
6871 'desc' => '',
69- 'icon' => '/static/logo.png',
72+ 'icon' => ASS_DOMAIN.'/logo.png',
7073 'pubkey' => ROOT.'/public/static/pub.pem',
7174 'privkey' => ROOT.'/data/priv.pem',
7275 ]);
@@ -160,7 +160,7 @@
160160 'name' => $rname = (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? '')),
161161 'color' => htmlspecialchars($rname, ENT_QUOTES, 'UTF-8'),
162162 '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'),
164164 'bio' => Html::sanitize((string)($doc['summary'] ?? '')),
165165 'local' => false,
166166 'profilePage' => self::safeUrl(is_string($doc['url'] ?? null) ? $doc['url'] : ''),
@@ -191,7 +191,7 @@
191191 'name' => $name = (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? '')),
192192 'color' => htmlspecialchars($name, ENT_QUOTES, 'UTF-8'),
193193 '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'),
195195 'bio' => Html::sanitize((string)($doc['summary'] ?? '')),
196196 'local' => true,
197197 'profilePage' => '',
@@ -590,7 +590,7 @@
590590 $u = Fedi::localUser($local);
591591 return ['url' => $url, 'name' => $u ? ($u->displayname ?: $u->username) : $local,
592592 '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}"];
594594 }
595595 $doc = Remote::cachedActor($url);
596596 $handle = $doc ? Remote::handle($doc) : '@'.basename((string)parse_url($url, PHP_URL_PATH)).'@'.Fedi::domainOf($url);
@@ -600,7 +600,7 @@
600600 'name' => $name = $doc ? (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? $handle)) : $handle,
601601 'color' => htmlspecialchars($name, ENT_QUOTES, 'UTF-8'),
602602 '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'),
604604 'page' => '/microblog/remote?actor='.rawurlencode($url),
605605 ];
606606 }
@@ -84,7 +84,8 @@
8484 $this->actor = FEDIINFO['actor'];
8585 $this->actorNick = FEDIINFO['actorNick'];
8686 $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'];
8889 $this->posts = $posts;
8990 }
9091
@@ -117,7 +118,7 @@
117118 ],
118119 'image' => [
119120 'type' => 'Image',
120- 'url' => "{$this->domain}/static/article/o_53803618dc1691.28179609.jpg",
121+ 'url' => ass_url('article/o_53803618dc1691.28179609.jpg', $this->domain),
121122 'mediaType' => 'image/jpeg',
122123 ],
123124 'type' => 'Person',
@@ -913,14 +914,14 @@
913914 }
914915
915916 $thumb = $post['thumbnail'] ?? '';
916- $imgpath = ROOT."/public/static/article/{$thumb}";
917+ $imgpath = ROOT.ass_path("article/{$thumb}");
917918 if ($thumb !== '' && is_file($imgpath)) {
918919 $note['attachment'] = [
919920 [
920921 'digestMultibase' => 'z'.base58btc_encode(hash_file('sha256', $imgpath, true)),
921922 'mediaType' => self::imageType($imgpath),
922923 'type' => 'Image',
923- 'url' => "{$this->domain}/static/article/".rawurlencode($thumb),
924+ 'url' => ass_url('article/'.rawurlencode($thumb), $this->domain),
924925 ],
925926 ];
926927 }
@@ -50,7 +50,8 @@
5050 private ?string $token;
5151
5252 protected string $dataDir = ROOT."/data/user/";
53- protected string $assDir = ROOT."/public/static/user/";
53+ /** アイコンの保存先(空ならASS_DOMAINのuser/) */
54+ protected string $assDir = '';
5455 protected int $minPassLen = 20;
5556 protected int $tokenDuration = 31536000; // 1年間
5657 protected int $verifyDuration = 86400; // 確認メールのリンクの有効期限(24時間)
@@ -62,6 +63,7 @@
6263 protected string|int|null $algo = PASSWORD_ARGON2ID;
6364
6465 public function __construct(?string $username = null) {
66+ if ($this->assDir === '') $this->assDir = ROOT.ass_path('user').'/';
6567 if (!AUTH_ENABLED) return;
6668 $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
6769 $this->token = \getcookie('kerozen');
@@ -662,7 +664,7 @@
662664 }
663665
664666 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));
666668 return Result::Error(implode('<br />', array_map(fn($e) => htmlspecialchars($e, ENT_QUOTES, 'UTF-8'), $errors)));
667669 }
668670
@@ -673,7 +675,7 @@
673675 return Result::Error('エラー:ユーザーデータの保存に失敗。');
674676 }
675677 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));
677679 }
678680
679681 if ($sendVerify !== null) {
@@ -575,18 +575,18 @@
575575 'name' => $u ? namecolor($u) : Html::esc($name),
576576 'handle' => '',
577577 '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'),
579579 'remote' => false,
580580 ];
581581 }
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];
583583 $icon = (string)($author['icon'] ?? '');
584584 return [
585585 'name' => Html::esc((string)($author['name'] ?? '')),
586586 'handle' => (string)($author['handle'] ?? ''),
587587 'url' => (string)($author['url'] ?? ''),
588588 // リモートの画像は直接読み込まない(閲覧者の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'),
590590 'remote' => true,
591591 ];
592592 }
@@ -127,7 +127,7 @@
127127 'icon' => [
128128 'type' => 'Image',
129129 'mediaType' => self::mediaTypeOf($avatar),
130- 'url' => Fedi::base().$avatar,
130+ 'url' => preg_match('#^https?://#i', $avatar) ? $avatar : Fedi::base().$avatar,
131131 ],
132132 ];
133133 $pem = self::publicKeyPem($user->username);
@@ -54,7 +54,7 @@
5454 * $_FILESの1件を保存する
5555 *
5656 * @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/ユーザー」)
5858 * @param string $prefix ファイル名の接頭辞
5959 * @param bool $uploaded is_uploaded_fileで確認するか(テストではfalse)
6060 * @param bool $video 動画も受け付けるか(アイコンは画像だけ)
@@ -81,7 +81,7 @@
8181 }
8282 if (!$isVideo && $size > self::MAX_BYTES) return \Result::Error(self::tooLarge(false));
8383
84- $rel = '/public/static/'.trim($dir, '/').'/';
84+ $rel = ass_path(trim($dir, '/')).'/';
8585 $abs = ROOT.$rel;
8686 if (!is_dir($abs) && !@mkdir($abs, 0755, true) && !is_dir($abs)) return \Result::Error('保存先を作れません。');
8787 $name = $prefix.bin2hex(random_bytes(12)).'.'.self::TYPES[$type];
@@ -112,7 +112,7 @@
112112 return \Result::Success('', [
113113 'name' => $name,
114114 'path' => $rel.$name,
115- 'url' => Fedi::base().'/static/'.trim($dir, '/').'/'.$name,
115+ 'url' => ass_url(trim($dir, '/').'/'.$name, Fedi::base()),
116116 'type' => $type,
117117 'width' => $w,
118118 'height' => $h,
@@ -283,7 +283,7 @@
283283 */
284284 public static function remove(string $path): void {
285285 $abs = realpath(ROOT.$path);
286- $root = realpath(ROOT.'/public/static');
286+ $root = realpath(ROOT.ass_path());
287287 if ($abs && $root && str_starts_with($abs, $root.DIRECTORY_SEPARATOR) && is_file($abs)) @unlink($abs);
288288 }
289289 }
@@ -310,6 +310,8 @@
310310 */
311311 public static function imageUrl(string $url): string {
312312 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;
313315 return '/microblog/media?u='.rawurlencode($url).'&s='.self::imageSig($url);
314316 }
315317
@@ -81,7 +81,7 @@
8181 */
8282 public function addCss(string $name): void {
8383 $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";
8585 $this->assign('custCss', $this->custCss);
8686 }
8787
@@ -120,9 +120,9 @@
120120 $test->assertFalse(isset($json['tag']));
121121 $test->assertStringContains('/blog/feature-test', $json['object']['url']);
122122
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'))) {
124124 $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']);
126126 $test->assertEquals('image/jpeg', $json['object']['attachment'][0]['mediaType']);
127127 }
128128 });
@@ -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 @@
223223 $test->assertTrue($view[0]['who']['remote']);
224224 // リモートの画像は直接読み込まない(マルチユーザーではこのサイト経由)
225225 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']);
227227 $test->assertTrue($view[0]['canLike'], 'リモートのコメントにもいいね出来る');
228228 $test->assertFalse(in_array("reply:{$carol}", $types('alice1234'), true), 'aliceへの返信ではない');
229229 $test->assertEquals(["comment:{$carol}", 'comment:alice1234'], array_slice($types('admin1234'), 0, 2), '管理者には全部のコメント');
@@ -269,7 +269,7 @@
269269 $test->assertEquals("{$alice}/inbox", $doc['inbox']);
270270 $test->assertEquals('https://1.1.1.1/ap/inbox', $doc['endpoints']['sharedInbox']);
271271 $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']);
273273 $test->assertEquals($alice, Actor::webfinger('acct:alice@1.1.1.1')['links'][0]['href']);
274274 $test->assertNull(Actor::webfinger('acct:alice@other.example'));
275275 $test->assertNull(Actor::webfinger('acct:nobody@1.1.1.1'));
@@ -126,7 +126,7 @@
126126 file_put_contents("{$viewDir}/css.maron", '{@ foreach ($custCss as $c) @}{{{ $c }}}{@ endforeach @}');
127127 $t = new Template('/', $viewDir);
128128 $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'));
130130 });
131131
132132 $test->it('サブディレクトリ(多言語)のテンプレートを使えるはず', function($test) use ($viewDir): void {
変更 util.php
@@ -433,13 +433,44 @@
433433 }
434434
435435 /**
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+/**
436467 * アイコンのURL(無ければnoicon.png)
437468 */
438469 function avatar_url(\stdClass $user): string {
439470 $avatar = (string)($user->avatar ?? '');
440471 $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));
443474 }
444475
445476 function make_csrf_token(?bool $force = false): string {
@@ -3,7 +3,7 @@
33 <div class="news-article">
44 {@ if (isset($meta->thumbnail) && $meta->thumbnail != '') @}
55 <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="" />
77 </div>
88 {@ endif @}
99 <div class="meta">
@@ -5,7 +5,7 @@
55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
66 <link rel="icon" href="/favicon.ico" id="favicon">
77 <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') }}" />
99 {@ if (isset($custCss) && !empty($custCss) && !is_bool($custCss)) @}
1010 {@ foreach ($custCss as $css) @}
1111 {{{ $css }}}
@@ -16,7 +16,7 @@
1616 <meta name="author" content="{{ $meta->author }}" />
1717 {@ endif @}
1818 {@ 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) }}" />
2020 {@ endif @}
2121 {@ endif @}
2222 <meta name="description" content="{{ $description }}" />
@@ -29,9 +29,9 @@
2929 <meta property="og:type" content="{{ isset($meta) && isset($meta->thumbnail) ? 'article' : 'website' }}" />
3030 <meta property="og:url" content="{{ isset($_SERVER['REQUEST_URI']) ? 'https://'.$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'] : 'https://'.$_SERVER['HTTP_HOST'] }}" />
3131 {@ 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; $}
3535 <meta property="og:image:type" content="{{ $imgspec->type }}" />
3636 <meta property="og:image:width" content="{{ $imgspec->width }}" />
3737 <meta property="og:image:height" content="{{ $imgspec->height }}" />
@@ -46,7 +46,7 @@
4646 <meta name="twitter:url" content="{{ isset($_SERVER['REQUEST_URI']) ? 'https://'.$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'] : 'https://'.$_SERVER['HTTP_HOST'] }}" />
4747 <meta name="twitter:description" content="{{ $description }}" />
4848 {@ 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']) }}" />
5050 {@ endif @}
5151 {@ endif @}
5252 {@ if (ATOM_ENABLED) @}
@@ -2,7 +2,7 @@
22 {@ if (isset($post['thumbnail']) && $post['thumbnail'] != '') @}
33 <div class="news-image">
44 <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" />
66 </a>
77 </div>
88 {@ endif @}