diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e1445c..eac5368 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,14 @@ They evolve on separate cadences, so each has its own version history below.
## Tools — `Line.OpenApi.Tools`
+### [1.3.0] - 2026-09-04
+
+Lets the Flex preview show your own local artwork and video without hosting.
+
+#### Added
+
+- **Local media serving for the Flex preview (`LINE_FLEX_MCP_ASSET_DIR`).** Set this environment variable to a folder and the loopback preview server serves media files from it, so a Flex message can reference them by a **relative** `url` (e.g. `assets/hero.png`) while designing — then swap only the origin for the production HTTPS URL later (the relative path stays the same). The served set matches what LINE renders in a Flex message: **JPEG/PNG** (APNG is `.png`) images and **`.mp4`** for the `video` component (other formats such as GIF/WebP are intentionally not served). Serving is **opt-in** (disabled unless the folder is set), confined to that folder (path traversal and out-of-directory symlinks are rejected), loopback-only, and read-only-safe. LINE itself renders neither local nor `data:` URLs, so this is a preview convenience.
+
### [1.2.0] - 2026-09-03
Adds a live, LINE-faithful Flex Message preview to the tool.
diff --git a/CHANGELOG_ja.md b/CHANGELOG_ja.md
index 7361ab9..0ed01c7 100644
--- a/CHANGELOG_ja.md
+++ b/CHANGELOG_ja.md
@@ -19,6 +19,14 @@ English version: [`CHANGELOG.md`](CHANGELOG.md)
## ツール — `Line.OpenApi.Tools`
+### [1.3.0] - 2026-09-04
+
+Flex プレビューで自分のローカル画像・動画をホスティングなしに表示できるようにします。
+
+#### 追加
+
+- **Flex プレビューのローカルメディア配信(`LINE_FLEX_MCP_ASSET_DIR`)。** この環境変数にフォルダを指定すると、ループバックのプレビューサーバがそのフォルダ内のメディアファイルを配信します。デザイン中は Flex メッセージから **相対** `url`(例 `assets/hero.png`)で参照でき、本番移行時は origin だけを HTTPS の URL に差し替えれば相対パス部分はそのまま使えます。配信対象は LINE が Flex メッセージでレンダリングする形式に一致します=画像は **JPEG/PNG**(APNG は `.png`)、動画は `video` コンポーネント用の **`.mp4`**(GIF/WebP 等は意図的に配信しません)。配信は **opt-in**(フォルダ未設定なら無効)、指定フォルダ配下への封じ込め(パストラバーサル・フォルダ外シンボリックリンクを拒否)、ループバック限定、read-only 安全です。LINE 本体はローカル URL も `data:` URL もレンダリングしないため、あくまでプレビュー用の利便機能です。
+
### [1.2.0] - 2026-09-03
LINE アプリに近い見た目の Flex Message ライブプレビューを追加します。
diff --git a/docs/reviews/2026-09-04-flex-preview-local-image-serving-review.md b/docs/reviews/2026-09-04-flex-preview-local-image-serving-review.md
new file mode 100644
index 0000000..e9b13f4
--- /dev/null
+++ b/docs/reviews/2026-09-04-flex-preview-local-image-serving-review.md
@@ -0,0 +1,64 @@
+# 2026-09-04 Flex プレビュー ローカル画像配信 レビュー記録
+
+## 概要
+
+`Line.OpenApi.Tools` の Flex プレビューサーバ(ループバック限定 `HttpListener`、`Services/FlexPreviewService.cs`)に、環境変数 `LINE_FLEX_MCP_IMAGE_DIR` で指定したフォルダ内の画像ファイル(`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`)を配信する機能を追加。
+
+**利用シーン:** 用意した画像をフォルダに配置 → Flex JSON からは相対 `url`(例 `"assets/hero.png"`)で参照 → 本番移行時は origin だけ HTTPS の CDN/ホストに差し替える(相対パス部分は不変=1:1 マッピング)。あくまでプレビュー用の利便機能(LINE 本体はローカル URL も `data:` URL もレンダリングしない)。
+
+## 設計判断(ADR)
+
+- **配信ディレクトリは環境変数(人が out-of-band 設定・LLM 非制御)** を採用。runtime MCP tool 方式(LLM が任意ローカルパスを配信面にできる=プレビュー経路経由の任意ファイル読み取り)と default folder 方式(暗黙的で危険)を退けた。プロジェクトの「安全ゲートはクロージャ束縛で LLM に出さない」方針と一致。
+- 未設定なら完全無効(opt-in、404 フォールスルー)。
+- パス解決を純粋関数 `ResolveImagePath` に切り出し、HTTP を介さず封じ込めを単体テスト可能に。
+- `renderer.js` は無改修(ブラウザが相対 URL をページ origin に解決)= `FlexWebAssetsParityTests`(`tools/web` ⇄ `extensions/web` byte 一致)に無影響。
+
+## 封じ込め(多層防御)
+
+1. `IsLoopbackHost(req.UserHostName)` ガード(`/api` と同軸の DNS リバインド対策)
+2. 拡張子ホワイトリスト(画像種別のみ)+ content-type も `image/*` か `application/octet-stream` に限定(XSS 誘発の text/html 等を返せない)
+3. 制御文字(`char.IsControl`)拒否=`%00` トリック無効
+4. `Path.GetFullPath` による字句正規化 → `fullBase + DirectorySeparatorChar` の Ordinal 前置比較(`../`・`..\`・`%2e%2e`・`..%2f`・`%5c`・rooted/UNC/ドライブレターをすべて捕捉。末尾セパレータ付与で兄弟プレフィックス誤許可も防止)
+5. **シンボリックリンク物理封じ込め**(レビュー反映):`File.ResolveLinkTarget(returnFinalTarget:true)` で最終ターゲットを解決し、ベース配下でなければ拒否。検証不能なら refuse。
+
+## 3 役ゲート結果(すべて PASS・BLOCKING なし)
+
+- **code-reviewer = PASS**(Low 4:e2e decode 経路未検証/シンボリックリンク/相対 env の CWD 解決/サイズ上限なし)
+- **security-reviewer = PASS**(Low:シンボリックリンク/8.3 短縮名/サイズ上限なし。封じ込め中核は堅牢と実証。タイミング攻撃・トークン漏洩・ホスト誤送出・DNS リバインド/CSRF いずれも該当なし or 十分ガード)
+- **test-arch-reviewer = PASS**(中:シンボリックリンク未カバー/e2e トラバーサルがクライアント正規化で退化。低:content-type 網羅/rooted・backslash/大文字拡張子)
+
+## 指摘への対応
+
+- **シンボリックリンク(3 役収束・中):** `File.ResolveLinkTarget` で物理封じ込めを追加。テスト追加(作成不可環境では skip)。
+- **e2e トラバーサル退化(code L1 / test-arch T1・中):** クライアント `Uri`/`HttpClient` が dot-segment を送信前正規化するため、**raw ソケットで非正規化パス `%2e%2e%2f` を送る e2e** に置換(http.sys のコネクションリセットも「非配信」として許容)。あわせて**正常系のサブディレクトリ+`%20` エンコード e2e** を追加(使い勝手の回帰固定)。
+- **content-type 網羅(T2):** `ImageContentType` を internal 化し png/jpg/jpeg/gif/webp/大文字/未知 をパラメタ化検証。
+- **rooted/backslash/大文字拡張子(T3/T4):** 主張裏取りの回帰ケースを追加。
+- **相対 env の CWD 解決(L3):** ctor コメント+README 英日に「絶対パス推奨」を明記。
+
+**見送り(follow-up):** ファイルサイズ上限なし(`File.ReadAllBytes`)— ループバック・人設定フォルダ・プレビュー用途で実害限定。既存の「POST ボディ上限未実装」follow-up と同カテゴリで将来まとめて対応。
+
+## 検証
+
+- ビルド 0 警告
+- `Line.OpenApi.Tools.Tests` 127/127 緑(+13:純粋関数封じ込め+ループバック/raw ソケット e2e+content-type)
+- 変更は `/tools` 支援ティア内の HTTP プレビュー機能。生成コード・R1 ルーティング・form-urlencoded・webhook 多態・公開 API snapshot・Kiota 版ピンに非接触(pack 12 パッケージ契約は Tools 除外で不変)。
+
+## 判定
+
+**GO 推奨・人の go/no-go 待ち(未コミット)。**
+
+## 変更ファイル
+
+- 実装: `tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs`
+- テスト: `tests/Line.OpenApi.Tools.Tests/FlexPreviewAssetServingTests.cs`(新規)
+- ドキュメント: `tools/README.md` / `tools/README_ja.md`
+
+## 追補(2026-09-04・LINE Flex 実仕様への準拠)
+
+一次情報(https://developers.line.biz/ja/reference/messaging-api/#flex-message)に合わせ、配信対象を LINE が Flex メッセージで実際にレンダリングする形式に厳密化。ゲート後・未公開のため破壊的変更の懸念なし。
+
+- **画像は JPEG/PNG(APNG=`.png`)のみ**に限定=`.gif`/`.webp` を配信対象から除外(LINE 非対応のため)。
+- **動画 `type:"video"`(mp4)に対応**=`.mp4` を配信対象に追加(content-type `video/mp4`)。renderer は video を `previewUrl`(JPEG/PNG のポスター)+▶ で描画し mp4 本体は取りに行かない(LINE アプリと同じ)ため、プレビュー表示は previewUrl 配信で足りるが、`url`(mp4)も相対参照で解決可能にした(アセット一式をフォルダに置く前提と一貫)。`renderer.js` は無改修(parity 維持)。
+- **名称を中立化**(対象が画像+動画になったため)=環境変数 `LINE_FLEX_MCP_IMAGE_DIR` → **`LINE_FLEX_MCP_ASSET_DIR`**、`ResolveImagePath`→`ResolveAssetPath`、`ImageContentType`→`AssetContentType`、`ImageExtensions`→`AssetExtensions`、`_imageDir`→`_assetDir`。
+- テスト更新: gif/webp は拒否・mp4 は受理・content-type に mp4 を追加、ループバック e2e に mp4(video/mp4)配信を追加。ファイルを `FlexPreviewAssetServingTests.cs` にリネーム。
+- ビルド 0 警告・Tools テスト **131/131** 緑(+4)。README 英日を JPEG/PNG+mp4・env var 改名・video コンポーネント例で更新。
diff --git a/tests/Line.OpenApi.Tools.Tests/FlexPreviewAssetServingTests.cs b/tests/Line.OpenApi.Tools.Tests/FlexPreviewAssetServingTests.cs
new file mode 100644
index 0000000..f9ebcb0
--- /dev/null
+++ b/tests/Line.OpenApi.Tools.Tests/FlexPreviewAssetServingTests.cs
@@ -0,0 +1,311 @@
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Text;
+using Line.OpenApi.Tools.Services;
+using Xunit;
+
+namespace Line.OpenApi.Tools.Tests;
+
+///
+/// Tests for local asset serving in : the pure path
+/// confinement logic () and loopback
+/// end-to-end fetches that prove a media file under LINE_FLEX_MCP_ASSET_DIR is actually
+/// served while traversal and unsupported extensions are refused. The served set mirrors
+/// what LINE renders in a Flex message: JPEG/PNG (incl. APNG) images and MP4 video.
+///
+public sealed class FlexPreviewAssetServingTests
+{
+ // --- pure path confinement ----------------------------------------------
+
+ [Fact]
+ public void Disabled_when_no_directory_configured()
+ {
+ Assert.Null(FlexPreviewService.ResolveAssetPath(null, "/hero.png"));
+ Assert.Null(FlexPreviewService.ResolveAssetPath("", "/hero.png"));
+ }
+
+ [Fact]
+ public void Resolves_a_file_directly_under_the_directory()
+ {
+ using var dir = new TempDir();
+ var file = dir.WriteFile("hero.png", "img");
+
+ var resolved = FlexPreviewService.ResolveAssetPath(dir.Path, "/hero.png");
+
+ Assert.Equal(Path.GetFullPath(file), resolved);
+ }
+
+ [Fact]
+ public void Resolves_a_file_in_a_subdirectory()
+ {
+ using var dir = new TempDir();
+ var file = dir.WriteFile(Path.Combine("assets", "hero.png"), "img");
+
+ var resolved = FlexPreviewService.ResolveAssetPath(dir.Path, "/assets/hero.png");
+
+ Assert.Equal(Path.GetFullPath(file), resolved);
+ }
+
+ [Theory]
+ [InlineData("clip.mp4")] // video component source
+ [InlineData("photo.jpg")]
+ [InlineData("photo.jpeg")]
+ public void Resolves_supported_media_extensions(string name)
+ {
+ using var dir = new TempDir();
+ var file = dir.WriteFile(name, "data");
+
+ var resolved = FlexPreviewService.ResolveAssetPath(dir.Path, "/" + name);
+
+ Assert.Equal(Path.GetFullPath(file), resolved);
+ }
+
+ [Fact]
+ public void Percent_encoded_path_is_decoded()
+ {
+ using var dir = new TempDir();
+ var file = dir.WriteFile(Path.Combine("my images", "a b.png"), "img");
+
+ var resolved = FlexPreviewService.ResolveAssetPath(dir.Path, "/my%20images/a%20b.png");
+
+ Assert.Equal(Path.GetFullPath(file), resolved);
+ }
+
+ [Fact]
+ public void Missing_file_resolves_to_null()
+ {
+ using var dir = new TempDir();
+ Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, "/nope.png"));
+ }
+
+ [Theory]
+ [InlineData("/notes.txt")]
+ [InlineData("/archive.zip")]
+ [InlineData("/config.json")]
+ [InlineData("/hero")]
+ [InlineData("/animation.gif")] // GIF is not a LINE Flex format → refused
+ [InlineData("/photo.webp")] // WebP is not a LINE Flex format → refused
+ public void Unsupported_extension_is_refused(string requestPath)
+ {
+ using var dir = new TempDir();
+ // Even if such a file exists on disk, an unsupported extension must not be served.
+ dir.WriteFile(requestPath.TrimStart('/'), "secret");
+ Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, requestPath));
+ }
+
+ [Theory]
+ [InlineData("/../secret.png")]
+ [InlineData("/../../secret.png")]
+ [InlineData("/assets/../../secret.png")]
+ [InlineData("/%2e%2e/secret.png")]
+ [InlineData("/..%2fsecret.png")]
+ [InlineData("/..%5csecret.png")] // backslash-encoded traversal (Windows separator)
+ [InlineData("/%2e%2e%2fsecret.png")] // fully-encoded ../
+ public void Traversal_outside_the_directory_is_refused(string requestPath)
+ {
+ using var dir = new TempDir();
+ // Place the target one level above the served directory; it must stay unreachable.
+ File.WriteAllText(Path.Combine(dir.Parent, "secret.png"), "secret");
+
+ Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, requestPath));
+ }
+
+ [Theory]
+ [InlineData("/C:/Windows/System32/drivers/etc/hosts.png")] // rooted second segment: Combine discards base
+ [InlineData("/\\\\server\\share\\x.png")] // UNC
+ public void Rooted_or_absolute_segment_is_refused(string requestPath)
+ {
+ using var dir = new TempDir();
+ Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, requestPath));
+ }
+
+ [Fact]
+ public void Uppercase_extension_is_accepted()
+ {
+ using var dir = new TempDir();
+ var file = dir.WriteFile("LOGO.PNG", "img");
+
+ var resolved = FlexPreviewService.ResolveAssetPath(dir.Path, "/LOGO.PNG");
+
+ Assert.Equal(Path.GetFullPath(file), resolved);
+ }
+
+ [Fact]
+ public void Symlink_pointing_outside_the_directory_is_refused()
+ {
+ using var dir = new TempDir();
+ var outside = Path.Combine(dir.Parent, "secret.png");
+ File.WriteAllText(outside, "secret");
+ var link = Path.Combine(dir.Path, "evil.png");
+ try { File.CreateSymbolicLink(link, outside); }
+ catch { return; } // symlink creation not permitted here (no admin/dev mode) → skip
+
+ // The lexical prefix check passes (the link sits under the dir), but resolving
+ // the final target must reveal it escapes the directory and refuse it.
+ Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, "/evil.png"));
+ }
+
+ [Fact]
+ public void Empty_or_root_path_is_refused()
+ {
+ using var dir = new TempDir();
+ Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, "/"));
+ Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, ""));
+ }
+
+ // --- content-type mapping -----------------------------------------------
+
+ [Theory]
+ [InlineData(".png", "image/png")]
+ [InlineData(".PNG", "image/png")]
+ [InlineData(".jpg", "image/jpeg")]
+ [InlineData(".jpeg", "image/jpeg")]
+ [InlineData(".mp4", "video/mp4")]
+ [InlineData(".bin", "application/octet-stream")]
+ public void Content_type_is_mapped_from_extension(string ext, string expected)
+ => Assert.Equal(expected, FlexPreviewService.AssetContentType(ext));
+
+ // --- loopback end-to-end -------------------------------------------------
+
+ [Fact]
+ public async Task Configured_media_is_served_over_loopback()
+ {
+ using var dir = new TempDir();
+ var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; // PNG magic
+ var mp4 = new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70 }; // ftyp box start
+ var sub = dir.EnsureSub("assets");
+ File.WriteAllBytes(Path.Combine(sub, "hero.png"), png);
+ File.WriteAllBytes(Path.Combine(sub, "night sky.png"), png); // a space in the name
+ File.WriteAllBytes(Path.Combine(sub, "promo.mp4"), mp4);
+
+ using var scope = new EnvScope(("LINE_FLEX_MCP_NO_OPEN", "1"), ("LINE_FLEX_MCP_ASSET_DIR", dir.Path));
+ using var service = new FlexPreviewService();
+ var url = service.Open(); // starts the loopback server, returns http://127.0.0.1:/
+ var baseUri = new Uri(url);
+
+ using var http = new HttpClient();
+
+ // An image is served with the right bytes and content type.
+ var image = await http.GetAsync(new Uri(baseUri, "assets/hero.png"));
+ Assert.Equal(HttpStatusCode.OK, image.StatusCode);
+ Assert.Equal("image/png", image.Content.Headers.ContentType?.MediaType);
+ Assert.Equal(png, await image.Content.ReadAsByteArrayAsync());
+
+ // The video component's mp4 source is served as video/mp4.
+ var video = await http.GetAsync(new Uri(baseUri, "assets/promo.mp4"));
+ Assert.Equal(HttpStatusCode.OK, video.StatusCode);
+ Assert.Equal("video/mp4", video.Content.Headers.ContentType?.MediaType);
+ Assert.Equal(mp4, await video.Content.ReadAsByteArrayAsync());
+
+ // A percent-encoded relative path (subdirectory + space) resolves end-to-end.
+ var encoded = await http.GetAsync(new Uri(baseUri, "assets/night%20sky.png"));
+ Assert.Equal(HttpStatusCode.OK, encoded.StatusCode);
+ Assert.Equal(png, await encoded.Content.ReadAsByteArrayAsync());
+
+ var missing = await http.GetAsync(new Uri(baseUri, "nope.png"));
+ Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
+
+ // Raw-socket traversal: HttpClient/Uri normalize dot-segments before sending, so
+ // assert confinement against a non-normalized wire path that reaches the server as-is.
+ // Anything other than a 200 means the secret was not served (a 400/404, or a
+ // connection reset from http.sys refusing the malformed target, all count as refused).
+ var status = await RawGetStatusAsync(baseUri, "/assets/%2e%2e%2f%2e%2e%2fsecret.png");
+ Assert.NotEqual(200, status);
+ }
+
+ [Fact]
+ public async Task Media_is_not_served_when_directory_unconfigured()
+ {
+ using var dir = new TempDir();
+ File.WriteAllText(Path.Combine(dir.Path, "hero.png"), "img");
+
+ using var scope = new EnvScope(("LINE_FLEX_MCP_NO_OPEN", "1"), ("LINE_FLEX_MCP_ASSET_DIR", null));
+ using var service = new FlexPreviewService();
+ var url = service.Open();
+
+ using var http = new HttpClient();
+ var res = await http.GetAsync(new Uri(new Uri(url), "hero.png"));
+ Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
+ }
+
+ // Send a raw HTTP/1.1 GET with an exact request-target (no client-side normalization)
+ // and return the numeric status code, or -1 if the server refused/reset the connection.
+ private static async Task RawGetStatusAsync(Uri baseUri, string rawTarget)
+ {
+ try
+ {
+ using var client = new TcpClient();
+ await client.ConnectAsync(baseUri.Host, baseUri.Port);
+ await using var stream = client.GetStream();
+ var request = $"GET {rawTarget} HTTP/1.1\r\nHost: {baseUri.Host}:{baseUri.Port}\r\nConnection: close\r\n\r\n";
+ var reqBytes = Encoding.ASCII.GetBytes(request);
+ await stream.WriteAsync(reqBytes);
+ using var reader = new StreamReader(stream, Encoding.ASCII);
+ var statusLine = await reader.ReadLineAsync() ?? "";
+ var parts = statusLine.Split(' ');
+ return parts.Length >= 2 && int.TryParse(parts[1], out var code) ? code : -1;
+ }
+ catch (Exception e) when (e is SocketException or IOException)
+ {
+ return -1; // connection reset / refused — the target was not served.
+ }
+ }
+
+ // --- helpers -------------------------------------------------------------
+
+ private sealed class TempDir : IDisposable
+ {
+ public string Path { get; }
+ public string Parent => Directory.GetParent(Path)!.FullName;
+
+ public TempDir()
+ {
+ Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(),
+ "line-flex-asset-tests", Guid.NewGuid().ToString("N"), "root");
+ Directory.CreateDirectory(Path);
+ }
+
+ public string EnsureSub(string sub)
+ {
+ var full = System.IO.Path.Combine(Path, sub);
+ Directory.CreateDirectory(full);
+ return full;
+ }
+
+ public string WriteFile(string relative, string content)
+ {
+ var full = System.IO.Path.Combine(Path, relative);
+ Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full)!);
+ File.WriteAllText(full, content);
+ return full;
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(Directory.GetParent(Path)!.FullName, recursive: true); }
+ catch { /* best effort */ }
+ }
+ }
+
+ private sealed class EnvScope : IDisposable
+ {
+ private readonly (string Key, string? Prev)[] _saved;
+
+ public EnvScope(params (string Key, string? Value)[] vars)
+ {
+ _saved = new (string, string?)[vars.Length];
+ for (var i = 0; i < vars.Length; i++)
+ {
+ _saved[i] = (vars[i].Key, Environment.GetEnvironmentVariable(vars[i].Key));
+ Environment.SetEnvironmentVariable(vars[i].Key, vars[i].Value);
+ }
+ }
+
+ public void Dispose()
+ {
+ foreach (var (key, prev) in _saved)
+ Environment.SetEnvironmentVariable(key, prev);
+ }
+ }
+}
diff --git a/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj b/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj
index 586dab2..2d4e4e4 100644
--- a/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj
+++ b/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj
@@ -14,7 +14,7 @@
- 1.2.0
+ 1.3.0
false
diff --git a/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs b/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs
index 31fdf4c..3330a40 100644
--- a/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs
+++ b/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs
@@ -21,15 +21,31 @@ namespace Line.OpenApi.Tools.Services;
/// It performs no LINE API calls and stores no secrets, so it is safe under
/// --read-only. State (the current Flex JSON) is persisted to a temp file
/// so a reopened tab restores the last preview.
+///
+/// When LINE_FLEX_MCP_ASSET_DIR is set, media files under that directory are
+/// also served, so a Flex message can reference local artwork by a relative url
+/// (e.g. "assets/hero.png") during preview and swap only the origin for the
+/// production HTTPS URL later. Serving is opt-in (disabled unless the directory is
+/// configured) and confined to that directory (path traversal is rejected). The served
+/// set matches what LINE actually renders in a Flex message: images are JPEG/PNG (APNG
+/// is .png) and videos are .mp4 (the video component). LINE itself
+/// does not render data:/local URLs, so this is a preview-only convenience.
///
internal sealed class FlexPreviewService : IDisposable
{
private static readonly string[] StaticFiles =
{ "viewer.html", "viewer.js", "renderer.js", "flex.css", "samples.js" };
+ // Only these extensions are served from the asset directory. The set mirrors the
+ // formats LINE renders in a Flex message: JPEG/PNG (incl. APNG) for the image
+ // component and MP4 for the video component. Other formats are intentionally excluded.
+ private static readonly HashSet AssetExtensions =
+ new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".mp4" };
+
private readonly object _gate = new();
private readonly ConcurrentDictionary _clients = new();
private readonly string _stateFile;
+ private readonly string? _assetDir;
private readonly bool _autoOpen;
private HttpListener? _listener;
@@ -43,6 +59,14 @@ public FlexPreviewService()
?? Path.Combine(Path.GetTempPath(), "line-flex-mcp");
_stateFile = Path.Combine(stateDir, "content.json");
_autoOpen = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("LINE_FLEX_MCP_NO_OPEN"));
+
+ // Opt-in local asset serving. Normalize to a full path once so request-time
+ // confinement compares against a stable base. A relative value is resolved
+ // against the current directory at construction time, so an absolute path is
+ // recommended. The directory need not exist yet (files may be added later);
+ // a missing file simply 404s.
+ var assetDir = Environment.GetEnvironmentVariable("LINE_FLEX_MCP_ASSET_DIR");
+ _assetDir = string.IsNullOrWhiteSpace(assetDir) ? null : SafeFullPath(assetDir);
}
// --- public API (called by the MCP tools) --------------------------------
@@ -308,6 +332,18 @@ private void HandleRequest(HttpListenerContext ctx)
WriteText(res, 200, ReadResource(name), ContentType(name));
return;
}
+ // Serve local media (opt-in via LINE_FLEX_MCP_ASSET_DIR) so a Flex
+ // message can reference artwork/video by a relative url. Apply the same
+ // loopback-host guard as /api to blunt DNS-rebinding reads.
+ if (_assetDir is not null && IsLoopbackHost(req.UserHostName))
+ {
+ var file = ResolveAssetPath(_assetDir, path);
+ if (file is not null)
+ {
+ WriteBytes(res, 200, File.ReadAllBytes(file), AssetContentType(Path.GetExtension(file)));
+ return;
+ }
+ }
WriteText(res, 404, "not found", "text/plain");
return;
}
@@ -423,8 +459,10 @@ private static void WriteRaw(HttpListenerResponse res, string text)
}
private static void WriteText(HttpListenerResponse res, int status, string text, string contentType)
+ => WriteBytes(res, status, Encoding.UTF8.GetBytes(text), contentType);
+
+ private static void WriteBytes(HttpListenerResponse res, int status, byte[] bytes, string contentType)
{
- var bytes = Encoding.UTF8.GetBytes(text);
res.StatusCode = status;
res.ContentType = contentType;
res.ContentLength64 = bytes.Length;
@@ -449,6 +487,87 @@ private static string RenderIndex(string html)
_ => "application/octet-stream",
};
+ // --- local asset serving -------------------------------------------------
+
+ // Internal for unit testing the extension → content-type mapping.
+ internal static string AssetContentType(string ext) => ext.ToLowerInvariant() switch
+ {
+ ".png" => "image/png",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".mp4" => "video/mp4",
+ _ => "application/octet-stream",
+ };
+
+ private static string? SafeFullPath(string path)
+ {
+ try { return Path.GetFullPath(path); }
+ catch { return null; }
+ }
+
+ ///
+ /// Resolve an HTTP request path (e.g. "/assets/hero.png") to a file under the
+ /// configured asset directory, or null when serving is disabled, the extension is
+ /// not an allowed media type, the resolved path escapes the directory, or the file does
+ /// not exist. Confinement is enforced by normalizing both the base and the combined path
+ /// to full paths and requiring the candidate to stay under the base — this rejects
+ /// ../ traversal, rooted/absolute segments, and backslash tricks regardless of the
+ /// encoded form. Internal for unit testing.
+ ///
+ internal static string? ResolveAssetPath(string? assetDir, string requestPath)
+ {
+ if (string.IsNullOrEmpty(assetDir) || string.IsNullOrEmpty(requestPath)) return null;
+
+ var decoded = Uri.UnescapeDataString(requestPath);
+ // Reject NUL / control characters defensively before touching the filesystem.
+ foreach (var c in decoded)
+ if (char.IsControl(c)) return null;
+
+ var relative = decoded.TrimStart('/');
+ if (relative.Length == 0) return null;
+
+ if (!AssetExtensions.Contains(Path.GetExtension(relative))) return null;
+
+ string fullBase, candidate;
+ try
+ {
+ fullBase = Path.GetFullPath(assetDir);
+ candidate = Path.GetFullPath(Path.Combine(fullBase, relative));
+ }
+ catch
+ {
+ return null;
+ }
+
+ // candidate is built from fullBase, so the base portion shares its casing;
+ // an Ordinal prefix check is both correct and case-safe here.
+ var baseWithSep = fullBase.EndsWith(Path.DirectorySeparatorChar)
+ ? fullBase
+ : fullBase + Path.DirectorySeparatorChar;
+ if (!candidate.StartsWith(baseWithSep, StringComparison.Ordinal)) return null;
+
+ if (!File.Exists(candidate)) return null;
+
+ // The prefix check above is lexical (Path.GetFullPath does not resolve links).
+ // Defense in depth: if the entry is a symlink/junction, resolve its final target
+ // and require that to stay under the base too, so a link inside the directory
+ // cannot escape it. Non-links resolve to null and are served as-is.
+ try
+ {
+ var target = File.ResolveLinkTarget(candidate, returnFinalTarget: true);
+ if (target is not null)
+ {
+ var real = Path.GetFullPath(target.FullName);
+ if (!real.StartsWith(baseWithSep, StringComparison.Ordinal)) return null;
+ }
+ }
+ catch
+ {
+ return null; // if the link target cannot be verified, refuse to serve.
+ }
+
+ return candidate;
+ }
+
// --- embedded web assets -------------------------------------------------
private static string ReadResource(string name)
diff --git a/tools/README.md b/tools/README.md
index dad44e7..3c198f2 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -383,7 +383,31 @@ before sending. No LINE API calls or credentials are involved, so these tools ar
- `line_flex_validate` — structurally validate Flex JSON → `{ valid, warnings }`
- `line_flex_open` — reopen the preview tab → `{ ok, url }`
-Env: `LINE_FLEX_MCP_NO_OPEN` (URL only, no auto-open), `LINE_FLEX_MCP_STATE_DIR` (state location).
+Env: `LINE_FLEX_MCP_NO_OPEN` (URL only, no auto-open), `LINE_FLEX_MCP_STATE_DIR` (state location),
+`LINE_FLEX_MCP_ASSET_DIR` (serve local images/video for preview — see below).
+
+#### Previewing local images and video
+
+LINE requires a public **HTTPS** URL for real media delivery, but while designing you often just want
+to see your own artwork. Set `LINE_FLEX_MCP_ASSET_DIR` to a folder (use an absolute path) and the
+preview server serves media files from it, so a Flex message can reference them by a **relative** `url`:
+
+```jsonc
+// with LINE_FLEX_MCP_ASSET_DIR=/path/to/flex-assets and flex-assets/assets/hero.png present
+{ "type": "image", "url": "assets/hero.png", "size": "full" }
+
+// a video component: the poster (previewUrl) is what renders in the preview; url is the mp4
+{ "type": "video", "url": "assets/promo.mp4", "previewUrl": "assets/promo-poster.png",
+ "altContent": { "type": "image", "url": "assets/promo-poster.png" }, "aspectRatio": "20:13" }
+```
+
+The served set matches what LINE renders in a Flex message: images are **JPEG/PNG** (APNG is `.png`)
+and video is **`.mp4`** — other formats (GIF/WebP, etc.) are intentionally not served. The browser
+resolves `assets/hero.png` against the preview origin (`http://127.0.0.1:/`). When you move the
+message to production, swap only the origin for your CDN/host — the relative path stays the same
+(`https://cdn.example.com/assets/hero.png`). Serving is **opt-in** (disabled unless the folder is set),
+**confined** to that folder (path traversal is rejected), loopback-only, and read-only-safe. This is a
+preview convenience: LINE itself renders neither local nor `data:` URLs.
The same browser renderer is also available as a Copilot App canvas extension (with a bundled
zero-dependency Node MCP server as an alternative for Claude Desktop/Code) — see
diff --git a/tools/README_ja.md b/tools/README_ja.md
index bf8f1eb..6b95ceb 100644
--- a/tools/README_ja.md
+++ b/tools/README_ja.md
@@ -382,7 +382,31 @@ AI が `line_flex_preview` で JSON をレンダリングすると、ループ
- `line_flex_validate` — Flex JSON を構造的に検証 → `{ valid, warnings }`
- `line_flex_open` — プレビュータブを開き直す → `{ ok, url }`
-環境変数: `LINE_FLEX_MCP_NO_OPEN`(自動で開かず URL のみ返す)、`LINE_FLEX_MCP_STATE_DIR`(状態の保存先)。
+環境変数: `LINE_FLEX_MCP_NO_OPEN`(自動で開かず URL のみ返す)、`LINE_FLEX_MCP_STATE_DIR`(状態の保存先)、
+`LINE_FLEX_MCP_ASSET_DIR`(プレビュー用にローカル画像/動画を配信=下記)。
+
+#### ローカル画像・動画のプレビュー
+
+実機の配信には公開 **HTTPS** URL が必須ですが、デザイン中は自分で用意した画像の見た目だけ確認したい
+ことがよくあります。`LINE_FLEX_MCP_ASSET_DIR` にフォルダ(絶対パス推奨)を指定すると、プレビューサーバが
+そのフォルダ内のメディアファイルを配信するので、Flex メッセージから **相対** `url` で参照できます:
+
+```jsonc
+// LINE_FLEX_MCP_ASSET_DIR=/path/to/flex-assets かつ flex-assets/assets/hero.png がある場合
+{ "type": "image", "url": "assets/hero.png", "size": "full" }
+
+// video コンポーネント: プレビューに映るのはポスター(previewUrl)で、url は mp4 本体
+{ "type": "video", "url": "assets/promo.mp4", "previewUrl": "assets/promo-poster.png",
+ "altContent": { "type": "image", "url": "assets/promo-poster.png" }, "aspectRatio": "20:13" }
+```
+
+配信対象は LINE が Flex メッセージでレンダリングする形式に一致します=画像は **JPEG/PNG**(APNG は `.png`)、
+動画は **`.mp4`**。それ以外(GIF/WebP 等)は配信しません。ブラウザは `assets/hero.png` をプレビューの
+origin(`http://127.0.0.1:/`)に対して解決します。本番へ移す際は origin だけを CDN/ホストに差し替え
+れば、相対パス部分はそのまま使えます(`https://cdn.example.com/assets/hero.png`)。配信は **opt-in**
+(フォルダ未設定なら無効)、指定フォルダ配下への **封じ込め**(パストラバーサル拒否)、ループバック限定、
+read-only 安全です。あくまでプレビュー用の利便機能で、LINE 本体はローカル URL も `data:` URL もレンダリング
+しません。
同じブラウザレンダラは Copilot App の canvas 拡張としても利用できます(`line` ツールを使わない
場合の代替として、依存パッケージのない Node MCP サーバも同梱)。詳細は