# FileStorage

> File storage (local disk or Google Drive) exposed via HTTP API for external apps and scripts

| | |
|---|---|
| Version | 1.0.4 |
| Category | Networking |
| Permissions | filesystem.read, filesystem.write, network.remote |
| Depends on | ai-chat |

# FileStorage

## Mục đích

FileStorage là plugin backend biến máy chạy Kazuo AI thành 1 nơi lưu trữ file, expose qua HTTP REST API để các app/script khác (local hoặc từ xa qua mạng) upload/tải/xoá file. Hỗ trợ 2 storage provider có thể chuyển đổi trong Settings: **Local Disk** (mặc định) hoặc **Google Drive**.

## File liên quan

- `plugins-library/file-storage/manifest.json`
- `plugins-library/file-storage/main/index.ts` — activate/deactivate, đăng ký IPC `plugin:file-storage:*`
- `plugins-library/file-storage/main/storage-server.ts` — HTTP server thật (Node `http` module qua `ctx.modules.http`), điều phối request tới backend đang active
- `plugins-library/file-storage/main/types.ts` — interface `FileItem`, `FolderItem`, `StorageItem`, `StorageBackend` dùng chung
- `plugins-library/file-storage/main/local-backend.ts` — backend lưu đĩa cục bộ
- `plugins-library/file-storage/main/google-drive.ts` — backend Google Drive (OAuth2 + REST API)
- `plugins-library/file-storage/widget.html`, `js/widget.js`, `css/widget.css`
- `plugins-library/file-storage/settings.html`, `js/settings.js`, `css/settings.css`
- `plugins-library/file-storage/locales/en.json5`, `vi.json5`

## Cách hoạt động

- Loại plugin: `backend` (có `mainEntry: main/index.js`, cần tiến trình HTTP server sống liên tục).
- Server lắng nghe trên **mọi network interface** (không truyền host cho `server.listen`) vì người dùng chọn phương án cho phép truy cập từ xa qua mạng, không giới hạn localhost.
- Xác thực: mọi request (trừ `GET /health`) bắt buộc header `X-API-Key` khớp với setting `apiKey`. Nếu `apiKey` rỗng thì server từ chối tất cả (fail-closed). `activate()` tự sinh API key ngẫu nhiên (`crypto.randomBytes(24)`) nếu chưa có.
- Giới hạn dung lượng: setting `maxFileSizeMB` (mặc định 100). Toàn bộ body upload được đọc vào bộ nhớ (Buffer) trước khi giao cho backend ghi — đơn giản hoá để 1 luồng xử lý dùng chung cho cả 2 backend (local ghi file, Drive cần multipart request); đây là đánh đổi chấp nhận được ở quy mô mặc định 100MB, không phù hợp cho file rất lớn.
- Đổi `port` cần bấm nút "Restart server" trong widget (gọi IPC `plugin:file-storage:restart`) vì server đã bind port cũ. Đổi `storageProvider`/`apiKey`/`maxFileSizeMB` có hiệu lực **ngay lập tức, không cần restart** — mỗi request đọc lại `ctx.getSetting()` để chọn backend.
- **Provider là kiểu "chuyển đổi" (switch), không đồng bộ song song**: tại 1 thời điểm chỉ 1 nơi lưu trữ hoạt động. File upload lúc đang ở Local Disk sẽ KHÔNG xuất hiện khi chuyển sang Google Drive và ngược lại (2 cây thư mục độc lập).
- **Mô hình thư mục** (`main/types.ts`): `StorageItem` = `FolderItem | FileItem`, phân biệt bằng field `kind`. Mọi item có `parentId: string | null` — `null` nghĩa là "thư mục gốc"; mỗi backend tự quy đổi `null` thành gốc thật của nó (Local: `null` thật sự; Drive: id của thư mục `googleFolderName`). Phần gọi API/IPC/UI không cần biết quy đổi này, chỉ truyền lại đúng id mà backend đã trả về trước đó. Phạm vi tính năng thư mục: xem + điều hướng (breadcrumb) + tạo mới + **xoá thư mục rỗng** (từ chối nếu còn item bên trong) — KHÔNG hỗ trợ move/rename/xoá đệ quy (xem "Giới hạn MVP").

### Local Disk backend (`local-backend.ts`)

