chocomint 1.0.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +35 -2
- data/THIRD_PARTY_LICENSES.md +1 -0
- data/bin/chocomint-server +2 -1
- data/config/config.yml +39 -37
- data/lib/chocomint/config.rb +137 -5
- data/lib/chocomint/edit_archive_handlers.rb +323 -0
- data/lib/chocomint/edit_handlers.rb +249 -32
- data/lib/chocomint/factory.rb +3 -3
- data/lib/chocomint/llm/chat_client.rb +5 -2
- data/lib/chocomint/llm/primary_client.rb +7 -4
- data/lib/chocomint/planner.rb +8 -6
- data/lib/chocomint/server.rb +53 -3
- data/lib/chocomint.rb +1 -1
- data/public/edit/edit.css +100 -2
- data/public/edit/edit.js +512 -23
- data/public/edit/index.html +60 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf5b5e18513023f06550376aa901ada01d10efd621f53fc01c4f1671b94e3f1d
|
|
4
|
+
data.tar.gz: 8fa2badc83ba90c08fd8bcbc5a2a2632a4342993befafb3721457e10c39ee168
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6be492f0a271ab2d6978da7c22c6740522983f933f167b96fcdc7800482b341aadc48156d4cb062612abadda090dcc34220a4f0e21745e2b835c51e0a658f8fb
|
|
7
|
+
data.tar.gz: b5408738a6734a31b1d311fa455c4f2374f176c375cd6fddb7136011353c8fe7dddd54461851baf2c7ed625c6091f7a3d36e90e30ea71a6d918eda31a66ebd0d
|
data/README.md
CHANGED
|
@@ -27,7 +27,14 @@ bundle install
|
|
|
27
27
|
|
|
28
28
|
## 設定
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
設定ファイルは XDG Base Directory 準拠のユーザーパスに置かれる:
|
|
31
|
+
|
|
32
|
+
- `~/.config/chocomint/config.yaml`(`$XDG_CONFIG_HOME` があればそちらの `chocomint/config.yaml`)
|
|
33
|
+
|
|
34
|
+
初回起動時、このファイルが無ければ gem 同梱の既定 `config/config.yml` が上記へ
|
|
35
|
+
コピーされる。以後はユーザーパス側を編集する(`/edit` のモデル切り替えもここへ保存される)。
|
|
36
|
+
|
|
37
|
+
実機確認済みの構成:
|
|
31
38
|
|
|
32
39
|
```yaml
|
|
33
40
|
llm:
|
|
@@ -191,11 +198,37 @@ ruby bin/chocomint-server
|
|
|
191
198
|
|----------------|------|
|
|
192
199
|
| `GET /edit` | エディタ画面 |
|
|
193
200
|
| `GET /edit/files` | `workspace/` のファイル一覧 (JSON) |
|
|
194
|
-
| `GET /edit/file?path=` | ファイル内容 (JSON) |
|
|
201
|
+
| `GET /edit/file?path=` | ファイル内容 (JSON)。画像/PDF/動画/音声は `media` 種別だけ返す |
|
|
202
|
+
| `GET /edit/raw?path=` | 生ファイル配信(メディアプレビュー用。`Range` 対応で動画のシークも可) |
|
|
203
|
+
| `GET /edit/archive?path=` | アーカイブの第一階層エントリ + メタデータ(zip / tar / tar.gz / bz2 等) |
|
|
195
204
|
| `POST /edit/save` | 人手編集の保存(`PathGuard` で `workspace/` 内に限定) |
|
|
196
205
|
| `POST /edit/chat` | 自然言語指示で AI 編集(`Planner#run`。`expectations` に指示を渡すため意味的検証が効く) |
|
|
197
206
|
| `GET /edit/assets/*` | Monaco / xterm.js のローカル同梱アセット |
|
|
198
207
|
|
|
208
|
+
### メディアプレビュー
|
|
209
|
+
|
|
210
|
+
テキストファイルは Monaco で編集し、以下のメディアはタブ内でプレビューする
|
|
211
|
+
(`workspace/` 配下のみ・`PathGuard` で保護):
|
|
212
|
+
|
|
213
|
+
- **画像**: PNG / JPEG / GIF / WebP / BMP / ICO / AVIF / TIFF / SVG(ベクター)
|
|
214
|
+
- **文書**: PDF(ブラウザ内蔵ビューアで表示)
|
|
215
|
+
- **動画**: MP4 / WebM / MKV / MOV / AVI / OGV(`<video>`。ブラウザが対応する形式のみ再生可)
|
|
216
|
+
- **音声**: MP3 / WAV / OGG / M4A / AAC / FLAC / Opus(`<audio>`)
|
|
217
|
+
|
|
218
|
+
動画・音声は `Range` リクエストに対応し、シークやストリーミング再生ができる。
|
|
219
|
+
ブラウザがコーデック非対応の形式(MKV の一部など)は再生できずメッセージを表示する。
|
|
220
|
+
|
|
221
|
+
### アーカイブプレビュー
|
|
222
|
+
|
|
223
|
+
zip / tar 系アーカイブをクリックすると、**第一階層(トップレベル)のエントリと
|
|
224
|
+
メタデータ**(エントリ数・展開後サイズ・圧縮サイズ)をタブ内に表示する。展開は
|
|
225
|
+
サーバー側でメモリ上に行い、ディスクには書き出さない。
|
|
226
|
+
|
|
227
|
+
- **対応**: `.zip` / `.tar` / `.tar.gz`(`.tgz`)/ `.gz` / `.tar.bz2`(`.tbz2`)/ `.bz2`
|
|
228
|
+
- 配下のエントリは親ディレクトリに畳み、その配下件数を添えて表示する。
|
|
229
|
+
- `.zip` は中央ディレクトリを自前パースするため rubyzip 非依存。`.bz2` は純Ruby
|
|
230
|
+
実装の `rbzip2` で展開する(ネイティブ `libbz2` 不要)。
|
|
231
|
+
|
|
199
232
|
### 追加依存
|
|
200
233
|
|
|
201
234
|
- `ffi` — Windows の擬似コンソール (ConPTY) を FFI で叩く(プリビルド gem、DevKit 不要)。
|
data/THIRD_PARTY_LICENSES.md
CHANGED
|
@@ -41,5 +41,6 @@ AI エディタ (`/edit`) をオフラインで動作させるため、以下を
|
|
|
41
41
|
| webrick | Ruby / BSD-2-Clause | https://github.com/ruby/webrick |
|
|
42
42
|
| ffi | BSD-3-Clause | https://github.com/ffi/ffi |
|
|
43
43
|
| websocket-driver | Apache-2.0 | https://github.com/faye/websocket-driver-ruby |
|
|
44
|
+
| rbzip2 | BSD-3-Clause | https://github.com/koraktor/rbzip2 |
|
|
44
45
|
|
|
45
46
|
上記はいずれも再配布・商用利用を許容する寛容なライセンスである。
|
data/bin/chocomint-server
CHANGED
data/config/config.yml
CHANGED
|
@@ -1,37 +1,39 @@
|
|
|
1
|
-
# chocomint 設定 (DESIGN.md §11)
|
|
2
|
-
llm:
|
|
3
|
-
base_url: http://localhost:11434/v1
|
|
4
|
-
model: gemma4:e4b
|
|
5
|
-
api_key_env: OLLAMA_API_KEY # 参照する環境変数名 (無ければ空)
|
|
6
|
-
max_tokens: 4096 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
1
|
+
# chocomint 設定 (DESIGN.md §11)
|
|
2
|
+
llm:
|
|
3
|
+
base_url: http://localhost:11434/v1
|
|
4
|
+
model: gemma4:e4b
|
|
5
|
+
api_key_env: OLLAMA_API_KEY # 参照する環境変数名 (無ければ空)
|
|
6
|
+
max_tokens: 4096 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
|
|
7
|
+
timeout_sec: 300 # LLM 応答待ちのタイムアウト (秒)。ローカル LLM は遅いため長めに
|
|
8
|
+
# (ツール実行の execution.timeout_sec とは別物)
|
|
9
|
+
|
|
10
|
+
verifier:
|
|
11
|
+
enabled: true
|
|
12
|
+
# モデルは指定しない。VRAM 節約のため llm.model を共有する (factory.rb 参照)。
|
|
13
|
+
max_tokens: 1024 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
|
|
14
|
+
|
|
15
|
+
execution:
|
|
16
|
+
max_retry: 10
|
|
17
|
+
max_steps: 20 # マルチステップ要求で実行を許すステップ数の上限 (暴走防止)
|
|
18
|
+
timeout_sec: 30
|
|
19
|
+
max_output_bytes: 1048576 # stdout/stderr サイズ制限 (1 MiB)
|
|
20
|
+
|
|
21
|
+
security:
|
|
22
|
+
allowed_dirs:
|
|
23
|
+
- . # サーバーを起動した場所 (Dir.pwd) を作業ディレクトリとして許可
|
|
24
|
+
max_file_bytes: 10485760 # ファイルサイズ制限 (10 MiB)
|
|
25
|
+
allowed_commands: # run_command で実行を許可するコマンド (whitelist)
|
|
26
|
+
- git
|
|
27
|
+
- ruby
|
|
28
|
+
- node
|
|
29
|
+
- uv # Python 環境構築 (uv init / uv add / venv)
|
|
30
|
+
- ping # ネットワーク疎通確認
|
|
31
|
+
allow_all_commands: true # true にすると run_command が allowed_commands を無視し任意コマンドを実行する
|
|
32
|
+
# (⚠️ whitelist を無効化する。信頼できるローカル環境でのみ true にすること)
|
|
33
|
+
|
|
34
|
+
logging:
|
|
35
|
+
sqlite_path: ./logs/execution.db
|
|
36
|
+
|
|
37
|
+
server:
|
|
38
|
+
host: 127.0.0.1
|
|
39
|
+
port: 9210
|
data/lib/chocomint/config.rb
CHANGED
|
@@ -1,21 +1,62 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "uri"
|
|
4
6
|
|
|
5
7
|
module Chocomint
|
|
6
|
-
#
|
|
8
|
+
# 設定ファイルを読み込み、環境変数オーバーライドを適用する。
|
|
9
|
+
#
|
|
10
|
+
# 設定は XDG Base Directory 準拠のユーザーパス
|
|
11
|
+
# (~/.config/chocomint/config.yaml。$XDG_CONFIG_HOME があればそちらを優先) に置く。
|
|
12
|
+
# ユーザー設定がまだ無い場合は、gem 同梱の既定 config をそこへコピーして雛形にする
|
|
13
|
+
# (初回起動時)。読み込み・保存 (モデル切り替えの persist) はいずれもこのユーザーパスを使う。
|
|
7
14
|
class Config
|
|
8
|
-
|
|
15
|
+
# gem 同梱の既定 config (初回のコピー元。読み取り専用として扱う)。
|
|
16
|
+
BUNDLED_PATH = File.expand_path("../../config/config.yml", __dir__)
|
|
9
17
|
|
|
10
18
|
attr_reader :data
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
# XDG 準拠のユーザー設定ファイルパス (~/.config/chocomint/config.yaml)。
|
|
21
|
+
def self.user_config_path
|
|
22
|
+
base = ENV["XDG_CONFIG_HOME"]
|
|
23
|
+
base = File.join(Dir.home, ".config") if base.nil? || base.empty?
|
|
24
|
+
File.join(base, "chocomint", "config.yaml")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 設定を読み込む。明示的な path が無ければ user_config_path を使い、そこに
|
|
28
|
+
# まだファイルが無ければ gem 同梱の既定 config をコピーして作成する。
|
|
29
|
+
# コピーできない (書き込み不可など) 場合は同梱 config をそのまま読み、
|
|
30
|
+
# 保存先だけユーザーパスに向ける (persist 時に改めて作成を試みる)。
|
|
31
|
+
def self.load(path = nil)
|
|
32
|
+
path ||= ensure_user_config
|
|
13
33
|
raw = File.exist?(path) ? YAML.safe_load(File.read(path)) : {}
|
|
14
|
-
new(raw || {})
|
|
34
|
+
new(raw || {}, path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# user_config_path を用意する。無ければ同梱 config をコピーして作る。
|
|
38
|
+
# 戻り値: 実際に読み込むべきパス (コピー成功時はユーザーパス、失敗時は同梱パス)。
|
|
39
|
+
def self.ensure_user_config
|
|
40
|
+
dest = user_config_path
|
|
41
|
+
return dest if File.exist?(dest)
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
45
|
+
FileUtils.cp(BUNDLED_PATH, dest) if File.exist?(BUNDLED_PATH)
|
|
46
|
+
File.exist?(dest) ? dest : BUNDLED_PATH
|
|
47
|
+
rescue SystemCallError
|
|
48
|
+
# 書き込み不可などでコピーできないときは同梱 config を読む。
|
|
49
|
+
BUNDLED_PATH
|
|
50
|
+
end
|
|
15
51
|
end
|
|
16
52
|
|
|
17
|
-
|
|
53
|
+
# 読み込み元パス。llm モデル設定を書き戻す (persist_llm!) のにも使う。
|
|
54
|
+
# path は第 2 引数 (キーワードにすると Config.new("k" => v) が keyword 誤認で壊れるため)。
|
|
55
|
+
attr_reader :path
|
|
56
|
+
|
|
57
|
+
def initialize(data, path = self.class.user_config_path)
|
|
18
58
|
@data = deep_stringify(data)
|
|
59
|
+
@path = path
|
|
19
60
|
apply_env_overrides!
|
|
20
61
|
end
|
|
21
62
|
|
|
@@ -37,6 +78,10 @@ module Chocomint
|
|
|
37
78
|
# マルチステップ実行のステップ数上限。未設定なら max_retry を流用する (暴走防止)。
|
|
38
79
|
def max_steps = execution.fetch("max_steps", max_retry)
|
|
39
80
|
def timeout_sec = execution.fetch("timeout_sec", 30)
|
|
81
|
+
# LLM への HTTP リクエストのタイムアウト (秒)。ツール実行の timeout_sec とは分離する:
|
|
82
|
+
# ローカル LLM は 1 応答に数分かかることがあり、暴走コマンド用の短い timeout_sec を
|
|
83
|
+
# 流用すると Net::ReadTimeout で失敗するため。未設定なら余裕を持たせた既定にする。
|
|
84
|
+
def llm_timeout_sec = llm.fetch("timeout_sec", 300)
|
|
40
85
|
def max_output_bytes = execution.fetch("max_output_bytes", 1_048_576)
|
|
41
86
|
|
|
42
87
|
def allowed_dirs = security.fetch("allowed_dirs", ["."])
|
|
@@ -49,8 +94,95 @@ module Chocomint
|
|
|
49
94
|
|
|
50
95
|
def verifier_enabled? = verifier.fetch("enabled", false)
|
|
51
96
|
|
|
97
|
+
# ---- LLM プロバイダ / モデル設定 (AI エディタのモデル切り替え用) ----------
|
|
98
|
+
#
|
|
99
|
+
# provider は base_url から推定する: openrouter.ai を含めば "openrouter"、
|
|
100
|
+
# それ以外 (localhost:11434 等) は "ollama" とみなす。UI での表示・選択に使う。
|
|
101
|
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
102
|
+
OLLAMA_BASE_URL = "http://localhost:11434/v1"
|
|
103
|
+
|
|
104
|
+
def llm_base_url = llm.fetch("base_url", OLLAMA_BASE_URL)
|
|
105
|
+
def llm_model = llm.fetch("model", nil)
|
|
106
|
+
|
|
107
|
+
def llm_provider
|
|
108
|
+
llm_base_url.to_s.include?("openrouter.ai") ? "openrouter" : "ollama"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Ollama の API ベース URL (末尾に /v1 を含まない)。インストール済みモデル一覧
|
|
112
|
+
# (/api/tags) の取得に使う。provider が OpenRouter でも Ollama タブのモデルは
|
|
113
|
+
# ここから取るため、現在の provider に依存させない。
|
|
114
|
+
#
|
|
115
|
+
# 優先順位: (1) OLLAMA_HOST 環境変数 (Ollama 標準の設定) →
|
|
116
|
+
# (2) 現在の base_url が Ollama を指していればその親 →
|
|
117
|
+
# (3) 既定 (http://localhost:11434)。
|
|
118
|
+
def ollama_api_base
|
|
119
|
+
if (host = ENV["OLLAMA_HOST"]) && !host.to_s.empty?
|
|
120
|
+
return normalize_ollama_host(host)
|
|
121
|
+
end
|
|
122
|
+
base = llm_provider == "ollama" ? llm_base_url : OLLAMA_BASE_URL
|
|
123
|
+
base.to_s.sub(%r{/v1/?\z}, "").chomp("/")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# 実行時にモデル (と provider) を差し替える。Factory がここを参照して
|
|
127
|
+
# Planner / chat_client を再構築する。api_key_env は provider ごとに固定する
|
|
128
|
+
# (Ollama: OLLAMA_API_KEY / OpenRouter: OPENROUTER_API_KEY)。
|
|
129
|
+
def set_llm!(provider:, model:)
|
|
130
|
+
llm_cfg = (@data["llm"] ||= {})
|
|
131
|
+
llm_cfg["model"] = model
|
|
132
|
+
case provider.to_s
|
|
133
|
+
when "openrouter"
|
|
134
|
+
llm_cfg["base_url"] = OPENROUTER_BASE_URL
|
|
135
|
+
llm_cfg["api_key_env"] = "OPENROUTER_API_KEY"
|
|
136
|
+
else
|
|
137
|
+
llm_cfg["base_url"] = OLLAMA_BASE_URL
|
|
138
|
+
llm_cfg["api_key_env"] = "OLLAMA_API_KEY"
|
|
139
|
+
end
|
|
140
|
+
self
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# 現在の llm 設定 (provider / model / base_url / api_key_env) をユーザー設定へ
|
|
144
|
+
# 書き戻す。llm セクションだけを更新し、他セクションは元のファイル内容を保つ。
|
|
145
|
+
#
|
|
146
|
+
# 保存先は常にユーザーパス (~/.config/chocomint/config.yaml)。読み込み元が
|
|
147
|
+
# gem 同梱 config だった場合 (初回コピーに失敗したケース) でも、gem ディレクトリ
|
|
148
|
+
# には書かず、ベースは @path (同梱) の内容を引き継いでユーザーパスへ書き出す。
|
|
149
|
+
def persist_llm!
|
|
150
|
+
dest = self.class.user_config_path
|
|
151
|
+
raw = begin
|
|
152
|
+
(File.exist?(@path) && YAML.safe_load(File.read(@path))) || {}
|
|
153
|
+
rescue Psych::SyntaxError
|
|
154
|
+
{}
|
|
155
|
+
end
|
|
156
|
+
raw = deep_stringify(raw)
|
|
157
|
+
raw["llm"] = @data["llm"]
|
|
158
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
159
|
+
File.write(dest, YAML.dump(raw))
|
|
160
|
+
@path = dest
|
|
161
|
+
end
|
|
162
|
+
|
|
52
163
|
private
|
|
53
164
|
|
|
165
|
+
# Ollama の既定ポート。OLLAMA_HOST にポートが無いとき補う。
|
|
166
|
+
OLLAMA_DEFAULT_PORT = 11434
|
|
167
|
+
|
|
168
|
+
# OLLAMA_HOST の値をクライアントが接続できる URL に正規化する。
|
|
169
|
+
# OLLAMA_HOST は Ollama サーバーの「待ち受け設定」なので、そのまま接続先には使えない:
|
|
170
|
+
# - scheme が無ければ http:// を補う
|
|
171
|
+
# - ポートが無ければ既定の 11434 を補う
|
|
172
|
+
# - 0.0.0.0 / :: (全 IF で listen する指定) は接続不可なので 127.0.0.1 に読み替える
|
|
173
|
+
def normalize_ollama_host(host)
|
|
174
|
+
host = host.to_s.strip
|
|
175
|
+
host = "http://#{host}" unless host.match?(%r{\Ahttps?://}i)
|
|
176
|
+
uri = URI.parse(host)
|
|
177
|
+
uri.host = "127.0.0.1" if ["0.0.0.0", "::", "[::]"].include?(uri.host)
|
|
178
|
+
# URI::HTTP は scheme 既定ポート (80) を .port に持つため、元文字列に ":ポート" が
|
|
179
|
+
# 明示されていたかで判定する。無ければ Ollama の既定ポートを補う。
|
|
180
|
+
uri.port = OLLAMA_DEFAULT_PORT unless host.match?(/:\d+(?:\/|\z)/)
|
|
181
|
+
uri.to_s.chomp("/")
|
|
182
|
+
rescue URI::InvalidURIError
|
|
183
|
+
OLLAMA_BASE_URL.sub(%r{/v1/?\z}, "")
|
|
184
|
+
end
|
|
185
|
+
|
|
54
186
|
def apply_env_overrides!
|
|
55
187
|
if (url = ENV["CHOCOMINT_LLM_BASE_URL"])
|
|
56
188
|
(@data["llm"] ||= {})["base_url"] = url
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "rubygems/package"
|
|
7
|
+
|
|
8
|
+
module Chocomint
|
|
9
|
+
# /edit AI エディタのアーカイブプレビュー用ハンドラ (Server に include して使う)。
|
|
10
|
+
#
|
|
11
|
+
# 提供機能:
|
|
12
|
+
# GET /edit/archive?path=foo.zip アーカイブの第一階層エントリ + メタデータ (JSON)
|
|
13
|
+
#
|
|
14
|
+
# 対応形式: .zip / .tar / .tar.gz (.tgz) / .gz / .tar.bz2 (.tbz2) / .bz2
|
|
15
|
+
# いずれも展開はメモリ上で行い、ディスクには書き出さない。tar/zip はエントリを
|
|
16
|
+
# ストリーム走査し、第一階層 (トップレベル) だけに畳んで返す。
|
|
17
|
+
#
|
|
18
|
+
# 対応形式は拡張子でしか判定しない (中身の magic は見ない)。ファイルアクセスは
|
|
19
|
+
# すべて @path_guard 経由で workspace 内に制限する。
|
|
20
|
+
module EditArchiveHandlers
|
|
21
|
+
# 一覧をブラウザで扱える範囲に抑えるためのエントリ数上限 (走査自体の安全弁)。
|
|
22
|
+
# tar は全走査するため、巨大アーカイブでも到達したら打ち切って capped を立てる。
|
|
23
|
+
MAX_ARCHIVE_ENTRIES = 20_000
|
|
24
|
+
|
|
25
|
+
# bzip2 展開に許す入力サイズ上限 (純Ruby 実装は重いので極端に巨大な .bz2 は弾く)。
|
|
26
|
+
MAX_BZIP2_INPUT_BYTES = 64 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
def handle_edit_archive(req, res)
|
|
29
|
+
return edit_method_guard(res) unless req.request_method == "GET"
|
|
30
|
+
|
|
31
|
+
path = req.query["path"].to_s
|
|
32
|
+
return edit_json(res, 400, "error" => "path required") if path.empty?
|
|
33
|
+
|
|
34
|
+
abs = @path_guard.resolve(path)
|
|
35
|
+
return edit_json(res, 404, "error" => "no such file") unless File.file?(abs)
|
|
36
|
+
|
|
37
|
+
kind = archive_kind(abs)
|
|
38
|
+
return edit_json(res, 415, "error" => "unsupported archive format") unless kind
|
|
39
|
+
|
|
40
|
+
info = inspect_archive(abs, kind)
|
|
41
|
+
edit_json(res, 200, { "path" => path }.merge(info))
|
|
42
|
+
rescue Chocomint::PathAccessError => e
|
|
43
|
+
edit_json(res, 403, "error" => e.message)
|
|
44
|
+
rescue ArchiveError => e
|
|
45
|
+
# 壊れている/未対応の内部構造など。UI 向けにメッセージを返す (500 にはしない)。
|
|
46
|
+
edit_json(res, 422, "error" => e.message)
|
|
47
|
+
rescue Chocomint::Error => e
|
|
48
|
+
edit_json(res, 500, "error" => e.message)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# アーカイブ処理中の想定内エラー (壊れたデータ・展開失敗など)。
|
|
52
|
+
class ArchiveError < StandardError; end
|
|
53
|
+
|
|
54
|
+
# ---- 形式判定 -----------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
# 拡張子からアーカイブ形式を判定して種別文字列を返す (非対応は nil)。
|
|
57
|
+
# 種別: "zip" / "tar" / "tar.gz" / "tar.bz2" / "gz" / "bz2"
|
|
58
|
+
# 二重拡張子 (.tar.gz 等) を単一拡張子 (.gz) より優先する。
|
|
59
|
+
def archive_kind(abs)
|
|
60
|
+
lower = File.basename(abs).downcase
|
|
61
|
+
return "tar.gz" if lower.end_with?(".tar.gz")
|
|
62
|
+
return "tar.gz" if lower.end_with?(".tgz")
|
|
63
|
+
return "tar.bz2" if lower.end_with?(".tar.bz2")
|
|
64
|
+
return "tar.bz2" if lower.end_with?(".tbz2") || lower.end_with?(".tbz")
|
|
65
|
+
return "zip" if lower.end_with?(".zip")
|
|
66
|
+
return "tar" if lower.end_with?(".tar")
|
|
67
|
+
return "gz" if lower.end_with?(".gz")
|
|
68
|
+
return "bz2" if lower.end_with?(".bz2")
|
|
69
|
+
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# フロントがメディア/アーカイブを区別できるよう、handle_edit_file からも使う判定。
|
|
74
|
+
def archive?(abs)
|
|
75
|
+
!archive_kind(abs).nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ---- 検査 (種別ごとにディスパッチ) --------------------------------------
|
|
79
|
+
|
|
80
|
+
def inspect_archive(abs, kind)
|
|
81
|
+
case kind
|
|
82
|
+
when "zip" then inspect_zip(abs)
|
|
83
|
+
when "tar" then inspect_tar(File.binread(abs))
|
|
84
|
+
when "tar.gz" then inspect_tar(gunzip(File.binread(abs)))
|
|
85
|
+
when "tar.bz2" then inspect_tar(bunzip2(abs))
|
|
86
|
+
when "gz" then inspect_single_compressed(abs, "gzip", strip_ext: ".gz")
|
|
87
|
+
when "bz2" then inspect_single_compressed(abs, "bzip2", strip_ext: ".bz2")
|
|
88
|
+
else
|
|
89
|
+
raise ArchiveError, "unsupported archive format"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ---- tar の走査 ---------------------------------------------------------
|
|
94
|
+
#
|
|
95
|
+
# 展開済み tar バイト列を走査し、第一階層に畳んだ entries とメタデータを返す。
|
|
96
|
+
def inspect_tar(tar_bytes)
|
|
97
|
+
dirs = {} # トップレベルのディレクトリ名 => 配下エントリ数
|
|
98
|
+
files = [] # トップレベルの直下ファイル { name, size }
|
|
99
|
+
total = 0
|
|
100
|
+
total_size = 0
|
|
101
|
+
capped = false
|
|
102
|
+
|
|
103
|
+
Gem::Package::TarReader.new(StringIO.new(tar_bytes)) do |reader|
|
|
104
|
+
reader.each do |entry|
|
|
105
|
+
name = entry.full_name.to_s
|
|
106
|
+
next if name.empty? || name == "./"
|
|
107
|
+
|
|
108
|
+
total += 1
|
|
109
|
+
if total > MAX_ARCHIVE_ENTRIES
|
|
110
|
+
capped = true
|
|
111
|
+
break
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
rel = normalize_entry_name(name)
|
|
115
|
+
next if rel.empty?
|
|
116
|
+
|
|
117
|
+
size = entry.header.size.to_i
|
|
118
|
+
total_size += size unless entry.directory?
|
|
119
|
+
accumulate_top_level(rel, entry.directory?, size, dirs, files)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
build_result(dirs, files, total, total_size, capped)
|
|
124
|
+
rescue Gem::Package::TarInvalidError, Zlib::GzipFile::Error => e
|
|
125
|
+
raise ArchiveError, "tar を読み取れませんでした: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ---- zip の走査 (中央ディレクトリを自前パース) --------------------------
|
|
129
|
+
#
|
|
130
|
+
# 標準ライブラリだけで済ませるため rubyzip には依存せず、ZIP の End Of Central
|
|
131
|
+
# Directory (EOCD) → Central Directory を辿ってエントリ名とサイズを読む。
|
|
132
|
+
# 実データ (ローカルファイルヘッダ) は読まないので大きな zip でも軽い。
|
|
133
|
+
def inspect_zip(abs)
|
|
134
|
+
raw = File.binread(abs)
|
|
135
|
+
cd = zip_central_directory(raw)
|
|
136
|
+
|
|
137
|
+
dirs = {}
|
|
138
|
+
files = []
|
|
139
|
+
total = 0
|
|
140
|
+
total_size = 0
|
|
141
|
+
capped = false
|
|
142
|
+
|
|
143
|
+
cd.each do |ent|
|
|
144
|
+
total += 1
|
|
145
|
+
if total > MAX_ARCHIVE_ENTRIES
|
|
146
|
+
capped = true
|
|
147
|
+
break
|
|
148
|
+
end
|
|
149
|
+
rel = normalize_entry_name(ent[:name])
|
|
150
|
+
next if rel.empty?
|
|
151
|
+
|
|
152
|
+
total_size += ent[:size] unless ent[:dir]
|
|
153
|
+
accumulate_top_level(rel, ent[:dir], ent[:size], dirs, files)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
build_result(dirs, files, total, total_size, capped)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ZIP の中央ディレクトリを読み、各エントリの { name, size, dir } を返す。
|
|
160
|
+
def zip_central_directory(raw)
|
|
161
|
+
eocd = find_eocd(raw)
|
|
162
|
+
raise ArchiveError, "zip の終端レコードが見つかりません" unless eocd
|
|
163
|
+
|
|
164
|
+
count = raw[eocd + 10, 2].unpack1("v")
|
|
165
|
+
offset = raw[eocd + 16, 4].unpack1("V")
|
|
166
|
+
entries = []
|
|
167
|
+
pos = offset
|
|
168
|
+
|
|
169
|
+
count.times do
|
|
170
|
+
break if pos + 46 > raw.bytesize
|
|
171
|
+
sig = raw[pos, 4]
|
|
172
|
+
break unless sig == "PK\x01\x02".b # Central Directory File Header
|
|
173
|
+
|
|
174
|
+
usize = raw[pos + 24, 4].unpack1("V") # uncompressed size
|
|
175
|
+
name_len = raw[pos + 28, 2].unpack1("v")
|
|
176
|
+
extra_len = raw[pos + 30, 2].unpack1("v")
|
|
177
|
+
comment_len = raw[pos + 32, 2].unpack1("v")
|
|
178
|
+
name = raw[pos + 46, name_len].to_s
|
|
179
|
+
name = zip_decode_name(name)
|
|
180
|
+
entries << { name: name, size: usize, dir: name.end_with?("/") }
|
|
181
|
+
pos += 46 + name_len + extra_len + comment_len
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
entries
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# EOCD (End Of Central Directory, シグネチャ PK\x05\x06) を末尾から探す。
|
|
188
|
+
# ZIP コメントは最大 65535 バイトなので、末尾 (22 + 65535) だけ後方走査すれば十分。
|
|
189
|
+
def find_eocd(raw)
|
|
190
|
+
sig = "PK\x05\x06".b
|
|
191
|
+
min = [raw.bytesize - (22 + 0xFFFF), 0].max
|
|
192
|
+
i = raw.bytesize - 22
|
|
193
|
+
while i >= min
|
|
194
|
+
return i if raw[i, 4] == sig
|
|
195
|
+
|
|
196
|
+
i -= 1
|
|
197
|
+
end
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# zip のエントリ名を文字列に整える。UTF-8 として妥当ならそれ、駄目なら置換して壊さない。
|
|
202
|
+
def zip_decode_name(name)
|
|
203
|
+
s = name.dup.force_encoding("UTF-8")
|
|
204
|
+
s.valid_encoding? ? s : s.scrub("?")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# ---- 単体圧縮ファイル (.gz / .bz2、tar でない) --------------------------
|
|
208
|
+
#
|
|
209
|
+
# tar を含まない単なる圧縮ファイルは、展開後の 1 ファイルとして扱う。
|
|
210
|
+
# 中身の全展開は避け、メタデータ (圧縮/展開サイズ・推定名) だけを返す。
|
|
211
|
+
def inspect_single_compressed(abs, algorithm, strip_ext:)
|
|
212
|
+
compressed_size = File.size(abs)
|
|
213
|
+
inner_name = File.basename(abs)
|
|
214
|
+
inner_name = inner_name[0...-strip_ext.length] if inner_name.downcase.end_with?(strip_ext)
|
|
215
|
+
|
|
216
|
+
uncompressed_size =
|
|
217
|
+
case algorithm
|
|
218
|
+
when "gzip" then gzip_uncompressed_size(abs)
|
|
219
|
+
when "bzip2" then bunzip2(abs).bytesize
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
"archive" => algorithm == "gzip" ? "gz" : "bz2",
|
|
224
|
+
"single" => true,
|
|
225
|
+
"entries" => [{ "name" => inner_name, "dir" => false, "size" => uncompressed_size }],
|
|
226
|
+
"total_entries" => 1,
|
|
227
|
+
"total_size" => uncompressed_size,
|
|
228
|
+
"compressed_size" => compressed_size,
|
|
229
|
+
"capped" => false
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# ---- 展開ヘルパ ---------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def gunzip(bytes)
|
|
236
|
+
Zlib::GzipReader.new(StringIO.new(bytes)).read
|
|
237
|
+
rescue Zlib::GzipFile::Error => e
|
|
238
|
+
raise ArchiveError, "gzip を展開できませんでした: #{e.message}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# gzip 末尾 4 バイトの ISIZE (展開後サイズ mod 2^32)。4GB 未満の実用ケース向け。
|
|
242
|
+
def gzip_uncompressed_size(abs)
|
|
243
|
+
tail = File.open(abs, "rb") { |f| f.seek(-4, IO::SEEK_END); f.read(4) }
|
|
244
|
+
tail ? tail.unpack1("V") : nil
|
|
245
|
+
rescue SystemCallError
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# bzip2 を純Ruby (rbzip2) で展開する。ネイティブ libbz2 は不要。
|
|
250
|
+
def bunzip2(abs)
|
|
251
|
+
require "rbzip2"
|
|
252
|
+
raw = File.binread(abs)
|
|
253
|
+
if raw.bytesize > MAX_BZIP2_INPUT_BYTES
|
|
254
|
+
raise ArchiveError, "bzip2 ファイルが大きすぎます (上限 #{MAX_BZIP2_INPUT_BYTES / (1024 * 1024)}MB)"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
RBzip2.default_adapter::Decompressor.new(StringIO.new(raw)).read
|
|
258
|
+
rescue LoadError
|
|
259
|
+
raise ArchiveError, "bzip2 の展開には rbzip2 gem が必要です"
|
|
260
|
+
rescue ArchiveError
|
|
261
|
+
raise
|
|
262
|
+
rescue StandardError => e
|
|
263
|
+
raise ArchiveError, "bzip2 を展開できませんでした: #{e.message}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# ---- 集計の共通処理 -----------------------------------------------------
|
|
267
|
+
|
|
268
|
+
# エントリ名を正規化する ("./" 前置や重複スラッシュを除く。先頭は相対に統一)。
|
|
269
|
+
def normalize_entry_name(name)
|
|
270
|
+
s = name.tr("\\", "/")
|
|
271
|
+
s = s.sub(%r{\A\./}, "")
|
|
272
|
+
s = s.sub(%r{\A/+}, "")
|
|
273
|
+
s.gsub(%r{/+}, "/")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# 正規化済みエントリを「第一階層」に畳んで dirs / files に足し込む。
|
|
277
|
+
# "src/main.rb" → トップレベル dir "src" を登録し配下件数 +1
|
|
278
|
+
# "README.md" → トップレベル直下 file "README.md"
|
|
279
|
+
# "src/" → dir "src" を登録するだけ (ディレクトリ自身なので件数は増やさない)
|
|
280
|
+
# "emptydir/" → dir "emptydir" を件数 0 で登録 (空ディレクトリ)
|
|
281
|
+
def accumulate_top_level(rel, is_dir, size, dirs, files)
|
|
282
|
+
# 末尾スラッシュはディレクトリ自身のエントリ。区切りとしての "/" と区別する。
|
|
283
|
+
trailing_dir = rel.end_with?("/")
|
|
284
|
+
trimmed = trailing_dir ? rel.chomp("/") : rel
|
|
285
|
+
slash = trimmed.index("/")
|
|
286
|
+
|
|
287
|
+
if slash.nil?
|
|
288
|
+
# トップレベル直下のエントリ (ファイル or ディレクトリ自身)。
|
|
289
|
+
if is_dir || trailing_dir
|
|
290
|
+
dirs[trimmed] ||= 0 # 件数は増やさない (自身の登録だけ)。
|
|
291
|
+
else
|
|
292
|
+
files << { name: trimmed, size: size }
|
|
293
|
+
end
|
|
294
|
+
else
|
|
295
|
+
# 配下エントリ → トップレベルのディレクトリに畳んで件数を +1。
|
|
296
|
+
top = trimmed[0...slash]
|
|
297
|
+
dirs[top] ||= 0
|
|
298
|
+
dirs[top] += 1
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# dirs / files から JSON 用の結果ハッシュを組み立てる。
|
|
303
|
+
# entries はディレクトリ→ファイルの順、各名前順 (一覧の並びに揃える)。
|
|
304
|
+
def build_result(dirs, files, total, total_size, capped)
|
|
305
|
+
dir_entries = dirs.keys.sort.map do |name|
|
|
306
|
+
{ "name" => name, "dir" => true, "child_count" => dirs[name] }
|
|
307
|
+
end
|
|
308
|
+
# 同名ファイルの重複は稀だが、名前順で安定させる。
|
|
309
|
+
file_entries = files.uniq { |f| f[:name] }
|
|
310
|
+
.sort_by { |f| f[:name] }
|
|
311
|
+
.map { |f| { "name" => f[:name], "dir" => false, "size" => f[:size] } }
|
|
312
|
+
|
|
313
|
+
{
|
|
314
|
+
"archive" => "tar_or_zip",
|
|
315
|
+
"single" => false,
|
|
316
|
+
"entries" => dir_entries + file_entries,
|
|
317
|
+
"total_entries" => total,
|
|
318
|
+
"total_size" => total_size,
|
|
319
|
+
"capped" => capped
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|