Repository: VOICEVOX/voicevox_engine (Python)

Issue:
書き込み系のAPIを起動引数および環境変数によって一括で無効化できるようにする
## 内容

エンジンの設定を変更するAPIが増えてきました。

- ユーザ辞書
- プリセット #523
- Web UIのCORS設定 #531
- （議論中）音声ライブラリダウンロード #536

複数のクライアント（利用者）が同居するエンジンや、設定の変更を永続化していないエンジンでは、APIを介して設定を変更できないようにすることが望ましいと思います。

エンジンを読み取り専用モードにする起動引数・環境変数を追加したいです。

<!-- ここに要望する機能を記載してください -->
<!-- その機能が必要な理由や、具体例も -->

### Pros 良くなる点

<!-- 改善される状態など -->
- 複数のクライアント間で、各自の設定の変更が共有されることを防ぐ
- 再起動すると設定が初期化されるエンジン（特別に設定していないDockerコンテナなど）で、混乱を招く設定の変更を予防する

### Cons 悪くなる点

<!-- ないことが望ましいが、もしあるなら -->

## その他

<!-- 関連して何か気がついたこと、気になることがあればココに書いてください -->



Discussion: issue作成ありがとうございます！！

こちらぜひ検討したいですね！！
デフォルトの挙動はもしかしたら設定操作系APIオフの方がいいのではと感じました。
もしこの方針なら、初期実装になるrelease-0.14内で対処したいです。
ちょっとお知恵を借りたく！ @aoirint @takana-v @y-chan 

辞書や設定変更など、操作できる系APIをデフォルトで無効にしようか考えています。
そもそもそうすべきか、また辞書は仕様変更になるが大丈夫かを検討したいのですが、どう思われますか👀

ローカル起動の時は変なリクエスト弾いてくれるので問題なさそうですが、サーバー起動時はちょっと危ないなと思ってます。
遅くなりました...!

セキュリティ的な観点から、書き込み系APIを無効化する引数および環境変数を追加すること自体は賛成です！
とはいえ、全部が無効化されると都合が悪いかもしれないので、2進数表記で各ビットに無効化する(あるいは無効化対象から外す)機能を指定する(`chmod 775 xxx`の4が読み、2が書き、1が実行みたいな)ようにすると都合がいいかも...?と思いました。(今後APIが増えることを考えるとあまり良くないかもですが、今までのVOICEVOXとして設定系APIが乱立していたわけでもないので、別にこれでも良いかなと思いました。)

あと、それをデフォルトにするというのはあまり良くないかも...?と思いました。
少なくとも現状では、辞書をデフォルトで無効化すれば、エディタにおいて機能が使えなくなってしまいます。(この場合、エディタ側で起動時引数および環境変数を変更すれば良いですが、デフォルトで起動するエンジンで、開発者向けでもない機能が使えないことには若干の違和感があるかもしれません)。

一般的なユーザーはエディタか、連携アプリケーションを使う方が大半だと思うので、デフォルト化するほどに緊急性や危険性が高いか、というとそうではないかなと思いました。