- `storage-data/<id>` (trong `ctx.installPath`) — blob file thật, tên = UUID.
- `storage-index.json` — mảng metadata `{ id, name, size, mimeType, uploadedAt }`.
- Thư mục này KHÔNG bị xoá khi cài lại plugin (host chỉ copy đè file có trong `plugins-library/`, không xoá file thừa trong install dir) — chỉ mất khi Uninstall.

### Google Drive backend (`google-drive.ts`)

- **Xác thực OAuth2** — Google chặn đăng nhập trong webview nhúng, nên flow là: mở trình duyệt hệ thống thật (`electron.shell.openExternal`) tới URL consent của Google → Google redirect về 1 local HTTP server tạm (`127.0.0.1:<port ngẫu nhiên>`) do plugin tự dựng để bắt `?code=...` → đổi code lấy `access_token`/`refresh_token` qua `https://oauth2.googleapis.com/token`. Không dùng `googleapis` SDK — gọi thẳng REST API Drive v3 qua module `https` có sẵn của Node để không phải thêm npm dependency.
- **Người dùng phải tự tạo OAuth Client ID (loại "Desktop app") trên Google Cloud Console** rồi nhập Client ID/Secret vào Settings — Kazuo AI không thể tạo hộ vì đây là định danh ứng dụng gắn với tài khoản Google Cloud của người dùng. Các bước:
  1. Vào https://console.cloud.google.com/apis/credentials, tạo project (nếu chưa có).
  2. Bật "Google Drive API" trong API Library.
  3. Cấu hình màn hình đồng ý OAuth (OAuth consent screen) — nếu app ở trạng thái "Testing", phải thêm chính email Google của mình vào danh sách "Test users". **Nên bấm "Publish app" ngay ở bước này**: để ở "Testing" thì refresh token chỉ sống 7 ngày, cứ 1 tuần lại phải đăng nhập lại (xem mục refresh token bên dưới).
  4. Tạo Credentials → OAuth client ID → loại **Desktop app**. Copy Client ID + Client Secret.
- **Scope xin quyền: `https://www.googleapis.com/auth/drive` (đầy đủ)** — ĐÃ ĐỔI từ `drive.file` (hẹp) theo yêu cầu người dùng, vì `drive.file` chỉ cho thấy file do CHÍNH APP tạo ra qua API — file người dùng tự tay upload trực tiếp qua trang drive.google.com vào cùng thư mục sẽ KHÔNG hiện trong widget (đây là hạn chế thật của Google, không phải bug). Đánh đổi: quyền rộng hơn (đọc/ghi được toàn bộ Drive của người dùng, không chỉ file do app tạo), người dùng đã được hỏi và đồng ý đánh đổi này. **Người đã kết nối bằng scope cũ (`drive.file`) phải bấm Disconnect rồi Connect lại** để refresh token mới được cấp theo scope mới — đổi hằng số `SCOPE` trong code không tự nâng cấp token cũ.
- Toàn bộ dữ liệu nằm trong 1 thư mục gốc riêng trong Drive thật của người dùng (hiển thị bình thường qua Drive UI) — đây chính là "root" (`parentId: null`) của cây thư mục ảo. **Tên thư mục gốc cấu hình được qua setting `googleFolderName`** (mặc định `"FileStorage (Kazuo AI)"`, không còn hardcode). Id thư mục gốc thật cache trong setting `googleFolderId` sau lần tạo/tìm đầu tiên (`ensureRootFolderId()` trong `google-drive.ts`) — có `googleFolderId` rồi thì không gọi lại Drive API để tra cứu nữa.
- **Đổi `googleFolderName` sẽ tự xoá `googleFolderId` đang cache** (thực hiện ở tầng UI — `fieldPatch()` trong `js/widget.js` và `js/settings.js` gửi cả 2 key cùng lúc khi field này đổi). Lần gọi Drive tiếp theo sẽ tìm-hoặc-tạo thư mục gốc theo tên mới, có hiệu lực ngay, không cần disconnect/reconnect lại OAuth. File ở thư mục gốc cũ vẫn còn nguyên trên Drive nhưng plugin sẽ không thấy nữa (đổi tên = đổi "cửa sổ nhìn vào Drive", không di chuyển dữ liệu).
- **Thư mục con** (tạo qua `createFolder`) là thư mục Drive thật lồng bên trong thư mục gốc (hoặc lồng nhau tiếp) — Drive vốn hỗ trợ sẵn khái niệm này (`mimeType: application/vnd.google-apps.folder` + field `parents`), không cần thêm cơ chế riêng. `deleteFolder(id)` kiểm tra còn item con hay không (`'<id>' in parents`) trước khi gọi DELETE, ném lỗi nếu còn.
- `refreshToken` lưu plain-text trong plugin settings (giống mức bảo mật của các field password khác trong hệ thống plugin hiện tại, ví dụ `docker-manager`) — không có mã hoá riêng.
- Access token cache trong bộ nhớ (module-level), tự làm mới qua `refresh_token` grant khi hết hạn hoặc chưa có.
- **Refresh token chết thì tự ngắt kết nối** — khi grant `refresh_token` bị Google trả về `invalid_grant`, `getAccessToken()` gọi `disconnect()` (xoá `googleRefreshToken` + `googleFolderId`) rồi ném lỗi tiếng Việt hướng dẫn kết nối lại. Không làm vậy thì `isConnected()` vẫn thấy chuỗi refresh token cũ trong settings và UI báo "Connected" trong khi mọi thao tác đều lỗi, buộc người dùng phải tự mò bấm Disconnect. `requestJson()` gắn `googleErrorCode` vào Error để phân biệt được `invalid_grant` với các lỗi 400 khác — endpoint token trả mã lỗi dạng chuỗi trần (`{"error":"invalid_grant"}`), khác dạng object của Drive API.
- **Nguyên nhân số 1 khiến refresh token chết: OAuth consent screen còn ở trạng thái "Testing"** — Google giới hạn refresh token sống đúng **7 ngày** ở trạng thái này, hết hạn là phải đăng nhập lại. Cách chữa tận gốc nằm ngoài code: Google Cloud Console → OAuth consent screen → **Publish app** (chuyển "In production"). App chỉ mình mình dùng thì không cần Google xét duyệt, chỉ hiện cảnh báo "app chưa xác minh" lúc đăng nhập. Hai nguyên nhân còn lại: người dùng thu hồi quyền ở myaccount.google.com/permissions, hoặc đổi mật khẩu Google.
- Upload dùng `multipart/related` upload (metadata JSON + bytes trong 1 request) — không dùng resumable upload, phù hợp file vừa/nhỏ (giới hạn bởi `maxFileSizeMB`).

