Compare commits

...
126 Commits
Author SHA1 Message Date
ssongliu 4c396d68ca fix: simplify login page i18n labels (#12584) 2026-04-23 18:22:51 +08:00
CityFun ab0fe4286a fix: fixed issue with model account invalid in hermes (#12590) 2026-04-23 18:01:54 +08:00
蘭 5ab758d626 fix: Fix the pagination issue in the historical version list (#12583) 2026-04-23 11:20:12 +08:00
王贺 3a0a6537f0 ci: skip add-labels for dependabot PRs
* ci: skip add-labels for dependabot PRs
2026-04-22 17:31:53 +08:00
蘭 4eda92f25c refactor: simplify pagination logic and update theme change listener (#12564) 2026-04-22 16:53:24 +08:00
王贺 94f8c5d2f1 chore: add dependabot config (#12565) 2026-04-22 16:53:12 +08:00
王贺 92598effc1 fix: keep app tags on one line (#12563) 2026-04-22 15:26:38 +08:00
CityFun 748cecaee8 feat: add fu-step (#12562) 2026-04-22 15:26:26 +08:00
王贺 b783034d0a refactor: refine translations (#12561) 2026-04-22 14:13:06 +08:00
CityFun 94a4e39793 feat: remove some config for hermes channel (#12560) 2026-04-22 13:43:24 +08:00
CityFun 1864dee591 style: change file history table style (#12559) 2026-04-22 03:03:01 +00:00
ssongliu 8c4ea85d02 feat: support fxplay resource URLs (#12558) 2026-04-22 03:01:00 +00:00
CityFun a714d5a84e feat: update hermes qq wecom channel config (#12555) 2026-04-21 19:49:32 +08:00
Wu MX 4f97a7890f feat: Enhance container data handling and status synchronization (#12554) 2026-04-21 18:45:50 +08:00
蘭 f15f91e5fa feat: add support for stopping compression tasks and enhance compression functionality (#12553) 2026-04-21 18:40:45 +08:00
CityFun 02bd500917 style: change table style (#12552) 2026-04-21 16:35:34 +08:00
CityFun 810c6fd232 feat: change hermes channel logic (#12551) 2026-04-21 16:35:24 +08:00
ssongliu d50a8983bc fix: adjust home dashboard styles (#12550) 2026-04-21 16:35:15 +08:00
ssongliu 89647304bb fix: adjust home dashboard styles (#12549) 2026-04-21 16:35:04 +08:00
蘭 9ebad13bf8 fix: Fix the error of switching between the current file and the historical version of the opened file (#12548) 2026-04-21 16:34:54 +08:00
王贺 cc0ac8cc43 fix mongodb root password sync (#12547)
#### What this PR does / why we need it?

#### Summary of your change

#### Please indicate you've done the following:

- [ ] Made sure tests are passing and test coverage is added if needed.
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
2026-04-21 04:00:57 +00:00
王贺 9f5a1d842a fix mongodb password action (#12546)
#### What this PR does / why we need it?

#### Summary of your change

#### Please indicate you've done the following:

- [ ] Made sure tests are passing and test coverage is added if needed.
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
2026-04-21 03:58:58 +00:00
王贺 beb33957be fix mongodb upload alert visibility (#12545)
#### What this PR does / why we need it?

#### Summary of your change

#### Please indicate you've done the following:

- [ ] Made sure tests are passing and test coverage is added if needed.
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
2026-04-21 03:56:57 +00:00
蘭 ffcbbb705b fix: restore codicons and folding support in Monaco editor (#12544) 2026-04-21 03:54:58 +00:00
王贺 058bc71630 fix mongodb remote edit action (#12543)
#### What this PR does / why we need it?

#### Summary of your change

#### Please indicate you've done the following:

- [ ] Made sure tests are passing and test coverage is added if needed.
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
2026-04-21 03:52:58 +00:00
王贺 0a38979bd9 fix mongodb uninstall cleanup (#12542)
#### What this PR does / why we need it?

#### Summary of your change

#### Please indicate you've done the following:

- [ ] Made sure tests are passing and test coverage is added if needed.
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
2026-04-21 03:50:57 +00:00
CityFun 5fa4a15914 style: Optimize the table styling. (#12540) 2026-04-20 22:21:45 +08:00
CityFun 81c551a11b feat: Optimize the change detection for application Compose files. (#12539) 2026-04-20 17:47:52 +08:00
CityFun 5681449353 fix: Optimize the website name badge style. (#12538) 2026-04-20 17:47:42 +08:00
CityFun 88849e6355 fix: Fix the issue where the directory can still be modified while editing the runtime environment. (#12537) 2026-04-20 17:47:31 +08:00
蘭 d6bbcdc32f fix: improve pagination handling and directory naming in recycle bin (#12535) 2026-04-20 06:44:56 +00:00
KOMATA abd1581c1d fix: enhance file and trash services with metadata validation (#12529)
* feat: Enhance file and recycle bin services with metadata handling and validation

* refactor: Rename recycle bin metadata handling to trash info and update related functions

* refactor: Remove orphan trash info pruning logic and related map usage in recycle bin service
2026-04-20 14:11:15 +08:00
CityFun cd30ec7632 feat: support session delete for hermes-agent (#12533) 2026-04-20 06:08:56 +00:00
蘭 646826769a feat: Enhance file sharing functions with optional headers for improved localization support (#12530) 2026-04-20 06:06:56 +00:00
卢亦之 074c0abb3b fix: correct listening status display on the firewall page (#12516)
The bug only appeared when the same process had both TCP and UDP sockets:
every listening port on that process was attributed to whichever protocol
was encountered first. For example, if a process listened on TCP 8000 and
UDP 8001, the UI could wrongly show TCP 8001 as in use while UDP 8001
looked unused.

Use (PID, conn.Type) as the cache key so each protocol has its own
ListeningProcess entry.
2026-04-20 11:27:18 +08:00
ssongliu 3a00e893bc fix: fix psession atomic alignment (#12528) 2026-04-20 03:26:55 +00:00
蘭 2f37677650 fix: Refactor file history management to include current content and improve error handling (#12527) 2026-04-20 02:58:56 +00:00
CityFun 12ae89923b feat: support skill management for hermes-agent (#12514) 2026-04-17 16:36:17 +08:00
蘭 031a203a74 fix: Enhance file history settings with default values and validation (#12512) 2026-04-17 16:05:57 +08:00
蘭 646b99974a feat: File editing supports Dockerfile language (#12511) 2026-04-17 16:05:46 +08:00
蘭 254ed954e9 feat: Add file history management features (#12510) 2026-04-16 19:11:05 +08:00
CityFun a0050dca09 feat: update golang Dependency (#12509) 2026-04-16 17:36:52 +08:00
王贺 cd80b4e6c3 fix(frontend): clean up build warnings (#12508) 2026-04-16 16:53:24 +08:00
CityFun df5d3d782c feat: Optimize the openclaw channel bot add flow (#12507) 2026-04-16 08:52:48 +00:00
CityFun 3c5b3c3467 feat: Optimize the Hermes WeChat login flow (#12506) 2026-04-16 16:42:21 +08:00
Copilotandwan92hen 2bdba42d87 docs: add translation contribution guide (#12505)
* docs: add translation contribution guide with reference PR and checklist

Agent-Logs-Url: https://github.com/1Panel-dev/1Panel/sessions/e6a41d10-557e-4ca1-a78b-18888a9f17be

Co-authored-by: wan92hen <27671436+wan92hen@users.noreply.github.com>

* docs: clarify frontend vs backend locale file naming conventions

Agent-Logs-Url: https://github.com/1Panel-dev/1Panel/sessions/e6a41d10-557e-4ca1-a78b-18888a9f17be

Co-authored-by: wan92hen <27671436+wan92hen@users.noreply.github.com>

* docs: update reference PR to #10352 (merged Spanish locale)

Agent-Logs-Url: https://github.com/1Panel-dev/1Panel/sessions/2b14f230-892a-412e-baed-07e67614d1e9

Co-authored-by: wan92hen <27671436+wan92hen@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wan92hen <27671436+wan92hen@users.noreply.github.com>
2026-04-16 15:00:11 +08:00
CityFun 0b146f7ff3 feat: add unbind website for agent (#12500) 2026-04-15 18:32:46 +08:00
BugKing c28a4f6d69 Remove link to development guideline from CONTRIBUTING.md (#12502)
Removed a link to the development guideline to streamline the contributing process.
2026-04-15 18:32:13 +08:00
CityFun f93a34acc4 fix(ai): Fixed issue with delete hermes-agent failed (#12499) 2026-04-15 08:24:46 +00:00
CityFun 4e78841bf6 feat(ai): support Hermes Agent chat sessions in 1Panel (#12497)
* feat(ai): support Hermes Agent chat sessions in 1Panel

* feat(ai): support Hermes Agent chat sessions in 1Panel

* feat(ai): change docker command
2026-04-15 15:44:20 +08:00
王贺 7f5bb58c72 fix: mongodb restore fixes (#12496)
* fix mongodb restore flow

* chore cleanup i18n and terminal style
2026-04-15 15:26:30 +08:00
王贺 6a28359ac2 fix mongodb route fallback (#12495) 2026-04-15 13:51:20 +08:00
王贺 59655695b5 fix(frontend): correct anti-leech component import path (#12494) 2026-04-15 11:29:27 +08:00
CityFun 786c5c1d7c feat: support hermes-agent (#12493) 2026-04-15 03:10:45 +00:00
KOMATAandssongliu 08388124b3 feat: Migrate to MinIO SDK and enhance S3 client with TLS and context (#12207)
* chore: update dependencies and migrate S3 client to MinIO SDK

- Removed AWS SDK dependency and replaced S3 client implementation with MinIO SDK.
- Updated various dependencies in go.mod and go.sum, including version upgrades for `klauspost/compress`, `minio/minio-go`, and `rs/xid`.
- Adjusted S3 client methods to utilize MinIO's API for bucket listing, object existence checks, uploads, downloads, and deletions.

* refactor: enhance S3 client with context management and TLS support

- Introduced context management with timeouts for S3 client operations to improve reliability.
- Added TLS configuration to support secure connections based on the endpoint scheme.
- Updated S3 client methods to utilize the new context and transport settings for enhanced performance and security.

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-04-15 10:00:08 +08:00
王贺 34ccc68b3a fix: clear firewall rule description (#12491)
#### What this PR does / why we need it?

Refs https://github.com/1Panel-dev/1Panel/issues/12488

#### Summary of your change

#### Please indicate you've done the following:

- [ ] Made sure tests are passing and test coverage is added if needed.
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
2026-04-14 10:44:46 +00:00
王贺 97ef386f17 fix: correct tamper english i18n (#12490)
#### What this PR does / why we need it?

Refs https://github.com/1Panel-dev/1Panel/issues/12482
#### Summary of your change

#### Please indicate you've done the following:

- [ ] Made sure tests are passing and test coverage is added if needed.
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
2026-04-14 10:42:45 +00:00
王贺 394339d7dc feat: support mongodb management (#12486)
#### What this PR does / why we need it?

Refs https://github.com/1Panel-dev/1Panel/issues/7759

#### Summary of your change

#### Please indicate you've done the following:

- [ ] Made sure tests are passing and test coverage is added if needed.
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
2026-04-14 10:40:44 +00:00
蘭 8362578355 fix: Resolve the issue of QR code sharing files not being able to download (#12485) 2026-04-14 07:12:44 +00:00
CityFun 6dc218cf80 perf: Optimize the frontend architecture. (#12479) 2026-04-13 18:36:28 +08:00
CityFun 89888cef73 feat: Optimize frontend structure (#12475) 2026-04-13 05:48:42 +00:00
ssongliu 4d06f444ac refactor: restore symlink-based xpack integration (#12473) 2026-04-13 10:34:59 +08:00
CityFun 6ea07421ac feat: change feishu channel logic (#12465) 2026-04-10 13:36:25 +08:00
蘭 326d52e311 fix: Fix some issues with sharing files (#12466) 2026-04-10 13:36:10 +08:00
CityFun 6e3f751dba feat: add model name required check for agent (#12463) 2026-04-10 03:30:40 +00:00
ssongliu 6982fd22d4 fix: extend agent role command timeout (#12464) 2026-04-10 03:28:39 +00:00
ssongliu 04fd456b63 fix: silence csrf forbidden toast (#12461) 2026-04-09 22:23:47 +08:00
ssongliu bea4bfeee8 feat: update ai agent config texts 2026-04-09 21:33:16 +08:00
蘭 fa6157b610 feat: enhance AI search functionality with response language support (#12460) 2026-04-09 19:00:26 +08:00
CityFun 30af26779c style: change openclaw channel style (#12459) 2026-04-09 10:48:38 +00:00
CityFun 16d5c00b9a fix: fix issue with openclaw discord config error (#12456) 2026-04-09 17:39:01 +08:00
蘭 3f14f8f30e feat: File management supports link share file (#12453) 2026-04-09 17:38:38 +08:00
ssongliu 218f92a960 fix: fallback to password login when mfa needs captcha (#12438) 2026-04-09 10:28:39 +08:00
ssongliu 75be8d3a23 fix: extend ai agent api timeouts (#12443) 2026-04-09 10:27:28 +08:00
ssongliu e2d8125b3d chore: shorten tamper translations (#12442) 2026-04-09 10:26:41 +08:00
CityFun 7ec3c517c2 feat: Modify the configuration of the OpenClaw DingTalk channel. (#12440) 2026-04-09 10:26:22 +08:00
CityFun e186e30d8c feat: remove deployment type for agent website binding (#12435) 2026-04-09 10:26:00 +08:00
CityFun 67a8ba2421 feat: Add app status sync logic for agent (#12436) 2026-04-08 08:36:40 +00:00
dependabot[bot] 24fc15beaf chore(deps): bump github.com/go-jose/go-jose/v4 in /agent (#12432)
Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.1.3 to 4.1.4.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.1.3...v4.1.4)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-version: 4.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 18:12:42 +08:00
王贺 c1fe70b53e chore: align build workflow runtime versions (#12434) 2026-04-07 18:11:20 +08:00
CityFun 30bf4a8d46 feat: add agent website check for delete (#12433) 2026-04-07 09:56:39 +00:00
CityFun 9e501ce02b feat: Modify the certificate auto-renewal logic. (#12430)
Refs https://github.com/1Panel-dev/1Panel/issues/12389
2026-04-07 09:54:38 +00:00
王贺 d74692a68b fix: optimize runtime script dropdown (#12431) 2026-04-07 17:45:09 +08:00
王贺 57fd80fb3d fix: optimize openresty other page layout (#12429) 2026-04-07 17:44:28 +08:00
KOMATA 39d2c34682 fix: ensure proper resource management in file operations (#12358)
* fix: Ensure proper resource management in file operations by closing files after creation and compression

* fix: Improve error handling for log file operations in MediaFile function
2026-04-07 17:35:31 +08:00
CityFun 3e4a48c892 feat: Modify the OpenClaw plugin installation parameters. (#12427) 2026-04-07 07:33:32 +00:00
CityFun 2c8f8f32fd feat: Add default external port access to application settings. (#12426) 2026-04-07 07:31:32 +00:00
maninhill 3cf6d9ac51 Update user count in README 2026-04-07 15:26:42 +08:00
BugKing 64d8fe8bd0 docs: Update README.md 2026-04-07 15:26:13 +08:00
蘭 8129260508 feat: File management supports AI search (#12415) 2026-04-03 22:11:01 +08:00
ssongliu a40786a7fa refactor: clean up ollama pull log parsing (#12414) 2026-04-03 22:10:47 +08:00
CityFun 368193a777 feat: Add website binding support for the agent (#12413) 2026-04-03 09:15:54 +00:00
ssongliu 61ef725892 feat: improve terminal ai runtime updates (#12409) 2026-04-03 07:58:13 +00:00
CityFun 0c49c41085 feat: change ai menu (#12407) 2026-04-03 07:56:12 +00:00
ssongliu 2a498de96e fix: tighten clean tree delete semantics (#12406)
Refs #12399
2026-04-03 07:54:12 +00:00
ssongliu d055cbb053 feat: improve terminal ai notice interaction 2026-04-03 14:28:09 +08:00
ssongliu 4b3f8a9fd0 style: refine app launcher card layout (#12404) 2026-04-03 11:23:27 +08:00
CityFun 4c26945910 feat: update default model config (#12403) 2026-04-02 10:30:07 +00:00
ssongliu 55b6104b56 fix: reset dashboard delta baseline after resume (#12402)
Refs #12067
2026-04-02 10:28:08 +00:00
CityFun 12dc8aae81 feat: Optimize Monaco editor loading (#12401) 2026-04-02 10:26:08 +00:00
ssongliu 37e2b93693 refactor: replace el-dialog with DialogPro (#12400) 2026-04-02 10:24:09 +00:00
ssongliu 62daf1e019 fix: improve ssh config file editing and include handling (#12398)
Refs #11899
2026-04-02 10:22:09 +00:00
CityFun f5e75d4bfa feat: The skill marketplace supports ClawHub China acceleration. (#12396) 2026-04-02 06:02:06 +00:00
ssongliu 32fed45c1e fix: skip proxy check when disabled (#12388)
Refs #12379
2026-04-01 11:22:08 +00:00
ssongliu 8b7597abef chore: upgrade grpc buildkit and x/image (#12387) 2026-04-01 11:20:08 +00:00
CityFun 01fb9a461b feat: OpenClaw supports configuring backup models. (#12386) 2026-04-01 11:18:09 +00:00
ssongliu bc022a4d21 refactor: simplify agent role binding inputs (#12385) 2026-04-01 11:16:09 +00:00
蘭 973cab2837 fix: Update ESLint plugin and solve the issue of code filter overflow in the development environment (#12383) 2026-04-01 09:14:09 +00:00
ssongliu 5ce155eee6 feat: add agent role binding management (#12382) 2026-04-01 09:12:07 +00:00
CityFun d83b49cc70 feat: Add more configuration options to channels. (#12380) 2026-04-01 08:24:08 +00:00
ssongliu ec981f8153 fix: harden login IP checks and entrance cookie (#12378) 2026-04-01 08:22:09 +00:00
ssongliu b2853277e7 fix: improve auth lock handling and login feedback (#12377) 2026-03-31 11:00:06 +00:00
ssongliu f573a0fa0e fix: improve request validation and session lifecycle (#12375) 2026-03-31 10:58:05 +00:00
CityFun f787734feb feat: Support uninstallation and upgrades of OpenClaw plugins. (#12373) 2026-03-31 10:08:05 +00:00
CityFun 52778f520a feat: QQ and Feishu use official plugins.QQ, Feishu, DingTalk, Telegram, and Discord support multiple accounts. (#12369)
feat: QQ and Feishu use official plugins.QQ, Feishu, DingTalk, Telegram, and Discord support multiple accounts.
2026-03-31 05:48:04 +00:00
ssongliu b5aba18248 fix: harden core memory session handling (#12366) 2026-03-30 14:30:06 +00:00
ssongliu bc9882da50 fix: guard mfa login with ip tracker (#12365) 2026-03-30 22:28:32 +08:00
ssongliu 0ba7e98e8d feat: add agent config role management (#12364) 2026-03-30 08:59:06 +00:00
王贺 e6dc44d135 chore: refine i18n (#12350)
* chore: refine i18n

* fix overview i18n

* feat: remove joinLabel funciton
2026-03-30 16:54:19 +08:00
蘭 a9f30e9f5f feat: Enhance Bark alert configuration options and visibility (#12362) 2026-03-30 06:55:05 +00:00
CityFun f3bf9d0905 feat: support remark for openclaw (#12361)
Refs https://github.com/1Panel-dev/1Panel/issues/12191
2026-03-30 06:53:05 +00:00
Alan 05ea384a00 feat: Add Bark as a new alert notification channel. (#12338) 2026-03-30 10:58:52 +08:00
maninhill 62c50aafe1 Add video introduction link to README
Added a link to a video introduction of 1Panel.
2026-03-27 09:59:17 +08:00
CityFun 6e052b8ef2 fix: Fixed issue with openclaw skill page return empty (#12347) 2026-03-26 17:57:07 +08:00
ssongliu 1d40bdc11b fix: resolve menu item wrapping issue (#12346) 2026-03-26 09:16:24 +00:00
CityFun 3b62197f29 fix: Fixed issue with openclaw skill page return empty (#12345) 2026-03-26 09:10:24 +00:00
603 changed files with 41993 additions and 9844 deletions
+14
View File
@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "gomod"
directories:
- "/agent"
- "/core"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "daily"
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
jobs:
generic_handler:
name: Add Labels to PR
if: github.repository == '1Panel-dev/1Panel'
if: github.repository == '1Panel-dev/1Panel' && github.event.pull_request.user.login != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.19.0'
node-version: '24.11.1'
- name: Build Web
run: |
@@ -26,7 +26,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25'
cache-dependency-path: |
core/go.sum
agent/go.sum
+2 -12
View File
@@ -9,7 +9,6 @@ build/1panel-core
# Mac
.DS_Store
*/.DS_Store
# VS Code
.vscode
@@ -41,20 +40,11 @@ core/cmd/server/web/index.html
frontend/auto-imports.d.ts
frontend/components.d.ts
frontend/src/xpack
frontend/src/xpack-ee
agent/xpack
agent/router/entry_xpack.go
agent/server/init_xpack.go
agent/utils/xpack/xpack.go
agent/utils/xpack/xpack_xpack.go
agent/xpack-ee
core/xpack
core/router/entry_xpack.go
core/server/init_xpack.go
core/utils/xpack/xpack.go
core/utils/xpack/xpack_xpack.go
core/xpack-ee
core/router/entry_xpackee.go
core/server/init_xpackee.go
core/utils/xpack/xpack_xpackee.go
.history/
dist/
+4 -2
View File
@@ -10,10 +10,12 @@ PR are always welcome, even if they only contain small fixes like typos or a few
Please submit a PR broken down into small changes bit by bit. A PR consisting of a lot of features and code changes may be hard to review. It is recommended to submit PRs in an incremental fashion.
This [development guideline](https://docs.1panel.pro/dev_manual/dev_manual/) contains information about repository structure, how to set up development environment, how to run it, and more.
Note: If you split your pull request to small changes, please make sure any of the changes goes to master will not break anything. Otherwise, it can not be merged until this feature complete.
## Add a new translation
If you'd like to help translate 1Panel into a new language, please read the [Translation Contribution Guide](docs/TRANSLATION.md). It lists every file you need to create or modify, along with a reference PR you can use as a concrete example.
## Report issues
It is a great way to contribute by reporting an issue. Well-written and complete bug reports are always welcome! Please open an issue and follow the template to fill in required information.
+68 -29
View File
@@ -1,17 +1,23 @@
<p align="center"><a href="https://1panel.pro"><img src="https://resource.1panel.pro/img/1panel-logo.png" alt="1Panel" width="300" /></a></p>
<h3 align="center">Take control of your VPS with 1Panel</h3>
<h3 align="center">The open-source VPS control panel with native AI agent support</h3>
<p align="center">
Trusted by <strong>2,000,000+</strong> self-hosters worldwide
</p>
<p align="center">
<a href="https://trendshift.io/repositories/2462" target="_blank"><img src="https://trendshift.io/api/badge/repositories/2462" alt="1Panel-dev%2F1Panel | Trendshift" style="width: 240px; height: auto;" /></a>
</p>
<p align="center">
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://shields.io/github/license/1Panel-dev/1Panel?color=%231890FF" alt="License: GPL v3"></a>
<a href="https://app.codacy.com/gh/1Panel-dev/1Panel?utm_source=github.com&utm_medium=referral&utm_content=1Panel-dev/1Panel&utm_campaign=Badge_Grade_Dashboard"><img src="https://app.codacy.com/project/badge/Grade/da67574fd82b473992781d1386b937ef" alt="Codacy"></a>
<a href="https://discord.gg/bUpUqWqdRr" target="_blank">
<img src="https://img.shields.io/discord/1318846410149335080?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb"
alt="chat on Discord"></a>
<a href="https://app.codacy.com/gh/1Panel-dev/1Panel"><img src="https://app.codacy.com/project/badge/Grade/da67574fd82b473992781d1386b937ef" alt="Codacy"></a>
<a href="https://discord.gg/bUpUqWqdRr"><img src="https://img.shields.io/discord/1318846410149335080?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="Discord"></a>
<a href="https://github.com/1Panel-dev/1Panel/releases"><img src="https://img.shields.io/github/v/release/1Panel-dev/1Panel" alt="GitHub release"></a>
<a href="https://github.com/1Panel-dev/1Panel"><img src="https://img.shields.io/github/stars/1Panel-dev/1Panel?color=%231890FF&style=flat-square" alt="Stars"></a><br>
<a href="https://github.com/1Panel-dev/1Panel"><img src="https://img.shields.io/github/stars/1Panel-dev/1Panel?color=%231890FF&style=flat-square" alt="Stars"></a>
</p>
<p align="center">
<a href="/README.md"><img alt="English" src="https://img.shields.io/badge/English-d9d9d9"></a>
<a href="/docs/README.zh-Hans.md"><img alt="中文(简体)" src="https://img.shields.io/badge/中文(简体)-d9d9d9"></a>
@@ -19,58 +25,91 @@
<a href="/docs/README.pt-br.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-d9d9d9"></a>
<a href="/docs/README.ar.md"><img alt="العربية" src="https://img.shields.io/badge/العربية-d9d9d9"></a>
<a href="/docs/README.de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-d9d9d9"></a>
<a href="/docs/README.es.md"><img alt="Español" src="https://img.shields.io/badge/Español-d9d9d9"></a><br>
<a href="/docs/README.es.md"><img alt="Español" src="https://img.shields.io/badge/Español-d9d9d9"></a>
<a href="/docs/README.fr.md"><img alt="français" src="https://img.shields.io/badge/français-d9d9d9"></a>
<a href="/docs/README.ko.md"><img alt="한국어" src="https://img.shields.io/badge/한국어-d9d9d9"></a>
<a href="/docs/README.id.md"><img alt="Bahasa Indonesia" src="https://img.shields.io/badge/Bahasa Indonesia-d9d9d9"></a>
<a href="/docs/README.zh-Hant.md"><img alt="中文(繁體)" src="https://img.shields.io/badge/中文(繁體)-d9d9d9"></a>
<a href="/docs/README.tr.md"><img alt="Türkçe" src="https://img.shields.io/badge/Türkçe-d9d9d9"></a>
<a href="/docs/README.ru.md"><img alt="Русский" src="https://img.shields.io/badge/%D0%A0%D1%83%D1%81%D1%81%D0%BA%D0%B8%D0%B9-d9d9d9"></a>
<a href="/docs/README.ru.md"><img alt="Русский" src="https://img.shields.io/badge/Русский-d9d9d9"></a>
<a href="/docs/README.ms.md"><img alt="Bahasa Melayu" src="https://img.shields.io/badge/Bahasa Melayu-d9d9d9"></a>
</p>
------------------------------
## What is 1Panel?
---
1Panel is a modern, open-source VPS control panel that streamlines administration through an intuitive, clean web interface.
## What is 1Panel?
## Key Features
1Panel is a modern, open-source VPS control panel — and the only one with **native AI agent support**. Run Ollama models, deploy OpenClaw agents, and manage your entire server stack from one clean web interface. No CLI memorization required.
- **One-Click OpenClaw Deployment**: Seamlessly deploy OpenClaw agents, host local LLMs using Ollama, and monitor GPU utilization—all within a unified workspace.  
- **One-Click Website Deployment**: Launch production-ready websites instantly with automatic domain binding, SSL certificate provisioning, and configuration—zero manual setup required.  
- **Curated App Marketplace**: Discover, install, and update trusted open-source applications (Nextcloud, Bitwarden, Umami, and more) with a single click.  
- **Centralized Server Management**: Monitor system resources and manage files, databases, containers, and services effortlessly through a unified, intuitive dashboard.  
- **Enterprise-Grade Security**: Strengthen your defenses with container isolation, firewall controls, audit logging, and secure deployment practices—ensuring compliance and peace of mind.  
- **Intelligent Backup & Restore**: Schedule automated backups or perform instant restores to local storage or major cloud platforms (AWS S3, Cloudflare R2, and more), with flexible retention and destination options.  
👉 Watch the [2-minute introduction](https://www.youtube.com/watch?v=Jl_wqp-XA08)
## Why 1Panel?
| | 1Panel | cPanel / Plesk | aaPanel | Webmin |
|--|--------|----------------|---------|--------|
| Free & open source | ✅ | ❌ | Partial | ✅ |
| Native AI agent runtime | ✅ | ❌ | ❌ | ❌ |
| One-click app marketplace | ✅ 165+ apps | ❌ | ✅ | ❌ |
| Modern UI (post-2020) | ✅ | ❌ | Partial | ❌ |
| Docker / container management | ✅ | ❌ | ❌ | ❌ |
| Active development | ✅ | ✅ | ✅ | Slow |
## Key Features
- **AI Agent Runtime**: Deploy Ollama LLMs, spin up OpenClaw personal agents, and monitor GPU utilization — all from the dashboard. No separate AI stack to manage.
- **One-Click Website Deployment**: Launch production-ready websites with automatic domain binding, SSL provisioning, and Nginx config — zero manual setup.
- **App Marketplace**: 165+ trusted open-source apps (Nextcloud, Bitwarden, Umami, NocoBase, and more) installed and updated with a single click.
- **Docker & Container Management**: Create, start, stop, and inspect containers, images, networks, and volumes through a visual UI — no CLI juggling.
- **Security Out of the Box**: Firewall rules, fail2ban, container isolation, WAF, and audit logs — configured and running from day one.
- **Backup & Restore**: Schedule automated backups to AWS S3, Cloudflare R2, or local storage. Restore any snapshot in one click.
## Quick Start
Execute the script below and follow the prompts to install 1Panel:
> **Requirements:** Linux VPS (Debian / Ubuntu / CentOS / Rocky), 1 GB RAM, internet access.
> Takes ~60 seconds.
```bash
bash -c "$(curl -sSL https://resource.1panel.pro/v2/quick_start.sh)"
```
After installation, open `http://<your-server-ip>:<port>/<security-path>` in your browser.
Run `1pctl user-info` via SSH if you need to retrieve your access credentials.
## Screenshot
![UI Display](https://resource.1panel.pro/img/overview_en_v2.png)
![1Panel UI](https://resource.1panel.pro/img/overview_en_v2.png)
## Pro Edition
1Panel OSS is free forever. Pro adds features built for teams and production workloads:
| Feature | OSS | Pro |
|---------|:---:|:---:|
| One-click app installs | ✅ | ✅ |
| AI agents (OpenClaw) | 1 agent | Unlimited |
| WAF & advanced security | Basic | ✅ |
| Website tamper protection | ❌ | ✅ |
| Website uptime monitoring | ❌ | ✅ |
| Multi-node management | ❌ | ✅ |
| Custom logo & theme | ❌ | ✅ |
| Priority support | ❌ | ✅ |
**From $80/year.** [Compare plans & start 30-day free trial →](https://1panel.pro/pricing)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=1Panel-dev/1Panel&type=Date)](https://star-history.com/#1Panel-dev/1Panel&Date)
## Pro Edition
## Community & Support
Compared to the OSS Edition, 1Panel Pro Edition provides users with a wealth of enhanced features and technical support services. Enhanced features include WAF enhancement, website tamper protection, website monitoring, GPU monitoring, custom logo and theme color, etc. [Click to view the detailed introduction of the Pro Edition](https://1panel.pro/pricing).
- **Discord** — [Join the community](https://discord.gg/bUpUqWqdRr) for help, feature requests, and show-and-tell
- **Docs** — [1panel.pro/docs](https://1panel.pro/docs)
- **Issues** — [GitHub Issues](https://github.com/1Panel-dev/1Panel/issues) for bug reports
## Security Information
## Security
If you discover any security issues, please refer to [SECURITY.md](/SECURITY.md).
Found a vulnerability? Please read [SECURITY.md](/SECURITY.md) before disclosing.
## License
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
<https://www.gnu.org/licenses/gpl-3.0.html>
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
Licensed under the [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html).
+428
View File
@@ -51,6 +51,27 @@ func (b *BaseApi) PageAgents(c *gin.Context) {
})
}
// @Tags AI
// @Summary Delete check Agent
// @Accept json
// @Param request body dto.AgentIDReq true "request"
// @Success 200 {array} dto.AppResource
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/delete/check [post]
func (b *BaseApi) DeleteCheckAgent(c *gin.Context) {
var req dto.AgentIDReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
checkData, err := agentService.DeleteCheck(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, checkData)
}
// @Tags AI
// @Summary Delete Agent
// @Accept json
@@ -91,6 +112,87 @@ func (b *BaseApi) ResetAgentToken(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Update Agent remark
// @Accept json
// @Param request body dto.AgentRemarkUpdateReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/remark [post]
func (b *BaseApi) UpdateAgentRemark(c *gin.Context) {
var req dto.AgentRemarkUpdateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UpdateRemark(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Bind Agent website
// @Accept json
// @Param request body dto.AgentWebsiteBindReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/website/bind [post]
func (b *BaseApi) BindAgentWebsite(c *gin.Context) {
var req dto.AgentWebsiteBindReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.BindWebsite(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Unbind Agent website
// @Accept json
// @Param request body dto.AgentIDReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/website/unbind [post]
func (b *BaseApi) UnbindAgentWebsite(c *gin.Context) {
var req dto.AgentIDReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UnbindWebsite(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Get Agent model config
// @Accept json
// @Param request body dto.AgentIDReq true "request"
// @Success 200 {object} dto.AgentModelConfig
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/model/get [post]
func (b *BaseApi) GetAgentModelConfig(c *gin.Context) {
var req dto.AgentIDReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := agentService.GetModelConfig(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags AI
// @Summary Update Agent model config
// @Accept json
@@ -132,6 +234,67 @@ func (b *BaseApi) GetAgentOverview(c *gin.Context) {
helper.SuccessWithData(c, res)
}
// @Tags AI
// @Summary Get Hermes chat sessions
// @Accept json
// @Param request body dto.AgentIDReq true "request"
// @Success 200 {array} dto.AgentHermesChatSessionItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/hermes/chat/sessions [post]
func (b *BaseApi) GetHermesChatSessions(c *gin.Context) {
var req dto.AgentIDReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := agentService.GetHermesChatSessions(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags AI
// @Summary Rename Hermes chat session
// @Accept json
// @Param request body dto.AgentHermesChatSessionRenameReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/hermes/chat/sessions/rename [post]
func (b *BaseApi) RenameHermesChatSession(c *gin.Context) {
var req dto.AgentHermesChatSessionRenameReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.RenameHermesChatSession(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Delete Hermes chat session
// @Accept json
// @Param request body dto.AgentHermesChatSessionDeleteReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/hermes/chat/sessions/delete [post]
func (b *BaseApi) DeleteHermesChatSession(c *gin.Context) {
var req dto.AgentHermesChatSessionDeleteReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.DeleteHermesChatSession(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Get Providers
// @Success 200 {array} dto.ProviderInfo
@@ -332,6 +495,170 @@ func (b *BaseApi) DeleteAgentAccount(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Create Agent role
// @Accept json
// @Param request body dto.AgentRoleCreateReq true "request"
// @Success 200 {object} dto.AgentRoleCreateResp
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/create [post]
func (b *BaseApi) CreateAgentRole(c *gin.Context) {
var req dto.AgentRoleCreateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.CreateRole(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Delete Agent role
// @Accept json
// @Param request body dto.AgentRoleDeleteReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/delete [post]
func (b *BaseApi) DeleteAgentRole(c *gin.Context) {
var req dto.AgentRoleDeleteReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.DeleteRole(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Bind Agent role channel
// @Accept json
// @Param request body dto.AgentRoleBindReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/bind [post]
func (b *BaseApi) BindAgentRole(c *gin.Context) {
var req dto.AgentRoleBindReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.BindRole(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Unbind Agent role channel
// @Accept json
// @Param request body dto.AgentRoleBindReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/unbind [post]
func (b *BaseApi) UnbindAgentRole(c *gin.Context) {
var req dto.AgentRoleBindReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UnbindRole(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Get configured Agent roles from config file
// @Accept json
// @Param request body dto.AgentConfiguredAgentsReq true "request"
// @Success 200 {array} dto.AgentConfiguredAgentItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/list [post]
func (b *BaseApi) GetConfiguredAgentRoles(c *gin.Context) {
var req dto.AgentConfiguredAgentsReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.GetConfiguredAgents(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Get Agent role channels from config file
// @Accept json
// @Param request body dto.AgentRoleChannelsReq true "request"
// @Success 200 {array} dto.AgentRoleChannelItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/channels [post]
func (b *BaseApi) GetAgentRoleChannels(c *gin.Context) {
var req dto.AgentRoleChannelsReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.GetRoleChannels(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Get Agent role markdown files
// @Accept json
// @Param request body dto.AgentRoleMarkdownFilesReq true "request"
// @Success 200 {array} dto.AgentRoleMarkdownFileItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/md/list [post]
func (b *BaseApi) GetAgentRoleMarkdownFiles(c *gin.Context) {
var req dto.AgentRoleMarkdownFilesReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.GetRoleMarkdownFiles(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Update Agent role markdown file
// @Accept json
// @Param request body dto.AgentRoleMarkdownFilesUpdateReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/md/update [post]
func (b *BaseApi) UpdateAgentRoleMarkdownFile(c *gin.Context) {
var req dto.AgentRoleMarkdownFilesUpdateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UpdateRoleMarkdownFiles(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Get Agent Feishu channel config
// @Accept json
@@ -537,6 +864,27 @@ func (b *BaseApi) UpdateAgentDingTalkConfig(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Get Agent Weixin channel config
// @Accept json
// @Param request body dto.AgentIDReq true "request"
// @Success 200 {object} dto.AgentWeixinConfig
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/channel/weixin/get [post]
func (b *BaseApi) GetAgentWeixinConfig(c *gin.Context) {
var req dto.AgentIDReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.GetWeixinConfig(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Get Agent QQ Bot channel config
// @Accept json
@@ -578,6 +926,26 @@ func (b *BaseApi) UpdateAgentQQBotConfig(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Delete Agent channel config
// @Accept json
// @Param request body dto.AgentChannelDeleteReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/channel/delete [post]
func (b *BaseApi) DeleteAgentChannelConfig(c *gin.Context) {
var req dto.AgentChannelDeleteReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.DeleteChannelConfig(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Install Agent plugin
// @Accept json
@@ -598,6 +966,46 @@ func (b *BaseApi) InstallAgentPlugin(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Upgrade Agent plugin
// @Accept json
// @Param request body dto.AgentPluginUpgradeReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugin/upgrade [post]
func (b *BaseApi) UpgradeAgentPlugin(c *gin.Context) {
var req dto.AgentPluginUpgradeReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UpgradePlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Uninstall Agent plugin
// @Accept json
// @Param request body dto.AgentPluginUninstallReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugin/uninstall [post]
func (b *BaseApi) UninstallAgentPlugin(c *gin.Context) {
var req dto.AgentPluginUninstallReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UninstallPlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Check Agent plugin installation status
// @Accept json
@@ -824,6 +1232,26 @@ func (b *BaseApi) InstallAgentSkill(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Uninstall Agent skill
// @Accept json
// @Param request body dto.AgentSkillUninstallReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/skills/uninstall [post]
func (b *BaseApi) UninstallAgentSkill(c *gin.Context) {
var req dto.AgentSkillUninstallReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UninstallSkill(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Login Agent Weixin channel
// @Accept json
+15
View File
@@ -415,6 +415,11 @@ func (b *BaseApi) Backup(c *gin.Context) {
helper.InternalServer(c, err)
return
}
case "mongodb":
if err := backupService.MongodbBackup(req); err != nil {
helper.InternalServer(c, err)
return
}
case constant.AppPostgresql, constant.AppPostgresqlCluster:
if err := backupService.PostgresqlBackup(req); err != nil {
helper.InternalServer(c, err)
@@ -475,6 +480,11 @@ func (b *BaseApi) Recover(c *gin.Context) {
helper.InternalServer(c, err)
return
}
case "mongodb":
if err := backupService.MongodbRecover(req); err != nil {
helper.InternalServer(c, err)
return
}
case constant.AppPostgresql, constant.AppPostgresqlCluster:
if err := backupService.PostgresqlRecover(req); err != nil {
helper.InternalServer(c, err)
@@ -530,6 +540,11 @@ func (b *BaseApi) RecoverByUpload(c *gin.Context) {
helper.InternalServer(c, err)
return
}
case "mongodb":
if err := backupService.MongodbRecoverByUpload(req); err != nil {
helper.InternalServer(c, err)
return
}
case constant.AppPostgresql, constant.AppPostgresqlCluster:
if err := backupService.PostgresqlRecoverByUpload(req); err != nil {
helper.InternalServer(c, err)
+294
View File
@@ -0,0 +1,294 @@
package v2
import (
"context"
"encoding/base64"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin"
)
// @Tags Database Mongodb
// @Summary Create mongodb database
// @Accept json
// @Param request body dto.MongodbDBCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mongodb 数据库 [name]","formatEN":"create mongodb database [name]"}
func (b *BaseApi) CreateMongodb(c *gin.Context) {
var req dto.MongodbDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
if _, err := mongodbService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mongodb
// @Summary Page mongodb databases
// @Accept json
// @Param request body dto.MongodbDBSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb/search [post]
func (b *BaseApi) SearchMongodb(c *gin.Context) {
var req dto.MongodbDBSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := mongodbService.SearchWithPage(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Items: list,
Total: total,
})
}
// @Tags Database Mongodb
// @Summary Update mongodb database description
// @Accept json
// @Param request body dto.UpdateDescription true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb/description [post]
// @x-panel-log {"bodyKeys":["id","description"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"database_mongodbs","output_column":"name","output_value":"name"}],"formatZH":"mongodb 数据库 [name] 描述信息修改 [description]","formatEN":"The description of the mongodb database [name] is modified => [description]"}
func (b *BaseApi) UpdateMongodbDescription(c *gin.Context) {
var req dto.UpdateDescription
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mongodbService.UpdateDescription(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mongodb
// @Summary Load mongodb database from remote
// @Accept json
// @Param request body dto.MongodbLoadDB true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb/load [post]
func (b *BaseApi) LoadMongodbFromRemote(c *gin.Context) {
var req dto.MongodbLoadDB
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mongodbService.LoadFromRemote(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mongodb
// @Summary Bind mongodb database user info
// @Accept json
// @Param request body dto.MongodbBind true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb/bind [post]
// @x-panel-log {"bodyKeys":["database", "name", "username"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"绑定 mongodb 数据库 [database] [name] 用户 [username]","formatEN":"bind mongodb database [database] [name] user [username]"}
func (b *BaseApi) BindMongodbUser(c *gin.Context) {
var req dto.MongodbBind
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
if err := mongodbService.BindUser(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mongodb
// @Summary Change mongodb database password
// @Accept json
// @Param request body dto.MongodbPassword true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb/password [post]
// @x-panel-log {"bodyKeys":["database", "name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 mongodb 数据库 [database] [name] 密码","formatEN":"update mongodb database [database] [name] password"}
func (b *BaseApi) ChangeMongodbPassword(c *gin.Context) {
var req dto.MongodbPassword
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
if err := mongodbService.ChangePassword(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mongodb
// @Summary Change mongodb root password
// @Accept json
// @Param request body dto.ChangeDBInfo true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb/root/password [post]
// @x-panel-log {"bodyKeys":["database"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 mongodb 数据库 [database] root 密码","formatEN":"update mongodb database [database] root password"}
func (b *BaseApi) ChangeMongodbRootPassword(c *gin.Context) {
var req dto.ChangeDBInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Value) != 0 {
value, err := base64.StdEncoding.DecodeString(req.Value)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Value = string(value)
}
if err := mongodbService.ChangeRootPassword(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mongodb
// @Summary Load mongodb privileges
// @Accept json
// @Param request body dto.MongodbPrivilegesLoad true "request"
// @Success 200 {string} string
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb/privileges [post]
func (b *BaseApi) LoadMongodbPrivileges(c *gin.Context) {
var req dto.MongodbPrivilegesLoad
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
permission, err := mongodbService.LoadPrivileges(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, permission)
}
// @Tags Database Mongodb
// @Summary Change mongodb privileges
// @Accept json
// @Param request body dto.MongodbPrivileges true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb/privileges/change [post]
// @x-panel-log {"bodyKeys":["database", "username"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 mongodb 数据库 [database] 用户 [username] 权限","formatEN":"update mongodb database [database] user [username] privileges"}
func (b *BaseApi) ChangeMongodbPrivileges(c *gin.Context) {
var req dto.MongodbPrivileges
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mongodbService.ChangePrivileges(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mongodb
// @Summary Check before delete mongodb database
// @Accept json
// @Param request body dto.MongodbDBDeleteCheck true "request"
// @Success 200 {array} string
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb/del/check [post]
func (b *BaseApi) DeleteCheckMongodb(c *gin.Context) {
var req dto.MongodbDBDeleteCheck
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
apps, err := mongodbService.DeleteCheck(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, apps)
}
// @Tags Database Mongodb
// @Summary Delete mongodb database
// @Accept json
// @Param request body dto.MongodbDBDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/mongodb/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"database_mongodbs","output_column":"name","output_value":"name"}],"formatZH":"删除 mongodb 数据库 [name]","formatEN":"delete mongodb database [name]"}
func (b *BaseApi) DeleteMongodb(c *gin.Context) {
var req dto.MongodbDBDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
tx, ctx := helper.GetTxAndContext()
if err := mongodbService.Delete(ctx, req); err != nil {
helper.InternalServer(c, err)
tx.Rollback()
return
}
tx.Commit()
helper.Success(c)
}
+9 -6
View File
@@ -31,17 +31,20 @@ var (
dbCommonService = service.NewIDBCommonService()
mysqlService = service.NewIMysqlService()
postgresqlService = service.NewIPostgresqlService()
mongodbService = service.NewIMongodbService()
databaseService = service.NewIDatabaseService()
redisService = service.NewIRedisService()
cronjobService = service.NewICronjobService()
fileService = service.NewIFileService()
sshService = service.NewISSHService()
firewallService = service.NewIFirewallService()
iptablesService = service.NewIIptablesService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
fileService = service.NewIFileService()
fileHistoryService = service.NewIFileHistoryService()
fileShareService = service.NewIFileShareService()
sshService = service.NewISSHService()
firewallService = service.NewIFirewallService()
iptablesService = service.NewIIptablesService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
deviceService = service.NewIDeviceService()
fail2banService = service.NewIFail2BanService()
+287
View File
@@ -25,6 +25,7 @@ import (
websocket2 "github.com/1Panel-dev/1Panel/agent/utils/websocket"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
qrcode "github.com/skip2/go-qrcode"
)
// @Tags File
@@ -48,6 +49,31 @@ func (b *BaseApi) ListFiles(c *gin.Context) {
helper.SuccessWithData(c, fileList)
}
// @Tags File
// @Summary File search: content grep + optional AI summary
// @Description When file-management AI is enabled, returns mode=ai with summary and hits. When disabled, returns mode=grep with hits only. Scans file contents only. Supports match options, extension/size/time filters, and scan limits.
// @Accept json
// @Param request body request.FileAISearch true "request"
// @Success 200 {object} response.FileAISearchResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/ai-search [post]
func (b *BaseApi) FileAISearch(c *gin.Context) {
var req request.FileAISearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if strings.TrimSpace(req.ResponseLanguage) == "" {
req.ResponseLanguage = strings.TrimSpace(c.GetHeader("Accept-Language"))
}
res, err := fileService.AISearch(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags File
// @Summary Page file
// @Accept json
@@ -224,6 +250,26 @@ func (b *BaseApi) CompressFile(c *gin.Context) {
helper.Success(c)
}
// @Tags File
// @Summary Stop compress task
// @Accept json
// @Param request body request.FileCompressStopReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/compress/stop [post]
func (b *BaseApi) StopCompressFile(c *gin.Context) {
var req request.FileCompressStopReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := fileService.StopCompress(req.TaskID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File
// @Summary Decompress file
// @Accept json
@@ -1038,3 +1084,244 @@ func (b *BaseApi) SetFileRemark(c *gin.Context) {
}
helper.Success(c)
}
// @Tags File
// @Summary List file shares
// @Accept json
// @Param request body dto.PageInfo true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/search [post]
func (b *BaseApi) SearchFileShare(c *gin.Context) {
var req dto.PageInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := fileShareService.Page(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: list,
})
}
// @Tags File
// @Summary Get file share detail by path
// @Accept json
// @Param request body dto.FilePath true "request"
// @Success 200 {object} response.FileShareInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/detail [post]
func (b *BaseApi) GetFileShareDetail(c *gin.Context) {
var req dto.FilePath
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
info, err := fileShareService.GetByPath(req.Path)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
// @Tags File
// @Summary Get file share detail by code (no login)
// @Param code query string true "share code"
// @Success 200 {object} response.FileSharePublicInfo
// @Router /files/share/info [get]
func (b *BaseApi) GetPublicFileShareInfo(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
info, err := fileShareService.GetPublicByCode(code)
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
func buildSharePublicURL(c *gin.Context, code, operateNode string) string {
scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if scheme == "" {
if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
if host == "" {
host = c.Request.Host
}
shareURL := url.URL{
Scheme: scheme,
Host: host,
Path: "/s/" + url.PathEscape(code),
}
query := shareURL.Query()
if strings.TrimSpace(operateNode) != "" {
query.Set("operateNode", operateNode)
}
shareURL.RawQuery = query.Encode()
return shareURL.String()
}
// @Tags File
// @Summary Get file share QR code image
// @Produce png
// @Param code query string true "share code"
// @Param operateNode query string false "operate node"
// @Success 200 {file} file
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/qrcode [get]
func (b *BaseApi) GetFileShareQRCode(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
if _, err := fileShareService.GetByCode(code); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
png, err := qrcode.Encode(buildSharePublicURL(c, code, c.Query("operateNode")), qrcode.Medium, 256)
if err != nil {
helper.InternalServer(c, err)
return
}
c.Header("Cache-Control", "private, max-age=300")
c.Data(http.StatusOK, "image/png", png)
}
// @Tags File
// @Summary Create temporary file share link
// @Accept json
// @Param request body request.FileShareCreate true "request"
// @Success 200 {object} response.FileShareInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/create [post]
// @x-panel-log {"bodyKeys":["path","expireMinutes"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建文件分享 [path]","formatEN":"Create file share [path]"}
func (b *BaseApi) CreateFileShare(c *gin.Context) {
var req request.FileShareCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := fileShareService.Create(req)
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusInternalServerError, be.Msg, be.Err)
return
}
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags File
// @Summary Delete file share by path
// @Accept json
// @Param request body dto.FilePath true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/del [post]
// @x-panel-log {"bodyKeys":["path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"关闭文件分享 [path]","formatEN":"Close file share [path]"}
func (b *BaseApi) DeleteFileShare(c *gin.Context) {
var req dto.FilePath
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := fileShareService.DeleteByPath(req.Path); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusInternalServerError, be.Msg, be.Err)
return
}
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File
// @Summary Check file share code (no login)
// @Param code query string true "share code"
// @Param password query string false "optional password"
// @Success 200 {object} dto.Response
// @Router /files/share/check [get]
func (b *BaseApi) CheckFileShare(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
password := c.Query("password")
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
if err := fileShareService.Check(code, password); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File
// @Summary Download file by share code (no login)
// @Produce octet-stream
// @Param code query string true "share code"
// @Param password query string false "optional password"
// @Success 200 {file} file
// @Router /files/share/download [get]
func (b *BaseApi) DownloadFileShare(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
password := c.Query("password")
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
filePath, displayName, err := fileShareService.PrepareDownload(code, password)
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
file, err := os.Open(filePath)
if err != nil {
helper.InternalServer(c, err)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
helper.InternalServer(c, err)
return
}
c.Header("Content-Length", strconv.FormatInt(info.Size(), 10))
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(displayName))
http.ServeContent(c.Writer, c.Request, displayName, info.ModTime(), file)
}
+91
View File
@@ -0,0 +1,91 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/gin-gonic/gin"
)
// @Tags File
// @Summary Load file history list
// @Accept json
// @Param request body request.FileHistorySearchReq true "request"
// @Success 200 {object} response.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/history/search [post]
func (b *BaseApi) SearchFileHistory(c *gin.Context) {
var req request.FileHistorySearchReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, items, err := fileHistoryService.Search(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{Items: items, Total: total})
}
// @Tags File
// @Summary Load file history content
// @Accept json
// @Param request body request.FileHistoryContentReq true "request"
// @Success 200 {object} response.FileHistoryInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/history/content [post]
func (b *BaseApi) GetFileHistoryContent(c *gin.Context) {
var req request.FileHistoryContentReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
item, err := fileHistoryService.GetContent(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, item)
}
// @Tags File
// @Summary Delete file history record
// @Accept json
// @Param request body request.FileHistoryDeleteReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/history/del [post]
func (b *BaseApi) DeleteFileHistory(c *gin.Context) {
var req request.FileHistoryDeleteReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := fileHistoryService.Delete(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File
// @Summary Restore file history record
// @Accept json
// @Param request body request.FileHistoryRestoreReq true "request"
// @Success 200 {object} response.FileInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/history/restore [post]
func (b *BaseApi) RestoreFileHistory(c *gin.Context) {
var req request.FileHistoryRestoreReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
info, err := fileHistoryService.Restore(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
+57
View File
@@ -5,6 +5,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/ssh"
@@ -81,6 +82,62 @@ func (b *BaseApi) UpdateTerminalAISetting(c *gin.Context) {
helper.Success(c)
}
func (b *BaseApi) GetFileManageAISettingInfo(c *gin.Context) {
setting, err := settingService.GetFileManageAIInfo()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, setting)
}
func (b *BaseApi) UpdateFileManageAISetting(c *gin.Context) {
var req dto.FileManageAIInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := settingService.UpdateFileManageAI(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags System Setting
// @Summary Load file history setting info
// @Success 200 {object} response.FileHistorySettingInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /settings/file-history/search [post]
func (b *BaseApi) GetFileHistorySettingInfo(c *gin.Context) {
setting, err := settingService.GetFileHistorySettingInfo()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, setting)
}
// @Tags System Setting
// @Summary Update file history setting
// @Accept json
// @Param request body request.FileHistorySettingUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /settings/file-history/update [post]
func (b *BaseApi) UpdateFileHistorySetting(c *gin.Context) {
var req request.FileHistorySettingUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := settingService.UpdateFileHistorySetting(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags System Setting
// @Summary Load local backup dir
// @Success 200 {string} path
+2 -2
View File
@@ -249,14 +249,14 @@ func (b *BaseApi) LoadSSHFile(c *gin.Context) {
// @Tags SSH
// @Summary Update host SSH setting by file
// @Accept json
// @Param request body dto.SSHConf true "request"
// @Param request body dto.SSHConfUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/ssh/file/update [post]
// @x-panel-log {"bodyKeys":["key"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"修改 SSH 配置文件 [key]","formatEN":"update SSH conf [key]"}
func (b *BaseApi) UpdateSSHByFile(c *gin.Context) {
var req dto.SettingUpdate
var req dto.SSHConfUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
+11 -1
View File
@@ -213,19 +213,29 @@ func loadContainerInitCmd(c *gin.Context) ([]string, error) {
func loadDatabaseInitCmd(c *gin.Context) ([]string, error) {
database := c.Query("database")
databaseType := c.Query("databaseType")
if len(database) == 0 || len(databaseType) == 0 {
if len(databaseType) == 0 {
return nil, fmt.Errorf("error param of database: %s or database type: %s", database, databaseType)
}
databaseConn, err := appInstallService.LoadConnInfo(dto.OperationWithNameAndType{Type: databaseType, Name: database})
if err != nil {
return nil, fmt.Errorf("no such database in db, err: %v", err)
}
if len(databaseConn.ContainerName) == 0 {
return nil, fmt.Errorf("no such database container for database: %s or database type: %s", database, databaseType)
}
commands := []string{"exec", "-it", databaseConn.ContainerName}
switch databaseType {
case "mysql", "mysql-cluster":
commands = append(commands, []string{"mysql", "-uroot", "-p" + databaseConn.Password}...)
case "mariadb":
commands = append(commands, []string{"mariadb", "-uroot", "-p" + databaseConn.Password}...)
case "mongodb":
commands = append(commands, []string{
"mongosh",
"--username", databaseConn.Username,
"--password", databaseConn.Password,
"--authenticationDatabase", "admin",
}...)
case "postgresql", "postgresql-cluster":
commands = []string{"exec", "-e", fmt.Sprintf("PGPASSWORD=%s", databaseConn.Password), "-it", databaseConn.ContainerName, "psql", "-t", "-U", databaseConn.Username}
}
+349 -100
View File
@@ -4,11 +4,12 @@ import "time"
type AgentCreateReq struct {
Name string `json:"name" validate:"required"`
Remark string `json:"remark"`
AppVersion string `json:"appVersion" validate:"required"`
WebUIPort int `json:"webUIPort" validate:"required"`
WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"`
BridgePort int `json:"bridgePort"`
AllowedOrigins []string `json:"allowedOrigins"`
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw"`
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent"`
Model string `json:"model"`
AccountID uint `json:"accountId"`
Token string `json:"token"`
@@ -27,30 +28,35 @@ type AgentCreateReq struct {
}
type AgentItem struct {
ID uint `json:"id"`
Name string `json:"name"`
AgentType string `json:"agentType"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
AccountID uint `json:"accountId"`
AppVersion string `json:"appVersion"`
Container string `json:"containerName"`
WebUIPort int `json:"webUIPort"`
BridgePort int `json:"bridgePort"`
Path string `json:"path"`
ConfigPath string `json:"configPath"`
Upgradable bool `json:"upgradable"`
CreatedAt time.Time `json:"createdAt"`
ID uint `json:"id"`
Name string `json:"name"`
Remark string `json:"remark"`
AgentType string `json:"agentType"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
WebsiteID uint `json:"websiteId"`
WebsitePrimaryDomain string `json:"websitePrimaryDomain"`
WebsiteType string `json:"websiteType"`
WebsiteProtocol string `json:"websiteProtocol"`
AccountID uint `json:"accountId"`
AppVersion string `json:"appVersion"`
Container string `json:"containerName"`
WebUIPort int `json:"webUIPort"`
BridgePort int `json:"bridgePort"`
Path string `json:"path"`
ConfigPath string `json:"configPath"`
Upgradable bool `json:"upgradable"`
CreatedAt time.Time `json:"createdAt"`
}
type AgentDeleteReq struct {
@@ -63,10 +69,36 @@ type AgentTokenResetReq struct {
ID uint `json:"id" validate:"required"`
}
type AgentRemarkUpdateReq struct {
ID uint `json:"id" validate:"required"`
Remark string `json:"remark"`
}
type AgentWebsiteBindReq struct {
AgentID uint `json:"agentId" validate:"required"`
WebsiteID uint `json:"websiteId" validate:"required"`
}
type AgentModelConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
AccountID uint `json:"accountId" validate:"required"`
Model string `json:"model" validate:"required"`
AgentID uint `json:"agentId" validate:"required"`
AccountID uint `json:"accountId" validate:"required"`
Model string `json:"model" validate:"required"`
Fallbacks []string `json:"fallbacks"`
}
type AgentModelConfig struct {
AccountID uint `json:"accountId"`
Model string `json:"model"`
Fallbacks []string `json:"fallbacks"`
}
type AgentHermesChatSessionItem struct {
ID string `json:"id"`
Title string `json:"title"`
Model string `json:"model"`
MessageCount int64 `json:"messageCount"`
StartedAt string `json:"startedAt"`
LastActive string `json:"lastActive"`
}
type AgentOverviewReq struct {
@@ -77,10 +109,94 @@ type AgentIDReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentHermesChatSessionRenameReq struct {
AgentID uint `json:"agentId" validate:"required"`
ID string `json:"id" validate:"required"`
Title string `json:"title" validate:"required"`
}
type AgentHermesChatSessionDeleteReq struct {
AgentID uint `json:"agentId" validate:"required"`
ID string `json:"id" validate:"required"`
}
type AgentOverview struct {
Snapshot AgentOverviewSnapshot `json:"snapshot"`
}
type AgentRoleBinding struct {
Channel string `json:"channel" validate:"required"`
AccountID string `json:"accountId"`
}
type AgentRoleCreateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Name string `json:"name" validate:"required"`
Model string `json:"model"`
Bindings []AgentRoleBinding `json:"bindings"`
}
type AgentRoleCreateResp struct {
Output string `json:"output"`
}
type AgentRoleDeleteReq struct {
AgentID uint `json:"agentId" validate:"required"`
ID string `json:"id" validate:"required"`
}
type AgentRoleBindReq struct {
AgentID uint `json:"agentId" validate:"required"`
ID string `json:"id" validate:"required"`
Channel string `json:"channel" validate:"required"`
AccountID string `json:"accountId"`
}
type AgentConfiguredAgentsReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentRoleChannelsReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentRoleChannelItem struct {
Name string `json:"name"`
Bound bool `json:"bound"`
AccountIDs []string `json:"accountIds"`
}
type AgentRoleMarkdownFilesReq struct {
AgentID uint `json:"agentId" validate:"required"`
Workspace string `json:"workspace" validate:"required"`
}
type AgentConfiguredAgentItem struct {
ID string `json:"id"`
Name string `json:"name"`
Workspace string `json:"workspace"`
Model string `json:"model"`
AgentDir string `json:"agentDir"`
Bindings []AgentRoleBinding `json:"bindings"`
}
type AgentRoleMarkdownFileItem struct {
Name string `json:"name"`
Content string `json:"content"`
}
type AgentRoleMarkdownFileUpdateItem struct {
Name string `json:"name" validate:"required,oneof=AGENTS.md SOUL.md USER.md IDENTITY.md TOOLS.md HEARTBEAT.md BOOT.md BOOTSTRAP.md"`
Content string `json:"content"`
}
type AgentRoleMarkdownFilesUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Workspace string `json:"workspace" validate:"required"`
Restart bool `json:"restart"`
Files []AgentRoleMarkdownFileUpdateItem `json:"files" validate:"required"`
}
type AgentOverviewSnapshot struct {
ContainerStatus string `json:"containerStatus"`
AppVersion string `json:"appVersion"`
@@ -195,12 +311,17 @@ type AgentFeishuConfigReq struct {
}
type AgentFeishuConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
BotName string `json:"botName" validate:"required"`
AppID string `json:"appId" validate:"required"`
AppSecret string `json:"appSecret" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required"`
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
ReplyMode string `json:"replyMode" validate:"required"`
Streaming bool `json:"streaming"`
RequireMention string `json:"requireMention" validate:"required,oneof=true false open"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Domain string `json:"domain"`
ConnectionMode string `json:"connectionMode"`
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
}
type AgentFeishuPairingApproveReq struct {
@@ -209,11 +330,17 @@ type AgentFeishuPairingApproveReq struct {
}
type AgentFeishuConfig struct {
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
BotName string `json:"botName"`
AppID string `json:"appId"`
AppSecret string `json:"appSecret"`
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
ReplyMode string `json:"replyMode"`
Streaming bool `json:"streaming"`
RequireMention string `json:"requireMention"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Domain string `json:"domain"`
ConnectionMode string `json:"connectionMode"`
Bots []AgentFeishuBot `json:"bots"`
Installed bool `json:"installed"`
}
type AgentTelegramConfigReq struct {
@@ -221,113 +348,223 @@ type AgentTelegramConfigReq struct {
}
type AgentTelegramConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required"`
BotToken string `json:"botToken" validate:"required"`
Proxy string `json:"proxy"`
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required,oneof=pairing open allowlist disabled"`
AllowFrom []string `json:"allowFrom"`
RequireMention bool `json:"requireMention"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Proxy string `json:"proxy"`
Streaming string `json:"streaming" validate:"required,oneof=off partial block progress"`
DefaultAccount string `json:"defaultAccount" validate:"required"`
Bots []AgentTelegramBot `json:"bots" validate:"required,min=1"`
}
type AgentTelegramConfig struct {
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
BotToken string `json:"botToken"`
Proxy string `json:"proxy"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
RequireMention bool `json:"requireMention"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Proxy string `json:"proxy"`
Streaming string `json:"streaming"`
DefaultAccount string `json:"defaultAccount"`
Bots []AgentTelegramBot `json:"bots"`
}
type AgentChannelPairingApproveReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu telegram discord wecom dingtalk-connector"`
Type string `json:"type" validate:"required,oneof=feishu telegram discord wecom qqbot dingtalk"`
PairingCode string `json:"pairingCode" validate:"required"`
AccountID string `json:"accountId"`
}
type AgentChannelDeleteReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu telegram discord wecom qqbot dingtalk weixin"`
}
type AgentWecomConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required,oneof=pairing open"`
BotID string `json:"botId" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
type AgentWecomConfig struct {
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
BotID string `json:"botId"`
Secret string `json:"secret"`
Installed bool `json:"installed"`
}
type AgentDingTalkConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
ClientID string `json:"clientId" validate:"required"`
ClientSecret string `json:"clientSecret" validate:"required"`
DmPolicy string `json:"dmPolicy" validate:"required,oneof=pairing allowlist open disabled"`
DmPolicy string `json:"dmPolicy" validate:"required,oneof=pairing open allowlist disabled"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
GroupAllowFrom []string `json:"groupAllowFrom"`
BotID string `json:"botId" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
type AgentDingTalkConfig struct {
type AgentWecomConfig struct {
Enabled bool `json:"enabled"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
BotID string `json:"botId"`
Secret string `json:"secret"`
Installed bool `json:"installed"`
}
type AgentDingTalkConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required,oneof=pairing allowlist open disabled"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
GroupAllowFrom []string `json:"groupAllowFrom"`
SeparateSessionByConversation bool `json:"separateSessionByConversation"`
GroupSessionScope string `json:"groupSessionScope" validate:"required,oneof=group group_sender"`
SharedMemoryAcrossConversations bool `json:"sharedMemoryAcrossConversations"`
AsyncMode bool `json:"asyncMode"`
AckText string `json:"ackText"`
Bots []AgentDingTalkBot `json:"bots" validate:"required,min=1"`
}
type AgentDingTalkConfig struct {
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
SeparateSessionByConversation bool `json:"separateSessionByConversation"`
GroupSessionScope string `json:"groupSessionScope"`
SharedMemoryAcrossConversations bool `json:"sharedMemoryAcrossConversations"`
AsyncMode bool `json:"asyncMode"`
AckText string `json:"ackText"`
Bots []AgentDingTalkBot `json:"bots"`
Installed bool `json:"installed"`
}
type AgentWeixinLoginReq struct {
AgentID uint `json:"agentId" validate:"required"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentWeixinConfig struct {
Enabled bool `json:"enabled"`
}
type AgentQQBotConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
AppID string `json:"appId" validate:"required"`
ClientSecret string `json:"clientSecret" validate:"required"`
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Bots []AgentQQBotBot `json:"bots" validate:"required,min=1"`
}
type AgentQQBotConfig struct {
Enabled bool `json:"enabled"`
AppID string `json:"appId"`
ClientSecret string `json:"clientSecret"`
Installed bool `json:"installed"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Bots []AgentQQBotBot `json:"bots"`
Installed bool `json:"installed"`
}
type AgentPluginInstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=qqbot wecom dingtalk weixin"`
Type string `json:"type" validate:"required,oneof=feishu qqbot wecom dingtalk weixin"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginUpgradeReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu qqbot wecom dingtalk weixin"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginUninstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu qqbot wecom dingtalk weixin"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginCheckReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=qqbot wecom dingtalk weixin"`
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu qqbot wecom dingtalk weixin"`
CheckLatest bool `json:"checkLatest"`
}
type AgentPluginStatus struct {
Installed bool `json:"installed"`
Installed bool `json:"installed"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
Upgradable bool `json:"upgradable"`
}
type AgentDiscordConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
Token string `json:"token" validate:"required"`
Proxy string `json:"proxy"`
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required"`
AllowFrom []string `json:"allowFrom"`
RequireMention bool `json:"requireMention"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
Proxy string `json:"proxy"`
DefaultAccount string `json:"defaultAccount" validate:"required"`
Bots []AgentDiscordBot `json:"bots" validate:"required,min=1"`
}
type AgentDiscordConfig struct {
Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
RequireMention bool `json:"requireMention"`
GroupPolicy string `json:"groupPolicy"`
Proxy string `json:"proxy"`
DefaultAccount string `json:"defaultAccount"`
Bots []AgentDiscordBot `json:"bots"`
}
type AgentChannelBotBase struct {
AccountID string `json:"accountId"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
IsDefault bool `json:"isDefault"`
}
func (b AgentChannelBotBase) IsEnabled() bool {
return b.Enabled
}
type AgentFeishuBot struct {
AgentChannelBotBase
AppID string `json:"appId"`
AppSecret string `json:"appSecret"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
}
type AgentTelegramBot struct {
AgentChannelBotBase
BotToken string `json:"botToken"`
DmPolicy string `json:"dmPolicy"`
GroupPolicy string `json:"groupPolicy"`
Token string `json:"token"`
Proxy string `json:"proxy"`
Streaming string `json:"streaming"`
}
type AgentDiscordBot struct {
AgentChannelBotBase
Token string `json:"token"`
}
type AgentQQBotBot struct {
AgentChannelBotBase
AppID string `json:"appId"`
ClientSecret string `json:"clientSecret"`
AllowFrom []string `json:"allowFrom"`
SystemPrompt string `json:"systemPrompt"`
}
type AgentDingTalkBot struct {
AgentChannelBotBase
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
type AgentSecurityConfigUpdateReq struct {
@@ -367,25 +604,32 @@ type AgentConfigFile struct {
type AgentSkillSearchReq struct {
AgentID uint `json:"agentId" validate:"required"`
Source string `json:"source" validate:"required,oneof=clawhub skillhub"`
Source string `json:"source" validate:"required,oneof=clawhub-global clawhub-cn skillhub official skills-sh"`
Keyword string `json:"keyword" validate:"required"`
}
type AgentSkillItem struct {
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
Bundled bool `json:"bundled"`
Disabled bool `json:"disabled"`
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
Tags []string `json:"tags"`
Source string `json:"source"`
Trust string `json:"trust"`
Identifier string `json:"identifier"`
Bundled bool `json:"bundled"`
Disabled bool `json:"disabled"`
Uninstallable bool `json:"uninstallable"`
}
type AgentSkillSearchItem struct {
Slug string `json:"slug"`
Identifier string `json:"identifier"`
Name string `json:"name"`
Description string `json:"description"`
Summary string `json:"summary"`
Version string `json:"version"`
Source string `json:"source"`
Trust string `json:"trust"`
Score string `json:"score"`
}
@@ -397,7 +641,12 @@ type AgentSkillUpdateReq struct {
type AgentSkillInstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Source string `json:"source" validate:"required,oneof=clawhub skillhub"`
Source string `json:"source" validate:"required,oneof=clawhub-global clawhub-cn skillhub official skills-sh"`
Slug string `json:"slug" validate:"required"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentSkillUninstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Name string `json:"name" validate:"required"`
}
+11 -10
View File
@@ -65,7 +65,7 @@ type UploadForRecover struct {
}
type CommonBackup struct {
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster container compose"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mongodb mysql-cluster postgresql-cluster redis-cluster container compose"`
Name string `json:"name"`
DetailName string `json:"detailName"`
Secret string `json:"secret"`
@@ -77,15 +77,16 @@ type CommonBackup struct {
Description string `json:"description"`
}
type CommonRecover struct {
DownloadAccountID uint `json:"downloadAccountID" validate:"required"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster container compose"`
Name string `json:"name"`
DetailName string `json:"detailName"`
File string `json:"file"`
Secret string `json:"secret"`
TaskID string `json:"taskID"`
BackupRecordID uint `json:"backupRecordID"`
Timeout int `json:"timeout"`
DownloadAccountID uint `json:"downloadAccountID" validate:"required"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mongodb mysql-cluster postgresql-cluster redis-cluster container compose"`
Name string `json:"name"`
DetailName string `json:"detailName"`
File string `json:"file"`
Secret string `json:"secret"`
DropAllCollections bool `json:"dropAllCollections"`
TaskID string `json:"taskID"`
BackupRecordID uint `json:"backupRecordID"`
Timeout int `json:"timeout"`
}
type RecordSearch struct {
+78 -1
View File
@@ -11,7 +11,7 @@ type DBConfUpdateByFile struct {
type ChangeDBInfo struct {
ID uint `json:"id"`
From string `json:"from" validate:"required,oneof=local remote"`
Type string `json:"type" validate:"required,oneof=mysql mariadb postgresql redis mysql-cluster postgresql-cluster redis-cluster"`
Type string `json:"type" validate:"required,oneof=mysql mariadb postgresql redis mongodb mysql-cluster postgresql-cluster redis-cluster"`
Database string `json:"database" validate:"required"`
Value string `json:"value" validate:"required"`
}
@@ -137,6 +137,83 @@ type MysqlStatus struct {
Position string `json:"Position"`
}
// mongodb
type MongodbDBSearch struct {
PageInfo
Info string `json:"info"`
Database string `json:"database" validate:"required"`
OrderBy string `json:"orderBy" validate:"required,oneof=name createdAt"`
Order string `json:"order" validate:"required,oneof=null ascending descending"`
}
type MongodbDBInfo struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Name string `json:"name"`
From string `json:"from"`
MongodbName string `json:"mongodbName"`
Username string `json:"username"`
Password string `json:"password"`
IsDelete bool `json:"isDelete"`
Description string `json:"description"`
}
type MongodbDBCreate struct {
Name string `json:"name" validate:"required"`
From string `json:"from" validate:"required,oneof=local remote"`
Database string `json:"database" validate:"required"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Permission string `json:"permission" validate:"required,oneof=dbOwner read readWrite userAdmin"`
Description string `json:"description"`
}
type MongodbLoadDB struct {
From string `json:"from" validate:"required,oneof=local remote"`
Type string `json:"type" validate:"required,oneof=mongodb"`
Database string `json:"database" validate:"required"`
}
type MongodbDBDeleteCheck struct {
ID uint `json:"id" validate:"required"`
Type string `json:"type" validate:"required,oneof=mongodb"`
Database string `json:"database" validate:"required"`
}
type MongodbDBDelete struct {
ID uint `json:"id" validate:"required"`
Type string `json:"type" validate:"required,oneof=mongodb"`
Database string `json:"database" validate:"required"`
ForceDelete bool `json:"forceDelete"`
DeleteBackup bool `json:"deleteBackup"`
}
type MongodbBind struct {
Database string `json:"database" validate:"required"`
Name string `json:"name" validate:"required"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
}
type MongodbPassword struct {
Database string `json:"database" validate:"required"`
Name string `json:"name" validate:"required"`
Password string `json:"password" validate:"required"`
}
type MongodbPrivileges struct {
Database string `json:"database" validate:"required"`
Name string `json:"name" validate:"required"`
Username string `json:"username" validate:"required"`
Permission string `json:"permission" validate:"required,oneof=dbOwner read readWrite userAdmin"`
}
type MongodbPrivilegesLoad struct {
Database string `json:"database" validate:"required"`
Name string `json:"name" validate:"required"`
Username string `json:"username" validate:"required"`
}
type MysqlVariables struct {
BinlogCacheSize string `json:"binlog_cache_size"`
InnodbBufferPoolSize string `json:"innodb_buffer_pool_size"`
+1
View File
@@ -7,6 +7,7 @@ import (
type SearchTaskLogReq struct {
Status string `json:"status"`
Type string `json:"type"`
TaskID string `json:"taskID"`
PageInfo
}
+34
View File
@@ -9,6 +9,29 @@ type FileOption struct {
files.FileOption
}
type FileAISearch struct {
Path string `json:"path" validate:"required"`
Query string `json:"query" validate:"required"`
ResponseLanguage string `json:"responseLanguage,omitempty"`
ContainSub *bool `json:"containSub,omitempty"`
MaxItems int `json:"maxItems" validate:"omitempty,min=1,max=2000"`
MatchCase bool `json:"matchCase"`
WholeWord bool `json:"wholeWord"`
UseRegex bool `json:"useRegex"`
Extensions []string `json:"extensions,omitempty"`
MinSize int64 `json:"minSize"`
MaxSize int64 `json:"maxSize"`
ModifiedAfter string `json:"modifiedAfter,omitempty"`
ModifiedBefore string `json:"modifiedBefore,omitempty"`
MaxScanFiles int `json:"maxScanFiles"`
MaxFileBytes int64 `json:"maxFileBytes"`
MaxHitsPerFile int `json:"maxHitsPerFile"`
MaxTotalHits int `json:"maxTotalHits"`
ContentHitsPromptMaxBytes int `json:"contentHitsPromptMaxBytes"`
LlmMaxOutputTokens int `json:"llmMaxOutputTokens"`
}
type FileContentReq struct {
Path string `json:"path" validate:"required"`
IsDetail bool `json:"isDetail"`
@@ -55,6 +78,11 @@ type FileCompress struct {
Name string `json:"name" validate:"required"`
Replace bool `json:"replace"`
Secret string `json:"secret"`
TaskID string `json:"taskID"`
}
type FileCompressStopReq struct {
TaskID string `json:"taskID" validate:"required"`
}
type FileDeCompress struct {
@@ -172,3 +200,9 @@ type FileRemarkUpdate struct {
Path string `json:"path" validate:"required"`
Remark string `json:"remark"`
}
type FileShareCreate struct {
Path string `json:"path" validate:"required"`
ExpireMinutes int `json:"expireMinutes" validate:"min=0,max=10080"`
Password *string `json:"password"`
}
+52
View File
@@ -0,0 +1,52 @@
package request
import (
"encoding/json"
"github.com/1Panel-dev/1Panel/agent/app/dto"
)
type FileHistorySearchReq struct {
dto.PageInfo
Path string `json:"path"`
Scope string `json:"scope" validate:"required,oneof=current all"`
Operation string `json:"operation"`
}
type FileHistoryContentReq struct {
ID uint `json:"id" validate:"required"`
}
type FileHistoryIDs []uint
func (ids *FileHistoryIDs) UnmarshalJSON(data []byte) error {
type alias []uint
var direct alias
if err := json.Unmarshal(data, &direct); err == nil {
*ids = FileHistoryIDs(direct)
return nil
}
var wrapped struct {
IDs []uint `json:"ids"`
}
if err := json.Unmarshal(data, &wrapped); err != nil {
return err
}
*ids = FileHistoryIDs(wrapped.IDs)
return nil
}
type FileHistoryDeleteReq struct {
IDs FileHistoryIDs `json:"ids" validate:"required"`
}
type FileHistoryRestoreReq struct {
ID uint `json:"id" validate:"required"`
}
type FileHistorySettingUpdate struct {
Enable string `json:"enable" validate:"required,oneof=Enable Disable"`
MaxPerPath int `json:"maxPerPath" validate:"min=0,max=1000"`
DiskQuotaMB int `json:"diskQuotaMB" validate:"min=0,max=1048576"`
}
-1
View File
@@ -1 +0,0 @@
package request
-1
View File
@@ -51,7 +51,6 @@ type WebsiteSSLApply struct {
ID uint `json:"ID" validate:"required"`
SkipDNSCheck bool `json:"skipDNSCheck"`
Nameservers []string `json:"nameservers"`
DisableLog bool `json:"disableLog"`
}
type WebsiteSSLObtain struct {
+38
View File
@@ -85,3 +85,41 @@ type FileConvertLog struct {
type FileRemarksRes struct {
Remarks map[string]string `json:"remarks"`
}
type FileShareInfo struct {
Code string `json:"code"`
Path string `json:"path"`
FileName string `json:"fileName"`
ExpiresAt int64 `json:"expiresAt"`
Permanent bool `json:"permanent"`
HasPassword bool `json:"hasPassword"`
Password string `json:"password,omitempty"`
}
type FileSharePublicInfo struct {
FileName string `json:"fileName"`
ExpiresAt int64 `json:"expiresAt"`
Permanent bool `json:"permanent"`
HasPassword bool `json:"hasPassword"`
}
type FileAIContentHit struct {
Path string `json:"path"`
Line int `json:"line"`
Text string `json:"text"`
}
type FileAISearchResult struct {
Mode string `json:"mode"`
Summary string `json:"summary"`
Hits []FileAIContentHit `json:"hits"`
ContentScannedFiles int `json:"contentScannedFiles"`
ContentHitsTruncated bool `json:"contentHitsTruncated"`
Truncated bool `json:"truncated"`
PreFiltered bool `json:"preFiltered"`
ItemCount int `json:"itemCount"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
Duration string `json:"duration"`
}
+31
View File
@@ -0,0 +1,31 @@
package response
import "time"
type FileHistoryInfo struct {
ID uint `json:"id"`
FileID string `json:"fileId"`
Path string `json:"path"`
CurrentPath string `json:"currentPath"`
PreviousID uint `json:"previousId"`
SourcePath string `json:"sourcePath"`
TargetPath string `json:"targetPath"`
FileName string `json:"fileName"`
Extension string `json:"extension"`
FileMode string `json:"fileMode"`
Operation string `json:"operation"`
Deleted bool `json:"deleted"`
ContentSize int64 `json:"contentSize"`
ContentSHA string `json:"contentSHA"`
StoragePath string `json:"storagePath,omitempty"`
Content string `json:"content,omitempty"`
CurrentContent string `json:"currentContent,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type FileHistorySettingInfo struct {
Enable string `json:"enable"`
MaxPerPath int `json:"maxPerPath"`
DiskQuotaMB int `json:"diskQuotaMB"`
}
+6
View File
@@ -55,6 +55,7 @@ type CleanTree struct {
Size uint64 `json:"size"`
IsCheck bool `json:"isCheck"`
IsRecommend bool `json:"isRecommend"`
CanDelete bool `json:"canDelete"`
}
type Clean struct {
@@ -95,6 +96,11 @@ type TerminalAIInfo struct {
AIRiskCommandsDefault string `json:"aiRiskCommandsDefault"`
}
type FileManageAIInfo struct {
AIStatus string `json:"aiStatus"`
AIAccountID string `json:"aiAccountId"`
}
type CommonDescription struct {
ID string `json:"id" validate:"required"`
Type string `json:"type" validate:"required"`
+6 -1
View File
@@ -4,7 +4,6 @@ import "time"
type SSHUpdate struct {
Key string `json:"key" validate:"required"`
OldValue string `json:"oldValue"`
NewValue string `json:"newValue"`
}
@@ -50,6 +49,12 @@ type GenerateLoad struct {
type SSHConf struct {
File string `json:"file"`
}
type SSHConfUpdate struct {
Key string `json:"key" validate:"required"`
Path string `json:"path"`
Value string `json:"value"`
}
type SearchSSHLog struct {
PageInfo
Info string `json:"info"`
+2
View File
@@ -3,6 +3,7 @@ package model
type Agent struct {
BaseModel
Name string `json:"name" gorm:"not null;unique"`
Remark string `json:"remark"`
AgentType string `json:"agentType" gorm:"default:openclaw"`
Provider string `json:"provider"`
Model string `json:"model"`
@@ -15,6 +16,7 @@ type Agent struct {
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
WebsiteID uint `json:"websiteId"`
AccountID uint `json:"accountId"`
ConfigPath string `json:"configPath"`
}
+12
View File
@@ -0,0 +1,12 @@
package model
type DatabaseMongodb struct {
BaseModel
Name string `json:"name" gorm:"not null"`
From string `json:"from" gorm:"not null;default:local"`
MongodbName string `json:"mongodbName" gorm:"not null"`
Username string `json:"username" gorm:"not null"`
Password string `json:"password" gorm:"not null"`
IsDelete bool `json:"isDelete"`
Description string `json:"description"`
}
+19
View File
@@ -0,0 +1,19 @@
package model
type FileHistory struct {
BaseModel
FileID string `json:"fileId" gorm:"not null;index:idx_file_history_file_id_created_at,priority:1;index:idx_file_history_file_id_deleted_created_at,priority:1"`
Path string `json:"path" gorm:"not null;index:idx_file_history_path_created_at,priority:1;index:idx_file_history_path_hash_created_at,priority:1;index:idx_file_history_related_path,priority:1"`
PathHash string `json:"pathHash" gorm:"not null;index:idx_file_history_path_created_at,priority:2;index:idx_file_history_path_hash_created_at,priority:2"`
PreviousID uint `json:"previousId" gorm:"index"`
SourcePath string `json:"sourcePath" gorm:"index:idx_file_history_related_path,priority:2"`
TargetPath string `json:"targetPath" gorm:"index:idx_file_history_related_path,priority:3"`
FileName string `json:"fileName" gorm:"not null"`
Extension string `json:"extension"`
FileMode string `json:"fileMode"`
Operation string `json:"operation" gorm:"not null;index:idx_file_history_operation_created_at,priority:1"`
Deleted bool `json:"deleted" gorm:"not null;default:false;index:idx_file_history_deleted_created_at,priority:1;index:idx_file_history_file_id_deleted_created_at,priority:2"`
ContentSize int64 `json:"contentSize" gorm:"not null"`
ContentSHA string `json:"contentSHA" gorm:"not null;index"`
StoragePath string `json:"storagePath" gorm:"not null;uniqueIndex"`
}
+14
View File
@@ -0,0 +1,14 @@
package model
type FileShare struct {
BaseModel
Path string `gorm:"not null;uniqueIndex" json:"path"`
Token string `gorm:"not null;uniqueIndex" json:"token"`
FileName string `gorm:"not null" json:"fileName"`
ExpiresUnix int64 `json:"expiresUnix"`
PasswordEnc string `json:"-"`
PasswordSalt string `json:"passwordSalt"`
PasswordHash string `json:"passwordHash"`
MaxDownloads int `json:"maxDownloads"`
DownloadCount int `json:"downloadCount"`
}
+34 -36
View File
@@ -78,14 +78,13 @@ var catalog = map[string]Meta{
EnvKey: "DEEPSEEK_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 128000,
ContextWindow: 131072,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "deepseek/deepseek-chat", Name: "DeepSeek Chat"},
{ID: "deepseek/deepseek-reasoner", Name: "DeepSeek Reasoner", Reasoning: true},
{ID: "deepseek/deepseek-r1:1.5b", Name: "DeepSeek R1 1.5B", Reasoning: true},
{ID: "deepseek/deepseek-reasoner", Name: "DeepSeek Reasoner", MaxTokens: 65536, ContextWindow: 131072, Reasoning: true},
},
},
"bailian-coding-plan": {
@@ -120,16 +119,16 @@ var catalog = map[string]Meta{
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
MaxTokens: 4096,
Input: []string{"text"},
},
Models: []Model{
{ID: "ark-coding-plan/doubao-seed-2.0-code", Name: "Doubao-Seed-2.0-Code"},
{ID: "ark-coding-plan/doubao-seed-code", Name: "Doubao-Seed-Code"},
{ID: "ark-coding-plan/kimi-k2.5", Name: "Kimi-K2.5", Reasoning: true},
{ID: "ark-coding-plan/glm-4.7", Name: "GLM-4.7", Reasoning: true},
{ID: "ark-coding-plan/deepseek-v3.2", Name: "DeepSeek-V3.2", Reasoning: true},
{ID: "ark-coding-plan/kimi-k2-thinking", Name: "Kimi-K2-thinking", Reasoning: true},
{ID: "ark-coding-plan/ark-code-latest", Name: "Ark Coding Plan", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/doubao-seed-code", Name: "Doubao Seed Code", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/glm-4.7", Name: "GLM 4.7 Coding", ContextWindow: 200000, MaxTokens: 4096},
{ID: "ark-coding-plan/kimi-k2-thinking", Name: "Kimi K2 Thinking", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/kimi-k2.5", Name: "Kimi K2.5 Coding", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/doubao-seed-code-preview-251028", Name: "Doubao Seed Code Preview", ContextWindow: 256000, MaxTokens: 4096},
},
},
"zai": {
@@ -174,19 +173,18 @@ var catalog = map[string]Meta{
Key: "xiaomi",
DisplayName: "Xiaomi",
Sort: 46,
DefaultBaseURL: "https://api.xiaomimimo.com/anthropic",
DefaultBaseURL: "https://api.xiaomimimo.com/v1",
EnvKey: "XIAOMI_API_KEY",
Default: RuntimeDefault{
APIType: "anthropic-messages",
APIType: "openai-completions",
ContextWindow: 262144,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "xiaomi/mimo-v2-pro", Name: "Xiaomi MiMo V2 Pro"},
{ID: "xiaomi/mimo-v2-omni", Name: "Xiaomi MiMo V2 Omni"},
{ID: "xiaomi/mimo-v2-tts", Name: "Xiaomi MiMo V2 TTS"},
{ID: "xiaomi/mimo-v2-flash", Name: "Xiaomi MiMo V2 Flash"},
{ID: "xiaomi/mimo-v2-flash", Name: "Xiaomi MiMo V2 Flash", ContextWindow: 262144, MaxTokens: 8192, Input: []string{"text"}},
{ID: "xiaomi/mimo-v2-pro", Name: "Xiaomi MiMo V2 Pro", ContextWindow: 1048576, MaxTokens: 32000, Reasoning: true, Input: []string{"text"}},
{ID: "xiaomi/mimo-v2-omni", Name: "Xiaomi MiMo V2 Omni", ContextWindow: 262144, MaxTokens: 32000, Reasoning: true, Input: []string{"text", "image"}},
},
},
"kimi": {
@@ -214,13 +212,14 @@ var catalog = map[string]Meta{
DefaultBaseURL: "https://api.kimi.com/coding/",
EnvKey: "KIMI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
APIType: "anthropic-messages",
ContextWindow: 262144,
MaxTokens: 32768,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "kimi-coding/k2p5", Name: "Kimi K2.5", Reasoning: true},
{ID: "kimi-coding/kimi-code", Name: "Kimi Code", ContextWindow: 262144, MaxTokens: 32768, Reasoning: true, Input: []string{"text", "image"}},
{ID: "kimi-coding/k2p5", Name: "Kimi K2.5", ContextWindow: 262144, MaxTokens: 32768, Reasoning: true, Input: []string{"text", "image"}},
},
},
"openai": {
@@ -230,17 +229,16 @@ var catalog = map[string]Meta{
DefaultBaseURL: "https://api.openai.com/v1",
EnvKey: "OPENAI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
APIType: "openai-responses",
ContextWindow: 272000,
MaxTokens: 128000,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "openai/codex-mini-latest", Name: "Codex Mini", Reasoning: true},
{ID: "openai/gpt-5", Name: "GPT-5", Reasoning: true},
{ID: "openai/gpt-5-mini", Name: "GPT-5 Mini", Reasoning: true},
{ID: "openai/gpt-5.4", Name: "GPT-5.4", Reasoning: true},
{ID: "openai/gpt-5.3-codex", Name: "GPT-5.3-Codex", Reasoning: true},
{ID: "openai/gpt-5.4", Name: "gpt-5.4", ContextWindow: 272000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-pro", Name: "gpt-5.4-pro", ContextWindow: 1050000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-mini", Name: "gpt-5.4-mini", ContextWindow: 400000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-nano", Name: "gpt-5.4-nano", ContextWindow: 400000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
},
},
"openrouter": {
@@ -267,17 +265,17 @@ var catalog = map[string]Meta{
DefaultBaseURL: "https://api.anthropic.com",
EnvKey: "ANTHROPIC_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
APIType: "anthropic-messages",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "anthropic/claude-3-haiku-20240307", Name: "Claude 3 Haiku"},
{ID: "anthropic/claude-3-5-haiku-latest", Name: "Claude 3.5 Haiku"},
{ID: "anthropic/claude-3-5-sonnet-20241022", Name: "Claude 3.5 Sonnet"},
{ID: "anthropic/claude-3-7-sonnet-20250219", Name: "Claude 3.7 Sonnet", Reasoning: true},
{ID: "anthropic/claude-opus-4-1", Name: "Claude Opus 4.1", Reasoning: true},
{ID: "anthropic/claude-sonnet-4-6", Name: "Claude Sonnet 4.6", Reasoning: true},
{ID: "anthropic/claude-opus-4-6", Name: "Claude Opus 4.6", Reasoning: true},
{ID: "anthropic/claude-opus-4-5", Name: "Claude Opus 4.5"},
{ID: "anthropic/claude-sonnet-4-5", Name: "Claude Sonnet 4.5"},
{ID: "anthropic/claude-haiku-4-5", Name: "Claude Haiku 4.5"},
},
},
"gemini": {
@@ -319,7 +317,7 @@ var catalog = map[string]Meta{
}
func Get(key string) (Meta, bool) {
meta, ok := catalog[strings.ToLower(strings.TrimSpace(key))]
meta, ok := catalog[key]
if !ok {
return Meta{}, false
}
@@ -335,7 +333,7 @@ func All() map[string]Meta {
}
func DefaultBaseURL(key string) (string, bool) {
meta, ok := catalog[strings.ToLower(strings.TrimSpace(key))]
meta, ok := catalog[key]
if !ok || strings.TrimSpace(meta.DefaultBaseURL) == "" {
return "", false
}
@@ -343,7 +341,7 @@ func DefaultBaseURL(key string) (string, bool) {
}
func EnvKey(key string) string {
meta, ok := catalog[strings.ToLower(strings.TrimSpace(key))]
meta, ok := catalog[key]
if !ok {
return ""
}
@@ -351,7 +349,7 @@ func EnvKey(key string) string {
}
func DisplayName(key string) string {
meta, ok := catalog[strings.ToLower(strings.TrimSpace(key))]
meta, ok := catalog[key]
if !ok {
return key
}
+40 -292
View File
@@ -5,334 +5,82 @@ import (
"strings"
)
type OpenClawPatch struct {
type OpenClawProviderPatch struct {
PrimaryModel string
Models map[string]interface{}
}
type openClawModelSpec struct {
ID string
Name string
Reasoning bool
Input []string
ContextWindow int
MaxTokens int
}
type openClawPatchSpec struct {
PrimaryModel string
Provider string
ProviderKey string
ModelID string
APIKey string
BaseURL string
APIType string
AuthHeader bool
Model openClawModelSpec
}
func BuildOpenClawPatch(provider, modelName, apiType string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) (*OpenClawPatch, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
modelName = strings.TrimSpace(modelName)
func BuildOpenClawProviderPatch(provider, modelName, apiType, baseURL, apiKey string) (*OpenClawProviderPatch, error) {
if modelName == "" {
return nil, fmt.Errorf("model is required")
}
apiType, maxTokens, contextWindow = ResolveRuntimeParams(provider, apiType, maxTokens, contextWindow)
modelID := modelName
if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 {
modelID = parts[1]
}
var spec openClawPatchSpec
resolvedAPIType, _, _ := ResolveRuntimeParams(provider, apiType, 0, 0)
modelID := resolveOpenClawModelID(provider, modelName)
switch provider {
case "deepseek":
spec = buildDeepseekPatchSpec(modelName, reasoning, maxTokens, contextWindow, baseURL, apiKey)
return newOpenClawProviderPatch(modelName, "deepseek", modelID, apiKey, baseURL, "openai-completions", false), nil
case "gemini":
spec = buildGenericPatchSpec(provider, modelName, modelID, apiType, reasoning, maxTokens, contextWindow, baseURL, apiKey)
return newOpenClawProviderPatch("google/"+modelID, "google", modelID, apiKey, baseURL, resolvedAPIType, false), nil
case "moonshot", "kimi":
spec = buildMoonshotPatchSpec(provider, modelName, modelID, reasoning, maxTokens, contextWindow, baseURL, apiKey)
return buildMoonshotProviderPatch(provider, modelName, modelID, baseURL, apiKey), nil
case "bailian-coding-plan":
spec = buildBailianPatchSpec(modelID, reasoning, maxTokens, contextWindow, baseURL, apiKey)
return newOpenClawProviderPatch("bailian-coding-plan/"+modelID, "bailian-coding-plan", modelID, apiKey, baseURL, "openai-completions", false), nil
case "ark-coding-plan":
spec = buildArkPatchSpec(modelID, reasoning, maxTokens, contextWindow, baseURL, apiKey)
return newOpenClawProviderPatch("ark-coding-plan/"+modelID, "ark-coding-plan", modelID, apiKey, baseURL, "openai-completions", false), nil
case "minimax":
spec = buildMiniMaxPatchSpec(modelID, reasoning, maxTokens, contextWindow, baseURL, apiKey)
return newOpenClawProviderPatch("minimax/"+modelID, "minimax", modelID, apiKey, baseURL, "anthropic-messages", true), nil
case "xiaomi":
spec = buildXiaomiPatchSpec(modelID, reasoning, maxTokens, contextWindow, baseURL, apiKey)
return newOpenClawProviderPatch("xiaomi/"+modelID, "xiaomi", modelID, apiKey, baseURL, "openai-completions", false), nil
case "custom", "vllm":
spec = buildCustomPatchSpec(provider, modelName, apiType, reasoning, maxTokens, contextWindow, baseURL, apiKey)
return newOpenClawProviderPatch(provider+"/"+modelID, provider, modelID, apiKey, baseURL, resolvedAPIType, false), nil
case "ollama":
spec = buildOllamaPatchSpec(modelName, modelID, apiType, reasoning, maxTokens, contextWindow, baseURL)
return newOpenClawProviderPatch(modelName, "ollama", modelID, "ollama", baseURL, resolvedAPIType, false), nil
case "kimi-coding":
spec = buildKimiCodingPatchSpec(modelName, modelID, reasoning, maxTokens, contextWindow, baseURL, apiKey)
return newOpenClawProviderPatch(modelName, "kimi-coding", modelID, apiKey, baseURL, "anthropic-messages", false), nil
case "zai":
spec = buildZaiPatchSpec(modelID, reasoning, maxTokens, contextWindow, baseURL, apiKey)
return newOpenClawProviderPatch("zai/"+modelID, "zai", modelID, apiKey, baseURL, "openai-completions", false), nil
default:
spec = buildGenericPatchSpec(provider, modelName, modelID, apiType, reasoning, maxTokens, contextWindow, baseURL, apiKey)
}
return buildOpenClawPatch(spec), nil
}
func buildOpenClawPatch(spec openClawPatchSpec) *OpenClawPatch {
return &OpenClawPatch{
PrimaryModel: spec.PrimaryModel,
Models: providerModels(
spec.Provider,
spec.APIKey,
spec.BaseURL,
spec.APIType,
spec.AuthHeader,
buildOpenClawModel(spec.Model),
),
return newOpenClawProviderPatch(modelName, provider, modelID, apiKey, baseURL, resolvedAPIType, false), nil
}
}
func buildOpenClawModel(spec openClawModelSpec) map[string]interface{} {
return map[string]interface{}{
"id": spec.ID,
"name": spec.Name,
"reasoning": spec.Reasoning,
"input": spec.Input,
"contextWindow": spec.ContextWindow,
"maxTokens": spec.MaxTokens,
"cost": map[string]interface{}{},
}
}
func buildDeepseekPatchSpec(modelName string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) openClawPatchSpec {
return openClawPatchSpec{
PrimaryModel: modelName,
Provider: "deepseek",
APIKey: strings.TrimSpace(apiKey),
BaseURL: baseURL,
APIType: "openai-completions",
Model: openClawModelSpec{
ID: "deepseek-chat",
Name: "DeepSeek Chat",
Reasoning: reasoning,
Input: []string{"text"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
}
}
func buildMoonshotPatchSpec(provider, modelName, modelID string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) openClawPatchSpec {
configProvider := provider
func buildMoonshotProviderPatch(provider, modelName, modelID, baseURL, apiKey string) *OpenClawProviderPatch {
providerKey := provider
primaryModel := modelName
if provider == "kimi" {
configProvider = "moonshot"
providerKey = "moonshot"
primaryModel = "moonshot/" + modelID
}
return openClawPatchSpec{
return newOpenClawProviderPatch(primaryModel, providerKey, modelID, apiKey, baseURL, "openai-completions", false)
}
func newOpenClawProviderPatch(primaryModel, providerKey, modelID, apiKey, baseURL, apiType string, authHeader bool) *OpenClawProviderPatch {
return &OpenClawProviderPatch{
PrimaryModel: primaryModel,
Provider: configProvider,
APIKey: strings.TrimSpace(apiKey),
BaseURL: baseURL,
APIType: "openai-completions",
Model: openClawModelSpec{
ID: modelID,
Name: modelID,
Reasoning: reasoning,
Input: []string{"text"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
}
}
func buildBailianPatchSpec(modelID string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) openClawPatchSpec {
return openClawPatchSpec{
PrimaryModel: "bailian-coding-plan/" + modelID,
Provider: "bailian-coding-plan",
APIKey: strings.TrimSpace(apiKey),
BaseURL: baseURL,
APIType: "openai-completions",
Model: openClawModelSpec{
ID: modelID,
Name: modelID,
Reasoning: reasoning,
Input: []string{"text"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
}
}
func buildArkPatchSpec(modelID string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) openClawPatchSpec {
return openClawPatchSpec{
PrimaryModel: "ark-coding-plan/" + modelID,
Provider: "ark-coding-plan",
APIKey: strings.TrimSpace(apiKey),
BaseURL: baseURL,
APIType: "openai-completions",
Model: openClawModelSpec{
ID: modelID,
Name: modelID,
Reasoning: reasoning,
Input: []string{"text"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
}
}
func buildMiniMaxPatchSpec(modelID string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) openClawPatchSpec {
return openClawPatchSpec{
PrimaryModel: "minimax/" + modelID,
Provider: "minimax",
APIKey: strings.TrimSpace(apiKey),
BaseURL: baseURL,
APIType: "anthropic-messages",
AuthHeader: true,
Model: openClawModelSpec{
ID: modelID,
Name: modelID,
Reasoning: reasoning,
Input: []string{"text"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
}
}
func buildXiaomiPatchSpec(modelID string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) openClawPatchSpec {
normalizedID := strings.TrimSpace(modelID)
return openClawPatchSpec{
PrimaryModel: "xiaomi/" + normalizedID,
Provider: "xiaomi",
APIKey: strings.TrimSpace(apiKey),
BaseURL: baseURL,
APIType: "anthropic-messages",
Model: openClawModelSpec{
ID: normalizedID,
Name: normalizedID,
Reasoning: reasoning,
Input: []string{"text"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
}
}
func buildCustomPatchSpec(provider, modelName, apiType string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) openClawPatchSpec {
customModelID := normalizeCustomModel(modelName)
return openClawPatchSpec{
PrimaryModel: provider + "/" + customModelID,
Provider: provider,
APIKey: strings.TrimSpace(apiKey),
BaseURL: strings.TrimSpace(baseURL),
APIType: apiType,
Model: openClawModelSpec{
ID: customModelID,
Name: customModelID,
Reasoning: reasoning,
Input: []string{"text"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
}
}
func buildOllamaPatchSpec(modelName, modelID, apiType string, reasoning bool, maxTokens, contextWindow int, baseURL string) openClawPatchSpec {
return openClawPatchSpec{
PrimaryModel: modelName,
Provider: "ollama",
APIKey: "ollama",
BaseURL: strings.TrimSpace(baseURL),
APIType: apiType,
Model: openClawModelSpec{
ID: modelID,
Name: modelID,
Reasoning: reasoning,
Input: []string{"text"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
}
}
func buildKimiCodingPatchSpec(modelName, modelID string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) openClawPatchSpec {
return openClawPatchSpec{
PrimaryModel: modelName,
Provider: "kimi-coding",
APIKey: strings.TrimSpace(apiKey),
BaseURL: baseURL,
APIType: "anthropic-messages",
Model: openClawModelSpec{
ID: modelID,
Name: "Kimi for Coding",
Reasoning: reasoning,
Input: []string{"text", "image"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
}
}
func buildZaiPatchSpec(modelID string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) openClawPatchSpec {
return openClawPatchSpec{
PrimaryModel: "zai/" + modelID,
Provider: "zai",
APIKey: strings.TrimSpace(apiKey),
BaseURL: baseURL,
APIType: "openai-completions",
Model: openClawModelSpec{
ID: modelID,
Name: modelID,
Reasoning: reasoning,
Input: []string{"text"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
}
}
func buildGenericPatchSpec(provider, modelName, modelID, apiType string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) openClawPatchSpec {
providerName := provider
primaryModel := modelName
if provider == "gemini" {
providerName = "google"
primaryModel = "google/" + modelID
}
return openClawPatchSpec{
PrimaryModel: primaryModel,
Provider: providerName,
APIKey: strings.TrimSpace(apiKey),
ProviderKey: providerKey,
ModelID: modelID,
APIKey: apiKey,
BaseURL: baseURL,
APIType: apiType,
Model: openClawModelSpec{
ID: modelID,
Name: modelID,
Reasoning: reasoning,
Input: []string{"text"},
ContextWindow: contextWindow,
MaxTokens: maxTokens,
},
AuthHeader: authHeader,
}
}
func providerModels(provider, apiKey, baseURL, api string, authHeader bool, model map[string]interface{}) map[string]interface{} {
providerConfig := map[string]interface{}{
"apiKey": apiKey,
"baseUrl": baseURL,
"api": api,
"models": []map[string]interface{}{model},
func resolveOpenClawModelID(provider, modelName string) string {
if provider == "custom" || provider == "vllm" {
target := strings.TrimLeft(modelName, "/")
if parts := strings.SplitN(target, "/", 2); len(parts) == 2 && parts[0] == "custom" {
return strings.TrimLeft(parts[1], "/")
}
return target
}
if authHeader {
providerConfig["authHeader"] = true
}
return map[string]interface{}{
"mode": "merge",
"providers": map[string]interface{}{
provider: providerConfig,
},
if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 {
return parts[1]
}
}
func normalizeCustomModel(modelName string) string {
trim := strings.TrimSpace(modelName)
trim = strings.TrimLeft(trim, "/")
if parts := strings.SplitN(trim, "/", 2); len(parts) == 2 && strings.EqualFold(parts[0], "custom") {
return strings.TrimLeft(strings.TrimSpace(parts[1]), "/")
}
return trim
return modelName
}
+7 -16
View File
@@ -23,7 +23,7 @@ const (
)
func SkipVerification(key string) bool {
switch strings.ToLower(strings.TrimSpace(key)) {
switch key {
case "custom", "vllm", "ollama", "kimi-coding":
return true
default:
@@ -62,7 +62,6 @@ func verifyTimeout() time.Duration {
}
func BuildVerifyRequest(provider, baseURL, apiKey string) VerifyRequest {
provider = strings.ToLower(strings.TrimSpace(provider))
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
headers := map[string]string{}
request := VerifyRequest{Method: http.MethodGet, Headers: headers}
@@ -117,7 +116,7 @@ func BuildVerifyRequest(provider, baseURL, apiKey string) VerifyRequest {
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"model": "doubao-seed-2.0-code",
"model": "ark-code-latest",
"messages": []map[string]string{{"role": "user", "content": "test"}},
"max_tokens": 1,
})
@@ -144,24 +143,16 @@ func BuildVerifyRequest(provider, baseURL, apiKey string) VerifyRequest {
})
case "xiaomi":
request.Method = http.MethodPost
headers["x-api-key"] = apiKey
headers["anthropic-version"] = "2023-06-01"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
if strings.Contains(base, "/v1") {
request.URL = base + "/messages"
} else {
request.URL = base + "/v1/messages"
if !strings.Contains(base, "/v1") {
base = base + "/v1"
}
request.URL = base + "/chat/completions"
request.Body = mustJSON(map[string]interface{}{
"model": "mimo-v2-flash",
"max_tokens": 1,
"messages": []map[string]interface{}{{
"role": "user",
"content": []map[string]string{{
"type": "text",
"text": "test",
}},
}},
"messages": []map[string]string{{"role": "user", "content": "test"}},
})
case "openrouter":
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
+8
View File
@@ -16,6 +16,7 @@ type IAgentRepo interface {
DeleteByID(id uint) error
DeleteByAppInstallID(appInstallID uint) error
DeleteByAppInstallIDWithCtx(ctx context.Context, appInstallID uint) error
ClearWebsiteIDByWebsiteIDWithCtx(ctx context.Context, websiteID uint) error
List(opts ...DBOption) ([]model.Agent, error)
}
@@ -66,6 +67,13 @@ func (a AgentRepo) DeleteByAppInstallIDWithCtx(ctx context.Context, appInstallID
return getTx(ctx).Where("app_install_id = ?", appInstallID).Delete(&model.Agent{}).Error
}
func (a AgentRepo) ClearWebsiteIDByWebsiteIDWithCtx(ctx context.Context, websiteID uint) error {
if websiteID == 0 {
return nil
}
return getTx(ctx).Model(&model.Agent{}).Where("website_id = ?", websiteID).Update("website_id", 0).Error
}
func (a AgentRepo) List(opts ...DBOption) ([]model.Agent, error) {
var agents []model.Agent
if err := getDb(opts...).Find(&agents).Error; err != nil {
+18
View File
@@ -112,6 +112,24 @@ func WithByAccountID(accountID uint) DBOption {
}
}
func WithByWebsiteID(websiteID uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
if websiteID == 0 {
return g
}
return g.Where("website_id = ?", websiteID)
}
}
func WithByAppInstallID(appInstallID uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
if appInstallID == 0 {
return g
}
return g.Where("app_install_id = ?", appInstallID)
}
}
func WithByType(tp string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("`type` = ?", tp)
+113
View File
@@ -0,0 +1,113 @@
package repo
import (
"context"
"fmt"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"gorm.io/gorm"
)
type MongodbRepo struct{}
type IMongodbRepo interface {
Get(opts ...DBOption) (model.DatabaseMongodb, error)
WithByMongodbName(mongodbName string) DBOption
List(opts ...DBOption) ([]model.DatabaseMongodb, error)
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMongodb, error)
Create(ctx context.Context, mongodb *model.DatabaseMongodb) error
Delete(ctx context.Context, opts ...DBOption) error
Update(id uint, vars map[string]interface{}) error
DeleteLocal(ctx context.Context) error
}
func NewIMongodbRepo() IMongodbRepo {
return &MongodbRepo{}
}
func (u *MongodbRepo) Get(opts ...DBOption) (model.DatabaseMongodb, error) {
var mongodb model.DatabaseMongodb
db := global.DB
for _, opt := range opts {
db = opt(db)
}
if err := db.First(&mongodb).Error; err != nil {
return mongodb, err
}
pass, err := encrypt.StringDecrypt(mongodb.Password)
if err != nil {
global.LOG.Errorf("decrypt mongodb db %s password failed, err: %v", mongodb.Name, err)
}
mongodb.Password = pass
return mongodb, err
}
func (u *MongodbRepo) List(opts ...DBOption) ([]model.DatabaseMongodb, error) {
var mongodbs []model.DatabaseMongodb
db := global.DB.Model(&model.DatabaseMongodb{})
for _, opt := range opts {
db = opt(db)
}
if err := db.Find(&mongodbs).Error; err != nil {
return mongodbs, err
}
for i := 0; i < len(mongodbs); i++ {
pass, err := encrypt.StringDecrypt(mongodbs[i].Password)
if err != nil {
global.LOG.Errorf("decrypt mongodb db %s password failed, err: %v", mongodbs[i].Name, err)
}
mongodbs[i].Password = pass
}
return mongodbs, nil
}
func (u *MongodbRepo) Page(page, size int, opts ...DBOption) (int64, []model.DatabaseMongodb, error) {
var mongodbs []model.DatabaseMongodb
db := global.DB.Model(&model.DatabaseMongodb{})
for _, opt := range opts {
db = opt(db)
}
count := int64(0)
db = db.Count(&count)
if err := db.Limit(size).Offset(size * (page - 1)).Find(&mongodbs).Error; err != nil {
return count, mongodbs, err
}
for i := 0; i < len(mongodbs); i++ {
pass, err := encrypt.StringDecrypt(mongodbs[i].Password)
if err != nil {
global.LOG.Errorf("decrypt mongodb db %s password failed, err: %v", mongodbs[i].Name, err)
}
mongodbs[i].Password = pass
}
return count, mongodbs, nil
}
func (u *MongodbRepo) Create(ctx context.Context, mongodb *model.DatabaseMongodb) error {
pass, err := encrypt.StringEncrypt(mongodb.Password)
if err != nil {
return fmt.Errorf("encrypt mongodb db %s password failed, err: %v", mongodb.Name, err)
}
mongodb.Password = pass
return getTx(ctx).Create(mongodb).Error
}
func (u *MongodbRepo) Delete(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseMongodb{}).Error
}
func (u *MongodbRepo) DeleteLocal(ctx context.Context) error {
return getTx(ctx).Where("`from` = ?", "local").Delete(&model.DatabaseMongodb{}).Error
}
func (u *MongodbRepo) Update(id uint, vars map[string]interface{}) error {
return global.DB.Model(&model.DatabaseMongodb{}).Where("id = ?", id).Updates(vars).Error
}
func (u *MongodbRepo) WithByMongodbName(mongodbName string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("mongodb_name = ?", mongodbName)
}
}
+121
View File
@@ -0,0 +1,121 @@
package repo
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type FileHistoryRepo struct{}
type IFileHistoryRepo interface {
Get(opts ...DBOption) (model.FileHistory, error)
Page(limit, offset int, opts ...DBOption) (int64, []model.FileHistory, error)
Create(history *model.FileHistory) error
Delete(opts ...DBOption) error
DeleteByIDs(ids []uint) error
WithByID(id uint) DBOption
WithByFileID(fileID string) DBOption
WithByPath(path string) DBOption
WithByPathHash(pathHash string) DBOption
WithByPathLike(path string) DBOption
WithByOperation(operation string) DBOption
WithNotOperation(operation string) DBOption
WithByRelatedPath(path string) DBOption
}
func NewIFileHistoryRepo() IFileHistoryRepo {
return &FileHistoryRepo{}
}
func (r *FileHistoryRepo) Get(opts ...DBOption) (model.FileHistory, error) {
var item model.FileHistory
db := global.DB.Model(&model.FileHistory{})
for _, opt := range opts {
db = opt(db)
}
return item, db.First(&item).Error
}
func (r *FileHistoryRepo) Page(limit, offset int, opts ...DBOption) (int64, []model.FileHistory, error) {
var total int64
var items []model.FileHistory
db := global.DB.Model(&model.FileHistory{})
for _, opt := range opts {
db = opt(db)
}
if err := db.Count(&total).Error; err != nil {
return 0, nil, err
}
if err := db.Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return 0, nil, err
}
return total, items, nil
}
func (r *FileHistoryRepo) Create(history *model.FileHistory) error {
return global.DB.Create(history).Error
}
func (r *FileHistoryRepo) Delete(opts ...DBOption) error {
db := global.DB.Model(&model.FileHistory{})
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.FileHistory{}).Error
}
func (r *FileHistoryRepo) DeleteByIDs(ids []uint) error {
if len(ids) == 0 {
return nil
}
return global.DB.Where("id in ?", ids).Delete(&model.FileHistory{}).Error
}
func (r *FileHistoryRepo) WithByID(id uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("id = ?", id)
}
}
func (r *FileHistoryRepo) WithByFileID(fileID string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("file_id = ?", fileID)
}
}
func (r *FileHistoryRepo) WithByPath(path string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("path = ?", path)
}
}
func (r *FileHistoryRepo) WithByPathHash(pathHash string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("path_hash = ?", pathHash)
}
}
func (r *FileHistoryRepo) WithByPathLike(path string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("path like ?", "%"+path+"%")
}
}
func (r *FileHistoryRepo) WithByOperation(operation string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("operation = ?", operation)
}
}
func (r *FileHistoryRepo) WithNotOperation(operation string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("operation <> ?", operation)
}
}
func (r *FileHistoryRepo) WithByRelatedPath(path string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("path = ? OR source_path = ? OR target_path = ?", path, path, path)
}
}
+77
View File
@@ -0,0 +1,77 @@
package repo
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type FileShareRepo struct{}
type IFileShareRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.FileShare, error)
Create(fileShare *model.FileShare) error
Save(fileShare *model.FileShare) error
Delete(opts ...DBOption) error
GetFirst(opts ...DBOption) (model.FileShare, error)
All() ([]model.FileShare, error)
WithByPath(path string) DBOption
WithByCode(code string) DBOption
}
func NewIFileShareRepo() IFileShareRepo {
return &FileShareRepo{}
}
func (r *FileShareRepo) WithByPath(path string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("path = ?", path)
}
}
func (r *FileShareRepo) WithByCode(code string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("token = ?", code)
}
}
func (r *FileShareRepo) Page(page, size int, opts ...DBOption) (int64, []model.FileShare, error) {
var (
items []model.FileShare
count int64
)
db := getDb(opts...).Model(&model.FileShare{})
db = db.Count(&count)
err := db.Order("path asc").Limit(size).Offset(size * (page - 1)).Find(&items).Error
return count, items, err
}
func (r *FileShareRepo) Create(fileShare *model.FileShare) error {
return global.DB.Create(fileShare).Error
}
func (r *FileShareRepo) Save(fileShare *model.FileShare) error {
return global.DB.Save(fileShare).Error
}
func (r *FileShareRepo) GetFirst(opts ...DBOption) (model.FileShare, error) {
var item model.FileShare
db := getDb(opts...).Model(&model.FileShare{})
if err := db.First(&item).Error; err != nil {
return item, err
}
return item, nil
}
func (r *FileShareRepo) Delete(opts ...DBOption) error {
db := getDb(opts...).Model(&model.FileShare{})
return db.Delete(&model.FileShare{}).Error
}
func (r *FileShareRepo) All() ([]model.FileShare, error) {
var items []model.FileShare
if err := getDb().Order("path asc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
+3
View File
@@ -165,6 +165,9 @@ func (h *HostRepo) SaveFirewallRecord(firewall *model.Firewall) error {
firewall.Strategy,
).First(&data).Error
}
if data.ID != 0 {
firewall.ID = data.ID
}
return global.DB.Save(firewall).Error
}
+10
View File
@@ -10,6 +10,7 @@ import (
type IWebsiteRepo interface {
WithAppInstallId(appInstallId uint) DBOption
WithAppInstallIds(appInstallIds []uint) DBOption
WithDomain(domain string) DBOption
WithAlias(alias string) DBOption
WithWebsiteSSLID(sslId uint) DBOption
@@ -48,6 +49,15 @@ func (w *WebsiteRepo) WithAppInstallId(appInstallID uint) DBOption {
}
}
func (w *WebsiteRepo) WithAppInstallIds(appInstallIDs []uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
if len(appInstallIDs) == 0 {
return db
}
return db.Where("app_install_id in (?)", appInstallIDs)
}
}
func (w *WebsiteRepo) WithRuntimeID(runtimeID uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("runtime_id = ?", runtimeID)
+10
View File
@@ -13,6 +13,7 @@ type WebsiteDomainRepo struct {
type IWebsiteDomainRepo interface {
WithWebsiteId(websiteId uint) DBOption
WithWebsiteIds(websiteIds []uint) DBOption
WithPort(port int) DBOption
WithDomain(domain string) DBOption
WithDomainLike(domain string) DBOption
@@ -36,6 +37,15 @@ func (w WebsiteDomainRepo) WithWebsiteId(websiteId uint) DBOption {
}
}
func (w WebsiteDomainRepo) WithWebsiteIds(websiteIds []uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
if len(websiteIds) == 0 {
return db
}
return db.Where("website_id in (?)", websiteIds)
}
}
func (w WebsiteDomainRepo) WithPort(port int) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("port = ?", port)
+294 -46
View File
@@ -7,6 +7,7 @@ import (
"os"
"path"
"sort"
"strconv"
"strings"
"time"
@@ -19,17 +20,28 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/docker/docker/api/types/container"
"gopkg.in/yaml.v3"
"gorm.io/gorm"
)
type IAgentService interface {
Create(req dto.AgentCreateReq) (*dto.AgentItem, error)
Page(req dto.SearchWithPage) (int64, []dto.AgentItem, error)
DeleteCheck(req dto.AgentIDReq) ([]dto.AppResource, error)
Delete(req dto.AgentDeleteReq) error
ResetToken(req dto.AgentTokenResetReq) error
UpdateRemark(req dto.AgentRemarkUpdateReq) error
BindWebsite(req dto.AgentWebsiteBindReq) error
UnbindWebsite(req dto.AgentIDReq) error
GetModelConfig(req dto.AgentIDReq) (*dto.AgentModelConfig, error)
UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
GetHermesChatSessions(req dto.AgentIDReq) ([]dto.AgentHermesChatSessionItem, error)
RenameHermesChatSession(req dto.AgentHermesChatSessionRenameReq) error
DeleteHermesChatSession(req dto.AgentHermesChatSessionDeleteReq) error
GetOverview(req dto.AgentOverviewReq) (*dto.AgentOverview, error)
GetProviders() ([]dto.ProviderInfo, error)
GetSecurityConfig(req dto.AgentIDReq) (*dto.AgentSecurityConfig, error)
@@ -42,6 +54,16 @@ type IAgentService interface {
SearchSkills(req dto.AgentSkillSearchReq) ([]dto.AgentSkillSearchItem, error)
UpdateSkill(req dto.AgentSkillUpdateReq) error
InstallSkill(req dto.AgentSkillInstallReq) error
UninstallSkill(req dto.AgentSkillUninstallReq) error
CreateRole(req dto.AgentRoleCreateReq) (*dto.AgentRoleCreateResp, error)
DeleteRole(req dto.AgentRoleDeleteReq) error
BindRole(req dto.AgentRoleBindReq) error
UnbindRole(req dto.AgentRoleBindReq) error
GetConfiguredAgents(req dto.AgentConfiguredAgentsReq) ([]dto.AgentConfiguredAgentItem, error)
GetRoleChannels(req dto.AgentRoleChannelsReq) ([]dto.AgentRoleChannelItem, error)
GetRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesReq) ([]dto.AgentRoleMarkdownFileItem, error)
UpdateRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesUpdateReq) error
CreateAccount(req dto.AgentAccountCreateReq) error
UpdateAccount(req dto.AgentAccountUpdateReq) error
@@ -64,16 +86,20 @@ type IAgentService interface {
UpdateWecomConfig(req dto.AgentWecomConfigUpdateReq) error
GetDingTalkConfig(req dto.AgentIDReq) (*dto.AgentDingTalkConfig, error)
UpdateDingTalkConfig(req dto.AgentDingTalkConfigUpdateReq) error
GetWeixinConfig(req dto.AgentIDReq) (*dto.AgentWeixinConfig, error)
LoginWeixinChannel(req dto.AgentWeixinLoginReq) error
GetQQBotConfig(req dto.AgentIDReq) (*dto.AgentQQBotConfig, error)
UpdateQQBotConfig(req dto.AgentQQBotConfigUpdateReq) error
DeleteChannelConfig(req dto.AgentChannelDeleteReq) error
InstallPlugin(req dto.AgentPluginInstallReq) error
UpgradePlugin(req dto.AgentPluginUpgradeReq) error
UninstallPlugin(req dto.AgentPluginUninstallReq) error
CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error)
ApproveChannelPairing(req dto.AgentChannelPairingApproveReq) error
}
const (
defaultBrowserExecutablePath = "/home/node/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome"
defaultBrowserExecutablePath = "/home/node/.cache/ms-playwright/openclaw-browser"
defaultBrowserProfile = "openclaw"
defaultUserTimezone = "Asia/Shanghai"
defaultToolsProfile = "full"
@@ -134,15 +160,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
var account *model.AgentAccount
var installHooks *appInstallHooks
if agentType == constant.AppOpenclaw {
var err error
allowedOrigins, err = normalizeAllowedOrigins(req.AllowedOrigins)
if err != nil {
return nil, err
}
if len(allowedOrigins) == 0 {
return nil, fmt.Errorf("allowed origins is required")
}
if agentType == constant.AppOpenclaw || agentType == constant.AppHermesAgent {
if req.AccountID == 0 {
return nil, buserr.New("ErrAgentAccountRequired")
}
@@ -154,7 +172,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
return nil, buserr.New("ErrAgentAccountNotVerified")
}
provider = account.Provider
baseURL = strings.TrimSpace(account.BaseURL)
baseURL = account.BaseURL
resolvedRuntime, err := resolveOpenclawAccountModelRuntimeByID(account, req.Model)
if err != nil {
return nil, err
@@ -166,6 +184,17 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
runtimeModel = resolvedRuntime.PrimaryModel
apiKey = account.APIKey
accountID = account.ID
}
if agentType == constant.AppOpenclaw {
var err error
allowedOrigins, err = normalizeAllowedOrigins(req.AllowedOrigins)
if err != nil {
return nil, err
}
if len(allowedOrigins) == 0 {
return nil, fmt.Errorf("allowed origins is required")
}
token = strings.TrimSpace(req.Token)
if token == "" {
token = generateToken()
@@ -175,6 +204,12 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
return prepareOpenclawInstallFiles(appInstall, account, storedModel, token, allowedOrigins)
},
}
} else if agentType == constant.AppHermesAgent {
installHooks = &appInstallHooks{
AfterCopyData: func(appInstall *model.AppInstall) error {
return prepareHermesInstallFiles(appInstall, account, storedModel)
},
}
}
params := map[string]interface{}{
@@ -182,12 +217,8 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
constant.MemoryLimit: "0",
constant.HostIP: "",
}
setAgentWebUIParams(params, agentType, detail.Version, req.WebUIPort)
if agentType == constant.AppOpenclaw {
if isOpenclawHTTPSWindowVersion(detail.Version) {
params["PANEL_APP_PORT_HTTPS"] = req.WebUIPort
} else {
params["PANEL_APP_PORT_HTTP"] = req.WebUIPort
}
if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" {
params["ALLOWED_ORIGIN"] = allowedOrigin
}
@@ -199,8 +230,6 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
params["BASE_URL"] = baseURL
params["API_KEY"] = apiKey
params["OPENCLAW_GATEWAY_TOKEN"] = token
} else {
params["PANEL_APP_PORT_HTTP"] = req.WebUIPort
}
if req.EditCompose && strings.TrimSpace(req.DockerCompose) == "" {
@@ -232,8 +261,12 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
if agentType == constant.AppOpenclaw {
configPath = path.Join(appInstall.GetPath(), "data", "conf", "openclaw.json")
}
if agentType == constant.AppHermesAgent {
configPath = path.Join(appInstall.GetPath(), "data", "config.yaml")
}
agent := &model.Agent{
Name: req.Name,
Remark: req.Remark,
AgentType: agentType,
Provider: provider,
Model: storedModel,
@@ -257,6 +290,14 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
return &item, nil
}
func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) {
if agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) {
params["PANEL_APP_PORT_HTTPS"] = webUIPort
return
}
params["PANEL_APP_PORT_HTTP"] = webUIPort
}
func (a AgentService) Page(req dto.SearchWithPage) (int64, []dto.AgentItem, error) {
var opts []repo.DBOption
if strings.TrimSpace(req.Info) != "" {
@@ -267,13 +308,22 @@ func (a AgentService) Page(req dto.SearchWithPage) (int64, []dto.AgentItem, erro
return 0, nil, err
}
items := make([]dto.AgentItem, 0, len(list))
appInstalls := make([]model.AppInstall, 0, len(list))
for _, item := range list {
appInstall, _ := appInstallRepo.GetFirst(repo.WithByID(item.AppInstallID))
appInstalls = append(appInstalls, appInstall)
}
syncAgentAppInstalls(appInstalls)
for index, item := range list {
appInstall := appInstalls[index]
envMap := readInstallEnv(appInstall.Env)
agentItem := buildAgentItem(&item, &appInstall, envMap)
agentItem.Upgradable = checkAgentUpgradable(appInstall)
items = append(items, agentItem)
}
if err := hydrateAgentWebsiteItems(items); err != nil {
return 0, nil, err
}
return count, items, nil
}
@@ -282,6 +332,13 @@ func (a AgentService) Delete(req dto.AgentDeleteReq) error {
if err != nil {
return err
}
resources, err := a.deleteCheckByAgent(agent)
if err != nil {
return err
}
if len(resources) > 0 {
return buserr.New("ErrAgentWebsiteBound")
}
if agent.AppInstallID == 0 {
return agentRepo.DeleteByID(agent.ID)
}
@@ -297,6 +354,58 @@ func (a AgentService) Delete(req dto.AgentDeleteReq) error {
return nil
}
func (a AgentService) DeleteCheck(req dto.AgentIDReq) ([]dto.AppResource, error) {
agent, err := agentRepo.GetFirst(repo.WithByID(req.AgentID))
if err != nil {
return nil, err
}
return a.deleteCheckByAgent(agent)
}
func (a AgentService) deleteCheckByAgent(agent *model.Agent) ([]dto.AppResource, error) {
if agent == nil || agent.WebsiteID == 0 {
return nil, nil
}
website, err := websiteRepo.GetFirst(repo.WithByID(agent.WebsiteID))
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
websiteName, err := loadAgentWebsiteResourceName(website)
if err != nil {
return nil, err
}
return []dto.AppResource{{Type: "website", Name: websiteName}}, nil
}
func syncAgentAppInstalls(appInstalls []model.AppInstall) {
if len(appInstalls) == 0 {
return
}
var containersMap map[string]container.Summary
cli, err := docker.NewClient()
if err == nil {
defer cli.Close()
containers, err := cli.ListAllContainers()
if err == nil {
containersMap = make(map[string]container.Summary, len(containers))
for _, contain := range containers {
containersMap[contain.Names[0]] = contain
}
}
}
for index := range appInstalls {
if appInstalls[index].ID == 0 || doNotNeedSync(appInstalls[index]) {
continue
}
synAppInstall(containersMap, &appInstalls[index], false)
}
}
func (a AgentService) ResetToken(req dto.AgentTokenResetReq) error {
agent, err := loadOpenclawAgentByID(req.ID)
if err != nil {
@@ -323,8 +432,68 @@ func (a AgentService) ResetToken(req dto.AgentTokenResetReq) error {
return agentRepo.Save(agent)
}
func (a AgentService) UpdateRemark(req dto.AgentRemarkUpdateReq) error {
agent, err := agentRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return err
}
agent.Remark = req.Remark
return agentRepo.Save(agent)
}
func (a AgentService) GetModelConfig(req dto.AgentIDReq) (*dto.AgentModelConfig, error) {
agent, err := agentRepo.GetFirst(repo.WithByID(req.AgentID))
if err != nil {
return nil, err
}
if agent.AgentType == constant.AppHermesAgent {
cfg, err := readHermesConfig(agent.ConfigPath)
if err != nil {
return nil, err
}
account, err := agentAccountRepo.GetFirst(repo.WithByID(agent.AccountID))
if err != nil {
return nil, err
}
accountModels, err := loadAgentAccountModels(account)
if err != nil {
return nil, err
}
model, err := resolveHermesConfiguredModelID(account, accountModels, cfg.Model.Default)
if err != nil {
return nil, err
}
return &dto.AgentModelConfig{
AccountID: agent.AccountID,
Model: model,
Fallbacks: []string{},
}, nil
}
agent, _, conf, err := a.loadOpenclawAgentConfig(req.AgentID)
if err != nil {
return nil, err
}
account, err := agentAccountRepo.GetFirst(repo.WithByID(agent.AccountID))
if err != nil {
return nil, err
}
models, err := loadAgentAccountModels(account)
if err != nil {
return nil, err
}
model := extractOpenclawPrimaryModelID(conf, account, models)
if model == "" {
model = agent.Model
}
return &dto.AgentModelConfig{
AccountID: agent.AccountID,
Model: model,
Fallbacks: extractOpenclawFallbackModelIDs(conf, account, models, model),
}, nil
}
func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error {
agent, err := loadOpenclawAgentByID(req.AgentID)
agent, err := agentRepo.GetFirst(repo.WithByID(req.AgentID))
if err != nil {
return err
}
@@ -339,8 +508,21 @@ func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
modelName := resolvedRuntime.StoredModel
apiType, maxTokens, contextWindow := resolvedRuntime.APIType, resolvedRuntime.MaxTokens, resolvedRuntime.ContextWindow
confDir := path.Dir(agent.ConfigPath)
if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil); err != nil {
return err
if agent.AgentType == constant.AppHermesAgent {
cfg, err := readHermesConfig(agent.ConfigPath)
if err != nil {
return err
}
if err := writeHermesConfig(confDir, account, modelName, cfg.Timezone); err != nil {
return err
}
} else {
if agent.AgentType != constant.AppOpenclaw {
return fmt.Errorf("%s does not support", agent.AgentType)
}
if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil, req.Fallbacks); err != nil {
return err
}
}
agent.Provider = account.Provider
agent.Model = modelName
@@ -442,6 +624,7 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
return err
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
if req.SyncAgents {
if err := a.syncAgentsByAccount(account); err != nil {
return err
@@ -582,6 +765,7 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
return err
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return a.syncAgentsByAccount(account)
}
@@ -614,6 +798,7 @@ func (a AgentService) DeleteAccountModel(req dto.AgentAccountModelDeleteReq) err
return err
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return a.syncAgentsByAccount(account)
}
@@ -633,10 +818,16 @@ func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
if exists, _ := agentRepo.GetFirst(repo.WithByAccountID(req.ID)); exists != nil && exists.ID > 0 {
return buserr.New("ErrAgentAccountBound")
}
if aiStatus, _ := settingRepo.GetValueByKey("AIStatus"); strings.EqualFold(strings.TrimSpace(aiStatus), constant.StatusEnable) {
if aiAccountID, _ := settingRepo.GetValueByKey("AIAccountID"); strings.TrimSpace(aiAccountID) == strconv.FormatUint(uint64(req.ID), 10) {
return buserr.New("ErrTerminalAIAccountInUse")
}
}
if err := agentAccountModelRepo.Delete(repo.WithByAccountID(req.ID)); err != nil {
return err
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return agentAccountRepo.DeleteByID(req.ID)
}
@@ -684,6 +875,17 @@ func (a AgentService) GetOtherConfig(req dto.AgentIDReq) (*dto.AgentOtherConfig,
if err != nil {
return nil, err
}
if agent.AgentType == constant.AppHermesAgent {
cfg, err := readHermesConfig(agent.ConfigPath)
if err != nil {
return nil, err
}
return &dto.AgentOtherConfig{
UserTimezone: cfg.Timezone,
BrowserEnabled: true,
NPMRegistry: "https://registry.npmjs.org/",
}, nil
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return nil, err
@@ -701,6 +903,19 @@ func (a AgentService) UpdateOtherConfig(req dto.AgentOtherConfigUpdateReq) error
if err != nil {
return err
}
if agent.AgentType == constant.AppHermesAgent {
account, err := agentAccountRepo.GetFirst(repo.WithByID(agent.AccountID))
if err != nil {
return err
}
if err := writeHermesConfig(path.Dir(agent.ConfigPath), account, agent.Model, strings.TrimSpace(req.UserTimezone)); err != nil {
return err
}
return NewIAppInstalledService().Operate(request.AppInstalledOperate{
InstallId: install.ID,
Operate: constant.Restart,
})
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return err
}
@@ -715,11 +930,14 @@ func (a AgentService) UpdateOtherConfig(req dto.AgentOtherConfigUpdateReq) error
if err := writeOpenclawConfigRaw(agent.ConfigPath, conf); err != nil {
return err
}
return setOpenclawNPMRegistry(install.ContainerName, req.NPMRegistry)
if err := setOpenclawNPMRegistry(install.ContainerName, req.NPMRegistry); err != nil {
return err
}
return nil
}
func (a AgentService) GetConfigFile(req dto.AgentConfigFileReq) (*dto.AgentConfigFile, error) {
agent, _, err := a.loadOpenclawAgentAndInstall(req.AgentID)
agent, _, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
@@ -731,12 +949,11 @@ func (a AgentService) GetConfigFile(req dto.AgentConfigFileReq) (*dto.AgentConfi
}
func (a AgentService) UpdateConfigFile(req dto.AgentConfigFileUpdateReq) error {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return err
}
var payload interface{}
if err := json.Unmarshal([]byte(req.Content), &payload); err != nil {
if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil {
return err
}
info, err := os.Stat(agent.ConfigPath)
@@ -746,14 +963,34 @@ func (a AgentService) UpdateConfigFile(req dto.AgentConfigFileUpdateReq) error {
if err := os.WriteFile(agent.ConfigPath, []byte(req.Content), info.Mode()); err != nil {
return err
}
if agent.AgentType == constant.AppHermesAgent {
cfg, err := readHermesConfig(agent.ConfigPath)
if err != nil {
return err
}
if cfg.Model.Default != "" {
agent.Model = cfg.Model.Default
if err := agentRepo.Save(agent); err != nil {
return err
}
}
}
return NewIAppInstalledService().Operate(request.AppInstalledOperate{
InstallId: install.ID,
Operate: constant.Restart,
})
}
func validateAgentConfigFileContent(agentType, content string) error {
var payload interface{}
if agentType == constant.AppHermesAgent {
return yaml.Unmarshal([]byte(content), &payload)
}
return json.Unmarshal([]byte(content), &payload)
}
func getOpenclawNPMRegistry(containerName string) (string, error) {
registry, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut("docker exec %s npm get registry", 20*time.Second, containerName)
registry, err := runDockerExecWithStdout(20*time.Second, containerName, "npm", "get", "registry")
if err != nil {
return "", err
}
@@ -788,8 +1025,8 @@ func (a AgentService) loadOpenclawAgentAndInstall(agentID uint) (*model.Agent, *
if err != nil {
return nil, nil, err
}
if agent.AgentType == constant.AppCopaw {
return nil, nil, fmt.Errorf("copaw does not support")
if agent.AgentType != constant.AppOpenclaw {
return nil, nil, fmt.Errorf("%s does not support", agent.AgentType)
}
return agent, install, nil
}
@@ -811,8 +1048,8 @@ func (a AgentService) loadOpenclawAgentConfig(agentID uint) (*model.Agent, *mode
if err != nil {
return nil, nil, nil, err
}
if agent.AgentType == constant.AppCopaw {
return nil, nil, nil, fmt.Errorf("copaw does not support")
if agent.AgentType != constant.AppOpenclaw {
return nil, nil, nil, fmt.Errorf("%s does not support", agent.AgentType)
}
return agent, install, conf, nil
}
@@ -840,29 +1077,40 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error {
if len(accountModels) == 0 {
return nil
}
baseURL := resolveAccountBaseURL(account)
for _, agent := range agents {
confDir := path.Dir(agent.ConfigPath)
modelName := strings.TrimSpace(agent.Model)
var selectedAccountModel dto.AgentAccountModel
if modelName != "" {
selectedAccountModel, err = requireAgentAccountModelForProvider(account.Provider, accountModels, modelName)
if err != nil {
return buserr.WithName("ErrAgentModelInUse", agent.Name)
}
} else {
selectedAccountModel = accountModels[0]
selectedAccountModel, err := requireAgentAccountModelForProvider(account.Provider, accountModels, agent.Model)
if err != nil {
return buserr.WithName("ErrAgentModelInUse", agent.Name)
}
resolvedRuntime, err := buildOpenclawAccountModelRuntime(account, selectedAccountModel)
if err != nil {
return err
}
modelName = resolvedRuntime.StoredModel
modelName := resolvedRuntime.StoredModel
apiType, maxTokens, contextWindow := resolvedRuntime.APIType, resolvedRuntime.MaxTokens, resolvedRuntime.ContextWindow
if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil); err != nil {
return err
confDir := path.Dir(agent.ConfigPath)
switch agent.AgentType {
case constant.AppOpenclaw:
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
fallbacks := extractOpenclawFallbackModelIDs(conf, account, accountModels, selectedAccountModel.ID)
if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil, fallbacks); err != nil {
return err
}
case constant.AppHermesAgent:
cfg, err := readHermesConfig(agent.ConfigPath)
if err != nil {
return err
}
if err := writeHermesConfig(confDir, account, modelName, cfg.Timezone); err != nil {
return err
}
default:
continue
}
agent.BaseURL = baseURL
agent.BaseURL = account.BaseURL
agent.APIKey = account.APIKey
agent.Provider = account.Provider
agent.Model = modelName
+533
View File
@@ -0,0 +1,533 @@
package service
import (
"os"
"path"
"sort"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
var agentMarkdownFileNames = []string{"AGENTS.md", "SOUL.md", "USER.md", "IDENTITY.md", "TOOLS.md", "HEARTBEAT.md", "BOOT.md", "BOOTSTRAP.md"}
func (a AgentService) CreateRole(req dto.AgentRoleCreateReq) (*dto.AgentRoleCreateResp, error) {
_, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
args := []string{"exec", install.ContainerName, "openclaw", "agents", "add", req.Name}
workspace := "/home/node/.openclaw/workspace-agent_" + req.Name
agentDir := "/home/node/.openclaw/agents/" + req.Name
args = append(args, "--workspace", workspace)
if model := strings.TrimSpace(req.Model); model != "" {
args = append(args, "--model", model)
}
for _, binding := range req.Bindings {
channel := binding.Channel
if channel == "" {
continue
}
if accountID := binding.AccountID; accountID != "" {
channel = channel + ":" + accountID
}
args = append(args, "--bind", channel)
}
args = append(args, "--agent-dir", agentDir)
args = append(args, "--non-interactive", "--json")
mgr := cmd.NewCommandMgr(cmd.WithTimeout(5 * time.Minute))
output, err := mgr.RunWithStdout("docker", args...)
if err != nil {
return nil, err
}
return &dto.AgentRoleCreateResp{Output: strings.TrimSpace(output)}, nil
}
func (a AgentService) GetConfiguredAgents(req dto.AgentConfiguredAgentsReq) ([]dto.AgentConfiguredAgentItem, error) {
agent, _, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return nil, err
}
agents, ok := conf["agents"].(map[string]interface{})
if !ok {
return []dto.AgentConfiguredAgentItem{}, nil
}
rawList, ok := agents["list"].([]interface{})
if !ok {
return []dto.AgentConfiguredAgentItem{}, nil
}
result := make([]dto.AgentConfiguredAgentItem, 0, len(rawList))
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
for _, item := range rawList {
record, ok := item.(map[string]interface{})
if !ok {
continue
}
configured := extractConfiguredAgentItem(baseDir, record)
if strings.EqualFold(configured.ID, "main") {
continue
}
result = append(result, configured)
}
applyConfiguredAgentBindings(result, conf["bindings"])
return result, nil
}
func (a AgentService) GetRoleChannels(req dto.AgentRoleChannelsReq) ([]dto.AgentRoleChannelItem, error) {
_, _, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return nil, err
}
channels, ok := conf["channels"].(map[string]interface{})
if !ok || len(channels) == 0 {
return []dto.AgentRoleChannelItem{}, nil
}
boundBindings := loadBoundChannelBindings(conf["bindings"])
result := make([]dto.AgentRoleChannelItem, 0, len(channels))
for key := range channels {
if key == "" {
continue
}
accountIDs := extractRoleChannelAccountIDs(conf, key)
availableAccountIDs := filterAvailableChannelAccountIDs(boundBindings, key, accountIDs)
result = append(result, dto.AgentRoleChannelItem{
Name: key,
Bound: isRoleChannelFullyBound(boundBindings, key, accountIDs, availableAccountIDs),
AccountIDs: availableAccountIDs,
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Name < result[j].Name
})
return result, nil
}
func (a AgentService) DeleteRole(req dto.AgentRoleDeleteReq) error {
agent, install, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return err
}
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
if req.ID == "" {
return buserr.New("ErrRecordNotFound")
}
target, ok := findConfiguredAgentByID(baseDir, conf, req.ID)
if !ok {
return buserr.New("ErrRecordNotFound")
}
args := []string{"exec", install.ContainerName, "openclaw", "agents", "delete", req.ID, "--force"}
mgr := cmd.NewCommandMgr(cmd.WithTimeout(5 * time.Minute))
if _, err = mgr.RunWithStdout("docker", args...); err != nil {
return err
}
if target.Workspace != "" {
if err := os.RemoveAll(target.Workspace); err != nil {
return err
}
}
if target.AgentDir != "" {
if err := os.RemoveAll(target.AgentDir); err != nil {
return err
}
}
return nil
}
func (a AgentService) BindRole(req dto.AgentRoleBindReq) error {
return a.operateRoleBinding(req, "bind")
}
func (a AgentService) UnbindRole(req dto.AgentRoleBindReq) error {
return a.operateRoleBinding(req, "unbind")
}
func (a AgentService) operateRoleBinding(req dto.AgentRoleBindReq, action string) error {
agent, install, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return err
}
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
if req.ID == "" {
return buserr.New("ErrRecordNotFound")
}
if _, ok := findConfiguredAgentByID(baseDir, conf, req.ID); !ok {
return buserr.New("ErrRecordNotFound")
}
binding := formatRoleBinding(req.Channel, req.AccountID)
if binding == "" {
return buserr.New("ErrInvalidParams")
}
args := []string{
"exec",
install.ContainerName,
"openclaw",
"agents",
action,
"--agent",
req.ID,
"--bind",
binding,
}
args = append(args, "--json")
mgr := cmd.NewCommandMgr(cmd.WithTimeout(5 * time.Minute))
_, err = mgr.RunWithStdout("docker", args...)
return err
}
func (a AgentService) GetRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesReq) ([]dto.AgentRoleMarkdownFileItem, error) {
agent, err := loadOpenclawAgentByID(req.AgentID)
if err != nil {
return nil, err
}
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
workspaceDir, err := resolveMarkdownWorkspaceDir(baseDir, req.Workspace)
if err != nil {
return nil, err
}
items := make([]dto.AgentRoleMarkdownFileItem, 0, len(agentMarkdownFileNames))
for _, name := range agentMarkdownFileNames {
item := dto.AgentRoleMarkdownFileItem{
Name: name,
}
content, readErr := os.ReadFile(path.Join(workspaceDir, name))
if readErr == nil {
item.Content = string(content)
} else if !os.IsNotExist(readErr) {
return nil, readErr
}
items = append(items, item)
}
return items, nil
}
func (a AgentService) UpdateRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesUpdateReq) error {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
dirPath, err := resolveMarkdownWorkspaceDir(baseDir, req.Workspace)
if err != nil {
return err
}
if err = os.MkdirAll(dirPath, 0755); err != nil {
return err
}
for _, item := range req.Files {
file := path.Join(dirPath, item.Name)
if err = os.WriteFile(file, []byte(item.Content), 0644); err != nil {
return err
}
}
if req.Restart {
return NewIAppInstalledService().Operate(request.AppInstalledOperate{
InstallId: install.ID,
Operate: constant.Restart,
})
}
return nil
}
func extractConfiguredAgentItem(installDir string, record map[string]interface{}) dto.AgentConfiguredAgentItem {
item := dto.AgentConfiguredAgentItem{Bindings: []dto.AgentRoleBinding{}}
if id, ok := record["id"].(string); ok {
item.ID = id
} else if id, ok := record["agentId"].(string); ok {
item.ID = id
}
if name, ok := record["name"].(string); ok {
item.Name = name
}
if workspace, ok := record["workspace"].(string); ok {
item.Workspace = resolveRoleDir(installDir, workspace)
}
if model, ok := record["model"].(string); ok {
item.Model = model
}
if agentDir, ok := record["agentDir"].(string); ok {
item.AgentDir = agentDir
} else if agentDir, ok := record["agent_dir"].(string); ok {
item.AgentDir = agentDir
}
item.AgentDir = resolveRoleDir(installDir, item.AgentDir)
return item
}
func findConfiguredAgentByID(baseDir string, conf map[string]interface{}, id string) (dto.AgentConfiguredAgentItem, bool) {
agents, ok := conf["agents"].(map[string]interface{})
if !ok {
return dto.AgentConfiguredAgentItem{}, false
}
rawList, ok := agents["list"].([]interface{})
if !ok {
return dto.AgentConfiguredAgentItem{}, false
}
for _, item := range rawList {
record, ok := item.(map[string]interface{})
if !ok {
continue
}
configured := extractConfiguredAgentItem(baseDir, record)
if strings.EqualFold(configured.ID, id) {
return configured, true
}
}
return dto.AgentConfiguredAgentItem{}, false
}
func formatRoleBinding(channel, accountID string) string {
if channel == "" {
return ""
}
if accountID == "" {
return channel
}
return channel + ":" + accountID
}
func applyConfiguredAgentBindings(agents []dto.AgentConfiguredAgentItem, value interface{}) {
bindings, ok := value.([]interface{})
if !ok || len(agents) == 0 {
return
}
indexByID := make(map[string]int, len(agents))
indexByName := make(map[string]int, len(agents))
for i, agent := range agents {
if agent.ID != "" {
indexByID[agent.ID] = i
}
if agent.Name != "" {
indexByName[agent.Name] = i
}
}
for _, binding := range bindings {
record, ok := binding.(map[string]interface{})
if !ok {
continue
}
if bindingType, _ := record["type"].(string); !strings.EqualFold(bindingType, "route") {
continue
}
targetID, _ := record["agentId"].(string)
if targetID == "" {
continue
}
match, ok := record["match"].(map[string]interface{})
if !ok {
continue
}
channel, _ := match["channel"].(string)
if channel == "" {
continue
}
index, ok := indexByID[targetID]
if !ok {
index, ok = indexByName[targetID]
}
if !ok {
continue
}
accountID, _ := match["accountId"].(string)
if accountID == "" {
accountID, _ = record["accountId"].(string)
}
agents[index].Bindings = append(agents[index].Bindings, dto.AgentRoleBinding{
Channel: channel,
AccountID: accountID,
})
}
}
func loadBoundChannelBindings(value interface{}) map[string]map[string]struct{} {
result := make(map[string]map[string]struct{})
bindings, ok := value.([]interface{})
if !ok {
return result
}
for _, binding := range bindings {
record, ok := binding.(map[string]interface{})
if !ok {
continue
}
if bindingType, _ := record["type"].(string); !strings.EqualFold(bindingType, "route") {
continue
}
match, ok := record["match"].(map[string]interface{})
if !ok {
continue
}
channel, _ := match["channel"].(string)
if channel == "" {
continue
}
accountID, _ := match["accountId"].(string)
if accountID == "" {
accountID, _ = record["accountId"].(string)
}
if _, ok := result[channel]; !ok {
result[channel] = make(map[string]struct{})
}
result[channel][accountID] = struct{}{}
}
return result
}
func extractRoleChannelAccountIDs(conf map[string]interface{}, channel string) []string {
switch channel {
case "feishu":
config := extractFeishuConfig(conf)
accountIDs := make([]string, 0, len(config.Bots))
for _, item := range config.Bots {
if accountID := item.AccountID; accountID != "" {
accountIDs = append(accountIDs, accountID)
}
}
sort.Strings(accountIDs)
return accountIDs
case "telegram":
config := extractTelegramConfig(conf)
accountIDs := make([]string, 0, len(config.Bots))
for _, item := range config.Bots {
if accountID := item.AccountID; accountID != "" {
accountIDs = append(accountIDs, accountID)
}
}
sort.Strings(accountIDs)
return accountIDs
case "discord":
config := extractDiscordConfig(conf)
accountIDs := make([]string, 0, len(config.Bots))
for _, item := range config.Bots {
if accountID := item.AccountID; accountID != "" {
accountIDs = append(accountIDs, accountID)
}
}
sort.Strings(accountIDs)
return accountIDs
case "qqbot":
config := extractQQBotConfig(conf)
accountIDs := make([]string, 0, len(config.Bots))
for _, item := range config.Bots {
if accountID := item.AccountID; accountID != "" {
accountIDs = append(accountIDs, accountID)
}
}
sort.Strings(accountIDs)
return accountIDs
case "dingtalk-connector":
config := extractDingTalkConfig(conf)
accountIDs := make([]string, 0, len(config.Bots))
for _, item := range config.Bots {
if accountID := item.AccountID; accountID != "" {
accountIDs = append(accountIDs, accountID)
}
}
sort.Strings(accountIDs)
return accountIDs
case "wecom":
return []string{}
default:
return extractRawChannelAccountIDs(getChannelConfig(conf, channel))
}
}
func filterAvailableChannelAccountIDs(bindings map[string]map[string]struct{}, channel string, accountIDs []string) []string {
channelBindings, ok := bindings[channel]
if !ok || len(channelBindings) == 0 {
return append([]string(nil), accountIDs...)
}
if _, ok := channelBindings[""]; ok {
return []string{}
}
result := make([]string, 0, len(accountIDs))
for _, accountID := range accountIDs {
if _, ok := channelBindings[accountID]; ok {
continue
}
result = append(result, accountID)
}
return result
}
func isRoleChannelFullyBound(bindings map[string]map[string]struct{}, channel string, allAccountIDs, availableAccountIDs []string) bool {
channelBindings, ok := bindings[channel]
if !ok || len(channelBindings) == 0 {
return false
}
if _, ok := channelBindings[""]; ok {
return true
}
if len(allAccountIDs) == 0 {
return true
}
return len(availableAccountIDs) == 0
}
func extractRawChannelAccountIDs(channel map[string]interface{}) []string {
if len(channel) == 0 {
return []string{}
}
accounts, ok := channel["accounts"].(map[string]interface{})
if !ok || len(accounts) == 0 {
return []string{}
}
result := make([]string, 0, len(accounts))
for key := range accounts {
if key == "" {
continue
}
result = append(result, key)
}
sort.Strings(result)
return result
}
func resolveRoleDir(installDir, workspace string) string {
if workspace == "" {
return ""
}
if strings.HasPrefix(workspace, "/home/node/.openclaw/workspace/") {
return strings.ReplaceAll(workspace, "/home/node/.openclaw", installDir)
}
return strings.ReplaceAll(workspace, "/home/node/.openclaw", path.Join(installDir, "conf"))
}
func resolveMarkdownWorkspaceDir(installDir, workspace string) (string, error) {
if workspace == "" {
return "", buserr.New("ErrRecordNotFound")
}
confDir := path.Join(installDir, "conf")
allowedOpenclawPrefixes := []string{
"/home/node/.openclaw/workspace/",
"/home/node/.openclaw/workspace-agent_",
}
for _, prefix := range allowedOpenclawPrefixes {
if strings.HasPrefix(workspace, prefix) {
return resolveRoleDir(installDir, workspace), nil
}
}
cleanWorkspace := path.Clean(workspace)
cleanConfDir := path.Clean(confDir)
if cleanWorkspace == cleanConfDir || strings.HasPrefix(cleanWorkspace, cleanConfDir+"/") {
return cleanWorkspace, nil
}
return "", buserr.New("ErrRecordNotFound")
}
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
package service
import (
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/common"
agentenv "github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/joho/godotenv"
"gopkg.in/yaml.v3"
)
const hermesWorkspaceDir = "/opt/data/workspace"
const hermesExecutablePath = "/opt/hermes/.venv/bin/hermes"
type hermesConfig struct {
Model hermesModelConfig `yaml:"model"`
Terminal hermesTerminalConfig `yaml:"terminal"`
Timezone string `yaml:"timezone,omitempty"`
}
type hermesModelConfig struct {
Default string `yaml:"default"`
Provider string `yaml:"provider"`
BaseURL string `yaml:"base_url,omitempty"`
APIKey string `yaml:"api_key,omitempty"`
}
type hermesTerminalConfig struct {
Backend string `yaml:"backend"`
Cwd string `yaml:"cwd"`
}
type hermesEnvEntry struct {
Key string
Value string
}
func buildHermesDockerExecCommandArgs(containerName, command string, commandArgs ...string) []string {
args := []string{"exec", "-u", "hermes", containerName, command}
return append(args, commandArgs...)
}
func buildHermesDockerExecArgs(containerName string, hermesArgs ...string) []string {
return buildHermesDockerExecCommandArgs(containerName, "hermes", hermesArgs...)
}
func writeHermesConfig(confDir string, account *model.AgentAccount, modelName string, timezone string) error {
if strings.TrimSpace(confDir) == "" {
return fmt.Errorf("config dir is required")
}
if account == nil {
return fmt.Errorf("account is required")
}
if strings.TrimSpace(modelName) == "" {
return fmt.Errorf("model is required")
}
fileOp := files.NewFileOp()
if !fileOp.Stat(confDir) {
if err := fileOp.CreateDir(confDir, constant.DirPerm); err != nil {
return err
}
}
provider := resolveHermesProvider(account.Provider)
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
model := map[string]interface{}{
"default": resolveHermesModel(account.Provider, provider, modelName),
"provider": provider,
"base_url": account.BaseURL,
}
if provider == "custom" && account.APIKey != "" {
model["api_key"] = "${CUSTOM_API_KEY}"
}
cfg["model"] = model
cfg["terminal"] = map[string]interface{}{
"backend": "local",
"cwd": hermesWorkspaceDir,
}
if timezone != "" {
cfg["timezone"] = timezone
} else {
delete(cfg, "timezone")
}
if err := writeHermesConfigMap(configPath, cfg); err != nil {
return err
}
return writeHermesModelEnv(path.Join(confDir, ".env"), account)
}
func prepareHermesInstallFiles(appInstall *model.AppInstall, account *model.AgentAccount, modelName string) error {
if appInstall == nil {
return fmt.Errorf("app install is required")
}
dataDir := path.Join(appInstall.GetPath(), "data")
if err := writeHermesConfig(dataDir, account, modelName, normalizeHermesTimezone(common.LoadTimeZoneByCmd())); err != nil {
return err
}
return files.NewFileOp().ChownR(dataDir, "1000", "1000", true)
}
func readHermesConfig(configPath string) (*hermesConfig, error) {
content, err := files.NewFileOp().GetContent(configPath)
if err != nil {
return nil, err
}
var cfg hermesConfig
if err := yaml.Unmarshal(content, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func readHermesConfigMap(configPath string) (map[string]interface{}, error) {
fileOp := files.NewFileOp()
if !fileOp.Stat(configPath) {
return map[string]interface{}{}, nil
}
content, err := fileOp.GetContent(configPath)
if err != nil {
return nil, err
}
cfg := map[string]interface{}{}
if strings.TrimSpace(string(content)) == "" {
return cfg, nil
}
if err := yaml.Unmarshal(content, &cfg); err != nil {
return nil, err
}
return cfg, nil
}
func writeHermesConfigMap(configPath string, cfg map[string]interface{}) error {
payload, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return files.NewFileOp().SaveFile(configPath, string(payload), 0600)
}
func readHermesTelegramChannelConfig(confDir string) (*dto.AgentTelegramConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
cfg, err := readHermesConfigMap(path.Join(confDir, "config.yaml"))
if err != nil {
return nil, err
}
allowFrom := splitHermesEnvList(envMap["TELEGRAM_ALLOWED_USERS"])
requireMention := extractBoolValue(childMap(cfg, "telegram")["require_mention"], false)
dmPolicy := ""
if extractHermesEnvBool(envMap, "TELEGRAM_ALLOW_ALL_USERS", false) {
dmPolicy = "open"
} else if len(allowFrom) > 0 {
dmPolicy = "allowlist"
} else if envMap["TELEGRAM_BOT_TOKEN"] != "" {
dmPolicy = "pairing"
}
groupPolicy := ""
if requireMention {
groupPolicy = "allowlist"
} else if envMap["TELEGRAM_BOT_TOKEN"] != "" {
groupPolicy = "open"
}
token := envMap["TELEGRAM_BOT_TOKEN"]
result := &dto.AgentTelegramConfig{
Enabled: token != "",
DmPolicy: dmPolicy,
AllowFrom: allowFrom,
RequireMention: requireMention,
GroupPolicy: groupPolicy,
GroupAllowFrom: []string{},
Streaming: "",
DefaultAccount: "",
}
if token != "" {
result.Bots = []dto.AgentTelegramBot{
{
AgentChannelBotBase: dto.AgentChannelBotBase{
AccountID: "default",
Name: "Default",
Enabled: true,
IsDefault: true,
},
BotToken: token,
DmPolicy: dmPolicy,
GroupPolicy: groupPolicy,
Streaming: "",
},
}
}
return result, nil
}
func writeHermesTelegramChannelConfig(confDir string, config dto.AgentTelegramConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
token := firstHermesTelegramBotToken(config.Bots, config.DefaultAccount)
if token != "" {
envMap["TELEGRAM_BOT_TOKEN"] = token
} else {
delete(envMap, "TELEGRAM_BOT_TOKEN")
}
delete(envMap, "TELEGRAM_ALLOWED_USERS")
delete(envMap, "TELEGRAM_ALLOW_ALL_USERS")
switch config.DmPolicy {
case "open":
envMap["TELEGRAM_ALLOW_ALL_USERS"] = "true"
case "allowlist":
if allow := joinHermesEnvList(config.AllowFrom); allow != "" {
envMap["TELEGRAM_ALLOWED_USERS"] = allow
}
}
if err := writeHermesEnvMap(envPath, envMap, []string{
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_ALLOW_ALL_USERS",
}); err != nil {
return err
}
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
telegram := ensureChildMap(cfg, "telegram")
telegram["require_mention"] = config.RequireMention
return writeHermesConfigMap(configPath, cfg)
}
func readHermesDiscordChannelConfig(confDir string) (*dto.AgentDiscordConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
cfg, err := readHermesConfigMap(path.Join(confDir, "config.yaml"))
if err != nil {
return nil, err
}
allowFrom := splitHermesEnvList(envMap["DISCORD_ALLOWED_USERS"])
requireMention := extractBoolValue(childMap(cfg, "discord")["require_mention"], false)
dmPolicy := ""
if extractHermesEnvBool(envMap, "DISCORD_ALLOW_ALL_USERS", false) {
dmPolicy = "open"
} else if len(allowFrom) > 0 {
dmPolicy = "allowlist"
} else if envMap["DISCORD_BOT_TOKEN"] != "" {
dmPolicy = "pairing"
}
groupPolicy := ""
if requireMention {
groupPolicy = "allowlist"
} else if envMap["DISCORD_BOT_TOKEN"] != "" {
groupPolicy = "open"
}
token := envMap["DISCORD_BOT_TOKEN"]
result := &dto.AgentDiscordConfig{
Enabled: token != "",
DmPolicy: dmPolicy,
AllowFrom: allowFrom,
RequireMention: requireMention,
GroupPolicy: groupPolicy,
DefaultAccount: "",
}
if token != "" {
result.Bots = []dto.AgentDiscordBot{
{
AgentChannelBotBase: dto.AgentChannelBotBase{
AccountID: "default",
Name: "Default",
Enabled: true,
IsDefault: true,
},
Token: token,
},
}
}
return result, nil
}
func writeHermesDiscordChannelConfig(confDir string, config dto.AgentDiscordConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
token := firstHermesDiscordBotToken(config.Bots, config.DefaultAccount)
if token != "" {
envMap["DISCORD_BOT_TOKEN"] = token
} else {
delete(envMap, "DISCORD_BOT_TOKEN")
}
delete(envMap, "DISCORD_ALLOWED_USERS")
delete(envMap, "DISCORD_ALLOW_ALL_USERS")
switch config.DmPolicy {
case "open":
envMap["DISCORD_ALLOW_ALL_USERS"] = "true"
case "allowlist":
if allow := joinHermesEnvList(config.AllowFrom); allow != "" {
envMap["DISCORD_ALLOWED_USERS"] = allow
}
}
if err := writeHermesEnvMap(envPath, envMap, []string{
"DISCORD_BOT_TOKEN",
"DISCORD_ALLOWED_USERS",
"DISCORD_ALLOW_ALL_USERS",
}); err != nil {
return err
}
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
discord := ensureChildMap(cfg, "discord")
discord["require_mention"] = config.RequireMention
return writeHermesConfigMap(configPath, cfg)
}
func deleteHermesEnvKeys(confDir string, keys ...string) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
for _, key := range keys {
delete(envMap, key)
}
return writeHermesEnvMap(envPath, envMap, keys)
}
func deleteHermesConfigSections(confDir string, topLevelKeys []string, platformKeys []string) error {
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
for _, key := range topLevelKeys {
delete(cfg, key)
}
if len(platformKeys) > 0 {
if platforms, ok := cfg["platforms"].(map[string]interface{}); ok {
for _, key := range platformKeys {
delete(platforms, key)
}
if len(platforms) == 0 {
delete(cfg, "platforms")
}
}
}
return writeHermesConfigMap(configPath, cfg)
}
func deleteHermesTelegramChannelConfig(confDir string) error {
if err := deleteHermesEnvKeys(confDir,
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_ALLOW_ALL_USERS",
"TELEGRAM_HOME_CHANNEL",
); err != nil {
return err
}
return deleteHermesConfigSections(confDir, []string{"telegram"}, []string{"telegram"})
}
func deleteHermesDiscordChannelConfig(confDir string) error {
if err := deleteHermesEnvKeys(confDir,
"DISCORD_BOT_TOKEN",
"DISCORD_ALLOWED_USERS",
"DISCORD_ALLOW_ALL_USERS",
"DISCORD_HOME_CHANNEL",
); err != nil {
return err
}
return deleteHermesConfigSections(confDir, []string{"discord"}, []string{"discord"})
}
func normalizeHermesTimezone(timezone string) string {
timezone = strings.TrimSpace(timezone)
if timezone == "" {
return ""
}
if _, err := time.LoadLocation(timezone); err != nil {
return ""
}
return timezone
}
func resolveHermesProvider(provider string) string {
switch provider {
case "":
return "custom"
case "openrouter", "anthropic", "gemini", "zai", "kimi-coding", "xiaomi":
return provider
case "minimax":
return "minimax-cn"
default:
return "custom"
}
}
func resolveHermesModel(sourceProvider, targetProvider, modelName string) string {
target := strings.TrimSpace(modelName)
if target == "" {
return ""
}
if targetProvider != "custom" {
return target
}
if sourceProvider == "custom" || sourceProvider == "vllm" {
return normalizeCustomModel(target)
}
if strings.Contains(target, "/") {
parts := strings.SplitN(target, "/", 2)
model := strings.TrimSpace(parts[1])
if model != "" {
return model
}
}
return target
}
func resolveHermesConfiguredModelID(account *model.AgentAccount, accountModels []dto.AgentAccountModel, configuredModel string) (string, error) {
if account == nil {
return "", buserr.New("ErrAgentModelNotInAccount")
}
configuredModel = strings.TrimSpace(configuredModel)
if configuredModel == "" {
return "", buserr.New("ErrAgentModelNotInAccount")
}
provider := resolveHermesProvider(account.Provider)
for _, item := range accountModels {
if resolveHermesModel(account.Provider, provider, item.ID) == configuredModel {
return item.ID, nil
}
}
return "", buserr.New("ErrAgentModelNotInAccount")
}
func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
if account == nil {
return nil
}
if resolveHermesProvider(account.Provider) == "custom" {
if account.APIKey == "" {
return nil
}
return []hermesEnvEntry{
{Key: "CUSTOM_API_KEY", Value: account.APIKey},
}
}
apiKey := account.APIKey
baseURL := account.BaseURL
entries := make([]hermesEnvEntry, 0, 4)
appendEntry := func(key, value string) {
if key == "" || value == "" {
return
}
entries = append(entries, hermesEnvEntry{Key: key, Value: value})
}
switch account.Provider {
case "openrouter":
appendEntry("OPENROUTER_API_KEY", apiKey)
appendEntry("OPENROUTER_BASE_URL", baseURL)
case "anthropic":
appendEntry("ANTHROPIC_API_KEY", apiKey)
case "gemini":
appendEntry("GOOGLE_API_KEY", apiKey)
appendEntry("GEMINI_API_KEY", apiKey)
appendEntry("GEMINI_BASE_URL", baseURL)
case "zai":
appendEntry("GLM_API_KEY", apiKey)
appendEntry("ZAI_API_KEY", apiKey)
appendEntry("Z_AI_API_KEY", apiKey)
appendEntry("GLM_BASE_URL", baseURL)
case "kimi-coding", "moonshot":
appendEntry("KIMI_API_KEY", apiKey)
appendEntry("KIMI_BASE_URL", baseURL)
case "kimi":
appendEntry("KIMI_CN_API_KEY", apiKey)
appendEntry("KIMI_BASE_URL", baseURL)
case "minimax":
appendEntry("MINIMAX_CN_API_KEY", apiKey)
appendEntry("MINIMAX_CN_BASE_URL", baseURL)
case "xiaomi":
appendEntry("XIAOMI_API_KEY", apiKey)
appendEntry("XIAOMI_BASE_URL", baseURL)
case "deepseek":
appendEntry("DEEPSEEK_API_KEY", apiKey)
appendEntry("DEEPSEEK_BASE_URL", baseURL)
case "bailian-coding-plan":
appendEntry("DASHSCOPE_API_KEY", apiKey)
appendEntry("DASHSCOPE_BASE_URL", baseURL)
case "openai":
appendEntry("OPENAI_API_KEY", apiKey)
appendEntry("OPENAI_BASE_URL", baseURL)
case "custom", "vllm":
if account.APIType == "openai-completions" || account.APIType == "openai-responses" {
appendEntry("OPENAI_API_KEY", apiKey)
appendEntry("OPENAI_BASE_URL", baseURL)
}
default:
if envKey := providercatalog.EnvKey(account.Provider); envKey != "" {
appendEntry(envKey, apiKey)
}
}
return entries
}
func writeHermesModelEnv(envPath string, account *model.AgentAccount) error {
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
for _, key := range hermesManagedModelEnvKeys() {
delete(envMap, key)
}
entries := resolveHermesEnvEntries(account)
order := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.Key == "" || entry.Value == "" {
continue
}
envMap[entry.Key] = entry.Value
order = append(order, entry.Key)
}
return writeHermesEnvMap(envPath, envMap, order)
}
func hermesManagedModelEnvKeys() []string {
keys := []string{
"OPENROUTER_API_KEY",
"OPENROUTER_BASE_URL",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"GEMINI_BASE_URL",
"GLM_API_KEY",
"ZAI_API_KEY",
"Z_AI_API_KEY",
"GLM_BASE_URL",
"KIMI_API_KEY",
"KIMI_CN_API_KEY",
"KIMI_BASE_URL",
"MINIMAX_CN_API_KEY",
"MINIMAX_CN_BASE_URL",
"XIAOMI_API_KEY",
"XIAOMI_BASE_URL",
"DEEPSEEK_API_KEY",
"DEEPSEEK_BASE_URL",
"DASHSCOPE_API_KEY",
"DASHSCOPE_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
}
seen := make(map[string]struct{}, len(keys))
result := make([]string, 0, len(keys))
appendKey := func(key string) {
if key == "" {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
result = append(result, key)
}
for _, key := range keys {
appendKey(key)
}
for _, meta := range providercatalog.All() {
appendKey(meta.EnvKey)
}
return result
}
func writeHermesEnv(envPath string, entries []hermesEnvEntry) error {
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
for _, entry := range entries {
if entry.Key == "" || entry.Value == "" {
continue
}
envMap[entry.Key] = entry.Value
}
order := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.Key == "" {
continue
}
order = append(order, entry.Key)
}
return writeHermesEnvMap(envPath, envMap, order)
}
func readHermesEnvMap(envPath string) (map[string]string, error) {
fileOp := files.NewFileOp()
if !fileOp.Stat(envPath) {
return map[string]string{}, nil
}
envMap, err := godotenv.Read(envPath)
if err != nil {
return nil, err
}
return envMap, nil
}
func writeHermesEnvMap(envPath string, envMap map[string]string, order []string) error {
if len(envMap) == 0 {
return files.NewFileOp().SaveFile(envPath, "", 0600)
}
return agentenv.WriteWithOrder(envMap, envPath, order)
}
func splitHermesEnvList(value string) []string {
if value == "" {
return []string{}
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
return result
}
func joinHermesEnvList(values []string) string {
if len(values) == 0 {
return ""
}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
item := strings.TrimSpace(value)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
return strings.Join(result, ",")
}
func firstHermesTelegramBotToken(bots []dto.AgentTelegramBot, defaultAccount string) string {
for _, bot := range bots {
if bot.AccountID == defaultAccount || bot.IsDefault {
return bot.BotToken
}
}
if len(bots) == 0 {
return ""
}
return bots[0].BotToken
}
func firstHermesDiscordBotToken(bots []dto.AgentDiscordBot, defaultAccount string) string {
for _, bot := range bots {
if bot.AccountID == defaultAccount || bot.IsDefault {
return bot.Token
}
}
if len(bots) == 0 {
return ""
}
return bots[0].Token
}
func extractHermesEnvBool(envMap map[string]string, key string, defaultValue bool) bool {
value, ok := envMap[key]
if !ok || value == "" {
return defaultValue
}
return strings.EqualFold(value, "true")
}
func validateHermesPairingApproveResult(output string, err error) error {
if strings.Contains(output, "Approved!") {
return nil
}
if strings.Contains(output, "not found or expired for platform") {
return buserr.New("ErrHermesPairingCodeUnavailable")
}
if err == nil {
if strings.TrimSpace(output) == "" {
return fmt.Errorf("unexpected hermes pairing approve result")
}
return errors.New(strings.TrimSpace(output))
}
if strings.Contains(err.Error(), "not found or expired for platform") {
return buserr.New("ErrHermesPairingCodeUnavailable")
}
return err
}
+591
View File
@@ -0,0 +1,591 @@
package service
import (
"path"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/buserr"
)
const hermesWeixinLoginScript = `import asyncio
from gateway.platforms.weixin import qr_login
from hermes_cli.config import save_env_value
creds = asyncio.run(qr_login('/opt/data'))
print('RESULT =', creds)
if not creds:
raise SystemExit(1)
save_env_value('WEIXIN_ACCOUNT_ID', creds.get('account_id', ''))
save_env_value('WEIXIN_TOKEN', creds.get('token', ''))
if creds.get('base_url'):
save_env_value('WEIXIN_BASE_URL', creds['base_url'])
save_env_value('WEIXIN_CDN_BASE_URL', 'https://novac2c.cdn.weixin.qq.com/c2c')
save_env_value('WEIXIN_DM_POLICY', 'open')
save_env_value('WEIXIN_ALLOW_ALL_USERS', 'true')
save_env_value('WEIXIN_ALLOWED_USERS', '')
save_env_value('WEIXIN_GROUP_POLICY', 'disabled')
save_env_value('WEIXIN_GROUP_ALLOWED_USERS', '')
save_env_value('WEIXIN_HOME_CHANNEL', creds.get('user_id', ''))
print('WEIXIN ENV WRITTEN')
print('Restart the Hermes-Agent container to apply the new Weixin settings.')
`
func readHermesQQBotChannelConfig(confDir string) (*dto.AgentQQBotConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
cfg, err := readHermesConfigMap(path.Join(confDir, "config.yaml"))
if err != nil {
return nil, err
}
platform := childMap(childMap(cfg, "platforms"), "qq")
extra := childMap(platform, "extra")
appID := envMap["QQ_APP_ID"]
clientSecret := envMap["QQ_CLIENT_SECRET"]
dmPolicy := extractStringValue(extra["dm_policy"])
allowFrom := splitHermesEnvList(envMap["QQ_ALLOWED_USERS"])
groupPolicy := extractStringValue(extra["group_policy"])
groupAllowFrom := splitHermesEnvList(envMap["QQ_GROUP_ALLOWED_USERS"])
result := &dto.AgentQQBotConfig{
Enabled: extractBoolValue(platform["enabled"], false) && appID != "" && clientSecret != "",
DmPolicy: dmPolicy,
AllowFrom: allowFrom,
GroupPolicy: groupPolicy,
GroupAllowFrom: groupAllowFrom,
}
if appID != "" || clientSecret != "" {
result.Bots = []dto.AgentQQBotBot{
{
AgentChannelBotBase: dto.AgentChannelBotBase{
AccountID: "default",
Name: "Default",
Enabled: true,
IsDefault: true,
},
AppID: appID,
ClientSecret: clientSecret,
},
}
}
return result, nil
}
func writeHermesQQBotChannelConfig(confDir string, config dto.AgentQQBotConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
defaultBot := getDefaultQQBot(config.Bots)
envMap["QQ_APP_ID"] = defaultBot.AppID
envMap["QQ_CLIENT_SECRET"] = defaultBot.ClientSecret
delete(envMap, "QQ_ALLOWED_USERS")
delete(envMap, "QQ_GROUP_ALLOWED_USERS")
delete(envMap, "QQ_ALLOW_ALL_USERS")
delete(envMap, "QQ_MARKDOWN_SUPPORT")
if config.DmPolicy == "open" {
envMap["QQ_ALLOW_ALL_USERS"] = "true"
} else if config.DmPolicy == "allowlist" {
if allow := joinHermesEnvList(config.AllowFrom); allow != "" {
envMap["QQ_ALLOWED_USERS"] = allow
}
}
if config.GroupPolicy == "allowlist" {
if allow := joinHermesEnvList(config.GroupAllowFrom); allow != "" {
envMap["QQ_GROUP_ALLOWED_USERS"] = allow
}
}
if err := writeHermesEnvMap(envPath, envMap, []string{
"QQ_APP_ID",
"QQ_CLIENT_SECRET",
"QQ_ALLOW_ALL_USERS",
"QQ_ALLOWED_USERS",
"QQ_GROUP_ALLOWED_USERS",
"QQ_HOME_CHANNEL",
"QQ_HOME_CHANNEL_NAME",
"QQ_STT_API_KEY",
"QQ_STT_BASE_URL",
"QQ_STT_MODEL",
}); err != nil {
return err
}
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
platform := ensureChildMap(ensureChildMap(cfg, "platforms"), "qq")
platform["enabled"] = config.Enabled && defaultBot.AppID != "" && defaultBot.ClientSecret != ""
extra := ensureChildMap(platform, "extra")
extra["markdown_support"] = true
extra["dm_policy"] = config.DmPolicy
extra["group_policy"] = config.GroupPolicy
delete(extra, "app_id")
delete(extra, "client_secret")
if config.DmPolicy == "allowlist" {
extra["allow_from"] = append([]string(nil), config.AllowFrom...)
} else {
delete(extra, "allow_from")
}
if config.GroupPolicy == "allowlist" {
extra["group_allow_from"] = append([]string(nil), config.GroupAllowFrom...)
} else {
delete(extra, "group_allow_from")
}
return writeHermesConfigMap(configPath, cfg)
}
func deleteHermesQQBotChannelConfig(confDir string) error {
if err := deleteHermesEnvKeys(confDir,
"QQ_APP_ID",
"QQ_CLIENT_SECRET",
"QQ_ALLOW_ALL_USERS",
"QQ_ALLOWED_USERS",
"QQ_GROUP_ALLOWED_USERS",
"QQ_HOME_CHANNEL",
"QQ_HOME_CHANNEL_NAME",
"QQ_STT_API_KEY",
"QQ_STT_BASE_URL",
"QQ_STT_MODEL",
); err != nil {
return err
}
return deleteHermesConfigSections(confDir, nil, []string{"qq"})
}
func readHermesWecomChannelConfig(confDir string) (*dto.AgentWecomConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
cfg, err := readHermesConfigMap(path.Join(confDir, "config.yaml"))
if err != nil {
return nil, err
}
platform := childMap(childMap(cfg, "platforms"), "wecom")
allowFrom := splitHermesEnvList(envMap["WECOM_ALLOWED_USERS"])
groupAllowFrom := splitHermesEnvList(envMap["WECOM_GROUP_ALLOWED_USERS"])
dmPolicy := envMap["WECOM_DM_POLICY"]
groupPolicy := envMap["WECOM_GROUP_POLICY"]
botID := envMap["WECOM_BOT_ID"]
secret := envMap["WECOM_SECRET"]
return &dto.AgentWecomConfig{
Enabled: extractBoolValue(platform["enabled"], false) && botID != "" && secret != "",
DmPolicy: dmPolicy,
AllowFrom: allowFrom,
GroupPolicy: groupPolicy,
GroupAllowFrom: groupAllowFrom,
BotID: botID,
Secret: secret,
}, nil
}
func writeHermesWecomChannelConfig(confDir string, config dto.AgentWecomConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
if config.BotID != "" {
envMap["WECOM_BOT_ID"] = config.BotID
} else {
delete(envMap, "WECOM_BOT_ID")
}
if config.Secret != "" {
envMap["WECOM_SECRET"] = config.Secret
} else {
delete(envMap, "WECOM_SECRET")
}
delete(envMap, "WECOM_ALLOWED_USERS")
delete(envMap, "WECOM_GROUP_ALLOWED_USERS")
delete(envMap, "WECOM_ALLOW_ALL_USERS")
delete(envMap, "WECOM_DM_POLICY")
delete(envMap, "WECOM_GROUP_POLICY")
if config.DmPolicy != "" {
envMap["WECOM_DM_POLICY"] = config.DmPolicy
}
if config.GroupPolicy != "" {
envMap["WECOM_GROUP_POLICY"] = config.GroupPolicy
}
if config.DmPolicy == "allowlist" {
if allow := joinHermesEnvList(config.AllowFrom); allow != "" {
envMap["WECOM_ALLOWED_USERS"] = allow
}
}
if config.GroupPolicy == "allowlist" {
if allow := joinHermesEnvList(config.GroupAllowFrom); allow != "" {
envMap["WECOM_GROUP_ALLOWED_USERS"] = allow
}
}
if err := writeHermesEnvMap(envPath, envMap, []string{
"WECOM_BOT_ID",
"WECOM_SECRET",
"WECOM_DM_POLICY",
"WECOM_ALLOWED_USERS",
"WECOM_GROUP_POLICY",
"WECOM_GROUP_ALLOWED_USERS",
}); err != nil {
return err
}
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
platform := ensureChildMap(ensureChildMap(cfg, "platforms"), "wecom")
platform["enabled"] = config.Enabled && config.BotID != "" && config.Secret != ""
extra := ensureChildMap(platform, "extra")
extra["dm_policy"] = config.DmPolicy
extra["group_policy"] = config.GroupPolicy
delete(extra, "bot_id")
delete(extra, "secret")
if config.DmPolicy == "allowlist" {
extra["allow_from"] = append([]string(nil), config.AllowFrom...)
} else {
delete(extra, "allow_from")
}
if config.GroupPolicy == "allowlist" {
extra["group_allow_from"] = append([]string(nil), config.GroupAllowFrom...)
} else {
delete(extra, "group_allow_from")
}
return writeHermesConfigMap(configPath, cfg)
}
func deleteHermesWecomChannelConfig(confDir string) error {
if err := deleteHermesEnvKeys(confDir,
"WECOM_BOT_ID",
"WECOM_SECRET",
"WECOM_ALLOW_ALL_USERS",
"WECOM_DM_POLICY",
"WECOM_ALLOWED_USERS",
"WECOM_GROUP_POLICY",
"WECOM_GROUP_ALLOWED_USERS",
"WECOM_HOME_CHANNEL",
); err != nil {
return err
}
return deleteHermesConfigSections(confDir, nil, []string{"wecom"})
}
func readHermesDingTalkChannelConfig(confDir string) (*dto.AgentDingTalkConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
cfg, err := readHermesConfigMap(path.Join(confDir, "config.yaml"))
if err != nil {
return nil, err
}
platform := childMap(childMap(cfg, "platforms"), "dingtalk")
extra := childMap(platform, "extra")
clientID := envMap["DINGTALK_CLIENT_ID"]
clientSecret := envMap["DINGTALK_CLIENT_SECRET"]
allowFrom := splitHermesEnvList(envMap["DINGTALK_ALLOWED_USERS"])
dmPolicy := ""
if extractHermesEnvBool(envMap, "DINGTALK_ALLOW_ALL_USERS", false) {
dmPolicy = "open"
} else if len(allowFrom) > 0 {
dmPolicy = "allowlist"
} else if extractStringValue(extra["unauthorized_dm_behavior"]) == "ignore" {
dmPolicy = "disabled"
} else if clientID != "" || clientSecret != "" {
dmPolicy = "pairing"
}
result := &dto.AgentDingTalkConfig{
Enabled: extractBoolValue(platform["enabled"], clientID != "" && clientSecret != ""),
DmPolicy: dmPolicy,
AllowFrom: allowFrom,
GroupPolicy: "",
GroupAllowFrom: []string{},
}
if clientID != "" || clientSecret != "" {
result.Bots = []dto.AgentDingTalkBot{
{
AgentChannelBotBase: dto.AgentChannelBotBase{
AccountID: "default",
Name: "Default",
Enabled: true,
IsDefault: true,
},
ClientID: clientID,
ClientSecret: clientSecret,
},
}
}
return result, nil
}
func writeHermesDingTalkChannelConfig(confDir string, config dto.AgentDingTalkConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
clientID, clientSecret := firstHermesDingTalkBotCredentials(config.Bots)
if config.Enabled && clientID != "" {
envMap["DINGTALK_CLIENT_ID"] = clientID
} else {
delete(envMap, "DINGTALK_CLIENT_ID")
}
if config.Enabled && clientSecret != "" {
envMap["DINGTALK_CLIENT_SECRET"] = clientSecret
} else {
delete(envMap, "DINGTALK_CLIENT_SECRET")
}
delete(envMap, "DINGTALK_ALLOWED_USERS")
delete(envMap, "DINGTALK_ALLOW_ALL_USERS")
if config.DmPolicy == "allowlist" {
if allow := joinHermesEnvList(config.AllowFrom); allow != "" {
envMap["DINGTALK_ALLOWED_USERS"] = allow
}
} else if config.DmPolicy == "open" {
envMap["DINGTALK_ALLOW_ALL_USERS"] = "true"
}
if err := writeHermesEnvMap(envPath, envMap, []string{
"DINGTALK_CLIENT_ID",
"DINGTALK_CLIENT_SECRET",
"DINGTALK_ALLOW_ALL_USERS",
"DINGTALK_ALLOWED_USERS",
}); err != nil {
return err
}
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
platform := ensureChildMap(ensureChildMap(cfg, "platforms"), "dingtalk")
platform["enabled"] = config.Enabled && clientID != "" && clientSecret != ""
extra := ensureChildMap(platform, "extra")
switch config.DmPolicy {
case "pairing":
extra["unauthorized_dm_behavior"] = "pair"
case "disabled":
extra["unauthorized_dm_behavior"] = "ignore"
default:
delete(extra, "unauthorized_dm_behavior")
}
return writeHermesConfigMap(configPath, cfg)
}
func deleteHermesDingTalkChannelConfig(confDir string) error {
if err := deleteHermesEnvKeys(confDir,
"DINGTALK_CLIENT_ID",
"DINGTALK_CLIENT_SECRET",
"DINGTALK_ALLOW_ALL_USERS",
"DINGTALK_ALLOWED_USERS",
"DINGTALK_HOME_CHANNEL",
); err != nil {
return err
}
return deleteHermesConfigSections(confDir, nil, []string{"dingtalk"})
}
func readHermesFeishuChannelConfig(confDir string) (*dto.AgentFeishuConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
cfg, err := readHermesConfigMap(path.Join(confDir, "config.yaml"))
if err != nil {
return nil, err
}
platform := childMap(childMap(cfg, "platforms"), "feishu")
appID := envMap["FEISHU_APP_ID"]
appSecret := envMap["FEISHU_APP_SECRET"]
allowFrom := splitHermesEnvList(envMap["FEISHU_ALLOWED_USERS"])
dmPolicy := ""
if extractHermesEnvBool(envMap, "FEISHU_ALLOW_ALL_USERS", false) {
dmPolicy = "open"
} else if len(allowFrom) > 0 {
dmPolicy = "allowlist"
} else if appID != "" || appSecret != "" {
dmPolicy = "pairing"
}
groupPolicy := envMap["FEISHU_GROUP_POLICY"]
result := &dto.AgentFeishuConfig{
Enabled: extractBoolValue(platform["enabled"], appID != "" && appSecret != ""),
ThreadSession: false,
ReplyMode: "",
Streaming: false,
RequireMention: "",
GroupPolicy: groupPolicy,
GroupAllowFrom: []string{},
Domain: envMap["FEISHU_DOMAIN"],
ConnectionMode: envMap["FEISHU_CONNECTION_MODE"],
}
if appID != "" || appSecret != "" {
result.Bots = []dto.AgentFeishuBot{
{
AgentChannelBotBase: dto.AgentChannelBotBase{
AccountID: "default",
Name: "Default",
Enabled: true,
IsDefault: true,
},
AppID: appID,
AppSecret: appSecret,
DmPolicy: dmPolicy,
AllowFrom: allowFrom,
},
}
}
return result, nil
}
func writeHermesFeishuChannelConfig(confDir string, config dto.AgentFeishuConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
bot := firstHermesFeishuBot(config.Bots)
if config.GroupPolicy == "allowlist" && bot.DmPolicy == "pairing" {
return buserr.New("ErrHermesFeishuGroupAllowlistRequiresAllowlist")
}
if bot.AppID != "" {
envMap["FEISHU_APP_ID"] = bot.AppID
} else {
delete(envMap, "FEISHU_APP_ID")
}
if bot.AppSecret != "" {
envMap["FEISHU_APP_SECRET"] = bot.AppSecret
} else {
delete(envMap, "FEISHU_APP_SECRET")
}
envMap["FEISHU_DOMAIN"] = "feishu"
envMap["FEISHU_CONNECTION_MODE"] = "websocket"
envMap["FEISHU_GROUP_POLICY"] = config.GroupPolicy
delete(envMap, "FEISHU_ALLOW_ALL_USERS")
delete(envMap, "FEISHU_ALLOWED_USERS")
switch bot.DmPolicy {
case "open":
envMap["FEISHU_ALLOW_ALL_USERS"] = "true"
case "allowlist":
if allow := joinHermesEnvList(bot.AllowFrom); allow != "" {
envMap["FEISHU_ALLOWED_USERS"] = allow
}
}
if config.GroupPolicy == "allowlist" {
if allow := joinHermesEnvList(bot.AllowFrom); allow != "" {
envMap["FEISHU_ALLOWED_USERS"] = allow
}
}
if err := writeHermesEnvMap(envPath, envMap, []string{
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_DOMAIN",
"FEISHU_CONNECTION_MODE",
"FEISHU_ALLOW_ALL_USERS",
"FEISHU_ALLOWED_USERS",
"FEISHU_GROUP_POLICY",
}); err != nil {
return err
}
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
platform := ensureChildMap(ensureChildMap(cfg, "platforms"), "feishu")
platform["enabled"] = config.Enabled && bot.AppID != "" && bot.AppSecret != ""
return writeHermesConfigMap(configPath, cfg)
}
func deleteHermesFeishuChannelConfig(confDir string) error {
if err := deleteHermesEnvKeys(confDir,
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_DOMAIN",
"FEISHU_CONNECTION_MODE",
"FEISHU_ALLOW_ALL_USERS",
"FEISHU_ALLOWED_USERS",
"FEISHU_GROUP_POLICY",
"FEISHU_HOME_CHANNEL",
"FEISHU_VERIFICATION_TOKEN",
"FEISHU_ENCRYPT_KEY",
); err != nil {
return err
}
return deleteHermesConfigSections(confDir, nil, []string{"feishu"})
}
func readHermesWeixinChannelConfig(confDir string) (*dto.AgentWeixinConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
return &dto.AgentWeixinConfig{
Enabled: envMap["WEIXIN_ACCOUNT_ID"] != "" || envMap["WEIXIN_TOKEN"] != "",
}, nil
}
func deleteHermesWeixinChannelConfig(confDir string) error {
if err := deleteHermesEnvKeys(confDir,
"WEIXIN_ACCOUNT_ID",
"WEIXIN_TOKEN",
"WEIXIN_BASE_URL",
"WEIXIN_CDN_BASE_URL",
"WEIXIN_DM_POLICY",
"WEIXIN_ALLOW_ALL_USERS",
"WEIXIN_ALLOWED_USERS",
"WEIXIN_GROUP_POLICY",
"WEIXIN_GROUP_ALLOWED_USERS",
"WEIXIN_HOME_CHANNEL",
); err != nil {
return err
}
return deleteHermesConfigSections(confDir, nil, []string{"weixin"})
}
func firstHermesDingTalkBotCredentials(bots []dto.AgentDingTalkBot) (string, string) {
for _, bot := range bots {
if bot.IsDefault || bot.AccountID == "default" {
return bot.ClientID, bot.ClientSecret
}
}
if len(bots) == 0 {
return "", ""
}
return bots[0].ClientID, bots[0].ClientSecret
}
func firstHermesFeishuBot(bots []dto.AgentFeishuBot) dto.AgentFeishuBot {
for _, bot := range bots {
if bot.IsDefault || bot.AccountID == "default" {
return bot
}
}
if len(bots) == 0 {
return dto.AgentFeishuBot{}
}
return bots[0]
}
func buildHermesWeixinLoginArgs(containerName string) []string {
return buildHermesDockerExecCommandArgs(
containerName,
"/opt/hermes/.venv/bin/python",
"-u",
"-c",
hermesWeixinLoginScript,
)
}
+126
View File
@@ -0,0 +1,126 @@
package service
import (
"database/sql"
"fmt"
"math"
"path/filepath"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/files"
)
const hermesSessionListLimit = 100
func (a AgentService) GetHermesChatSessions(req dto.AgentIDReq) ([]dto.AgentHermesChatSessionItem, error) {
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if agent.AgentType != constant.AppHermesAgent {
return nil, fmt.Errorf("%s does not support", agent.AgentType)
}
return listHermesChatSessionsFromStateDB(filepath.Join(install.GetPath(), "data", "state.db"))
}
func (a AgentService) RenameHermesChatSession(req dto.AgentHermesChatSessionRenameReq) error {
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if agent.AgentType != constant.AppHermesAgent {
return fmt.Errorf("%s does not support", agent.AgentType)
}
_, err = cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout(
"docker",
buildHermesDockerExecArgs(install.ContainerName, "sessions", "rename", req.ID, req.Title)...,
)
return err
}
func (a AgentService) DeleteHermesChatSession(req dto.AgentHermesChatSessionDeleteReq) error {
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if agent.AgentType != constant.AppHermesAgent {
return fmt.Errorf("%s does not support", agent.AgentType)
}
_, err = cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout(
"docker",
buildHermesDockerExecArgs(install.ContainerName, "sessions", "delete", req.ID, "--yes")...,
)
return err
}
func listHermesChatSessionsFromStateDB(stateDBPath string) ([]dto.AgentHermesChatSessionItem, error) {
if !files.NewFileOp().Stat(stateDBPath) {
return []dto.AgentHermesChatSessionItem{}, nil
}
db, err := sql.Open("sqlite", stateDBPath)
if err != nil {
return nil, err
}
defer db.Close()
rows, err := db.Query(`
SELECT
s.id,
COALESCE(NULLIF(TRIM(s.title), ''), s.id) AS title,
COALESCE(s.model, '') AS model,
COALESCE(s.message_count, 0) AS message_count,
s.started_at,
COALESCE(MAX(m.timestamp), s.started_at) AS last_active
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE s.source = 'cli'
GROUP BY s.id, title, model, s.message_count, s.started_at
ORDER BY last_active DESC
LIMIT ?
`, hermesSessionListLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]dto.AgentHermesChatSessionItem, 0, 8)
for rows.Next() {
var item dto.AgentHermesChatSessionItem
var title sql.NullString
var model sql.NullString
var startedAt sql.NullFloat64
var lastActive sql.NullFloat64
if err := rows.Scan(&item.ID, &title, &model, &item.MessageCount, &startedAt, &lastActive); err != nil {
return nil, err
}
item.Title = strings.TrimSpace(title.String)
if item.Title == "" {
item.Title = item.ID
}
item.Model = strings.TrimSpace(model.String)
item.StartedAt = formatHermesSessionTimestamp(startedAt)
item.LastActive = formatHermesSessionTimestamp(lastActive)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func formatHermesSessionTimestamp(value sql.NullFloat64) string {
if !value.Valid || value.Float64 <= 0 {
return ""
}
seconds, fraction := math.Modf(value.Float64)
return time.Unix(int64(seconds), int64(fraction*float64(time.Second))).UTC().Format(time.RFC3339)
}
+215
View File
@@ -0,0 +1,215 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
type hermesSkillsListEntry struct {
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
}
type hermesSkillsListPayload struct {
Skills []hermesSkillsListEntry `json:"skills"`
}
func listHermesSkills(containerName string) ([]dto.AgentSkillItem, error) {
output, err := runHermesSkillsCommandWithStdout(2*time.Minute, containerName, "list", "--source", "all")
if err != nil {
return nil, err
}
items, err := parseHermesSkillsListOutput(output)
if err != nil {
return nil, err
}
metadata, err := readHermesSkillsListMetadata(containerName)
if err != nil {
return nil, err
}
return mergeHermesSkillsWithMetadata(items, metadata), nil
}
func searchHermesSkills(containerName, source, keyword string) ([]dto.AgentSkillSearchItem, error) {
if source != "official" && source != "skills-sh" {
return nil, fmt.Errorf("unsupported hermes skill source: %s", source)
}
output, err := runHermesSkillsCommandWithStdout(
2*time.Minute,
containerName,
"search",
keyword,
"--source",
source,
"--limit",
"20",
)
if err != nil {
return nil, err
}
return parseHermesSkillSearchOutput(output)
}
func runHermesSkillsCommandWithStdout(timeout time.Duration, containerName string, hermesArgs ...string) (string, error) {
args := []string{"exec", "-e", "COLUMNS=240", "-u", "hermes", containerName, "hermes", "skills"}
args = append(args, hermesArgs...)
return cmd.NewCommandMgr(cmd.WithTimeout(timeout)).RunWithStdout("docker", args...)
}
func readHermesSkillsListMetadata(containerName string) (map[string]hermesSkillsListEntry, error) {
output, err := cmd.NewCommandMgr(cmd.WithTimeout(2*time.Minute)).RunWithStdout(
"docker",
"exec",
"-u",
"hermes",
containerName,
"python",
"-c",
"import sys; sys.path.insert(0, '/opt/hermes'); from tools.skills_tool import skills_list; print(skills_list())",
)
if err != nil {
return nil, err
}
return parseHermesSkillsListMetadataOutput(output)
}
func parseHermesSkillsListOutput(output string) ([]dto.AgentSkillItem, error) {
headers, rows, err := parseHermesTableOutput(output)
if err != nil {
return nil, err
}
items := make([]dto.AgentSkillItem, 0, len(rows))
for _, row := range rows {
item := dto.AgentSkillItem{
Name: row[headers["Name"]],
Category: row[headers["Category"]],
Source: row[headers["Source"]],
Trust: row[headers["Trust"]],
Uninstallable: row[headers["Source"]] != "builtin" && row[headers["Source"]] != "local",
}
items = append(items, item)
}
return items, nil
}
func parseHermesSkillsListMetadataOutput(output string) (map[string]hermesSkillsListEntry, error) {
if strings.TrimSpace(output) == "" {
return map[string]hermesSkillsListEntry{}, nil
}
var payload hermesSkillsListPayload
if err := json.Unmarshal([]byte(output), &payload); err != nil {
return nil, err
}
metadata := make(map[string]hermesSkillsListEntry, len(payload.Skills))
for _, skill := range payload.Skills {
if skill.Name == "" {
continue
}
metadata[skill.Name] = skill
}
return metadata, nil
}
func mergeHermesSkillsWithMetadata(items []dto.AgentSkillItem, metadata map[string]hermesSkillsListEntry) []dto.AgentSkillItem {
for i := range items {
entry, ok := metadata[items[i].Name]
if !ok {
continue
}
items[i].Description = entry.Description
if items[i].Category == "" && entry.Category != "" {
items[i].Category = entry.Category
}
}
return items
}
func parseHermesSkillSearchOutput(output string) ([]dto.AgentSkillSearchItem, error) {
if strings.Contains(output, "No skills found matching your query.") {
return []dto.AgentSkillSearchItem{}, nil
}
headers, rows, err := parseHermesTableOutput(output)
if err != nil {
return nil, err
}
items := make([]dto.AgentSkillSearchItem, 0, len(rows))
for _, row := range rows {
identifier := row[headers["Identifier"]]
items = append(items, dto.AgentSkillSearchItem{
Slug: identifier,
Identifier: identifier,
Name: row[headers["Name"]],
Description: row[headers["Description"]],
Source: row[headers["Source"]],
Trust: row[headers["Trust"]],
})
}
return items, nil
}
func parseHermesTableOutput(output string) (map[string]int, [][]string, error) {
lines := strings.Split(strings.TrimSpace(ansiEscapePattern.ReplaceAllString(output, "")), "\n")
var headers []string
rows := make([][]string, 0)
var current []string
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if (!strings.HasPrefix(line, "│") || !strings.HasSuffix(line, "│")) &&
(!strings.HasPrefix(line, "┃") || !strings.HasSuffix(line, "┃")) {
continue
}
line = strings.ReplaceAll(line, "┃", "│")
parts := strings.Split(line, "│")
if len(parts) < 3 {
continue
}
cols := make([]string, 0, len(parts)-2)
for _, part := range parts[1 : len(parts)-1] {
cols = append(cols, strings.TrimSpace(part))
}
if len(headers) == 0 {
headers = cols
continue
}
if cols[0] != "" {
if current != nil {
rows = append(rows, current)
}
current = cols
continue
}
if current == nil {
continue
}
for i := range cols {
if cols[i] == "" {
continue
}
if current[i] == "" {
current[i] = cols[i]
continue
}
current[i] = current[i] + " " + cols[i]
}
}
if current != nil {
rows = append(rows, current)
}
if len(headers) == 0 {
return nil, nil, fmt.Errorf("hermes skills table not found")
}
headerIndex := make(map[string]int, len(headers))
for i, header := range headers {
headerIndex[header] = i
}
return headerIndex, rows, nil
}
+1 -6
View File
@@ -10,7 +10,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
const (
@@ -102,11 +101,7 @@ func countOpenclawConfiguredChannels(conf map[string]interface{}) int {
}
func loadOpenclawOverviewSkillStats(containerName string) (int, error) {
output, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s openclaw skills list --json 2>&1",
30*time.Second,
containerName,
)
output, err := runDockerExecWithStdout(5*time.Minute, containerName, "sh", "-c", "openclaw skills list --json 2>&1")
if err != nil {
return 0, err
}
+114 -32
View File
@@ -9,10 +9,14 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
const clawhubGlobalRegistry = "https://clawhub.com"
const clawhubChinaRegistry = "https://mirror-cn.clawhub.com"
type openclawSkillsList struct {
Skills []openclawSkillListItem `json:"skills"`
}
@@ -35,20 +39,23 @@ type skillhubSearchPayload struct {
}
var clawhubSearchLinePattern = regexp.MustCompile(`^(\S+)\s+(.+?)\s+\(([\d.]+)\)$`)
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
func (a AgentService) ListSkills(req dto.AgentIDReq) ([]dto.AgentSkillItem, error) {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return nil, err
}
output, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s openclaw skills list --json 2>&1",
30*time.Second,
install.ContainerName,
)
if agent.AgentType == constant.AppHermesAgent {
return listHermesSkills(install.ContainerName)
}
if agent.AgentType != constant.AppOpenclaw {
return nil, fmt.Errorf("%s does not support", agent.AgentType)
}
output, err := runDockerExecWithStdout(5*time.Minute, install.ContainerName, "sh", "-c", "openclaw skills list --json 2>&1")
if err != nil {
return nil, err
}
@@ -59,13 +66,19 @@ func (a AgentService) ListSkills(req dto.AgentIDReq) ([]dto.AgentSkillItem, erro
}
func (a AgentService) SearchSkills(req dto.AgentSkillSearchReq) ([]dto.AgentSkillSearchItem, error) {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return nil, err
}
if agent.AgentType == constant.AppHermesAgent {
return searchHermesSkills(install.ContainerName, req.Source, req.Keyword)
}
if agent.AgentType != constant.AppOpenclaw {
return nil, fmt.Errorf("%s does not support", agent.AgentType)
}
output, err := loadOpenclawSkillSearchOutput(install.ContainerName, req.Source, req.Keyword)
if err != nil {
return nil, err
@@ -77,7 +90,7 @@ func (a AgentService) SearchSkills(req dto.AgentSkillSearchReq) ([]dto.AgentSkil
case "skillhub":
return parseSkillhubSearchResult(output)
default:
return parseClawhubSearchResult(output), nil
return parseClawhubSearchResult(output, req.Source), nil
}
}
@@ -102,7 +115,7 @@ func (a AgentService) UpdateSkill(req dto.AgentSkillUpdateReq) error {
}
func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return err
}
@@ -113,8 +126,23 @@ func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
if err != nil {
return err
}
if agent.AgentType == constant.AppHermesAgent {
installTask.AddSubTask("Install Hermes skill", func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
return mgr.Run("docker", buildHermesDockerExecArgs(install.ContainerName, "skills", "install", req.Slug, "--yes")...)
}, nil)
go func() {
if err := installTask.Execute(); err != nil {
global.LOG.Errorf("install hermes skill failed: %v", err)
}
}()
return nil
}
if agent.AgentType != constant.AppOpenclaw {
return fmt.Errorf("%s does not support", agent.AgentType)
}
installTask.AddSubTask("Install OpenClaw skill", func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(10*time.Minute))
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
return mgr.Run("docker", "exec", install.ContainerName, "sh", "-c", buildOpenclawSkillInstallCommand(req.Source, req.Slug))
}, nil)
go func() {
@@ -125,9 +153,40 @@ func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
return nil
}
func (a AgentService) UninstallSkill(req dto.AgentSkillUninstallReq) error {
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if agent.AgentType != constant.AppHermesAgent {
return fmt.Errorf("%s does not support", agent.AgentType)
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return err
}
return cmd.NewCommandMgr(cmd.WithTimeout(5*time.Minute)).Run(
"docker",
buildHermesDockerExecCommandArgs(
install.ContainerName,
"sh",
"-lc",
fmt.Sprintf(`printf 'y\n' | %s skills uninstall "$1"`, hermesExecutablePath),
"sh",
req.Name,
)...,
)
}
func parseOpenclawSkillsList(output string) ([]dto.AgentSkillItem, error) {
payloadBytes, err := extractEmbeddedJSON(output)
if err != nil {
return nil, err
}
if len(payloadBytes) == 0 {
return nil, nil
}
var payload openclawSkillsList
if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil {
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return nil, err
}
items := make([]dto.AgentSkillItem, 0, len(payload.Skills))
@@ -146,18 +205,14 @@ func parseOpenclawSkillsList(output string) ([]dto.AgentSkillItem, error) {
func loadOpenclawSkillSearchOutput(containerName, source, keyword string) (string, error) {
switch source {
case "skillhub":
return cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s skillhub search %q --json 2>&1",
30*time.Second,
containerName,
keyword,
)
return runDockerExecWithStdout(2*time.Minute, containerName, "skillhub", "search", keyword, "--json")
default:
return cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s clawhub search %q 2>&1",
30*time.Second,
return runDockerExecWithStdout(
2*time.Minute,
containerName,
keyword,
"sh",
"-c",
fmt.Sprintf("CLAWHUB_REGISTRY=%q clawhub search %q", resolveClawhubRegistry(source), keyword),
)
}
}
@@ -188,7 +243,7 @@ func parseSkillhubSearchResult(output string) ([]dto.AgentSkillSearchItem, error
return items, nil
}
func parseClawhubSearchResult(output string) []dto.AgentSkillSearchItem {
func parseClawhubSearchResult(output, source string) []dto.AgentSkillSearchItem {
lines := strings.Split(strings.TrimSpace(output), "\n")
items := make([]dto.AgentSkillSearchItem, 0, len(lines))
for _, line := range lines {
@@ -200,7 +255,7 @@ func parseClawhubSearchResult(output string) []dto.AgentSkillSearchItem {
Slug: matches[1],
Name: matches[2],
Score: matches[3],
Source: "clawhub",
Source: source,
})
}
return items
@@ -208,10 +263,11 @@ func parseClawhubSearchResult(output string) []dto.AgentSkillSearchItem {
func buildOpenclawSkillInstallCommand(source, slug string) string {
switch source {
case "clawhub":
case "clawhub-global", "clawhub-cn":
return fmt.Sprintf(
"mkdir -p %s && clawhub --workdir /home/node/.openclaw --dir skills install %q",
"mkdir -p %s && CLAWHUB_REGISTRY=%q clawhub --workdir /home/node/.openclaw --dir skills install %q",
openclawManagedSkillsDir,
resolveClawhubRegistry(source),
slug,
)
default:
@@ -224,13 +280,17 @@ func buildOpenclawSkillInstallCommand(source, slug string) string {
}
}
func resolveClawhubRegistry(source string) string {
switch source {
case "clawhub-cn":
return clawhubChinaRegistry
default:
return clawhubGlobalRegistry
}
}
func getOpenclawSkillKey(containerName, name string) (string, error) {
output, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s openclaw skills info %q --json 2>&1",
30*time.Second,
containerName,
name,
)
output, err := runDockerExecWithStdout(2*time.Minute, containerName, "sh", "-c", fmt.Sprintf("openclaw skills info %q --json 2>&1", name))
if err != nil {
return "", err
}
@@ -238,8 +298,12 @@ func getOpenclawSkillKey(containerName, name string) (string, error) {
}
func parseOpenclawSkillKey(name, output string) (string, error) {
payloadBytes, err := extractEmbeddedJSON(output)
if err != nil {
return "", err
}
var payload openclawSkillInfo
if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil {
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return "", err
}
if payload.SkillKey == "" {
@@ -248,6 +312,24 @@ func parseOpenclawSkillKey(name, output string) (string, error) {
return payload.SkillKey, nil
}
func extractEmbeddedJSON(output string) ([]byte, error) {
trimmed := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(output, ""))
if trimmed == "" {
return nil, nil
}
for i := 0; i < len(trimmed); i++ {
if trimmed[i] != '{' && trimmed[i] != '[' {
continue
}
decoder := json.NewDecoder(strings.NewReader(trimmed[i:]))
var raw json.RawMessage
if err := decoder.Decode(&raw); err == nil {
return raw, nil
}
}
return nil, fmt.Errorf("json payload not found")
}
func setOpenclawSkillEnabled(conf map[string]interface{}, skillKey string, enabled bool) {
skills := ensureChildMap(conf, "skills")
entries := ensureChildMap(skills, "entries")
+206 -190
View File
@@ -19,6 +19,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
@@ -42,8 +43,8 @@ func loadOpenclawAgentByID(agentID uint) (*model.Agent, error) {
if err != nil {
return nil, err
}
if agent.AgentType == constant.AppCopaw {
return nil, fmt.Errorf("copaw does not support")
if agent.AgentType != constant.AppOpenclaw {
return nil, fmt.Errorf("%s does not support", agent.AgentType)
}
return agent, nil
}
@@ -59,6 +60,11 @@ func ensureContainerRunning(containerName string) error {
return nil
}
func runDockerExecWithStdout(timeout time.Duration, containerName string, args ...string) (string, error) {
commandArgs := append([]string{"exec", containerName}, args...)
return cmd.NewCommandMgr(cmd.WithTimeout(timeout)).RunWithStdout("docker", commandArgs...)
}
func resolveAgentAccountInput(provider, apiKey, baseURL string) (resolvedAgentAccountInput, error) {
resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedBaseURL := strings.TrimSpace(baseURL)
@@ -333,13 +339,11 @@ func setOtherConfig(conf map[string]interface{}, config dto.AgentOtherConfig) {
}
func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map[string]interface{}) dto.AgentItem {
agentType := normalizeAgentType(agent.AgentType)
if appInstall != nil && appInstall.ID > 0 && appInstall.App.Key == constant.AppCopaw {
agentType = constant.AppCopaw
}
agentType := agent.AgentType
item := dto.AgentItem{
ID: agent.ID,
Name: agent.Name,
Remark: agent.Remark,
AgentType: agentType,
Provider: agent.Provider,
ProviderName: providercatalog.DisplayName(agent.Provider),
@@ -353,6 +357,7 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
Status: agent.Status,
Message: agent.Message,
AppInstallID: agent.AppInstallID,
WebsiteID: agent.WebsiteID,
AccountID: agent.AccountID,
ConfigPath: agent.ConfigPath,
CreatedAt: agent.CreatedAt,
@@ -381,6 +386,10 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
return item
}
func isAgentAppKey(appKey string) bool {
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent
}
func isOpenclawLegacyHTTPVersion(version string) bool {
return !common.CompareAppVersion(version, openclawHTTPSVersion)
}
@@ -653,7 +662,8 @@ type agentDefaults struct {
}
type modelRef struct {
Primary string `json:"primary"`
Primary string `json:"primary"`
Fallbacks []string `json:"fallbacks,omitempty"`
}
type modelsConfig struct {
@@ -679,6 +689,23 @@ type modelEntry struct {
Cost modelCost `json:"cost"`
}
func requiresOpenclawProviderModels(provider string) bool {
return provider != "gemini"
}
func applyOpenclawModelsConfig(conf map[string]interface{}, models *modelsConfig) error {
if models == nil {
delete(conf, "models")
return nil
}
modelsMap, err := structToMap(models)
if err != nil {
return err
}
conf["models"] = modelsMap
return nil
}
type modelCost struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
@@ -694,7 +721,7 @@ type browserConfig struct {
DefaultProfile string `json:"defaultProfile"`
}
func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName, token string, allowedOrigins []string) error {
func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName, token string, allowedOrigins []string, fallbacks []string) error {
if strings.TrimSpace(confDir) == "" {
return fmt.Errorf("config dir is required")
}
@@ -717,6 +744,10 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
if err != nil {
return err
}
resolvedFallbacks, err := resolveOpenclawFallbackModels(account, modelName, fallbacks)
if err != nil {
return err
}
cfg := openclawConfig{
Gateway: gatewayConfig{
@@ -736,7 +767,7 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
Agents: agentsConfig{
Defaults: agentDefaults{
UserTimezone: resolveServerTimezone(),
Model: modelRef{Primary: primaryModel},
Model: modelRef{Primary: primaryModel, Fallbacks: resolvedFallbacks},
Models: defaultsModels,
},
},
@@ -775,12 +806,8 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
}
conf = initial
} else {
if cfg.Models != nil {
modelsMap, err := structToMap(cfg.Models)
if err != nil {
return err
}
conf["models"] = modelsMap
if err := applyOpenclawModelsConfig(conf, cfg.Models); err != nil {
return err
}
if _, ok := conf["browser"]; !ok {
browserMap, err := structToMap(cfg.Browser)
@@ -804,6 +831,11 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
}
modelMap := ensureChildMap(defaultsMap, "model")
modelMap["primary"] = cfg.Agents.Defaults.Model.Primary
if len(cfg.Agents.Defaults.Model.Fallbacks) > 0 {
modelMap["fallbacks"] = cfg.Agents.Defaults.Model.Fallbacks
} else {
delete(modelMap, "fallbacks")
}
if cfg.Agents.Defaults.Models != nil {
defaultsMap["models"] = cfg.Agents.Defaults.Models
}
@@ -833,19 +865,135 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
}
envPath := path.Join(confDir, ".env")
lines := []string{fmt.Sprintf("OPENCLAW_GATEWAY_TOKEN=%s", token)}
if envKey := providercatalog.EnvKey(account.Provider); envKey != "" && strings.TrimSpace(account.APIKey) != "" {
if envKey := providercatalog.EnvKey(account.Provider); envKey != "" && account.APIKey != "" {
lines = append(lines, fmt.Sprintf("%s=%s", envKey, account.APIKey))
}
content := strings.Join(lines, "\n") + "\n"
return fileOp.SaveFile(envPath, content, 0600)
}
func resolveOpenclawFallbackModels(account *model.AgentAccount, primaryModel string, fallbackIDs []string) ([]string, error) {
accountModels, err := loadAgentAccountModels(account)
if err != nil {
return nil, err
}
return resolveOpenclawFallbackModelsFromPool(account, accountModels, primaryModel, fallbackIDs, true)
}
func resolveOpenclawFallbackModelsFromPool(account *model.AgentAccount, accountModels []dto.AgentAccountModel, primaryModel string, fallbackIDs []string, strict bool) ([]string, error) {
if len(fallbackIDs) == 0 {
return nil, nil
}
result := make([]string, 0, len(fallbackIDs))
seen := make(map[string]struct{}, len(fallbackIDs))
for _, item := range fallbackIDs {
target := strings.TrimSpace(item)
if target == "" {
continue
}
accountModel, err := requireAgentAccountModelForProvider(account.Provider, accountModels, target)
if err != nil {
if strict {
return nil, err
}
continue
}
if sameProviderModelID(account.Provider, accountModel.ID, primaryModel) {
if strict {
return nil, buserr.New("ErrAgentFallbackModelPrimary")
}
continue
}
resolvedPrimary, _, _, _, err := buildOpenclawAccountModelConfig(account, accountModel)
if err != nil {
return nil, err
}
if _, ok := seen[resolvedPrimary]; ok {
if strict {
return nil, buserr.New("ErrAgentFallbackModelDuplicate")
}
continue
}
seen[resolvedPrimary] = struct{}{}
result = append(result, resolvedPrimary)
}
return result, nil
}
func extractOpenclawPrimaryModelID(conf map[string]interface{}, account *model.AgentAccount, accountModels []dto.AgentAccountModel) string {
modelMap := openclawModelRefMap(conf)
primary := strings.TrimSpace(fmt.Sprintf("%v", modelMap["primary"]))
if primary == "" {
return ""
}
resolved, err := buildOpenclawResolvedModelIDMap(account, accountModels)
if err != nil {
return ""
}
return resolved[primary]
}
func extractOpenclawFallbackModelIDs(conf map[string]interface{}, account *model.AgentAccount, accountModels []dto.AgentAccountModel, primaryModel string) []string {
modelMap := openclawModelRefMap(conf)
raw, ok := modelMap["fallbacks"].([]interface{})
if !ok || len(raw) == 0 {
return nil
}
resolved, err := buildOpenclawResolvedModelIDMap(account, accountModels)
if err != nil {
return nil
}
result := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for _, item := range raw {
target := strings.TrimSpace(fmt.Sprintf("%v", item))
modelID, ok := resolved[target]
if !ok || sameProviderModelID(account.Provider, modelID, primaryModel) {
continue
}
if _, ok := seen[modelID]; ok {
continue
}
seen[modelID] = struct{}{}
result = append(result, modelID)
}
return result
}
func openclawModelRefMap(conf map[string]interface{}) map[string]interface{} {
agentsMap, ok := conf["agents"].(map[string]interface{})
if !ok {
return nil
}
defaultsMap, ok := agentsMap["defaults"].(map[string]interface{})
if !ok {
return nil
}
modelMap, ok := defaultsMap["model"].(map[string]interface{})
if !ok {
return nil
}
return modelMap
}
func buildOpenclawResolvedModelIDMap(account *model.AgentAccount, accountModels []dto.AgentAccountModel) (map[string]string, error) {
result := make(map[string]string, len(accountModels))
for _, item := range accountModels {
resolvedPrimary, _, _, _, err := buildOpenclawAccountModelConfig(account, item)
if err != nil {
return nil, err
}
result[resolvedPrimary] = item.ID
}
return result, nil
}
func prepareOpenclawInstallFiles(appInstall *model.AppInstall, account *model.AgentAccount, modelName, token string, allowedOrigins []string) error {
if appInstall == nil {
return fmt.Errorf("app install is required")
}
confDir := path.Join(appInstall.GetPath(), "data", "conf")
if err := writeOpenclawConfig(confDir, account, modelName, token, allowedOrigins); err != nil {
if err := writeOpenclawConfig(confDir, account, modelName, token, allowedOrigins, nil); err != nil {
return err
}
dataDir := path.Join(appInstall.GetPath(), "data")
@@ -858,14 +1006,10 @@ func buildOpenclawModelsFromAccount(account *model.AgentAccount, selectedModel s
return "", nil, nil, err
}
if len(accountModels) == 0 {
return "", nil, nil, fmt.Errorf("model is required")
}
selectedModel = strings.TrimSpace(selectedModel)
if selectedModel == "" {
selectedModel = strings.TrimSpace(accountModels[0].ID)
return "", nil, nil, buserr.New("ErrAgentModelNotInAccount")
}
if selectedModel == "" {
return "", nil, nil, fmt.Errorf("model is required")
return "", nil, nil, buserr.New("ErrAgentModelNotInAccount")
}
selectedAccountModel, err := requireAgentAccountModelForProvider(account.Provider, accountModels, selectedModel)
if err != nil {
@@ -879,7 +1023,7 @@ func buildOpenclawModelsFromAccount(account *model.AgentAccount, selectedModel s
primaryModel := ""
defaultsModels := make(map[string]map[string]interface{}, len(accountModels))
for _, item := range accountModels {
resolvedPrimary, entry, key, baseCfg, err := buildOpenclawCatalogModel(account, item)
resolvedPrimary, entry, key, baseCfg, err := buildOpenclawAccountModelConfig(account, item)
if err != nil {
return "", nil, nil, err
}
@@ -899,6 +1043,9 @@ func buildOpenclawModelsFromAccount(account *model.AgentAccount, selectedModel s
if primaryModel == "" {
return "", nil, nil, buserr.New("ErrAgentModelNotInAccount")
}
if !requiresOpenclawProviderModels(account.Provider) {
return primaryModel, defaultsModels, nil, nil
}
providerCfg.Models = entries
return primaryModel, defaultsModels, &modelsConfig{
Mode: "merge",
@@ -908,25 +1055,33 @@ func buildOpenclawModelsFromAccount(account *model.AgentAccount, selectedModel s
}, nil
}
func buildOpenclawCatalogModel(account *model.AgentAccount, model dto.AgentAccountModel) (string, modelEntry, string, modelProvider, error) {
primaryModel, inferredEntry, providerKey, providerCfg, err := inferOpenclawCatalogModel(account, model.ID, model.Reasoning, model.MaxTokens, model.ContextWindow)
func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.AgentAccountModel) (string, modelEntry, string, modelProvider, error) {
providerPatch, err := providercatalog.BuildOpenClawProviderPatch(account.Provider, model.ID, account.APIType, account.BaseURL, account.APIKey)
if err != nil {
return "", modelEntry{}, "", modelProvider{}, err
}
if strings.TrimSpace(model.Name) != "" {
inferredEntry.Name = strings.TrimSpace(model.Name)
return providerPatch.PrimaryModel, buildOpenclawModelEntry(providerPatch.ModelID, model), providerPatch.ProviderKey, modelProvider{
ApiKey: providerPatch.APIKey,
BaseUrl: providerPatch.BaseURL,
Api: providerPatch.APIType,
AuthHeader: providerPatch.AuthHeader,
}, nil
}
func buildOpenclawModelEntry(modelID string, model dto.AgentAccountModel) modelEntry {
name := strings.TrimSpace(model.Name)
if name == "" {
name = strings.TrimSpace(modelID)
}
if len(model.Input) > 0 {
inferredEntry.Input = sanitizeAgentAccountModelInputs(model.Input)
return modelEntry{
ID: strings.TrimSpace(modelID),
Name: name,
Reasoning: model.Reasoning,
Input: sanitizeAgentAccountModelInputs(model.Input),
ContextWindow: model.ContextWindow,
MaxTokens: model.MaxTokens,
Cost: modelCost{},
}
inferredEntry.Reasoning = model.Reasoning
if model.ContextWindow > 0 {
inferredEntry.ContextWindow = model.ContextWindow
}
if model.MaxTokens > 0 {
inferredEntry.MaxTokens = model.MaxTokens
}
return primaryModel, inferredEntry, providerKey, providerCfg, nil
}
type openclawAccountModelRuntime struct {
@@ -938,22 +1093,16 @@ type openclawAccountModelRuntime struct {
}
func buildOpenclawAccountModelRuntime(account *model.AgentAccount, model dto.AgentAccountModel) (openclawAccountModelRuntime, error) {
apiType, maxTokens, contextWindow := providercatalog.ResolveRuntimeParams(
account.Provider,
account.APIType,
model.MaxTokens,
model.ContextWindow,
)
primaryModel, _, _, _, err := buildOpenclawCatalogModel(account, model)
primaryModel, _, _, _, err := buildOpenclawAccountModelConfig(account, model)
if err != nil {
return openclawAccountModelRuntime{}, err
}
return openclawAccountModelRuntime{
StoredModel: model.ID,
PrimaryModel: primaryModel,
APIType: apiType,
MaxTokens: maxTokens,
ContextWindow: contextWindow,
APIType: account.APIType,
MaxTokens: model.MaxTokens,
ContextWindow: model.ContextWindow,
}, nil
}
@@ -969,44 +1118,6 @@ func resolveOpenclawAccountModelRuntimeByID(account *model.AgentAccount, modelID
return buildOpenclawAccountModelRuntime(account, selectedAccountModel)
}
func inferOpenclawCatalogModel(account *model.AgentAccount, modelID string, reasoning bool, maxTokens, contextWindow int) (string, modelEntry, string, modelProvider, error) {
baseURL := resolveAccountBaseURL(account)
resolvedAPIType, resolvedMaxTokens, resolvedContextWindow := providercatalog.ResolveRuntimeParams(account.Provider, account.APIType, maxTokens, contextWindow)
patch, err := providercatalog.BuildOpenClawPatch(account.Provider, modelID, resolvedAPIType, reasoning, resolvedMaxTokens, resolvedContextWindow, baseURL, account.APIKey)
if err != nil {
return "", modelEntry{}, "", modelProvider{}, err
}
if patch.Models == nil {
return "", modelEntry{}, "", modelProvider{}, fmt.Errorf("models patch is required")
}
modelsCfg, err := mapToModelsConfig(patch.Models)
if err != nil {
return "", modelEntry{}, "", modelProvider{}, err
}
for key, providerCfg := range modelsCfg.Providers {
if len(providerCfg.Models) == 0 {
continue
}
return patch.PrimaryModel, providerCfg.Models[0], key, modelProvider{
ApiKey: providerCfg.ApiKey,
BaseUrl: providerCfg.BaseUrl,
Api: providerCfg.Api,
AuthHeader: providerCfg.AuthHeader,
}, nil
}
return "", modelEntry{}, "", modelProvider{}, fmt.Errorf("models patch is invalid")
}
func resolveAccountBaseURL(account *model.AgentAccount) string {
baseURL := strings.TrimSpace(account.BaseURL)
if baseURL == "" {
if defaultURL, ok := providercatalog.DefaultBaseURL(account.Provider); ok {
baseURL = defaultURL
}
}
return baseURL
}
func buildInitialAgentAccountModels(account *model.AgentAccount, requested []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) {
if account == nil {
return nil, fmt.Errorf("account is required")
@@ -1169,12 +1280,6 @@ func replacePersistedAgentAccountModelsWithTx(tx *gorm.DB, accountID uint, model
return nil
}
func replacePersistedAgentAccountModels(accountID uint, models []dto.AgentAccountModel) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
return replacePersistedAgentAccountModelsWithTx(tx, accountID, models)
})
}
func normalizeAgentAccountModels(account *model.AgentAccount, models []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) {
requested := append([]dto.AgentAccountModel(nil), models...)
if len(requested) == 0 {
@@ -1207,76 +1312,37 @@ func normalizeAgentAccountModel(account *model.AgentAccount, model dto.AgentAcco
if modelID == "" {
return dto.AgentAccountModel{}, fmt.Errorf("model is required")
}
inferredReasoning := model.Reasoning
if !model.Reasoning && model.Name == "" && model.MaxTokens == 0 && model.ContextWindow == 0 && len(model.Input) == 0 {
if catalogModel, ok := providercatalog.FindModel(account.Provider, modelID); ok {
inferredReasoning = catalogModel.Reasoning
}
}
primaryModel, inferredEntry, _, _, err := inferOpenclawCatalogModel(account, modelID, inferredReasoning, model.MaxTokens, model.ContextWindow)
if err != nil {
return dto.AgentAccountModel{}, err
}
name := strings.TrimSpace(model.Name)
if name == "" {
name = strings.TrimSpace(inferredEntry.Name)
}
reasoning := model.Reasoning
if !model.Reasoning && model.Name == "" && model.MaxTokens == 0 && model.ContextWindow == 0 && len(model.Input) == 0 {
reasoning = inferredEntry.Reasoning
name = modelID
}
inputs := sanitizeAgentAccountModelInputs(model.Input)
if len(inputs) == 0 {
inputs = sanitizeAgentAccountModelInputs(inferredEntry.Input)
}
contextWindow := model.ContextWindow
if contextWindow <= 0 {
contextWindow = inferredEntry.ContextWindow
}
maxTokens := model.MaxTokens
if maxTokens <= 0 {
maxTokens = inferredEntry.MaxTokens
}
return dto.AgentAccountModel{
ID: normalizeAgentAccountModelID(account.Provider, primaryModel, modelID),
ID: normalizeAgentAccountModelID(account.Provider, modelID),
Name: name,
ContextWindow: contextWindow,
MaxTokens: maxTokens,
Reasoning: reasoning,
ContextWindow: model.ContextWindow,
MaxTokens: model.MaxTokens,
Reasoning: model.Reasoning,
Input: inputs,
}, nil
}
func normalizeAgentAccountModelID(provider, primaryModel, requestedID string) string {
func normalizeAgentAccountModelID(provider, requestedID string) string {
switch provider {
case "custom", "vllm":
target := requestedID
if strings.TrimSpace(target) == "" {
target = primaryModel
}
return normalizeCustomModel(target)
return normalizeCustomModel(requestedID)
case "ollama":
target := strings.TrimSpace(primaryModel)
if strings.HasPrefix(target, "ollama/") {
return target
}
target = strings.TrimSpace(requestedID)
target := strings.TrimSpace(requestedID)
if strings.HasPrefix(target, "ollama/") {
return target
}
target = strings.TrimLeft(strings.TrimSpace(target), "/")
if target == "" {
target = strings.TrimLeft(strings.TrimSpace(primaryModel), "/")
}
if target == "" {
return ""
}
return "ollama/" + target
default:
target := strings.TrimSpace(requestedID)
if target == "" {
target = strings.TrimSpace(primaryModel)
}
if target == "" {
return ""
}
@@ -1394,9 +1460,6 @@ func ensureAccountModelsNotBound(account *model.AgentAccount, models []dto.Agent
return err
}
for _, agent := range agents {
if strings.TrimSpace(agent.Model) == "" {
continue
}
if _, ok := findAgentAccountModelForProvider(account.Provider, models, agent.Model); !ok {
return buserr.WithName("ErrAgentModelInUse", agent.Name)
}
@@ -1432,19 +1495,6 @@ func extractStringList(value interface{}) []string {
}
}
func extractGatewayToken(conf map[string]interface{}) string {
gateway, ok := conf["gateway"].(map[string]interface{})
if !ok {
return ""
}
auth, ok := gateway["auth"].(map[string]interface{})
if !ok {
return ""
}
token, _ := auth["token"].(string)
return token
}
func ensureChildMap(parent map[string]interface{}, key string) map[string]interface{} {
if child, ok := parent[key].(map[string]interface{}); ok {
return child
@@ -1466,18 +1516,6 @@ func structToMap(value interface{}) (map[string]interface{}, error) {
return result, nil
}
func mapToModelsConfig(value map[string]interface{}) (*modelsConfig, error) {
payload, err := json.Marshal(value)
if err != nil {
return nil, err
}
result := &modelsConfig{}
if err := json.Unmarshal(payload, result); err != nil {
return nil, err
}
return result, nil
}
func readInstallEnv(envStr string) map[string]interface{} {
if strings.TrimSpace(envStr) == "" {
return nil
@@ -1527,24 +1565,6 @@ func normalizeCustomModel(modelName string) string {
return trim
}
func normalizeAgentType(agentType string) string {
trim := agentType
if trim == "" {
return constant.AppOpenclaw
}
return trim
}
func modelMatchesProvider(provider, modelName string) bool {
target := strings.TrimSpace(modelName)
for _, prefix := range supportedProviderModelPrefixes(provider) {
if prefix != "" && strings.HasPrefix(target, prefix+"/") {
return true
}
}
return false
}
func runtimeProviderModelPrefix(provider string) string {
switch provider {
case "gemini":
@@ -1594,11 +1614,7 @@ func generateToken() string {
}
func asyncReportAIProviderInstall(provider string) {
if global.CONF.Base.Mode != "stable" {
return
}
provider = provider
if provider == "" {
if global.CONF.Base.Mode != "stable" || provider == "" {
return
}
go func(provider string) {
+338
View File
@@ -0,0 +1,338 @@
package service
import (
"context"
"errors"
"fmt"
"net"
"sort"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"gorm.io/gorm"
)
func (a AgentService) BindWebsite(req dto.AgentWebsiteBindReq) error {
agent, err := agentRepo.GetFirst(repo.WithByID(req.AgentID))
if err != nil {
return err
}
if agent.WebsiteID != 0 {
return buserr.New("ErrAgentWebsiteBound")
}
website, err := websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
if err != nil {
return err
}
if !isBindableAgentWebsiteType(website.Type) {
return buserr.New("ErrAgentWebsiteTypeUnsupported")
}
boundAgent, err := agentRepo.GetFirst(repo.WithByWebsiteID(req.WebsiteID))
if err == nil && boundAgent.ID > 0 {
return buserr.New("ErrAgentWebsiteInUse")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
agent.WebsiteID = req.WebsiteID
if err := agentRepo.Save(agent); err != nil {
return err
}
return ensureOpenclawWebsiteAllowedOrigin(agent, &website)
}
func (a AgentService) UnbindWebsite(req dto.AgentIDReq) error {
agent, err := agentRepo.GetFirst(repo.WithByID(req.AgentID))
if err != nil {
return err
}
if agent.WebsiteID == 0 {
return nil
}
website, err := websiteRepo.GetFirst(repo.WithByID(agent.WebsiteID))
if err != nil {
return err
}
if website.Type == constant.Deployment {
return buserr.New("ErrAgentWebsiteUnbindUnsupported")
}
agent.WebsiteID = 0
return agentRepo.Save(agent)
}
func hydrateAgentWebsiteItems(items []dto.AgentItem) error {
explicitWebsiteMap, err := loadAgentWebsiteMapByID(items)
if err != nil {
return err
}
websiteDomainMap, err := loadAgentWebsiteDomainMapByWebsiteID(items)
if err != nil {
return err
}
fillAgentWebsiteItems(items, explicitWebsiteMap, websiteDomainMap)
return nil
}
func loadAgentWebsiteMapByID(items []dto.AgentItem) (map[uint]model.Website, error) {
websiteIDs := make([]uint, 0, len(items))
for _, item := range items {
if item.WebsiteID > 0 {
websiteIDs = append(websiteIDs, item.WebsiteID)
}
}
if len(websiteIDs) == 0 {
return map[uint]model.Website{}, nil
}
websites, err := websiteRepo.GetBy(repo.WithByIDs(uniqueUintList(websiteIDs)))
if err != nil {
return nil, err
}
websiteMap := make(map[uint]model.Website, len(websites))
for _, website := range websites {
websiteMap[website.ID] = website
}
return websiteMap, nil
}
func loadAgentWebsiteDomainMapByWebsiteID(items []dto.AgentItem) (map[uint][]model.WebsiteDomain, error) {
websiteIDs := make([]uint, 0, len(items))
for _, item := range items {
if item.WebsiteID > 0 {
websiteIDs = append(websiteIDs, item.WebsiteID)
}
}
if len(websiteIDs) == 0 {
return map[uint][]model.WebsiteDomain{}, nil
}
websiteDomains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteIds(uniqueUintList(websiteIDs)), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
websiteDomainMap := make(map[uint][]model.WebsiteDomain, len(websiteDomains))
for _, websiteDomain := range websiteDomains {
websiteDomainMap[websiteDomain.WebsiteID] = append(websiteDomainMap[websiteDomain.WebsiteID], websiteDomain)
}
return websiteDomainMap, nil
}
func loadAgentWebsiteResourceName(website model.Website) (string, error) {
websiteDomains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteId(website.ID), repo.WithOrderAsc("id"))
if err != nil {
return "", err
}
if len(websiteDomains) > 0 {
return websiteDomains[0].Domain, nil
}
if website.PrimaryDomain != "" {
return website.PrimaryDomain, nil
}
return fmt.Sprintf("%d", website.ID), nil
}
func fillAgentWebsiteItems(items []dto.AgentItem, explicitWebsiteMap map[uint]model.Website, websiteDomainMap map[uint][]model.WebsiteDomain) {
for index := range items {
if items[index].WebsiteID == 0 {
continue
}
website, ok := explicitWebsiteMap[items[index].WebsiteID]
if !ok {
items[index].WebsiteID = 0
items[index].WebsitePrimaryDomain = ""
items[index].WebsiteType = ""
items[index].WebsiteProtocol = ""
continue
}
items[index].WebsiteType = website.Type
items[index].WebsiteProtocol = website.Protocol
websiteDomains := websiteDomainMap[items[index].WebsiteID]
if len(websiteDomains) == 0 {
items[index].WebsitePrimaryDomain = ""
continue
}
items[index].WebsitePrimaryDomain = websiteDomains[0].Domain
}
}
func uniqueDeploymentWebsiteMapByAppInstall(websites []model.Website) map[uint]model.Website {
websiteMap := make(map[uint]model.Website)
duplicateAppInstallIDs := make(map[uint]struct{})
for _, website := range websites {
if website.AppInstallID == 0 {
continue
}
if _, duplicated := duplicateAppInstallIDs[website.AppInstallID]; duplicated {
continue
}
if _, exists := websiteMap[website.AppInstallID]; exists {
delete(websiteMap, website.AppInstallID)
duplicateAppInstallIDs[website.AppInstallID] = struct{}{}
continue
}
websiteMap[website.AppInstallID] = website
}
return websiteMap
}
func UniqueDeploymentWebsiteMapForMigration(websites []model.Website) map[uint]model.Website {
return uniqueDeploymentWebsiteMapByAppInstall(websites)
}
func uniqueUintList(items []uint) []uint {
itemMap := make(map[uint]struct{}, len(items))
uniq := make([]uint, 0, len(items))
for _, item := range items {
if item == 0 {
continue
}
if _, exists := itemMap[item]; exists {
continue
}
itemMap[item] = struct{}{}
uniq = append(uniq, item)
}
return uniq
}
func isBindableAgentWebsiteType(websiteType string) bool {
return websiteType == constant.Proxy || websiteType == constant.Static
}
func bindDeploymentWebsiteToAgentByAppInstall(website *model.Website) error {
if website.ID == 0 || website.Type != constant.Deployment || website.AppInstallID == 0 {
return nil
}
appInstall, err := appInstallRepo.GetFirst(repo.WithByID(website.AppInstallID))
if err != nil {
return err
}
if appInstall.App.Key != constant.AppOpenclaw && appInstall.App.Key != constant.AppCopaw && appInstall.App.Key != constant.AppHermesAgent {
return nil
}
agent, err := agentRepo.GetFirst(repo.WithByAppInstallID(website.AppInstallID))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if agent.WebsiteID != 0 {
return nil
}
agent.WebsiteID = website.ID
if err := agentRepo.Save(agent); err != nil {
return err
}
return ensureOpenclawWebsiteAllowedOrigin(agent, website)
}
func ensureOpenclawWebsiteAllowedOrigin(agent *model.Agent, website *model.Website) error {
if agent == nil || website == nil || agent.AgentType != constant.AppOpenclaw {
return nil
}
origins, err := buildWebsiteAllowedOrigins(website)
if err != nil {
return err
}
if len(origins) == 0 {
return nil
}
install, err := appInstallRepo.GetFirst(repo.WithByID(agent.AppInstallID))
if err != nil {
return err
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
allowedOrigins := extractSecurityConfig(conf).AllowedOrigins
allowedOrigins, err = normalizeAllowedOrigins(append(allowedOrigins, origins...))
if err != nil {
return err
}
setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins})
if err := writeOpenclawConfigRaw(agent.ConfigPath, conf); err != nil {
return err
}
if err := syncOpenclawAllowedOriginEnv(&install, allowedOrigins); err != nil {
return err
}
return appInstallRepo.Save(context.Background(), &install)
}
func buildWebsiteAllowedOrigins(website *model.Website) ([]string, error) {
websiteDomains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteId(website.ID), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
if len(websiteDomains) == 0 {
return nil, nil
}
defaultHTTPSPort := 443
if strings.EqualFold(website.Protocol, "https") {
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err == nil && nginxInstall.HttpsPort > 0 {
defaultHTTPSPort = nginxInstall.HttpsPort
}
}
return buildWebsiteAllowedOriginsFromDomains(website, websiteDomains, defaultHTTPSPort)
}
func buildWebsiteAllowedOriginsFromDomains(website *model.Website, websiteDomains []model.WebsiteDomain, defaultHTTPSPort int) ([]string, error) {
if len(websiteDomains) == 0 {
return nil, nil
}
sort.Slice(websiteDomains, func(i, j int) bool {
return websiteDomains[i].ID < websiteDomains[j].ID
})
origins := make([]string, 0, len(websiteDomains))
for _, websiteDomain := range websiteDomains {
origin, err := buildWebsiteDomainOrigin(website.Protocol, websiteDomain, defaultHTTPSPort)
if err != nil {
return nil, err
}
origins = append(origins, origin)
}
return origins, nil
}
func buildWebsiteDomainOrigin(protocol string, websiteDomain model.WebsiteDomain, defaultHTTPSPort int) (string, error) {
origin := strings.ToLower(strings.TrimSpace(protocol)) + "://" + formatWebsiteDomainHost(websiteDomain.Domain)
switch strings.ToLower(strings.TrimSpace(protocol)) {
case "http":
if websiteDomain.Port > 0 && websiteDomain.Port != 80 {
origin = fmt.Sprintf("%s:%d", origin, websiteDomain.Port)
}
case "https":
port := websiteDomain.Port
if !websiteDomain.SSL {
port = defaultHTTPSPort
}
if port > 0 && port != 443 {
origin = fmt.Sprintf("%s:%d", origin, port)
}
}
return normalizeAllowedOrigin(origin)
}
func formatWebsiteDomainHost(domain string) string {
host := strings.Trim(strings.TrimSpace(domain), "[]")
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
return "[" + host + "]"
}
return host
}
+130 -4
View File
@@ -1,11 +1,14 @@
package service
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path"
"strings"
"syscall"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -19,6 +22,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"github.com/jinzhu/copier"
)
@@ -107,8 +111,7 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
}
go func() {
taskItem.AddSubTask(i18n.GetWithName("OllamaModelPull", req.Name), func(t *task.Task) error {
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*taskItem), cmd.WithTimeout(time.Hour))
return cmdMgr.Run("docker", "exec", containerName, "ollama", "pull", info.Name)
return runOllamaPullWithProcess(t, containerName, info.Name)
}, nil)
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
itemSize, err := loadModelSize(info.Name, containerName)
@@ -162,8 +165,7 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
}
go func() {
taskItem.AddSubTask(i18n.GetWithName("OllamaModelPull", req.Name), func(t *task.Task) error {
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*taskItem), cmd.WithTimeout(time.Hour))
return cmdMgr.Run("docker", "exec", containerName, "ollama", "pull", req.Name)
return runOllamaPullWithProcess(t, containerName, req.Name)
}, nil)
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
itemSize, err := loadModelSize(modelInfo.Name, containerName)
@@ -394,3 +396,127 @@ func loadModelSize(name string, containerName string) (string, error) {
}
return "", fmt.Errorf("no such model %s in ollama list, std: %s", name, stdout)
}
func runOllamaPullWithProcess(taskItem *task.Task, containerName, modelName string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
cmdItem := exec.CommandContext(ctx, "docker", "exec", containerName, "ollama", "pull", modelName)
cmdItem.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
writer := &ollamaPullLogWriter{taskItem: taskItem}
cmdItem.Stdout = writer
cmdItem.Stderr = writer
if err := cmdItem.Start(); err != nil {
return fmt.Errorf("failed to start ollama pull %s: %w", modelName, err)
}
waitErr := cmdItem.Wait()
writer.Flush()
if ctx.Err() == context.DeadlineExceeded {
if cmdItem.Process != nil && cmdItem.Process.Pid > 0 {
_ = syscall.Kill(-cmdItem.Process.Pid, syscall.SIGKILL)
}
return buserr.New("ErrCmdTimeout")
}
if waitErr == nil {
return nil
}
if len(strings.TrimSpace(writer.errBuf.String())) > 0 {
return fmt.Errorf("%s", strings.TrimSpace(writer.errBuf.String()))
}
return waitErr
}
type ollamaPullLogWriter struct {
taskItem *task.Task
errBuf bytes.Buffer
}
func (w *ollamaPullLogWriter) Write(p []byte) (n int, err error) {
w.errBuf.Write(p)
for _, segment := range splitOllamaPullSegments(string(p)) {
w.logLine(segment)
}
return len(p), nil
}
func (w *ollamaPullLogWriter) Flush() {}
func (w *ollamaPullLogWriter) logLine(line string) {
if w == nil || w.taskItem == nil {
return
}
line = sanitizeOllamaPullLine(line)
if line == "" {
return
}
if prefix := resolveOllamaPullLogPrefix(line); prefix != "" {
_ = replaceOllamaPullLogLine(prefix, line, w.taskItem)
return
}
w.taskItem.Log(line)
}
func sanitizeOllamaPullLine(line string) string {
line = re.StripAnsiControlSeq(line)
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "pulling manifes") {
return ""
}
return line
}
func splitOllamaPullSegments(chunk string) []string {
if chunk == "" {
return nil
}
chunk = strings.ReplaceAll(chunk, "\r", "\n")
parts := strings.Split(chunk, "\x1b[1G")
segments := make([]string, 0, len(parts))
for _, part := range parts {
for _, line := range strings.Split(part, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
segments = append(segments, line)
}
}
return segments
}
func resolveOllamaPullLogPrefix(line string) string {
if strings.HasPrefix(line, "pulling ") {
if idx := strings.Index(line, ":"); idx > 0 {
return strings.TrimSpace(line[:idx])
}
fields := strings.Fields(line)
if len(fields) >= 2 {
return strings.TrimSpace(fields[0] + " " + fields[1])
}
}
return ""
}
func replaceOllamaPullLogLine(prefix, newLine string, taskItem *task.Task) error {
if taskItem == nil || taskItem.Task == nil {
return nil
}
data, err := os.ReadFile(taskItem.Task.LogFile)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
for idx, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, prefix) {
lines[idx] = time.Now().Format("2006/01/02 15:04:05") + " " + newLine
return os.WriteFile(taskItem.Task.LogFile, []byte(strings.Join(lines, "\n")), os.ModePerm)
}
}
taskItem.Log(newLine)
return nil
}
+22
View File
@@ -650,6 +650,28 @@ func sendAlerts(alert dto.AlertDTO, alertType, quota, quotaType string, params [
continue
}
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, m)
case constant.Bark:
todayCount, isValid := canSendAlertToday(alertType, quotaType, alert.SendCount, m)
if !isValid {
continue
}
var create = dto.AlertLogCreate{
Type: alertUtil.GetCronJobType(alert.Type),
AlertId: alert.ID,
Count: todayCount + 1,
}
alertInfo := alert
alertInfo.Type = alertType
create.AlertRule = alertUtil.ProcessAlertRule(alert)
create.AlertDetail = alertUtil.ProcessAlertDetail(alertInfo, quotaType, params, m)
transport := xpack.LoadRequestTransport()
agentInfo, _ := xpack.GetAgentInfo()
alertErr := alertUtil.CreateBarkAlertLog(create, alertInfo, params, transport, agentInfo)
if alertErr != nil {
global.LOG.Infof("%s alert %s push failed, err: %v", alertType, m, alertErr.Error())
continue
}
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, m)
default:
}
}
+53
View File
@@ -30,6 +30,8 @@ func (s *AlertSender) Send(quota string, params []dto.Param) {
s.sendSMS(quota, params)
case constant.Email:
s.sendEmail(quota, params)
case constant.Bark:
s.sendBark(quota, params)
case constant.WeCom, constant.DingTalk, constant.FeiShu:
s.sendWebhook(quota, params, method)
}
@@ -45,6 +47,8 @@ func (s *AlertSender) ResourceSend(quota string, params []dto.Param) {
s.sendResourceSMS(quota, params)
case constant.Email:
s.sendResourceEmail(quota, params)
case constant.Bark:
s.sendResourceBark(quota, params)
case constant.WeCom, constant.DingTalk, constant.FeiShu:
s.sendResourceWebhook(quota, params, method)
}
@@ -101,6 +105,31 @@ func (s *AlertSender) sendEmail(quota string, params []dto.Param) {
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, constant.Email)
}
func (s *AlertSender) sendBark(quota string, params []dto.Param) {
totalCount, isValid := s.canSendAlert(constant.Bark)
if !isValid {
return
}
create := dto.AlertLogCreate{
Status: constant.AlertSuccess,
Count: totalCount + 1,
AlertId: s.alert.ID,
Type: s.alert.Type,
AlertRule: alertUtil.ProcessAlertRule(s.alert),
AlertDetail: alertUtil.ProcessAlertDetail(s.alert, quota, params, constant.Bark),
}
transport := xpack.LoadRequestTransport()
agentInfo, _ := xpack.GetAgentInfo()
err := alertUtil.CreateBarkAlertLog(create, s.alert, params, transport, agentInfo)
if err != nil {
global.LOG.Errorf("%s alert bark push failed: %v", s.alert.Type, err)
return
}
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, constant.Bark)
}
func (s *AlertSender) sendWebhook(quota string, params []dto.Param, method string) {
totalCount, isValid := s.canSendAlert(method)
if !isValid {
@@ -171,6 +200,30 @@ func (s *AlertSender) sendResourceEmail(quota string, params []dto.Param) {
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, constant.Email)
}
func (s *AlertSender) sendResourceBark(quota string, params []dto.Param) {
todayCount, isValid := s.canResourceSendAlert(constant.Bark)
if !isValid {
return
}
create := dto.AlertLogCreate{
Status: constant.AlertSuccess,
Count: todayCount + 1,
AlertId: s.alert.ID,
Type: s.alert.Type,
AlertRule: alertUtil.ProcessAlertRule(s.alert),
AlertDetail: alertUtil.ProcessAlertDetail(s.alert, quota, params, constant.Bark),
}
transport := xpack.LoadRequestTransport()
agentInfo, _ := xpack.GetAgentInfo()
if err := alertUtil.CreateBarkAlertLog(create, s.alert, params, transport, agentInfo); err != nil {
global.LOG.Errorf("failed to send Bark alert: %v", err)
return
}
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, constant.Bark)
}
func (s *AlertSender) sendResourceWebhook(quota string, params []dto.Param, method string) {
todayCount, isValid := s.canResourceSendAlert(method)
if !isValid {
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -576,7 +577,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
_ = appInstallRepo.Save(context.Background(), appInstall)
}
installTask.AddSubTask(task.GetTaskName(appInstall.Name, task.TaskInstall, task.TaskScopeApp), installApp, handleAppStatus)
installTask.AddSubTaskWithOps(task.GetTaskName(appInstall.Name, task.TaskInstall, task.TaskScopeApp), installApp, handleAppStatus, 0, time.Hour)
go func() {
if taskErr := installTask.Execute(); taskErr != nil {
+6 -2
View File
@@ -853,6 +853,10 @@ func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
return nil
}
func SyncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
return syncAppInstallStatus(appInstall, force)
}
func updateInstallInfoInDB(appKey, appName, param string, value interface{}) error {
if param != "password" && param != "port" && param != "user-password" {
return nil
@@ -870,7 +874,7 @@ func updateInstallInfoInDB(appKey, appName, param string, value interface{}) err
envKey := ""
switch param {
case "password":
if appKey == "mysql" || appKey == "mariadb" || appKey == "postgresql" {
if appKey == "mysql" || appKey == "mariadb" || appKey == "postgresql" || appKey == "mongodb" {
envKey = "PANEL_DB_ROOT_PASSWORD="
} else {
envKey = "PANEL_REDIS_ROOT_PASSWORD="
@@ -911,7 +915,7 @@ func updateInstallInfoInDB(appKey, appName, param string, value interface{}) err
"param": strings.ReplaceAll(appInstall.Param, oldVal, newVal),
"env": strings.ReplaceAll(appInstall.Env, oldVal, newVal),
}, repo.WithByID(appInstall.ID))
if appKey == "mysql" || appKey == "postgresql" {
if appKey == "mysql" || appKey == "postgresql" || appKey == "mongodb" {
return nil
}
}
+35 -4
View File
@@ -392,7 +392,7 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
return err
}
appKey := install.App.Key
if appKey == constant.AppOpenclaw || appKey == constant.AppCopaw {
if isAgentAppKey(appKey) {
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
}
@@ -425,6 +425,8 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
switch install.App.Key {
case constant.AppMysql, constant.AppMariaDB, constant.AppMysqlCluster:
_ = mysqlRepo.Delete(ctx, mysqlRepo.WithByMysqlName(install.Name))
case constant.AppMongodb:
_ = mongodbRepo.Delete(ctx, mongodbRepo.WithByMongodbName(install.Name))
case constant.AppPostgresql, constant.AppPostgresqlCluster:
_ = postgresqlRepo.Delete(ctx, postgresqlRepo.WithByPostgresqlName(install.Name))
}
@@ -2246,10 +2248,39 @@ func isEditCompose(installed model.AppInstall) bool {
if rawCompose == "" || err != nil {
return false
}
if rawCompose != installed.DockerCompose {
return true
equal, err := composeEqualExceptImage(rawCompose, installed.DockerCompose)
if err != nil {
return false
}
return !equal
}
func composeEqualExceptImage(expected, current string) (bool, error) {
expectedCompose := make(map[string]interface{})
if err := yaml.Unmarshal([]byte(expected), &expectedCompose); err != nil {
return false, err
}
currentCompose := make(map[string]interface{})
if err := yaml.Unmarshal([]byte(current), &currentCompose); err != nil {
return false, err
}
removeComposeServiceImages(expectedCompose)
removeComposeServiceImages(currentCompose)
return reflect.DeepEqual(expectedCompose, currentCompose), nil
}
func removeComposeServiceImages(composeMap map[string]interface{}) {
services, ok := composeMap["services"].(map[string]interface{})
if !ok {
return
}
for _, service := range services {
serviceMap, ok := service.(map[string]interface{})
if !ok {
continue
}
delete(serviceMap, "image")
}
return false
}
func getAppVersions(key string, details []model.AppDetail) []string {
+3
View File
@@ -44,10 +44,13 @@ type IBackupService interface {
MysqlBackup(db dto.CommonBackup) error
PostgresqlBackup(db dto.CommonBackup) error
MongodbBackup(db dto.CommonBackup) error
MysqlRecover(db dto.CommonRecover) error
PostgresqlRecover(db dto.CommonRecover) error
MongodbRecover(db dto.CommonRecover) error
MysqlRecoverByUpload(req dto.CommonRecover) error
PostgresqlRecoverByUpload(req dto.CommonRecover) error
MongodbRecoverByUpload(req dto.CommonRecover) error
RedisBackup(db dto.CommonBackup) error
RedisRecover(db dto.CommonRecover) error
+588
View File
@@ -0,0 +1,588 @@
package service
import (
"bytes"
"context"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path"
"regexp"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/files"
dockerImage "github.com/docker/docker/api/types/image"
dockerClient "github.com/docker/docker/client"
)
const opensslSaltedHeader = "Salted__"
func (u *BackupService) MongodbBackup(req dto.CommonBackup) error {
timeNow := time.Now().Format(constant.DateTimeSlimLayout)
itemDir := fmt.Sprintf("database/%s/%s/%s", req.Type, req.Name, req.DetailName)
targetDir := path.Join(global.Dir.LocalBackupDir, itemDir)
fileName := fmt.Sprintf("%s_%s.gz", req.DetailName, timeNow+common.RandStrAndNum(5))
record := &model.BackupRecord{
Type: req.Type,
Name: req.Name,
DetailName: req.DetailName,
SourceAccountIDs: "1",
DownloadAccountID: 1,
FileDir: itemDir,
FileName: fileName,
TaskID: req.TaskID,
Status: constant.StatusWaiting,
Description: req.Description,
}
if err := backupRepo.CreateRecord(record); err != nil {
global.LOG.Errorf("save backup record failed, err: %v", err)
return err
}
if err := handleMongodbBackup(req, nil, record.ID, targetDir, fileName); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return err
}
return nil
}
func (u *BackupService) MongodbRecover(req dto.CommonRecover) error {
return handleMongodbRecover(req, nil, false)
}
func (u *BackupService) MongodbRecoverByUpload(req dto.CommonRecover) error {
return handleMongodbRecover(req, nil, false)
}
func handleMongodbBackup(req dto.CommonBackup, parentTask *task.Task, recordID uint, targetDir, fileName string) error {
dbItem, err := mongodbRepo.Get(repo.WithByName(req.DetailName), mongodbRepo.WithByMongodbName(req.Name))
if err != nil {
return err
}
itemName := fmt.Sprintf("%s[%s] - %s", req.Name, req.Type, req.DetailName)
backupTask := parentTask
if backupTask == nil {
backupTask, err = task.NewTaskWithOps(itemName, task.TaskBackup, task.TaskScopeBackup, req.TaskID, dbItem.ID)
if err != nil {
return err
}
}
itemHandler := func(t *task.Task) error {
return doMongodbBackup(req.Name, req.Type, req.DetailName, targetDir, fileName, req.Secret, t)
}
if parentTask != nil {
return itemHandler(parentTask)
}
backupTask.AddSubTaskWithOps(
task.GetTaskName(itemName, task.TaskBackup, task.TaskScopeBackup),
func(t *task.Task) error { return itemHandler(t) },
nil,
0,
3*time.Hour,
)
go func() {
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
}()
return nil
}
func handleMongodbRecover(req dto.CommonRecover, parentTask *task.Task, isRollback bool) error {
dbItem, err := mongodbRepo.Get(repo.WithByName(req.DetailName), mongodbRepo.WithByMongodbName(req.Name))
if err != nil {
return err
}
itemName := fmt.Sprintf("%s[%s] - %s", req.Name, req.Type, req.DetailName)
recoverTask := parentTask
if recoverTask == nil {
recoverTask, err = task.NewTaskWithOps(itemName, task.TaskRecover, task.TaskScopeBackup, req.TaskID, dbItem.ID)
if err != nil {
return err
}
}
recoverDatabase := func(t *task.Task) error {
fileOp := files.NewFileOp()
if !fileOp.Stat(req.File) {
return buserr.WithName("ErrFileNotFound", req.File)
}
restoreFile, cleanup, err := prepareMongodbBackupFileForRestore(req.File, req.Secret, t)
if err != nil {
return err
}
defer cleanup()
isOk := false
if !isRollback {
rollbackFile := path.Join(
global.Dir.TmpDir,
fmt.Sprintf("database/%s/%s_%s.gz", req.Type, req.DetailName, time.Now().Format(constant.DateTimeSlimLayout)),
)
if err := doMongodbBackup(req.Name, req.Type, req.DetailName, path.Dir(rollbackFile), path.Base(rollbackFile), "", t); err != nil {
return fmt.Errorf("backup mongodb db %s for rollback before recover failed, err: %v", req.DetailName, err)
}
defer func() {
if !isOk {
global.LOG.Info("recover failed, start to rollback now")
if err := doMongodbRestore(req.Name, req.Type, req.DetailName, rollbackFile, t); err != nil {
global.LOG.Errorf("rollback mongodb db %s from %s failed, err: %v", req.DetailName, rollbackFile, err)
} else {
global.LOG.Infof("rollback mongodb db %s from %s successful", req.DetailName, rollbackFile)
}
}
_ = os.RemoveAll(rollbackFile)
}()
}
if req.DropAllCollections {
if err := clearMongodbDatabase(req.Name, req.Type, req.DetailName, t); err != nil {
return err
}
}
if err := doMongodbRestore(req.Name, req.Type, req.DetailName, restoreFile, t); err != nil {
global.LOG.Errorf("recover mongodb db %s from %s failed, err: %v", req.DetailName, restoreFile, err)
return err
}
isOk = true
return nil
}
if parentTask != nil {
return recoverDatabase(parentTask)
}
var timeout time.Duration
switch req.Timeout {
case -1:
timeout = 0
case 0:
timeout = 3 * time.Hour
default:
timeout = time.Duration(req.Timeout) * time.Second
}
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("TaskRecover"), recoverDatabase, nil, 0, timeout)
go func() {
_ = recoverTask.Execute()
}()
return nil
}
func doMongodbBackup(database, dbType, dbName, targetDir, fileName, secret string, taskItem *task.Task) error {
dbItem, err := mongodbRepo.Get(repo.WithByName(dbName), mongodbRepo.WithByMongodbName(database))
if err == nil && dbItem.From == constant.AppResourceRemote {
if err := doRemoteMongodbBackup(database, dbName, targetDir, fileName, taskItem); err != nil {
return err
}
if len(secret) != 0 {
return files.OpensslEncrypt(path.Join(targetDir, fileName), secret)
}
return nil
}
appInfo, err := appInstallRepo.LoadBaseInfo(dbType, database)
if err != nil {
return err
}
if appInfo.ContainerName == "" {
return fmt.Errorf("mongodb container not found for database %s", database)
}
if err := os.MkdirAll(targetDir, constant.DirPerm); err != nil {
return err
}
targetFile := path.Join(targetDir, fileName)
containerFile := path.Join("/tmp", fileName)
defer func() {
_ = cmd.NewCommandMgr().Run("docker", "exec", appInfo.ContainerName, "rm", "-f", containerFile)
}()
uri := buildMongodbDumpURI(appInfo.UserName, appInfo.Password, dbName)
cmdMgr := mongodbCmdMgr(taskItem)
if err := cmdMgr.Run(
"docker",
"exec",
appInfo.ContainerName,
"mongodump",
"--uri="+uri,
"--archive="+containerFile,
"--gzip",
); err != nil {
return err
}
if err := cmdMgr.Run("docker", "cp", fmt.Sprintf("%s:%s", appInfo.ContainerName, containerFile), targetFile); err != nil {
return err
}
if len(secret) != 0 {
return files.OpensslEncrypt(targetFile, secret)
}
return nil
}
func doMongodbRestore(database, dbType, dbName, sourceFile string, taskItem *task.Task) error {
dbItem, err := mongodbRepo.Get(repo.WithByName(dbName), mongodbRepo.WithByMongodbName(database))
if err == nil && dbItem.From == constant.AppResourceRemote {
return doRemoteMongodbRestore(database, dbName, sourceFile, taskItem)
}
appInfo, err := appInstallRepo.LoadBaseInfo(dbType, database)
if err != nil {
return err
}
if appInfo.ContainerName == "" {
return fmt.Errorf("mongodb container not found for database %s", database)
}
containerFile := path.Join("/tmp", fmt.Sprintf("1panel-mongodb-restore-%s.gz", common.RandStrAndNum(8)))
defer func() {
_ = cmd.NewCommandMgr().Run("docker", "exec", appInfo.ContainerName, "rm", "-f", containerFile)
}()
cmdMgr := mongodbCmdMgr(taskItem)
if err := cmdMgr.Run("docker", "cp", sourceFile, fmt.Sprintf("%s:%s", appInfo.ContainerName, containerFile)); err != nil {
return err
}
uri := buildMongodbRestoreURI(appInfo.UserName, appInfo.Password)
if err := cmdMgr.Run(
"docker",
"exec",
appInfo.ContainerName,
"mongorestore",
"--uri="+uri,
"--nsInclude="+buildMongodbNamespace(sourceFile, dbName),
"--nsFrom="+buildMongodbNamespace(sourceFile, dbName),
"--nsTo="+dbName+".*",
"--archive="+containerFile,
"--gzip",
"--drop",
); err != nil {
return err
}
return nil
}
func prepareMongodbBackupFileForRestore(filePath, secret string, taskItem *task.Task) (string, func(), error) {
isEncrypted, err := isOpenSSLEncryptedMongodbBackup(filePath)
if err != nil {
return "", nil, err
}
if !isEncrypted {
return filePath, func() {}, nil
}
if secret == "" {
return "", nil, buserr.New("ErrBadDecrypt")
}
if err := files.OpensslDecrypt(filePath, secret); err != nil {
return "", nil, err
}
restoreFile := path.Join(path.Dir(filePath), "tmp_"+path.Base(filePath))
taskItem.LogWithStatus(i18n.GetMsgByKey("Decrypt"), nil)
return restoreFile, func() { _ = os.Remove(restoreFile) }, nil
}
func isOpenSSLEncryptedMongodbBackup(filePath string) (bool, error) {
file, err := os.Open(filePath)
if err != nil {
return false, err
}
defer file.Close()
header := make([]byte, len(opensslSaltedHeader))
n, err := io.ReadFull(file, header)
if err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
return false, nil
}
return false, err
}
return n == len(opensslSaltedHeader) && string(header) == opensslSaltedHeader, nil
}
func clearMongodbDatabase(database, dbType, dbName string, taskItem *task.Task) error {
dbItem, err := mongodbRepo.Get(repo.WithByName(dbName), mongodbRepo.WithByMongodbName(database))
if err == nil && dbItem.From == constant.AppResourceRemote {
return clearRemoteMongodbDatabase(database, dbName, taskItem)
}
appInfo, err := appInstallRepo.LoadBaseInfo(dbType, database)
if err != nil {
return err
}
if appInfo.ContainerName == "" {
return fmt.Errorf("mongodb container not found for database %s", database)
}
logRemoteMongodbStep(taskItem, fmt.Sprintf("clear local mongodb database %s before restore", dbName))
uri := buildMongodbRestoreURI(appInfo.UserName, appInfo.Password)
return mongodbCmdMgr(taskItem).Run(
"docker",
"exec",
appInfo.ContainerName,
"mongosh",
uri,
"--quiet",
"--eval",
fmt.Sprintf(`db.getSiblingDB(%q).dropDatabase()`, dbName),
)
}
func clearRemoteMongodbDatabase(database, dbName string, taskItem *task.Task) error {
info, err := loadRemoteMongodbConnection(database)
if err != nil {
return err
}
client, ctx, cancel, err := newRemoteMongodbClient(info)
if err != nil {
return err
}
defer cancel()
defer client.Disconnect(context.Background())
logRemoteMongodbStep(taskItem, fmt.Sprintf("clear remote mongodb database %s before restore", dbName))
if err := client.Database(dbName).Drop(ctx); err != nil {
return fmt.Errorf("drop mongodb database %s failed, err: %v", dbName, err)
}
return nil
}
func buildMongodbDumpURI(username, password, dbName string) string {
return (&url.URL{
Scheme: "mongodb",
User: url.UserPassword(username, password),
Host: "127.0.0.1:27017",
Path: "/" + dbName,
RawQuery: "authSource=admin",
}).String()
}
func buildMongodbRestoreURI(username, password string) string {
return (&url.URL{
Scheme: "mongodb",
User: url.UserPassword(username, password),
Host: "127.0.0.1:27017",
Path: "/",
RawQuery: "authSource=admin",
}).String()
}
func mongodbCmdMgr(taskItem *task.Task) *cmd.CommandHelper {
if taskItem == nil {
return cmd.NewCommandMgr(cmd.WithTimeout(3 * time.Hour))
}
return cmd.NewCommandMgr(cmd.WithTimeout(3*time.Hour), cmd.WithTask(*taskItem))
}
func doRemoteMongodbBackup(database, dbName, targetDir, fileName string, taskItem *task.Task) error {
info, err := loadRemoteMongodbConnection(database)
if err != nil {
return err
}
imageTag, err := ensureMongodbImage(database, taskItem)
if err != nil {
return err
}
logRemoteMongodbImage(taskItem, "backup", database, dbName, imageTag, info)
logRemoteMongodbStep(taskItem, fmt.Sprintf("local image %s is ready, start backup", imageTag))
if err := os.MkdirAll(targetDir, constant.DirPerm); err != nil {
return err
}
targetFile, err := os.OpenFile(path.Join(targetDir, fileName), os.O_RDWR|os.O_CREATE|os.O_TRUNC, constant.DirPerm)
if err != nil {
return fmt.Errorf("open file %s failed, err: %v", path.Join(targetDir, fileName), err)
}
defer func() { _ = targetFile.Close() }()
backupCmd := exec.Command(
"docker",
"run",
"--rm",
"--net=host",
"-i",
imageTag,
"mongodump",
"--uri="+buildRemoteMongodbURI(info),
"--db="+dbName,
"--archive",
"--gzip",
)
backupCmd.Stdout = targetFile
var stderr bytes.Buffer
backupCmd.Stderr = &stderr
if err := backupCmd.Run(); err != nil {
return fmt.Errorf("handle backup mongodb database failed, err: %s", strings.TrimSpace(stderr.String()))
}
return nil
}
func doRemoteMongodbRestore(database, dbName, sourceFile string, taskItem *task.Task) error {
info, err := loadRemoteMongodbConnection(database)
if err != nil {
return err
}
imageTag, err := ensureMongodbImage(database, taskItem)
if err != nil {
return err
}
logRemoteMongodbImage(taskItem, "restore", database, dbName, imageTag, info)
logRemoteMongodbStep(taskItem, fmt.Sprintf("local image %s is ready, start restore", imageTag))
fi, err := os.Open(sourceFile)
if err != nil {
return err
}
defer func() { _ = fi.Close() }()
restoreCmd := exec.Command(
"docker",
"run",
"--rm",
"--net=host",
"-i",
imageTag,
"mongorestore",
"--uri="+buildRemoteMongodbURI(info),
"--nsInclude="+buildMongodbNamespace(sourceFile, dbName),
"--nsFrom="+buildMongodbNamespace(sourceFile, dbName),
"--nsTo="+dbName+".*",
"--archive",
"--gzip",
"--drop",
)
restoreCmd.Stdin = fi
var stderr bytes.Buffer
restoreCmd.Stderr = &stderr
if err := restoreCmd.Run(); err != nil {
return fmt.Errorf("handle recover mongodb database failed, err: %s", strings.TrimSpace(stderr.String()))
}
return nil
}
func ensureMongodbImage(database string, taskItem *task.Task) (string, error) {
imageTag, exists, err := loadMongodbImageTag(database)
if err != nil {
return "", err
}
logRemoteMongodbStep(taskItem, fmt.Sprintf("check local image %s", imageTag))
if exists {
logRemoteMongodbStep(taskItem, fmt.Sprintf("local image %s exists", imageTag))
return imageTag, nil
}
logRemoteMongodbStep(taskItem, fmt.Sprintf("local image %s not found, start docker pull", imageTag))
if err := mongodbCmdMgr(taskItem).Run("docker", "pull", imageTag); err != nil {
return "", err
}
logRemoteMongodbStep(taskItem, fmt.Sprintf("docker pull %s finished", imageTag))
return imageTag, nil
}
func loadMongodbImageTag(database string) (string, bool, error) {
databaseInfo, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return "", false, err
}
cli, err := dockerClient.NewClientWithOpts(dockerClient.FromEnv, dockerClient.WithAPIVersionNegotiation())
if err != nil {
return "", false, err
}
defer cli.Close()
images, err := cli.ImageList(context.Background(), dockerImage.ListOptions{})
if err != nil {
return "", false, err
}
imagePrefix := "mongo:" + loadMongodbImageMajor(databaseInfo.Version)
for _, image := range images {
for _, tag := range image.RepoTags {
if strings.HasPrefix(tag, imagePrefix) {
return tag, true, nil
}
}
}
return imagePrefix, false, nil
}
func buildMongodbNamespace(sourceFile, targetDB string) string {
sourceDB := loadMongodbBackupDBName(sourceFile, targetDB)
return sourceDB + ".*"
}
func loadMongodbBackupDBName(sourceFile, defaultDB string) string {
baseName := path.Base(sourceFile)
if strings.HasSuffix(baseName, ".gz") {
baseName = strings.TrimSuffix(baseName, ".gz")
}
// Encrypted backups are restored from a decrypted temp file like tmp_<original>.
// Strip the temp prefix before deriving the original database name.
baseName = strings.TrimPrefix(baseName, "tmp_")
patterns := []*regexp.Regexp{
regexp.MustCompile(`^1panel_mongodb_(.+)_\d{14}[A-Za-z0-9]*$`),
regexp.MustCompile(`^db_(.+)_\d{14}[A-Za-z0-9]*$`),
regexp.MustCompile(`^(.+)_\d{14}[A-Za-z0-9]*$`),
}
for _, pattern := range patterns {
matches := pattern.FindStringSubmatch(baseName)
if len(matches) == 2 && len(matches[1]) != 0 {
return matches[1]
}
}
return defaultDB
}
func loadMongodbImageMajor(version string) string {
switch {
case strings.HasPrefix(version, "6"):
return "6"
case strings.HasPrefix(version, "7"):
return "7"
default:
return "8"
}
}
func logRemoteMongodbImage(taskItem *task.Task, action, database, dbName, imageTag string, info mongodbConnectionInfo) {
message := fmt.Sprintf(
"use local docker image %s to %s remote mongodb %s/%s via %s:%d",
imageTag,
action,
database,
dbName,
info.Address,
info.Port,
)
global.LOG.Info(message)
if taskItem != nil {
taskItem.Log(message)
}
}
func logRemoteMongodbStep(taskItem *task.Task, message string) {
global.LOG.Info(message)
if taskItem != nil {
taskItem.Log(message)
}
}
+9
View File
@@ -287,6 +287,15 @@ func (u *CronjobService) Import(req []dto.CronjobTrans) error {
}
dbIDs = append(dbIDs, fmt.Sprintf("%v", dbItem.ID))
}
} else if cronjob.DBType == constant.AppMongodb {
for _, db := range item.DBNames {
dbItem, err := mongodbRepo.Get(mongodbRepo.WithByMongodbName(db.Name), repo.WithByName(db.DetailName))
if err != nil {
hasNotFound = true
continue
}
dbIDs = append(dbIDs, fmt.Sprintf("%v", dbItem.ID))
}
} else {
for _, db := range item.DBNames {
dbItem, err := mysqlRepo.Get(mysqlRepo.WithByMysqlName(db.Name), repo.WithByName(db.DetailName))
+46 -1
View File
@@ -165,7 +165,20 @@ func (u *CronjobService) handleDatabase(cronjob model.Cronjob, startTime time.Ti
record.DownloadAccountID, record.SourceAccountIDs = cronjob.DownloadAccountID, cronjob.SourceAccountIDs
backupDir := path.Join(global.Dir.LocalBackupDir, fmt.Sprintf("tmp/database/%s/%s/%s", dbInfo.DBType, record.Name, dbInfo.Name))
record.FileName = simplifiedFileName(fmt.Sprintf("db_%s_%s.sql.gz", dbInfo.Name, startTime.Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5)))
switch dbInfo.DBType {
case constant.AppMongodb:
record.FileName = simplifiedFileName(fmt.Sprintf(
"db_%s_%s.gz",
dbInfo.Name,
startTime.Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5),
))
default:
record.FileName = simplifiedFileName(fmt.Sprintf(
"db_%s_%s.sql.gz",
dbInfo.Name,
startTime.Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5),
))
}
if cronjob.DBType == "mysql" || cronjob.DBType == "mariadb" || cronjob.DBType == "mysql-cluster" {
if err := doMysqlBackup(dbInfo, backupDir, record.FileName, cronjob.Secret); err != nil {
if retry < int(cronjob.RetryTimes) || !cronjob.IgnoreErr {
@@ -177,6 +190,17 @@ func (u *CronjobService) handleDatabase(cronjob model.Cronjob, startTime time.Ti
return nil
}
}
} else if cronjob.DBType == constant.AppMongodb {
if err := doMongodbBackup(dbInfo.Database, dbInfo.DBType, dbInfo.Name, backupDir, record.FileName, cronjob.Secret, task); err != nil {
if retry < int(cronjob.RetryTimes) || !cronjob.IgnoreErr {
retry++
return err
} else {
task.Log(i18n.GetMsgWithDetail("IgnoreBackupErr", err.Error()))
cleanAccountMap(accountMap)
return nil
}
}
} else {
if err := doPostgresqlBackup(dbInfo, backupDir, record.FileName, cronjob.Secret, taskItem); err != nil {
if retry < int(cronjob.RetryTimes) || !cronjob.IgnoreErr {
@@ -404,6 +428,18 @@ func loadDbsForJob(cronjob model.Cronjob) []DatabaseHelper {
Args: strings.Split(cronjob.Args, ","),
})
}
} else if cronjob.DBType == constant.AppMongodb {
databaseService := NewIDatabaseService()
mongodbItems, _ := databaseService.LoadItems(cronjob.DBType)
for _, mongodb := range mongodbItems {
dbs = append(dbs, DatabaseHelper{
ID: mongodb.ID,
DBType: cronjob.DBType,
Database: mongodb.Database,
Name: mongodb.Name,
Args: strings.Split(cronjob.Args, ","),
})
}
} else {
pgItems, _ := postgresqlRepo.List()
for _, pg := range pgItems {
@@ -430,6 +466,15 @@ func loadDbsForJob(cronjob model.Cronjob) []DatabaseHelper {
Name: mysqlItem.Name,
Args: strings.Split(cronjob.Args, ","),
})
} else if cronjob.DBType == constant.AppMongodb {
mongodbItem, _ := mongodbRepo.Get(repo.WithByID(uint(itemID)))
dbs = append(dbs, DatabaseHelper{
ID: mongodbItem.ID,
DBType: cronjob.DBType,
Database: mongodbItem.MongodbName,
Name: mongodbItem.Name,
Args: strings.Split(cronjob.Args, ","),
})
} else {
pgItem, _ := postgresqlRepo.Get(repo.WithByID(uint(itemID)))
dbs = append(dbs, DatabaseHelper{
+47 -1
View File
@@ -90,7 +90,7 @@ func (u *DatabaseService) LoadItems(dbType string) ([]dto.DatabaseItem, error) {
dbs, err := databaseRepo.GetList(databaseRepo.WithTypeList(dbType))
var datas []dto.DatabaseItem
for _, db := range dbs {
if dbType == constant.AppPostgresql || dbType == constant.AppPostgresqlCluster {
if db.Type == constant.AppPostgresql || db.Type == constant.AppPostgresqlCluster {
items, _ := postgresqlRepo.List(postgresqlRepo.WithByPostgresqlName(db.Name))
for _, item := range items {
var dItem dto.DatabaseItem
@@ -100,6 +100,16 @@ func (u *DatabaseService) LoadItems(dbType string) ([]dto.DatabaseItem, error) {
dItem.Database = db.Name
datas = append(datas, dItem)
}
} else if db.Type == constant.AppMongodb {
items, _ := mongodbRepo.List(mongodbRepo.WithByMongodbName(db.Name))
for _, item := range items {
var dItem dto.DatabaseItem
if err := copier.Copy(&dItem, &item); err != nil {
continue
}
dItem.Database = db.Name
datas = append(datas, dItem)
}
} else {
items, _ := mysqlRepo.List(mysqlRepo.WithByMysqlName(db.Name))
for _, item := range items {
@@ -138,6 +148,13 @@ func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
Password: req.Password,
Timeout: req.Timeout,
})
case constant.AppMongodb:
client, ctx, cancel, connectErr := newRemoteMongodbClient(mongodbConnectionInfoFromCreate(req))
if connectErr == nil {
defer cancel()
defer client.Disconnect(ctx)
}
err = connectErr
case "mysql", "mariadb":
_, err = mysql.NewMysqlClient(client.DBInfo{
From: "remote",
@@ -195,6 +212,13 @@ func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
}); err != nil {
return err
}
case constant.AppMongodb:
client, ctx, cancel, err := newRemoteMongodbClient(mongodbConnectionInfoFromCreate(req))
if err != nil {
return err
}
defer cancel()
defer client.Disconnect(ctx)
case "mysql", "mariadb":
if _, err := mysql.NewMysqlClient(client.DBInfo{
From: "remote",
@@ -265,6 +289,10 @@ func (u *DatabaseService) Delete(req dto.DatabaseDelete) error {
if err := mysqlRepo.Delete(context.Background(), mysqlRepo.WithByMysqlName(db.Name)); err != nil && !req.ForceDelete {
return err
}
} else if db.Type == constant.AppMongodb {
if err := mongodbRepo.Delete(context.Background(), mongodbRepo.WithByMongodbName(db.Name)); err != nil && !req.ForceDelete {
return err
}
} else {
if err := postgresqlRepo.Delete(context.Background(), postgresqlRepo.WithByPostgresqlName(db.Name)); err != nil && !req.ForceDelete {
return err
@@ -297,6 +325,24 @@ func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
}); err != nil {
return err
}
case constant.AppMongodb:
client, ctx, cancel, err := newRemoteMongodbClient(mongodbConnectionInfoFromCreate(dto.DatabaseCreate{
Address: req.Address,
Port: req.Port,
Username: req.Username,
Password: req.Password,
Timeout: req.Timeout,
SSL: req.SSL,
RootCert: req.RootCert,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
SkipVerify: req.SkipVerify,
}))
if err != nil {
return err
}
defer cancel()
defer client.Disconnect(ctx)
case "mysql", "mariadb":
if _, err := mysql.NewMysqlClient(client.DBInfo{
From: "remote",
+948
View File
@@ -0,0 +1,948 @@
package service
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"github.com/jinzhu/copier"
"go.mongodb.org/mongo-driver/bson"
)
type MongodbService struct{}
type IMongodbService interface {
SearchWithPage(search dto.MongodbDBSearch) (int64, interface{}, error)
Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error)
LoadFromRemote(req dto.MongodbLoadDB) error
UpdateDescription(req dto.UpdateDescription) error
BindUser(req dto.MongodbBind) error
ChangePassword(req dto.MongodbPassword) error
ChangeRootPassword(req dto.ChangeDBInfo) error
LoadPrivileges(req dto.MongodbPrivilegesLoad) (string, error)
ChangePrivileges(req dto.MongodbPrivileges) error
DeleteCheck(req dto.MongodbDBDeleteCheck) ([]dto.DBResource, error)
Delete(ctx context.Context, req dto.MongodbDBDelete) error
}
func NewIMongodbService() IMongodbService {
return &MongodbService{}
}
func (u *MongodbService) SearchWithPage(search dto.MongodbDBSearch) (int64, interface{}, error) {
total, mongodbs, err := mongodbRepo.Page(
search.Page,
search.PageSize,
mongodbRepo.WithByMongodbName(search.Database),
repo.WithByLikeName(search.Info),
repo.WithOrderRuleBy(search.OrderBy, search.Order),
)
var dtoMongodbs []dto.MongodbDBInfo
for _, mongodb := range mongodbs {
var item dto.MongodbDBInfo
if err := copier.Copy(&item, &mongodb); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
dtoMongodbs = append(dtoMongodbs, item)
}
return total, dtoMongodbs, err
}
func (u *MongodbService) UpdateDescription(req dto.UpdateDescription) error {
return mongodbRepo.Update(req.ID, map[string]interface{}{"description": req.Description})
}
func (u *MongodbService) Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password) {
return nil, buserr.New("ErrCmdIllegal")
}
if !isSupportedMongodbPrivilege(req.Permission) {
return nil, buserr.New("ErrCmdIllegal")
}
mongodb, _ := mongodbRepo.Get(repo.WithByName(req.Name), mongodbRepo.WithByMongodbName(req.Database), repo.WithByFrom(req.From))
if mongodb.ID != 0 {
return nil, buserr.New("ErrRecordExist")
}
var createItem model.DatabaseMongodb
if err := copier.Copy(&createItem, &req); err != nil {
return nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
createItem.MongodbName = req.Database
if err := runMongodbCreate(req.From, req.Database, req.Name, req.Username, req.Password, req.Permission); err != nil {
return nil, err
}
global.LOG.Infof("create mongodb database %s successful", req.Name)
if err := mongodbRepo.Create(ctx, &createItem); err != nil {
return nil, err
}
return &createItem, nil
}
func (u *MongodbService) LoadFromRemote(req dto.MongodbLoadDB) error {
databases, err := mongodbRepo.List(mongodbRepo.WithByMongodbName(req.Database))
if err != nil {
return err
}
datas, err := loadMongodbDatabases(req)
if err != nil {
return err
}
global.LOG.Infof("sync mongodb databases from %s:%s, found %d items", req.From, req.Database, len(datas))
deleteList := append([]model.DatabaseMongodb(nil), databases...)
for _, data := range datas {
hasOld := false
for i := 0; i < len(databases); i++ {
if strings.EqualFold(databases[i].Name, data.Name) && strings.EqualFold(databases[i].MongodbName, req.Database) {
hasOld = true
updateMap := map[string]interface{}{"is_delete": false}
if len(data.Username) != 0 {
updateMap["username"] = data.Username
}
_ = mongodbRepo.Update(databases[i].ID, updateMap)
for j := 0; j < len(deleteList); j++ {
if deleteList[j].ID == databases[i].ID {
deleteList = append(deleteList[:j], deleteList[j+1:]...)
break
}
}
break
}
}
if !hasOld {
createItem := model.DatabaseMongodb{
Name: data.Name,
From: req.From,
MongodbName: req.Database,
Username: data.Username,
Password: "",
Description: "",
}
if err := mongodbRepo.Create(context.Background(), &createItem); err != nil {
return err
}
}
}
for _, delItem := range deleteList {
_ = mongodbRepo.Update(delItem.ID, map[string]interface{}{"is_delete": true})
}
return nil
}
func (u *MongodbService) BindUser(req dto.MongodbBind) error {
if cmd.CheckIllegal(req.Database, req.Name, req.Username, req.Password) {
return buserr.New("ErrCmdIllegal")
}
dbItem, err := mongodbRepo.Get(mongodbRepo.WithByMongodbName(req.Database), repo.WithByName(req.Name))
if err != nil {
return err
}
if err := bindMongodbUser(dbItem.From, dbItem.MongodbName, dbItem.Name, req.Username, req.Password); err != nil {
return err
}
pass, err := encrypt.StringEncrypt(req.Password)
if err != nil {
return fmt.Errorf("encrypt mongodb db %s password failed, err: %v", req.Name, err)
}
return mongodbRepo.Update(dbItem.ID, map[string]interface{}{
"username": req.Username,
"password": pass,
})
}
func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
if cmd.CheckIllegal(req.Database, req.Name, req.Password) {
return buserr.New("ErrCmdIllegal")
}
dbItem, err := mongodbRepo.Get(mongodbRepo.WithByMongodbName(req.Database), repo.WithByName(req.Name))
if err != nil {
return err
}
if dbItem.Username == "" {
return buserr.New("ErrRecordNotFound")
}
if err := updateMongodbPassword(dbItem.From, dbItem.MongodbName, dbItem.Name, dbItem.Username, req.Password); err != nil {
return err
}
pass, err := encrypt.StringEncrypt(req.Password)
if err != nil {
return fmt.Errorf("encrypt mongodb db %s password failed, err: %v", req.Name, err)
}
return mongodbRepo.Update(dbItem.ID, map[string]interface{}{"password": pass})
}
func (u *MongodbService) ChangeRootPassword(req dto.ChangeDBInfo) error {
if cmd.CheckIllegal(req.Value) {
return buserr.New("ErrCmdIllegal")
}
if req.From != constant.AppResourceLocal {
return buserr.New("ErrRecordNotFound")
}
appInfo, err := appInstallRepo.LoadBaseInfo(req.Type, req.Database)
if err != nil {
return err
}
if appInfo.UserName == "" {
return buserr.New("ErrRecordNotFound")
}
if err := updateMongodbPassword(req.From, req.Database, "admin", appInfo.UserName, req.Value); err != nil {
return err
}
if err := updateInstallInfoInDB(req.Type, req.Database, "password", req.Value); err != nil {
return err
}
remote, err := databaseRepo.Get(repo.WithByName(req.Database))
if err != nil {
return err
}
pass, err := encrypt.StringEncrypt(req.Value)
if err != nil {
return fmt.Errorf("encrypt mongodb root password failed, err: %v", err)
}
_ = databaseRepo.Update(remote.ID, map[string]interface{}{"password": pass})
return nil
}
func (u *MongodbService) DeleteCheck(req dto.MongodbDBDeleteCheck) ([]dto.DBResource, error) {
var res []dto.DBResource
db, err := mongodbRepo.Get(repo.WithByID(req.ID))
if err != nil {
return res, err
}
if db.From == "local" {
app, err := appInstallRepo.LoadBaseInfo(req.Type, req.Database)
if err != nil {
return res, err
}
apps, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithLinkId(app.ID), appInstallResourceRepo.WithResourceId(db.ID))
for _, app := range apps {
appInstall, _ := appInstallRepo.GetFirst(repo.WithByID(app.AppInstallId))
if appInstall.ID != 0 {
res = append(res, dto.DBResource{
Type: constant.TypeApp,
Name: appInstall.Name,
})
}
}
} else {
apps, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithResourceId(db.ID), appRepo.WithKey(req.Type))
for _, app := range apps {
appInstall, _ := appInstallRepo.GetFirst(repo.WithByID(app.AppInstallId))
if appInstall.ID != 0 {
res = append(res, dto.DBResource{
Type: constant.TypeApp,
Name: appInstall.Name,
})
}
}
}
return res, nil
}
func (u *MongodbService) LoadPrivileges(req dto.MongodbPrivilegesLoad) (string, error) {
dbItem, err := mongodbRepo.Get(mongodbRepo.WithByMongodbName(req.Database), repo.WithByName(req.Name))
if err != nil {
return "", err
}
return loadMongodbPrivilege(dbItem.From, dbItem.MongodbName, dbItem.Name, req.Username)
}
func (u *MongodbService) ChangePrivileges(req dto.MongodbPrivileges) error {
if cmd.CheckIllegal(req.Database, req.Username) {
return buserr.New("ErrCmdIllegal")
}
if !isSupportedMongodbPrivilege(req.Permission) {
return buserr.New("ErrCmdIllegal")
}
dbItem, err := mongodbRepo.Get(mongodbRepo.WithByMongodbName(req.Database), repo.WithByName(req.Name))
if err != nil {
return err
}
return updateMongodbPrivilege(dbItem.From, dbItem.MongodbName, dbItem.Name, req.Username, req.Permission)
}
func (u *MongodbService) Delete(ctx context.Context, req dto.MongodbDBDelete) error {
db, err := mongodbRepo.Get(repo.WithByID(req.ID))
if err != nil && !req.ForceDelete {
return err
}
if err := runMongodbDelete(db.From, req.Database, db.Name); err != nil && !req.ForceDelete {
return err
}
if req.DeleteBackup {
uploadDir := filepath.Join(global.Dir.DataDir, fmt.Sprintf("uploads/database/%s/%s/%s", req.Type, req.Database, db.Name))
if _, err := os.Stat(uploadDir); err == nil {
_ = os.RemoveAll(uploadDir)
}
backupDir := filepath.Join(global.Dir.LocalBackupDir, fmt.Sprintf("database/%s/%s/%s", req.Type, req.Database, db.Name))
if _, err := os.Stat(backupDir); err == nil {
_ = os.RemoveAll(backupDir)
}
_ = backupRepo.DeleteRecord(ctx, repo.WithByType(req.Type), repo.WithByName(req.Database), repo.WithByDetailName(db.Name))
global.LOG.Infof("delete mongodb database %s-%s backups successful", req.Database, db.Name)
}
_ = mongodbRepo.Delete(ctx, repo.WithByID(db.ID))
return nil
}
func runMongodbCreate(from, database, dbName, username, password, permission string) error {
if from == constant.AppResourceRemote {
return runRemoteMongodbCreate(database, dbName, username, password, permission)
}
script, err := buildMongodbCreateScript(dbName, username, password, permission)
if err != nil {
return err
}
return runMongodbAdminScript(database, script)
}
func runMongodbDelete(from, database, dbName string) error {
if from == constant.AppResourceRemote {
return runRemoteMongodbDelete(database, dbName)
}
script, err := buildMongodbDeleteScript(dbName)
if err != nil {
return err
}
return runMongodbAdminScript(database, script)
}
func bindMongodbUser(from, connectionName, dbName, username, password string) error {
if from == constant.AppResourceRemote {
return bindRemoteMongodbUser(connectionName, dbName, username, password)
}
script, err := buildMongodbBindUserScript(dbName, username, password)
if err != nil {
return err
}
return runMongodbAdminScript(connectionName, script)
}
func updateMongodbPassword(from, connectionName, dbName, username, password string) error {
if from == constant.AppResourceRemote {
return updateRemoteMongodbPasswordOnly(connectionName, dbName, username, password)
}
script, err := buildMongodbPasswordScript(dbName, username, password)
if err != nil {
return err
}
return runMongodbAdminScript(connectionName, script)
}
func runMongodbAdminScript(database, script string) error {
appInfo, err := appInstallRepo.LoadBaseInfo(constant.AppMongodb, database)
if err != nil {
return err
}
if appInfo.ContainerName == "" {
return fmt.Errorf("mongodb container not found for database %s", database)
}
return cmd.NewCommandMgr().Run(
"docker",
"exec",
appInfo.ContainerName,
"mongosh",
buildMongodbRestoreURI(appInfo.UserName, appInfo.Password),
"--quiet",
"--eval",
script,
)
}
func runMongodbAdminScriptWithStdout(database, script string) (string, error) {
appInfo, err := appInstallRepo.LoadBaseInfo(constant.AppMongodb, database)
if err != nil {
return "", err
}
if appInfo.ContainerName == "" {
return "", fmt.Errorf("mongodb container not found for database %s", database)
}
return cmd.NewCommandMgr().RunWithStdout(
"docker",
"exec",
appInfo.ContainerName,
"mongosh",
buildMongodbRestoreURI(appInfo.UserName, appInfo.Password),
"--quiet",
"--eval",
script,
)
}
func buildMongodbCreateScript(dbName, username, password, permission string) (string, error) {
dbNameJSON, err := json.Marshal(dbName)
if err != nil {
return "", err
}
usernameJSON, err := json.Marshal(username)
if err != nil {
return "", err
}
passwordJSON, err := json.Marshal(password)
if err != nil {
return "", err
}
permissionJSON, err := json.Marshal(permission)
if err != nil {
return "", err
}
return strings.TrimSpace(fmt.Sprintf(`
const dbName = %s;
const userName = %s;
const password = %s;
const permission = %s;
const targetDb = db.getSiblingDB(dbName);
targetDb.createCollection("_init");
targetDb.createUser({
user: userName,
pwd: password,
roles: [{ role: permission, db: dbName }]
});
`, dbNameJSON, usernameJSON, passwordJSON, permissionJSON)), nil
}
func buildMongodbDeleteScript(dbName string) (string, error) {
dbNameJSON, err := json.Marshal(dbName)
if err != nil {
return "", err
}
return strings.TrimSpace(fmt.Sprintf(`
const dbName = %s;
const targetDb = db.getSiblingDB(dbName);
const dropUsersResult = targetDb.runCommand({ dropAllUsersFromDatabase: 1 });
if (!dropUsersResult || dropUsersResult.ok !== 1) {
throw new Error("failed to drop users from " + dbName);
}
const dropDatabaseResult = targetDb.runCommand({ dropDatabase: 1 });
if (!dropDatabaseResult || dropDatabaseResult.ok !== 1) {
throw new Error("failed to drop database " + dbName);
}
`, dbNameJSON)), nil
}
func buildMongodbBindUserScript(dbName, username, password string) (string, error) {
dbNameJSON, err := json.Marshal(dbName)
if err != nil {
return "", err
}
usernameJSON, err := json.Marshal(username)
if err != nil {
return "", err
}
passwordJSON, err := json.Marshal(password)
if err != nil {
return "", err
}
return strings.TrimSpace(fmt.Sprintf(`
const dbName = %s;
const userName = %s;
const password = %s;
const targetDb = db.getSiblingDB(dbName);
const userInfo = targetDb.runCommand({
usersInfo: userName,
showCredentials: false,
showCustomData: false
});
if (!userInfo || userInfo.ok !== 1) {
throw new Error("failed to load mongodb user " + userName);
}
const roles = [{ role: "readWrite", db: dbName }];
if (Array.isArray(userInfo.users) && userInfo.users.length > 0) {
const result = targetDb.runCommand({
updateUser: userName,
pwd: password,
roles: roles
});
if (!result || result.ok !== 1) {
throw new Error("failed to update mongodb user " + userName);
}
} else {
const result = targetDb.runCommand({
createUser: userName,
pwd: password,
roles: roles
});
if (!result || result.ok !== 1) {
throw new Error("failed to create mongodb user " + userName);
}
}
`, dbNameJSON, usernameJSON, passwordJSON)), nil
}
func buildMongodbPasswordScript(dbName, username, password string) (string, error) {
dbNameJSON, err := json.Marshal(dbName)
if err != nil {
return "", err
}
usernameJSON, err := json.Marshal(username)
if err != nil {
return "", err
}
passwordJSON, err := json.Marshal(password)
if err != nil {
return "", err
}
return strings.TrimSpace(fmt.Sprintf(`
const dbName = %s;
const userName = %s;
const password = %s;
const targetDb = db.getSiblingDB(dbName);
const result = targetDb.runCommand({
updateUser: userName,
pwd: password
});
if (!result || result.ok !== 1) {
throw new Error("failed to update mongodb user password " + userName);
}
`, dbNameJSON, usernameJSON, passwordJSON)), nil
}
type mongodbSyncItem struct {
Name string `json:"name"`
Username string `json:"username"`
}
func loadMongodbDatabases(req dto.MongodbLoadDB) ([]mongodbSyncItem, error) {
if req.From == constant.AppResourceRemote {
return loadRemoteMongodbDatabases(req.Database)
}
return loadLocalMongodbDatabases(req.Database)
}
func loadLocalMongodbDatabases(database string) ([]mongodbSyncItem, error) {
script := strings.TrimSpace(`
const systemDbs = new Set(["admin", "config", "local"]);
const result = db.adminCommand({ listDatabases: 1, nameOnly: false });
if (!result || result.ok !== 1) {
throw new Error("failed to list mongodb databases");
}
const items = result.databases
.filter(item => !systemDbs.has(item.name))
.map(item => ({ name: item.name, username: "" }));
print("__1panel_json_begin__");
print(JSON.stringify(items));
print("__1panel_json_end__");
`)
stdout, err := runMongodbAdminScriptWithStdout(database, script)
if err != nil {
return nil, err
}
items := make([]mongodbSyncItem, 0)
jsonResult, err := extractMongodbJSONOutput(stdout)
if err != nil {
return nil, err
}
if err := json.Unmarshal(jsonResult, &items); err != nil {
return nil, err
}
return items, nil
}
func runRemoteMongodbCreate(database, dbName, username, password, permission string) error {
info, err := loadRemoteMongodbConnection(database)
if err != nil {
return err
}
client, ctx, cancel, err := newRemoteMongodbClient(info)
if err != nil {
return err
}
defer cancel()
defer client.Disconnect(ctx)
targetDB := client.Database(dbName)
if err := targetDB.CreateCollection(ctx, "_init"); err != nil {
return err
}
return targetDB.RunCommand(ctx, bson.D{
{Key: "createUser", Value: username},
{Key: "pwd", Value: password},
{Key: "roles", Value: bson.A{
bson.D{{Key: "role", Value: permission}, {Key: "db", Value: dbName}},
}},
}).Err()
}
func runRemoteMongodbDelete(database, dbName string) error {
info, err := loadRemoteMongodbConnection(database)
if err != nil {
return err
}
client, ctx, cancel, err := newRemoteMongodbClient(info)
if err != nil {
return err
}
defer cancel()
defer client.Disconnect(ctx)
targetDB := client.Database(dbName)
if err := targetDB.RunCommand(ctx, bson.D{{Key: "dropAllUsersFromDatabase", Value: 1}}).Err(); err != nil {
return err
}
return targetDB.RunCommand(ctx, bson.D{{Key: "dropDatabase", Value: 1}}).Err()
}
func bindRemoteMongodbUser(connectionName, dbName, username, password string) error {
info, err := loadRemoteMongodbConnection(connectionName)
if err != nil {
return err
}
client, ctx, cancel, err := newRemoteMongodbClient(info)
if err != nil {
return err
}
defer cancel()
defer client.Disconnect(ctx)
targetDB := client.Database(dbName)
var userInfo struct {
Users []struct{} `bson:"users"`
}
if err := targetDB.RunCommand(ctx, bson.D{
{Key: "usersInfo", Value: username},
{Key: "showCredentials", Value: false},
{Key: "showCustomData", Value: false},
}).Decode(&userInfo); err != nil {
return err
}
if len(userInfo.Users) > 0 {
return targetDB.RunCommand(ctx, bson.D{
{Key: "updateUser", Value: username},
{Key: "pwd", Value: password},
{Key: "roles", Value: bson.A{
bson.D{{Key: "role", Value: "readWrite"}, {Key: "db", Value: dbName}},
}},
}).Err()
}
return targetDB.RunCommand(ctx, bson.D{
{Key: "createUser", Value: username},
{Key: "pwd", Value: password},
{Key: "roles", Value: bson.A{
bson.D{{Key: "role", Value: "readWrite"}, {Key: "db", Value: dbName}},
}},
}).Err()
}
func updateRemoteMongodbPasswordOnly(connectionName, dbName, username, password string) error {
info, err := loadRemoteMongodbConnection(connectionName)
if err != nil {
return err
}
client, ctx, cancel, err := newRemoteMongodbClient(info)
if err != nil {
return err
}
defer cancel()
defer client.Disconnect(ctx)
targetDB := client.Database(dbName)
return targetDB.RunCommand(ctx, bson.D{
{Key: "updateUser", Value: username},
{Key: "pwd", Value: password},
}).Err()
}
func loadRemoteMongodbDatabases(database string) ([]mongodbSyncItem, error) {
info, err := loadRemoteMongodbConnection(database)
if err != nil {
return nil, err
}
client, ctx, cancel, err := newRemoteMongodbClient(info)
if err != nil {
return nil, err
}
defer cancel()
defer client.Disconnect(ctx)
adminDB := client.Database("admin")
var listResult struct {
Databases []struct {
Name string `bson:"name"`
} `bson:"databases"`
}
if err := adminDB.RunCommand(ctx, bson.D{
{Key: "listDatabases", Value: 1},
{Key: "nameOnly", Value: false},
}).Decode(&listResult); err != nil {
return nil, err
}
systemDbs := map[string]struct{}{
"admin": {},
"config": {},
"local": {},
}
items := make([]mongodbSyncItem, 0, len(listResult.Databases))
for _, databaseItem := range listResult.Databases {
if _, ok := systemDbs[databaseItem.Name]; ok {
continue
}
items = append(items, mongodbSyncItem{
Name: databaseItem.Name,
Username: "",
})
}
return items, nil
}
func loadMongodbPrivilege(from, connectionName, dbName, username string) (string, error) {
if from == constant.AppResourceRemote {
return loadRemoteMongodbPrivilege(connectionName, dbName, username)
}
return loadLocalMongodbPrivilege(connectionName, dbName, username)
}
func updateMongodbPrivilege(from, connectionName, dbName, username, permission string) error {
if from == constant.AppResourceRemote {
return updateRemoteMongodbPrivilege(connectionName, dbName, username, permission)
}
return updateLocalMongodbPrivilege(connectionName, dbName, username, permission)
}
func loadLocalMongodbPrivilege(connectionName, dbName, username string) (string, error) {
databaseJSON, err := json.Marshal(dbName)
if err != nil {
return "", err
}
usernameJSON, err := json.Marshal(username)
if err != nil {
return "", err
}
script := strings.TrimSpace(fmt.Sprintf(`
const dbName = %s;
const userName = %s;
const result = db.getSiblingDB(dbName).runCommand({
usersInfo: userName,
showCredentials: false,
showCustomData: false
});
if (!result || result.ok !== 1) {
throw new Error("failed to load mongodb user privileges");
}
const roles = Array.isArray(result.users) && result.users.length > 0 ? result.users[0].roles || [] : [];
const permissions = roles.filter(role => role.db === dbName).map(role => role.role);
print("__1panel_json_begin__");
print(JSON.stringify(permissions));
print("__1panel_json_end__");
`, databaseJSON, usernameJSON))
stdout, err := runMongodbAdminScriptWithStdout(connectionName, script)
if err != nil {
return "", err
}
var permissions []string
jsonResult, err := extractMongodbJSONOutput(stdout)
if err != nil {
return "", err
}
if err := json.Unmarshal(jsonResult, &permissions); err != nil {
return "", err
}
return normalizeMongodbPrivilege(permissions), nil
}
func updateLocalMongodbPrivilege(connectionName, dbName, username, permission string) error {
databaseJSON, err := json.Marshal(dbName)
if err != nil {
return err
}
usernameJSON, err := json.Marshal(username)
if err != nil {
return err
}
permissionJSON, err := json.Marshal(permission)
if err != nil {
return err
}
script := strings.TrimSpace(fmt.Sprintf(`
const dbName = %s;
const userName = %s;
const permission = %s;
const targetDb = db.getSiblingDB(dbName);
const userInfo = targetDb.runCommand({
usersInfo: userName,
showCredentials: false,
showCustomData: false
});
if (!userInfo || userInfo.ok !== 1) {
throw new Error("failed to load mongodb user privileges");
}
if (!Array.isArray(userInfo.users) || userInfo.users.length === 0) {
throw new Error("mongodb user not found: " + userName);
}
const roles = (userInfo.users[0].roles || []).filter(role => role.db !== dbName);
roles.push({ role: permission, db: dbName });
const result = targetDb.runCommand({
updateUser: userName,
roles: roles
});
if (!result || result.ok !== 1) {
throw new Error("failed to update mongodb user privileges");
}
`, databaseJSON, usernameJSON, permissionJSON))
return runMongodbAdminScript(connectionName, script)
}
func loadRemoteMongodbPrivilege(connectionName, dbName, username string) (string, error) {
info, err := loadRemoteMongodbConnection(connectionName)
if err != nil {
return "", err
}
client, ctx, cancel, err := newRemoteMongodbClient(info)
if err != nil {
return "", err
}
defer cancel()
defer client.Disconnect(ctx)
targetDB := client.Database(dbName)
var result struct {
Users []struct {
Roles []struct {
Role string `bson:"role"`
DB string `bson:"db"`
} `bson:"roles"`
} `bson:"users"`
}
if err := targetDB.RunCommand(ctx, bson.D{
{Key: "usersInfo", Value: username},
{Key: "showCredentials", Value: false},
{Key: "showCustomData", Value: false},
}).Decode(&result); err != nil {
return "", err
}
permissions := make([]string, 0)
if len(result.Users) > 0 {
for _, role := range result.Users[0].Roles {
if role.DB == dbName {
permissions = append(permissions, role.Role)
}
}
}
return normalizeMongodbPrivilege(permissions), nil
}
func updateRemoteMongodbPrivilege(connectionName, dbName, username, permission string) error {
info, err := loadRemoteMongodbConnection(connectionName)
if err != nil {
return err
}
client, ctx, cancel, err := newRemoteMongodbClient(info)
if err != nil {
return err
}
defer cancel()
defer client.Disconnect(ctx)
targetDB := client.Database(dbName)
var userInfo struct {
Users []struct {
Roles []struct {
Role string `bson:"role"`
DB string `bson:"db"`
} `bson:"roles"`
} `bson:"users"`
}
if err := targetDB.RunCommand(ctx, bson.D{
{Key: "usersInfo", Value: username},
{Key: "showCredentials", Value: false},
{Key: "showCustomData", Value: false},
}).Decode(&userInfo); err != nil {
return err
}
if len(userInfo.Users) == 0 {
return fmt.Errorf("mongodb user %s not found in database %s", username, dbName)
}
roles := make(bson.A, 0, len(userInfo.Users[0].Roles)+1)
for _, role := range userInfo.Users[0].Roles {
if role.DB == dbName {
continue
}
roles = append(roles, bson.D{{Key: "role", Value: role.Role}, {Key: "db", Value: role.DB}})
}
roles = append(roles, bson.D{{Key: "role", Value: permission}, {Key: "db", Value: dbName}})
return targetDB.RunCommand(ctx, bson.D{
{Key: "updateUser", Value: username},
{Key: "roles", Value: roles},
}).Err()
}
func normalizeMongodbPrivilege(permissions []string) string {
roleMap := map[string]struct{}{
"dbOwner": {},
"read": {},
"readWrite": {},
"userAdmin": {},
}
for _, permission := range permissions {
if _, ok := roleMap[permission]; ok {
return permission
}
}
return ""
}
func isSupportedMongodbPrivilege(permission string) bool {
switch permission {
case "dbOwner", "read", "readWrite", "userAdmin":
return true
default:
return false
}
}
func extractMongodbJSONOutput(stdout string) ([]byte, error) {
const (
beginMark = "__1panel_json_begin__"
endMark = "__1panel_json_end__"
)
output := strings.TrimSpace(stdout)
if output == "" {
return nil, fmt.Errorf("empty mongodb command output")
}
beginIndex := strings.Index(output, beginMark)
endIndex := strings.LastIndex(output, endMark)
if beginIndex >= 0 && endIndex > beginIndex {
content := strings.TrimSpace(output[beginIndex+len(beginMark) : endIndex])
if content != "" {
return []byte(content), nil
}
}
lines := strings.Split(output, "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
return []byte(line), nil
}
return nil, fmt.Errorf("mongodb command output does not contain json result")
}
+39 -21
View File
@@ -49,6 +49,7 @@ func (u *DeviceService) Scan() dto.CleanData {
Size: uint64(originalSize),
IsCheck: originalSize > 0,
IsRecommend: true,
CanDelete: false,
Type: "1panel_original",
Children: loadTreeWithDir(true, "1panel_original", originalPath, fileOp),
})
@@ -68,6 +69,7 @@ func (u *DeviceService) Scan() dto.CleanData {
Size: rollbackSize,
IsCheck: rollbackSize > 0,
IsRecommend: true,
CanDelete: false,
Type: "rollback",
Children: rollBackTree,
})
@@ -105,11 +107,7 @@ func (u *DeviceService) Clean(req []dto.Clean) {
case "tmp_backup":
dropFileOrDir(path.Join(global.Dir.LocalBackupDir, "tmp"))
case "unknown_backup":
if strings.HasPrefix(item.Name, path.Join(global.Dir.LocalBackupDir, "log/website")) {
dropFileOrDir(item.Name)
} else {
dropFile(item.Name)
}
dropFileOrDir(item.Name)
case "rollback":
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "app"))
@@ -269,6 +267,7 @@ func loadUpgradeTree(fileOp fileUtils.FileOp) dto.CleanTree {
Size: uint64(upgradeSize),
IsCheck: false,
IsRecommend: true,
CanDelete: false,
Type: "upgrade",
Children: loadTreeWithDir(true, "upgrade", upgradePath, fileOp),
}
@@ -302,6 +301,7 @@ func loadAgentPackage(fileOp fileUtils.FileOp) dto.CleanTree {
Label: "agent_packages",
IsCheck: false,
IsRecommend: true,
CanDelete: false,
Type: "agent",
}
files, _ := os.ReadDir(pathItem)
@@ -316,6 +316,7 @@ func loadAgentPackage(fileOp fileUtils.FileOp) dto.CleanTree {
Size: uint64(itemSize),
IsCheck: true,
IsRecommend: true,
CanDelete: true,
Type: "agent",
})
} else {
@@ -333,6 +334,7 @@ func loadAgentPackage(fileOp fileUtils.FileOp) dto.CleanTree {
Size: uint64(itemSize.Size()),
IsCheck: !isCurrentVersion,
IsRecommend: true,
CanDelete: true,
Type: "agent",
})
}
@@ -353,6 +355,7 @@ func loadBackupTree(fileOp fileUtils.FileOp) []dto.CleanTree {
Size: uint64(tmpSize),
IsCheck: tmpSize != 0,
IsRecommend: true,
CanDelete: true,
Type: "tmp_backup",
})
backupRecords, _ := backupRepo.ListRecord()
@@ -391,6 +394,7 @@ func loadUnknownApps(fileOp fileUtils.FileOp, recordMap map[string][]string) dto
Label: "unknown_app",
IsCheck: false,
IsRecommend: false,
CanDelete: false,
Name: backupPath,
Type: "unknown_backup",
}
@@ -419,6 +423,7 @@ func loadUnknownDbs(fileOp fileUtils.FileOp, recordMap map[string][]string) dto.
Name: backupPath,
IsCheck: false,
IsRecommend: false,
CanDelete: false,
Type: "unknown_backup",
}
_ = loadFileOrDirWithExclude(fileOp, 0, backupPath, &treeData, excludePaths)
@@ -442,6 +447,7 @@ func loadUnknownWebsites(fileOp fileUtils.FileOp, recordMap map[string][]string)
Name: backupPath,
IsCheck: false,
IsRecommend: false,
CanDelete: false,
Type: "unknown_backup",
}
_ = loadFileOrDirWithExclude(fileOp, 0, backupPath, &treeData, excludePaths)
@@ -460,6 +466,7 @@ func loadUnknownSnapshot(fileOp fileUtils.FileOp) dto.CleanTree {
Name: backupPath,
IsCheck: false,
IsRecommend: false,
CanDelete: false,
Type: "unknown_backup",
}
entries, _ := os.ReadDir(backupPath)
@@ -473,6 +480,7 @@ func loadUnknownSnapshot(fileOp fileUtils.FileOp) dto.CleanTree {
Label: entry.Name(),
IsCheck: false,
IsRecommend: false,
CanDelete: true,
Name: childPath,
Type: "unknown_backup",
}
@@ -499,6 +507,7 @@ func loadUnknownWebsiteLog(fileOp fileUtils.FileOp) dto.CleanTree {
Label: "unknown_website_log",
IsCheck: false,
IsRecommend: true,
CanDelete: false,
Type: "unknown_backup",
}
dir := path.Join(global.Dir.LocalBackupDir, "log/website")
@@ -522,6 +531,7 @@ func loadUnknownWebsiteLog(fileOp fileUtils.FileOp) dto.CleanTree {
Label: dirName,
IsCheck: true,
IsRecommend: true,
CanDelete: true,
Name: dirPath,
Type: "unknown_backup",
Size: uint64(itemSize),
@@ -552,6 +562,7 @@ func loadFileOrDirWithExclude(fileOp fileUtils.FileOp, index uint, dir string, r
Label: entry.Name(),
IsCheck: false,
IsRecommend: false,
CanDelete: true,
Name: childPath,
Type: "unknown_backup",
}
@@ -564,18 +575,19 @@ func loadFileOrDirWithExclude(fileOp fileUtils.FileOp, index uint, dir string, r
for _, child := range childNode.Children {
childNode.Size += child.Size
}
rootTree.Size += childNode.Size
} else {
itemSize, _ := fileOp.GetDirSize(childPath)
childNode.Size = uint64(itemSize)
rootTree.Size += childNode.Size
}
if childNode.Size == 0 {
continue
}
} else {
info, _ := entry.Info()
childNode.Size = uint64(info.Size())
rootTree.Size += childNode.Size
}
rootTree.Size += childNode.Size
rootTree.Children = append(rootTree.Children, childNode)
}
return nil
@@ -633,6 +645,7 @@ func loadDownloadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
Label: "app_tmp_download",
IsCheck: true,
IsRecommend: true,
CanDelete: false,
Type: "app_tmp_download",
Name: "apps",
}
@@ -653,19 +666,19 @@ func loadLogTree(fileOp fileUtils.FileOp) []dto.CleanTree {
for _, file := range list1 {
size += file.Size
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "system_log", Size: size, Children: list1, Type: "system_log", IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "system_log", Size: size, Children: list1, Type: "system_log", IsRecommend: true, CanDelete: false})
path2 := path.Join(global.Dir.TaskDir)
list2 := loadTreeWithDir(false, "task_log", path2, fileOp)
size2, _ := fileOp.GetDirSize(path2)
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "task_log", Size: uint64(size2), Children: list2, Type: "task_log"})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "task_log", Size: uint64(size2), Children: list2, Type: "task_log", CanDelete: false})
websiteLogList := loadWebsiteLogTree(fileOp)
logTotalSize := uint64(0)
for _, websiteLog := range websiteLogList {
logTotalSize += websiteLog.Size
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "website_log", Size: logTotalSize, Children: websiteLogList, Type: "website_log", IsRecommend: false})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "website_log", Size: logTotalSize, Children: websiteLogList, Type: "website_log", IsRecommend: false, CanDelete: false})
return treeData
}
@@ -679,11 +692,12 @@ func loadWebsiteLogTree(fileOp fileUtils.FileOp) []dto.CleanTree {
for _, website := range websites {
size3, _ := fileOp.GetDirSize(path.Join(GetSiteDir(website.Alias), "log"))
res = append(res, dto.CleanTree{
ID: uuid.NewString(),
Label: website.PrimaryDomain,
Size: uint64(size3),
Type: "website_log",
Name: website.Alias,
ID: uuid.NewString(),
Label: website.PrimaryDomain,
Size: uint64(size3),
Type: "website_log",
Name: website.Alias,
CanDelete: true,
})
}
return res
@@ -733,6 +747,7 @@ func loadAppTmpDownloadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
appTree.Name = appKey
appTree.IsRecommend = true
appTree.IsCheck = true
appTree.CanDelete = false
for _, version := range missingVersions {
versionPath := filepath.Join(appPath, version)
size, _ := fileOp.GetDirSize(versionPath)
@@ -743,6 +758,7 @@ func loadAppTmpDownloadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
Size: uint64(size),
IsCheck: true,
IsRecommend: true,
CanDelete: true,
Type: "app_tmp_download_version",
Name: path.Join(appKey, version),
})
@@ -770,7 +786,7 @@ func loadContainerTree() []dto.CleanTree {
imageSize += uint64(file.Size)
}
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_images", Size: imageSize, Children: nil, Type: "images", IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_images", Size: imageSize, Children: nil, Type: "images", IsRecommend: true, CanDelete: true})
containerSize := uint64(0)
for _, file := range diskUsage.Containers {
@@ -778,7 +794,7 @@ func loadContainerTree() []dto.CleanTree {
containerSize += uint64(file.SizeRw)
}
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_containers", Size: containerSize, Children: nil, Type: "containers", IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_containers", Size: containerSize, Children: nil, Type: "containers", IsRecommend: true, CanDelete: true})
volumeSize := uint64(0)
for _, file := range diskUsage.Volumes {
@@ -786,7 +802,7 @@ func loadContainerTree() []dto.CleanTree {
volumeSize += uint64(file.UsageData.Size)
}
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_volumes", Size: volumeSize, IsCheck: volumeSize > 0, Children: nil, Type: "volumes", IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_volumes", Size: volumeSize, IsCheck: volumeSize > 0, Children: nil, Type: "volumes", IsRecommend: true, CanDelete: true})
var buildCacheTotalSize int64
for _, cache := range diskUsage.BuildCache {
@@ -794,7 +810,7 @@ func loadContainerTree() []dto.CleanTree {
buildCacheTotalSize += cache.Size
}
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "build_cache", Size: uint64(buildCacheTotalSize), IsCheck: buildCacheTotalSize > 0, Type: "build_cache", IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "build_cache", Size: uint64(buildCacheTotalSize), IsCheck: buildCacheTotalSize > 0, Type: "build_cache", IsRecommend: true, CanDelete: true})
return treeData
}
@@ -804,7 +820,7 @@ func loadTreeWithCheck(treeData []dto.CleanTree, pathItem, treeType string, file
return treeData
}
list := loadTreeWithAllFile(true, pathItem, treeType, pathItem, fileOp)
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: treeType, Size: uint64(size), IsCheck: size > 0, Children: list, Type: treeType, IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: treeType, Size: uint64(size), IsCheck: size > 0, Children: list, Type: treeType, IsRecommend: true, CanDelete: false})
return treeData
}
@@ -831,6 +847,7 @@ func loadTreeWithDir(isCheck bool, treeType, pathItem string, fileOp fileUtils.F
Name: strings.TrimPrefix(file.Name(), "/"),
IsCheck: isCheck,
IsRecommend: isCheck,
CanDelete: true,
}
lists = append(lists, item)
}
@@ -878,6 +895,7 @@ func loadTreeWithAllFile(isCheck bool, originalPath, treeType, pathItem string,
Name: name,
IsCheck: isCheck,
IsRecommend: isCheck,
CanDelete: true,
}
if file.IsDir() {
item.Children = loadTreeWithAllFile(isCheck, originalPath, treeType, path.Join(pathItem, file.Name()), fileOp)
+3 -1
View File
@@ -21,6 +21,7 @@ var (
mysqlRepo = repo.NewIMysqlRepo()
postgresqlRepo = repo.NewIPostgresqlRepo()
mongodbRepo = repo.NewIMongodbRepo()
databaseRepo = repo.NewIDatabaseRepo()
imageRepoRepo = repo.NewIImageRepoRepo()
@@ -49,7 +50,8 @@ var (
runtimeRepo = repo.NewIRunTimeRepo()
phpExtensionsRepo = repo.NewIPHPExtensionsRepo()
favoriteRepo = repo.NewIFavoriteRepo()
favoriteRepo = repo.NewIFavoriteRepo()
fileShareRepo = repo.NewIFileShareRepo()
taskRepo = repo.NewITaskRepo()
+381 -26
View File
@@ -2,6 +2,7 @@ package service
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
@@ -41,6 +42,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/files"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
"github.com/pkg/errors"
)
@@ -55,6 +57,7 @@ type IFileService interface {
Delete(op request.FileDelete) error
BatchDelete(op request.FileBatchDelete) error
Compress(c request.FileCompress) error
StopCompress(taskID string) error
DeCompress(c request.FileDeCompress) error
GetContent(op request.FileContentReq) (response.FileInfo, error)
GetPreviewContent(op request.FileContentReq) (response.FileInfo, error)
@@ -78,10 +81,7 @@ type IFileService interface {
ConvertLog(req dto.PageInfo) (int64, []response.FileConvertLog, error)
BatchGetRemarks(req request.FileRemarkBatch) map[string]string
SetRemark(req request.FileRemarkUpdate) error
}
var filteredPaths = []string{
"/.1panel_clash",
AISearch(req request.FileAISearch) (*response.FileAISearchResult, error)
}
const (
@@ -106,10 +106,29 @@ func (f *FileService) GetFileList(op request.FileOption) (response.FileInfo, err
if err != nil {
return fileInfo, err
}
shareMap, err := NewIFileShareService().SharePathCodeMap()
if err != nil {
return fileInfo, err
}
applyFileShares(info, shareMap)
fileInfo.FileInfo = *info
return fileInfo, nil
}
func applyFileShares(info *files.FileInfo, shareMap map[string]string) {
if info == nil {
return
}
if code, ok := shareMap[info.Path]; ok {
info.ShareCode = code
} else {
info.ShareCode = ""
}
for _, item := range info.Items {
applyFileShares(item, shareMap)
}
}
func (f *FileService) SearchUploadWithPage(req request.SearchUploadWithPage) (int64, interface{}, error) {
var (
files []response.UploadInfo
@@ -170,14 +189,7 @@ func (f *FileService) GetFileTree(op request.FileOption) ([]response.FileTree, e
}
func shouldFilterPath(path string) bool {
cleanedPath := filepath.Clean(path)
for _, filteredPath := range filteredPaths {
cleanedFilteredPath := filepath.Clean(filteredPath)
if cleanedFilteredPath == cleanedPath || strings.HasPrefix(cleanedPath, cleanedFilteredPath+"/") {
return true
}
}
return false
return files.ShouldFilterSensitivePath(path)
}
func (f *FileService) buildFileTree(node *response.FileTree, items []*files.FileInfo, op request.FileOption, level int) error {
@@ -219,8 +231,12 @@ func (f *FileService) buildChildNode(childNode *response.FileTree, fileInfo *fil
return f.buildFileTree(childNode, subInfo.Items, op, level-1)
}
func hasInvalidFileName(fullPath string) bool {
return files.IsInvalidChar(filepath.Base(fullPath))
}
func (f *FileService) Create(op request.FileCreate) error {
if files.IsInvalidChar(op.Path) {
if hasInvalidFileName(op.Path) {
return buserr.New("ErrInvalidChar")
}
fo := files.NewFileOp()
@@ -272,16 +288,37 @@ func (f *FileService) Delete(op request.FileDelete) error {
if recycleBinStatus.Value == "Disable" {
op.ForceDelete = true
}
var historyTargets []string
if op.ForceDelete {
if op.IsDir {
return fo.DeleteDir(op.Path)
} else {
return fo.DeleteFile(op.Path)
var err error
historyTargets, err = f.collectPermanentDeleteTargets(op.Path, op.IsDir)
if err != nil {
return err
}
}
if op.ForceDelete {
var err error
if op.IsDir {
err = fo.DeleteDir(op.Path)
} else {
err = fo.DeleteFile(op.Path)
}
if err != nil {
return err
}
if err := cleanupTrashInfoByEntryPath(op.Path); err != nil {
global.LOG.Warnf("cleanup trashinfo failed for %s: %v", op.Path, err)
}
f.cleanupPermanentDeleteHistory(historyTargets)
return nil
}
info, _ := fo.Fs.Stat(op.Path)
if info == nil || files.IsSymlink(info.Mode()) {
return os.Remove(op.Path)
if err := os.Remove(op.Path); err != nil {
return err
}
f.cleanupPermanentDeleteHistory([]string{op.Path})
return nil
}
if err := NewIRecycleBinService().Create(request.RecycleBinCreate{SourcePath: op.Path}); err != nil {
@@ -294,15 +331,27 @@ func (f *FileService) BatchDelete(op request.FileBatchDelete) error {
fo := files.NewFileOp()
if op.IsDir {
for _, file := range op.Paths {
targets, err := f.collectPermanentDeleteTargets(file, true)
if err != nil {
return err
}
if err := fo.DeleteDir(file); err != nil {
return err
}
if err := cleanupTrashInfoByEntryPath(file); err != nil {
global.LOG.Warnf("cleanup trashinfo failed for %s: %v", file, err)
}
f.cleanupPermanentDeleteHistory(targets)
}
} else {
for _, file := range op.Paths {
if err := fo.DeleteFile(file); err != nil {
return err
}
if err := cleanupTrashInfoByEntryPath(file); err != nil {
global.LOG.Warnf("cleanup trashinfo failed for %s: %v", file, err)
}
f.cleanupPermanentDeleteHistory([]string{file})
}
}
return nil
@@ -310,7 +359,10 @@ func (f *FileService) BatchDelete(op request.FileBatchDelete) error {
func (f *FileService) ChangeMode(op request.FileCreate) error {
fo := files.NewFileOp()
return fo.ChmodR(op.Path, op.Mode, op.Sub)
if err := fo.ChmodR(op.Path, op.Mode, op.Sub); err != nil {
return err
}
return nil
}
func (f *FileService) BatchChangeModeAndOwner(op request.FileRoleReq) error {
@@ -329,9 +381,44 @@ func (f *FileService) BatchChangeModeAndOwner(op request.FileRoleReq) error {
return nil
}
func (f *FileService) collectPermanentDeleteTargets(targetPath string, isDir bool) ([]string, error) {
if !isDir {
return []string{targetPath}, nil
}
var targets []string
if err := filepath.WalkDir(targetPath, func(currentPath string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d == nil || d.IsDir() {
return nil
}
targets = append(targets, currentPath)
return nil
}); err != nil {
return nil, err
}
return targets, nil
}
func (f *FileService) cleanupPermanentDeleteHistory(targets []string) {
if len(targets) == 0 {
return
}
for _, target := range targets {
if err := historyService.DeleteRelatedHistory(target); err != nil {
global.LOG.Warnf("cleanup file history failed for %s: %v", target, err)
}
}
}
func (f *FileService) ChangeOwner(req request.FileRoleUpdate) error {
fo := files.NewFileOp()
return fo.ChownR(req.Path, req.User, req.Group, req.Sub)
if err := fo.ChownR(req.Path, req.User, req.Group, req.Sub); err != nil {
return err
}
return nil
}
func (f *FileService) Compress(c request.FileCompress) error {
@@ -339,7 +426,58 @@ func (f *FileService) Compress(c request.FileCompress) error {
if !c.Replace && fo.Stat(filepath.Join(c.Dst, c.Name)) {
return buserr.New("ErrFileIsExist")
}
return fo.Compress(c.Files, c.Dst, c.Name, files.CompressType(c.Type), c.Secret)
if err := preflightCompressTool(files.CompressType(c.Type)); err != nil {
return err
}
taskItem, err := task.NewTask(c.Name, task.TaskExec, task.TaskScopeTask, c.TaskID, 1)
if err != nil {
return err
}
go func() {
taskItem.AddSubTask(c.Name, func(t *task.Task) error {
t.LogStart(c.Name)
compressType := files.CompressType(c.Type)
dstFile := filepath.Join(c.Dst, c.Name)
success := false
defer func() {
if !success {
_ = os.Remove(dstFile)
}
}()
if err := fo.Compress(t.TaskCtx, c.Files, c.Dst, c.Name, compressType, c.Secret, nil); err != nil {
return err
}
info, err := os.Stat(dstFile)
if err != nil {
return err
}
if info.Size() == 0 {
return fmt.Errorf("compressed file not generated: %s", dstFile)
}
success = true
return nil
}, nil)
_ = taskItem.Execute()
}()
return nil
}
func preflightCompressTool(compressType files.CompressType) error {
switch compressType {
case files.TarGz, files.Rar, files.X7z:
_, err := files.NewShellArchiver(compressType)
return err
default:
return nil
}
}
func (f *FileService) StopCompress(taskID string) error {
if cancel, ok := global.TaskCtxMap[taskID]; ok {
cancel()
return nil
}
return buserr.New("TaskNotFound")
}
func (f *FileService) DeCompress(c request.FileDeCompress) error {
@@ -472,15 +610,36 @@ func (f *FileService) SaveContent(edit request.FileEdit) error {
}
fo := files.NewFileOp()
return fo.WriteFile(edit.Path, strings.NewReader(edit.Content), info.FileMode)
oldContent, _ := os.ReadFile(edit.Path)
if bytes.Equal(oldContent, []byte(edit.Content)) {
return nil
}
if err := fo.WriteFile(edit.Path, strings.NewReader(edit.Content), info.FileMode); err != nil {
return err
}
if err := historyService.RecordSave(edit.Path, oldContent, info.FileMode); err != nil {
global.LOG.Warnf("record file save history failed for %s: %v", edit.Path, err)
}
return nil
}
func (f *FileService) ChangeName(req request.FileRename) error {
if files.IsInvalidChar(req.NewName) {
if hasInvalidFileName(req.NewName) {
return buserr.New("ErrInvalidChar")
}
fo := files.NewFileOp()
return fo.Rename(req.OldName, req.NewName)
info, _ := files.NewFileInfo(files.FileOption{Path: req.OldName, Expand: false})
content, _ := os.ReadFile(req.OldName)
if err := fo.Rename(req.OldName, req.NewName); err != nil {
return err
}
if info != nil && !info.IsDir {
if histErr := historyService.RecordOperation(fileHistoryOpRename, req.OldName, content, info.FileMode, req.OldName, req.NewName); histErr != nil {
global.LOG.Warnf("record file rename history failed for %s: %v", req.OldName, histErr)
}
}
return nil
}
func (f *FileService) Wget(w request.FileWget) (string, error) {
@@ -502,6 +661,23 @@ func (f *FileService) MvFile(m request.FileMove) error {
return buserr.New("ErrMovePathFailed")
}
}
type moveSnapshot struct {
path string
content []byte
mode os.FileMode
isDir bool
}
snapshots := make([]moveSnapshot, 0, len(m.OldPaths))
for _, oldPath := range m.OldPaths {
content, _ := os.ReadFile(oldPath)
mode := os.FileMode(0640)
isDir := false
if info, err := files.NewFileInfo(files.FileOption{Path: oldPath, Expand: false}); err == nil {
mode = info.FileMode
isDir = info.IsDir
}
snapshots = append(snapshots, moveSnapshot{path: oldPath, content: content, mode: mode, isDir: isDir})
}
var errs []error
if m.Type == "cut" {
if len(m.CoverPaths) > 0 {
@@ -512,7 +688,18 @@ func (f *FileService) MvFile(m request.FileMove) error {
}
}
}
return fo.Cut(m.OldPaths, m.NewPath, m.Name, m.Cover)
if err := fo.Cut(m.OldPaths, m.NewPath, m.Name, m.Cover); err != nil {
return err
}
for _, snapshot := range snapshots {
if !snapshot.isDir {
targetPath := buildHistoryMoveTargetPath(m.NewPath, m.Name, snapshot.path, len(m.OldPaths))
if histErr := historyService.RecordOperation(fileHistoryOpMove, snapshot.path, snapshot.content, snapshot.mode, snapshot.path, targetPath); histErr != nil {
global.LOG.Warnf("record file move history failed for %s: %v", snapshot.path, histErr)
}
}
}
return nil
}
if m.Type == "copy" {
for _, src := range m.OldPaths {
@@ -541,6 +728,16 @@ func (f *FileService) MvFile(m request.FileMove) error {
return nil
}
func buildHistoryMoveTargetPath(dst, name, sourcePath string, sourceCount int) string {
if strings.TrimSpace(dst) == "" {
return sourcePath
}
if strings.TrimSpace(name) != "" && sourceCount == 1 {
return filepath.Join(dst, name)
}
return filepath.Join(dst, filepath.Base(sourcePath))
}
func (f *FileService) FileDownload(d request.FileDownload) (string, error) {
filePath := d.Paths[0]
if d.Compress {
@@ -549,7 +746,7 @@ func (f *FileService) FileDownload(d request.FileDownload) (string, error) {
return "", err
}
fo := files.NewFileOp()
if err := fo.Compress(d.Paths, tempPath, d.Name, files.CompressType(d.Type), ""); err != nil {
if err := fo.Compress(context.Background(), d.Paths, tempPath, d.Name, files.CompressType(d.Type), "", nil); err != nil {
return "", err
}
filePath = filepath.Join(tempPath, d.Name)
@@ -1019,3 +1216,161 @@ func (f *FileService) ConvertLog(req dto.PageInfo) (total int64, data []response
return total, data, nil
}
func (f *FileService) AISearch(req request.FileAISearch) (*response.FileAISearchResult, error) {
root := filepath.Clean(strings.TrimSpace(req.Path))
if root == "" {
return nil, buserr.WithDetail("ErrInvalidParams", "path is required", nil)
}
query := strings.TrimSpace(req.Query)
if query == "" {
return nil, buserr.WithDetail("ErrInvalidParams", "query is required", nil)
}
st, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return nil, buserr.New("ErrPathNotFound")
}
return nil, err
}
if !st.IsDir() {
return nil, buserr.New("ErrPathNotFound")
}
maxItems := req.MaxItems
if maxItems <= 0 {
maxItems = files.DefaultFileAIMaxItems
}
if maxItems > 2000 {
maxItems = 2000
}
containSub := true
if req.ContainSub != nil {
containSub = *req.ContainSub
}
searchOpts, err := files.MergeContentSearchOptions(
req.MatchCase, req.WholeWord, req.UseRegex,
req.Extensions,
req.MinSize, req.MaxSize,
req.ModifiedAfter, req.ModifiedBefore,
req.MaxScanFiles,
req.MaxFileBytes,
req.MaxHitsPerFile, req.MaxTotalHits,
req.ContentHitsPromptMaxBytes,
req.LlmMaxOutputTokens,
)
if err != nil {
return nil, buserr.WithDetail("ErrInvalidParams", err.Error(), nil)
}
matchFn, err := files.NewContentLineMatcher(query, searchOpts)
if err != nil {
return nil, buserr.WithDetail("ErrFileAISearchBadPattern", err.Error(), nil)
}
cfg, timeout, err := terminalai.LoadFileAIRuntimeConfig()
aiEnabled := err == nil
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
items, truncated, err := files.CollectDirInventory(root, containSub, maxItems)
if err != nil {
return nil, err
}
preFiltered := false
llmItems := items
qLower := strings.ToLower(query)
if len(llmItems) > 0 && query != "" {
filtered := make([]files.AISearchInventoryItem, 0, len(llmItems))
for _, it := range llmItems {
rel := strings.TrimSpace(it.RelPath)
if rel == "" {
continue
}
if !req.UseRegex && !req.MatchCase && !req.WholeWord && strings.Contains(strings.ToLower(rel), qLower) {
filtered = append(filtered, it)
}
}
if len(filtered) >= 8 {
llmItems = filtered
preFiltered = true
}
}
start := time.Now()
contentHits, scannedFiles, hitsTrunc := files.SearchFileAIContentHits(root, llmItems, searchOpts, matchFn)
hitsDTO := make([]response.FileAIContentHit, 0, len(contentHits))
for _, h := range contentHits {
hitsDTO = append(hitsDTO, response.FileAIContentHit{Path: h.Path, Line: h.Line, Text: h.Text})
}
matchDesc := searchOpts.ContentMatchDescription()
result := &response.FileAISearchResult{
Hits: hitsDTO,
ContentScannedFiles: scannedFiles,
ContentHitsTruncated: hitsTrunc,
Truncated: truncated,
PreFiltered: preFiltered,
ItemCount: len(llmItems),
}
if len(llmItems) == 0 {
if aiEnabled {
result.Mode = "ai"
result.Summary = i18n.GetMsgByKey("FileAISearchEmptyDir")
if result.Summary == "" || result.Summary == "FileAISearchEmptyDir" {
result.Summary = "No files or directories found under this path (or all entries were filtered)."
}
} else {
result.Mode = "grep"
result.Summary = ""
}
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
if !aiEnabled {
result.Mode = "grep"
result.Summary = ""
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
result.Mode = "ai"
clientTimeout := timeout
if clientTimeout < 30*time.Second {
clientTimeout = 90 * time.Second
}
if clientTimeout > 5*time.Minute {
clientTimeout = 5 * time.Minute
}
runCtx, cancel := context.WithTimeout(context.Background(), timeout+time.Minute)
defer cancel()
llmMaxOut := searchOpts.LlmMaxOutputTokens
summary, usage, err := files.RunFileAISearchLLM(runCtx, cfg, clientTimeout, root, query, req.ResponseLanguage, llmItems, truncated, preFiltered, contentHits, scannedFiles, hitsTrunc, matchDesc, searchOpts.ContentHitsPromptMaxBytes, llmMaxOut)
if err != nil {
result.Mode = "grep"
result.Summary = ""
result.Duration = time.Since(start).Round(time.Millisecond).String()
if errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout") {
return result, nil
}
return result, nil
}
result.Summary = summary
result.PromptTokens = usage.PromptTokens
result.CompletionTokens = usage.CompletionTokens
result.TotalTokens = usage.TotalTokens
if result.TotalTokens == 0 {
result.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
+750
View File
@@ -0,0 +1,750 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"gorm.io/gorm"
"strconv"
)
const (
fileHistorySettingEnable = "FileHistoryStatus"
fileHistorySettingMaxPerPath = "FileHistoryMaxPerPath"
fileHistorySettingDiskQuotaMB = "FileHistoryDiskQuotaMB"
fileHistoryOpSave = "save"
fileHistoryOpRestore = "restore"
fileHistoryOpRename = "rename"
fileHistoryOpMove = "move"
fileHistoryRootDirName = "file-history"
defaultFileHistoryMaxPerPath = 20
defaultFileHistoryDiskQuotaMB = 1024
)
var historyService = NewIFileHistoryService()
var fileHistoryPathLocks sync.Map
type FileHistoryService struct {
repo repo.IFileHistoryRepo
}
type IFileHistoryService interface {
GetSettingInfo() (*response.FileHistorySettingInfo, error)
UpdateSetting(req request.FileHistorySettingUpdate) error
RecordSave(path string, content []byte, fileMode os.FileMode) error
HasRelatedHistory(path string) (bool, error)
RecordOperation(operation string, filePath string, content []byte, fileMode os.FileMode, sourcePath string, targetPath string) error
Restore(req request.FileHistoryRestoreReq) (response.FileInfo, error)
Search(req request.FileHistorySearchReq) (int64, []response.FileHistoryInfo, error)
GetContent(req request.FileHistoryContentReq) (response.FileHistoryInfo, error)
Delete(req request.FileHistoryDeleteReq) error
DeleteRelatedHistory(path string) error
}
func NewIFileHistoryService() IFileHistoryService {
return &FileHistoryService{repo: repo.NewIFileHistoryRepo()}
}
func (s *FileHistoryService) GetSettingInfo() (*response.FileHistorySettingInfo, error) {
info := &response.FileHistorySettingInfo{
Enable: constant.StatusEnable,
MaxPerPath: defaultFileHistoryMaxPerPath,
DiskQuotaMB: defaultFileHistoryDiskQuotaMB,
}
if value, err := settingRepo.GetValueByKey(fileHistorySettingEnable); err == nil && value != "" {
info.Enable = value
}
if value, err := settingRepo.GetValueByKey(fileHistorySettingMaxPerPath); err == nil && value != "" {
if parsed, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && parsed > 0 {
info.MaxPerPath = parsed
}
}
if value, err := settingRepo.GetValueByKey(fileHistorySettingDiskQuotaMB); err == nil && value != "" {
if parsed, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && parsed > 0 {
info.DiskQuotaMB = parsed
}
}
return info, nil
}
func (s *FileHistoryService) UpdateSetting(req request.FileHistorySettingUpdate) error {
if req.MaxPerPath <= 0 {
req.MaxPerPath = defaultFileHistoryMaxPerPath
}
if req.DiskQuotaMB <= 0 {
req.DiskQuotaMB = defaultFileHistoryDiskQuotaMB
}
if err := settingRepo.UpdateOrCreate(fileHistorySettingEnable, req.Enable); err != nil {
return err
}
if err := settingRepo.UpdateOrCreate(fileHistorySettingMaxPerPath, fmt.Sprintf("%d", req.MaxPerPath)); err != nil {
return err
}
if err := settingRepo.UpdateOrCreate(fileHistorySettingDiskQuotaMB, fmt.Sprintf("%d", req.DiskQuotaMB)); err != nil {
return err
}
return nil
}
func (s *FileHistoryService) RecordSave(filePath string, content []byte, fileMode os.FileMode) error {
return s.RecordOperation(fileHistoryOpSave, filePath, content, fileMode, "", "")
}
func (s *FileHistoryService) HasRelatedHistory(filePath string) (bool, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
absPath = filePath
}
if _, err = s.getLatestActiveRelatedByPath(absPath); err == nil {
return true, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
if _, err = s.getLatestRelatedByPath(absPath); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
func (s *FileHistoryService) RecordOperation(operation string, filePath string, content []byte, fileMode os.FileMode, sourcePath string, targetPath string) error {
info, err := s.GetSettingInfo()
if err != nil {
return err
}
if info.Enable != constant.StatusEnable {
return nil
}
if fileMode == 0 {
fileMode = 0640
}
if content == nil {
content = []byte{}
}
absPath, err := filepath.Abs(filePath)
if err != nil {
absPath = filePath
}
absSourcePath := normalizeAbsPath(sourcePath)
absTargetPath := normalizeAbsPath(targetPath)
resolvePaths := []string{absPath}
recordPath := absPath
switch operation {
case fileHistoryOpRename, fileHistoryOpMove:
resolvePaths = []string{absSourcePath, absTargetPath, absPath}
if absTargetPath != "" {
recordPath = absTargetPath
}
default:
if absSourcePath != "" {
resolvePaths = append(resolvePaths, absSourcePath)
}
if absTargetPath != "" {
resolvePaths = append(resolvePaths, absTargetPath)
}
}
fileID, latestChainRecord, chainErr := s.resolveFileChain(resolvePaths...)
if chainErr != nil && !errors.Is(chainErr, gorm.ErrRecordNotFound) {
return chainErr
}
if fileID == "" {
fileID = common.GetUuid()
}
if operation == fileHistoryOpRename || operation == fileHistoryOpMove {
if !s.isEditableHistorySnapshot(fileMode, content) {
return nil
}
}
previousID := uint(0)
if latestVersion, err := s.getLatestVersionByFileID(fileID); err == nil {
switch operation {
case fileHistoryOpSave:
contentHash := sha256HexBytes(content)
if latestVersion.ContentSHA == contentHash {
return nil
}
previousID = latestVersion.ID
case fileHistoryOpRestore:
previousID = latestVersion.ID
default:
if latestChainRecord.ID != 0 {
previousID = latestChainRecord.ID
}
}
} else if latestChainRecord.ID != 0 {
previousID = latestChainRecord.ID
}
recordContent := content
recordDeleted := false
recordID, storagePath, err := s.recordSnapshot(fileID, recordDeleted, operation, recordPath, recordContent, fileMode, absSourcePath, absTargetPath, previousID)
if err != nil {
return err
}
if err := s.enforceRetention(fileID, recordID); err != nil {
_ = s.repo.DeleteByIDs([]uint{recordID})
_ = os.Remove(s.absStoragePath(storagePath))
return err
}
return nil
}
func (s *FileHistoryService) recordSnapshot(fileID string, deleted bool, operation string, absPath string, content []byte, fileMode os.FileMode, sourcePath string, targetPath string, previousID uint) (uint, string, error) {
if fileMode == 0 {
fileMode = 0640
}
now := time.Now()
pathHash := sha256Hex(absPath)
contentHash := sha256HexBytes(content)
fileName := filepath.Base(absPath)
extension := filepath.Ext(absPath)
storagePath := s.buildStoragePath(absPath, now)
if err := os.MkdirAll(path.Dir(s.absStoragePath(storagePath)), os.ModePerm); err != nil {
return 0, "", err
}
if err := os.WriteFile(s.absStoragePath(storagePath), content, 0640); err != nil {
return 0, "", err
}
record := &model.FileHistory{
FileID: fileID,
Path: absPath,
PathHash: pathHash,
SourcePath: sourcePath,
TargetPath: targetPath,
FileName: fileName,
Extension: extension,
FileMode: fmt.Sprintf("%04o", fileMode.Perm()),
Operation: operation,
Deleted: deleted,
ContentSize: int64(len(content)),
ContentSHA: contentHash,
StoragePath: storagePath,
PreviousID: previousID,
}
if err := s.repo.Create(record); err != nil {
_ = os.Remove(s.absStoragePath(storagePath))
return 0, "", err
}
return record.ID, storagePath, nil
}
func normalizeAbsPath(filePath string) string {
filePath = strings.TrimSpace(filePath)
if filePath == "" {
return ""
}
absPath, err := filepath.Abs(filePath)
if err != nil {
return filePath
}
return absPath
}
func (s *FileHistoryService) Search(req request.FileHistorySearchReq) (int64, []response.FileHistoryInfo, error) {
page := req.Page
if page <= 0 {
page = 1
}
size := req.PageSize
if size <= 0 {
size = 20
}
offset := (page - 1) * size
opts := []repo.DBOption{}
opts = append(opts, s.repo.WithNotOperation("init"))
switch strings.ToLower(strings.TrimSpace(req.Scope)) {
case "current":
if strings.TrimSpace(req.Path) == "" {
return 0, nil, buserr.New("ErrInvalidParams")
}
absPath, err := filepath.Abs(req.Path)
if err != nil {
absPath = req.Path
}
chain, err := s.getLatestActiveRelatedByPath(absPath)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
chain, err = s.getLatestRelatedByPath(absPath)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, []response.FileHistoryInfo{}, nil
}
return 0, nil, err
}
} else {
return 0, nil, err
}
}
opts = append(opts, s.repo.WithByFileID(chain.FileID))
case "all":
if strings.TrimSpace(req.Path) != "" {
opts = append(opts, s.repo.WithByRelatedPath(normalizeAbsPath(req.Path)))
}
default:
return 0, nil, buserr.New("ErrInvalidParams")
}
if strings.TrimSpace(req.Operation) != "" {
opts = append(opts, s.repo.WithByOperation(strings.TrimSpace(req.Operation)))
}
total, items, err := s.repo.Page(size, offset, opts...)
if err != nil {
return 0, nil, err
}
res := make([]response.FileHistoryInfo, 0, len(items))
for _, item := range items {
currentPath := item.Path
if resolved, err := s.getCurrentPathByFileID(item.FileID); err == nil && strings.TrimSpace(resolved) != "" {
currentPath = resolved
}
res = append(res, response.FileHistoryInfo{
ID: item.ID,
FileID: item.FileID,
Path: item.Path,
CurrentPath: currentPath,
PreviousID: item.PreviousID,
SourcePath: item.SourcePath,
TargetPath: item.TargetPath,
FileName: item.FileName,
Extension: item.Extension,
FileMode: item.FileMode,
Operation: item.Operation,
Deleted: item.Deleted,
ContentSize: item.ContentSize,
ContentSHA: item.ContentSHA,
StoragePath: item.StoragePath,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
})
}
return total, res, nil
}
func (s *FileHistoryService) GetContent(req request.FileHistoryContentReq) (response.FileHistoryInfo, error) {
record, err := s.repo.Get(s.repo.WithByID(req.ID))
if err != nil {
return response.FileHistoryInfo{}, err
}
content, err := os.ReadFile(s.absStoragePath(record.StoragePath))
if err != nil {
return response.FileHistoryInfo{}, err
}
currentContent := s.getCurrentContent(record)
return response.FileHistoryInfo{
ID: record.ID,
FileID: record.FileID,
Path: record.Path,
CurrentPath: func() string {
if currentPath, err := s.getCurrentPathByFileID(record.FileID); err == nil && strings.TrimSpace(currentPath) != "" {
return currentPath
}
return record.Path
}(),
PreviousID: record.PreviousID,
SourcePath: record.SourcePath,
TargetPath: record.TargetPath,
FileName: record.FileName,
Extension: record.Extension,
FileMode: record.FileMode,
Operation: record.Operation,
Deleted: record.Deleted,
ContentSize: record.ContentSize,
ContentSHA: record.ContentSHA,
StoragePath: record.StoragePath,
Content: string(content),
CurrentContent: currentContent,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
}, nil
}
func (s *FileHistoryService) Restore(req request.FileHistoryRestoreReq) (response.FileInfo, error) {
record, err := s.repo.Get(s.repo.WithByID(req.ID))
if err != nil {
return response.FileInfo{}, err
}
if !s.isVersionOperation(record.Operation) {
return response.FileInfo{}, buserr.New("ErrInvalidParams")
}
content, err := os.ReadFile(s.absStoragePath(record.StoragePath))
if err != nil {
return response.FileInfo{}, err
}
currentPath := record.Path
if chainPath, err := s.getCurrentPathByFileID(record.FileID); err == nil && strings.TrimSpace(chainPath) != "" {
currentPath = chainPath
}
lock := s.getPathLock(currentPath)
lock.Lock()
defer lock.Unlock()
var rollbackContent []byte
var rollbackMode os.FileMode
var existedBefore bool
currentInfo, currentErr := files.NewFileInfo(files.FileOption{
Path: currentPath,
Expand: false,
})
if currentErr == nil {
existedBefore = true
rollbackContent, err = os.ReadFile(currentPath)
if err != nil {
return response.FileInfo{}, err
}
rollbackMode = currentInfo.FileMode
} else {
rollbackMode = parseFileMode(record.FileMode)
if rollbackMode == 0 {
rollbackMode = 0640
}
}
targetMode := rollbackMode
if targetMode == 0 {
targetMode = parseFileMode(record.FileMode)
}
if targetMode == 0 {
targetMode = 0640
}
fo := files.NewFileOp()
if err := fo.WriteFile(currentPath, strings.NewReader(string(content)), targetMode); err != nil {
_ = s.rollbackRestore(currentPath, rollbackContent, rollbackMode, existedBefore)
return response.FileInfo{}, err
}
if err := historyService.RecordOperation(fileHistoryOpRestore, currentPath, rollbackContent, rollbackMode, "", ""); err != nil {
global.LOG.Warnf("record file restore history failed for %s: %v", currentPath, err)
}
info, err := files.NewFileInfo(files.FileOption{
Path: currentPath,
Expand: true,
})
if err != nil {
return response.FileInfo{}, err
}
return response.FileInfo{FileInfo: *info}, nil
}
func (s *FileHistoryService) Delete(req request.FileHistoryDeleteReq) error {
if len(req.IDs) == 0 {
return nil
}
for _, id := range req.IDs {
record, err := s.repo.Get(s.repo.WithByID(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return err
}
if err := s.repo.DeleteByIDs([]uint{id}); err != nil {
return err
}
_ = os.Remove(s.absStoragePath(record.StoragePath))
}
return nil
}
func (s *FileHistoryService) DeleteRelatedHistory(filePath string) error {
absPath := normalizeAbsPath(filePath)
if strings.TrimSpace(absPath) == "" {
return nil
}
fileID, latestRecord, err := s.resolveFileChain(absPath)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if strings.TrimSpace(fileID) == "" {
fileID = latestRecord.FileID
}
if strings.TrimSpace(fileID) == "" {
return nil
}
var records []model.FileHistory
if err := global.DB.Model(&model.FileHistory{}).Where("file_id = ?", fileID).Find(&records).Error; err != nil {
return err
}
if len(records) == 0 {
return nil
}
ids := make([]uint, 0, len(records))
for _, record := range records {
ids = append(ids, record.ID)
_ = os.Remove(s.absStoragePath(record.StoragePath))
}
return s.repo.DeleteByIDs(ids)
}
func (s *FileHistoryService) rollbackRestore(filePath string, rollbackContent []byte, rollbackMode os.FileMode, existedBefore bool) error {
fo := files.NewFileOp()
if !existedBefore {
return os.Remove(filePath)
}
if rollbackMode == 0 {
rollbackMode = 0640
}
return fo.WriteFile(filePath, strings.NewReader(string(rollbackContent)), rollbackMode)
}
func (s *FileHistoryService) enforceRetention(fileID string, keepID uint) error {
info, err := s.GetSettingInfo()
if err != nil {
return err
}
if info.Enable != constant.StatusEnable {
return nil
}
maxPerPath := info.MaxPerPath
if maxPerPath <= 0 {
maxPerPath = 20
}
quotaBytes := int64(info.DiskQuotaMB) * 1024 * 1024
if maxPerPath > 0 {
total, records, err := s.listFileOldest(fileID)
if err != nil {
return err
}
if int(total) > maxPerPath {
removeCount := int(total) - maxPerPath
for i := 0; i < removeCount && i < len(records); i++ {
if err := s.repo.DeleteByIDs([]uint{records[i].ID}); err != nil {
return err
}
_ = os.Remove(s.absStoragePath(records[i].StoragePath))
}
}
}
if quotaBytes <= 0 {
return nil
}
totalSize, err := s.totalSize()
if err != nil {
return err
}
if totalSize <= quotaBytes {
return nil
}
return buserr.New("ErrHistoryQuotaExceeded")
}
func (s *FileHistoryService) getPathLock(absPath string) *sync.Mutex {
actual, _ := fileHistoryPathLocks.LoadOrStore(absPath, &sync.Mutex{})
return actual.(*sync.Mutex)
}
func (s *FileHistoryService) listFileOldest(fileID string) (int64, []model.FileHistory, error) {
var total int64
var items []model.FileHistory
db := global.DB.Model(&model.FileHistory{}).Where("file_id = ?", fileID)
if err := db.Count(&total).Error; err != nil {
return 0, nil, err
}
if err := db.Order("created_at asc").Find(&items).Error; err != nil {
return 0, nil, err
}
return total, items, nil
}
func (s *FileHistoryService) getLatestRelatedByPath(absPath string) (model.FileHistory, error) {
var item model.FileHistory
db := global.DB.Model(&model.FileHistory{}).Where("path = ? OR source_path = ? OR target_path = ?", absPath, absPath, absPath).Order("created_at desc")
err := db.First(&item).Error
return item, err
}
func (s *FileHistoryService) getLatestActiveRelatedByPath(absPath string) (model.FileHistory, error) {
var item model.FileHistory
db := global.DB.Model(&model.FileHistory{}).Where("deleted = ? AND (path = ? OR source_path = ? OR target_path = ?)", false, absPath, absPath, absPath).Order("created_at desc")
err := db.First(&item).Error
return item, err
}
func (s *FileHistoryService) getLatestVersionByFileID(fileID string) (model.FileHistory, error) {
var item model.FileHistory
db := global.DB.Model(&model.FileHistory{}).Where("file_id = ? AND operation IN ?", fileID, []string{fileHistoryOpSave, fileHistoryOpRestore}).Order("created_at desc")
err := db.First(&item).Error
return item, err
}
func (s *FileHistoryService) getCurrentPathByFileID(fileID string) (string, error) {
record, err := s.getLatestRelatedByFileID(fileID)
if err != nil {
return "", err
}
return record.Path, nil
}
func (s *FileHistoryService) getCurrentContent(record model.FileHistory) string {
currentPath := ""
if resolved, err := s.getCurrentPathByFileID(record.FileID); err == nil && strings.TrimSpace(resolved) != "" {
currentPath = resolved
}
candidates := []string{}
if strings.TrimSpace(currentPath) != "" {
candidates = append(candidates, currentPath)
}
if strings.TrimSpace(record.Path) != "" && record.Path != currentPath {
candidates = append(candidates, record.Path)
}
for _, candidate := range candidates {
content, err := os.ReadFile(candidate)
if err == nil {
return string(content)
}
}
content, err := os.ReadFile(s.absStoragePath(record.StoragePath))
if err == nil {
return string(content)
}
return ""
}
func (s *FileHistoryService) isEditableHistorySnapshot(fileMode os.FileMode, content []byte) bool {
if fileMode.IsDir() || files.IsBlockDevice(fileMode) {
return false
}
if len(content) == 0 {
return true
}
return !files.DetectBinary(content)
}
func (s *FileHistoryService) getLatestRelatedByFileID(fileID string) (model.FileHistory, error) {
var item model.FileHistory
db := global.DB.Model(&model.FileHistory{}).Where("file_id = ?", fileID).Order("created_at desc")
err := db.First(&item).Error
return item, err
}
func (s *FileHistoryService) resolveFileChain(paths ...string) (string, model.FileHistory, error) {
for _, itemPath := range paths {
itemPath = strings.TrimSpace(itemPath)
if itemPath == "" {
continue
}
record, err := s.getLatestActiveRelatedByPath(itemPath)
if err == nil {
return record.FileID, record, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return "", model.FileHistory{}, err
}
}
return "", model.FileHistory{}, gorm.ErrRecordNotFound
}
func (s *FileHistoryService) isVersionOperation(operation string) bool {
return operation == fileHistoryOpSave || operation == fileHistoryOpRestore
}
func (s *FileHistoryService) totalSize() (int64, error) {
var total int64
err := global.DB.Model(&model.FileHistory{}).Select("coalesce(sum(content_size),0)").Scan(&total).Error
return total, err
}
func (s *FileHistoryService) listOldest() ([]model.FileHistory, error) {
var items []model.FileHistory
err := global.DB.Model(&model.FileHistory{}).Order("created_at asc").Find(&items).Error
return items, err
}
func (s *FileHistoryService) buildStoragePath(absPath string, now time.Time) string {
fileName := filepath.Base(absPath)
extension := filepath.Ext(fileName)
baseName := strings.TrimSuffix(fileName, extension)
if baseName == "" {
baseName = fileName
extension = ""
}
baseName = sanitizeHistoryFileName(baseName)
if baseName == "" {
baseName = "file"
}
dayDir := now.Format("2006-01-02")
shortCode := strings.ReplaceAll(common.GetUuid(), "-", "")
if len(shortCode) > 8 {
shortCode = shortCode[:8]
}
return path.Join(fileHistoryRootDirName, dayDir, fmt.Sprintf("%s__%s%s", baseName, shortCode, extension))
}
func (s *FileHistoryService) absStoragePath(rel string) string {
return path.Join(global.Dir.LocalBackupDir, rel)
}
func sanitizeHistoryFileName(name string) string {
name = strings.TrimSpace(name)
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
)
return replacer.Replace(name)
}
func parseFileMode(mode string) os.FileMode {
if strings.TrimSpace(mode) == "" {
return 0
}
parsed, err := strconv.ParseUint(strings.TrimSpace(mode), 8, 32)
if err != nil {
return 0
}
return os.FileMode(parsed)
}
func sha256Hex(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func sha256HexBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}
+381
View File
@@ -0,0 +1,381 @@
package service
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"gorm.io/gorm"
)
type FileShareService struct{}
const (
fileShareCodeMinLength = 10
fileShareCodeMaxLength = 16
fileShareCodeDefaultLength = 13
fileShareCharset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
)
var fileShareCodeRegexp = regexp.MustCompile(`^[A-Za-z0-9]{10,16}$`)
type IFileShareService interface {
Create(req request.FileShareCreate) (*response.FileShareInfo, error)
Page(req dto.PageInfo) (int64, []response.FileShareInfo, error)
GetByPath(path string) (*response.FileShareInfo, error)
GetByCode(code string) (*response.FileShareInfo, error)
GetPublicByCode(code string) (*response.FileSharePublicInfo, error)
DeleteByPath(path string) error
SharePathCodeMap() (map[string]string, error)
Check(code, password string) error
PrepareDownload(code, password string) (filePath, fileName string, err error)
}
func NewIFileShareService() IFileShareService {
return &FileShareService{}
}
func randomShareCode(length int) (string, error) {
if length < fileShareCodeMinLength || length > fileShareCodeMaxLength {
length = fileShareCodeDefaultLength
}
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
buf := make([]byte, length)
for i := range b {
buf[i] = fileShareCharset[int(b[i])%len(fileShareCharset)]
}
return string(buf), nil
}
func randomSalt() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func hashPassword(salt, password string) string {
sum := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(sum[:])
}
func shareModelToInfo(item model.FileShare) response.FileShareInfo {
return response.FileShareInfo{
Code: item.Token,
Path: item.Path,
FileName: item.FileName,
ExpiresAt: item.ExpiresUnix,
Permanent: item.ExpiresUnix == 0,
HasPassword: item.PasswordHash != "",
}
}
func fillSharePassword(info *response.FileShareInfo, item model.FileShare) {
if info == nil || item.PasswordEnc == "" {
return
}
password, err := encrypt.StringDecrypt(item.PasswordEnc)
if err != nil {
return
}
info.Password = password
}
func shareModelToPublicInfo(item model.FileShare) response.FileSharePublicInfo {
return response.FileSharePublicInfo{
FileName: item.FileName,
ExpiresAt: item.ExpiresUnix,
Permanent: item.ExpiresUnix == 0,
HasPassword: item.PasswordHash != "",
}
}
func (s *FileShareService) generateUniqueCode() (string, error) {
for i := 0; i < 8; i++ {
code, err := randomShareCode(fileShareCodeDefaultLength)
if err != nil {
return "", err
}
_, err = fileShareRepo.GetFirst(fileShareRepo.WithByCode(code))
if errors.Is(err, gorm.ErrRecordNotFound) {
return code, nil
}
if err != nil {
return "", err
}
}
return "", errors.New("failed to generate unique file share code")
}
func (s *FileShareService) Create(req request.FileShareCreate) (*response.FileShareInfo, error) {
path := strings.TrimSpace(req.Path)
if path == "" || strings.Contains(path, "..") {
return nil, buserr.New("ErrFileSharePath")
}
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return nil, buserr.New("ErrFileSharePath")
}
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByPath(path))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
isNew := errors.Is(err, gorm.ErrRecordNotFound)
if isNew {
code, err := s.generateUniqueCode()
if err != nil {
return nil, err
}
item = model.FileShare{
Path: path,
Token: code,
FileName: filepath.Base(path),
}
} else if !fileShareCodeRegexp.MatchString(item.Token) {
code, err := s.generateUniqueCode()
if err != nil {
return nil, err
}
item.Token = code
}
item.FileName = filepath.Base(path)
item.MaxDownloads = 0
item.DownloadCount = 0
item.ExpiresUnix = 0
if req.ExpireMinutes > 0 {
item.ExpiresUnix = time.Now().Add(time.Duration(req.ExpireMinutes) * time.Minute).Unix()
}
if req.Password != nil {
pw := strings.TrimSpace(*req.Password)
if pw == "" {
item.PasswordEnc = ""
item.PasswordSalt = ""
item.PasswordHash = ""
} else {
pwLen := utf8.RuneCountInString(pw)
if pwLen < 4 || pwLen > 256 {
return nil, buserr.New("ErrFileSharePasswordPolicy")
}
enc, err := encrypt.StringEncrypt(pw)
if err != nil {
return nil, err
}
item.PasswordEnc = enc
salt, err := randomSalt()
if err != nil {
return nil, err
}
item.PasswordSalt = salt
item.PasswordHash = hashPassword(salt, pw)
}
}
if isNew {
if err := fileShareRepo.Create(&item); err != nil {
return nil, err
}
} else {
if err := fileShareRepo.Save(&item); err != nil {
return nil, err
}
}
res := shareModelToInfo(item)
fillSharePassword(&res, item)
return &res, nil
}
func (s *FileShareService) Page(req dto.PageInfo) (int64, []response.FileShareInfo, error) {
items, err := fileShareRepo.All()
if err != nil {
return 0, nil, err
}
result := make([]response.FileShareInfo, 0, len(items))
for _, item := range items {
if err := s.pruneInvalidShare(item); err != nil {
return 0, nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
continue
}
result = append(result, shareModelToInfo(item))
}
total := len(result)
start := (req.Page - 1) * req.PageSize
if start >= total {
return int64(total), []response.FileShareInfo{}, nil
}
end := start + req.PageSize
if end > total {
end = total
}
return int64(total), result[start:end], nil
}
func (s *FileShareService) GetByPath(path string) (*response.FileShareInfo, error) {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByPath(strings.TrimSpace(path)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
if err := s.pruneInvalidShare(item); err != nil {
return nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
return nil, nil
}
info := shareModelToInfo(item)
fillSharePassword(&info, item)
return &info, nil
}
func (s *FileShareService) GetByCode(code string) (*response.FileShareInfo, error) {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByCode(strings.TrimSpace(code)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrFileShareInvalid")
}
return nil, err
}
if err := s.pruneInvalidShare(item); err != nil {
return nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
return nil, buserr.New("ErrFileShareExpired")
}
info := shareModelToInfo(item)
return &info, nil
}
func (s *FileShareService) GetPublicByCode(code string) (*response.FileSharePublicInfo, error) {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByCode(strings.TrimSpace(code)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrFileShareInvalid")
}
return nil, err
}
if err := s.pruneInvalidShare(item); err != nil {
return nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
return nil, buserr.New("ErrFileShareExpired")
}
info := shareModelToPublicInfo(item)
return &info, nil
}
func (s *FileShareService) DeleteByPath(path string) error {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByPath(strings.TrimSpace(path)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return buserr.New("ErrFileShareInvalid")
}
return err
}
return fileShareRepo.Delete(repo.WithByID(item.ID))
}
func (s *FileShareService) SharePathCodeMap() (map[string]string, error) {
items, err := fileShareRepo.All()
if err != nil {
return nil, err
}
result := make(map[string]string, len(items))
now := time.Now().Unix()
for _, item := range items {
if item.ExpiresUnix > 0 && now > item.ExpiresUnix {
continue
}
if _, err := os.Stat(item.Path); err != nil {
continue
}
result[item.Path] = item.Token
}
return result, nil
}
func (s *FileShareService) Check(code, password string) error {
_, err := s.check(code, password)
return err
}
func (s *FileShareService) PrepareDownload(code, password string) (string, string, error) {
item, err := s.check(code, password)
if err != nil {
return "", "", err
}
return item.Path, item.FileName, nil
}
func (s *FileShareService) pruneInvalidShare(item model.FileShare) error {
now := time.Now().Unix()
if item.ExpiresUnix > 0 && now > item.ExpiresUnix {
return fileShareRepo.Delete(repo.WithByID(item.ID))
}
info, err := os.Stat(item.Path)
if err != nil || info.IsDir() {
return fileShareRepo.Delete(repo.WithByID(item.ID))
}
return nil
}
func (s *FileShareService) check(code, password string) (*model.FileShare, error) {
code = strings.TrimSpace(code)
password = strings.TrimSpace(password)
if code == "" {
return nil, buserr.New("ErrFileShareInvalid")
}
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByCode(code))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrFileShareInvalid")
}
return nil, err
}
now := time.Now().Unix()
if item.ExpiresUnix > 0 && now > item.ExpiresUnix {
_ = fileShareRepo.Delete(repo.WithByID(item.ID))
return nil, buserr.New("ErrFileShareExpired")
}
if item.PasswordHash != "" {
if subtle.ConstantTimeCompare([]byte(hashPassword(item.PasswordSalt, password)), []byte(item.PasswordHash)) != 1 {
return nil, buserr.New("ErrFileSharePassword")
}
}
info, err := os.Stat(item.Path)
if err != nil || info.IsDir() {
_ = fileShareRepo.Delete(repo.WithByID(item.ID))
return nil, buserr.New("ErrFileSharePath")
}
return &item, nil
}
+147
View File
@@ -0,0 +1,147 @@
package service
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/url"
"strconv"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.mongodb.org/mongo-driver/mongo/readpref"
)
type mongodbConnectionInfo struct {
Address string
Port uint
InitialDB string
Username string
Password string
Timeout uint
SSL bool
RootCert string
ClientKey string
ClientCert string
SkipVerify bool
}
func mongodbConnectionInfoFromCreate(req dto.DatabaseCreate) mongodbConnectionInfo {
return mongodbConnectionInfo{
Address: req.Address,
Port: req.Port,
InitialDB: req.InitialDB,
Username: req.Username,
Password: req.Password,
Timeout: req.Timeout,
SSL: req.SSL,
RootCert: req.RootCert,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
SkipVerify: req.SkipVerify,
}
}
func mongodbConnectionInfoFromModel(db model.Database) mongodbConnectionInfo {
return mongodbConnectionInfo{
Address: db.Address,
Port: db.Port,
InitialDB: db.InitialDB,
Username: db.Username,
Password: db.Password,
Timeout: db.Timeout,
SSL: db.SSL,
RootCert: db.RootCert,
ClientKey: db.ClientKey,
ClientCert: db.ClientCert,
SkipVerify: db.SkipVerify,
}
}
func loadRemoteMongodbConnection(database string) (mongodbConnectionInfo, error) {
db, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return mongodbConnectionInfo{}, err
}
return mongodbConnectionInfoFromModel(db), nil
}
func newRemoteMongodbClient(info mongodbConnectionInfo) (*mongo.Client, context.Context, context.CancelFunc, error) {
timeout := time.Duration(info.Timeout) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
clientOptions := options.Client().ApplyURI(buildRemoteMongodbURI(info)).
SetServerSelectionTimeout(timeout).
SetConnectTimeout(timeout)
if info.SSL {
tlsConfig, err := buildRemoteMongodbTLSConfig(info)
if err != nil {
return nil, nil, nil, err
}
clientOptions.SetTLSConfig(tlsConfig)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
client, err := mongo.Connect(ctx, clientOptions)
if err != nil {
cancel()
return nil, nil, nil, err
}
if err := client.Ping(ctx, readpref.Primary()); err != nil {
_ = client.Disconnect(context.Background())
cancel()
return nil, nil, nil, err
}
return client, ctx, cancel, nil
}
func buildRemoteMongodbURI(info mongodbConnectionInfo) string {
uri := url.URL{
Scheme: "mongodb",
Host: net.JoinHostPort(info.Address, strconv.Itoa(int(info.Port))),
Path: "/",
}
uri.User = url.UserPassword(info.Username, info.Password)
query := url.Values{}
authSource := info.InitialDB
if authSource == "" {
authSource = "admin"
}
query.Set("authSource", authSource)
query.Set("directConnection", "true")
uri.RawQuery = query.Encode()
return uri.String()
}
func buildRemoteMongodbTLSConfig(info mongodbConnectionInfo) (*tls.Config, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: info.SkipVerify,
}
if info.RootCert != "" {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(info.RootCert)) {
return nil, fmt.Errorf("load mongodb ca cert failed")
}
tlsConfig.RootCAs = pool
}
if info.ClientCert != "" && info.ClientKey != "" {
cert, err := tls.X509KeyPair([]byte(info.ClientCert), []byte(info.ClientKey))
if err != nil {
return nil, err
}
tlsConfig.Certificates = []tls.Certificate{cert}
}
return tlsConfig, nil
}
+11 -5
View File
@@ -52,7 +52,12 @@ func (ps *ProcessService) GetListeningProcess(c context.Context) ([]ListeningPro
if err != nil {
return nil, err
}
procCache := make(map[int32]ListeningProcess, 64)
// One cache entry per (PID, socket type) so TCP and UDP sockets are not merged under one Protocol.
type procKey struct {
pid int32
protocol uint32
}
procCache := make(map[procKey]ListeningProcess, 64)
for _, conn := range conn {
if conn.Pid == 0 {
@@ -60,7 +65,8 @@ func (ps *ProcessService) GetListeningProcess(c context.Context) ([]ListeningPro
}
if (conn.Status == "LISTEN" && conn.Type == syscall.SOCK_STREAM) || (conn.Type == syscall.SOCK_DGRAM && conn.Raddr.Port == 0) {
if _, exists := procCache[conn.Pid]; !exists {
key := procKey{pid: conn.Pid, protocol: conn.Type}
if _, exists := procCache[key]; !exists {
proc, err := process.NewProcess(conn.Pid)
if err != nil {
continue
@@ -72,11 +78,11 @@ func (ps *ProcessService) GetListeningProcess(c context.Context) ([]ListeningPro
procData.Port = make(map[uint32]struct{})
procData.Port[conn.Laddr.Port] = struct{}{}
procData.Protocol = conn.Type
procCache[conn.Pid] = procData
procCache[key] = procData
} else {
p := procCache[conn.Pid]
p := procCache[key]
p.Port[conn.Laddr.Port] = struct{}{}
procCache[conn.Pid] = p
procCache[key] = p
}
}
}
+338 -39
View File
@@ -1,8 +1,10 @@
package service
import (
"bytes"
"fmt"
"math"
"net/url"
"os"
"path"
"strconv"
@@ -15,9 +17,20 @@ import (
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"github.com/shirou/gopsutil/v4/disk"
"gopkg.in/ini.v1"
)
const (
recycleBinClashDir = ".1panel_clash"
recycleBinFilesSubdir = "files"
recycleBinInfoSubdir = "info"
trashInfoSuffix = ".trashinfo"
trashInfoSection = "Trash Info"
trashInfoTimeLayout = "2006-01-02T15:04:05"
)
type RecycleBinService struct {
@@ -35,39 +48,32 @@ func NewIRecycleBinService() IRecycleBinService {
}
func (r RecycleBinService) Page(search dto.PageInfo) (int64, []response.RecycleBinDTO, error) {
var (
result []response.RecycleBinDTO
)
var result []response.RecycleBinDTO
partitions, err := disk.Partitions(false)
if err != nil {
return 0, nil, err
}
op := files.NewFileOp()
for _, p := range partitions {
dir := path.Join(p.Mountpoint, ".1panel_clash")
if !op.Stat(dir) {
clashRoot := path.Join(p.Mountpoint, recycleBinClashDir)
if !op.Stat(clashRoot) {
continue
}
clashFiles, err := os.ReadDir(dir)
if err != nil {
return 0, nil, err
}
for _, file := range clashFiles {
if strings.HasPrefix(file.Name(), "_1p_") {
recycleDTO, err := getRecycleBinDTOFromName(file.Name())
recycleDTO.IsDir = file.IsDir()
recycleDTO.From = dir
if err == nil {
result = append(result, *recycleDTO)
}
}
}
result = append(result, collectTrashEntries(clashRoot)...)
result = append(result, collectLegacyEntries(clashRoot)...)
}
startIndex := (search.Page - 1) * search.PageSize
if search.PageSize <= 0 {
return int64(len(result)), []response.RecycleBinDTO{}, nil
}
page := search.Page
if page < 1 {
page = 1
}
startIndex := (page - 1) * search.PageSize
endIndex := startIndex + search.PageSize
if startIndex > len(result) {
return int64(len(result)), result, nil
if startIndex >= len(result) {
return int64(len(result)), []response.RecycleBinDTO{}, nil
}
if endIndex > len(result) {
endIndex = len(result)
@@ -83,34 +89,55 @@ func (r RecycleBinService) Create(create request.RecycleBinCreate) error {
if !op.Stat(create.SourcePath) {
return buserr.New("ErrLinkPathNotFound")
}
clashDir, err := getClashDir(create.SourcePath)
clashRoot, err := getClashDir(create.SourcePath)
if err != nil {
return err
}
paths := strings.Split(create.SourcePath, "/")
rNamePre := strings.Join(paths, "_1p_")
filesDir, infoDir, err := ensureTrashDirs(clashRoot)
if err != nil {
return err
}
deleteTime := time.Now()
openFile, err := op.OpenFile(create.SourcePath)
if err != nil {
return err
}
fileInfo, err := openFile.Stat()
_ = openFile.Close()
if err != nil {
return err
}
size := 0
size := int64(0)
if fileInfo.IsDir() {
sizeF, err := op.GetDirSize(create.SourcePath)
if err != nil {
return err
}
size = int(sizeF)
size = int64(sizeF)
} else {
size = int(fileInfo.Size())
size = fileInfo.Size()
}
rName := fmt.Sprintf("_1p_%s%s_p_%d_%d", "file", rNamePre, size, deleteTime.Unix())
return op.Mv(create.SourcePath, path.Join(clashDir, rName))
rName := allocateTrashEntryName(filesDir, infoDir, create.SourcePath)
info := trashInfo{
Path: create.SourcePath,
DeletionDate: deleteTime,
Size: size,
IsDir: fileInfo.IsDir(),
}
infoPath := path.Join(infoDir, rName+trashInfoSuffix)
if err := writeTrashInfo(infoPath, info); err != nil {
return err
}
targetPath := path.Join(filesDir, rName)
if err := op.Mv(create.SourcePath, targetPath); err != nil {
if rmErr := os.Remove(infoPath); rmErr != nil && !os.IsNotExist(rmErr) {
global.LOG.Warnf("rollback trashinfo failed for %s: %v", infoPath, rmErr)
}
return err
}
return nil
}
func (r RecycleBinService) Reduce(reduce request.RecycleBinReduce) error {
@@ -119,10 +146,13 @@ func (r RecycleBinService) Reduce(reduce request.RecycleBinReduce) error {
if !op.Stat(filePath) {
return buserr.New("ErrLinkPathNotFound")
}
recycleBinDTO, err := getRecycleBinDTOFromName(reduce.RName)
recycleBinDTO, err := loadRecycleBinDTO(reduce.From, reduce.RName)
if err != nil {
return err
}
if recycleBinDTO.SourcePath == "" {
return buserr.New("ErrSourcePathNotFound")
}
if !op.Stat(path.Dir(recycleBinDTO.SourcePath)) {
return buserr.New("ErrSourcePathNotFound")
}
@@ -131,7 +161,13 @@ func (r RecycleBinService) Reduce(reduce request.RecycleBinReduce) error {
return err
}
}
return op.Mv(filePath, recycleBinDTO.SourcePath)
if err := op.Mv(filePath, recycleBinDTO.SourcePath); err != nil {
return err
}
if err := cleanupTrashInfoByEntryPath(filePath); err != nil {
global.LOG.Warnf("cleanup trashinfo failed for %s: %v", filePath, err)
}
return nil
}
func (r RecycleBinService) Clear() error {
@@ -141,17 +177,22 @@ func (r RecycleBinService) Clear() error {
}
op := files.NewFileOp()
for _, p := range partitions {
dir := path.Join(p.Mountpoint, ".1panel_clash")
dir := path.Join(p.Mountpoint, recycleBinClashDir)
if !op.Stat(dir) {
continue
}
newDir := path.Join(p.Mountpoint, "1panel_clash")
newDir := path.Join(p.Mountpoint, recycleBinClashDir+"-"+strings.ReplaceAll(common.GetUuid(), "-", ""))
if err := op.Mv(dir, newDir); err != nil {
return err
}
go func() {
_ = op.DeleteDir(newDir)
}()
go func(target string) {
defer func() {
if r := recover(); r != nil {
global.LOG.Warnf("clear recycle bin panic on %s: %v", target, r)
}
}()
_ = op.DeleteDir(target)
}(newDir)
}
return nil
}
@@ -166,8 +207,8 @@ func getClashDir(realPath string) (string, error) {
continue
}
if strings.HasPrefix(realPath, p.Mountpoint) {
clashDir := path.Join(p.Mountpoint, ".1panel_clash")
if err = createClashDir(path.Join(p.Mountpoint, ".1panel_clash")); err != nil {
clashDir := path.Join(p.Mountpoint, recycleBinClashDir)
if err = createClashDir(clashDir); err != nil {
return "", err
}
return clashDir, nil
@@ -186,6 +227,264 @@ func createClashDir(clashDir string) error {
return nil
}
func ensureTrashDirs(clashRoot string) (string, string, error) {
filesDir := path.Join(clashRoot, recycleBinFilesSubdir)
infoDir := path.Join(clashRoot, recycleBinInfoSubdir)
op := files.NewFileOp()
if !op.Stat(filesDir) {
if err := op.CreateDir(filesDir, constant.DirPerm); err != nil {
return "", "", err
}
}
if !op.Stat(infoDir) {
if err := op.CreateDir(infoDir, constant.DirPerm); err != nil {
return "", "", err
}
}
return filesDir, infoDir, nil
}
func trashFilesDir(clashRoot string) string {
return path.Join(clashRoot, recycleBinFilesSubdir)
}
func trashInfoDir(clashRoot string) string {
return path.Join(clashRoot, recycleBinInfoSubdir)
}
func trashInfoPath(infoDir, entryName string) string {
return path.Join(infoDir, entryName+trashInfoSuffix)
}
type trashInfo struct {
Path string
DeletionDate time.Time
Size int64
IsDir bool
}
func writeTrashInfo(dstPath string, info trashInfo) error {
cfg := ini.Empty()
section, err := cfg.NewSection(trashInfoSection)
if err != nil {
return err
}
if _, err := section.NewKey("Path", url.PathEscape(info.Path)); err != nil {
return err
}
if _, err := section.NewKey("DeletionDate", info.DeletionDate.Format(trashInfoTimeLayout)); err != nil {
return err
}
if _, err := section.NewKey("Size", strconv.FormatInt(info.Size, 10)); err != nil {
return err
}
if _, err := section.NewKey("IsDir", strconv.FormatBool(info.IsDir)); err != nil {
return err
}
var buf bytes.Buffer
if _, err := cfg.WriteTo(&buf); err != nil {
return err
}
tmp := dstPath + ".tmp"
if err := os.WriteFile(tmp, buf.Bytes(), constant.FilePerm); err != nil {
return err
}
return os.Rename(tmp, dstPath)
}
func readTrashInfo(srcPath string) (*trashInfo, error) {
cfg, err := ini.Load(srcPath)
if err != nil {
return nil, err
}
section, err := cfg.GetSection(trashInfoSection)
if err != nil {
return nil, err
}
rawPath := section.Key("Path").Value()
decodedPath, err := url.PathUnescape(rawPath)
if err != nil {
decodedPath = rawPath
}
info := &trashInfo{
Path: decodedPath,
IsDir: section.Key("IsDir").MustBool(false),
}
if deletionStr := section.Key("DeletionDate").Value(); deletionStr != "" {
if t, parseErr := time.ParseInLocation(trashInfoTimeLayout, deletionStr, time.Local); parseErr == nil {
info.DeletionDate = t
}
}
if sizeStr := section.Key("Size").Value(); sizeStr != "" {
if sz, sizeErr := strconv.ParseInt(sizeStr, 10, 64); sizeErr == nil {
info.Size = sz
}
}
return info, nil
}
func collectTrashEntries(clashRoot string) []response.RecycleBinDTO {
filesDir := trashFilesDir(clashRoot)
infoDir := trashInfoDir(clashRoot)
op := files.NewFileOp()
if !op.Stat(filesDir) {
return nil
}
entries, err := os.ReadDir(filesDir)
if err != nil {
global.LOG.Warnf("read recycle files dir %s failed: %v", filesDir, err)
return nil
}
var result []response.RecycleBinDTO
for _, entry := range entries {
dto, err := buildTrashDTO(filesDir, infoDir, entry)
if err != nil {
global.LOG.Warnf("build recycle dto for %s failed: %v", entry.Name(), err)
continue
}
result = append(result, *dto)
}
return result
}
func collectLegacyEntries(clashRoot string) []response.RecycleBinDTO {
op := files.NewFileOp()
if !op.Stat(clashRoot) {
return nil
}
entries, err := os.ReadDir(clashRoot)
if err != nil {
return nil
}
var result []response.RecycleBinDTO
for _, entry := range entries {
name := entry.Name()
if name == recycleBinFilesSubdir || name == recycleBinInfoSubdir {
continue
}
if !strings.HasPrefix(name, "_1p_") {
continue
}
dto, err := getRecycleBinDTOFromName(name)
if err != nil {
continue
}
dto.IsDir = entry.IsDir()
dto.From = clashRoot
result = append(result, *dto)
}
return result
}
func buildTrashDTO(filesDir, infoDir string, entry os.DirEntry) (*response.RecycleBinDTO, error) {
entryName := entry.Name()
infoPath := trashInfoPath(infoDir, entryName)
if info, err := readTrashInfo(infoPath); err == nil {
return trashInfoToDTO(entryName, filesDir, info), nil
} else if !os.IsNotExist(err) {
global.LOG.Warnf("read trashinfo %s failed: %v", infoPath, err)
}
entryPath := path.Join(filesDir, entryName)
fi, err := os.Stat(entryPath)
if err != nil {
return nil, err
}
size := fi.Size()
if fi.IsDir() {
if sz, sizeErr := files.NewFileOp().GetDirSize(entryPath); sizeErr == nil {
size = int64(sz)
}
}
return &response.RecycleBinDTO{
Name: entryName,
Size: clampToInt(size),
Type: "file",
DeleteTime: fi.ModTime(),
RName: entryName,
SourcePath: "",
IsDir: fi.IsDir(),
From: filesDir,
}, nil
}
func trashInfoToDTO(entryName, filesDir string, info *trashInfo) *response.RecycleBinDTO {
return &response.RecycleBinDTO{
Name: path.Base(info.Path),
Size: clampToInt(info.Size),
Type: "file",
DeleteTime: info.DeletionDate,
RName: entryName,
SourcePath: info.Path,
IsDir: info.IsDir,
From: filesDir,
}
}
func loadRecycleBinDTO(from, name string) (*response.RecycleBinDTO, error) {
if path.Base(from) == recycleBinFilesSubdir {
infoDir := trashInfoDir(path.Dir(from))
info, err := readTrashInfo(trashInfoPath(infoDir, name))
if err == nil {
return trashInfoToDTO(name, from, info), nil
}
if !os.IsNotExist(err) {
global.LOG.Warnf("read trashinfo for %s failed: %v", name, err)
}
}
dto, err := getRecycleBinDTOFromName(name)
if err != nil {
return nil, err
}
dto.From = from
return dto, nil
}
func cleanupTrashInfoByEntryPath(filePath string) error {
cleaned := path.Clean(filePath)
parent := path.Dir(cleaned)
if path.Base(parent) != recycleBinFilesSubdir {
return nil
}
clashRoot := path.Dir(parent)
if path.Base(clashRoot) != recycleBinClashDir {
return nil
}
infoPath := trashInfoPath(trashInfoDir(clashRoot), path.Base(cleaned))
if err := os.Remove(infoPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func allocateTrashEntryName(filesDir, infoDir, sourcePath string) string {
ext := path.Ext(path.Base(sourcePath))
op := files.NewFileOp()
for i := 0; i < 5; i++ {
name := strings.ReplaceAll(common.GetUuid(), "-", "") + ext
if op.Stat(path.Join(filesDir, name)) {
continue
}
if op.Stat(trashInfoPath(infoDir, name)) {
continue
}
return name
}
return fmt.Sprintf("%s-%d%s", strings.ReplaceAll(common.GetUuid(), "-", ""), time.Now().UnixNano(), ext)
}
func clampToInt(v int64) int {
if v > math.MaxInt {
return math.MaxInt
}
if v < math.MinInt {
return math.MinInt
}
return int(v)
}
func getRecycleBinDTOFromName(filename string) (*response.RecycleBinDTO, error) {
matches := re.GetRegex(re.RecycleBinFilePattern).FindStringSubmatch(filename)
if len(matches) != 4 {
+58
View File
@@ -8,6 +8,8 @@ import (
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
@@ -23,8 +25,12 @@ type SettingService struct{}
type ISettingService interface {
GetSettingInfo() (*dto.SettingInfo, error)
GetTerminalAIInfo() (*dto.TerminalAIInfo, error)
GetFileManageAIInfo() (*dto.FileManageAIInfo, error)
GetFileHistorySettingInfo() (*response.FileHistorySettingInfo, error)
Update(key, value string) error
UpdateTerminalAI(req dto.TerminalAIInfo) error
UpdateFileManageAI(req dto.FileManageAIInfo) error
UpdateFileHistorySetting(req request.FileHistorySettingUpdate) error
TestConnByInfo(req dto.SSHConnData) bool
SaveConnInfo(req dto.SSHConnData) error
@@ -87,6 +93,24 @@ func (u *SettingService) GetTerminalAIInfo() (*dto.TerminalAIInfo, error) {
return info, nil
}
func (u *SettingService) GetFileManageAIInfo() (*dto.FileManageAIInfo, error) {
info := &dto.FileManageAIInfo{
AIStatus: constant.StatusDisable,
AIAccountID: "",
}
if value, err := settingRepo.GetValueByKey("FileAIStatus"); err == nil && value != "" {
info.AIStatus = value
}
if value, err := settingRepo.GetValueByKey("FileAIAccountID"); err == nil {
info.AIAccountID = value
}
return info, nil
}
func (u *SettingService) GetFileHistorySettingInfo() (*response.FileHistorySettingInfo, error) {
return historyService.GetSettingInfo()
}
func (u *SettingService) Update(key, value string) error {
return settingRepo.UpdateOrCreate(key, value)
}
@@ -123,6 +147,40 @@ func (u *SettingService) UpdateTerminalAI(req dto.TerminalAIInfo) error {
return nil
}
func (u *SettingService) UpdateFileManageAI(req dto.FileManageAIInfo) error {
if strings.EqualFold(strings.TrimSpace(req.AIStatus), constant.StatusEnable) {
accountID, err := strconv.ParseUint(strings.TrimSpace(req.AIAccountID), 10, 64)
if err != nil || accountID == 0 {
return buserr.New("ErrAgentAccountIDRequired")
}
currentStatus, _ := settingRepo.GetValueByKey("FileAIStatus")
currentAccountID, _ := settingRepo.GetValueByKey("FileAIAccountID")
needValidate := !strings.EqualFold(strings.TrimSpace(currentStatus), constant.StatusEnable) ||
strings.TrimSpace(currentAccountID) != strings.TrimSpace(req.AIAccountID)
if needValidate {
if err := terminalai.ValidateTerminalAccount(uint(accountID)); err != nil {
return buserr.WithErr("ErrAgentAccountUnavailable", err)
}
}
}
accountVal := strings.TrimSpace(req.AIAccountID)
if !strings.EqualFold(strings.TrimSpace(req.AIStatus), constant.StatusEnable) {
accountVal = ""
}
if err := settingRepo.UpdateOrCreate("FileAIStatus", req.AIStatus); err != nil {
return err
}
if err := settingRepo.UpdateOrCreate("FileAIAccountID", accountVal); err != nil {
return err
}
terminalai.InvalidateFileAIRuntimeCache()
return nil
}
func (u *SettingService) UpdateFileHistorySetting(req request.FileHistorySettingUpdate) error {
return historyService.UpdateSetting(req)
}
func (u *SettingService) TestConnByInfo(req dto.SSHConnData) bool {
if req.AuthMode == "password" && len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
+570 -123
View File
@@ -9,6 +9,7 @@ import (
"os"
"os/user"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -33,6 +34,8 @@ import (
)
const sshPath = "/etc/ssh/sshd_config"
const defaultSSHPort = "22"
const sshManagedMarker = "# config by 1panel"
type SSHService struct{}
@@ -41,7 +44,7 @@ type ISSHService interface {
OperateSSH(operation string) error
Update(req dto.SSHUpdate) error
LoadSSHFile(name string) (string, error)
UpdateByFile(req dto.SettingUpdate) error
UpdateByFile(req dto.SSHConfUpdate) error
LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (int64, []dto.SSHHistory, error)
ExportLog(ctx *gin.Context, req dto.SearchSSHLog) (string, error)
@@ -57,6 +60,28 @@ func NewISSHService() ISSHService {
return &SSHService{}
}
type sshDirective struct {
Key string
Value string
File string
Line int
InMatch bool
}
type sshConfigFile struct {
Path string `json:"path"`
Priority int `json:"priority"`
}
type sshManagedBlock struct {
Start int
End int
}
func (b sshManagedBlock) contains(line int) bool {
return line >= b.Start && line < b.End
}
func (u *SSHService) GetSSHInfo() (*dto.SSHInfo, error) {
data := dto.SSHInfo{
AutoStart: true,
@@ -70,64 +95,9 @@ func (u *SSHService) GetSSHInfo() (*dto.SSHInfo, error) {
PermitRootLogin: "yes",
UseDNS: "yes",
}
serviceName, err := loadServiceName()
if err != nil {
data.IsExist = false
data.Message = err.Error()
} else {
active, err := controller.CheckActive(serviceName)
data.IsActive = active
if !active && err != nil {
data.Message = err.Error()
}
}
enable, err := controller.CheckEnable(serviceName)
if err != nil {
data.AutoStart = false
} else {
data.AutoStart = enable
}
sshConf, err := os.ReadFile(sshPath)
if err != nil {
data.Message = err.Error()
data.IsActive = false
}
lines := strings.Split(string(sshConf), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "Port ") {
data.Port = strings.ReplaceAll(line, "Port ", "")
}
if strings.HasPrefix(line, "ListenAddress ") {
itemAddr := strings.ReplaceAll(line, "ListenAddress ", "")
if len(data.ListenAddress) != 0 {
data.ListenAddress += ("," + itemAddr)
} else {
data.ListenAddress = itemAddr
}
}
if strings.HasPrefix(line, "PasswordAuthentication ") {
data.PasswordAuthentication = strings.ReplaceAll(line, "PasswordAuthentication ", "")
}
if strings.HasPrefix(line, "PubkeyAuthentication ") {
data.PubkeyAuthentication = strings.ReplaceAll(line, "PubkeyAuthentication ", "")
}
if strings.HasPrefix(line, "PermitRootLogin ") {
data.PermitRootLogin = strings.ReplaceAll(strings.ReplaceAll(line, "PermitRootLogin ", ""), "prohibit-password", "without-password")
}
if strings.HasPrefix(line, "UseDNS ") {
data.UseDNS = strings.ReplaceAll(line, "UseDNS ", "")
}
}
currentUser, err := user.Current()
if err != nil || len(currentUser.Name) == 0 {
data.CurrentUser = "root"
} else {
data.CurrentUser = currentUser.Name
}
loadSSHServiceStatus(&data)
loadSSHConfigInfo(&data)
data.CurrentUser = loadCurrentUserName()
return &data, nil
}
@@ -160,58 +130,175 @@ func (u *SSHService) Update(req dto.SSHUpdate) error {
return err
}
sshConf, err := os.ReadFile(sshPath)
directives, _, err := parseSSHConfigTree(sshPath)
if err != nil {
return err
}
lines := strings.Split(string(sshConf), "\n")
newFiles := updateSSHConf(lines, req.Key, req.NewValue)
file, err := os.OpenFile(sshPath, os.O_WRONLY|os.O_TRUNC, constant.FilePerm)
if err != nil {
oldPortValue := strings.Join(loadSSHPortValues(directives), ",")
if err := updateSSHDirectiveValue(req.Key, req.NewValue, directives); err != nil {
return err
}
defer file.Close()
if _, err = file.WriteString(strings.Join(newFiles, "\n")); err != nil {
return err
}
sudo := cmd.SudoHandleCmd()
if req.Key == "Port" {
stdout, _ := cmd.RunDefaultWithStdoutBashCf("%s getenforce", sudo)
if stdout == "Enforcing\n" {
_ = cmd.RunDefaultBashCf("%s semanage port -a -t ssh_port_t -p tcp %s", sudo, req.NewValue)
}
ruleItem := dto.PortRuleUpdate{
OldRule: dto.PortRuleOperate{
Operation: "remove",
Port: req.OldValue,
Protocol: "tcp",
Strategy: "accept",
},
NewRule: dto.PortRuleOperate{
Operation: "add",
Port: req.NewValue,
Protocol: "tcp",
Strategy: "accept",
},
}
if err := NewIFirewallService().UpdatePortRule(ruleItem); err != nil {
global.LOG.Errorf("reset firewall rules %s -> %s failed, err: %v", req.OldValue, req.NewValue, err)
}
newPort, _ := strconv.Atoi(req.NewValue)
if err := updateLocalConn(uint(newPort)); err != nil {
global.LOG.Errorf("update local conn for terminal failed, err: %v", err)
}
if err := updateSSHSocketFile(req.NewValue); err != nil {
global.LOG.Errorf("update port for ssh.socket failed, err: %v", err)
}
handleSSHPortUpdate(oldPortValue, req.NewValue)
}
_ = controller.HandleRestart(serviceName)
return nil
}
func loadSSHServiceStatus(data *dto.SSHInfo) {
serviceName, err := loadServiceName()
if err != nil {
data.IsExist = false
data.Message = err.Error()
return
}
active, err := controller.CheckActive(serviceName)
data.IsActive = active
if !active && err != nil {
data.Message = err.Error()
}
enable, err := controller.CheckEnable(serviceName)
if err != nil {
data.AutoStart = false
return
}
data.AutoStart = enable
}
func loadSSHConfigInfo(data *dto.SSHInfo) {
directives, _, err := parseSSHConfigTree(sshPath)
if err != nil {
data.Message = err.Error()
data.IsActive = false
return
}
if port := strings.Join(loadSSHPortValues(directives), ","); port != "" {
data.Port = port
}
data.ListenAddress = strings.Join(loadSSHDirectiveValues(directives, "ListenAddress"), ",")
if value, ok := loadFirstSSHDirectiveValue(directives, "PasswordAuthentication"); ok {
data.PasswordAuthentication = value
}
if value, ok := loadFirstSSHDirectiveValue(directives, "PubkeyAuthentication"); ok {
data.PubkeyAuthentication = value
}
if value, ok := loadFirstSSHDirectiveValue(directives, "PermitRootLogin"); ok {
data.PermitRootLogin = strings.ReplaceAll(value, "prohibit-password", "without-password")
}
if value, ok := loadFirstSSHDirectiveValue(directives, "UseDNS"); ok {
data.UseDNS = value
}
}
func loadCurrentUserName() string {
currentUser, err := user.Current()
if err != nil || currentUser.Name == "" {
return "root"
}
return currentUser.Name
}
func handleSSHPortUpdate(oldValue, newValue string) {
sudo := cmd.SudoHandleCmd()
newPorts := splitSSHPorts(newValue)
oldPorts := splitSSHPorts(oldValue)
stdout, _ := runWithOptionalSudo(sudo, "getenforce")
if stdout == "Enforcing\n" {
for _, port := range diffSSHPorts(oldPorts, newPorts) {
if _, err := runWithOptionalSudo(sudo, "semanage", "port", "-d", "-t", "ssh_port_t", "-p", "tcp", port); err != nil {
global.LOG.Warnf("remove selinux ssh port %s failed, err: %v", port, err)
}
}
for _, port := range newPorts {
_, _ = runWithOptionalSudo(sudo, "semanage", "port", "-a", "-t", "ssh_port_t", "-p", "tcp", port)
}
}
removedPorts, err := parseSSHPortsToInts(diffSSHPorts(oldPorts, newPorts))
if err != nil {
global.LOG.Errorf("parse removed ssh ports failed, err: %v", err)
} else {
addedPorts, err := parseSSHPortsToInts(diffSSHPorts(newPorts, oldPorts))
if err != nil {
global.LOG.Errorf("parse added ssh ports failed, err: %v", err)
} else if err := OperateFirewallPort(removedPorts, addedPorts); err != nil {
global.LOG.Errorf("reset firewall rules %s -> %s failed, err: %v", oldValue, newValue, err)
}
}
primaryPort, err := loadPrimarySSHPort(newValue)
if err != nil {
global.LOG.Errorf("load primary ssh port from %s failed, err: %v", newValue, err)
return
}
if err := updateLocalConn(uint(primaryPort)); err != nil {
global.LOG.Errorf("update local conn for terminal failed, err: %v", err)
}
if err := updateSSHSocketFile(strconv.Itoa(primaryPort)); err != nil {
global.LOG.Errorf("update port for ssh.socket failed, err: %v", err)
}
}
func splitSSHPorts(value string) []string {
var ports []string
for _, item := range strings.Split(value, ",") {
port := strings.TrimSpace(item)
if port != "" {
ports = append(ports, port)
}
}
return ports
}
func diffSSHPorts(left, right []string) []string {
rightSet := make(map[string]struct{}, len(right))
for _, item := range right {
rightSet[item] = struct{}{}
}
var diff []string
for _, item := range left {
if _, ok := rightSet[item]; ok {
continue
}
diff = append(diff, item)
}
return diff
}
func loadPrimarySSHPort(value string) (int, error) {
ports := splitSSHPorts(value)
if len(ports) == 0 {
return 0, fmt.Errorf("ssh port is empty")
}
return strconv.Atoi(ports[0])
}
func parseSSHPortsToInts(ports []string) ([]int, error) {
var values []int
for _, port := range ports {
value, err := strconv.Atoi(port)
if err != nil {
return nil, err
}
values = append(values, value)
}
return values, nil
}
func runWithOptionalSudo(sudo, name string, args ...string) (string, error) {
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(20 * time.Second))
if sudo != "" {
commandArgs := append([]string{name}, args...)
return cmdMgr.RunWithStdout("sudo", commandArgs...)
}
return cmdMgr.RunWithStdout(name, args...)
}
func (u *SSHService) SyncRootCert() error {
currentUser, err := user.Current()
if err != nil {
@@ -582,8 +669,25 @@ func (u *SSHService) LoadSSHFile(name string) (string, error) {
fileName = currentUser.HomeDir + "/.ssh/authorized_keys"
case "sshdConf":
fileName = "/etc/ssh/sshd_config"
case "sshdConfOptions":
_, fileList, err := parseSSHConfigTree(sshPath)
if err != nil {
return "", err
}
content, err := json.Marshal(fileList)
if err != nil {
return "", err
}
return string(content), nil
default:
return "", buserr.WithName("ErrNotSupportType", name)
if strings.HasPrefix(name, "sshdConfPath:") {
fileName = strings.TrimPrefix(name, "sshdConfPath:")
if !isSSHConfigPathAllowed(fileName) {
return "", buserr.WithName("ErrNotSupportType", name)
}
} else {
return "", buserr.WithName("ErrNotSupportType", name)
}
}
if _, err := os.Stat(fileName); err != nil {
return "", buserr.WithErr("ErrHttpReqNotFound", err)
@@ -595,7 +699,7 @@ func (u *SSHService) LoadSSHFile(name string) (string, error) {
return string(content), nil
}
func (u *SSHService) UpdateByFile(req dto.SettingUpdate) error {
func (u *SSHService) UpdateByFile(req dto.SSHConfUpdate) error {
var fileName string
switch req.Key {
case "authKeys":
@@ -606,6 +710,11 @@ func (u *SSHService) UpdateByFile(req dto.SettingUpdate) error {
fileName = currentUser.HomeDir + "/.ssh/authorized_keys"
case "sshdConf":
fileName = "/etc/ssh/sshd_config"
case "sshdConfPath":
fileName = req.Path
if !isSSHConfigPathAllowed(fileName) {
return buserr.WithName("ErrNotSupportType", req.Key)
}
default:
return buserr.WithName("ErrNotSupportType", req.Key)
}
@@ -647,35 +756,372 @@ func sortFileList(fileNames []sshFileItem) []sshFileItem {
return fileNames
}
func updateSSHConf(oldFiles []string, param string, value string) []string {
var valueItems []string
if param != "ListenAddress" {
valueItems = append(valueItems, value)
} else {
if value != "" {
valueItems = strings.Split(value, ",")
}
func parseSSHConfigTree(root string) ([]sshDirective, []sshConfigFile, error) {
rootPath, err := filepath.Abs(root)
if err != nil {
return nil, nil, err
}
var newFiles []string
for _, line := range oldFiles {
lineItem := strings.TrimSpace(line)
if (strings.HasPrefix(lineItem, param) || strings.HasPrefix(lineItem, fmt.Sprintf("#%s", param))) && len(valueItems) != 0 {
newFiles = append(newFiles, fmt.Sprintf("%s %s", param, valueItems[0]))
valueItems = valueItems[1:]
var (
directives []sshDirective
fileList []sshConfigFile
order int
priority int
)
visited := make(map[string]bool)
if err := parseSSHConfigFile(rootPath, false, visited, &directives, &fileList, &order, &priority); err != nil {
return nil, nil, err
}
return directives, fileList, nil
}
func parseSSHConfigFile(
fileName string,
inMatch bool,
visited map[string]bool,
directives *[]sshDirective,
fileList *[]sshConfigFile,
order *int,
priority *int,
) error {
absolutePath, err := filepath.Abs(fileName)
if err != nil {
return err
}
if visited[absolutePath] {
return nil
}
visited[absolutePath] = true
content, err := os.ReadFile(absolutePath)
if err != nil {
return err
}
lines := strings.Split(string(content), "\n")
currentMatch := inMatch
fileRegistered := false
for index, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(lineItem, param) && len(valueItems) == 0 {
newFiles = append(newFiles, fmt.Sprintf("#%s", line))
key, value, ok := parseSSHConfigLine(line)
if !ok {
continue
}
newFiles = append(newFiles, line)
if strings.EqualFold(key, "Match") {
currentMatch = true
continue
}
if strings.EqualFold(key, "Include") {
for _, includePath := range expandSSHIncludePaths(absolutePath, value) {
if err := parseSSHConfigFile(includePath, currentMatch, visited, directives, fileList, order, priority); err != nil {
global.LOG.Warnf("parse ssh include %s failed, err: %v", includePath, err)
}
}
continue
}
if !fileRegistered {
registerSSHConfigFile(absolutePath, fileList, priority)
fileRegistered = true
}
*order++
*directives = append(*directives, sshDirective{
Key: key,
Value: normalizeSSHDirectiveValue(key, value),
File: absolutePath,
Line: index,
InMatch: currentMatch,
})
}
if len(valueItems) != 0 {
for _, item := range valueItems {
newFiles = append(newFiles, fmt.Sprintf("%s %s", param, item))
if !fileRegistered {
registerSSHConfigFile(absolutePath, fileList, priority)
}
return nil
}
func registerSSHConfigFile(fileName string, fileList *[]sshConfigFile, priority *int) {
for _, item := range *fileList {
if item.Path == fileName {
return
}
}
return newFiles
*priority++
*fileList = append(*fileList, sshConfigFile{Path: fileName, Priority: *priority})
}
func parseSSHConfigLine(line string) (string, string, bool) {
line = trimSSHInlineComment(line)
fields := strings.Fields(line)
if len(fields) < 2 {
return "", "", false
}
return fields[0], strings.Join(fields[1:], " "), true
}
func trimSSHInlineComment(line string) string {
inQuote := false
for i := 0; i < len(line); i++ {
switch line[i] {
case '"':
inQuote = !inQuote
case '#':
if !inQuote && (i == 0 || line[i-1] == ' ' || line[i-1] == '\t') {
return strings.TrimSpace(line[:i])
}
}
}
return strings.TrimSpace(line)
}
func expandSSHIncludePaths(baseFile, value string) []string {
var includeFiles []string
for _, item := range strings.Fields(strings.ReplaceAll(value, "\"", "")) {
pattern := item
if !filepath.IsAbs(pattern) {
pattern = filepath.Join(filepath.Dir(baseFile), pattern)
}
matches, err := filepath.Glob(pattern)
if err != nil {
continue
}
sort.Strings(matches)
includeFiles = append(includeFiles, matches...)
}
return includeFiles
}
func normalizeSSHDirectiveValue(key, value string) string {
if strings.EqualFold(key, "PermitRootLogin") && value == "prohibit-password" {
return "without-password"
}
return value
}
func loadFirstSSHDirectiveValue(directives []sshDirective, key string) (string, bool) {
for _, item := range directives {
if item.InMatch || !strings.EqualFold(item.Key, key) {
continue
}
return item.Value, true
}
return "", false
}
func loadSSHDirectiveValues(directives []sshDirective, key string) []string {
var values []string
for _, item := range directives {
if item.InMatch || !strings.EqualFold(item.Key, key) {
continue
}
values = append(values, item.Value)
}
return values
}
func loadSSHPortValues(directives []sshDirective) []string {
values := loadSSHDirectiveValues(directives, "Port")
if len(values) == 0 {
return []string{defaultSSHPort}
}
return values
}
func updateSSHDirectiveValue(key, value string, directives []sshDirective) error {
if key == "Port" || key == "ListenAddress" {
return rewriteSSHMultiValueDirective(key, value, directives)
}
return updateSSHSingleDirectiveValue(key, value, directives)
}
func updateSSHSingleDirectiveValue(key, value string, directives []sshDirective) error {
block, err := loadSSHManagedBlockFromFile(sshPath)
if err != nil {
return err
}
for _, item := range directives {
if item.InMatch || !strings.EqualFold(item.Key, key) {
continue
}
if item.File == sshPath && block.contains(item.Line) {
continue
}
if err := commentSSHConfigLines(item.File, []int{item.Line}); err != nil {
return err
}
}
return rewriteSSHManagedDirectives(sshPath, key, []string{fmt.Sprintf("%s %s", key, value)})
}
func rewriteSSHMultiValueDirective(key, value string, directives []sshDirective) error {
block, err := loadSSHManagedBlockFromFile(sshPath)
if err != nil {
return err
}
targetFiles := make(map[string][]int)
for _, item := range directives {
if item.InMatch || !strings.EqualFold(item.Key, key) {
continue
}
if item.File == sshPath && block.contains(item.Line) {
continue
}
targetFiles[item.File] = append(targetFiles[item.File], item.Line)
}
for fileName, lines := range targetFiles {
if err := commentSSHConfigLines(fileName, lines); err != nil {
return err
}
}
return rewriteSSHManagedDirectives(sshPath, key, buildSSHDirectiveLines(key, value))
}
func buildSSHDirectiveLines(key, value string) []string {
var directives []string
for _, item := range strings.Split(value, ",") {
if item != "" {
directives = append(directives, fmt.Sprintf("%s %s", key, item))
}
}
return directives
}
func rewriteSSHManagedDirectives(fileName, key string, newDirectives []string) error {
content, err := os.ReadFile(fileName)
if err != nil {
return err
}
lines := strings.Split(string(content), "\n")
lines, insertAt := ensureSSHManagedMarker(lines)
block, ok := loadSSHManagedBlock(lines)
if ok {
var filtered []string
for index := block.Start; index < block.End; index++ {
line := strings.TrimSpace(lines[index])
if line == "" || strings.HasPrefix(line, "#") {
filtered = append(filtered, lines[index])
continue
}
itemKey, _, ok := parseSSHConfigLine(line)
if ok && strings.EqualFold(itemKey, key) {
continue
}
filtered = append(filtered, lines[index])
}
lines = append(append(lines[:block.Start], filtered...), lines[block.End:]...)
insertAt = block.Start
}
if len(newDirectives) != 0 {
lines = insertSSHDirectivesAt(lines, insertAt, newDirectives)
}
return os.WriteFile(fileName, []byte(strings.Join(lines, "\n")), constant.FilePerm)
}
func commentSSHConfigLines(fileName string, targetLines []int) error {
content, err := os.ReadFile(fileName)
if err != nil {
return err
}
lines := strings.Split(string(content), "\n")
targetSet := make(map[int]struct{}, len(targetLines))
for _, line := range targetLines {
targetSet[line] = struct{}{}
}
for index := range lines {
if _, ok := targetSet[index]; !ok {
continue
}
trimmed := strings.TrimSpace(lines[index])
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
lines[index] = "#" + lines[index]
}
return os.WriteFile(fileName, []byte(strings.Join(lines, "\n")), constant.FilePerm)
}
func loadSSHInsertIndex(lines []string) int {
for index, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(trimmed), "match ") {
return index
}
}
return len(lines)
}
func ensureSSHManagedMarker(lines []string) ([]string, int) {
for index, line := range lines {
if strings.TrimSpace(line) == sshManagedMarker {
return lines, index + 1
}
}
insertAt := loadSSHInsertIndex(lines)
lines = insertSSHDirectivesAt(lines, insertAt, []string{sshManagedMarker})
return lines, insertAt + 1
}
func loadSSHManagedBlock(lines []string) (sshManagedBlock, bool) {
for index, line := range lines {
if strings.TrimSpace(line) != sshManagedMarker {
continue
}
end := len(lines)
for next := index + 1; next < len(lines); next++ {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(lines[next])), "match ") {
end = next
break
}
}
return sshManagedBlock{Start: index + 1, End: end}, true
}
return sshManagedBlock{}, false
}
func loadSSHManagedBlockFromFile(fileName string) (sshManagedBlock, error) {
content, err := os.ReadFile(fileName)
if err != nil {
return sshManagedBlock{}, err
}
block, ok := loadSSHManagedBlock(strings.Split(string(content), "\n"))
if !ok {
return sshManagedBlock{}, nil
}
return block, nil
}
func insertSSHDirectivesAt(lines []string, insertAt int, directives []string) []string {
newLines := make([]string, 0, len(lines)+len(directives))
newLines = append(newLines, lines[:insertAt]...)
newLines = append(newLines, directives...)
newLines = append(newLines, lines[insertAt:]...)
return newLines
}
func isSSHConfigPathAllowed(fileName string) bool {
if fileName == "" {
return false
}
absolutePath, err := filepath.Abs(fileName)
if err != nil {
return false
}
if absolutePath == sshPath {
return true
}
_, fileList, err := parseSSHConfigTree(sshPath)
if err != nil {
return false
}
for _, item := range fileList {
if item.Path == absolutePath {
return true
}
}
return false
}
func loadSSHData(ctx *gin.Context, command string, showCountFrom, showCountTo, currentYear int, nyc *time.Location) ([]dto.SSHHistory, int, int) {
@@ -804,7 +1250,8 @@ func checkIsStandard(item dto.SSHHistory) bool {
}
func handleGunzip(path string) error {
if err := cmd.RunDefaultBashCf("gunzip %s", path); err != nil {
cmdMgr := cmd.NewCommandMgr()
if err := cmdMgr.Run("gunzip", path); err != nil {
return err
}
return nil
+3
View File
@@ -21,6 +21,9 @@ func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, e
opts := []repo.DBOption{
repo.WithOrderDesc("created_at"),
}
if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID))
}
if req.Status != "" {
opts = append(opts, repo.WithByStatus(req.Status))
}
+21 -1
View File
@@ -379,6 +379,17 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
req request.AppInstallCreate
install *model.AppInstall
)
appDetail, err := appDetailRepo.GetFirst(repo.WithByID(create.AppInstall.AppDetailId))
if err != nil {
return err
}
app, err := appRepo.GetFirst(repo.WithByID(appDetail.AppId))
if err != nil {
return err
}
if isAgentAppKey(app.Key) {
return fmt.Errorf("%s does not support website deployment", app.Key)
}
req.Name = create.AppInstall.Name
req.AppDetailId = create.AppInstall.AppDetailId
req.Params = create.AppInstall.Params
@@ -538,7 +549,13 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
createTask.AddSubTaskWithIgnoreErr(i18n.GetWithName("ConfigFTP", create.FtpUser), createFtpUser)
}
return createTask.Execute()
if err := createTask.Execute(); err != nil {
return err
}
if err := bindDeploymentWebsiteToAgentByAppInstall(website); err != nil {
global.LOG.Errorf("bind deployment website to agent failed: %v", err)
}
return nil
}
func (w WebsiteService) OpWebsite(req request.WebsiteOp) error {
@@ -733,6 +750,9 @@ func (w WebsiteService) DeleteWebsite(req request.WebsiteDelete) error {
if err := websiteRepo.DeleteBy(ctx, repo.WithByID(req.ID)); err != nil {
return err
}
if err := agentRepo.ClearWebsiteIDByWebsiteIDWithCtx(ctx, req.ID); err != nil {
return err
}
if err := websiteDomainRepo.DeleteBy(ctx, websiteDomainRepo.WithWebsiteId(req.ID)); err != nil {
return err
}
+1 -1
View File
@@ -443,7 +443,7 @@ func (w WebsiteCAService) DownloadFile(id uint) (*os.File, error) {
return nil, err
}
fileName := ca.Name + ".zip"
if err = fileOp.Compress([]string{path.Join(dir, "ca.crt"), path.Join(dir, "ca.key")}, dir, fileName, files.SdkZip, ""); err != nil {
if err = fileOp.Compress(context.Background(), []string{path.Join(dir, "ca.crt"), path.Join(dir, "ca.key")}, dir, fileName, files.SdkZip, "", nil); err != nil {
return nil, err
}
return os.Open(path.Join(dir, fileName))
+63 -31
View File
@@ -5,8 +5,7 @@ import (
"crypto/x509"
"encoding/pem"
"fmt"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/go-acme/lego/v4/certificate"
"io"
"log"
"os"
"path"
@@ -14,6 +13,10 @@ import (
"strings"
"time"
"github.com/go-acme/lego/v4/certificate"
legoLogger "github.com/go-acme/lego/v4/log"
"github.com/jinzhu/gorm"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
@@ -27,8 +30,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/1Panel-dev/1Panel/agent/utils/ssl"
legoLogger "github.com/go-acme/lego/v4/log"
"github.com/jinzhu/gorm"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
)
type WebsiteSSLService struct {
@@ -45,6 +47,7 @@ type IWebsiteSSLService interface {
Update(update request.WebsiteSSLUpdate) error
Upload(req request.WebsiteSSLUpload) error
ObtainSSL(apply request.WebsiteSSLApply) error
AutoRenewSSL(id uint) error
SyncForRestart() error
DownloadFile(id uint) (*os.File, error)
ImportMasterSSL(create model.WebsiteSSL) error
@@ -210,13 +213,35 @@ func (w WebsiteSSLService) Create(create request.WebsiteSSLCreate) (request.Webs
return create, nil
}
func printSSLLog(logger *log.Logger, msgKey string, params map[string]interface{}, disableLog bool) {
if disableLog {
func printSSLLog(logger *log.Logger, msgKey string, params map[string]interface{}) {
if logger == nil {
return
}
logger.Println(i18n.GetMsgWithMap(msgKey, params))
}
func newWebsiteSSLLogger(websiteSSL *model.WebsiteSSL, autoRenew bool) (*os.File, *log.Logger) {
flags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC
if autoRenew {
flags = os.O_CREATE | os.O_WRONLY | os.O_APPEND
}
logFile, err := os.OpenFile(websiteSSL.GetLogPath(), flags, constant.FilePerm)
if err != nil {
global.LOG.Errorf("open ssl log file failed, domain: %s, err: %v", websiteSSL.PrimaryDomain, err)
return nil, log.New(io.Discard, "", log.LstdFlags)
}
if autoRenew {
if info, statErr := logFile.Stat(); statErr == nil && info.Size() > 0 {
_, _ = logFile.WriteString("\n")
}
_, _ = logFile.WriteString(fmt.Sprintf("========== [%s] auto renew attempt ==========\n", time.Now().Format(constant.DateTimeLayout)))
}
return logFile, log.New(logFile, "", log.LstdFlags)
}
func reloadSystemSSL(websiteSSL *model.WebsiteSSL, logger *log.Logger) {
if !global.IsMaster {
return
@@ -226,7 +251,7 @@ func reloadSystemSSL(websiteSSL *model.WebsiteSSL, logger *log.Logger) {
fileOp := files.NewFileOp()
certPath := path.Join(global.Dir.DataDir, "secret/server.crt")
keyPath := path.Join(global.Dir.DataDir, "secret/server.key")
printSSLLog(logger, "StartUpdateSystemSSL", nil, logger == nil)
printSSLLog(logger, "StartUpdateSystemSSL", nil)
if err := fileOp.WriteFile(certPath, strings.NewReader(websiteSSL.Pem), 0600); err != nil {
logger.Printf("Failed to update the SSL certificate File for 1Panel System domain [%s] , err:%s", websiteSSL.PrimaryDomain, err.Error())
return
@@ -239,11 +264,19 @@ func reloadSystemSSL(websiteSSL *model.WebsiteSSL, logger *log.Logger) {
logger.Printf("Failed to update the SSL certificate for 1Panel System domain [%s] , err:%s", websiteSSL.PrimaryDomain, err.Error())
return
}
printSSLLog(logger, "UpdateSystemSSLSuccess", nil, logger == nil)
printSSLLog(logger, "UpdateSystemSSLSuccess", nil)
}
}
func (w WebsiteSSLService) ObtainSSL(apply request.WebsiteSSLApply) error {
return w.obtainSSL(apply.ID, false)
}
func (w WebsiteSSLService) AutoRenewSSL(id uint) error {
return w.obtainSSL(id, true)
}
func (w WebsiteSSLService) obtainSSL(id uint, autoRenew bool) error {
var (
err error
websiteSSL *model.WebsiteSSL
@@ -254,7 +287,7 @@ func (w WebsiteSSLService) ObtainSSL(apply request.WebsiteSSLApply) error {
resource certificate.Resource
)
websiteSSL, err = websiteSSLRepo.GetFirst(repo.WithByID(apply.ID))
websiteSSL, err = websiteSSLRepo.GetFirst(repo.WithByID(id))
if err != nil {
return err
}
@@ -306,17 +339,16 @@ func (w WebsiteSSLService) ObtainSSL(apply request.WebsiteSSLApply) error {
}
go func() {
logFile, _ := os.OpenFile(path.Join(global.Dir.SSLLogDir, fmt.Sprintf("%s-ssl-%d.log", websiteSSL.PrimaryDomain, websiteSSL.ID)), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, constant.FilePerm)
defer logFile.Close()
logger := log.New(logFile, "", log.LstdFlags)
legoLogger.Logger = logger
if !apply.DisableLog {
startMsg := i18n.GetMsgWithMap("ApplySSLStart", map[string]interface{}{"domain": strings.Join(domains, ","), "type": i18n.GetMsgByKey(websiteSSL.Provider)})
if websiteSSL.Provider == constant.DNSAccount {
startMsg = startMsg + i18n.GetMsgWithMap("DNSAccountName", map[string]interface{}{"name": dnsAccount.Name, "type": dnsAccount.Type})
}
logger.Println(startMsg)
logFile, logger := newWebsiteSSLLogger(websiteSSL, autoRenew)
if logFile != nil {
defer logFile.Close()
}
legoLogger.Logger = logger
startMsg := i18n.GetMsgWithMap("ApplySSLStart", map[string]interface{}{"domain": strings.Join(domains, ","), "type": i18n.GetMsgByKey(websiteSSL.Provider)})
if websiteSSL.Provider == constant.DNSAccount {
startMsg = startMsg + i18n.GetMsgWithMap("DNSAccountName", map[string]interface{}{"name": dnsAccount.Name, "type": dnsAccount.Type})
}
logger.Println(startMsg)
if websiteSSL.Provider != constant.DnsManual {
privateKey, err := ssl.GetPrivateKeyByType(websiteSSL.KeyType, websiteSSL.PrivateKey)
if err != nil {
@@ -361,7 +393,7 @@ func (w WebsiteSSLService) ObtainSSL(apply request.WebsiteSSLApply) error {
websiteSSL.Organization = cert.Issuer.Organization[0]
}
websiteSSL.Status = constant.SSLReady
printSSLLog(logger, "ApplySSLSuccess", map[string]interface{}{"domain": strings.Join(domains, ",")}, apply.DisableLog)
printSSLLog(logger, "ApplySSLSuccess", map[string]interface{}{"domain": strings.Join(domains, ",")})
saveCertificateFile(websiteSSL, logger)
if websiteSSL.ExecShell {
@@ -369,12 +401,12 @@ func (w WebsiteSSLService) ObtainSSL(apply request.WebsiteSSLApply) error {
if websiteSSL.PushDir {
workDir = websiteSSL.Dir
}
printSSLLog(logger, "ExecShellStart", nil, apply.DisableLog)
printSSLLog(logger, "ExecShellStart", nil)
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(30*time.Minute), cmd.WithLogger(logger), cmd.WithWorkDir(workDir))
if err = cmdMgr.RunBashC(websiteSSL.Shell); err != nil {
printSSLLog(logger, "ErrExecShell", map[string]interface{}{"err": err.Error()}, apply.DisableLog)
printSSLLog(logger, "ErrExecShell", map[string]interface{}{"err": err.Error()})
} else {
printSSLLog(logger, "ExecShellSuccess", nil, apply.DisableLog)
printSSLLog(logger, "ExecShellSuccess", nil)
}
}
@@ -386,9 +418,9 @@ func (w WebsiteSSLService) ObtainSSL(apply request.WebsiteSSLApply) error {
websites, _ := websiteRepo.GetBy(websiteRepo.WithWebsiteSSLID(websiteSSL.ID))
if len(websites) > 0 {
for _, website := range websites {
printSSLLog(logger, "ApplyWebSiteSSLLog", map[string]interface{}{"name": website.PrimaryDomain}, apply.DisableLog)
printSSLLog(logger, "ApplyWebSiteSSLLog", map[string]interface{}{"name": website.PrimaryDomain})
if err := createPemFile(website, *websiteSSL); err != nil {
printSSLLog(logger, "ErrUpdateWebsiteSSL", map[string]interface{}{"name": website.PrimaryDomain, "err": err.Error()}, apply.DisableLog)
printSSLLog(logger, "ErrUpdateWebsiteSSL", map[string]interface{}{"name": website.PrimaryDomain, "err": err.Error()})
}
}
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
@@ -396,19 +428,19 @@ func (w WebsiteSSLService) ObtainSSL(apply request.WebsiteSSLApply) error {
return
}
if err := opNginx(nginxInstall.ContainerName, constant.NginxReload); err != nil {
printSSLLog(logger, "ErrSSLApply", nil, apply.DisableLog)
printSSLLog(logger, "ErrSSLApply", nil)
return
}
printSSLLog(logger, "ApplyWebSiteSSLSuccess", nil, apply.DisableLog)
printSSLLog(logger, "ApplyWebSiteSSLSuccess", nil)
}
reloadSystemSSL(websiteSSL, logger)
if websiteSSL.PushNode {
printSSLLog(logger, "StartPushSSLToNode", nil, apply.DisableLog)
printSSLLog(logger, "StartPushSSLToNode", nil)
if err = xpack.PushSSLToNode(websiteSSL); err != nil {
printSSLLog(logger, "PushSSLToNodeFailed", map[string]interface{}{"err": err.Error()}, apply.DisableLog)
printSSLLog(logger, "PushSSLToNodeFailed", map[string]interface{}{"err": err.Error()})
return
}
printSSLLog(logger, "PushSSLToNodeSuccess", nil, apply.DisableLog)
printSSLLog(logger, "PushSSLToNodeSuccess", nil)
}
}()
@@ -726,7 +758,7 @@ func (w WebsiteSSLService) DownloadFile(id uint) (*os.File, error) {
return nil, err
}
fileName := websiteSSL.PrimaryDomain + ".zip"
if err = fileOp.Compress([]string{path.Join(dir, "fullchain.pem"), path.Join(dir, "privkey.pem")}, dir, fileName, files.SdkZip, ""); err != nil {
if err = fileOp.Compress(context.Background(), []string{path.Join(dir, "fullchain.pem"), path.Join(dir, "privkey.pem")}, dir, fileName, files.SdkZip, "", nil); err != nil {
return nil, err
}
return os.Open(path.Join(dir, fileName))
+13
View File
@@ -127,6 +127,19 @@ func CheckResourceTaskIsExecuting(operate, scope string, resourceID uint) bool {
return task.ID != ""
}
func CheckScopeTaskIsExecuting(scope string, resourceID uint) error {
taskRepo := repo.NewITaskRepo()
task, _ := taskRepo.GetFirst(
taskRepo.WithByStatus(constant.StatusExecuting),
taskRepo.WithResourceID(resourceID),
repo.WithByType(scope),
)
if task.ID != "" {
return buserr.New("TaskIsExecuting")
}
return nil
}
func NewTask(name, operate, taskScope, taskID string, resourceID uint) (*Task, error) {
if taskID == "" {
taskID = uuid.New().String()
+1
View File
@@ -3,6 +3,7 @@ base:
mode: dev
is_demo: false
is_offline: false
is_fxplay: false
log:
level: debug
+1
View File
@@ -25,4 +25,5 @@ const (
DingTalk = "dingTalk"
FeiShu = "feiShu"
Custom = "custom"
Bark = "bark"
)
+1
View File
@@ -9,6 +9,7 @@ const (
AppOpenresty = "openresty"
AppOpenclaw = "openclaw"
AppCopaw = "copaw"
AppHermesAgent = "hermes-agent"
AppMysql = "mysql"
AppMariaDB = "mariadb"
AppPostgresql = "postgresql"
+1 -1
View File
@@ -41,7 +41,7 @@ func Run() {
if _, err := global.Cron.AddJob("@daily", job.NewWebsiteJob()); err != nil {
global.LOG.Errorf("can not add website corn job: %s", err.Error())
}
if _, err := global.Cron.AddJob("@daily", job.NewSSLJob()); err != nil {
if _, err := global.Cron.AddJob("0 */6 * * *", job.NewSSLJob()); err != nil {
global.LOG.Errorf("can not add ssl corn job: %s", err.Error())
}
minuteRand, err := rand.Int(rand.Reader, big.NewInt(60))
+1 -4
View File
@@ -46,10 +46,7 @@ func (ssl *ssl) Run() {
continue
}
} else {
if err := sslService.ObtainSSL(request.WebsiteSSLApply{
ID: s.ID,
DisableLog: true,
}); err != nil {
if err := sslService.AutoRenewSSL(s.ID); err != nil {
global.LOG.Errorf("Failed to update the SSL certificate for the [%s] domain , err:%s", s.PrimaryDomain, err.Error())
continue
}
+1
View File
@@ -8,6 +8,7 @@ type ServerConfig struct {
type Base struct {
Port string `mapstructure:"port"`
IsFxplay bool `mapstructure:"is_fxplay"`
Edition string `mapstructure:"edition"` // [ cn / intl]
Version string `mapstructure:"version"`
EncryptKey string `mapstructure:"encrypt_key"`
+9
View File
@@ -42,18 +42,27 @@ var (
)
func RepoURL() string {
if CONF.Base.IsFxplay {
return "https://resource.fit2cloud.com/1panel/package/fusionxplay"
}
if CONF.Base.Edition != "intl" {
return "https://resource.fit2cloud.com/1panel/package/v2"
}
return "https://resource.1panel.pro/v2"
}
func ResourceURL() string {
if CONF.Base.IsFxplay {
return "https://resource.fit2cloud.com/1panel/resource/fusionxplay"
}
if CONF.Base.Edition != "intl" {
return "https://resource.fit2cloud.com/1panel/resource/v2"
}
return "https://resource.1panel.pro/v2/resource"
}
func AppRepoURL() string {
if CONF.Base.IsFxplay {
return "https://apps-assets.fit2cloud.com"
}
if CONF.Base.Edition != "intl" {
return "https://apps-assets.fit2cloud.com"
}
+149 -145
View File
@@ -6,184 +6,185 @@ replace github.com/moby/go-archive => github.com/moby/go-archive v0.1.0
require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/aws/aws-sdk-go v1.55.0
github.com/compose-spec/compose-go/v2 v2.9.1
github.com/compose-spec/compose-go/v2 v2.10.2
github.com/creack/pty v1.1.24
github.com/docker/cli v29.2.0+incompatible
github.com/docker/cli v29.4.0+incompatible
github.com/docker/compose/v2 v2.40.3
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.6.0
github.com/docker/go-connections v0.7.0
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-gonic/gin v1.10.0
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-acme/lego/v4 v4.30.1
github.com/go-gormigrate/gormigrate/v2 v2.1.2
github.com/go-playground/validator/v10 v10.26.0
github.com/go-acme/lego/v4 v4.34.0
github.com/go-gormigrate/gormigrate/v2 v2.1.5
github.com/go-playground/validator/v10 v10.30.2
github.com/go-redis/redis v6.15.9+incompatible
github.com/go-resty/resty/v2 v2.17.0
github.com/go-resty/resty/v2 v2.17.2
github.com/go-sql-driver/mysql v1.9.3
github.com/goh-chunlin/go-onedrive v1.1.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/jackc/pgx/v5 v5.7.5
github.com/jackc/pgx/v5 v5.9.1
github.com/jinzhu/copier v0.4.0
github.com/jinzhu/gorm v1.9.16
github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.18.2
github.com/klauspost/compress v1.18.5
github.com/mholt/archiver/v4 v4.0.0-alpha.8
github.com/miekg/dns v1.1.69
github.com/minio/minio-go/v7 v7.0.74
github.com/nicksnyder/go-i18n/v2 v2.4.0
github.com/miekg/dns v1.1.72
github.com/minio/minio-go/v7 v7.0.100
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/opencontainers/image-spec v1.1.1
github.com/oschwald/maxminddb-golang v1.13.1
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/errors v0.9.1
github.com/pkg/sftp v1.13.6
github.com/pkg/sftp v1.13.10
github.com/qiniu/go-sdk/v7 v7.26.4
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.25.11
github.com/shirou/gopsutil/v4 v4.26.3
github.com/sirupsen/logrus v1.9.4
github.com/spf13/afero v1.11.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.19.0
github.com/spf13/viper v1.21.0
github.com/subosito/gotenv v1.6.0
github.com/tencentyun/cos-go-sdk-v5 v0.7.54
github.com/tencentyun/cos-go-sdk-v5 v0.7.73
github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19
github.com/upyun/go-sdk v2.1.0+incompatible
golang.org/x/crypto v0.46.0
golang.org/x/net v0.48.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.40.0
golang.org/x/text v0.32.0
google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38
gopkg.in/ini.v1 v1.67.0
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0
golang.org/x/time v0.15.0
google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478
gopkg.in/ini.v1 v1.67.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.30.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.1 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.16 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea v1.3.14 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/aliyun/credentials-go v1.4.7 // indirect
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.5 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/baidubce/bce-sdk-go v0.9.254 // indirect
github.com/bodgit/plumbing v1.2.0 // indirect
github.com/bodgit/sevenzip v1.3.0 // indirect
github.com/bodgit/windows v1.0.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/alibabacloud-go/tea v1.4.0 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
github.com/aliyun/credentials-go v1.4.12 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.25.0 // indirect
github.com/baidubce/bce-sdk-go v0.9.264 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/connesc/cipherio v0.2.1 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/console v1.0.5 // indirect
github.com/containerd/containerd/api v1.10.0 // indirect
github.com/containerd/containerd/v2 v2.2.1 // indirect
github.com/containerd/containerd/v2 v2.2.3 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v1.0.0-rc.2 // indirect
github.com/containerd/ttrpc v1.2.7 // indirect
github.com/containerd/platforms v1.0.0-rc.4 // indirect
github.com/containerd/ttrpc v1.2.8 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/buildx v0.31.1 // indirect
github.com/docker/buildx v0.33.0 // indirect
github.com/docker/docker-credential-helpers v0.9.5 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.1 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fvbommel/sortorder v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gammazero/toposort v0.1.1 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-acme/alidns-20150109/v4 v4.7.0 // indirect
github.com/go-acme/esa-20240910/v2 v2.40.3 // indirect
github.com/go-acme/tencentclouddnspod v1.1.25 // indirect
github.com/go-acme/esa-20240910/v2 v2.48.0 // indirect
github.com/go-acme/tencentclouddnspod v1.3.24 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.9.8 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/h2non/filetype v1.1.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.180 // indirect
github.com/in-toto/in-toto-golang v0.9.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.192 // indirect
github.com/in-toto/attestation v1.2.0 // indirect
github.com/in-toto/in-toto-golang v0.10.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-shellwords v1.0.13 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/buildkit v0.27.0 // indirect
github.com/moby/buildkit v0.29.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/moby/api v1.52.0 // indirect
github.com/moby/moby/client v0.2.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
@@ -192,38 +193,40 @@ require (
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/montanaflynn/stats v0.9.0 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/mozillazg/go-httpheader v0.2.1 // indirect
github.com/mozillazg/go-httpheader v0.4.0 // indirect
github.com/namedotcom/go/v4 v4.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nrdcg/dnspod-go v0.4.0 // indirect
github.com/nrdcg/freemyip v0.3.0 // indirect
github.com/nrdcg/goacmedns v0.2.0 // indirect
github.com/nrdcg/namesilo v0.5.0 // indirect
github.com/nrdcg/porkbun v0.4.0 // indirect
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/ovh/go-ovh v1.9.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/sigstore/sigstore v1.10.0 // indirect
github.com/sigstore/sigstore-go v1.1.4-0.20251124094504-b5fe07a5a7d7 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/sigstore/sigstore v1.10.5 // indirect
github.com/sigstore/sigstore-go v1.1.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.12 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.80 // indirect
github.com/therootcompany/xz v1.0.1 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
@@ -233,43 +236,44 @@ require (
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/volcengine/volc-sdk-golang v1.0.230 // indirect
github.com/volcengine/volc-sdk-golang v1.0.241 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20200411211856-f5505b9728dd // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.39.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/grpc v1.77.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/arch v0.26.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/fileutil v1.3.3 // indirect
modernc.org/libc v1.66.1 // indirect
modernc.org/fileutil v1.4.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.0 // indirect
modernc.org/sqlite v1.48.2 // indirect
)
+437 -423
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -2,6 +2,8 @@ ErrInvalidParams: 'Bad request: {{ .detail }}'
ErrTokenParse: 'Token error: {{ .detail }}'
ErrInitialPassword: 'Original password is incorrect'
ErrInternalServer: 'Internal server error: {{ .detail }}'
ErrFileAINotEnabled: 'File-management AI summary requires an enabled AI account in File Management. Content-only search still works when AI is off.'
ErrFileAISearchBadPattern: 'Invalid file search pattern (regex or whole word): {{ .detail }}'
ErrWgetRemoteFailed: 'Remote download failed or URL is unreachable: {{ .detail }}'
ErrWgetInvalidContentType: 'Remote URL did not return a file (possibly an error page), {{ .detail }}'
ErrRecordExist: 'Record already exists'
@@ -42,6 +44,7 @@ Decrypt: "Decrypt"
#agent
ErrAgentAccountBound: 'Account is bound to an agent and cannot be deleted'
ErrTerminalAIAccountInUse: 'This model account is currently used by Terminal AI. Switch the account in Terminal AI settings or disable Terminal AI and try again.'
ErrAgentAccountUnavailable: 'Account connection unavailable: {{ .err }}'
ErrAgentProviderNotSupported: 'Unsupported agent provider'
ErrAgentAccountRequired: 'Select an agent account first'
@@ -49,13 +52,21 @@ ErrAgentAccountNotVerified: 'Agent account not verified'
ErrAgentAccountModelsRequired: 'Please configure at least one model'
ErrAgentAccountSingleInitialModel: 'Only one initial model can be configured during account creation'
ErrAgentModelNotInAccount: 'Model is not configured in the selected account'
ErrAgentFallbackModelDuplicate: 'Fallback models cannot be duplicated'
ErrAgentFallbackModelPrimary: 'Fallback model cannot match the primary model'
ErrAgentModelInUse: 'A bound agent is still using this model: {{ .name }}'
ErrAgentBaseURLRequired: 'Base URL is required'
ErrAgentApiKeyRequired: 'API key is required'
ErrAgentComposeRequired: 'Compose content is required'
ErrAgentIDRequired: 'Agent ID is required'
ErrAgentAccountIDRequired: 'Account ID is required'
ErrAgentLimitReached: 'Community Edition supports up to {{ .max }} AI agents. Upgrade to Professional Edition for unlimited agents'
ErrAgentLimitReached: 'Community Edition supports up to {{ .max }} AI agents. Upgrade to Pro Edition for unlimited agents'
ErrAgentWebsiteBound: 'This agent is already bound to a website'
ErrAgentWebsiteTypeUnsupported: 'Only proxy or static websites can be bound'
ErrAgentWebsiteInUse: 'This website is already bound to another agent'
ErrAgentWebsiteUnbindUnsupported: 'Deployment websites cannot be unbound manually'
ErrHermesPairingCodeUnavailable: 'The pairing code is temporarily unavailable in Hermes, possibly due to network issues. Please try again later.'
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'When the Feishu group policy is Allowlist, the DM policy cannot be Pairing Code.'
#backup
Localhost: 'Local'
@@ -135,6 +146,13 @@ ErrLinkPathNotFound: 'The target path does not exist!'
ErrFileIsExist: 'The file or folder already exists!'
ErrFileUpload: 'Upload failed: {{ .name }} {{ .detail }}'
ErrFileDownloadDir: 'Directory download is not supported'
ErrFileSharePath: 'Invalid file path or target is not a file'
ErrFileShareInvalid: 'Invalid or expired share link'
ErrFileShareExpired: 'Share link has expired'
ErrFileSharePassword: 'Incorrect share password'
ErrFileShareExhausted: 'Download limit for this share has been reached'
ErrFileSharePasswordPolicy: 'Password must be at least 4 characters'
ErrFileShareRateLimit: 'Too many requests, please try again later'
ErrCmdNotFound: 'Command not found: {{ .name }}'
ErrSourcePathNotFound: 'Source directory does not exist'
ErrFavoriteExist: 'Path already in favorites'
@@ -573,3 +591,4 @@ TerminalAIBlockedRiskyCommand: 'blocked risky command: {{ .command }}'
TerminalAIThinking: 'AI is thinking...'
TerminalAIReadyToExecute: 'Thinking complete, press Enter to execute (duration: {{ .duration }}, tokens: {{ .tokens }})'
TerminalAIRequestFailed: 'AI request failed: {{ .err }}'
FileAISearchEmptyDir: 'No files or directories found under this path (or all entries were filtered).'
+25 -6
View File
@@ -2,6 +2,8 @@ ErrInvalidParams: 'Solicitud inválida: {{ .detail }}'
ErrTokenParse: 'Token inválido: {{ .detail }}'
ErrInitialPassword: 'Contraseña original incorrecta'
ErrInternalServer: 'Error interno: {{ .detail }}'
ErrFileAINotEnabled: 'La búsqueda con IA de archivos está desactivada o sin cuenta. En Gestión de archivos, use Cuenta de IA, active y elija una cuenta.'
ErrFileAISearchBadPattern: 'Patrón de búsqueda no válido (regex u otra opción): {{ .detail }}'
ErrWgetRemoteFailed: 'La descarga remota falló o la URL no es accesible: {{ .detail }}'
ErrWgetInvalidContentType: 'La URL remota no devolvió un archivo (posiblemente una página de error), {{ .detail }}'
ErrRecordExist: 'Registro ya existe'
@@ -37,6 +39,7 @@ ErrGroupIsDefault: 'Grupo predeterminado, no se puede eliminar'
ErrGroupIsInWebsiteUse: 'El grupo está siendo usado por otro sitio web y no se puede eliminar.'
Decrypt: 'Descifrar'
ErrAgentAccountBound: 'Cuenta vinculada a agente'
ErrTerminalAIAccountInUse: 'Esta cuenta de modelo esta siendo usada por Terminal AI. Cambia la cuenta en la configuracion de Terminal AI o desactiva Terminal AI y vuelve a intentarlo.'
ErrAgentAccountUnavailable: 'Conexión de cuenta no disponible: {{ .err }}'
ErrAgentProviderNotSupported: 'Proveedor de agente no soportado'
ErrAgentAccountRequired: 'Elige una cuenta de agente primero'
@@ -44,13 +47,21 @@ ErrAgentAccountNotVerified: 'Cuenta de agente no verificada'
ErrAgentAccountModelsRequired: 'Configure al menos un modelo'
ErrAgentAccountSingleInitialModel: 'Solo se puede configurar un modelo inicial al crear la cuenta'
ErrAgentModelNotInAccount: 'El modelo no está configurado en la cuenta seleccionada'
ErrAgentFallbackModelDuplicate: 'Los modelos de respaldo no pueden duplicarse'
ErrAgentFallbackModelPrimary: 'El modelo de respaldo no puede ser igual al modelo principal'
ErrAgentModelInUse: 'Un agente vinculado todavía usa este modelo: {{ .name }}'
ErrAgentBaseURLRequired: 'URL base requerida'
ErrAgentApiKeyRequired: 'Clave API requerida'
ErrAgentComposeRequired: 'Contenido compose requerido'
ErrAgentIDRequired: 'ID de agente requerido'
ErrAgentAccountIDRequired: 'ID de cuenta requerido'
ErrAgentLimitReached: 'La edición Community admite hasta {{ .max }} agentes de IA. Actualice a la edición Professional para eliminar el límite'
ErrAgentLimitReached: 'La edición Community admite hasta {{ .max }} agentes de IA. Actualiza a la edición Pro para usar agentes ilimitados'
ErrAgentWebsiteBound: 'Este agente ya está vinculado a un sitio web'
ErrAgentWebsiteTypeUnsupported: 'Solo se pueden vincular sitios proxy o estáticos'
ErrAgentWebsiteInUse: 'Este sitio web ya está vinculado a otro agente'
ErrAgentWebsiteUnbindUnsupported: 'Los sitios web de despliegue no se pueden desvincular manualmente'
ErrHermesPairingCodeUnavailable: 'El código de emparejamiento no está disponible temporalmente en Hermes, posiblemente por un problema de red. Inténtalo de nuevo más tarde.'
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Cuando la política de grupo de Feishu es Lista permitida, la política de MD no puede ser Código de emparejamiento.'
Localhost: 'Máquina local'
ErrBackupInUsed: 'Cuenta de respaldo en uso por tarea programada'
ErrBackupCheck: 'Conexión de respaldo falló: {{ .err }}'
@@ -122,6 +133,13 @@ ErrLinkPathNotFound: 'La ruta de destino no existe'
ErrFileIsExist: 'El archivo o carpeta ya existe'
ErrFileUpload: '{{ .name }} falló al subir archivo {{ .detail }}'
ErrFileDownloadDir: 'No se admite la descarga de carpetas'
ErrFileSharePath: 'Ruta no válida o el destino no es un archivo'
ErrFileShareInvalid: 'Enlace de uso compartido no válido o caducado'
ErrFileShareExpired: 'El enlace de uso compartido ha caducado'
ErrFileSharePassword: 'Contraseña de uso compartido incorrecta'
ErrFileShareExhausted: 'Se alcanzó el límite de descargas de este recurso compartido'
ErrFileSharePasswordPolicy: 'La contraseña debe tener al menos 4 caracteres'
ErrFileShareRateLimit: 'Demasiadas solicitudes, inténtelo de nuevo más tarde'
ErrCmdNotFound: 'El comando {{ .name }} no existe, instálelo primero en el host'
ErrSourcePathNotFound: 'El directorio fuente no existe'
ErrFavoriteExist: 'Esta ruta ya ha sido marcada como favorita'
@@ -169,7 +187,7 @@ ErrSSLCertificateNotFound: 'El archivo de certificado no existe'
ErrSSLKeyFormat: 'Error en la verificación del archivo de clave privada'
ErrSSLCertificateFormat: 'Formato de certificado incorrecto, use formato pem'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid o EabHmacKey no pueden estar en blanco'
ErrOpenrestyNotFound: 'El modo Http requiere tener Openresty instalado primero'
ErrOpenrestyNotFound: 'El modo HTTP requiere OpenResty'
ApplySSLStart: 'Iniciando solicitud de certificado, dominio [{{ .domain }}] método de solicitud [{{ .type }}]'
dnsAccount: 'DNS Automático'
dnsManual: 'DNS Manual'
@@ -343,9 +361,9 @@ sqlInject: 'Inyección SQL'
ErrDBNotExist: 'La base de datos no existe'
allow: 'permitir'
deny: 'denegar'
OpenrestyNotFound: 'Openresty no está instalado'
OpenrestyNotFound: 'OpenResty no está instalado'
remoteIpIsNull: 'La lista de IP está vacía'
OpenrestyVersionErr: 'La versión de Openresty es demasiado baja, actualice a 1.27.1.2-2-2-focal'
OpenrestyVersionErr: 'La versión de OpenResty es demasiado antigua. Actualízalo a 1.27.1.2-2-2-focal'
ErrFileTooLarge: 'El archivo supera 1 MB y no se puede cargar'
TaskStart: 'La tarea {{ .name }} inicia [INICIO]'
TaskEnd: 'La tarea {{ .name }} completada [COMPLETADA]'
@@ -369,7 +387,7 @@ TaskRollback: 'Revertir'
TaskPull: 'Descargar'
TaskCommit: 'Commit'
TaskBuild: 'Construir'
TaskPush: 'Subir'
TaskPush: 'Enviar'
TaskClean: 'Limpieza'
TaskHandle: 'Ejecutar'
TaskImport: 'Importar'
@@ -379,7 +397,7 @@ App: 'Aplicación'
Runtime: 'Entorno de ejecución'
Database: 'Base de datos'
ConfigFTP: 'Crear usuario FTP {{ .name }}'
ConfigOpenresty: 'Crear archivo de configuración Openresty'
ConfigOpenresty: 'Crear archivo de configuración de OpenResty'
InstallAppSuccess: 'Aplicación {{ .name }} instalada correctamente'
ConfigRuntime: 'Configurar entorno de ejecución'
ConfigApp: 'Configurar aplicación'
@@ -515,3 +533,4 @@ TerminalAIBlockedRiskyCommand: 'Comando de riesgo bloqueado: {{ .command }}'
TerminalAIThinking: 'La IA está pensando...'
TerminalAIReadyToExecute: 'Pensamiento completado, pulsa Enter para ejecutar (duración: {{ .duration }}, token: {{ .tokens }})'
TerminalAIRequestFailed: 'La solicitud de AI ha fallado: {{ .err }}'
FileAISearchEmptyDir: 'No hay archivos ni carpetas bajo esta ruta (o todos fueron filtrados).'
+25 -6
View File
@@ -2,6 +2,8 @@ ErrInvalidParams: '不正なリクエスト: {{ .detail }}'
ErrTokenParse: 'トークンエラー: {{ .detail }}'
ErrInitialPassword: '元のパスワードが違います'
ErrInternalServer: '内部エラー: {{ .detail }}'
ErrFileAINotEnabled: 'ファイル管理の「AI アカウント」でファイル AI 検索を有効にし、アカウントを選んでください。'
ErrFileAISearchBadPattern: '検索パターンが無効です(正規表現など): {{ .detail }}'
ErrWgetRemoteFailed: 'リモートのダウンロード URL が無効か、到達できません:{{ .detail }}'
ErrWgetInvalidContentType: 'リモート URL がファイルではなくエラーページ等を返しました、{{ .detail }}'
ErrRecordExist: 'レコードが既に存在します'
@@ -37,6 +39,7 @@ ErrGroupIsDefault: 'デフォルト グループ、削除できません'
ErrGroupIsInWebsiteUse: 'グループは別の Web サイトで使用されているため、削除できません。'
Decrypt: '復号化'
ErrAgentAccountBound: 'アカウントはエージェントに紐づいています'
ErrTerminalAIAccountInUse: 'このモデルアカウントは Terminal AI で使用中です。Terminal AI の設定で別のアカウントへ切り替えるか、Terminal AI を無効にしてから再試行してください。'
ErrAgentAccountUnavailable: 'アカウント接続不可: {{ .err }}'
ErrAgentProviderNotSupported: 'エージェントプロバイダ非対応'
ErrAgentAccountRequired: 'まずエージェントアカウントを選択'
@@ -44,13 +47,21 @@ ErrAgentAccountNotVerified: 'アカウント未検証です'
ErrAgentAccountModelsRequired: '少なくとも 1 つのモデルを設定してください'
ErrAgentAccountSingleInitialModel: 'アカウント作成時に設定できる初期モデルは 1 つだけです'
ErrAgentModelNotInAccount: '選択したモデルはこのアカウントに設定されていません'
ErrAgentFallbackModelDuplicate: 'フォールバックモデルは重複できません'
ErrAgentFallbackModelPrimary: 'フォールバックモデルは主モデルと同じにできません'
ErrAgentModelInUse: 'このモデルはまだエージェントで使用中です: {{ .name }}'
ErrAgentBaseURLRequired: 'ベースURLが必要です'
ErrAgentApiKeyRequired: 'APIキーが必要です'
ErrAgentComposeRequired: 'Composeが必要です'
ErrAgentIDRequired: 'エージェントIDが必要です'
ErrAgentAccountIDRequired: 'アカウントIDが必要です'
ErrAgentLimitReached: 'Community Edition では AI エージェントを最大 {{ .max }} 件まで作成できます。Professional Edition にアップグレードすると数の制限はありません'
ErrAgentLimitReached: 'Community Edition では AI エージェントを最大 {{ .max }} 件まで作成できます。Pro Edition にアップグレードすると無制限で利用できます'
ErrAgentWebsiteBound: 'このエージェントはすでにサイトに関連付けられています'
ErrAgentWebsiteTypeUnsupported: '関連付けできるのはプロキシサイトまたは静的サイトのみです'
ErrAgentWebsiteInUse: 'このサイトはすでに別のエージェントに関連付けられています'
ErrAgentWebsiteUnbindUnsupported: 'ワンクリックデプロイのサイトは手動で関連解除できません'
ErrHermesPairingCodeUnavailable: 'Hermes でペアリングコードが一時的に見つかりません。ネットワーク要因の可能性があるため、しばらくしてから再試行してください。'
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Feishu のグループポリシーが許可リストの場合、DM ポリシーをペアリングコードにはできません。'
Localhost: 'ローカルマシン'
ErrBackupInUsed: 'バックアップアカウントがスケジュールで使用中'
ErrBackupCheck: '接続テストに失敗しました: {{ .err }}'
@@ -122,6 +133,13 @@ ErrLinkPathNotFound: 'ターゲット パスが存在しません'
ErrFileIsExist: 'ファイルまたはフォルダーは既に存在します'
ErrFileUpload: '{{ .name }} はファイル {{ .detail }} のアップロードに失敗しました'
ErrFileDownloadDir: 'ダウンロード フォルダーはサポートされていません'
ErrFileSharePath: '無効なパスか、対象がファイルではありません'
ErrFileShareInvalid: '共有リンクが無効か期限切れです'
ErrFileShareExpired: '共有リンクの有効期限が切れました'
ErrFileSharePassword: '共有パスワードが正しくありません'
ErrFileShareExhausted: 'この共有のダウンロード上限に達しました'
ErrFileSharePasswordPolicy: 'パスワードは4文字以上にしてください'
ErrFileShareRateLimit: 'アクセスが多すぎます。しばらくしてから再試行してください'
ErrCmdNotFound: '{{ .name }} コマンドが存在しません。まずこのコマンドをホストにインストールしてください'
ErrSourcePathNotFound: 'ソース ディレクトリが存在しません'
ErrFavoriteExist: 'このパスはすでにお気に入りに登録されています'
@@ -169,7 +187,7 @@ ErrSSLCertificateNotFound: '証明書ファイルが存在しません'
ErrSSLKeyFormat: '秘密鍵ファイルの検証に失敗しました'
ErrSSLCertificateFormat: '証明書ファイルの形式が正しくありません。pem 形式を使用してください'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid または EabHmacKey は空白にできません'
ErrOpenrestyNotFound: 'Http モードでは、まず Openresty をインストールする必要があります'
ErrOpenrestyNotFound: 'HTTP モードでは OpenResty が必要です'
ApplySSLStart: '証明書の申請を開始します。ドメイン名 [{{ .domain }}] 申請方法 [{{ .type }}]'
dnsAccount: 'DNS 自動'
dnsManual: 'DNS マニュアル'
@@ -343,9 +361,9 @@ sqlInject: 'SQL インジェクション'
ErrDBNotExist: 'データベースが存在しません'
allow: '許可'
deny: '拒否'
OpenrestyNotFound: 'Openresty がインストールされていません'
OpenrestyNotFound: 'OpenResty がインストールされていません'
remoteIpIsNull: 'IPリストが空です'
OpenrestyVersionErr: 'Openrestyのバージョンが低すぎます。Openrestyを1.27.1.2-2-2-focalにアップグレードしてください'
OpenrestyVersionErr: 'OpenResty のバージョンが古すぎます。1.27.1.2-2-2-focal 以降にアップグレードしてください'
ErrFileTooLarge: 'ファイルが 1MB を超えているためアップロードできません'
TaskStart: '{{ .name }} タスクが開始されました [START]'
TaskEnd: '{{ .name }} タスクが完了しました [COMPLETED]'
@@ -369,7 +387,7 @@ TaskRollback: 'ロールバック'
TaskPull: 'プル'
TaskCommit: 'コミット'
TaskBuild: 'ビルド'
TaskPush: 'プッシュ'
TaskPush: '送信'
TaskClean: 'クリーンアップ'
TaskHandle: '実行'
TaskImport: 'インポート'
@@ -379,7 +397,7 @@ App: 'アプリケーション'
Runtime: 'ランタイム環境'
Database: 'データベース'
ConfigFTP: 'FTP ユーザー {{ .name }} を作成します'
ConfigOpenresty: 'Openresty 構成ファイルを作成する'
ConfigOpenresty: 'OpenResty の設定ファイルを作成'
InstallAppSuccess: 'アプリケーション {{ .name }} が正常にインストールされました'
ConfigRuntime: 'ランタイム環境を構成する'
ConfigApp: '構成アプリケーション'
@@ -515,3 +533,4 @@ TerminalAIBlockedRiskyCommand: '危険なコマンドをブロックしました
TerminalAIThinking: 'AI が考えています...'
TerminalAIReadyToExecute: '思考が完了しました。Enter で実行してください(所要時間 {{ .duration }}、token: {{ .tokens }})'
TerminalAIRequestFailed: 'AI リクエストに失敗しました: {{ .err }}'
FileAISearchEmptyDir: 'このパス以下に一覧できるファイルまたはフォルダーがありません(またはすべて除外されました)。'
+25 -6
View File
@@ -2,6 +2,8 @@ ErrInvalidParams: '잘못된 요청: {{ .detail }}'
ErrTokenParse: '토큰 오류: {{ .detail }}'
ErrInitialPassword: '원래 비밀번호가 틀립니다'
ErrInternalServer: '서버 오류: {{ .detail }}'
ErrFileAINotEnabled: '파일 관리에서 「AI 계정」으로 파일 AI 검색을 켜고 계정을 선택하세요.'
ErrFileAISearchBadPattern: '검색 패턴이 잘못되었습니다(정규식 등): {{ .detail }}'
ErrWgetRemoteFailed: '원격 다운로드 주소가 잘못되었거나 접근할 수 없습니다: {{ .detail }}'
ErrWgetInvalidContentType: '원격 주소가 파일이 아닌 콘텐츠를 반환했습니다 (오류 페이지일 수 있음), {{ .detail }}'
ErrRecordExist: '레코드가 이미 있습니다'
@@ -37,6 +39,7 @@ ErrGroupIsDefault: '기본 그룹, 삭제할 수 없습니다'
ErrGroupIsInWebsiteUse: '그룹이 다른 웹사이트에서 사용 중이므로 삭제할 수 없습니다.'
Decrypt: '복호화'
ErrAgentAccountBound: '계정이 에이전트에 묶여 있습니다'
ErrTerminalAIAccountInUse: '이 모델 계정은 현재 터미널 AI에서 사용 중입니다. 터미널 AI 설정에서 다른 계정으로 변경하거나 터미널 AI를 비활성화한 뒤 다시 시도하세요.'
ErrAgentAccountUnavailable: '계정 연결 불가: {{ .err }}'
ErrAgentProviderNotSupported: '지원되지 않는 에이전트 공급자'
ErrAgentAccountRequired: '먼저 에이전트 계정을 선택하세요'
@@ -44,13 +47,21 @@ ErrAgentAccountNotVerified: '에이전트 계정 미확인'
ErrAgentAccountModelsRequired: '최소 한 개의 모델을 구성해 주세요'
ErrAgentAccountSingleInitialModel: '계정 생성 시 초기 모델은 하나만 구성할 수 있습니다'
ErrAgentModelNotInAccount: '선택한 모델이 현재 계정에 구성되어 있지 않습니다'
ErrAgentFallbackModelDuplicate: '폴백 모델은 중복될 수 없습니다'
ErrAgentFallbackModelPrimary: '폴백 모델은 기본 모델과 같을 수 없습니다'
ErrAgentModelInUse: '이 모델을 아직 사용 중인 에이전트가 있습니다: {{ .name }}'
ErrAgentBaseURLRequired: '기본 URL 필요'
ErrAgentApiKeyRequired: 'API 키 필요'
ErrAgentComposeRequired: 'Compose 내용 필요'
ErrAgentIDRequired: '에이전트 ID 필요'
ErrAgentAccountIDRequired: '계정 ID 필요'
ErrAgentLimitReached: '커뮤니티 에디션에서는 AI 에이전트를 최대 {{ .max }}개까지 생성할 수 있으며, Professional 에디션으로 업그레이드하면 수량 제한이 없습니다'
ErrAgentLimitReached: '커뮤니티 에디션에서는 AI 에이전트를 최대 {{ .max }}개까지 만들 수 있습니다. Pro Edition으로 업그레이드하면 제한 없이 사용할 수 있습니다'
ErrAgentWebsiteBound: '이 에이전트는 이미 웹사이트에 연결되어 있습니다'
ErrAgentWebsiteTypeUnsupported: '프록시 또는 정적 웹사이트만 연결할 수 있습니다'
ErrAgentWebsiteInUse: '이 웹사이트는 이미 다른 에이전트에 연결되어 있습니다'
ErrAgentWebsiteUnbindUnsupported: '원클릭 배포 웹사이트는 수동으로 연결 해제할 수 없습니다'
ErrHermesPairingCodeUnavailable: 'Hermes에서 페어링 코드가 일시적으로 존재하지 않습니다. 네트워크 문제일 수 있으니 잠시 후 다시 시도해 주세요.'
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Feishu 그룹 정책이 허용 목록이면 DM 정책을 페어링 코드로 설정할 수 없습니다.'
Localhost: '로컬 머신'
ErrBackupInUsed: '백업 계정이 예약에 사용 중'
ErrBackupCheck: '연결 테스트 실패: {{ .err }}'
@@ -122,6 +133,13 @@ ErrLinkPathNotFound: '대상 경로가 존재하지 않습니다'
ErrFileIsExist: '파일이나 폴더가 이미 존재합니다'
ErrFileUpload: '{{ .name }}이 파일 {{ .detail }}을 업로드하지 못했습니다.'
ErrFileDownloadDir: '다운로드 폴더가 지원되지 않습니다'
ErrFileSharePath: '잘못된 파일 경로이거나 대상이 파일이 아닙니다'
ErrFileShareInvalid: '유효하지 않거나 만료된 공유 링크입니다'
ErrFileShareExpired: '공유 링크가 만료되었습니다'
ErrFileSharePassword: '공유 비밀번호가 올바르지 않습니다'
ErrFileShareExhausted: '이 공유의 다운로드 한도에 도달했습니다'
ErrFileSharePasswordPolicy: '비밀번호는 4자 이상이어야 합니다'
ErrFileShareRateLimit: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요'
ErrCmdNotFound: '{{ .name }} 명령이 존재하지 않습니다. 먼저 호스트에 이 명령을 설치하세요'
ErrSourcePathNotFound: '소스 디렉토리가 존재하지 않습니다'
ErrFavoriteExist: '이 경로는 이미 즐겨찾기되었습니다'
@@ -169,7 +187,7 @@ ErrSSLCertificateNotFound: '인증서 파일이 존재하지 않습니다'
ErrSSLKeyFormat: '개인 키 파일 검증에 실패했습니다'
ErrSSLCertificateFormat: '인증서 파일 형식이 올바르지 않습니다. pem 형식을 사용하세요.'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid 또는 EabHmacKey는 비워둘 수 없습니다'
ErrOpenrestyNotFound: 'Http 모드를 사용하려면 먼저 Openresty를 설치해야 합니다.'
ErrOpenrestyNotFound: 'HTTP 모드에서는 OpenResty가 필요합니다'
ApplySSLStart: '인증서 신청 시작, 도메인 이름 [{{ .domain }}] 신청 방법 [{{ .type }}]'
dnsAccount: 'DNS 자동'
dnsManual: 'DNS 매뉴얼'
@@ -343,9 +361,9 @@ sqlInject: 'SQL 주입'
ErrDBNotExist: '데이터베이스가 존재하지 않습니다'
allow: '허용하다'
deny: '거부하다'
OpenrestyNotFound: 'Openresty가 설치되지 않았습니다'
OpenrestyNotFound: 'OpenResty가 설치되어 있지 않습니다'
remoteIpIsNull: 'IP 목록이 비어 있습니다'
OpenrestyVersionErr: 'Openresty 버전이 너무 낮습니다. Openresty를 1.27.1.2-2-2-focal로 업그레이드하세요'
OpenrestyVersionErr: 'OpenResty 버전이 너무 낮습니다. 1.27.1.2-2-2-focal 이상으로 업그레이드하세요'
ErrFileTooLarge: '파일이 1MB를 초과하여 업로드할 수 없습니다'
TaskStart: '[START] {{ .name }} 작업이 시작됩니다.'
TaskEnd: '{{ .name }} 작업이 완료되었습니다 [완료]'
@@ -369,7 +387,7 @@ TaskRollback: '롤백'
TaskPull: '당기기'
TaskCommit: '커밋'
TaskBuild: '빌드'
TaskPush: '푸시'
TaskPush: '전송'
TaskClean: '정리'
TaskHandle: '실행'
TaskImport: '가져오기'
@@ -379,7 +397,7 @@ App: '애플리케이션'
Runtime: '런타임 환경'
Database: '데이터베이스'
ConfigFTP: 'FTP 사용자 {{ .name }} 생성'
ConfigOpenresty: 'Openresty 구성 파일 생성'
ConfigOpenresty: 'OpenResty 구성 파일 생성'
InstallAppSuccess: '애플리케이션 {{ .name }}이 성공적으로 설치되었습니다.'
ConfigRuntime: '런타임 환경 구성'
ConfigApp: '구성 애플리케이션'
@@ -515,3 +533,4 @@ TerminalAIBlockedRiskyCommand: '위험한 명령이 차단되었습니다: {{ .c
TerminalAIThinking: 'AI가 생각 중입니다...'
TerminalAIReadyToExecute: '생각이 완료되었습니다. Enter를 눌러 실행하세요 (소요 시간 {{ .duration }}, token: {{ .tokens }})'
TerminalAIRequestFailed: 'AI 요청 실패: {{ .err }}'
FileAISearchEmptyDir: '이 경로에 나열할 파일이나 디렉터리가 없습니다(또는 모두 필터링됨).'
+29 -10
View File
@@ -2,6 +2,8 @@ ErrInvalidParams: 'Permintaan tidak sah: {{ .detail }}'
ErrTokenParse: 'Token tidak sah: {{ .detail }}'
ErrInitialPassword: 'Kata laluan asal salah'
ErrInternalServer: 'Ralat pelayan: {{ .detail }}'
ErrFileAINotEnabled: 'Carian AI fail dimatikan atau tiada akaun. Dalam Pengurusan Fail, gunakan Akaun AI, aktifkan dan pilih akaun.'
ErrFileAISearchBadPattern: 'Corak carian tidak sah (regex dsb.): {{ .detail }}'
ErrWgetRemoteFailed: 'Muat turun jauh gagal atau URL tidak boleh diakses: {{ .detail }}'
ErrWgetInvalidContentType: 'URL jauh tidak mengembalikan fail (mungkin halaman ralat), {{ .detail }}'
ErrRecordExist: 'Rekod sudah wujud'
@@ -37,6 +39,7 @@ ErrGroupIsDefault: 'Kumpulan lalai, tidak boleh dipadamkan'
ErrGroupIsInWebsiteUse: 'Kumpulan sedang digunakan oleh tapak web lain dan tidak boleh dipadamkan.'
Decrypt: 'Dekripsi'
ErrAgentAccountBound: 'Akaun terikat kepada ejen'
ErrTerminalAIAccountInUse: 'Akaun model ini sedang digunakan oleh Terminal AI. Tukar akaun dalam tetapan Terminal AI atau nyahaktifkan Terminal AI, kemudian cuba lagi.'
ErrAgentAccountUnavailable: 'Sambungan akaun tidak tersedia: {{ .err }}'
ErrAgentProviderNotSupported: 'Penyedia ejen tidak disokong'
ErrAgentAccountRequired: 'Pilih akaun ejen terlebih dahulu'
@@ -44,13 +47,21 @@ ErrAgentAccountNotVerified: 'Akaun ejen belum disahkan'
ErrAgentAccountModelsRequired: 'Sila konfigur sekurang-kurangnya satu model'
ErrAgentAccountSingleInitialModel: 'Hanya satu model awal boleh dikonfigurasi semasa mencipta akaun'
ErrAgentModelNotInAccount: 'Model tidak dikonfigurasikan dalam akaun yang dipilih'
ErrAgentFallbackModelDuplicate: 'Model fallback tidak boleh berulang'
ErrAgentFallbackModelPrimary: 'Model fallback tidak boleh sama dengan model utama'
ErrAgentModelInUse: 'Masih ada ejen terikat yang menggunakan model ini: {{ .name }}'
ErrAgentBaseURLRequired: 'URL asas diperlukan'
ErrAgentApiKeyRequired: 'Kunci API diperlukan'
ErrAgentComposeRequired: 'Kandungan compose diperlukan'
ErrAgentIDRequired: 'ID ejen diperlukan'
ErrAgentAccountIDRequired: 'ID akaun diperlukan'
ErrAgentLimitReached: 'Edisi Community menyokong sehingga {{ .max }} ejen AI. Naik taraf ke edisi Professional untuk tiada had bilangan'
ErrAgentLimitReached: 'Edisi Community menyokong sehingga {{ .max }} ejen AI. Naik taraf ke Edisi Pro untuk penggunaan tanpa had'
ErrAgentWebsiteBound: 'Ejen ini sudah dipautkan ke laman web'
ErrAgentWebsiteTypeUnsupported: 'Hanya laman web proxy atau statik boleh dipautkan'
ErrAgentWebsiteInUse: 'Laman web ini sudah dipautkan ke ejen lain'
ErrAgentWebsiteUnbindUnsupported: 'Laman web one-click deployment tidak menyokong nyahikat manual'
ErrHermesPairingCodeUnavailable: 'Kod pasangan buat sementara waktu tidak wujud dalam Hermes, mungkin disebabkan masalah rangkaian. Sila cuba lagi sebentar nanti.'
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Apabila dasar kumpulan Feishu ialah senarai benarkan, dasar DM tidak boleh menggunakan kod pasangan.'
Localhost: 'Mesin Tempatan'
ErrBackupInUsed: 'Akaun sandaran sedang digunakan oleh tugas'
ErrBackupCheck: 'Ujian sambungan gagal: {{ .err }}'
@@ -122,6 +133,13 @@ ErrLinkPathNotFound: 'Laluan sasaran tidak wujud'
ErrFileIsExist: 'Fail atau folder sudah wujud'
ErrFileUpload: '{{ .name }} gagal memuat naik fail {{ .detail }}'
ErrFileDownloadDir: 'Folder muat turun tidak disokong'
ErrFileSharePath: 'Laluan fail tidak sah atau sasaran bukan fail'
ErrFileShareInvalid: 'Pautan kongsi tidak sah atau telah tamat tempoh'
ErrFileShareExpired: 'Pautan kongsi telah tamat tempoh'
ErrFileSharePassword: 'Kata laluan kongsi tidak betul'
ErrFileShareExhausted: 'Had muat turun untuk kongsi ini telah dicapai'
ErrFileSharePasswordPolicy: 'Kata laluan mestilah sekurang-kurangnya 4 aksara'
ErrFileShareRateLimit: 'Terlalu banyak permintaan, sila cuba lagi kemudian'
ErrCmdNotFound: 'Arahan {{ .name }} tidak wujud, sila pasang arahan ini pada hos dahulu'
ErrSourcePathNotFound: 'Direktori sumber tidak wujud'
ErrFavoriteExist: 'Laluan ini telah digemari'
@@ -169,7 +187,7 @@ ErrSSLCertificateNotFound: 'Fail sijil tidak wujud'
ErrSSLKeyFormat: 'Pengesahan fail kunci peribadi gagal'
ErrSSLCertificateFormat: 'Format fail sijil tidak betul, sila gunakan format pem'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid atau EabHmacKey tidak boleh kosong'
ErrOpenrestyNotFound: 'Mod Http memerlukan Openresty dipasang dahulu'
ErrOpenrestyNotFound: 'Mod HTTP memerlukan OpenResty dipasang terlebih dahulu'
ApplySSLStart: 'Mula memohon sijil, nama domain [{{ .domain }}] kaedah permohonan [{{ .type }}]'
dnsAccount: 'DNS Auto'
dnsManual: 'Manual DNS'
@@ -254,8 +272,8 @@ ErrScriptsNotFound: 'Item konfigurasi skrip tidak ditemui dalam package.json'
ErrContainerNameNotFound: 'Tidak dapat mendapatkan nama kontena, sila semak fail.env'
ErrNodeModulesNotFound: 'Folder node_modules tidak wujud Sila edit persekitaran masa jalan atau tunggu persekitaran masa jalan berjaya dimulakan'
ErrContainerNameIsNull: 'Nama kontena tidak wujud'
ErrPHPPortIsDefault: 'Порт 9000 является портом по умолчанию, пожалуйста, измените и попробуйте снова'
ErrPHPRuntimePortFailed: 'Порт {{ .name }} уже используется текущей средой выполнения, пожалуйста, измените и попробуйте снова'
ErrPHPPortIsDefault: 'Port 9000 ialah port lalai. Sila tukar dan cuba lagi'
ErrPHPRuntimePortFailed: 'Port {{ .name }} sudah digunakan oleh runtime semasa. Sila tukar dan cuba lagi'
ErrConfigNotFound: 'Fail konfigurasi tidak wujud'
ErrConfigParse: 'Format fail konfigurasi tidak betul'
ErrConfigIsNull: 'Fail konfigurasi tidak boleh kosong'
@@ -343,10 +361,10 @@ sqlInject: 'Suntikan SQL'
ErrDBNotExist: 'Pangkalan data tidak wujud'
allow: 'membenarkan'
deny: 'menafikan'
OpenrestyNotFound: 'Openresty tidak dipasang'
OpenrestyNotFound: 'OpenResty tidak dipasang'
remoteIpIsNull: 'Senarai IP kosong'
OpenrestyVersionErr: 'Versi Openresty terlalu rendah, sila naik taraf Openresty ke 1.27.1.2-2-2-focal'
ErrFileTooLarge: 'File is larger than 1 MB and cannot be uploaded'
OpenrestyVersionErr: 'Versi OpenResty terlalu lama. Naik taraf ke 1.27.1.2-2-2-focal'
ErrFileTooLarge: 'Fail melebihi 1 MB dan tidak boleh dimuat naik'
TaskStart: '{{ .name }} Task mula [MULA]'
TaskEnd: '{{ .name }} Task selesai [SELESAI]'
TaskFailed: 'tugas {{ .name }} gagal'
@@ -369,7 +387,7 @@ TaskRollback: 'Rollback'
TaskPull: 'Tarik'
TaskCommit: 'Komit'
TaskBuild: 'Bina'
TaskPush: 'Tolak'
TaskPush: 'Hantar'
TaskClean: 'Pembersihan'
TaskHandle: 'Laksanakan'
TaskImport: 'Import'
@@ -379,7 +397,7 @@ App: 'Aplikasi'
Runtime: 'Persekitaran masa jalan'
Database: 'Pangkalan Data'
ConfigFTP: 'Buat pengguna FTP {{ .name }}'
ConfigOpenresty: 'Buat fail konfigurasi Openresty'
ConfigOpenresty: 'Buat fail konfigurasi OpenResty'
InstallAppSuccess: 'Aplikasi {{ .name }} berjaya dipasang'
ConfigRuntime: 'Konfigurasikan persekitaran masa jalan'
ConfigApp: 'Aplikasi Konfigurasi'
@@ -436,7 +454,7 @@ SnapCompressSize: 'Saiz fail syot kilat {{ .name }}'
SnapUpload: 'Muat naik fail syot kilat'
SnapUploadTo: 'Muat naik fail syot kilat ke {{ .name }}'
SnapUploadRes: 'Muat naik fail syot kilat ke {{ .name }}'
SnapshotRecover: 'Snapshot Restore'
SnapshotRecover: 'Pulihkan syot kilat'
RecoverDownload: 'Muat turun fail syot kilat'
Download: 'Muat turun'
RecoverDownloadAccount: 'Dapatkan syot kilat muat turun akaun sandaran {{ .name }}'
@@ -515,3 +533,4 @@ TerminalAIBlockedRiskyCommand: 'Perintah berisiko telah disekat: {{ .command }}'
TerminalAIThinking: 'AI sedang berfikir...'
TerminalAIReadyToExecute: 'Pemikiran selesai, tekan Enter untuk jalankan (tempoh: {{ .duration }}, token: {{ .tokens }})'
TerminalAIRequestFailed: 'Permintaan AI gagal: {{ .err }}'
FileAISearchEmptyDir: 'Tiada fail atau direktori di bawah laluan ini (atau semua ditapis).'
+28 -9
View File
@@ -2,6 +2,8 @@ ErrInvalidParams: 'Requisição inválida: {{ .detail }}'
ErrTokenParse: 'Erro de token: {{ .detail }}'
ErrInitialPassword: 'Senha original incorreta'
ErrInternalServer: 'Erro interno: {{ .detail }}'
ErrFileAINotEnabled: 'A pesquisa com IA de arquivos está desativada ou sem conta. Em Gerenciamento de arquivos, use Conta de IA, ative e escolha uma conta.'
ErrFileAISearchBadPattern: 'Padrão de busca inválido (regex ou opção): {{ .detail }}'
ErrWgetRemoteFailed: 'Falha no download remoto ou URL inacessível: {{ .detail }}'
ErrWgetInvalidContentType: 'A URL remota não retornou um arquivo (possivelmente uma página de erro), {{ .detail }}'
ErrRecordExist: 'Registro já existe'
@@ -37,6 +39,7 @@ ErrGroupIsDefault: 'Grupo padrão, não pode ser excluído'
ErrGroupIsInWebsiteUse: 'O grupo está sendo usado por outro site e não pode ser excluído.'
Decrypt: 'Descriptografar'
ErrAgentAccountBound: 'Conta vinculada a agente'
ErrTerminalAIAccountInUse: 'Esta conta de modelo esta sendo usada pelo Terminal AI. Troque a conta nas configuracoes do Terminal AI ou desative o Terminal AI e tente novamente.'
ErrAgentAccountUnavailable: 'Conexão da conta indisponível: {{ .err }}'
ErrAgentProviderNotSupported: 'Provedor de agente não suportado'
ErrAgentAccountRequired: 'Selecione uma conta de agente primeiro'
@@ -44,13 +47,21 @@ ErrAgentAccountNotVerified: 'Conta de agente não verificada'
ErrAgentAccountModelsRequired: 'Configure pelo menos um modelo'
ErrAgentAccountSingleInitialModel: 'Apenas um modelo inicial pode ser configurado ao criar a conta'
ErrAgentModelNotInAccount: 'O modelo não está configurado na conta selecionada'
ErrAgentFallbackModelDuplicate: 'Os modelos de fallback não podem ser duplicados'
ErrAgentFallbackModelPrimary: 'O modelo de fallback não pode ser igual ao modelo principal'
ErrAgentModelInUse: 'Ainda há um agente vinculado usando este modelo: {{ .name }}'
ErrAgentBaseURLRequired: 'URL base obrigatória'
ErrAgentApiKeyRequired: 'Chave API obrigatória'
ErrAgentComposeRequired: 'Conteúdo de compose obrigatório'
ErrAgentIDRequired: 'ID do agente obrigatório'
ErrAgentAccountIDRequired: 'ID da conta obrigatório'
ErrAgentLimitReached: 'A edição Community suporta até {{ .max }} agentes de IA. Atualize para a edição Professional para remover o limite'
ErrAgentLimitReached: 'A edição Community suporta até {{ .max }} agentes de IA. Faça upgrade para a edição Pro para usar agentes ilimitados'
ErrAgentWebsiteBound: 'Este agente já está vinculado a um site'
ErrAgentWebsiteTypeUnsupported: 'Somente sites proxy ou estáticos podem ser vinculados'
ErrAgentWebsiteInUse: 'Este site já está vinculado a outro agente'
ErrAgentWebsiteUnbindUnsupported: 'Sites implantados em um clique não podem ser desvinculados manualmente'
ErrHermesPairingCodeUnavailable: 'O código de pareamento está temporariamente indisponível no Hermes, possivelmente por causa de rede. Tente novamente mais tarde.'
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Quando a política de grupo do Feishu é Lista de permissões, a política de DM não pode ser Código de pareamento.'
Localhost: 'Máquina Local'
ErrBackupInUsed: 'Conta de backup em uso por tarefa'
ErrBackupCheck: 'Teste de conexão falhou: {{ .err }}'
@@ -122,6 +133,13 @@ ErrLinkPathNotFound: 'O caminho de destino não existe'
ErrFileIsExist: 'O arquivo ou pasta já existe'
ErrFileUpload: '{{ .name }} falhou ao carregar o arquivo {{ .detail }}'
ErrFileDownloadDir: 'A pasta de download não é suportada'
ErrFileSharePath: 'Caminho inválido ou o destino não é um arquivo'
ErrFileShareInvalid: 'Link de compartilhamento inválido ou expirado'
ErrFileShareExpired: 'O link de compartilhamento expirou'
ErrFileSharePassword: 'Senha de compartilhamento incorreta'
ErrFileShareExhausted: 'O limite de downloads deste compartilhamento foi atingido'
ErrFileSharePasswordPolicy: 'A senha deve ter pelo menos 4 caracteres'
ErrFileShareRateLimit: 'Muitas solicitações, tente novamente mais tarde'
ErrCmdNotFound: 'O comando {{ .name }} não existe, instale este comando no host primeiro'
ErrSourcePathNotFound: 'O diretório de origem não existe'
ErrFavoriteExist: 'Este caminho já foi favorito'
@@ -162,14 +180,14 @@ SSL: 'Certificado'
Set: 'Configurações'
ErrSSLCannotDelete: 'O certificado {{ .name }} está sendo usado por um site e não pode ser excluído'
ErrAccountCannotDelete: 'A conta está associada a um certificado e não pode ser excluída'
ErrSSLApply: 'Renovação do certificado bem-sucedida, falha na recarga do Openresty, verifique a configuração'
ErrSSLApply: 'Renovação do certificado concluída, mas a recarga do OpenResty falhou. Verifique a configuração'
ErrEmailIsExist: 'A caixa de correio já existe'
ErrSSLKeyNotFound: 'O arquivo de chave privada não existe'
ErrSSLCertificateNotFound: 'O arquivo de certificado não existe'
ErrSSLKeyFormat: 'Falha na verificação do arquivo de chave privada'
ErrSSLCertificateFormat: 'O formato do arquivo de certificado está incorreto, use o formato pem'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid ou EabHmacKey não podem estar em branco'
ErrOpenrestyNotFound: 'O modo HTTP requer que o Openresty seja instalado primeiro'
ErrOpenrestyNotFound: 'O modo HTTP requer o OpenResty instalado'
ApplySSLStart: 'Comece a solicitar um certificado, nome de domínio [{{ .domain }}] método de solicitação [{{ .type }}]'
dnsAccount: 'DNS Automático'
dnsManual: 'Manual DNS'
@@ -254,8 +272,8 @@ ErrScriptsNotFound: 'O item de configuração de scripts não foi encontrado em
ErrContainerNameNotFound: 'Não foi possível obter o nome do contêiner, verifique o arquivo.env'
ErrNodeModulesNotFound: 'A pasta node_modules não existe Edite o ambiente de execução ou aguarde até que o ambiente de execução inicie com sucesso'
ErrContainerNameIsNull: 'O nome do contêiner não existe'
ErrPHPPortIsDefault: 'Port 9000 adalah port lalai, sila ubah dan cuba lagi'
ErrPHPRuntimePortFailed: 'Port {{ .name }} telah digunakan oleh persekitaran runtime semasa, sila ubah dan cuba lagi'
ErrPHPPortIsDefault: 'A porta 9000 é a porta padrão. Altere-a e tente novamente'
ErrPHPRuntimePortFailed: 'A porta {{ .name }} já está em uso neste runtime. Altere-a e tente novamente'
ErrConfigNotFound: 'O arquivo de configuração não existe'
ErrConfigParse: 'O formato do arquivo de configuração está incorreto'
ErrConfigIsNull: 'O arquivo de configuração não pode estar vazio'
@@ -343,9 +361,9 @@ sqlInject: 'injeção de SQL'
ErrDBNotExist: 'O banco de dados não existe'
allow: 'permitir'
deny: 'negar'
OpenrestyNotFound: 'Openresty não está instalado'
OpenrestyNotFound: 'OpenResty não está instalado'
remoteIpIsNull: 'A lista de IP está vazia'
OpenrestyVersionErr: 'A versão do Openresty é muito baixa atualize o Openresty para 1.27.1.2-2-2-focal'
OpenrestyVersionErr: 'A versão do OpenResty é muito antiga. Atualize para 1.27.1.2-2-2-focal'
ErrFileTooLarge: 'O arquivo excede 1 MB e não pode ser enviado'
TaskStart: '{{ .name }} A tarefa inicia [START]'
TaskEnd: '{{ .name }} Tarefa concluída [CONCLUÍDA]'
@@ -369,7 +387,7 @@ TaskRollback: 'Reverter'
TaskPull: 'Puxar'
TaskCommit: 'Commit'
TaskBuild: 'Construir'
TaskPush: 'Empurrar'
TaskPush: 'Enviar'
TaskClean: 'Limpeza'
TaskHandle: 'Executar'
TaskImport: 'Importar'
@@ -379,7 +397,7 @@ App: 'Aplicativo'
Runtime: 'Ambiente de tempo de execução'
Database: 'Banco de dados'
ConfigFTP: 'Criar usuário FTP {{ .name }}'
ConfigOpenresty: 'Criar arquivo de configuração Openresty'
ConfigOpenresty: 'Criar arquivo de configuração do OpenResty'
InstallAppSuccess: 'Aplicativo {{ .name }} instalado com sucesso'
ConfigRuntime: 'Configurar o ambiente de tempo de execução'
ConfigApp: 'Aplicativo de configuração'
@@ -515,3 +533,4 @@ TerminalAIBlockedRiskyCommand: 'Comando de risco bloqueado: {{ .command }}'
TerminalAIThinking: 'A IA está pensando...'
TerminalAIReadyToExecute: 'Pensamento concluído, pressione Enter para executar (duração: {{ .duration }}, token: {{ .tokens }})'
TerminalAIRequestFailed: 'Falha na solicitação de AI: {{ .err }}'
FileAISearchEmptyDir: 'Nenhum arquivo ou pasta encontrado neste caminho (ou todos foram filtrados).'
+28 -9
View File
@@ -2,6 +2,8 @@ ErrInvalidParams: 'Неверный запрос: {{ .detail }}'
ErrTokenParse: 'Ошибка токена: {{ .detail }}'
ErrInitialPassword: 'Исходный пароль неверен'
ErrInternalServer: 'Внутренняя ошибка: {{ .detail }}'
ErrFileAINotEnabled: 'Поиск с AI в файлах выключен или не выбран аккаунт. В управлении файлами откройте «AI-аккаунт», включите и выберите аккаунт.'
ErrFileAISearchBadPattern: 'Недопустимый шаблон поиска (regex и т.п.): {{ .detail }}'
ErrWgetRemoteFailed: 'Неверный или недоступный URL удалённой загрузки: {{ .detail }}'
ErrWgetInvalidContentType: 'Удалённый сервер вернул не файл (возможно, страницу ошибки), {{ .detail }}'
ErrRecordExist: 'Запись уже существует'
@@ -37,6 +39,7 @@ ErrGroupIsDefault: 'Группа по умолчанию, не может быт
ErrGroupIsInWebsiteUse: 'Группа используется другим веб-сайтом и не может быть удалена.'
Decrypt: 'Расшифровать'
ErrAgentAccountBound: 'Акаунт привязан к агенту'
ErrTerminalAIAccountInUse: 'Эта учетная запись модели сейчас используется Terminal AI. Смените учетную запись в настройках Terminal AI или отключите Terminal AI и повторите попытку.'
ErrAgentAccountUnavailable: 'Связь с аккаунтом недоступна: {{ .err }}'
ErrAgentProviderNotSupported: 'Провайдер агента не поддерживается'
ErrAgentAccountRequired: 'Выберите аккаунт агента'
@@ -44,13 +47,21 @@ ErrAgentAccountNotVerified: 'Аккаунт не подтверждён'
ErrAgentAccountModelsRequired: 'Настройте хотя бы одну модель'
ErrAgentAccountSingleInitialModel: 'При создании аккаунта можно указать только одну начальную модель'
ErrAgentModelNotInAccount: 'Модель не настроена в выбранном аккаунте'
ErrAgentFallbackModelDuplicate: 'Резервные модели не могут дублироваться'
ErrAgentFallbackModelPrimary: 'Резервная модель не может совпадать с основной'
ErrAgentModelInUse: 'Эта модель всё ещё используется агентом: {{ .name }}'
ErrAgentBaseURLRequired: 'Нужен базовый URL'
ErrAgentApiKeyRequired: 'Нужен API-ключ'
ErrAgentComposeRequired: 'Нужен compose'
ErrAgentIDRequired: 'Нужен ID агента'
ErrAgentAccountIDRequired: 'Нужен ID аккаунта'
ErrAgentLimitReached: 'Community Edition поддерживает до {{ .max }} AI-агентов. Перейдите на Professional Edition, чтобы снять ограничение'
ErrAgentLimitReached: 'Community Edition поддерживает до {{ .max }} AI-агентов. Обновитесь до Pro Edition, чтобы использовать их без ограничений'
ErrAgentWebsiteBound: 'Этот агент уже связан с сайтом'
ErrAgentWebsiteTypeUnsupported: 'Можно связывать только proxy- или static-сайты'
ErrAgentWebsiteInUse: 'Этот сайт уже связан с другим агентом'
ErrAgentWebsiteUnbindUnsupported: 'Сайты one-click deployment нельзя отвязать вручную'
ErrHermesPairingCodeUnavailable: 'Код сопряжения временно недоступен в Hermes, возможно из-за проблем с сетью. Повторите попытку позже.'
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Когда групповая политика Feishu — белый список, политика личных сообщений не может быть кодом сопряжения.'
Localhost: 'Локальная машина'
ErrBackupInUsed: 'Аккаунт бэкапа занят задачей'
ErrBackupCheck: 'Проверка подключения не удалась: {{ .err }}'
@@ -122,6 +133,13 @@ ErrLinkPathNotFound: 'Целевой путь не существует'
ErrFileIsExist: 'Файл или папка уже существует'
ErrFileUpload: '{{ .name }} не удалось загрузить файл {{ .detail }}'
ErrFileDownloadDir: 'Папка загрузки не поддерживается'
ErrFileSharePath: 'Неверный путь или цель не является файлом'
ErrFileShareInvalid: 'Недействительная или просроченная ссылка'
ErrFileShareExpired: 'Срок действия ссылки истёк'
ErrFileSharePassword: 'Неверный пароль доступа'
ErrFileShareExhausted: 'Лимит скачиваний по этой ссылке исчерпан'
ErrFileSharePasswordPolicy: 'Пароль должен содержать не менее 4 символов'
ErrFileShareRateLimit: 'Слишком много запросов, попробуйте позже'
ErrCmdNotFound: 'Команда {{ .name }} не существует, сначала установите эту команду на хосте'
ErrSourcePathNotFound: 'Исходный каталог не существует'
ErrFavoriteExist: 'Этот путь уже добавлен в избранное'
@@ -169,7 +187,7 @@ ErrSSLCertificateNotFound: 'Файл сертификата не существ
ErrSSLKeyFormat: 'Проверка файла закрытого ключа не удалась'
ErrSSLCertificateFormat: 'Неверный формат файла сертификата, используйте формат pem'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid или EabHmacKey не могут быть пустыми'
ErrOpenrestyNotFound: 'Для режима HTTP необходимо сначала установить Openresty'
ErrOpenrestyNotFound: 'Для режима HTTP требуется OpenResty'
ApplySSLStart: 'Начать подачу заявки на сертификат, доменное имя [{{ .domain }}], метод подачи заявки [{{ .type }}]'
dnsAccount: 'DNS Авто'
dnsManual: 'Руководство по DNS'
@@ -254,8 +272,8 @@ ErrScriptsNotFound: 'Элемент конфигурации скриптов н
ErrContainerNameNotFound: 'Не удалось получить имя контейнера, проверьте файл.env'
ErrNodeModulesNotFound: 'Папка node_modules не существует Измените среду выполнения или дождитесь ее успешного запуска'
ErrContainerNameIsNull: 'Имя контейнера не существует'
ErrPHPPortIsDefault: 'A porta 9000 é a porta padrão, por favor, modifique e tente novamente'
ErrPHPRuntimePortFailed: 'A porta {{ .name }} já está sendo usada pelo ambiente de tempo de execução atual, por favor, modifique e tente novamente'
ErrPHPPortIsDefault: 'Порт 9000 зарезервирован как стандартный. Укажите другой порт'
ErrPHPRuntimePortFailed: 'Порт {{ .name }} уже используется этой средой выполнения'
ErrConfigNotFound: 'Файл конфигурации не существует'
ErrConfigParse: 'Неверный формат файла конфигурации'
ErrConfigIsNull: 'Файл конфигурации не может быть пустым'
@@ -343,10 +361,10 @@ sqlInject: 'SQL-инъекция'
ErrDBNotExist: 'База данных не существует'
allow: 'разрешить'
deny: 'отрицать'
OpenrestyNotFound: 'Openresty не установлен'
OpenrestyNotFound: 'OpenResty не установлен'
remoteIpIsNull: 'Список IP пуст'
OpenrestyVersionErr: 'Версия Openresty слишком низкая, пожалуйста, обновите Openresty до 1.27.1.2-2-2-focal'
ErrFileTooLarge: 'File is larger than 1 MB and cannot be uploaded'
OpenrestyVersionErr: 'Версия OpenResty слишком старая. Обновите до 1.27.1.2-2-2-focal'
ErrFileTooLarge: 'Файл больше 1 МБ и не может быть загружен'
TaskStart: '{{ .name }} Задача начинается [START]'
TaskEnd: '{{ .name }} Задача выполнена [ЗАВЕРШЕНО]'
TaskFailed: 'Задача {{ .name }} не выполнена'
@@ -369,7 +387,7 @@ TaskRollback: 'Откат'
TaskPull: 'Вытянуть'
TaskCommit: 'Kоммит'
TaskBuild: 'Сборка'
TaskPush: 'Push'
TaskPush: 'Отправить'
TaskClean: 'Очистка'
TaskHandle: 'Выполнить'
TaskImport: 'Импорт'
@@ -379,7 +397,7 @@ App: 'Приложение'
Runtime: 'Среда выполнения'
Database: 'База данных'
ConfigFTP: 'Создать пользователя FTP {{ .name }}'
ConfigOpenresty: 'Создать файл конфигурации Openresty'
ConfigOpenresty: 'Создать файл конфигурации OpenResty'
InstallAppSuccess: 'Приложение {{ .name }} успешно установлено'
ConfigRuntime: 'Настройка среды выполнения'
ConfigApp: 'Приложение конфигурации'
@@ -515,3 +533,4 @@ TerminalAIBlockedRiskyCommand: 'Опасная команда заблокиро
TerminalAIThinking: 'AI думает...'
TerminalAIReadyToExecute: 'Обдумывание завершено, нажмите Enter для выполнения (время: {{ .duration }}, token: {{ .tokens }})'
TerminalAIRequestFailed: 'Ошибка запроса AI: {{ .err }}'
FileAISearchEmptyDir: 'В этом пути нет файлов или каталогов для отображения (или все отфильтрованы).'
+25 -6
View File
@@ -2,6 +2,8 @@ ErrInvalidParams: 'İstek geçersiz: {{ .detail }}'
ErrTokenParse: 'Token hatası: {{ .detail }}'
ErrInitialPassword: 'Orijinal şifre yanlış'
ErrInternalServer: 'Sunucu hatası: {{ .detail }}'
ErrFileAINotEnabled: 'Dosya yönetimi AI araması kapalı veya hesap yok. Dosya Yönetimi’nde AI hesabını açıp etkinleştirin ve bir hesap seçin.'
ErrFileAISearchBadPattern: 'Geçersiz arama deseni (regex vb.): {{ .detail }}'
ErrWgetRemoteFailed: 'Uzak indirme başarısız veya URL erişilemiyor: {{ .detail }}'
ErrWgetInvalidContentType: 'Uzak URL bir dosya değil, muhtemelen bir hata sayfası döndürdü, {{ .detail }}'
ErrRecordExist: 'Kayıt zaten var'
@@ -37,6 +39,7 @@ ErrGroupIsDefault: 'Varsayılan grup, silinemez'
ErrGroupIsInWebsiteUse: 'Grup başka bir web sitesi tarafından kullanılıyor ve silinemez.'
Decrypt: 'Şifre Çöz'
ErrAgentAccountBound: 'Hesap bir ajana bağlı'
ErrTerminalAIAccountInUse: 'Bu model hesabi su anda Terminal AI tarafindan kullaniliyor. Terminal AI ayarlarinda baska bir hesaba gecin veya Terminal AI yi devre disi birakip tekrar deneyin.'
ErrAgentAccountUnavailable: 'Hesap bağlantısı yok: {{ .err }}'
ErrAgentProviderNotSupported: 'Ajans sağlayıcısı desteklenmiyor'
ErrAgentAccountRequired: 'Önce bir ajans hesabı seçin'
@@ -44,13 +47,21 @@ ErrAgentAccountNotVerified: 'Ajans hesabı doğrulanmadı'
ErrAgentAccountModelsRequired: 'Lütfen en az bir model yapılandırın'
ErrAgentAccountSingleInitialModel: 'Hesap oluştururken yalnızca bir başlangıç modeli yapılandırılabilir'
ErrAgentModelNotInAccount: 'Model seçilen hesapta yapılandırılmadı'
ErrAgentFallbackModelDuplicate: 'Yedek modeller tekrar edemez'
ErrAgentFallbackModelPrimary: 'Yedek model birincil modelle aynı olamaz'
ErrAgentModelInUse: 'Bu modeli hâlâ kullanan bağlı bir ajan var: {{ .name }}'
ErrAgentBaseURLRequired: 'Temel URL gerekli'
ErrAgentApiKeyRequired: 'API anahtarı gerekli'
ErrAgentComposeRequired: 'Compose içeriği gerekli'
ErrAgentIDRequired: 'Ajans ID gerekli'
ErrAgentAccountIDRequired: 'Hesap ID gerekli'
ErrAgentLimitReached: 'Community sürümü en fazla {{ .max }} AI ajanını destekler. Sınırı kaldırmak için Professional sürüme yükseltin'
ErrAgentLimitReached: 'Community sürümü en fazla {{ .max }} AI ajanını destekler. Sınırsız kullanım için Pro Sürüme yükseltin'
ErrAgentWebsiteBound: 'Bu ajan zaten bir web sitesine bağlı'
ErrAgentWebsiteTypeUnsupported: 'Yalnızca proxy veya statik web siteleri bağlanabilir'
ErrAgentWebsiteInUse: 'Bu web sitesi zaten başka bir ajana bağlı'
ErrAgentWebsiteUnbindUnsupported: 'Tek tıkla dağıtılan web sitelerinin bağlantısı manuel olarak kaldırılamaz'
ErrHermesPairingCodeUnavailable: 'Eşleştirme kodu Hermes içinde geçici olarak bulunamıyor; bu durum ağ kaynaklı olabilir. Lütfen daha sonra tekrar deneyin.'
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Feishu grup ilkesi izin listesi olduğunda, DM ilkesi eşleştirme kodu olamaz.'
Localhost: 'Yerel Makine'
ErrBackupInUsed: 'Yedek hesabı görevde kullanılıyor'
ErrBackupCheck: 'Bağlantı testi başarısız: {{ .err }}'
@@ -122,6 +133,13 @@ ErrLinkPathNotFound: 'Hedef yol mevcut değil'
ErrFileIsExist: 'Dosya veya klasör zaten mevcut'
ErrFileUpload: '{{ .name }} dosya yükleme başarısız {{ .detail }}'
ErrFileDownloadDir: 'Klasör indirme desteklenmiyor'
ErrFileSharePath: 'Geçersiz dosya yolu veya hedef bir dosya değil'
ErrFileShareInvalid: 'Geçersiz veya süresi dolmuş paylaşım bağlantısı'
ErrFileShareExpired: 'Paylaşım bağlantısının süresi doldu'
ErrFileSharePassword: 'Yanlış paylaşım parolası'
ErrFileShareExhausted: 'Bu paylaşım için indirme sınırına ulaşıldı'
ErrFileSharePasswordPolicy: 'Parola en az 4 karakter olmalıdır'
ErrFileShareRateLimit: 'Çok fazla istek gönderildi, lütfen daha sonra tekrar deneyin'
ErrCmdNotFound: '{{ .name }} komutu mevcut değil, lütfen önce bu komutu host a yükleyin'
ErrSourcePathNotFound: 'Kaynak dizin mevcut değil'
ErrFavoriteExist: 'Bu yol zaten favorilere eklendi'
@@ -169,7 +187,7 @@ ErrSSLCertificateNotFound: 'Sertifika dosyası mevcut değil'
ErrSSLKeyFormat: 'Özel anahtar dosyası doğrulama başarısız'
ErrSSLCertificateFormat: 'Sertifika dosyası formatı yanlış, lütfen pem formatını kullanın'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid veya EabHmacKey boş olamaz'
ErrOpenrestyNotFound: 'Http modu önce Openresty yüklenmesini gerektiriyor'
ErrOpenrestyNotFound: 'HTTP modu için OpenResty gereklidir'
ApplySSLStart: 'Sertifika başvurusu başlatılıyor, alan adı [{{ .domain }}] başvuru yöntemi [{{ .type }}]'
dnsAccount: 'DNS Otomatik'
dnsManual: 'DNS Manuel'
@@ -343,10 +361,10 @@ sqlInject: 'SQL enjeksiyonu'
ErrDBNotExist: 'Veritabanı mevcut değil'
allow: 'izin ver'
deny: 'reddet'
OpenrestyNotFound: 'Openresty yüklü değil'
OpenrestyNotFound: 'OpenResty yüklü değil'
remoteIpIsNull: 'IP listesi boş'
OpenrestyVersionErr: 'Openresty sürümü çok düşük, lütfen Openresty''i 1.27.1.2-2-2-focal olarak güncelleyin'
ErrFileTooLarge: 'File is larger than 1 MB and cannot be uploaded'
OpenrestyVersionErr: 'OpenResty sürümü çok eski. 1.27.1.2-2-2-focal sürümüne yükseltin'
ErrFileTooLarge: 'Dosya 1 MB''den büyük olduğu için yüklenemiyor'
TaskStart: '{{ .name }} görevi başlıyor [BAŞLAT]'
TaskEnd: '{{ .name }} Görev tamamlandı [TAMAMLANDI]'
TaskFailed: '{{ .name }} görev başarısız'
@@ -379,7 +397,7 @@ App: 'Uygulama'
Runtime: 'Çalışma ortamı'
Database: 'Veritabanı'
ConfigFTP: 'FTP kullanıcısı {{ .name }} oluştur'
ConfigOpenresty: 'Openresty yapılandırma dosyası oluştur'
ConfigOpenresty: 'OpenResty yapılandırma dosyası oluştur'
InstallAppSuccess: 'Uygulama {{ .name }} başarıyla yüklendi'
ConfigRuntime: 'Çalışma ortamını yapılandır'
ConfigApp: 'Uygulama Yapılandırması'
@@ -515,3 +533,4 @@ TerminalAIBlockedRiskyCommand: 'Riskli komut engellendi: {{ .command }}'
TerminalAIThinking: 'AI dusunuyor...'
TerminalAIReadyToExecute: 'Dusunme tamamlandi, calistirmak icin Enter tusuna basin (sure: {{ .duration }}, token: {{ .tokens }})'
TerminalAIRequestFailed: 'AI istegi basarisiz oldu: {{ .err }}'
FileAISearchEmptyDir: 'Bu yol altında listelenecek dosya veya klasör yok (veya tümü filtrelendi).'
+22 -3
View File
@@ -2,6 +2,8 @@ ErrInvalidParams: '請求參數錯誤: {{ .detail }}'
ErrTokenParse: 'Token 產生錯誤: {{ .detail }}'
ErrInitialPassword: '原密碼錯誤'
ErrInternalServer: '服務內部錯誤: {{ .detail }}'
ErrFileAINotEnabled: '請先在檔案管理頁透過「AI 帳號」啟用檔案 AI 搜尋並選擇帳號。'
ErrFileAISearchBadPattern: '搜尋條件無效(正則或全詞): {{ .detail }}'
ErrWgetRemoteFailed: '遠端下載網址無效或無法存取:{{ .detail }}'
ErrWgetInvalidContentType: '遠端網址未回傳檔案內容(可能是錯誤頁),{{ .detail }}'
ErrRecordExist: '記錄已存在'
@@ -37,6 +39,7 @@ ErrGroupIsDefault: '預設分組,無法刪除'
ErrGroupIsInWebsiteUse: '分組正在被其他網站使用,無法刪除'
Decrypt: '解密'
ErrAgentAccountBound: '該帳號已綁定到智能體,無法刪除,請重試。'
ErrTerminalAIAccountInUse: '該模型帳號正被終端 AI 使用,請在終端 AI 設定中更換帳號或關閉終端 AI 後再試。'
ErrAgentAccountUnavailable: '帳號連線資訊不可用,錯誤:{{ .err }},請重試'
ErrAgentProviderNotSupported: '暫不支援該智能體提供商,請重試'
ErrAgentAccountRequired: '請選擇智能體帳號後重試'
@@ -44,13 +47,21 @@ ErrAgentAccountNotVerified: '帳號未驗證通過,請重試'
ErrAgentAccountModelsRequired: '請至少配置一個模型'
ErrAgentAccountSingleInitialModel: '建立帳號時只能配置一個初始模型'
ErrAgentModelNotInAccount: '所選模型未配置在當前帳號中,請重試'
ErrAgentFallbackModelDuplicate: '備用模型不能重複'
ErrAgentFallbackModelPrimary: '備用模型不能與主模型相同'
ErrAgentModelInUse: '仍有智能體正在使用該模型:{{ .name }}'
ErrAgentBaseURLRequired: 'Base URL 不可為空,請重試'
ErrAgentApiKeyRequired: 'API Key 不可為空,請重試'
ErrAgentComposeRequired: '自訂編排內容不可為空,請重試'
ErrAgentIDRequired: '智能體 ID 不可為空,請重試'
ErrAgentAccountIDRequired: '帳號 ID 不可為空,請重試'
ErrAgentLimitReached: '社群版最多支援建立 {{ .max }} 個智能體,升級至專業版則無數量限制'
ErrAgentLimitReached: '社群版最多支援建立 {{ .max }} 個 AI 智能體,升級至 Pro 版即可無限制使用'
ErrAgentWebsiteBound: '該智能體已關聯網站'
ErrAgentWebsiteTypeUnsupported: '只能關聯反向代理或靜態網站'
ErrAgentWebsiteInUse: '該網站已被其他智能體關聯'
ErrAgentWebsiteUnbindUnsupported: '一鍵部署網站不支援手動解綁'
ErrHermesPairingCodeUnavailable: '配對碼在 Hermes 中暫時不存在,可能是由於網路原因,請稍後再試'
ErrHermesFeishuGroupAllowlistRequiresAllowlist: '飛書群組策略為白名單時,私聊策略不能為配對碼'
Localhost: '本機'
ErrBackupInUsed: '此備份帳號已在排程任務中使用,無法刪除'
ErrBackupCheck: '備份帳號測試連線失敗{{ .err }}'
@@ -122,6 +133,13 @@ ErrLinkPathNotFound: '目標路徑不存在!'
ErrFileIsExist: '檔案或資料夾已存在!'
ErrFileUpload: '{{ .name }} 上傳檔案失敗{{ .detail }}'
ErrFileDownloadDir: '不支援下載資料夾'
ErrFileSharePath: '無效的分享路徑或目標不是檔案'
ErrFileShareInvalid: '分享連結無效或已失效'
ErrFileShareExpired: '分享連結已過期'
ErrFileSharePassword: '存取密碼錯誤'
ErrFileShareExhausted: '此分享連結的下載次數已用盡'
ErrFileSharePasswordPolicy: '存取密碼至少 4 位'
ErrFileShareRateLimit: '操作過於頻繁,請稍後再試'
ErrCmdNotFound: '{{ .name }} 指令不存在,請先在宿主機安裝此指令'
ErrSourcePathNotFound: '來源目錄不存在'
ErrFavoriteExist: '已收藏此路徑'
@@ -169,7 +187,7 @@ ErrSSLCertificateNotFound: '憑證檔案不存在'
ErrSSLKeyFormat: '私鑰檔案驗證失敗'
ErrSSLCertificateFormat: '憑證檔案格式錯誤,請使用 pem 格式'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid 或 EabHmacKey 不可為空'
ErrOpenrestyNotFound: 'Http 模式需要先安裝OpenResty'
ErrOpenrestyNotFound: 'HTTP 模式需要先安裝 OpenResty'
ApplySSLStart: '開始申請憑證,網域名稱[{{ .domain }}] 申請方式[{{ .type }}]'
dnsAccount: 'DNS 自動'
dnsManual: 'DNS 手排'
@@ -379,7 +397,7 @@ App: '應用程式'
Runtime: '運作環境'
Database: '資料庫'
ConfigFTP: '建立FTP 使用者{{ .name }}'
ConfigOpenresty: '建立OpenResty 設定檔'
ConfigOpenresty: '建立 OpenResty 設定檔'
InstallAppSuccess: '應用程式{{ .name }} 安裝成功'
ConfigRuntime: '設定執行環境'
ConfigApp: '設定應用程式'
@@ -515,3 +533,4 @@ TerminalAIBlockedRiskyCommand: '已攔截風險命令:{{ .command }}'
TerminalAIThinking: 'AI 正在思考...'
TerminalAIReadyToExecute: '思考完成,請按 Enter 執行(耗時 {{ .duration }},token:{{ .tokens }})'
TerminalAIRequestFailed: 'AI 請求失敗:{{ .err }}'
FileAISearchEmptyDir: '目前目錄下沒有可列出的檔案或資料夾(或均被過濾)。'
+19
View File
@@ -2,6 +2,8 @@ ErrInvalidParams: "参数错误: {{ .detail }}"
ErrTokenParse: "Token 错误: {{ .detail }}"
ErrInitialPassword: "原密码错误"
ErrInternalServer: "服务错误: {{ .detail }}"
ErrFileAINotEnabled: "生成 AI 总结需在文件管理中启用并保存文件 AI 账号。关闭 AI 时仍可使用纯内容检索。"
ErrFileAISearchBadPattern: "搜索条件无效(正则或全词匹配): {{ .detail }}"
ErrWgetRemoteFailed: "远程下载地址无效或无法访问:{{ .detail }}"
ErrWgetInvalidContentType: "远程地址返回的不是文件内容(可能是错误页),{{ .detail }}"
ErrRecordExist: "记录已存在"
@@ -42,6 +44,7 @@ Decrypt: "解密"
#agent
ErrAgentAccountBound: "该账号已绑定智能体,无法删除"
ErrTerminalAIAccountInUse: "该模型账号正被终端 AI 使用,请在终端 AI 设置中更换账号或关闭终端 AI 后重试"
ErrAgentAccountUnavailable: "账号连接信息不可用: {{ .err }}"
ErrAgentProviderNotSupported: "不支持该智能体提供商"
ErrAgentAccountRequired: "请选择智能体账号后重试"
@@ -49,6 +52,8 @@ ErrAgentAccountNotVerified: "账号未通过验证"
ErrAgentAccountModelsRequired: "请至少配置一个模型"
ErrAgentAccountSingleInitialModel: "创建账号时只能配置一个初始模型"
ErrAgentModelNotInAccount: "所选模型未配置在当前账号中"
ErrAgentFallbackModelDuplicate: "备用模型不能重复"
ErrAgentFallbackModelPrimary: "备用模型不能与主模型相同"
ErrAgentModelInUse: "仍有智能体正在使用该模型: {{ .name }}"
ErrAgentBaseURLRequired: "Base URL 不能为空"
ErrAgentApiKeyRequired: "API Key 不能为空"
@@ -56,6 +61,12 @@ ErrAgentComposeRequired: "自定义编排内容不能为空"
ErrAgentIDRequired: "智能体 ID 不能为空"
ErrAgentAccountIDRequired: "账号 ID 不能为空"
ErrAgentLimitReached: "社区版最多支持创建 {{ .max }} 个智能体,升级至专业版无数量限制"
ErrAgentWebsiteBound: "该智能体已关联网站"
ErrAgentWebsiteTypeUnsupported: "只能关联反向代理或静态网站"
ErrAgentWebsiteInUse: "该网站已被其他智能体关联"
ErrAgentWebsiteUnbindUnsupported: "一键部署网站不支持手动解绑"
ErrHermesPairingCodeUnavailable: "配对码在 hermes 中暂时不存在,可能是由于网络原因,请稍后尝试"
ErrHermesFeishuGroupAllowlistRequiresAllowlist: "飞书群组策略为白名单时,私聊策略不能为配对码"
#backup
Localhost: '本机'
@@ -135,6 +146,13 @@ ErrLinkPathNotFound: "目标路径不存在!"
ErrFileIsExist: "文件或文件夹已存在!"
ErrFileUpload: "{{ .name }} 上传文件失败 {{ .detail }}"
ErrFileDownloadDir: "不支持下载目录"
ErrFileSharePath: "无效的分享路径或目标不是文件"
ErrFileShareInvalid: "分享链接无效或已失效"
ErrFileShareExpired: "分享链接已过期"
ErrFileSharePassword: "访问密码错误"
ErrFileShareExhausted: "该分享链接的下载次数已用尽"
ErrFileSharePasswordPolicy: "访问密码至少 4 位"
ErrFileShareRateLimit: "访问过于频繁,请稍后再试"
ErrCmdNotFound: "{{ .name }} 命令不存在,请先在宿主机安装此命令"
ErrSourcePathNotFound: "源目录不存在"
ErrFavoriteExist: "已收藏此路径"
@@ -573,3 +591,4 @@ TerminalAIBlockedRiskyCommand: "已拦截风险命令:{{ .command }}"
TerminalAIThinking: "AI 正在思考..."
TerminalAIReadyToExecute: "思考完成,请回车执行(耗时 {{ .duration }},token:{{ .tokens }})"
TerminalAIRequestFailed: "AI 请求失败:{{ .err }}"
FileAISearchEmptyDir: "当前目录下没有可列出的文件或文件夹(或均被过滤)。"
+6
View File
@@ -57,6 +57,7 @@ func InitAgentDB() {
migrations.UpdateDatabase,
migrations.AddGPUMonitor,
migrations.UpdateDatabaseMysql,
migrations.AddDatabaseMongodb,
migrations.InitIptablesStatus,
migrations.UpdateWebsite,
migrations.AddisIPtoWebsiteSSL,
@@ -77,6 +78,11 @@ func InitAgentDB() {
migrations.AddAITerminalSettings,
migrations.UpdateAgentQuickJumpTitle,
migrations.FixOpenclaw20260323HTTPPort,
migrations.AddAgentRemarkColumn,
migrations.AddAgentWebsiteBinding,
migrations.AddFileManageAISettings,
migrations.AddFileShareTable,
migrations.AddFileHistoryTable,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)

Some files were not shown because too many files have changed in this diff Show More