Fix (patch):
diff --git a/README.md b/README.md
index 2c4da3ff5..0c54b115a 100644
--- a/README.md
+++ b/README.md
@@ -319,6 +319,10 @@ VOICEVOX ではセキュリティ保護のため`localhost`・`127.0.0.1`・`app
 3. 保存ボタンを押して、変更を確定してください。
 4. 設定の適用にはエンジンの再起動が必要です。必要に応じて再起動をしてください。
 
+### データを変更する API を無効化する
+
+実行時引数`--disable_mutable_api`か環境変数`VV_DISABLE_MUTABLE_API=1`を指定することで、エンジンの設定や辞書などを変更する API を無効にできます。
+
 ### 文字コード
 
 リクエスト・レスポンスの文字コードはすべて UTF-8 です。
diff --git a/run.py b/run.py
index 1edbc5301..9380d4365 100644
--- a/run.py
+++ b/run.py
@@ -17,7 +17,7 @@
 
 import soundfile
 import uvicorn
-from fastapi import FastAPI, Form, HTTPException, Query, Request, Response
+from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request, Response
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.openapi.utils import get_openapi
 from fastapi.responses import JSONResponse
@@ -85,6 +85,7 @@
     get_save_dir,
     internal_root,
 )
+from voicevox_engine.utility.run_utility import decide_boolean_from_env
 
 
 def get_style_id_from_deprecated(style_id: int | None, speaker_id: int | None) -> int:
@@ -141,6 +142,7 @@ def generate_app(
     root_dir: Optional[Path] = None,
     cors_policy_mode: CorsPolicyMode = CorsPolicyMode.localapps,
     allow_origin: Optional[List[str]] = None,
+    disable_mutable_api: bool = False,
 ) -> FastAPI:
     if root_dir is None:
         root_dir = engine_root()
@@ -197,6 +199,11 @@ async def block_origin_middleware(request: Request, call_next):
                 status_code=403, content={"detail": "Origin not allowed"}
             )
 
+    # 許可されていないAPIを無効化する
+    def check_disabled_mutable_api():
+        if disable_mutable_api:
+            raise HTTPException(status_code=403, detail="エンジンの静的なデータを変更するAPIは無効化されています")
+
     engine_manifest_data = EngineManifestLoader(
         engine_root() / "engine_manifest.json", engine_root()
     ).load_manifest()
@@ -706,7 +713,12 @@ def get_presets() -> list[Preset]:
             raise HTTPException(status_code=422, detail=str(err))
         return presets
 
-    @app.post("/add_preset", response_model=int, tags=["その他"])
+    @app.post(
+        "/add_preset",
+        response_model=int,
+        tags=["その他"],
+        dependencies=[Depends(check_disabled_mutable_api)],
+    )
     def add_preset(preset: Preset) -> int:
         """
         新しいプリセットを追加します
@@ -728,7 +740,12 @@ def add_preset(preset: Preset) -> int:
             raise HTTPException(status_code=422, detail=str(err))
         return id
 
-    @app.post("/update_preset", response_model=int, tags=["その他"])
+    @app.post(
+        "/update_preset",
+        response_model=int,
+        tags=["その他"],
+        dependencies=[Depends(check_disabled_mutable_api)],
+    )
     def update_preset(preset: Preset) -> int:
         """
         既存のプリセットを更新します
@@ -750,7 +767,12 @@ def update_preset(preset: Preset) -> int:
             raise HTTPException(status_code=422, detail=str(err))
         return id
 
-    @app.post("/delete_preset", status_code=204, tags=["その他"])
+    @app.post(
+        "/delete_preset",
+        status_code=204,
+        tags=["その他"],
+        dependencies=[Depends(check_disabled_mutable_api)],
+    )
     def delete_preset(id: int) -> Response:
         """
         既存のプリセットを削除します
@@ -919,6 +941,7 @@ def installed_libraries() -> dict[str, InstalledLibraryInfo]:
             "/install_library/{library_uuid}",
             status_code=204,
             tags=["音声ライブラリ管理"],
+            dependencies=[Depends(check_disabled_mutable_api)],
         )
         async def install_library(
             library_uuid: str,
@@ -946,6 +969,7 @@ async def install_library(
             "/uninstall_library/{library_uuid}",
             status_code=204,
             tags=["音声ライブラリ管理"],
+            dependencies=[Depends(check_disabled_mutable_api)],
         )
         def uninstall_library(library_uuid: str) -> Response:
             """
@@ -1042,7 +1066,12 @@ def get_user_dict_words() -> dict[str, UserDictWord]:
             traceback.print_exc()
             raise HTTPException(status_code=422, detail="辞書の読み込みに失敗しました。")
 
-    @app.post("/user_dict_word", response_model=str, tags=["ユーザー辞書"])
+    @app.post(
+        "/user_dict_word",
+        response_model=str,
+        tags=["ユーザー辞書"],
+        dependencies=[Depends(check_disabled_mutable_api)],
+    )
     def add_user_dict_word(
         surface: str,
         pronunciation: str,
@@ -1083,7 +1112,12 @@ def add_user_dict_word(
             traceback.print_exc()
             raise HTTPException(status_code=422, detail="ユーザー辞書への追加に失敗しました。")
 
-    @app.put("/user_dict_word/{word_uuid}", status_code=204, tags=["ユーザー辞書"])
+    @app.put(
+        "/user_dict_word/{word_uuid}",
+        status_code=204,
+        tags=["ユーザー辞書"],
+        dependencies=[Depends(check_disabled_mutable_api)],
+    )
     def rewrite_user_dict_word(
         surface: str,
         pronunciation: str,
@@ -1130,7 +1164,12 @@ def rewrite_user_dict_word(
             traceback.print_exc()
             raise HTTPException(status_code=422, detail="ユーザー辞書の更新に失敗しました。")
 
-    @app.delete("/user_dict_word/{word_uuid}", status_code=204, tags=["ユーザー辞書"])
+    @app.delete(
+        "/user_dict_word/{word_uuid}",
+        status_code=204,
+        tags=["ユーザー辞書"],
+        dependencies=[Depends(check_disabled_mutable_api)],
+    )
     def delete_user_dict_word(word_uuid: str) -> Response:
         """
         ユーザー辞書に登録されている言葉を削除します。
@@ -1149,7 +1188,12 @@ def delete_user_dict_word(word_uuid: str) -> Response:
             traceback.print_exc()
             raise HTTPException(status_code=422, detail="ユーザー辞書の更新に失敗しました。")
 
-    @app.post("/import_user_dict", status_code=204, tags=["ユーザー辞書"])
+    @app.post(
+        "/import_user_dict",
+        status_code=204,
+        tags=["ユーザー辞書"],
+        dependencies=[Depends(check_disabled_mutable_api)],
+    )
     def import_user_dict_words(
         import_dict_data: dict[str, UserDictWord],
         override: bool,
@@ -1237,7 +1281,12 @@ def setting_get(request: Request) -> Response:
             },
         )
 
-    @app.post("/setting", response_class=Response, tags=["設定"])
+    @app.post(
+        "/setting",
+        response_class=Response,
+        tags=["設定"],
+        dependencies=[Depends(check_disabled_mutable_api)],
+    )
     def setting_post(
         request: Request,
         cors_policy_mode: str | None = Form(None),  # noqa: B008
@@ -1303,23 +1352,16 @@ def custom_openapi():
 def main() -> None:
     multiprocessing.freeze_support()
 
-    output_log_utf8 = os.getenv("VV_OUTPUT_LOG_UTF8", default="")
-    if output_log_utf8 == "1":
+    output_log_utf8 = decide_boolean_from_env("VV_OUTPUT_LOG_UTF8")
+    if output_log_utf8:
         set_output_log_utf8()
-    elif not (output_log_utf8 == "" or output_log_utf8 == "0"):
-        print(
-            "WARNING:  invalid VV_OUTPUT_LOG_UTF8 environment variable value",
-            file=sys.stderr,
-        )
 
     parser = argparse.ArgumentParser(description="VOICEVOX のエンジンです。")
     parser.add_argument(
         "--host", type=str, default="127.0.0.1", help="接続を受け付けるホストアドレスです。"
     )
     parser.add_argument("--port", type=int, default=50021, help="接続を受け付けるポート番号です。")
-    parser.add_argument(
-        "--use_gpu", action="store_true", help="指定するとGPUを使って音声合成するようになります。"
-    )
+    parser.add_argument("--use_gpu", action="store_true", help="GPUを使って音声合成するようになります。")
     parser.add_argument(
         "--voicevox_dir", type=Path, default=None, help="VOICEVOXのディレクトリパスです。"
     )
@@ -1340,12 +1382,12 @@ def main() -> None:
     parser.add_argument(
         "--enable_mock",
         action="store_true",
-        help="指定するとVOICEVOX COREを使わずモックで音声合成を行います。",
+        help="VOICEVOX COREを使わずモックで音声合成を行います。",
     )
     parser.add_argument(
         "--enable_cancellable_synthesis",
         action="store_true",
-        help="指定すると音声合成を途中でキャンセルできるようになります。",
+        help="音声合成を途中でキャンセルできるようになります。",
     )
     parser.add_argument(
         "--init_processes",
@@ -1354,7 +1396,7 @@ def main() -> None:
         help="cancellable_synthesis機能の初期化時に生成するプロセス数です。",
     )
     parser.add_argument(
-        "--load_all_models", action="store_true", help="指定すると起動時に全ての音声合成モデルを読み込みます。"
+        "--load_all_models", action="store_true", help="起動時に全ての音声合成モデルを読み込みます。"
     )
 
     # 引数へcpu_num_threadsの指定がなければ、環境変数をロールします。
@@ -1365,8 +1407,8 @@ def main() -> None:
         type=int,
         default=os.getenv("VV_CPU_NUM_THREADS") or None,
         help=(
-            "音声合成を行うスレッド数です。指定しないと、代わりに環境変数VV_CPU_NUM_THREADSの値が使われます。"
-            "VV_CPU_NUM_THREADSが空文字列でなく数値でもない場合はエラー終了します。"
+            "音声合成を行うスレッド数です。指定しない場合、代わりに環境変数 VV_CPU_NUM_THREADS の値が使われます。"
+            "VV_CPU_NUM_THREADS が空文字列でなく数値でもない場合はエラー終了します。"
         ),
     )
 
@@ -1374,7 +1416,7 @@ def main() -> None:
         "--output_log_utf8",
         action="store_true",
         help=(
-            "指定するとログ出力をUTF-8でおこないます。指定しないと、代わりに環境変数 VV_OUTPUT_LOG_UTF8 の値が使われます。"
+            "ログ出力をUTF-8でおこないます。指定しない場合、代わりに環境変数 VV_OUTPUT_LOG_UTF8 の値が使われます。"
             "VV_OUTPUT_LOG_UTF8 の値が1の場合はUTF-8で、0または空文字、値がない場合は環境によって自動的に決定されます。"
         ),
     )
@@ -1410,6 +1452,16 @@ def main() -> None:
         ),
     )
 
+    parser.add_argument(
+        "--disable_mutable_api",
+        action="store_true",
+        help=(
+            "辞書登録や設定変更など、エンジンの静的なデータを変更するAPIを無効化します。"
+            "指定しない場合、代わりに環境変数 VV_DISABLE_MUTABLE_API の値が使われます。"
+            "VV_DISABLE_MUTABLE_API の値が1の場合は無効化で、0または空文字、値がない場合は無視されます。"
+        ),
+    )
+
     args = parser.parse_args()
 
     if args.output_log_utf8:
@@ -1488,6 +1540,10 @@ def main() -> None:
         preset_path=preset_path,
     )
 
+    disable_mutable_api: bool = args.disable_mutable_api | decide_boolean_from_env(
+        "VV_DISABLE_MUTABLE_API"
+    )
+
     uvicorn.run(
         generate_app(
             synthesis_engines,
@@ -1499,6 +1555,7 @@ def main() -> None:
             root_dir=root_dir,
             cors_policy_mode=cors_policy_mode,
             allow_origin=allow_origin,
+            disable_mutable_api=disable_mutable_api,
         ),
         host=args.host,
         port=args.port,
diff --git a/voicevox_engine/utility/run_utility.py b/voicevox_engine/utility/run_utility.py
new file mode 100644
index 000000000..c61891b9f
--- /dev/null
+++ b/voicevox_engine/utility/run_utility.py
@@ -0,0 +1,23 @@
+import os
+import warnings
+
+
+def decide_boolean_from_env(env_name: str) -> bool:
+    """
+    環境変数からbool値を返す。
+
+    * 環境変数が"1"ならTrueを返す
+    * 環境変数が"0"か空白か存在しないならFalseを返す
+    * それ以外はwarningを出してFalseを返す
+    """
+    env = os.getenv(env_name, default="")
+    if env == "1":
+        return True
+    elif env == "" or env == "0":
+        return False
+    else:
+        warnings.warn(
+            f"Invalid environment variable value: {env_name}={env}",
+            stacklevel=1,
+        )
+        return False