## REST API

Base URL: `http://<host>:<port>` (port mặc định `8787`, cấu hình trong Settings). API giống hệt nhau bất kể provider nào đang active. `parentId` bỏ trống/omit = thư mục gốc.

| Method | Path | Auth | Mô tả |
|---|---|---|---|
| GET | `/health` | Không | `{ ok: true }` — kiểm tra server sống |
| GET | `/items?parentId=<id>` | Có | Danh sách thư mục + file bên trong `parentId` (gốc nếu bỏ trống): `[{ kind, id, name, parentId, size?, mimeType?, uploadedAt?, createdAt? }]` |
| POST | `/folders` | Có | Tạo thư mục — JSON body `{ name, parentId }` → trả về `FolderItem` vừa tạo |
| DELETE | `/folders/:id` | Có | Xoá thư mục — trả lỗi 409 nếu thư mục còn item bên trong |
| POST | `/files?parentId=<id>` | Có | Upload — body = raw bytes, header `X-File-Name` bắt buộc (percent-encoded UTF-8), trả về `FileItem` vừa tạo |
| GET | `/files/:id` | Có | Tải file, `Content-Disposition: attachment` |
| DELETE | `/files/:id` | Có | Xoá file, `{ ok: true }` |

Header xác thực: `X-API-Key: <apiKey trong Settings>`. **Không tương thích ngược** với API bản cũ (`GET /files` trả danh sách phẳng) — đã xác nhận chưa có app/script nào phụ thuộc bản cũ trước khi đổi.

## IPC channels (`plugin:file-storage:*`)

`status`, `list-items` (parentId), `create-folder` (name, parentId), `delete-folder` (id), `delete-file` (id), `restart` (server HTTP) + `drive-status`, `connect-drive`, `disconnect-drive` (Google Drive OAuth, gọi `server.drive.*` trong `storage-server.ts`).

## UI

- **Widget kiểu File Explorer** (`js/widget.js`): breadcrumb đường dẫn (`pathStack`, mảng `{id, name}`, phần tử đầu luôn là gốc `{id: null, name: 'Home'}`) + danh sách thư mục (📁, click để mở, nút Xoá) rồi tới file (📄, size, nút Xoá) + nút "+ New Folder". Đổi provider hoặc connect/disconnect Drive đều reset `pathStack` về gốc (khác cây thư mục, id của bên này vô nghĩa với bên kia). Widget mặc định 700×600 (tăng từ 480×500 để duyệt thư mục dễ hơn).
- **"New Folder" dùng `window['kazuo-ai'].prompt()` chung** (dialog in-app của platform, chạy cả desktop lẫn web) — hàm `createFolderViaPrompt()` hỏi tên rồi gọi `create-folder`. Trước đây dùng dialog tự vẽ (`#new-folder-overlay`) vì `window.prompt()` native không render được trong webview overlay trong suốt; nay đã thay bằng prompt CHUNG của platform (không phải `window.prompt` native) nên render đúng — dialog tự vẽ đã gỡ. Mọi báo lỗi (folder không rỗng, lỗi tạo thư mục, lỗi Drive, download) dùng `window['kazuo-ai'].alert()` chung; file "Download" dùng `window['kazuo-ai'].saveFileDialog()` để chọn nơi lưu (không còn `dialog.showSaveDialog` native ở backend). Xem `.claude/features/shared-in-app-dialogs.md`.
- Dòng "Storage: Local Disk / Google Drive"; nếu đang ở Drive thì hiện thêm trạng thái kết nối + nút "Connect Google Drive"/"Disconnect"; trạng thái server (đang chạy/dừng + port); tổng số thư mục + số file + tổng dung lượng. Tự cập nhật realtime qua event `plugin:file-storage:event` (backend broadcast `files-changed` sau mọi thao tác tạo/xoá file hoặc thư mục).
- Settings (cả `settings.html` và overlay trong widget, 2 nơi giữ đồng bộ theo đúng quy ước của hệ thống plugin): chọn Storage Provider, Port, Max file size (MB), API Key, Google Client ID/Secret, Drive Folder Name, và nút Connect/Disconnect Google Drive.
- **Lưu setting theo từng field riêng lẻ** (`fieldPatch(fieldId)` trong `js/widget.js`/`js/settings.js`): mỗi ô chỉ gửi đúng key của chính nó khi đổi, KHÔNG gộp gửi toàn bộ form. Đây là fix cho 1 bug thật đã gặp: bản cũ gộp cả 6+ field vào 1 patch mỗi lần đổi 1 ô, nên 1 ô hiển thị giá trị cũ/rỗng (vd `apiKey` chưa kịp load) có thể ghi đè mất giá trị đúng đang lưu ở field khác. Nút "Back" (`settings-close` trong widget) còn chủ động gọi `flushSettingsOverlay()` để lưu lại toàn bộ trước khi đóng, phòng trường hợp field vừa sửa chưa kịp fire `change` event.
- **`toggleSettingsOverlay(show)` trong `js/widget.js` luôn `await loadSettings()` (tải lại settings mới nhất) trước khi hiện panel** — fix cho 1 race condition thật đã gặp: nếu panel Settings mở ra ngay sau khi widget vừa tải lại (vd sau bật/tắt plugin) nhưng TRƯỚC KHI `getSettings()` (IPC round-trip) hoàn tất, các field sẽ hiện giá trị rỗng từ biến `settings = {}` ban đầu; do `flushSettingsOverlay()` luôn gửi lại TOÀN BỘ field khi bấm "Back" (kể cả field không đổi), việc này từng xoá mất `googleClientId`/`googleClientSecret`/`apiKey` thật đang lưu. Luôn gọi `loadSettings()` ngay trước khi hiện panel đảm bảo dữ liệu hiển thị/flush luôn là dữ liệu thật, bất kể `init()` đã chạy xong hay chưa.
- **Ẩn/hiện nhóm "Google Drive OAuth" theo Storage Provider**: nhóm field Client ID/Secret/Drive Folder Name (+ trạng thái/nút Connect trong `settings.html`) chỉ hiển thị khi `storageProvider === 'drive'`, ẩn khi chọn Local Disk. Toggle bằng class `.hidden` trên `#ov-google-group` (widget overlay) / `#google-group` (settings.html), cập nhật ngay khi đổi provider — không cần đóng/mở lại Settings.
- **`boot(retries)` trong `js/widget.js` — retry chờ cầu nối `window['kazuo-ai']` sẵn sàng**: host inject `window['kazuo-ai']` vào webview bất đồng bộ (lúc webview bắn sự kiện `dom-ready`), chạy đua với chính script này (script nằm cuối `<body>`, chạy ngay khi HTML parse xong) — KHÔNG đảm bảo `window['kazuo-ai']` đã tồn tại lúc script chạy. Bug thật đã gặp: code cũ gọi `init()` ngay lập tức không retry, nếu thua cuộc đua thì TOÀN BỘ IPC call (`getSettings`, `invoke(...)`) im lặng trả về rỗng/null mãi mãi (không có cơ chế nào chạy lại sau đó) — biểu hiện: widget kẹt hiển thị "Storage: Local Disk" (sai), "Status unknown", "0 folders • 0 files • 0 B" dù setting thật đúng, và mọi nút (kể cả "+ New Folder") bấm không phản ứng gì (vì `invoke()` trả `null` ngay lập tức khi thiếu cầu nối, không throw nên không có alert lỗi). Fix: bọc toàn bộ `init()` + đăng ký event listener (`settingsChanged`, `plugin:file-storage:event`, i18n) trong `boot(retries)`, thử lại mỗi 200ms tối đa 20 lần (~4 giây) — cùng pattern `tryLoad(retries)` mà `js/settings.js` đã dùng từ đầu. `js/widget.js` trước đây KHÔNG có cơ chế này dù `js/settings.js` đã có — đây là chỗ khác biệt cần nhớ nếu sửa lại 1 trong 2 file sau này.
- **"Storage Location" + nút "Open" trong Settings** (cả `settings.html` và overlay widget): hiện đường dẫn thật của nơi lưu trữ đang active và cho phép mở trực tiếp.
  - IPC mới: `plugin:file-storage:get-location` (trả `{ kind: 'local'|'drive', path?, url? }`) và `plugin:file-storage:open-location` (thực thi mở), cả 2 implement trong `main/index.ts` (không phải trong từng backend, vì đây là hành vi UI-support, không phải logic lưu trữ cốt lõi).
  - Local Disk: `path` = `<installPath>/storage-data` (thư mục thật trên máy), nút Open gọi `electron.shell.openPath()` → mở File Explorer.
  - Google Drive: `url` = `https://drive.google.com/drive/folders/<googleFolderId>` (chỉ có khi đã kết nối và folder gốc đã resolve xong), nút Open gọi `electron.shell.openExternal()` → mở trình duyệt. Nếu chưa kết nối/chưa có folder id, ô hiện text "Connect Google Drive first..." và nút bị disable.
  - `refreshLocation()` (widget.js) / `refreshLocation()` (settings.js) được gọi lại mỗi khi: mở panel Settings, đổi Storage Provider, đổi Drive Folder Name (vì folder id bị xoá cache), connect/disconnect Drive — vì kết quả `get-location` phụ thuộc vào tất cả các thứ đó.

## Giới hạn MVP

- Không có CORS handling (dùng cho app/script gọi trực tiếp, không phải fetch từ trình duyệt cross-origin).
- Thư mục chỉ hỗ trợ **xem + điều hướng + tạo mới + xoá khi rỗng** — KHÔNG hỗ trợ move file/thư mục giữa các nơi, không đổi tên, không xoá đệ quy (xoá thư mục còn item bên trong sẽ bị từ chối, phải xoá hết bên trong trước).
- Không hỗ trợ resumable upload. Toàn bộ upload buffer vào RAM trước khi ghi (không stream trực tiếp xuống đĩa/Drive) — đơn giản nhưng không phù hợp file cực lớn.
- Không đồng bộ Local Disk và Google Drive — chuyển provider là đổi hẳn "nơi nhìn thấy file", không copy dữ liệu qua lại. Đổi provider cũng reset breadcrumb widget về gốc.
- Widget chưa có nút Upload file trực tiếp (chỉ xem/xoá/tạo thư mục qua UI) — upload vẫn phải qua REST API từ app/script ngoài.
- Chưa expose `getMcpTools` cho AI agent (ai-chat) — có thể bổ sung sau nếu cần AI thao tác trực tiếp (list/upload/delete) qua Dev Mode.
- Icon còn là placeholder (`icon.svg` sinh tự động) — nên thay icon thật trước khi dùng lâu dài.
