Compare commits

..
Author SHA1 Message Date
wanghe-fit2cloud c3c7fe320d feat: support enterprise demo mode 2026-08-19 17:31:41 +08:00
14728f889e fix(ssh): correlate disconnect logs by client endpoint (#13581)
* fix(ssh): correlate disconnect logs by client endpoint

* fix(ssh): scope endpoint correlation to active sessions

---------

Co-authored-by: JayLee-sre <1.18655426e+08+JayLee-sre@users.noreply.github.com>
Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-08-19 10:20:16 +08:00
CityFun ff199245b0 feat: add some translate (#13585) 2026-08-18 18:46:27 +08:00
蘭 667807e249 fix: Fix large file/slow network upload timeout in file management (#13584) 2026-08-18 18:12:42 +08:00
Jet.andJayLee-sre 63e09c8c47 docs: name Halo in website deployment overview (#13580)
Co-authored-by: JayLee-sre <1.18655426e+08+JayLee-sre@users.noreply.github.com>
2026-08-18 09:49:11 +08:00
ssongliu 17a8835d59 feat: support Ascend 910B GPU monitoring (#13579)
* feat: support Ascend 910B GPU monitoring

* chore: remove GPU test files
2026-08-17 17:38:43 +08:00
王贺 b306bfa77a fix: correct disk usage for device aliases (#13570) 2026-08-17 15:20:15 +08:00
蘭 b1eff2a893 feat: change some translate (#13568) 2026-08-17 13:58:53 +08:00
王贺 5ac7c80881 fix: support underscores and hyphens in website default documents (#13551) 2026-08-14 10:55:44 +08:00
CityFun d402f67fc3 feat: Optimize application upgrade logic (#13549)
* feat: Optimize application upgrade logic

* feat: Optimize application upgrade logic

* feat: Optimize application upgrade logic
2026-08-13 18:28:59 +08:00
Chen, Ting-An c13793c445 fix(i18n): polish Traditional Chinese agent copy (#13536) 2026-08-12 15:32:39 +08:00
CityFun daa3f6b206 chore: Update dependencies (#13528) 2026-08-12 15:29:40 +08:00
maninhill a2d85c911d Revise user statistics and AI agents limit in README (#13525)
Updated user statistics and modified AI agents limit in the feature table.
2026-08-11 09:39:10 +08:00
蘭 1b76c91e1b fix: Fix file loss issues when copying or moving large directories (#13524) 2026-08-10 22:07:45 +08:00
maninhill d663a4397a Update README for clarity and security features (#13516)
Removed redundant mention of AI gateway in the Full-Stack AI Management section and added WAF to the security features.
2026-08-10 10:04:41 +08:00
maninhill d1558c5eae Revise README for clarity and feature updates (#13515)
Updated the README to enhance the description of 1Panel's features, including AI management, security, and backup capabilities. Adjusted the Pro Edition feature comparison to include the Enterprise edition.
2026-08-09 09:14:52 +08:00
maninhill 0da4f77a2e Revise README.zh-Hans.md for feature updates (#13512)
Updated the README in Chinese to reflect new features and improvements in 1Panel, including AI management capabilities and enhanced security features.
2026-08-07 22:51:57 +08:00
ssongliu e7ef35740c fix: align compose project name handling (#13500) 2026-08-07 12:44:29 +08:00
ssongliu b0d561e33b feat: show license expiration alert on dashboard (#13499) 2026-08-07 10:22:23 +08:00
CityFun 75b362fa9b chore: update dependencies (#13497) 2026-08-06 21:47:20 +08:00
蘭 466f373ef6 feat: change some translate (#13496) 2026-08-06 18:27:27 +08:00
ssongliu 4489641b54 perf(snapshot): hard link local recovery archive (#13494) 2026-08-06 16:08:58 +08:00
蘭 1971d9dec2 fix: handle external login state and emit readiness event (#13493)
* fix: handle external login state and emit readiness event

* fix: handle external login state and improve SAML2 logout response handling
2026-08-06 13:53:36 +08:00
CityFun c38d741770 fix: Fix an issue where the website monitoring directory was not completely removed when deleting a website. (#13492) 2026-08-06 13:52:53 +08:00
蘭 122a474032 feat: enhance alert log search with start and end time filters (#13489) 2026-08-05 21:13:25 +08:00
CityFun 54854e99e7 feat: change deepseek api url (#13487) 2026-08-05 17:10:34 +08:00
CityFun e01fb7c905 fix: Fixed bug with website template (#13484) 2026-08-05 16:56:08 +08:00
CityFun 83a3675a0c feat: QwenPaw support config username/password (#13478) 2026-08-05 16:35:09 +08:00
ssongliu 9dfa451fae fix: adjust host column display (#13477) 2026-08-05 13:52:33 +08:00
ssongliu 9204a287fd fix: improve community restore loading state (#13475)
* fix: improve community restore loading state

* fix: resume license page initialization after restore
2026-08-05 13:52:14 +08:00
f027507f9a fix: preserve active cronjob records during cleanup (#13474)
* fix: preserve active cronjob records during cleanup

* fix: delete cronjob records before removing files

Co-authored-by: ssongliu <73214554+ssongliu@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-08-05 11:32:20 +08:00
ssongliu 01aee89f3b fix: correct process start time in LXC (#13473)
* fix: correct process start time in LXC

* fix: reject unresolved process start times
2026-08-05 11:22:26 +08:00
CityFun 17285d4397 fix: Fixed issue with upgrade openresty failed (#13470)
* fix: Fixed issue with upgrade openresty failed

* fix: Fixed issue with upgrade openresty failed
2026-08-05 09:37:02 +08:00
蘭 91ae846418 feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation (#13458)
* feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation

* feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation

* feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation
2026-08-04 17:34:50 +08:00
ssongliu 1eb429631d fix: resolve compose project names consistently (#13468) 2026-08-04 17:17:07 +08:00
CityFun 6584e3b868 chore: update dependencies (#13467) 2026-08-04 16:46:27 +08:00
ssongliu 14e2294db9 feat: refine node health check settings (#13463) 2026-08-04 15:09:34 +08:00
ssongliu 26b69bc208 fix: bypass cached route in user info navigation (#13462)
* fix: bypass cached route in user info navigation

* fix: avoid caching entrance route
2026-08-04 15:09:17 +08:00
CityFun 2111d4c16b style: change table page config (#13456) 2026-08-04 10:45:13 +08:00
蘭 b682835b4e fix: Implement file sharing password processing and remote download file name processing (#13452) 2026-08-04 09:37:26 +08:00
蘭 c34ac2f31e feat: change some translate (#13451) 2026-08-04 09:37:08 +08:00
CityFun c28047374a feat: Fix the issue where website configuration files fail to restore from backups. (#13445) 2026-08-03 15:26:20 +08:00
ssongliu 60d16609f7 fix: clarify API trusted proxy placeholder (#13444) 2026-08-03 14:30:50 +08:00
CityFun 02ca9347dc feat: Update Xiaomi Models (#13443) 2026-08-03 14:28:26 +08:00
ssongliu d0187994ee fix: ignore empty directories in device clean scan (#13442)
* fix: ignore empty directories in device clean scan

* fix: skip zero-size device clean entries
2026-08-03 14:06:56 +08:00
蘭 be672d604f feat:Support logging in with oidc and saml2 (#13441)
* feat(auth): implement OIDC authentication endpoints and error handling

* feat(auth): add OIDC provider discovery endpoint and related functionality

* feat(auth): add SAML2 authentication support and related functionality

* feat(auth): add LDAP authentication support and related functionality

* fix(auth): improve login keydown handler for better event handling
2026-08-03 13:43:01 +08:00
CityFun 16e3d496eb Merge branch 'dev-v2' into pr@dev-v2@common (#13436) 2026-08-01 14:08:03 +08:00
ssongliu 9159ab842d feat: support filtering app store composes (#13432)
* feat: support filtering app store composes

* fix: initialize app store filter during setup
2026-07-31 18:27:10 +08:00
ssongliu 0d8835d494 fix: constrain dashboard column height (#13433) 2026-07-31 18:26:02 +08:00
ssongliu 9f9e3aacfc feat: add Community Edition restore UI (#13431) 2026-07-31 18:15:24 +08:00
CityFun 7bd11fe73e feat: Keep Website List Order Stable After Editing (#13424) 2026-07-31 11:05:23 +08:00
BugPleaseGoandCityFun e11dc5fadd Add Website Template (#13400)
* feat: Add Website Template

* fix: Don't display the template list

* feat: Add Mcp TopList

* docs: Remove Mcp TopList

* Add more languages

---------

Co-authored-by: CityFun <31820853+zhengkunwang223@users.noreply.github.com>
2026-07-31 10:01:20 +08:00
HynoR f35b0deb29 refactor(firewall): extract port forwarding subsystem (#13347)
Port forwarding no longer shares the filter client. FilterClient keeps only
filter capabilities, and forwarding gets its own adapter, service and boot
replay:

- utils/firewall/forwarding holds the provider adapters. firewalld uses native
  forward-port, ufw and iptables share the NAT implementation moved out of
  client/iptables/forward.go.
- service/forwarding.go owns base info, search, operate, enable and replay.
  The API keeps its routes and dispatches on name/type/operate.
- init/firewall replays forwarding through that service instead of loading NAT
  rule files inline.

Also adds 1PANEL_FORWARD to the IptablesOp name enum: the frontend already
sends {"name":"1PANEL_FORWARD","operate":"init-forward"} and the validator
rejected it with 400 before reaching the service. Besides that, the only
observable difference is that a forward-tab search no longer triggers the
port/address record cleanup goroutine on the side.
2026-07-30 14:07:41 +08:00
ssongliu 33b3eecb95 feat: unify pin actions (#13417) 2026-07-30 09:39:47 +08:00
CityFun 6ac7a5f167 feat: Optimize the application upgrade logic. (#13416) 2026-07-29 17:44:50 +08:00
CityFun a5fbbfc460 feat: Optimize the application upgrade logic. (#13415) 2026-07-29 17:19:52 +08:00
ssongliu 563df3da71 fix: support trusted proxies for API allowlist (#13409) 2026-07-29 16:48:59 +08:00
ssongliu 13e6bc4fac feat: separate website and standalone FTP identities (#13412) 2026-07-29 16:41:43 +08:00
ssongliu 357d77856a fix: align dashboard uptime with boot time (#13410)
* fix: align dashboard uptime with boot time

* fix: handle invalid dashboard boot time
2026-07-29 16:36:05 +08:00
ssongliu 4279339189 fix: simplify cronjob record duration display (#13398) 2026-07-29 10:57:40 +08:00
ssongliuandCopilot Autofix powered by AI 380033dfe0 fix: support MySQL backup GTID options (#13407)
* fix: support MySQL backup GTID options

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-29 10:42:21 +08:00
ssongliu 97d383ed12 feat: add explicit FTP identity initialization (#13390) 2026-07-28 17:07:57 +08:00
ssongliu 52e6a63ebc fix: improve host system log pagination (#13395) 2026-07-28 17:07:22 +08:00
CityFun 7506e709e2 Add plugin management support to OpenClaw. (#13388) 2026-07-28 14:20:55 +08:00
蘭 cf37de66fc feat(auth): add LDAP authentication support and related configurations (#13385)
* feat(auth): add LDAP authentication support and related configurations

* feat(ldap): implement LDAP synchronization features and enhance user import logic

* feat(auth): add authSource and authSourceStatus to user information

* feat(i18n): update LDAP synchronization error messages in multiple languages

* feat(i18n): update LDAP synchronization error messages in multiple languages

* feat(i18n): update LDAP synchronization error messages in multiple languages
2026-07-28 14:20:42 +08:00
CityFun e2754b447d feat: Add support for text-to-image APIs. (#13380)
* feat: Add support for text-to-image APIs.

* feat: Add support for text-to-image APIs.
2026-07-28 09:42:23 +08:00
331 changed files with 28471 additions and 5677 deletions
+23 -37
View File
@@ -1,9 +1,6 @@
<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">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
Loved by a global community of <strong>2.5M+</strong> self-hosters.
</p>
<p align="center">
@@ -12,7 +9,6 @@
<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"><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>
@@ -41,34 +37,28 @@
## What is 1Panel?
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.
👉 Watch the [2-minute introduction](https://www.youtube.com/watch?v=Jl_wqp-XA08)
1Panel is a modern, open-source Linux server management panel and a lightweight AI management platform. Through an intuitive web interface, it provides users with comprehensive, one-stop server management capabilities:
- **AI Management**: Offers a unified management platform from bare metal to agents (Metal-to-Agent). It integrates an AI gateway, and Skills Hub, while supporting centralized management of agents and models.
- **Efficient Visual Operations**: Easily manage Linux servers through a web-based GUI, streamlining tasks such as host monitoring, file management, database management, and container management.
- **Rapid Website Deployment**: Deeply integrates with popular website builders like WordPress and Halo. It enables one-click domain binding and SSL certificate configuration, significantly lowering the barrier to website creation.
- **Curated App Store**: Features a built-in store of high-quality open-source applications, providing one-click installation and upgrade services to effortlessly extend server capabilities.
- **Enterprise-Grade Security**: Deploys applications based on container technology to effectively minimize vulnerability exposure. It also provides security features such as WAF and log auditing to ensure comprehensive server protection.
- **One-Click Data Backup**: Supports one-click backup and restoration, and integrates with various cloud storage solutions to ensure data security and prevent loss.
## Why 1Panel?
| | 1Panel | cPanel / Plesk | aaPanel | Webmin |
|--|--------|----------------|---------|--------|
| Free & open source | ✅ | ❌ | Partial | ✅ |
| Native AI agent runtime | ✅ | ❌ | ❌ | ❌ |
| AI management | ✅ | ❌ | ❌ | ❌ |
| 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
> **Requirements:** Linux VPS (Debian / Ubuntu / CentOS / Rocky), 1 GB RAM, internet access.
> Takes ~60 seconds.
Prepare your Linux server and run the following script:
```bash
bash -c "$(curl -sSL https://resource.1panel.pro/v2/quick_start.sh)"
@@ -83,24 +73,20 @@ Run `1pctl user-info` via SSH if you need to retrieve your access credentials.
## Pro Edition
1Panel OSS is free forever. Pro adds features built for teams and production workloads:
1Panel OSS is free forever. 1Panel Pro and Ent 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)
| Feature | OSS | Pro | Ent |
|---------|:---:|:---:|:---:|
| One-click app installs | ✅ | ✅ | ✅ |
| AI agents (OpenClaw) | 5 agent | Unlimited | ✅ |
| WAF & advanced security | Basic | ✅ | ✅ |
| Website tamper protection | ❌ | ✅ | ✅ |
| Website uptime monitoring | ❌ | ✅ | ✅ |
| Multi-node management | ❌ | ✅ | ✅ |
| Custom logo & theme | ❌ | ✅ | ✅ |
| KVM Web UI | ❌ | ❌ | ✅ |
| AI Gateway | ❌ | ❌ | ✅ |
| Priority support | ❌ | ❌ | ✅ |
## Community & Support
+84 -2
View File
@@ -124,7 +124,7 @@ func (b *BaseApi) PageAgents(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := agentService.Page(req)
total, list, err := agentService.Page(req, helper.IsDemoRequest(c))
if err != nil {
helper.BadRequest(c, err)
return
@@ -447,7 +447,7 @@ func (b *BaseApi) PageAgentAccounts(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := agentService.PageAccounts(req)
total, list, err := agentService.PageAccounts(req, helper.IsDemoRequest(c))
if err != nil {
helper.BadRequest(c, err)
return
@@ -1378,6 +1378,88 @@ func (b *BaseApi) UninstallAgentSkill(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary List OpenClaw plugins
// @Accept json
// @Param request body dto.AgentPluginsReq true "request"
// @Success 200 {array} dto.AgentPluginItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/list [post]
func (b *BaseApi) ListAgentPlugins(c *gin.Context) {
var req dto.AgentPluginsReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.ListPlugins(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Search OpenClaw plugins
// @Accept json
// @Param request body dto.AgentPluginSearchReq true "request"
// @Success 200 {array} dto.AgentPluginSearchItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/search [post]
func (b *BaseApi) SearchAgentPlugins(c *gin.Context) {
var req dto.AgentPluginSearchReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.SearchPlugins(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Install an OpenClaw marketplace plugin
// @Accept json
// @Param request body dto.AgentPluginMarketInstallReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/install [post]
func (b *BaseApi) InstallAgentMarketPlugin(c *gin.Context) {
var req dto.AgentPluginMarketInstallReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.InstallMarketPlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Operate an OpenClaw plugin
// @Accept json
// @Param request body dto.AgentPluginOperateReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/operate [post]
func (b *BaseApi) OperateAgentPlugin(c *gin.Context) {
var req dto.AgentPluginOperateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.OperatePlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Login Agent Weixin channel
// @Accept json
+1 -1
View File
@@ -268,7 +268,7 @@ func (b *BaseApi) PageAlertConfig(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, configs, err := alertService.PageAlertConfig(req)
total, configs, err := alertService.PageAlertConfig(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -120,7 +120,7 @@ func (b *BaseApi) GetAppDetail(c *gin.Context) {
}
version := c.Param("version")
appType := c.Param("type")
appDetailDTO, err := appService.GetAppDetail(appID, version, appType)
appDetailDTO, err := appService.GetAppDetail(appID, version, appType, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -14,7 +14,7 @@ import (
// @Security Timestamp
// @Router /apps/ignored/detail [get]
func (b *BaseApi) ListAppIgnored(c *gin.Context) {
res, err := appIgnoreUpgradeService.List()
res, err := appIgnoreUpgradeService.List(helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+6 -4
View File
@@ -21,6 +21,7 @@ func (b *BaseApi) SearchAppInstalled(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
req.ReadOnly = helper.IsDemoRequest(c)
if req.All {
list, err := appInstallService.SearchForWebsite(req)
if err != nil {
@@ -73,6 +74,7 @@ func (b *BaseApi) CheckAppInstalled(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
req.ReadOnly = helper.IsDemoRequest(c)
checkData, err := appInstallService.CheckExist(req)
if err != nil {
helper.InternalServer(c, err)
@@ -115,7 +117,7 @@ func (b *BaseApi) LoadConnInfo(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
conn, err := appInstallService.LoadConnInfo(req)
conn, err := appInstallService.LoadConnInfo(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -137,7 +139,7 @@ func (b *BaseApi) DeleteCheck(c *gin.Context) {
helper.BadRequest(c, err)
return
}
checkData, err := appInstallService.DeleteCheck(appInstallId)
checkData, err := appInstallService.DeleteCheck(appInstallId, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -277,7 +279,7 @@ func (b *BaseApi) GetParams(c *gin.Context) {
helper.BadRequest(c, err)
return
}
content, err := appInstallService.GetParams(appInstallId)
content, err := appInstallService.GetParams(appInstallId, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -341,7 +343,7 @@ func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
helper.BadRequest(c, err)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId)
info, err := appInstallService.GetAppInstallInfo(appInstallId, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -209,7 +209,7 @@ func (b *BaseApi) SearchBackup(c *gin.Context) {
return
}
total, list, err := backupService.SearchWithPage(req)
total, list, err := backupService.SearchWithPage(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -106,7 +106,7 @@ func (b *BaseApi) SearchClam(c *gin.Context) {
// @Security Timestamp
// @Router /toolbox/clam/base [post]
func (b *BaseApi) LoadClamBaseInfo(c *gin.Context) {
info, err := clamService.LoadBaseInfo()
info, err := clamService.LoadBaseInfo(helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+2 -2
View File
@@ -65,7 +65,7 @@ func (b *BaseApi) SearchComposeTemplate(c *gin.Context) {
return
}
total, list, err := composeTemplateService.SearchWithPage(req)
total, list, err := composeTemplateService.SearchWithPage(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -85,7 +85,7 @@ func (b *BaseApi) SearchComposeTemplate(c *gin.Context) {
// @Security Timestamp
// @Router /containers/template [get]
func (b *BaseApi) ListComposeTemplate(c *gin.Context) {
list, err := composeTemplateService.List()
list, err := composeTemplateService.List(helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+22 -2
View File
@@ -273,7 +273,7 @@ func (b *BaseApi) SearchCompose(c *gin.Context) {
return
}
total, list, err := containerService.PageCompose(req)
total, list, err := containerService.PageCompose(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -387,7 +387,7 @@ func (b *BaseApi) ContainerInfo(c *gin.Context) {
return
}
data, err := containerService.ContainerInfo(req)
data, err := containerService.ContainerInfo(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -881,6 +881,26 @@ func (b *BaseApi) ComposeUpdate(c *gin.Context) {
helper.Success(c)
}
// @Tags Container Compose
// @Summary Pin compose
// @Accept json
// @Param request body dto.ComposePin true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/compose/pin [post]
func (b *BaseApi) ComposePin(c *gin.Context) {
var req dto.ComposePin
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := containerService.ComposePin(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Container Compose
// @Summary Load compose environment variables
// @Accept json
+1 -1
View File
@@ -144,7 +144,7 @@ func (b *BaseApi) SearchCronjob(c *gin.Context) {
return
}
total, list, err := cronjobService.SearchWithPage(req)
total, list, err := cronjobService.SearchWithPage(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+2 -2
View File
@@ -78,7 +78,7 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
return
}
total, list, err := databaseService.SearchWithPage(req)
total, list, err := databaseService.SearchWithPage(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -147,7 +147,7 @@ func (b *BaseApi) GetDatabase(c *gin.Context) {
helper.BadRequest(c, err)
return
}
data, err := databaseService.Get(name)
data, err := databaseService.Get(name, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -54,7 +54,7 @@ func (b *BaseApi) SearchMongodb(c *gin.Context) {
return
}
total, list, err := mongodbService.SearchWithPage(req)
total, list, err := mongodbService.SearchWithPage(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -53,7 +53,7 @@ func (b *BaseApi) ListMysqlUsers(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := mysqlService.ListUsers(req)
data, err := mysqlService.ListUsers(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -151,7 +151,7 @@ func (b *BaseApi) SearchPostgresql(c *gin.Context) {
return
}
total, list, err := postgresqlService.SearchWithPage(req)
total, list, err := postgresqlService.SearchWithPage(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+2
View File
@@ -42,6 +42,7 @@ var (
fileShareService = service.NewIFileShareService()
sshService = service.NewISSHService()
firewallService = service.NewIFirewallService()
forwardingService = service.NewIForwardingService()
iptablesService = service.NewIIptablesService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
@@ -60,6 +61,7 @@ var (
websiteDnsAccountService = service.NewIWebsiteDnsAccountService()
websiteSSLService = service.NewIWebsiteSSLService()
websiteAcmeAccountService = service.NewIWebsiteAcmeAccountService()
websiteTemplateService = service.NewIWebsiteTemplateService()
nginxService = service.NewINginxService()
+451 -29
View File
@@ -35,6 +35,81 @@ var cancelledChunkUploads = struct {
ids map[string]struct{}
}{ids: make(map[string]struct{})}
type chunkUploadLock struct {
mutex sync.Mutex
refs int
}
var chunkUploadLocks = struct {
sync.Mutex
items map[string]*chunkUploadLock
}{items: make(map[string]*chunkUploadLock)}
type completedChunkUpload struct {
dstDir string
filename string
fileSize int64
}
var completedChunkUploads = struct {
sync.RWMutex
items map[string]completedChunkUpload
}{items: make(map[string]completedChunkUpload)}
var activeChunkUploadTTL = 24 * time.Hour
var (
errChunkUploadCancelled = errors.New("upload cancelled")
errInvalidChunkUpload = errors.New("invalid chunk upload")
)
type activeChunkUpload struct {
upload completedChunkUpload
expiresAt time.Time
timer *time.Timer
}
var activeChunkUploads = struct {
sync.RWMutex
items map[string]activeChunkUpload
}{items: make(map[string]activeChunkUpload)}
type resumableUploadChunk struct {
UploadID string
Filename string
DstDir string
ChunkIndex int
ChunkCount int
Offset int64
FileSize int64
Overwrite bool
}
func invalidChunkUploadError(message string) error {
return fmt.Errorf("%w: %s", errInvalidChunkUpload, message)
}
func isRetryableChunkUploadError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errChunkUploadCancelled) ||
errors.Is(err, errInvalidChunkUpload) ||
errors.Is(err, os.ErrExist) ||
errors.Is(err, os.ErrPermission) ||
errors.Is(err, os.ErrInvalid) ||
errors.Is(err, syscall.ENOSPC) ||
errors.Is(err, syscall.EDQUOT) ||
errors.Is(err, syscall.EROFS) ||
errors.Is(err, syscall.EFBIG) ||
errors.Is(err, syscall.ENAMETOOLONG) ||
errors.Is(err, syscall.ENOTDIR) ||
errors.Is(err, syscall.EISDIR) {
return false
}
return true
}
// @Tags File
// @Summary List files
// @Accept json
@@ -73,7 +148,7 @@ func (b *BaseApi) FileAISearch(c *gin.Context) {
if strings.TrimSpace(req.ResponseLanguage) == "" {
req.ResponseLanguage = strings.TrimSpace(c.GetHeader("Accept-Language"))
}
res, err := fileService.AISearch(req)
res, err := fileService.AISearch(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -474,11 +549,7 @@ func (b *BaseApi) UploadFiles(c *gin.Context) {
continue
}
dstInfo, statErr := os.Stat(dstFilename)
if overwrite {
_ = os.Remove(dstFilename)
}
err = os.Rename(tmpFilename, dstFilename)
err = finalizeUploadedFile(tmpFilename, dstFilename, overwrite)
if err != nil {
_ = os.Remove(tmpFilename)
e := fmt.Errorf("upload [%s] file failed, err: %v", file.Filename, err)
@@ -638,6 +709,26 @@ func (b *BaseApi) MoveFile(c *gin.Context) {
helper.Success(c)
}
// @Tags File
// @Summary Stop file move task
// @Accept json
// @Param request body request.FileMoveStopReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/move/stop [post]
func (b *BaseApi) StopMoveFile(c *gin.Context) {
var req request.FileMoveStopReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := fileService.StopMvFile(req.TaskID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File
// @Summary Download file
// @Accept json
@@ -792,6 +883,289 @@ func (b *BaseApi) DepthDirSize(c *gin.Context) {
helper.SuccessWithData(c, res)
}
func lockChunkUpload(uploadID string) func() {
chunkUploadLocks.Lock()
lock, ok := chunkUploadLocks.items[uploadID]
if !ok {
lock = &chunkUploadLock{}
chunkUploadLocks.items[uploadID] = lock
}
lock.refs++
chunkUploadLocks.Unlock()
lock.mutex.Lock()
return func() {
lock.mutex.Unlock()
chunkUploadLocks.Lock()
lock.refs--
if lock.refs == 0 {
delete(chunkUploadLocks.items, uploadID)
}
chunkUploadLocks.Unlock()
}
}
func resumableUploadPartPath(dstDir, uploadID string) string {
return filepath.Join(dstDir, fmt.Sprintf(".1panel-upload-%s.part", uploadID))
}
func finalizeUploadedFile(tmpFile, dstFile string, overwrite bool) error {
if overwrite {
return os.Rename(tmpFile, dstFile)
}
if err := os.Link(tmpFile, dstFile); err != nil {
return err
}
if err := os.Remove(tmpFile); err != nil {
if rollbackErr := os.Remove(dstFile); rollbackErr != nil {
return fmt.Errorf("remove upload temporary file failed: %v, rollback destination failed: %w", err, rollbackErr)
}
return err
}
return nil
}
func registerActiveChunkUpload(uploadID string, upload completedChunkUpload) error {
activeChunkUploads.Lock()
defer activeChunkUploads.Unlock()
if active, ok := activeChunkUploads.items[uploadID]; ok {
if active.upload != upload {
return invalidChunkUploadError("upload ID is already used by another file")
}
active.timer.Stop()
}
expiresAt := time.Now().Add(activeChunkUploadTTL)
timer := time.AfterFunc(activeChunkUploadTTL, func() {
expireActiveChunkUpload(uploadID, expiresAt)
})
activeChunkUploads.items[uploadID] = activeChunkUpload{
upload: upload,
expiresAt: expiresAt,
timer: timer,
}
return nil
}
func loadActiveChunkUpload(uploadID string) (completedChunkUpload, bool) {
activeChunkUploads.RLock()
active, ok := activeChunkUploads.items[uploadID]
activeChunkUploads.RUnlock()
return active.upload, ok
}
func deleteActiveChunkUpload(uploadID string) {
activeChunkUploads.Lock()
if active, ok := activeChunkUploads.items[uploadID]; ok {
active.timer.Stop()
}
delete(activeChunkUploads.items, uploadID)
activeChunkUploads.Unlock()
}
func discardActiveChunkUpload(uploadID, partFile string) error {
deleteActiveChunkUpload(uploadID)
if err := os.Remove(partFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove upload temporary file failed: %w", err)
}
return nil
}
func finalizeActiveChunkUpload(uploadID, partFile, dstFile string, overwrite bool) error {
if err := finalizeUploadedFile(partFile, dstFile, overwrite); err != nil {
if removeErr := discardActiveChunkUpload(uploadID, partFile); removeErr != nil {
return errors.Join(err, removeErr)
}
return err
}
return nil
}
func expireActiveChunkUpload(uploadID string, expiresAt time.Time) {
unlock := lockChunkUpload(uploadID)
defer unlock()
activeChunkUploads.Lock()
active, ok := activeChunkUploads.items[uploadID]
if !ok || !active.expiresAt.Equal(expiresAt) {
activeChunkUploads.Unlock()
return
}
delete(activeChunkUploads.items, uploadID)
activeChunkUploads.Unlock()
partFile := resumableUploadPartPath(active.upload.dstDir, uploadID)
if err := os.Remove(partFile); err != nil && !os.IsNotExist(err) {
global.LOG.Warnf("remove inactive upload part [%s] failed: %v", partFile, err)
}
}
func removeActiveResumableUploadPart(uploadID string) error {
unlock := lockChunkUpload(uploadID)
defer unlock()
upload, ok := loadActiveChunkUpload(uploadID)
if !ok {
return nil
}
err := os.Remove(resumableUploadPartPath(upload.dstDir, uploadID))
if err == nil || os.IsNotExist(err) {
deleteActiveChunkUpload(uploadID)
return nil
}
return err
}
func loadCompletedChunkUpload(uploadID string) (completedChunkUpload, bool) {
completedChunkUploads.RLock()
completed, ok := completedChunkUploads.items[uploadID]
completedChunkUploads.RUnlock()
return completed, ok
}
func markChunkUploadCompleted(uploadID string, completed completedChunkUpload) {
completedChunkUploads.Lock()
completedChunkUploads.items[uploadID] = completed
completedChunkUploads.Unlock()
time.AfterFunc(10*time.Minute, func() {
completedChunkUploads.Lock()
delete(completedChunkUploads.items, uploadID)
completedChunkUploads.Unlock()
})
}
func writeResumableUploadChunk(chunk resumableUploadChunk, chunkData []byte) error {
unlock := lockChunkUpload(chunk.UploadID)
defer unlock()
if chunkUploadCancelled(chunk.UploadID) {
return errChunkUploadCancelled
}
if chunk.UploadID == "" || filepath.Base(chunk.UploadID) != chunk.UploadID || strings.ContainsAny(chunk.UploadID, `/\`) {
return invalidChunkUploadError("invalid upload ID")
}
if chunk.Filename == "" || filepath.Base(chunk.Filename) != chunk.Filename || strings.ContainsAny(chunk.Filename, `/\`) {
return invalidChunkUploadError("invalid filename")
}
if strings.TrimSpace(chunk.DstDir) == "" {
return invalidChunkUploadError("upload destination is required")
}
dstDir := filepath.Clean(strings.TrimSpace(chunk.DstDir))
if chunk.ChunkCount <= 0 || chunk.ChunkIndex < 0 || chunk.ChunkIndex >= chunk.ChunkCount {
return invalidChunkUploadError("invalid chunk index")
}
if chunk.FileSize <= 0 || chunk.Offset < 0 || chunk.Offset > chunk.FileSize {
return invalidChunkUploadError("invalid upload offset")
}
chunkEnd := chunk.Offset + int64(len(chunkData))
if chunkEnd > chunk.FileSize {
return invalidChunkUploadError("chunk exceeds file size")
}
if chunk.ChunkIndex+1 == chunk.ChunkCount {
if chunkEnd != chunk.FileSize {
return invalidChunkUploadError("final chunk does not match file size")
}
} else if chunkEnd >= chunk.FileSize {
return invalidChunkUploadError("non-final chunk reaches file size")
}
if completed, ok := loadCompletedChunkUpload(chunk.UploadID); ok {
if completed.dstDir == dstDir && completed.filename == chunk.Filename && completed.fileSize == chunk.FileSize {
return nil
}
return invalidChunkUploadError("upload ID has already completed another file")
}
upload := completedChunkUpload{dstDir: dstDir, filename: chunk.Filename, fileSize: chunk.FileSize}
if err := registerActiveChunkUpload(chunk.UploadID, upload); err != nil {
return err
}
mode, err := files.GetParentMode(dstDir)
if err != nil {
return err
}
if err = os.MkdirAll(dstDir, mode); err != nil {
return err
}
dstDirInfo, err := os.Stat(dstDir)
if err != nil {
return err
}
if !dstDirInfo.IsDir() {
return invalidChunkUploadError(fmt.Sprintf("upload destination [%s] is not a directory", dstDir))
}
dstFile := filepath.Join(dstDir, chunk.Filename)
partFile := resumableUploadPartPath(dstDir, chunk.UploadID)
if dstFile == partFile {
return invalidChunkUploadError("filename conflicts with upload temporary file")
}
fileMode := dstDirInfo.Mode().Perm()
ownerInfo := dstDirInfo
if dstInfo, statErr := os.Stat(dstFile); statErr == nil {
if !chunk.Overwrite {
if err := discardActiveChunkUpload(chunk.UploadID, partFile); err != nil {
return errors.Join(os.ErrExist, err)
}
return os.ErrExist
}
fileMode = dstInfo.Mode().Perm()
ownerInfo = dstInfo
} else if !os.IsNotExist(statErr) {
return statErr
}
part, err := os.OpenFile(partFile, os.O_CREATE|os.O_RDWR, fileMode)
if err != nil {
return err
}
partClosed := false
defer func() {
if !partClosed {
_ = part.Close()
}
}()
if stat, statErr := part.Stat(); statErr != nil {
return statErr
} else if chunk.Offset > stat.Size() {
return invalidChunkUploadError(fmt.Sprintf("unexpected upload offset %d, current size is %d", chunk.Offset, stat.Size()))
} else if chunk.Offset < stat.Size() && chunkEnd > stat.Size() {
if err = part.Truncate(chunk.Offset); err != nil {
return err
}
}
if _, err = part.WriteAt(chunkData, chunk.Offset); err != nil {
return err
}
if chunk.ChunkIndex+1 != chunk.ChunkCount {
return nil
}
partInfo, err := part.Stat()
if err != nil {
return err
}
if partInfo.Size() != chunk.FileSize {
return invalidChunkUploadError(fmt.Sprintf("uploaded file size mismatch: expected %d, got %d", chunk.FileSize, partInfo.Size()))
}
if err = part.Close(); err != nil {
return err
}
partClosed = true
if err = os.Chmod(partFile, fileMode); err != nil {
return err
}
if stat, ok := ownerInfo.Sys().(*syscall.Stat_t); ok {
if err = os.Chown(partFile, int(stat.Uid), int(stat.Gid)); err != nil {
return err
}
}
if chunkUploadCancelled(chunk.UploadID) {
return errChunkUploadCancelled
}
if err = finalizeActiveChunkUpload(chunk.UploadID, partFile, dstFile, chunk.Overwrite); err != nil {
return err
}
markChunkUploadCompleted(chunk.UploadID, upload)
deleteActiveChunkUpload(chunk.UploadID)
return nil
}
func mergeChunks(fileName string, fileDir string, dstDir string, chunkCount int, overwrite bool) error {
defer func() {
_ = os.RemoveAll(fileDir)
@@ -871,6 +1245,10 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
helper.BadRequest(c, err)
return
}
if chunkCount <= 0 || chunkIndex < 0 || chunkIndex >= chunkCount {
helper.BadRequest(c, errors.New("invalid chunk index"))
return
}
fileOp := files.NewFileOp()
tmpDir := path.Join(global.Dir.TmpDir, "upload")
if !fileOp.Stat(tmpDir) {
@@ -885,20 +1263,25 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
return
}
uploadID := strings.TrimSpace(c.PostForm("uploadID"))
resumable := c.PostForm("fileSize") != "" || c.PostForm("offset") != ""
cancellable := uploadID != ""
if cancellable && (filepath.Base(uploadID) != uploadID || strings.ContainsAny(uploadID, `/\\`)) {
helper.BadRequest(c, errors.New("invalid upload ID"))
return
}
if resumable && !cancellable {
helper.BadRequest(c, errors.New("upload ID is required"))
return
}
if !cancellable {
uploadID = filename
}
fileDir := filepath.Join(tmpDir, uploadID)
if cancellable && chunkUploadCancelled(uploadID) {
helper.BadRequest(c, errors.New("upload cancelled"))
helper.BadRequest(c, errChunkUploadCancelled)
return
}
if chunkIndex == 0 {
if !resumable && chunkIndex == 0 {
if fileOp.Stat(fileDir) {
_ = fileOp.DeleteDir(fileDir)
}
@@ -907,32 +1290,67 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
filePath := filepath.Join(fileDir, filename)
defer func() {
if err != nil {
if !resumable && err != nil {
_ = os.RemoveAll(fileDir)
}
}()
var (
emptyFile *os.File
chunkData []byte
)
emptyFile, err = os.Create(filePath)
if err != nil {
helper.BadRequest(c, err)
return
}
defer emptyFile.Close()
chunkData, err = io.ReadAll(uploadFile)
chunkData, err := io.ReadAll(uploadFile)
if err != nil {
helper.InternalServer(c, buserr.WithMap("ErrFileUpload", map[string]interface{}{"name": filename, "detail": err.Error()}, err))
return
}
if cancellable && chunkUploadCancelled(uploadID) {
err = errors.New("upload cancelled")
err = errChunkUploadCancelled
helper.BadRequest(c, err)
return
}
if resumable {
offset, parseErr := strconv.ParseInt(c.PostForm("offset"), 10, 64)
if parseErr != nil {
helper.BadRequest(c, parseErr)
return
}
fileSize, parseErr := strconv.ParseInt(c.PostForm("fileSize"), 10, 64)
if parseErr != nil {
helper.BadRequest(c, parseErr)
return
}
overwrite := true
if ow := c.PostForm("overwrite"); ow != "" {
overwrite, _ = strconv.ParseBool(ow)
}
err = writeResumableUploadChunk(resumableUploadChunk{
UploadID: uploadID,
Filename: filename,
DstDir: c.PostForm("path"),
ChunkIndex: chunkIndex,
ChunkCount: chunkCount,
Offset: offset,
FileSize: fileSize,
Overwrite: overwrite,
}, chunkData)
if err != nil {
uploadErr := buserr.WithMap("ErrFileUpload", map[string]interface{}{"name": filename, "detail": err.Error()}, err)
helper.ErrorWithDetailAndData(c, http.StatusInternalServerError, "ErrInternalServer", uploadErr, gin.H{
"retryable": isRetryableChunkUploadError(err),
})
return
}
if chunkIndex+1 == chunkCount {
cancelledChunkUploads.Lock()
delete(cancelledChunkUploads.ids, uploadID)
cancelledChunkUploads.Unlock()
}
helper.SuccessWithData(c, true)
return
}
emptyFile, err := os.Create(filePath)
if err != nil {
helper.BadRequest(c, err)
return
}
defer emptyFile.Close()
chunkPath := filepath.Join(fileDir, fmt.Sprintf("%s.%d", filename, chunkIndex))
err = os.WriteFile(chunkPath, chunkData, constant.DirPerm)
@@ -985,6 +1403,10 @@ func (b *BaseApi) StopChunkUpload(c *gin.Context) {
helper.InternalServer(c, err)
return
}
if err := removeActiveResumableUploadPart(uploadID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
@@ -1211,7 +1633,7 @@ func (b *BaseApi) SearchFileShare(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := fileShareService.Page(req)
total, list, err := fileShareService.Page(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -1235,7 +1657,7 @@ func (b *BaseApi) GetFileShareDetail(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
info, err := fileShareService.GetByPath(req.Path)
info, err := fileShareService.GetByPath(req.Path, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -1254,7 +1676,7 @@ func (b *BaseApi) GetPublicFileShareInfo(c *gin.Context) {
helper.BadRequest(c, errors.New("code is required"))
return
}
info, err := fileShareService.GetPublicByCode(code)
info, err := fileShareService.GetPublicByCode(code, helper.IsDemoRequest(c))
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
@@ -1307,7 +1729,7 @@ func (b *BaseApi) GetFileShareQRCode(c *gin.Context) {
helper.BadRequest(c, errors.New("code is required"))
return
}
if _, err := fileShareService.GetByCode(code); err != nil {
if _, err := fileShareService.GetByCode(code, helper.IsDemoRequest(c)); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
@@ -1389,7 +1811,7 @@ func (b *BaseApi) CheckFileShare(c *gin.Context) {
helper.BadRequest(c, errors.New("code is required"))
return
}
if err := fileShareService.Check(code, password); err != nil {
if err := fileShareService.Check(code, password, helper.IsDemoRequest(c)); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
@@ -1414,7 +1836,7 @@ func (b *BaseApi) DownloadFileShare(c *gin.Context) {
helper.BadRequest(c, errors.New("code is required"))
return
}
filePath, displayName, err := fileShareService.PrepareDownload(code, password)
filePath, displayName, err := fileShareService.PrepareDownload(code, password, helper.IsDemoRequest(c))
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
+32 -4
View File
@@ -20,7 +20,15 @@ func (b *BaseApi) LoadFirewallBaseInfo(c *gin.Context) {
return
}
data, err := firewallService.LoadBaseInfo(req.Name)
var (
data dto.FirewallBaseInfo
err error
)
if req.Name == "forward" {
data, err = forwardingService.LoadBaseInfo()
} else {
data, err = firewallService.LoadBaseInfo(req.Name)
}
if err != nil {
helper.InternalServer(c, err)
return
@@ -43,7 +51,21 @@ func (b *BaseApi) SearchFirewallRule(c *gin.Context) {
return
}
total, list, err := firewallService.SearchWithPage(req)
var (
total int64
list interface{}
err error
)
if req.Type == "forward" {
total, list, err = forwardingService.SearchWithPage(dto.ForwardRuleSearch{
PageInfo: req.PageInfo,
Info: req.Info,
Status: req.Status,
Strategy: req.Strategy,
})
} else {
total, list, err = firewallService.SearchWithPage(req)
}
if err != nil {
helper.InternalServer(c, err)
return
@@ -116,7 +138,7 @@ func (b *BaseApi) OperateForwardRule(c *gin.Context) {
return
}
if err := firewallService.OperateForwardRule(req); err != nil {
if err := forwardingService.Operate(req); err != nil {
helper.InternalServer(c, err)
return
}
@@ -313,7 +335,13 @@ func (b *BaseApi) OperateFilterChain(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := iptablesService.Operate(req); err != nil {
var err error
if req.Operate == "init-forward" {
err = forwardingService.Enable()
} else {
err = iptablesService.Operate(req)
}
if err != nil {
helper.InternalServer(c, err)
return
}
+1 -1
View File
@@ -87,7 +87,7 @@ func (b *BaseApi) SearchFtp(c *gin.Context) {
return
}
total, list, err := ftpService.SearchWithPage(req)
total, list, err := ftpService.SearchWithPage(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+14
View File
@@ -30,6 +30,16 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
ctx.Abort()
}
func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
res := dto.Response{
Code: code,
Data: data,
}
res.Message = i18n.GetMsgWithDetail(msgKey, err.Error())
ctx.JSON(http.StatusOK, res)
ctx.Abort()
}
func InternalServer(ctx *gin.Context, err error) {
ErrorWithDetail(ctx, http.StatusInternalServerError, "ErrInternalServer", err)
}
@@ -38,6 +48,10 @@ func BadRequest(ctx *gin.Context, err error) {
ErrorWithDetail(ctx, http.StatusBadRequest, "ErrInvalidParams", err)
}
func IsDemoRequest(ctx *gin.Context) bool {
return global.CONF.Base.IsDemo || ctx.GetHeader(constant.DemoModeHeader) == strconv.FormatBool(true)
}
func SuccessWithData(ctx *gin.Context, data interface{}) {
if data == nil {
data = gin.H{}
+1 -1
View File
@@ -100,7 +100,7 @@ func (b *BaseApi) SearchHost(c *gin.Context) {
return
}
total, list, err := hostService.SearchWithPage(req)
total, list, err := hostService.SearchWithPage(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -21,7 +21,7 @@ func (b *BaseApi) SearchRepo(c *gin.Context) {
return
}
total, list, err := imageRepoService.Page(req)
total, list, err := imageRepoService.Page(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -19,7 +19,7 @@ func (b *BaseApi) PageMcpServers(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
list := mcpServerService.Page(req)
list := mcpServerService.Page(req, helper.IsDemoRequest(c))
helper.SuccessWithData(c, list)
}
+3 -2
View File
@@ -20,6 +20,7 @@ func (b *BaseApi) SearchRuntimes(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
req.ReadOnly = helper.IsDemoRequest(c)
total, items, err := runtimeService.Page(req)
if err != nil {
helper.InternalServer(c, err)
@@ -132,7 +133,7 @@ func (b *BaseApi) GetRuntime(c *gin.Context) {
helper.BadRequest(c, err)
return
}
res, err := runtimeService.Get(id)
res, err := runtimeService.Get(id, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -529,7 +530,7 @@ func (b *BaseApi) GetPHPContainerConfig(c *gin.Context) {
helper.BadRequest(c, err)
return
}
data, err := runtimeService.GetPHPContainerConfig(id)
data, err := runtimeService.GetPHPContainerConfig(id, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -191,7 +191,7 @@ func (b *BaseApi) LoadBaseDir(c *gin.Context) {
// @Security Timestamp
// @Router /settings/ssh/conn [get]
func (b *BaseApi) LoadLocalConn(c *gin.Context) {
helper.SuccessWithData(c, settingService.GetLocalConn())
helper.SuccessWithData(c, settingService.GetLocalConn(helper.IsDemoRequest(c)))
}
// @Tags System Setting
+1 -1
View File
@@ -144,7 +144,7 @@ func (b *BaseApi) SearchRootCert(c *gin.Context) {
return
}
total, data, err := sshService.SearchRootCerts(req)
total, data, err := sshService.SearchRootCerts(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -19,7 +19,7 @@ func (b *BaseApi) PageTensorRTLLMs(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
list := tensorrtLLMService.Page(req)
list := tensorrtLLMService.Page(req, helper.IsDemoRequest(c))
helper.SuccessWithData(c, list)
}
+1 -1
View File
@@ -98,7 +98,7 @@ func prepareTerminalSession(c *gin.Context) (*websocket.Conn, int, int, bool) {
return nil, 0, 0, false
}
if global.CONF.Base.IsDemo {
if helper.IsDemoRequest(c) {
if wshandleError(wsConn, errors.New(" demo server, prohibit this operation!")) {
return nil, 0, 0, false
}
+2 -2
View File
@@ -255,7 +255,7 @@ func (b *BaseApi) GetHTTPSConfig(c *gin.Context) {
helper.BadRequest(c, err)
return
}
res, err := websiteService.GetWebsiteHTTPS(id)
res, err := websiteService.GetWebsiteHTTPS(id, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -1080,7 +1080,7 @@ func (b *BaseApi) GetWebsiteResource(c *gin.Context) {
helper.BadRequest(c, err)
return
}
res, err := websiteService.GetWebsiteResource(id)
res, err := websiteService.GetWebsiteResource(id, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -20,7 +20,7 @@ func (b *BaseApi) PageWebsiteAcmeAccount(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, accounts, err := websiteAcmeAccountService.Page(req)
total, accounts, err := websiteAcmeAccountService.Page(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+2 -2
View File
@@ -24,7 +24,7 @@ func (b *BaseApi) PageWebsiteCA(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, cas, err := websiteCAService.Page(req)
total, cas, err := websiteCAService.Page(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -70,7 +70,7 @@ func (b *BaseApi) GetWebsiteCA(c *gin.Context) {
if err != nil {
return
}
res, err := websiteCAService.GetCA(id)
res, err := websiteCAService.GetCA(id, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -20,7 +20,7 @@ func (b *BaseApi) PageWebsiteDnsAccount(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, accounts, err := websiteDnsAccountService.Page(req)
total, accounts, err := websiteDnsAccountService.Page(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+3 -3
View File
@@ -28,7 +28,7 @@ func (b *BaseApi) PageWebsiteSSL(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, accounts, err := websiteSSLService.Page(req)
total, accounts, err := websiteSSLService.Page(req, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -159,7 +159,7 @@ func (b *BaseApi) GetWebsiteSSLByWebsiteId(c *gin.Context) {
helper.BadRequest(c, err)
return
}
websiteSSL, err := websiteSSLService.GetWebsiteSSL(websiteId)
websiteSSL, err := websiteSSLService.GetWebsiteSSL(websiteId, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
@@ -181,7 +181,7 @@ func (b *BaseApi) GetWebsiteSSLById(c *gin.Context) {
helper.BadRequest(c, err)
return
}
websiteSSL, err := websiteSSLService.GetSSL(id)
websiteSSL, err := websiteSSLService.GetSSL(id, helper.IsDemoRequest(c))
if err != nil {
helper.InternalServer(c, err)
return
+259
View File
@@ -0,0 +1,259 @@
package v2
import (
"io"
"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 Website Template
// @Summary Page website templates
// @Accept json
// @Param request body request.WebsiteTemplateSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/search [post]
func (b *BaseApi) PageWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, templates, err := websiteTemplateService.PageTemplate(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: templates,
})
}
// @Tags Website Template
// @Summary Create website template
// @Accept json
// @Param request body request.WebsiteTemplateCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建网站模板 [name]","formatEN":"Create website template [name]"}
func (b *BaseApi) CreateWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.CreateTemplate(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Update website template
// @Accept json
// @Param request body request.WebsiteTemplateUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/update [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新网站模板 [name]","formatEN":"Update website template [name]"}
func (b *BaseApi) UpdateWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.UpdateTemplate(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Delete website template
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_templates","output_column":"name","output_value":"name"}],"formatZH":"删除网站模板 [name]","formatEN":"Delete website template [name]"}
func (b *BaseApi) DeleteWebsiteTemplate(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.DeleteTemplate(req.ID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Get website template
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200 {object} response.WebsiteTemplateDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/get [post]
func (b *BaseApi) GetWebsiteTemplate(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
template, err := websiteTemplateService.GetTemplate(req.ID)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, template)
}
// @Tags Website Template
// @Summary Upload website template zip
// @Accept multipart/form-data
// @Param file formData file true "file"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/upload [post]
func (b *BaseApi) UploadTemplateZip(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
helper.BadRequest(c, err)
return
}
file, err := fileHeader.Open()
if err != nil {
helper.InternalServer(c, err)
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
helper.InternalServer(c, err)
return
}
filePath, variables, err := websiteTemplateService.SaveUploadZip(fileHeader.Filename, content)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, gin.H{"filePath": filePath, "variables": variables})
}
// @Tags Website Template
// @Summary Preview website template
// @Accept json
// @Param request body request.WebsitePreviewReq true "request"
// @Success 200 {object} response.WebsitePreviewDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/preview [post]
func (b *BaseApi) PreviewWebsiteTemplate(c *gin.Context) {
var req request.WebsitePreviewReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
preview, err := websiteTemplateService.Preview(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, preview)
}
// @Tags Website Template
// @Summary Page website template outputs
// @Accept json
// @Param request body request.WebsiteTemplateOutputSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/search [post]
func (b *BaseApi) PageWebsiteTemplateOutput(c *gin.Context) {
var req request.WebsiteTemplateOutputSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, outputs, err := websiteTemplateService.PageOutput(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: outputs,
})
}
// @Tags Website Template
// @Summary Create website template output
// @Accept json
// @Param request body request.WebsiteTemplateOutputCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成模板产物 [name]","formatEN":"Generate template output [name]"}
func (b *BaseApi) CreateWebsiteTemplateOutput(c *gin.Context) {
var req request.WebsiteTemplateOutputCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.CreateOutput(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Delete website template output
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_template_outputs","output_column":"name","output_value":"name"}],"formatZH":"删除模板产物 [name]","formatEN":"Delete template output [name]"}
func (b *BaseApi) DeleteWebsiteTemplateOutput(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.DeleteOutput(req.ID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Get website template output
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200 {object} response.WebsiteTemplateOutputDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/get [post]
func (b *BaseApi) GetWebsiteTemplateOutput(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
output, err := websiteTemplateService.GetOutput(req.ID)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, output)
}
+79 -27
View File
@@ -322,29 +322,31 @@ type AgentAccountModelDeleteReq struct {
}
type AgentAccountCreateReq struct {
Provider string `json:"provider" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
Models []AgentAccountModel `json:"models"`
APIType string `json:"apiType" validate:"required"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
Remark string `json:"remark"`
Provider string `json:"provider" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
Models []AgentAccountModel `json:"models"`
APIType string `json:"apiType" validate:"required"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
ValidateAvailability *bool `json:"validateAvailability"`
Remark string `json:"remark"`
}
type AgentAccountUpdateReq struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
APIType string `json:"apiType" validate:"required"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
APIType string `json:"apiType" validate:"required"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
ValidateAvailability *bool `json:"validateAvailability"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
}
type AgentAccountVerifyReq struct {
@@ -363,6 +365,8 @@ type AgentAccountDeleteReq struct {
type AgentAccountSearch struct {
PageInfo
Provider string `json:"provider"`
APIType string `json:"apiType"`
TextOnly bool `json:"textOnly"`
Name string `json:"name"`
}
@@ -394,11 +398,13 @@ type ProviderModelInfo struct {
}
type ProviderAPIInfo struct {
APIType string `json:"apiType"`
BaseURL string `json:"baseUrl"`
EditableBaseURL bool `json:"editableBaseUrl"`
DefaultAuthMode string `json:"defaultAuthMode"`
AuthModes []string `json:"authModes"`
APIType string `json:"apiType"`
BaseURL string `json:"baseUrl"`
EditableBaseURL bool `json:"editableBaseUrl"`
SupportsModelDiscovery bool `json:"supportsModelDiscovery"`
DefaultAuthMode string `json:"defaultAuthMode"`
AuthModes []string `json:"authModes"`
Models []ProviderModelInfo `json:"models"`
}
type ProviderInfo struct {
@@ -598,6 +604,52 @@ type AgentPluginStatus struct {
Upgradable bool `json:"upgradable"`
}
type AgentPluginsReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentPluginSearchReq struct {
AgentID uint `json:"agentId" validate:"required"`
Keyword string `json:"keyword" validate:"required,max=100"`
Limit int `json:"limit" validate:"omitempty,min=1,max=100"`
}
type AgentPluginMarketInstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Package string `json:"package" validate:"required,max=200"`
Version string `json:"version" validate:"required,max=100"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginOperateReq struct {
AgentID uint `json:"agentId" validate:"required"`
PluginID string `json:"pluginId" validate:"required,max=200"`
Operate string `json:"operate" validate:"required,oneof=enable disable update uninstall"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginItem struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Origin string `json:"origin"`
Enabled bool `json:"enabled"`
}
type AgentPluginSearchItem struct {
Package string `json:"package"`
PluginID string `json:"pluginId"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Channel string `json:"channel"`
VerificationTier string `json:"verificationTier"`
Categories []string `json:"categories"`
Official bool `json:"official"`
Downloads int64 `json:"downloads"`
Score float64 `json:"score"`
}
type AgentDiscordConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
@@ -678,9 +730,9 @@ type AgentSecurityConfig struct {
type AgentOtherConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
UserTimezone string `json:"userTimezone" validate:"required"`
UserTimezone string `json:"userTimezone"`
BrowserEnabled bool `json:"browserEnabled"`
NPMRegistry string `json:"npmRegistry" validate:"required"`
NPMRegistry string `json:"npmRegistry"`
DashboardUsername string `json:"dashboardUsername"`
DashboardPassword string `json:"dashboardPassword"`
}
+4 -2
View File
@@ -113,8 +113,10 @@ type DiskDTO struct {
type AlertLogSearch struct {
PageInfo
Count uint `json:"count"`
Status string `json:"status"`
Count uint `json:"count"`
Status string `json:"status"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
}
type AlertLogDTO struct {
+2 -1
View File
@@ -2,7 +2,8 @@ package dto
type SearchWithPage struct {
PageInfo
Info string `json:"info"`
Info string `json:"info"`
ExcludeAppStore bool `json:"excludeAppStore"`
}
type SearchPageWithType struct {
+6
View File
@@ -300,6 +300,7 @@ type ComposeInfo struct {
ConfigFile string `json:"configFile"`
Workdir string `json:"workdir"`
ComposeFileExists bool `json:"composeFileExists"`
IsPinned bool `json:"isPinned"`
Path string `json:"path"`
Containers []ComposeContainer `json:"containers"`
Env string `json:"env"`
@@ -314,6 +315,7 @@ type ComposeContainer struct {
type ComposeCreate struct {
TaskID string `json:"taskID"`
Name string `json:"name"`
DirName string `json:"dirName"`
From string `json:"from" validate:"required,oneof=edit path template"`
File string `json:"file"`
Path string `json:"path"`
@@ -337,6 +339,10 @@ type ComposeUpdate struct {
Env string `json:"env"`
ForcePull bool `json:"forcePull"`
}
type ComposePin struct {
Name string `json:"name" validate:"required"`
IsPinned bool `json:"isPinned"`
}
type ComposeLogClean struct {
Name string `json:"name" validate:"required"`
Path string `json:"path" validate:"required"`
+1
View File
@@ -197,6 +197,7 @@ type SearchRecord struct {
type Record struct {
ID uint `json:"id"`
CronjobID uint `json:"cronjobID"`
TaskID string `json:"taskID"`
StartTime string `json:"startTime"`
Records string `json:"records"`
+1
View File
@@ -156,6 +156,7 @@ type DiskInfo struct {
}
type GPUInfo struct {
Type string `json:"type"`
Index uint `json:"index"`
ProductName string `json:"productName"`
GPUUtil string `json:"gpuUtil"`
+1 -14
View File
@@ -35,19 +35,6 @@ type PortRuleOperate struct {
Description string `json:"description"`
}
type ForwardRuleOperate struct {
ForceDelete bool `json:"forceDelete"`
Rules []struct {
Operation string `json:"operation" validate:"required,oneof=add remove"`
Num string `json:"num"`
Protocol string `json:"protocol" validate:"required,oneof=tcp udp tcp/udp"`
Interface string `json:"interface"`
Port string `json:"port" validate:"required"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort" validate:"required"`
} `json:"rules"`
}
type UpdateFirewallDescription struct {
Type string `json:"type"`
Chain string `json:"chain"`
@@ -86,7 +73,7 @@ type BatchRuleOperate struct {
}
type IptablesOp struct {
Name string `json:"name" validate:"required,oneof=1PANEL_INPUT 1PANEL_OUTPUT 1PANEL_BASIC"`
Name string `json:"name" validate:"required,oneof=1PANEL_INPUT 1PANEL_OUTPUT 1PANEL_BASIC 1PANEL_FORWARD"`
Operate string `json:"operate" validate:"required,oneof=init-base init-forward init-advance bind-base unbind-base bind unbind"`
}
+43
View File
@@ -0,0 +1,43 @@
package dto
type ForwardRuleSearch struct {
PageInfo
Info string `json:"info"`
Status string `json:"status"`
Strategy string `json:"strategy"`
}
// ForwardRule preserves the existing firewall search response shape while
// keeping forwarding data separate from the filter client model.
type ForwardRule struct {
ID uint `json:"id"`
Chain string `json:"chain"`
Family string `json:"family"`
Address string `json:"address"`
Port string `json:"port"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Num string `json:"num"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort"`
Interface string `json:"interface"`
UsedStatus string `json:"usedStatus"`
Description string `json:"description"`
}
type ForwardRuleOperate struct {
ForceDelete bool `json:"forceDelete"`
Rules []ForwardRuleOperation `json:"rules"`
}
type ForwardRuleOperation struct {
Operation string `json:"operation" validate:"required,oneof=add remove"`
Num string `json:"num"`
Protocol string `json:"protocol" validate:"required,oneof=tcp udp tcp/udp"`
Interface string `json:"interface"`
Port string `json:"port" validate:"required"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort" validate:"required"`
}
+1
View File
@@ -49,6 +49,7 @@ type GPUChartHide struct {
GPU bool `json:"gpu"`
Memory bool `json:"memory"`
Power bool `json:"power"`
PowerLimit bool `json:"powerLimit"`
Temperature bool `json:"temperature"`
Speed bool `json:"speed"`
}
+4 -2
View File
@@ -62,11 +62,13 @@ type AppInstalledSearch struct {
All bool `json:"all"`
Sync bool `json:"sync"`
CheckUpdate bool `json:"checkUpdate"`
ReadOnly bool `json:"-"`
}
type AppInstalledInfo struct {
Key string `json:"key" validate:"required"`
Name string `json:"name"`
Key string `json:"key" validate:"required"`
Name string `json:"name"`
ReadOnly bool `json:"-"`
}
type AppBackupSearch struct {
+5
View File
@@ -131,6 +131,11 @@ type FileMove struct {
Name string `json:"name"`
Cover bool `json:"cover"`
CoverPaths []string `json:"coverPaths"`
TaskID string `json:"taskID"`
}
type FileMoveStopReq struct {
TaskID string `json:"taskID" validate:"required"`
}
type FileDownload struct {
+4 -3
View File
@@ -6,9 +6,10 @@ import (
type RuntimeSearch struct {
dto.PageInfo
Type string `json:"type"`
Name string `json:"name"`
Status string `json:"status"`
Type string `json:"type"`
Name string `json:"name"`
Status string `json:"status"`
ReadOnly bool `json:"-"`
}
type RuntimeCreate struct {
+2
View File
@@ -34,6 +34,8 @@ type WebsiteCreate struct {
SiteDir string `json:"siteDir"`
TemplateOutputID uint `json:"templateOutputID"`
RuntimeConfig
FtpConfig
DataBaseConfig
+46
View File
@@ -0,0 +1,46 @@
package request
import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
)
type WebsiteTemplateSearch struct {
dto.PageInfo
Name string `json:"name"`
Type string `json:"type"`
}
type WebsiteTemplateCreate struct {
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required,oneof=single multi"`
Content string `json:"content"`
FilePath string `json:"filePath"`
Variables string `json:"variables"`
Remark string `json:"remark"`
}
type WebsiteTemplateUpdate struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required,oneof=single multi"`
Content string `json:"content"`
FilePath string `json:"filePath"`
Variables string `json:"variables"`
Remark string `json:"remark"`
}
type WebsiteTemplateOutputSearch struct {
dto.PageInfo
TemplateID uint `json:"templateID"`
}
type WebsiteTemplateOutputCreate struct {
TemplateID uint `json:"templateID" validate:"required"`
Name string `json:"name" validate:"required"`
VariableValues map[string]string `json:"variableValues"`
}
type WebsitePreviewReq struct {
TemplateID uint `json:"templateID" validate:"required"`
VariableValues map[string]string `json:"variableValues"`
}
@@ -0,0 +1,18 @@
package response
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
)
type WebsiteTemplateDTO struct {
model.WebsiteTemplate
}
type WebsiteTemplateOutputDTO struct {
model.WebsiteTemplateOutput
TemplateName string `json:"templateName"`
}
type WebsitePreviewDTO struct {
HTML string `json:"html"`
}
+1
View File
@@ -31,4 +31,5 @@ type BackupRecord struct {
Status string `json:"status"`
Message string `json:"message"`
Description string `json:"description"`
Args string `gorm:"not null;default:''" json:"args"`
}
+3 -2
View File
@@ -11,6 +11,7 @@ type ComposeTemplate struct {
type Compose struct {
BaseModel
Name string `json:"name"`
Path string `json:"path"`
Name string `json:"name"`
Path string `json:"path"`
IsPinned bool `json:"isPinned"`
}
+2
View File
@@ -8,4 +8,6 @@ type Ftp struct {
Status string `gorm:"not null" json:"status"`
Path string `gorm:"not null" json:"path"`
Description string `gorm:"not null" json:"description"`
UID uint `gorm:"column:uid;not null;default:1000" json:"-"`
GID uint `gorm:"column:gid;not null;default:1000" json:"-"`
}
+28
View File
@@ -0,0 +1,28 @@
package model
type WebsiteTemplate struct {
BaseModel
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // single | multi
Content string `gorm:"type:longtext" json:"content"`
FilePath string `json:"filePath"`
Variables string `gorm:"type:text" json:"variables"`
Remark string `json:"remark"`
}
func (w WebsiteTemplate) TableName() string {
return "website_templates"
}
type WebsiteTemplateOutput struct {
BaseModel
Name string `gorm:"not null" json:"name"`
TemplateID uint `gorm:"not null" json:"templateID"`
TemplateType string `json:"templateType"`
VariableValues string `gorm:"type:text" json:"variableValues"`
OutputPath string `json:"outputPath"`
}
func (w WebsiteTemplateOutput) TableName() string {
return "website_template_outputs"
}
+143 -13
View File
@@ -10,8 +10,10 @@ type APIConfig struct {
APIType string
BaseURL string
EditableBaseURL bool
DiscoverModels bool
DefaultAuthMode string
AuthModes []string
Models []Model
}
const (
@@ -27,6 +29,7 @@ type Model struct {
type Meta struct {
Key string
DisplayName string
DisplayNameKey string
Sort uint
DefaultAPIType string
APIConfigs []APIConfig
@@ -37,26 +40,27 @@ type Meta struct {
var catalog = map[string]Meta{
"custom": {
Key: "custom", DisplayName: "Custom", Sort: 10, DefaultAPIType: "openai-completions", EnvKey: "CUSTOM_API_KEY",
APIConfigs: editableAPIConfigs("openai-completions", "openai-responses", "anthropic-messages"),
APIConfigs: editableAPIConfigs(true, "openai-completions", "openai-responses", "anthropic-messages", "openai-images", "openai-embeddings"),
},
"ollama": {
Key: "ollama", DisplayName: "Ollama", Sort: 15, DefaultAPIType: "openai-responses",
APIConfigs: editableAPIConfigs("openai-responses", "openai-completions"),
APIConfigs: editableAPIConfigs(false, "openai-responses", "openai-completions", "openai-embeddings"),
},
"vllm": {
Key: "vllm", DisplayName: "vLLM", Sort: 20, DefaultAPIType: "openai-completions", EnvKey: "VLLM_API_KEY",
APIConfigs: editableAPIConfigs("openai-completions", "openai-responses", "anthropic-messages"),
APIConfigs: editableAPIConfigs(false, "openai-completions", "openai-responses", "anthropic-messages", "openai-images", "openai-embeddings"),
},
"deepseek": {
Key: "deepseek", DisplayName: "DeepSeek", Sort: 25, DefaultAPIType: "openai-completions", EnvKey: "DEEPSEEK_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://api.deepseek.com/v1"},
{APIType: "openai-completions", BaseURL: "https://api.deepseek.com"},
{APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
},
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
},
"bailian-coding-plan": {
Key: "bailian-coding-plan", DisplayName: "阿里云百炼 Coding Plan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://coding.dashscope.aliyuncs.com/v1"},
anthropicAPIConfig("https://coding.dashscope.aliyuncs.com/apps/anthropic", AuthModeBearer),
@@ -75,7 +79,7 @@ var catalog = map[string]Meta{
},
},
"ark-coding-plan": {
Key: "ark-coding-plan", DisplayName: "方舟 Coding Plan", Sort: 35, DefaultAPIType: "openai-completions", EnvKey: "ARK_API_KEY",
Key: "ark-coding-plan", DisplayNameKey: "AIProviderArkCodingPlan", Sort: 35, DefaultAPIType: "openai-completions", EnvKey: "ARK_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3"},
anthropicAPIConfig("https://ark.cn-beijing.volces.com/api/coding", AuthModeBearer),
@@ -88,14 +92,18 @@ var catalog = map[string]Meta{
},
"zai": {
Key: "zai", DisplayName: "Z.ai", Sort: 40, DefaultAPIType: "openai-completions", EnvKey: "ZAI_API_KEY",
APIConfigs: []APIConfig{{APIType: "openai-completions", BaseURL: "https://open.bigmodel.cn/api/paas/v4", EditableBaseURL: true}},
Models: []Model{{ID: "glm-5", Name: "GLM-5"}, {ID: "glm-4.7", Name: "GLM-4.7"}, {ID: "glm-4.7-flash", Name: "GLM-4.7-Flash"}, {ID: "glm-4.7-flashx", Name: "GLM-4.7-FlashX"}},
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://open.bigmodel.cn/api/paas/v4", EditableBaseURL: true},
{APIType: "openai-images", BaseURL: "https://open.bigmodel.cn/api/paas/v4", EditableBaseURL: true},
},
Models: []Model{{ID: "glm-5", Name: "GLM-5"}, {ID: "glm-4.7", Name: "GLM-4.7"}, {ID: "glm-4.7-flash", Name: "GLM-4.7-Flash"}, {ID: "glm-4.7-flashx", Name: "GLM-4.7-FlashX"}},
},
"minimax": {
Key: "minimax", DisplayName: "MiniMax (CN)", Sort: 45, DefaultAPIType: "anthropic-messages", EnvKey: "MINIMAX_API_KEY",
APIConfigs: []APIConfig{
anthropicAPIConfig("https://api.minimaxi.com/anthropic", AuthModeXAPIKey, AuthModeBearer),
{APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
{APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
},
Models: []Model{{ID: "MiniMax-M3", Name: "MiniMax M3"}, {ID: "MiniMax-M2.7", Name: "MiniMax M2.7"}, {ID: "MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"}},
},
@@ -103,9 +111,10 @@ var catalog = map[string]Meta{
Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://api.xiaomimimo.com/v1"},
{APIType: "openai-responses", BaseURL: "https://api.xiaomimimo.com/v1"},
anthropicAPIConfig("https://api.xiaomimimo.com/anthropic", AuthModeBearer),
},
Models: []Model{{ID: "mimo-v2-flash", Name: "Xiaomi MiMo V2 Flash"}, {ID: "mimo-v2-pro", Name: "Xiaomi MiMo V2 Pro"}, {ID: "mimo-v2-omni", Name: "Xiaomi MiMo V2 Omni"}},
Models: []Model{{ID: "mimo-v2.5", Name: "Xiaomi MiMo V2.5"}, {ID: "mimo-v2.5-pro", Name: "Xiaomi MiMo V2.5 Pro"}},
},
"kimi": {
Key: "kimi", DisplayName: "Kimi (CN)", Sort: 50, DefaultAPIType: "openai-completions", EnvKey: "KIMI_API_KEY",
@@ -122,13 +131,21 @@ var catalog = map[string]Meta{
APIConfigs: []APIConfig{
{APIType: "openai-responses", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-completions", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-images", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-embeddings", BaseURL: "https://api.openai.com/v1", Models: []Model{
{ID: "text-embedding-3-small", Name: "text-embedding-3-small"},
{ID: "text-embedding-3-large", Name: "text-embedding-3-large"},
}},
},
Models: []Model{{ID: "gpt-5.4", Name: "gpt-5.4"}, {ID: "gpt-5.4-pro", Name: "gpt-5.4-pro"}, {ID: "gpt-5.4-mini", Name: "gpt-5.4-mini"}, {ID: "gpt-5.4-nano", Name: "gpt-5.4-nano"}},
},
"openrouter": {
Key: "openrouter", DisplayName: "OpenRouter", Sort: 56, DefaultAPIType: "openai-completions", EnvKey: "OPENROUTER_API_KEY",
APIConfigs: []APIConfig{{APIType: "openai-completions", BaseURL: "https://openrouter.ai/api/v1"}},
Models: []Model{{ID: "openrouter/free", Name: "openrouter/free"}, {ID: "openrouter/auto", Name: "openrouter/auto"}},
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://openrouter.ai/api/v1"},
{APIType: "openrouter-images", BaseURL: "https://openrouter.ai"},
},
Models: []Model{{ID: "openrouter/free", Name: "openrouter/free"}, {ID: "openrouter/auto", Name: "openrouter/auto"}},
},
"anthropic": {
Key: "anthropic", DisplayName: "Anthropic", Sort: 60, DefaultAPIType: "anthropic-messages", EnvKey: "ANTHROPIC_API_KEY",
@@ -145,9 +162,62 @@ var catalog = map[string]Meta{
APIConfigs: []APIConfig{{APIType: "openai-completions", BaseURL: "https://api.moonshot.ai/v1"}},
Models: []Model{{ID: "kimi-k2.5", Name: "Kimi K2.5"}, {ID: "kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, {ID: "kimi-k2-thinking", Name: "Kimi K2 Thinking"}},
},
"bailian": {
Key: "bailian", DisplayNameKey: "AIProviderBailian", Sort: 31, DefaultAPIType: "openai-completions", EnvKey: "DASHSCOPE_API_KEY",
APIConfigs: []APIConfig{
{
APIType: "openai-completions", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
DiscoverModels: true,
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
},
{
APIType: "openai-responses", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
DiscoverModels: true,
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
},
{
APIType: "anthropic-messages", BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
DefaultAuthMode: AuthModeBearer,
AuthModes: []string{AuthModeBearer},
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
},
{
APIType: "dashscope-images", BaseURL: "https://dashscope.aliyuncs.com",
Models: []Model{
{ID: "qwen-image-2.0-pro", Name: "qwen-image-2.0-pro"},
{ID: "qwen-image-2.0", Name: "qwen-image-2.0"},
{ID: "wan2.7-image-pro", Name: "wan2.7-image-pro"},
{ID: "wan2.7-image", Name: "wan2.7-image"},
},
},
},
},
"ark": {
Key: "ark", DisplayNameKey: "AIProviderArk", Sort: 36, DefaultAPIType: "openai-completions", EnvKey: "ARK_API_KEY",
APIConfigs: []APIConfig{
{
APIType: "openai-completions", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
DiscoverModels: true,
Models: []Model{{ID: "doubao-seed-2-0-pro-260215", Name: "doubao-seed-2-0-pro-260215"}, {ID: "doubao-seed-2-0-lite-260215", Name: "doubao-seed-2-0-lite-260215"}},
},
{
APIType: "openai-responses", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
DiscoverModels: true,
Models: []Model{{ID: "doubao-seed-2-0-pro-260215", Name: "doubao-seed-2-0-pro-260215"}, {ID: "doubao-seed-2-0-lite-260215", Name: "doubao-seed-2-0-lite-260215"}},
},
{
APIType: "openai-images", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
Models: []Model{
{ID: "doubao-seedream-5-0-260128", Name: "doubao-seedream-5-0-260128"},
{ID: "doubao-seedream-5-0-lite-260128", Name: "doubao-seedream-5-0-lite-260128"},
{ID: "doubao-seedream-4-5-251128", Name: "doubao-seedream-4-5-251128"},
},
},
},
},
}
func editableAPIConfigs(apiTypes ...string) []APIConfig {
func editableAPIConfigs(discoverModels bool, apiTypes ...string) []APIConfig {
configs := make([]APIConfig, 0, len(apiTypes))
for _, apiType := range apiTypes {
if apiType == "anthropic-messages" {
@@ -156,7 +226,11 @@ func editableAPIConfigs(apiTypes ...string) []APIConfig {
configs = append(configs, config)
continue
}
configs = append(configs, APIConfig{APIType: apiType, EditableBaseURL: true})
configs = append(configs, APIConfig{
APIType: apiType,
EditableBaseURL: true,
DiscoverModels: discoverModels && (apiType == "openai-completions" || apiType == "openai-responses"),
})
}
return configs
}
@@ -203,12 +277,38 @@ func FindAPIConfig(key, apiType string) (APIConfig, bool) {
}
for _, config := range meta.APIConfigs {
if config.APIType == target {
config.AuthModes = append([]string(nil), config.AuthModes...)
config.Models = append([]Model(nil), config.Models...)
return config, true
}
}
return APIConfig{}, false
}
func DefaultModels(key, apiType string) []Model {
meta, ok := catalog[key]
if !ok {
return nil
}
target := strings.TrimSpace(apiType)
if target == "" {
target = meta.DefaultAPIType
}
for _, config := range meta.APIConfigs {
if config.APIType != target {
continue
}
if len(config.Models) > 0 {
return append([]Model(nil), config.Models...)
}
if IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType) {
return nil
}
break
}
return append([]Model(nil), meta.Models...)
}
func ResolveAuthMode(provider, apiType, requested string) (string, error) {
config, ok := FindAPIConfig(provider, apiType)
if !ok {
@@ -264,6 +364,9 @@ func ResolveBaseURL(key, apiType, requested string) (string, error) {
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid base url")
}
if key == "custom" && (IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType)) {
return baseURL, nil
}
parsed.Path = normalizeEndpointPath(config.APIType, parsed.Path)
parsed.RawQuery = ""
parsed.Fragment = ""
@@ -271,6 +374,9 @@ func ResolveBaseURL(key, apiType, requested string) (string, error) {
}
func normalizeEndpointPath(apiType, value string) string {
if IsImageAPIType(apiType) {
return strings.TrimRight(value, "/")
}
path := strings.TrimRight(value, "/")
suffixes := []string{}
switch apiType {
@@ -280,6 +386,8 @@ func normalizeEndpointPath(apiType, value string) string {
suffixes = []string{"/responses"}
case "anthropic-messages":
suffixes = []string{"/v1/messages", "/messages"}
case "openai-embeddings":
suffixes = []string{"/v1/embeddings", "/embeddings"}
}
for _, suffix := range suffixes {
if strings.HasSuffix(strings.ToLower(path), suffix) {
@@ -289,6 +397,19 @@ func normalizeEndpointPath(apiType, value string) string {
return path
}
func IsEmbeddingAPIType(apiType string) bool {
return apiType == "openai-embeddings"
}
func IsImageAPIType(apiType string) bool {
switch apiType {
case "openai-images", "dashscope-images", "minimax-images", "openrouter-images":
return true
default:
return false
}
}
func EnvKey(key string) string {
meta, ok := catalog[key]
if !ok {
@@ -305,6 +426,14 @@ func DisplayName(key string) string {
return meta.DisplayName
}
func DisplayNameKey(key string) string {
meta, ok := catalog[key]
if !ok {
return ""
}
return meta.DisplayNameKey
}
func NormalizeModelID(provider, modelID string) string {
target := strings.TrimLeft(strings.TrimSpace(modelID), "/")
for _, prefix := range legacyModelPrefixes[provider] {
@@ -344,6 +473,7 @@ func cloneMeta(meta Meta) Meta {
for index, config := range meta.APIConfigs {
clone.APIConfigs[index] = config
clone.APIConfigs[index].AuthModes = append([]string(nil), config.AuthModes...)
clone.APIConfigs[index].Models = append([]Model(nil), config.Models...)
}
clone.Models = append([]Model(nil), meta.Models...)
return clone
+12 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
)
@@ -54,11 +55,20 @@ func buildModelDiscoveryURL(baseURL string) string {
base = normalizeEndpointPath(apiType, base)
}
switch {
case strings.HasSuffix(base, "/v1/models"):
case strings.HasSuffix(base, "/models"):
return base
case strings.HasSuffix(base, "/v1"):
case hasAPIVersionSuffix(base):
return base + "/models"
default:
return base + "/v1/models"
}
}
func hasAPIVersionSuffix(value string) bool {
segment := value[strings.LastIndex(value, "/")+1:]
if len(segment) < 2 || segment[0] != 'v' {
return false
}
_, err := strconv.Atoi(segment[1:])
return err == nil
}
+3
View File
@@ -23,6 +23,9 @@ func BuildOpenClawProviderPatch(provider, modelName, apiType, authMode, baseURL,
if _, ok := FindAPIConfig(provider, resolvedAPIType); !ok {
resolvedAPIType = DefaultAPIType(provider)
}
if IsImageAPIType(resolvedAPIType) || IsEmbeddingAPIType(resolvedAPIType) {
return nil, fmt.Errorf("api type %s does not support text generation", resolvedAPIType)
}
resolvedAuthMode, err := ResolveAuthMode(provider, resolvedAPIType, authMode)
if err != nil {
return nil, err
+48 -1
View File
@@ -64,7 +64,10 @@ func VerifyAccount(provider, apiType, authMode, baseURL, apiKey, model string) e
}
func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model string) VerifyRequest {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
baseURL = strings.TrimSpace(baseURL)
if provider != "custom" || !IsImageAPIType(apiType) {
baseURL = strings.TrimRight(baseURL, "/")
}
headers := map[string]string{"Content-Type": "application/json"}
request := VerifyRequest{Method: http.MethodPost, Headers: headers}
@@ -78,6 +81,32 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
}
switch apiType {
case "openai-embeddings":
request.URL = embeddingVerifyURL(baseURL)
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "input": "ping"})
case "openai-images":
request.URL = imageVerifyURL(provider, baseURL, "/images/generations")
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
case "dashscope-images":
request.URL = imageVerifyURL(provider, baseURL, "/api/v1/services/aigc/multimodal-generation/generation")
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{
"model": model,
"input": map[string]interface{}{"messages": []map[string]interface{}{
{"role": "user", "content": []map[string]string{{"text": "test"}}},
}},
"parameters": map[string]interface{}{"n": 1},
})
case "minimax-images":
request.URL = imageVerifyURL(provider, baseURL, "/v1/image_generation")
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
case "openrouter-images":
request.URL = imageVerifyURL(provider, baseURL, "/api/v1/images")
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
case "anthropic-messages":
request.URL = baseURL + "/v1/messages"
if authMode == AuthModeBearer {
@@ -106,6 +135,24 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
return request
}
func embeddingVerifyURL(baseURL string) string {
lowerBaseURL := strings.ToLower(baseURL)
if strings.HasSuffix(lowerBaseURL, "/embeddings") {
return baseURL
}
if strings.HasSuffix(lowerBaseURL, "/v1") {
return baseURL + "/embeddings"
}
return baseURL + "/v1/embeddings"
}
func imageVerifyURL(provider, baseURL, endpoint string) string {
if provider == "custom" || strings.HasSuffix(strings.ToLower(baseURL), endpoint) {
return baseURL
}
return baseURL + endpoint
}
func verifyHTTPError(statusCode int, body []byte) string {
message := strings.TrimSpace(string(body))
var payload verifyErrorResponse
+3 -2
View File
@@ -17,7 +17,7 @@ type IAgentAccountRepo interface {
Save(account *model.AgentAccount) error
DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error)
CountByProviders(providers []string) (map[string]int64, error)
CountTextByProviders(providers []string) (map[string]int64, error)
}
func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -67,7 +67,7 @@ func (a AgentAccountRepo) List(opts ...DBOption) ([]model.AgentAccount, error) {
return accounts, nil
}
func (a AgentAccountRepo) CountByProviders(providers []string) (map[string]int64, error) {
func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]int64, error) {
normalizedProviders := normalizeProviders(providers)
counts := make(map[string]int64, len(normalizedProviders))
for _, provider := range normalizedProviders {
@@ -86,6 +86,7 @@ func (a AgentAccountRepo) CountByProviders(providers []string) (map[string]int64
Model(&model.AgentAccount{}).
Select("provider, COUNT(*) as count").
Where("provider IN ?", normalizedProviders).
Scopes(WithTextAPIType()).
Group("provider").
Scan(&rows).Error; err != nil {
return nil, err
+18
View File
@@ -49,6 +49,12 @@ func WithByName(name string) DBOption {
}
}
func WithByPath(path string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("path = ?", path)
}
}
func WithByAddr(addr string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("addr = ?", addr)
@@ -94,6 +100,18 @@ func WithByProvider(provider string) DBOption {
}
}
func WithByAPIType(apiType string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("api_type = ?", apiType)
}
}
func WithTextAPIType() DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("api_type NOT LIKE ? AND api_type <> ?", "%-images", "openai-embeddings")
}
}
func WithByModel(model string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(model) == 0 {
+3
View File
@@ -73,6 +73,9 @@ func (u *MonitorRepo) CreateMonitorBase(model model.MonitorBase) error {
return global.MonitorDB.Create(&model).Error
}
func (s *MonitorRepo) BatchCreateMonitorGPU(list []model.MonitorGPU) error {
if len(list) == 0 {
return nil
}
return global.GPUMonitorDB.CreateInBatches(&list, len(list)).Error
}
func (u *MonitorRepo) BatchCreateMonitorIO(ioList []model.MonitorIO) error {
+131
View File
@@ -0,0 +1,131 @@
package repo
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"gorm.io/gorm"
)
type IWebsiteTemplateRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplate, error)
GetFirst(opts ...DBOption) (*model.WebsiteTemplate, error)
List(opts ...DBOption) ([]model.WebsiteTemplate, error)
Create(template *model.WebsiteTemplate) error
Save(template *model.WebsiteTemplate) error
DeleteBy(opts ...DBOption) error
WithName(name string) DBOption
WithType(templateType string) DBOption
}
func NewIWebsiteTemplateRepo() IWebsiteTemplateRepo {
return &WebsiteTemplateRepo{}
}
type WebsiteTemplateRepo struct {
}
func (w *WebsiteTemplateRepo) WithName(name string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("name like ?", "%"+name+"%")
}
}
func (w *WebsiteTemplateRepo) WithType(templateType string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("type = ?", templateType)
}
}
func (w *WebsiteTemplateRepo) Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplate, error) {
var templates []model.WebsiteTemplate
db := getDb(opts...).Model(&model.WebsiteTemplate{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&templates).Error
return count, templates, err
}
func (w *WebsiteTemplateRepo) GetFirst(opts ...DBOption) (*model.WebsiteTemplate, error) {
var template model.WebsiteTemplate
db := getDb(opts...).Model(&model.WebsiteTemplate{})
if err := db.First(&template).Error; err != nil {
return nil, err
}
return &template, nil
}
func (w *WebsiteTemplateRepo) List(opts ...DBOption) ([]model.WebsiteTemplate, error) {
var templates []model.WebsiteTemplate
err := getDb(opts...).Model(&model.WebsiteTemplate{}).Find(&templates).Error
return templates, err
}
func (w *WebsiteTemplateRepo) Create(template *model.WebsiteTemplate) error {
return getDb().Create(template).Error
}
func (w *WebsiteTemplateRepo) Save(template *model.WebsiteTemplate) error {
return getDb().Save(template).Error
}
func (w *WebsiteTemplateRepo) DeleteBy(opts ...DBOption) error {
return getDb(opts...).Delete(&model.WebsiteTemplate{}).Error
}
type IWebsiteTemplateOutputRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplateOutput, error)
GetFirst(opts ...DBOption) (*model.WebsiteTemplateOutput, error)
List(opts ...DBOption) ([]model.WebsiteTemplateOutput, error)
Create(output *model.WebsiteTemplateOutput) error
Save(output *model.WebsiteTemplateOutput) error
DeleteBy(opts ...DBOption) error
WithByTemplateID(templateID uint) DBOption
}
func NewIWebsiteTemplateOutputRepo() IWebsiteTemplateOutputRepo {
return &WebsiteTemplateOutputRepo{}
}
type WebsiteTemplateOutputRepo struct {
}
func (w *WebsiteTemplateOutputRepo) WithByTemplateID(templateID uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("template_id = ?", templateID)
}
}
func (w *WebsiteTemplateOutputRepo) Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplateOutput, error) {
var outputs []model.WebsiteTemplateOutput
db := getDb(opts...).Model(&model.WebsiteTemplateOutput{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&outputs).Error
return count, outputs, err
}
func (w *WebsiteTemplateOutputRepo) GetFirst(opts ...DBOption) (*model.WebsiteTemplateOutput, error) {
var output model.WebsiteTemplateOutput
db := getDb(opts...).Model(&model.WebsiteTemplateOutput{})
if err := db.First(&output).Error; err != nil {
return nil, err
}
return &output, nil
}
func (w *WebsiteTemplateOutputRepo) List(opts ...DBOption) ([]model.WebsiteTemplateOutput, error) {
var outputs []model.WebsiteTemplateOutput
err := getDb(opts...).Model(&model.WebsiteTemplateOutput{}).Find(&outputs).Error
return outputs, err
}
func (w *WebsiteTemplateOutputRepo) Create(output *model.WebsiteTemplateOutput) error {
return getDb().Create(output).Error
}
func (w *WebsiteTemplateOutputRepo) Save(output *model.WebsiteTemplateOutput) error {
return getDb().Save(output).Error
}
func (w *WebsiteTemplateOutputRepo) DeleteBy(opts ...DBOption) error {
return getDb(opts...).Delete(&model.WebsiteTemplateOutput{}).Error
}
+83 -29
View File
@@ -38,7 +38,7 @@ type IAgentService interface {
BatchUpgrade(req dto.AgentBatchUpgradeReq) ([]dto.AgentBatchUpgradeResult, error)
BatchInstallSkill(req dto.AgentBatchSkillInstallReq) ([]dto.AgentBatchSkillInstallResult, error)
BatchOperate(req dto.AgentBatchOperateReq) ([]dto.AgentBatchOperateResult, error)
Page(req dto.SearchWithPage) (int64, []dto.AgentItem, error)
Page(req dto.SearchWithPage, readOnly bool) (int64, []dto.AgentItem, error)
DeleteCheck(req dto.AgentIDReq) ([]dto.AppResource, error)
Delete(req dto.AgentDeleteReq) error
ResetToken(req dto.AgentTokenResetReq) error
@@ -76,7 +76,7 @@ type IAgentService interface {
CreateAccount(req dto.AgentAccountCreateReq) error
UpdateAccount(req dto.AgentAccountUpdateReq) error
SyncAgentsByAccount(account *model.AgentAccount) error
PageAccounts(req dto.AgentAccountSearch) (int64, []dto.AgentAccountInfo, error)
PageAccounts(req dto.AgentAccountSearch, readOnly ...bool) (int64, []dto.AgentAccountInfo, error)
CountAccountsByProviders(req dto.AgentAccountProviderCountReq) (map[string]int64, error)
GetAccountModels(req dto.AgentAccountModelReq) ([]dto.AgentAccountModel, error)
DiscoverAccountModels(req dto.AgentAccountModelDiscoverReq) ([]dto.AgentAccountModel, error)
@@ -105,6 +105,10 @@ type IAgentService interface {
UpgradePlugin(req dto.AgentPluginUpgradeReq) error
UninstallPlugin(req dto.AgentPluginUninstallReq) error
CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error)
ListPlugins(req dto.AgentPluginsReq) ([]dto.AgentPluginItem, error)
SearchPlugins(req dto.AgentPluginSearchReq) ([]dto.AgentPluginSearchItem, error)
InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) error
OperatePlugin(req dto.AgentPluginOperateReq) error
ApproveChannelPairing(req dto.AgentChannelPairingApproveReq) error
}
@@ -177,7 +181,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
var allowedOrigins []string
var account *model.AgentAccount
var installHooks *appInstallHooks
var hermesAuth hermesDashboardAuth
var dashboardAuth agentDashboardAuth
if agentType == constant.AppOpenclaw || agentType == constant.AppHermesAgent {
if req.AccountID == 0 {
@@ -222,15 +226,17 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
},
}
} else if agentType == constant.AppHermesAgent {
hermesAuth = normalizeHermesDashboardAuth(req.DashboardUsername, req.DashboardPassword)
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
installHooks = &appInstallHooks{
AfterCopyData: func(appInstall *model.AppInstall) error {
if err := prepareHermesInstallFiles(appInstall, account, storedModel); err != nil {
return err
}
return writeHermesDashboardAuthEnv(path.Join(appInstall.GetPath(), ".env"), hermesAuth, false)
return writeAgentDashboardAuthEnv(appInstall.GetEnvPath(), agentType, dashboardAuth, false)
},
}
} else if agentType == constant.AppCopaw {
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
}
params := map[string]interface{}{
@@ -250,9 +256,12 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
params["API_KEY"] = apiKey
params["OPENCLAW_GATEWAY_TOKEN"] = token
}
if agentType == constant.AppHermesAgent {
params[hermesDashboardUsernameEnvKey] = hermesAuth.Username
params[hermesDashboardPasswordEnvKey] = hermesAuth.Password
if usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType); ok {
params[usernameKey] = dashboardAuth.Username
params[passwordKey] = dashboardAuth.Password
if agentType == constant.AppCopaw {
params[qwenPawAuthEnabledEnvKey] = "true"
}
}
if req.EditCompose && strings.TrimSpace(req.DockerCompose) == "" {
@@ -736,7 +745,7 @@ func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion st
params["PANEL_APP_PORT_HTTP"] = webUIPort
}
func (a AgentService) Page(req dto.SearchWithPage) (int64, []dto.AgentItem, error) {
func (a AgentService) Page(req dto.SearchWithPage, readOnly bool) (int64, []dto.AgentItem, error) {
var opts []repo.DBOption
if strings.TrimSpace(req.Info) != "" {
opts = append(opts, repo.WithByLikeName(req.Info))
@@ -751,11 +760,19 @@ func (a AgentService) Page(req dto.SearchWithPage) (int64, []dto.AgentItem, erro
appInstall, _ := appInstallRepo.GetFirst(repo.WithByID(item.AppInstallID))
appInstalls = append(appInstalls, appInstall)
}
syncAgentAppInstalls(appInstalls)
readOnlyMode := isDemoReadOnly(readOnly)
if !readOnlyMode {
syncAgentAppInstalls(appInstalls)
}
for index, item := range list {
appInstall := appInstalls[index]
envMap := readInstallEnv(appInstall.Env)
agentItem := buildAgentItem(&item, &appInstall, envMap)
if readOnlyMode {
agentItem.Token = ""
agentItem.APIKey = ""
agentItem.DashboardPassword = ""
}
agentItem.Upgradable = checkAgentUpgradable(appInstall)
items = append(items, agentItem)
}
@@ -984,19 +1001,28 @@ func (a AgentService) GetProviders() ([]dto.ProviderInfo, error) {
}
apiTypes := make([]dto.ProviderAPIInfo, 0, len(def.APIConfigs))
for _, item := range def.APIConfigs {
apiModels := make([]dto.ProviderModelInfo, 0, len(item.Models))
for _, model := range item.Models {
apiModels = append(apiModels, dto.ProviderModelInfo{
ID: model.ID,
Name: model.Name,
})
}
apiTypes = append(apiTypes, dto.ProviderAPIInfo{
APIType: item.APIType,
BaseURL: item.BaseURL,
EditableBaseURL: item.EditableBaseURL,
DefaultAuthMode: item.DefaultAuthMode,
AuthModes: item.AuthModes,
APIType: item.APIType,
BaseURL: item.BaseURL,
EditableBaseURL: item.EditableBaseURL,
SupportsModelDiscovery: item.DiscoverModels,
DefaultAuthMode: item.DefaultAuthMode,
AuthModes: item.AuthModes,
Models: apiModels,
})
}
baseURL, _ := providercatalog.DefaultBaseURL(key)
providers = append(providers, dto.ProviderInfo{
Sort: def.Sort,
Provider: key,
DisplayName: def.DisplayName,
DisplayName: localizedAgentProviderName(key),
BaseURL: baseURL,
DefaultAPIType: def.DefaultAPIType,
APITypes: apiTypes,
@@ -1016,7 +1042,7 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
if err := ensureAgentAccountNameAvailable(provider, req.Name, 0); err != nil {
return err
}
initialModels, err := buildInitialAgentAccountModels(&model.AgentAccount{Provider: provider}, req.Models)
initialModels, err := buildInitialAgentAccountModels(&model.AgentAccount{Provider: provider, APIType: req.APIType}, req.Models)
if err != nil {
return err
}
@@ -1024,7 +1050,8 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
if err != nil {
return err
}
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel)
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
if err != nil {
return err
}
@@ -1062,6 +1089,9 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
if err != nil {
return err
}
if req.APIType != account.APIType {
return buserr.WithDetail("ErrInvalidParams", "API type cannot be changed", nil)
}
provider := account.Provider
if err := ensureAgentAccountNameAvailable(provider, req.Name, account.ID); err != nil {
return err
@@ -1078,7 +1108,8 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
if err != nil {
return err
}
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel)
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
if err != nil {
return err
}
@@ -1105,11 +1136,17 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
return nil
}
func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.AgentAccountInfo, error) {
func (a AgentService) PageAccounts(req dto.AgentAccountSearch, readOnly ...bool) (int64, []dto.AgentAccountInfo, error) {
var opts []repo.DBOption
if strings.TrimSpace(req.Provider) != "" {
opts = append(opts, repo.WithByProvider(req.Provider))
}
if apiType := strings.TrimSpace(req.APIType); apiType != "" {
opts = append(opts, repo.WithByAPIType(apiType))
}
if req.TextOnly {
opts = append(opts, repo.WithTextAPIType())
}
if strings.TrimSpace(req.Name) != "" {
opts = append(opts, repo.WithByLikeName(req.Name))
}
@@ -1120,14 +1157,14 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
items := make([]dto.AgentAccountInfo, 0, len(list))
for _, item := range list {
apiKey := ""
if item.RememberAPIKey {
if item.RememberAPIKey && !isDemoReadOnly(readOnly...) {
apiKey = item.APIKey
}
items = append(items, dto.AgentAccountInfo{
ID: item.ID,
MasterAccountID: item.MasterAccountID,
Provider: item.Provider,
ProviderName: providercatalog.DisplayName(item.Provider),
ProviderName: localizedAgentProviderName(item.Provider),
Name: item.Name,
APIKey: apiKey,
RememberAPIKey: item.RememberAPIKey,
@@ -1166,7 +1203,7 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
}
func (a AgentService) CountAccountsByProviders(req dto.AgentAccountProviderCountReq) (map[string]int64, error) {
return agentAccountRepo.CountByProviders(req.Providers)
return agentAccountRepo.CountTextByProviders(req.Providers)
}
func (a AgentService) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.AgentAccountModel, error) {
@@ -1178,7 +1215,8 @@ func (a AgentService) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.Agen
}
func (a AgentService) DiscoverAccountModels(req dto.AgentAccountModelDiscoverReq) ([]dto.AgentAccountModel, error) {
if req.APIType != "openai-completions" && req.APIType != "openai-responses" {
config, ok := providercatalog.FindAPIConfig(req.Provider, req.APIType)
if !ok || !config.DiscoverModels {
return nil, buserr.New("ErrAgentAccountModelsRequired")
}
baseURL, err := providercatalog.ResolveBaseURL(req.Provider, req.APIType, req.BaseURL)
@@ -1320,7 +1358,7 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
}
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model)
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true)
return err
}
@@ -1410,7 +1448,7 @@ func (a AgentService) GetOtherConfig(req dto.AgentIDReq) (*dto.AgentOtherConfig,
if err != nil {
return nil, err
}
auth := readHermesDashboardAuthFromInstall(install)
auth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
return &dto.AgentOtherConfig{
UserTimezone: cfg.Timezone,
BrowserEnabled: true,
@@ -1419,6 +1457,13 @@ func (a AgentService) GetOtherConfig(req dto.AgentIDReq) (*dto.AgentOtherConfig,
DashboardPassword: auth.Password,
}, nil
}
if agent.AgentType == constant.AppCopaw {
auth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
return &dto.AgentOtherConfig{
DashboardUsername: auth.Username,
DashboardPassword: auth.Password,
}, nil
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return nil, err
@@ -1437,16 +1482,19 @@ func (a AgentService) UpdateOtherConfig(req dto.AgentOtherConfigUpdateReq) error
return err
}
if agent.AgentType == constant.AppHermesAgent {
if strings.TrimSpace(req.UserTimezone) == "" {
return buserr.New("ErrInvalidParams")
}
account, err := agentAccountRepo.GetFirst(repo.WithByID(agent.AccountID))
if err != nil {
return err
}
previousAuth := readHermesDashboardAuthFromInstall(install)
nextAuth := normalizeHermesDashboardAuth(req.DashboardUsername, req.DashboardPassword)
previousAuth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
nextAuth := normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
if err := writeHermesConfig(path.Dir(agent.ConfigPath), account, agent.Model, strings.TrimSpace(req.UserTimezone)); err != nil {
return err
}
if err := writeHermesDashboardAuthEnv(path.Join(install.GetPath(), ".env"), nextAuth, true); err != nil {
if err := writeAgentDashboardAuthEnv(install.GetEnvPath(), agent.AgentType, nextAuth, true); err != nil {
return err
}
operate := constant.Restart
@@ -1458,6 +1506,12 @@ func (a AgentService) UpdateOtherConfig(req dto.AgentOtherConfigUpdateReq) error
Operate: operate,
})
}
if agent.AgentType == constant.AppCopaw {
return updateQwenPawDashboardAuth(install, normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword))
}
if strings.TrimSpace(req.UserTimezone) == "" || strings.TrimSpace(req.NPMRegistry) == "" {
return buserr.New("ErrInvalidParams")
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return err
}
+128
View File
@@ -0,0 +1,128 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
)
type qwenPawAuthStatus struct {
Enabled bool `json:"enabled"`
HasUsers bool `json:"has_users"`
}
type qwenPawLoginResponse struct {
Token string `json:"token"`
}
func updateQwenPawDashboardAuth(install *model.AppInstall, next agentDashboardAuth) error {
if install == nil || install.ID == 0 {
return buserr.New("ErrRecordNotFound")
}
current, err := readAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw)
if err != nil {
return err
}
if current == next {
return writeAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw, next, true)
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return err
}
baseURL := fmt.Sprintf("http://127.0.0.1:%d/api/auth", install.HttpPort)
var status qwenPawAuthStatus
if _, err := requestQwenPawAuth(http.MethodGet, baseURL+"/status", nil, "", &status); err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
if !status.Enabled {
return buserr.New("ErrQwenPawAuthDisabled")
}
if !status.HasUsers {
payload := map[string]string{"username": next.Username, "password": next.Password}
if _, err := requestQwenPawAuth(http.MethodPost, baseURL+"/register", payload, "", nil); err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
} else {
var login qwenPawLoginResponse
payload := map[string]string{"username": current.Username, "password": current.Password}
statusCode, err := requestQwenPawAuth(http.MethodPost, baseURL+"/login", payload, "", &login)
if statusCode == http.StatusUnauthorized {
return buserr.New("ErrQwenPawAuthOutOfSync")
}
if err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
payload = map[string]string{"current_password": current.Password}
if current.Username != next.Username {
payload["new_username"] = next.Username
}
if current.Password != next.Password {
payload["new_password"] = next.Password
}
if _, err := requestQwenPawAuth(http.MethodPost, baseURL+"/update-profile", payload, login.Token, nil); err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
}
return writeAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw, next, true)
}
func requestQwenPawAuth(method, reqURL string, payload interface{}, token string, result interface{}) (int, error) {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return 0, err
}
body = bytes.NewReader(data)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return resp.StatusCode, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
detail := strings.TrimSpace(string(data))
var errorResponse struct {
Detail string `json:"detail"`
}
if json.Unmarshal(data, &errorResponse) == nil && strings.TrimSpace(errorResponse.Detail) != "" {
detail = strings.TrimSpace(errorResponse.Detail)
}
if detail == "" {
detail = resp.Status
}
return resp.StatusCode, errors.New(detail)
}
if result != nil && len(data) > 0 {
if err := json.Unmarshal(data, result); err != nil {
return resp.StatusCode, err
}
}
return resp.StatusCode, nil
}
-53
View File
@@ -19,13 +19,6 @@ import (
const hermesWorkspaceDir = "/opt/data/workspace"
const hermesExecutablePath = "/opt/hermes/.venv/bin/hermes"
const hermesDashboardUsernameEnvKey = "HERMES_DASHBOARD_USERNAME"
const hermesDashboardPasswordEnvKey = "HERMES_DASHBOARD_PASSWORD"
type hermesDashboardAuth struct {
Username string
Password string
}
type hermesConfig struct {
Model hermesModelConfig `yaml:"model"`
@@ -117,52 +110,6 @@ func prepareHermesInstallFiles(appInstall *model.AppInstall, account *model.Agen
return files.NewFileOp().ChownR(dataDir, "1000", "1000", true)
}
func normalizeHermesDashboardAuth(username, password string) hermesDashboardAuth {
auth := hermesDashboardAuth{
Username: strings.TrimSpace(username),
Password: strings.TrimSpace(password),
}
if auth.Username == "" {
auth.Username = "admin"
}
if auth.Password == "" {
auth.Password = common.RandStr(8)
}
return auth
}
func writeHermesDashboardAuthEnv(envPath string, auth hermesDashboardAuth, overwrite bool) error {
return upsertAgentEnv(envPath, map[string]string{
hermesDashboardUsernameEnvKey: auth.Username,
hermesDashboardPasswordEnvKey: auth.Password,
}, []string{
hermesDashboardUsernameEnvKey,
hermesDashboardPasswordEnvKey,
}, overwrite)
}
func readHermesDashboardAuthEnv(envPath string) (hermesDashboardAuth, error) {
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return hermesDashboardAuth{}, err
}
return hermesDashboardAuth{
Username: strings.TrimSpace(envMap[hermesDashboardUsernameEnvKey]),
Password: strings.TrimSpace(envMap[hermesDashboardPasswordEnvKey]),
}, nil
}
func readHermesDashboardAuthFromInstall(appInstall *model.AppInstall) hermesDashboardAuth {
if appInstall == nil || appInstall.ID == 0 {
return hermesDashboardAuth{}
}
auth, err := readHermesDashboardAuthEnv(path.Join(appInstall.GetPath(), ".env"))
if err != nil {
return hermesDashboardAuth{}
}
return auth
}
func readHermesConfig(configPath string) (*hermesConfig, error) {
content, err := files.NewFileOp().GetContent(configPath)
if err != nil {
+315
View File
@@ -0,0 +1,315 @@
package service
import (
"database/sql"
"encoding/json"
"fmt"
"path/filepath"
"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/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"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/compose"
)
var (
openclawPluginPackagePattern = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$`)
openclawPluginVersionPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z._-]*$`)
openclawPluginIDPattern = regexp.MustCompile(`^(@[A-Za-z0-9][A-Za-z0-9._-]*/)?[A-Za-z0-9][A-Za-z0-9._-]*$`)
)
type openclawPluginListOutput struct {
Plugins []struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Origin string `json:"origin"`
Enabled bool `json:"enabled"`
} `json:"plugins"`
}
type openclawPluginIndexItem struct {
PluginID string `json:"pluginId"`
PackageName string `json:"packageName"`
PackageVersion string `json:"packageVersion"`
Origin string `json:"origin"`
Enabled bool `json:"enabled"`
}
type openclawPluginSearchOutput struct {
Results []struct {
Score float64 `json:"score"`
Package struct {
Name string `json:"name"`
RuntimeID string `json:"runtimeId"`
DisplayName string `json:"displayName"`
Summary string `json:"summary"`
LatestVersion string `json:"latestVersion"`
Categories []string `json:"categories"`
Channel string `json:"channel"`
IsOfficial bool `json:"isOfficial"`
VerificationTier string `json:"verificationTier"`
Stats struct {
Downloads int64 `json:"downloads"`
} `json:"stats"`
} `json:"package"`
} `json:"results"`
}
func (a AgentService) ListPlugins(req dto.AgentPluginsReq) ([]dto.AgentPluginItem, error) {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if plugins, err := readOpenclawPluginIndex(filepath.Join(filepath.Dir(agent.ConfigPath), "state", "openclaw.sqlite")); err == nil {
return plugins, nil
}
output, err := cmd.RunDockerExecWithStdout(2*time.Minute, install.ContainerName, "openclaw", "plugins", "list", "--json")
if err != nil {
return nil, err
}
return parseOpenclawPluginList([]byte(output))
}
func (a AgentService) SearchPlugins(req dto.AgentPluginSearchReq) ([]dto.AgentPluginSearchItem, error) {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
limit := req.Limit
if limit == 0 {
limit = 20
}
output, err := cmd.RunDockerExecWithStdout(
2*time.Minute,
install.ContainerName,
"openclaw", "plugins", "search", strings.TrimSpace(req.Keyword), "--limit", fmt.Sprint(limit), "--json",
)
if err != nil {
return nil, err
}
return parseOpenclawPluginSearch([]byte(output))
}
func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) error {
spec, err := buildOpenclawPluginInstallSpec(req.Package, req.Version)
if err != nil {
return err
}
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
taskName := fmt.Sprintf("%s [%s]", i18n.GetMsgByKey("AgentPluginInstall"), req.Package)
installTask, err := task.NewTask(taskName, task.TaskInstall, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
installTask.AddSubTask(taskName, func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(10*time.Minute))
return mgr.Run("docker", "exec", install.ContainerName, "openclaw", "plugins", "install", spec)
}, nil)
addOpenclawPluginRestartTask(installTask, install)
go executeAgentPluginTask(installTask)
return nil
}
func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
if !openclawPluginIDPattern.MatchString(req.PluginID) {
return buserr.New("ErrInvalidChar")
}
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
if req.Operate == "update" || req.Operate == "uninstall" {
plugins, err := a.ListPlugins(dto.AgentPluginsReq{AgentID: req.AgentID})
if err != nil {
return err
}
for _, plugin := range plugins {
if plugin.ID == req.PluginID && plugin.Origin == "bundled" {
return buserr.WithName("ErrNotSupportType", req.Operate)
}
}
}
taskType := map[string]string{
"enable": task.TaskUpdate,
"disable": task.TaskUpdate,
"update": task.TaskUpgrade,
"uninstall": task.TaskUninstall,
}[req.Operate]
taskName := fmt.Sprintf("%s [%s]", i18n.GetMsgByKey(map[string]string{
"enable": "AgentPluginEnable",
"disable": "AgentPluginDisable",
"update": "AgentPluginUpdate",
"uninstall": "AgentPluginUninstall",
}[req.Operate]), req.PluginID)
operateTask, err := task.NewTask(taskName, taskType, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
operateTask.AddSubTask(taskName, func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(10*time.Minute))
if req.Operate == "uninstall" {
if err := uninstallOpenclawPlugin(mgr, install.ContainerName, req.PluginID); err != nil {
return err
}
return cleanupManagedOpenclawPlugin(agent, req.PluginID)
}
return mgr.Run("docker", "exec", install.ContainerName, "openclaw", "plugins", req.Operate, req.PluginID)
}, nil)
addOpenclawPluginRestartTask(operateTask, install)
go executeAgentPluginTask(operateTask)
return nil
}
func parseOpenclawPluginList(raw []byte) ([]dto.AgentPluginItem, error) {
payload, err := extractEmbeddedJSON(string(raw))
if err != nil {
return nil, err
}
if len(payload) == 0 {
return []dto.AgentPluginItem{}, nil
}
var output openclawPluginListOutput
if err := json.Unmarshal(payload, &output); err != nil {
return nil, err
}
items := make([]dto.AgentPluginItem, 0, len(output.Plugins))
for _, plugin := range output.Plugins {
items = append(items, dto.AgentPluginItem{
ID: plugin.ID,
Name: plugin.Name,
Version: plugin.Version,
Origin: plugin.Origin,
Enabled: plugin.Enabled,
})
}
return items, nil
}
func readOpenclawPluginIndex(dbPath string) ([]dto.AgentPluginItem, error) {
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
var raw []byte
if err := db.QueryRow(
"SELECT plugins_json FROM installed_plugin_index WHERE index_key = ?",
"installed-plugin-index",
).Scan(&raw); err != nil {
return nil, err
}
var plugins []openclawPluginIndexItem
if err := json.Unmarshal(raw, &plugins); err != nil {
return nil, err
}
items := make([]dto.AgentPluginItem, 0, len(plugins))
for _, plugin := range plugins {
name := plugin.PackageName
if name == "" {
name = plugin.PluginID
}
items = append(items, dto.AgentPluginItem{
ID: plugin.PluginID,
Name: name,
Version: plugin.PackageVersion,
Origin: plugin.Origin,
Enabled: plugin.Enabled,
})
}
return items, nil
}
func parseOpenclawPluginSearch(raw []byte) ([]dto.AgentPluginSearchItem, error) {
payload, err := extractEmbeddedJSON(string(raw))
if err != nil {
return nil, err
}
if len(payload) == 0 {
return []dto.AgentPluginSearchItem{}, nil
}
var output openclawPluginSearchOutput
if err := json.Unmarshal(payload, &output); err != nil {
return nil, err
}
items := make([]dto.AgentPluginSearchItem, 0, len(output.Results))
for _, result := range output.Results {
items = append(items, dto.AgentPluginSearchItem{
Package: result.Package.Name,
PluginID: result.Package.RuntimeID,
Name: result.Package.DisplayName,
Description: result.Package.Summary,
Version: result.Package.LatestVersion,
Channel: result.Package.Channel,
VerificationTier: result.Package.VerificationTier,
Categories: append([]string{}, result.Package.Categories...),
Official: result.Package.IsOfficial,
Downloads: result.Package.Stats.Downloads,
Score: result.Score,
})
}
return items, nil
}
func buildOpenclawPluginInstallSpec(packageName, version string) (string, error) {
packageName = strings.TrimSpace(packageName)
version = strings.TrimSpace(version)
if !openclawPluginPackagePattern.MatchString(packageName) || !openclawPluginVersionPattern.MatchString(version) {
return "", buserr.New("ErrInvalidChar")
}
return "clawhub:" + packageName + "@" + version, nil
}
func cleanupManagedOpenclawPlugin(agent *model.Agent, pluginID string) error {
pluginType := map[string]string{
"openclaw-lark": "feishu",
"openclaw-qqbot": "qqbot",
"wecom-openclaw-plugin": "wecom",
"dingtalk-connector": "dingtalk",
"openclaw-weixin": "weixin",
}[pluginID]
if pluginType == "" {
return nil
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
cleanupOpenclawPluginConfig(conf, pluginType)
return writeOpenclawConfigRaw(agent.ConfigPath, conf)
}
func addOpenclawPluginRestartTask(t *task.Task, install *model.AppInstall) {
t.AddSubTask(task.GetTaskName("OpenClaw", task.TaskRestart, task.TaskScopeAI), func(t *task.Task) error {
output, err := compose.Restart(install.GetComposePath())
if output != "" {
t.Log(output)
}
return err
}, nil)
}
func executeAgentPluginTask(t *task.Task) {
if err := t.Execute(); err != nil {
global.LOG.Errorf("operate openclaw plugin failed: %v", err)
}
}
+101 -9
View File
@@ -21,6 +21,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/i18n"
"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"
@@ -96,7 +97,7 @@ func ensureContainerRunning(containerName string) error {
return nil
}
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string) (resolvedAgentAccountInput, error) {
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedAPIType := strings.TrimSpace(apiType)
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
@@ -114,7 +115,8 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
if modelID == "" {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
}
if !providercatalog.SkipVerification(provider) {
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
if validateAvailability && (imageAPI || providercatalog.IsEmbeddingAPIType(resolvedAPIType) || !providercatalog.SkipVerification(provider)) {
if err := providercatalog.VerifyAccount(provider, resolvedAPIType, resolvedAuthMode, resolvedBaseURL, resolvedAPIKey, modelID); err != nil {
return resolvedAgentAccountInput{}, err
}
@@ -386,7 +388,7 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
Remark: agent.Remark,
AgentType: agentType,
Provider: agent.Provider,
ProviderName: providercatalog.DisplayName(agent.Provider),
ProviderName: localizedAgentProviderName(agent.Provider),
Model: agent.Model,
APIType: agent.APIType,
BaseURL: agent.BaseURL,
@@ -420,8 +422,8 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
item.BridgePort = toInt(bridge)
}
}
if agentType == constant.AppHermesAgent {
auth := readHermesDashboardAuthFromInstall(appInstall)
if _, _, ok := agentDashboardAuthEnvKeys(agentType); ok {
auth := readAgentDashboardAuthFromInstall(appInstall, agentType)
item.DashboardUsername = auth.Username
item.DashboardPassword = auth.Password
}
@@ -429,6 +431,15 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
return item
}
func localizedAgentProviderName(provider string) string {
if key := providercatalog.DisplayNameKey(provider); key != "" {
if name := strings.TrimSpace(i18n.GetMsgByKey(key)); name != "" {
return name
}
}
return providercatalog.DisplayName(provider)
}
func isAgentAppKey(appKey string) bool {
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent
}
@@ -1159,15 +1170,15 @@ func buildInitialAgentAccountModels(account *model.AgentAccount, requested []dto
if len(requested) > 0 {
return normalizeAgentAccountModels(account, requested)
}
meta, ok := providercatalog.Get(account.Provider)
if !ok || len(meta.Models) == 0 {
defaultModels := providercatalog.DefaultModels(account.Provider, account.APIType)
if len(defaultModels) == 0 {
if requiresInitialAgentAccountModels(account.Provider) {
return nil, buserr.New("ErrAgentAccountModelsRequired")
}
return nil, nil
}
requested = make([]dto.AgentAccountModel, 0, len(meta.Models))
for _, item := range meta.Models {
requested = make([]dto.AgentAccountModel, 0, len(defaultModels))
for _, item := range defaultModels {
requested = append(requested, dto.AgentAccountModel{
ID: item.ID,
Name: item.Name,
@@ -1452,6 +1463,87 @@ func readInstallEnv(envStr string) map[string]interface{} {
return data
}
const (
hermesDashboardUsernameEnvKey = "HERMES_DASHBOARD_USERNAME"
hermesDashboardPasswordEnvKey = "HERMES_DASHBOARD_PASSWORD"
qwenPawAuthEnabledEnvKey = "QWENPAW_AUTH_ENABLED"
qwenPawAuthUsernameEnvKey = "QWENPAW_AUTH_USERNAME"
qwenPawAuthPasswordEnvKey = "QWENPAW_AUTH_PASSWORD"
)
type agentDashboardAuth struct {
Username string
Password string
}
func normalizeAgentDashboardAuth(username, password string) agentDashboardAuth {
auth := agentDashboardAuth{
Username: strings.TrimSpace(username),
Password: strings.TrimSpace(password),
}
if auth.Username == "" {
auth.Username = "admin"
}
if auth.Password == "" {
auth.Password = common.RandStr(8)
}
return auth
}
func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
switch agentType {
case constant.AppHermesAgent:
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
case constant.AppCopaw:
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
default:
return "", "", false
}
}
func writeAgentDashboardAuthEnv(envPath, agentType string, auth agentDashboardAuth, overwrite bool) error {
usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType)
if !ok {
return fmt.Errorf("dashboard auth is not supported for %s", agentType)
}
values := map[string]string{
usernameKey: auth.Username,
passwordKey: auth.Password,
}
order := []string{usernameKey, passwordKey}
if agentType == constant.AppCopaw {
values[qwenPawAuthEnabledEnvKey] = "true"
order = append([]string{qwenPawAuthEnabledEnvKey}, order...)
}
return upsertAgentEnv(envPath, values, order, overwrite)
}
func readAgentDashboardAuthEnv(envPath, agentType string) (agentDashboardAuth, error) {
usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType)
if !ok {
return agentDashboardAuth{}, fmt.Errorf("dashboard auth is not supported for %s", agentType)
}
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return agentDashboardAuth{}, err
}
return agentDashboardAuth{
Username: strings.TrimSpace(envMap[usernameKey]),
Password: strings.TrimSpace(envMap[passwordKey]),
}, nil
}
func readAgentDashboardAuthFromInstall(appInstall *model.AppInstall, agentType string) agentDashboardAuth {
if appInstall == nil || appInstall.ID == 0 {
return agentDashboardAuth{}
}
auth, err := readAgentDashboardAuthEnv(appInstall.GetEnvPath(), agentType)
if err != nil {
return agentDashboardAuth{}
}
return auth
}
func readAgentEnvMap(envPath string) (map[string]string, error) {
fileOp := files.NewFileOp()
if !fileOp.Stat(envPath) {
+24 -3
View File
@@ -51,7 +51,7 @@ type IAlertService interface {
GetCronJobs(req dto.CronJobReq) ([]dto.CronJobDTO, error)
GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertConfig, error)
PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error)
PageAlertConfig(req dto.AlertConfigPageReq, readOnly ...bool) (int64, []model.AlertConfig, error)
UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error
DeleteAlertConfig(id uint) error
TestAlertConfig(req dto.AlertConfigTest) (bool, error)
@@ -384,6 +384,9 @@ func (a AlertService) PageAlertLogs(search dto.AlertLogSearch) (int64, []dto.Ale
if search.Count != 0 {
opts = append(opts, alertRepo.WithByCount(search.Count))
}
if !search.StartTime.IsZero() && !search.EndTime.IsZero() {
opts = append(opts, repo.WithByCreatedAt(search.StartTime, search.EndTime))
}
opts = append(opts, repo.WithOrderDesc("created_at"))
total, alerts, err := alertRepo.PageLog(search.Page, search.PageSize, opts...)
@@ -494,7 +497,7 @@ func (a AlertService) GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertCon
return configs, err
}
func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error) {
func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq, readOnly ...bool) (int64, []model.AlertConfig, error) {
opts := []repo.DBOption{
alertRepo.WithByTypeNotIn([]string{"common"}),
repo.WithOrderDesc("created_at"),
@@ -502,7 +505,25 @@ func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []mode
if len(req.ExcludeTypes) > 0 {
opts = append(opts, alertRepo.WithByTypeNotIn(req.ExcludeTypes))
}
return alertRepo.PageAlertConfig(req.Page, req.PageSize, opts...)
total, configs, err := alertRepo.PageAlertConfig(req.Page, req.PageSize, opts...)
if err != nil || !isDemoReadOnly(readOnly...) {
return total, configs, err
}
for i := range configs {
var value interface{}
if err := json.Unmarshal([]byte(configs[i].Config), &value); err != nil {
configs[i].Config = ""
continue
}
redactSensitiveData(value)
data, err := json.Marshal(value)
if err != nil {
configs[i].Config = ""
continue
}
configs[i].Config = string(data)
}
return total, configs, nil
}
func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error {
+40 -35
View File
@@ -48,7 +48,7 @@ type IAppService interface {
PageApp(ctx *gin.Context, req request.AppSearch) (*response.AppRes, error)
GetAppTags(ctx *gin.Context) ([]response.TagDTO, error)
GetApp(ctx *gin.Context, key string) (*response.AppDTO, error)
GetAppDetail(appId uint, version, appType string) (response.AppDetailDTO, error)
GetAppDetail(appId uint, version, appType string, readOnly ...bool) (response.AppDetailDTO, error)
Install(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error)
SyncAppListFromRemote(taskID string) error
GetAppUpdate() (*response.AppUpdateRes, error)
@@ -231,7 +231,7 @@ func (a AppService) GetAppDetailByKey(appKey, version string) (response.AppDetai
return appDetailDTO, nil
}
func (a AppService) GetAppDetail(appID uint, version, appType string) (response.AppDetailDTO, error) {
func (a AppService) GetAppDetail(appID uint, version, appType string, readOnly ...bool) (response.AppDetailDTO, error) {
var (
appDetailDTO response.AppDetailDTO
opts []repo.DBOption
@@ -243,6 +243,7 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
}
appDetailDTO.AppDetail = detail
appDetailDTO.Enable = true
readOnlyMode := isDemoReadOnly(readOnly...)
if appType == "runtime" {
app, err := appRepo.GetFirst(repo.WithByID(appID))
@@ -252,42 +253,46 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
fileOp := files.NewFileOp()
versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version)
if !fileOp.Stat(versionPath) || detail.Update {
versionExists := fileOp.Stat(versionPath)
if (!versionExists || detail.Update) && !readOnlyMode {
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
return appDetailDTO, err
}
versionExists = fileOp.Stat(versionPath)
}
switch app.Type {
case constant.RuntimePHP:
paramsPath := filepath.Join(versionPath, "data.yml")
if !fileOp.Stat(paramsPath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil)
}
param, err := fileOp.GetContent(paramsPath)
if err != nil {
return appDetailDTO, err
}
paramMap := make(map[string]interface{})
if err = yaml.Unmarshal(param, &paramMap); err != nil {
return appDetailDTO, err
}
appDetailDTO.Params = paramMap["additionalProperties"]
composePath := filepath.Join(versionPath, "docker-compose.yml")
if !fileOp.Stat(composePath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil)
}
compose, err := fileOp.GetContent(composePath)
if err != nil {
return appDetailDTO, err
}
composeMap := make(map[string]interface{})
if err := yaml.Unmarshal(compose, &composeMap); err != nil {
return appDetailDTO, err
}
if service, ok := composeMap["services"]; ok {
servicesMap := service.(map[string]interface{})
for k := range servicesMap {
appDetailDTO.Image = k
if versionExists {
switch app.Type {
case constant.RuntimePHP:
paramsPath := filepath.Join(versionPath, "data.yml")
if !fileOp.Stat(paramsPath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil)
}
param, err := fileOp.GetContent(paramsPath)
if err != nil {
return appDetailDTO, err
}
paramMap := make(map[string]interface{})
if err = yaml.Unmarshal(param, &paramMap); err != nil {
return appDetailDTO, err
}
appDetailDTO.Params = paramMap["additionalProperties"]
composePath := filepath.Join(versionPath, "docker-compose.yml")
if !fileOp.Stat(composePath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil)
}
compose, err := fileOp.GetContent(composePath)
if err != nil {
return appDetailDTO, err
}
composeMap := make(map[string]interface{})
if err := yaml.Unmarshal(compose, &composeMap); err != nil {
return appDetailDTO, err
}
if service, ok := composeMap["services"]; ok {
servicesMap := service.(map[string]interface{})
for k := range servicesMap {
appDetailDTO.Image = k
}
}
}
}
@@ -299,7 +304,7 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
appDetailDTO.Params = paramMap
}
if appDetailDTO.DockerCompose == "" {
if appDetailDTO.DockerCompose == "" && !readOnlyMode {
filename := filepath.Base(appDetailDTO.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml")
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
+8 -4
View File
@@ -13,7 +13,7 @@ type AppIgnoreUpgradeService struct {
}
type IAppIgnoreUpgradeService interface {
List() ([]response.AppIgnoreUpgradeDTO, error)
List(readOnly ...bool) ([]response.AppIgnoreUpgradeDTO, error)
CreateAppIgnore(req request.AppIgnoreUpgradeReq) error
Delete(req request.ReqWithID) error
}
@@ -22,7 +22,7 @@ func NewIAppIgnoreUpgradeService() IAppIgnoreUpgradeService {
return AppIgnoreUpgradeService{}
}
func (a AppIgnoreUpgradeService) List() ([]response.AppIgnoreUpgradeDTO, error) {
func (a AppIgnoreUpgradeService) List(readOnly ...bool) ([]response.AppIgnoreUpgradeDTO, error) {
var res []response.AppIgnoreUpgradeDTO
ignores, err := appIgnoreUpgradeRepo.List()
if err != nil {
@@ -37,14 +37,18 @@ func (a AppIgnoreUpgradeService) List() ([]response.AppIgnoreUpgradeDTO, error)
}
app, err := appRepo.GetFirst(repo.WithByID(ignore.AppID))
if errors.Is(err, gorm.ErrRecordNotFound) {
_ = appIgnoreUpgradeRepo.Delete(repo.WithByID(ignore.ID))
if !isDemoReadOnly(readOnly...) {
_ = appIgnoreUpgradeRepo.Delete(repo.WithByID(ignore.ID))
}
continue
}
dto.Name = app.Name
if ignore.Scope == "version" {
appDetail, err := appDetailRepo.GetFirst(repo.WithByID(ignore.AppDetailID))
if errors.Is(err, gorm.ErrRecordNotFound) {
_ = appIgnoreUpgradeRepo.Delete(repo.WithByID(ignore.ID))
if !isDemoReadOnly(readOnly...) {
_ = appIgnoreUpgradeRepo.Delete(repo.WithByID(ignore.ID))
}
continue
}
dto.Version = appDetail.Version
+35 -17
View File
@@ -42,21 +42,21 @@ type IAppInstallService interface {
Page(req request.AppInstalledSearch) (int64, []response.AppInstallDTO, error)
CheckExist(req request.AppInstalledInfo) (*response.AppInstalledCheck, error)
LoadPort(req dto.OperationWithNameAndType) (int64, error)
LoadConnInfo(req dto.OperationWithNameAndType) (response.DatabaseConn, error)
LoadConnInfo(req dto.OperationWithNameAndType, readOnly ...bool) (response.DatabaseConn, error)
SearchForWebsite(req request.AppInstalledSearch) ([]response.AppInstallDTO, error)
Operate(req request.AppInstalledOperate) error
Update(req request.AppInstalledUpdate) error
SyncAll(systemInit bool) error
GetServices(key string) ([]response.AppService, error)
GetUpdateVersions(req request.AppUpdateVersion) ([]dto.AppVersion, error)
GetParams(id uint) (*response.AppConfig, error)
GetParams(id uint, readOnly ...bool) (*response.AppConfig, error)
ChangeAppPort(req request.PortUpdate) error
GetDefaultConfigByKey(key, name string) (string, error)
DeleteCheck(installId uint) ([]dto.AppResource, error)
DeleteCheck(installId uint, readOnly ...bool) ([]dto.AppResource, error)
UpdateAppConfig(req request.AppConfigUpdate) error
GetInstallList() ([]dto.AppInstallInfo, error)
GetAppInstallInfo(appInstallID uint) (*response.AppInstallInfo, error)
GetAppInstallInfo(appInstallID uint, readOnly ...bool) (*response.AppInstallInfo, error)
UpdateSort(req request.AppInstallSort) error
}
@@ -123,7 +123,7 @@ func (a *AppInstallService) Page(req request.AppInstalledSearch) (int64, []respo
}
}
installDTOs, _ := handleInstalled(installs, req.Update, req.Sync, req.CheckUpdate)
installDTOs, _ := handleInstalled(installs, req.Update, req.Sync && !req.ReadOnly, req.CheckUpdate, req.ReadOnly)
if req.Update {
total = int64(len(installDTOs))
}
@@ -151,8 +151,10 @@ func (a *AppInstallService) CheckExist(req request.AppInstalledInfo) (*response.
if reflect.DeepEqual(appInstall, model.AppInstall{}) {
return res, nil
}
if err = syncAppInstallStatus(&appInstall, false); err != nil {
return nil, err
if !req.ReadOnly {
if err = syncAppInstallStatus(&appInstall, false); err != nil {
return nil, err
}
}
res.ContainerName = appInstall.ContainerName
@@ -182,7 +184,7 @@ func (a *AppInstallService) LoadPort(req dto.OperationWithNameAndType) (int64, e
return app.Port, nil
}
func (a *AppInstallService) LoadConnInfo(req dto.OperationWithNameAndType) (response.DatabaseConn, error) {
func (a *AppInstallService) LoadConnInfo(req dto.OperationWithNameAndType, readOnly ...bool) (response.DatabaseConn, error) {
var data response.DatabaseConn
app, err := appInstallRepo.LoadBaseInfo(req.Type, req.Name)
if err != nil {
@@ -191,6 +193,9 @@ func (a *AppInstallService) LoadConnInfo(req dto.OperationWithNameAndType) (resp
data.Status = app.Status
data.Username = app.UserName
data.Password = app.Password
if isDemoReadOnly(readOnly...) {
data.Password = ""
}
data.ServiceName = app.ServiceName
data.Port = app.Port
data.ContainerName = app.ContainerName
@@ -240,7 +245,7 @@ func (a *AppInstallService) SearchForWebsite(req request.AppInstalledSearch) ([]
}
}
return handleInstalled(installs, false, true, false)
return handleInstalled(installs, false, !req.ReadOnly, false, req.ReadOnly)
}
func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
@@ -664,7 +669,7 @@ func (a *AppInstallService) ChangeAppPort(req request.PortUpdate) error {
return nil
}
func (a *AppInstallService) DeleteCheck(installID uint) ([]dto.AppResource, error) {
func (a *AppInstallService) DeleteCheck(installID uint, readOnly ...bool) ([]dto.AppResource, error) {
var res []dto.AppResource
appInstall, err := appInstallRepo.GetFirst(repo.WithByID(installID))
if err != nil {
@@ -685,7 +690,7 @@ func (a *AppInstallService) DeleteCheck(installID uint) ([]dto.AppResource, erro
Type: "app",
Name: linkInstall.Name,
})
} else {
} else if !isDemoReadOnly(readOnly...) {
_ = appInstallResourceRepo.DeleteBy(context.Background(), appInstallResourceRepo.WithAppInstallId(resource.AppInstallId))
}
}
@@ -723,7 +728,7 @@ func (a *AppInstallService) GetDefaultConfigByKey(key, name string) (string, err
return string(contentByte), nil
}
func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
func (a *AppInstallService) GetParams(id uint, readOnly ...bool) (*response.AppConfig, error) {
var (
params []response.AppParam
appForm dto.AppForm
@@ -818,8 +823,14 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
}
}
readOnlyMode := isDemoReadOnly(readOnly...)
if readOnlyMode {
redactSensitiveAppParams(params)
}
config := getAppCommonConfig(envs)
config.DockerCompose = install.DockerCompose
if !readOnlyMode {
config.DockerCompose = install.DockerCompose
}
res.Params = params
if config.ContainerName == "" {
config.ContainerName = install.ContainerName
@@ -829,8 +840,10 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
res.RestartPolicy = getRestartPolicy(install.DockerCompose)
res.WebUI = install.WebUI
res.Type = install.App.Type
if rawCompose, err := getUpgradeCompose(install, detail); err == nil {
res.RawCompose = rawCompose
if !readOnlyMode {
if rawCompose, err := getUpgradeCompose(install, detail); err == nil {
res.RawCompose = rawCompose
}
}
return &res, nil
}
@@ -958,20 +971,25 @@ func updateInstallInfoInDB(appKey, appName, param string, value interface{}) err
return nil
}
func (a *AppInstallService) GetAppInstallInfo(installID uint) (*response.AppInstallInfo, error) {
func (a *AppInstallService) GetAppInstallInfo(installID uint, readOnly ...bool) (*response.AppInstallInfo, error) {
appInstall, _ := appInstallRepo.GetFirst(repo.WithByID(installID))
if appInstall.ID == 0 {
return &response.AppInstallInfo{
Status: constant.StatusDeleted,
}, nil
}
_ = syncAppInstallStatus(&appInstall, false)
if !isDemoReadOnly(readOnly...) {
_ = syncAppInstallStatus(&appInstall, false)
}
appInstall, _ = appInstallRepo.GetFirst(repo.WithByID(installID))
var envMap map[string]interface{}
err := json.Unmarshal([]byte(appInstall.Env), &envMap)
if err != nil {
return nil, err
}
if isDemoReadOnly(readOnly...) {
redactSensitiveValues(envMap)
}
res := &response.AppInstallInfo{
ID: appInstall.ID,
Name: appInstall.Name,
+927
View File
@@ -0,0 +1,927 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path"
"sort"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"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/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/joho/godotenv"
)
type appUpgradePhase int
const (
appUpgradePreparing appUpgradePhase = iota
appUpgradePrepared
appUpgradeStopped
appUpgradeBackedUp
appUpgradeDown
appUpgradeMutated
appUpgradeStarted
appUpgradeReady
appUpgradeCommitted
)
const composeServiceLabel = "com.docker.compose.service"
var appUpgradeLocks sync.Map
type appUpgradeSnapshot interface {
Restore() error
Cleanup()
}
type upgradeFileSnapshot struct {
installPath string
backupPath string
paths []string
existing map[string]bool
}
type appUpgradeContext struct {
req request.AppInstallUpgrade
original model.AppInstall
candidate model.AppInstall
detail model.AppDetail
phase appUpgradePhase
stopAttempted bool
downAttempted bool
rollbackErr error
detailDir string
stageDir string
envContent []byte
oldEnvContent []byte
oldDockerCompose string
oldImageIDs []appImageID
backupFile string
snapshot appUpgradeSnapshot
createdPaths []string
}
func upgradeInstall(req request.AppInstallUpgrade) error {
install, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if err != nil {
return err
}
if install.Status == constant.StatusUpgrading {
return buserr.New("TaskIsExecuting")
}
if err = task.CheckScopeTaskIsExecuting(task.TaskScopeApp, install.ID); err != nil {
return err
}
if _, loaded := appUpgradeLocks.LoadOrStore(install.ID, struct{}{}); loaded {
return buserr.New("TaskIsExecuting")
}
releaseLock := true
defer func() {
if releaseLock {
appUpgradeLocks.Delete(install.ID)
}
}()
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil {
return err
}
if install.App.Key == vllmAppKeyForUpgrade && !isVllmUpgradeVersionAllowed(install.Version, detail.Version, loadVllmImageFromEnv(install.Env)) {
return errors.New("vLLM can only upgrade within the same image type")
}
if install.Version == detail.Version {
return errors.New("two version is same")
}
upgradeTask, err := task.NewTaskWithOps(install.Name, task.TaskUpgrade, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil {
return err
}
ctx := &appUpgradeContext{
req: req,
original: install,
candidate: install,
detail: detail,
phase: appUpgradePreparing,
oldDockerCompose: install.DockerCompose,
}
upgradeTask.AddSubTaskWithOps(i18n.GetMsgByKey("UpgradePrepare"), ctx.prepare, nil, 0, 0)
upgradeTask.AddSubTaskWithOps(
task.GetTaskName(install.Name, task.TaskUpgrade, task.TaskScopeApp),
ctx.cutover,
func(t *task.Task) {
ctx.rollbackErr = ctx.rollback(t)
},
0,
0,
)
upgradingInstall := install
upgradingInstall.Status = constant.StatusUpgrading
upgradingInstall.Message = ""
if err = appInstallRepo.Save(context.Background(), &upgradingInstall); err != nil {
return err
}
releaseLock = false
go func() {
defer appUpgradeLocks.Delete(install.ID)
defer ctx.cleanup()
taskErr := upgradeTask.Execute()
if taskErr == nil {
return
}
if ctx.rollbackErr != nil {
taskErr = fmt.Errorf("%w; %s: %v", taskErr, i18n.GetMsgByKey("UpgradeRollbackFailed"), ctx.rollbackErr)
upgradeTask.Task.ErrorMsg = taskErr.Error()
_ = repo.NewITaskRepo().Update(context.Background(), upgradeTask.Task)
}
if !ctx.stopAttempted || ctx.rollbackErr == nil {
restored := ctx.original
_ = appInstallRepo.Save(context.Background(), &restored)
return
}
failed := ctx.original
failed.Status = constant.StatusUpgradeErr
failed.Message = taskErr.Error()
_ = appInstallRepo.Save(context.Background(), &failed)
}()
return nil
}
func (u *appUpgradeContext) prepare(t *task.Task) error {
fileOp := files.NewFileOp()
u.detailDir = path.Join(u.original.App.GetAppResourcePath(), u.detail.Version)
if u.original.App.Resource == constant.AppResourceRemote {
if err := downloadApp(u.original.App, u.detail, nil, t.Logger); err != nil {
return err
}
}
if !fileOp.Stat(u.detailDir) {
return buserr.WithName("ErrFileNotFound", u.detailDir)
}
if u.detail.DockerCompose == "" {
composeContent, err := fileOp.GetContent(path.Join(u.detailDir, "docker-compose.yml"))
if err != nil {
return err
}
u.detail.DockerCompose = string(composeContent)
_ = appDetailRepo.Update(context.Background(), u.detail)
}
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
}
var err error
u.oldEnvContent, err = fileOp.GetContent(u.original.GetEnvPath())
if err != nil {
return err
}
u.stageDir, err = os.MkdirTemp(u.original.GetAppPath(), "."+u.original.Name+"-upgrade-")
if err != nil {
return err
}
if err = fileOp.CopyDirWithNewName(u.detailDir, u.stageDir, "."); err != nil {
return err
}
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, ".env"); err != nil {
return err
}
if u.original.App.Key == constant.AppOpenclaw {
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
return err
}
}
if u.original.App.Key == constant.AppOpenresty {
for _, relativePath := range []string{
nginxModuleBuildDir,
nginxModuleModulesDir,
path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir),
} {
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, relativePath); err != nil {
return err
}
}
}
stagedInstall := u.original
stagedInstall.Name = path.Base(u.stageDir)
stagedInstall.Version = u.detail.Version
stagedInstall.AppDetailId = u.req.DetailID
if stagedInstall.App.Key == vllmAppKeyForUpgrade {
envs := make(map[string]interface{})
if err = json.Unmarshal([]byte(stagedInstall.Env), &envs); err != nil {
return err
}
image := buildVllmUpgradeImage(loadVllmImageFromEnv(stagedInstall.Env), u.original.Version, u.detail.Version)
envs[vllmImageEnvKey] = image
paramBytes, marshalErr := json.Marshal(envs)
if marshalErr != nil {
return marshalErr
}
stagedInstall.Env = string(paramBytes)
}
if err = migrateOpenclawProtocolUpgrade(&stagedInstall, u.original.Version, u.detail.Version); err != nil {
return err
}
u.candidate = stagedInstall
u.candidate.Name = u.original.Name
u.candidate.DockerCompose, err = renderUpgradeCompose(u.candidate, u.detail, u.req.DockerCompose)
if err != nil {
return err
}
if strings.TrimSpace(u.candidate.DockerCompose) == "" {
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
}
u.envContent, err = renderUpgradeEnv(&u.candidate, u.oldEnvContent)
if err != nil {
return err
}
if err = writeUpgradeFile(path.Join(u.stageDir, ".env"), u.envContent, constant.FilePerm); err != nil {
return err
}
if err = writeUpgradeFile(path.Join(u.stageDir, "docker-compose.yml"), []byte(u.candidate.DockerCompose), constant.FilePerm); err != nil {
return err
}
project, err := docker.GetComposeProject(u.original.Name, u.stageDir, []byte(u.candidate.DockerCompose), u.envContent, false)
if err != nil {
return err
}
hasBuild := false
for _, service := range project.Services {
if service.Image == "" && service.Build == nil {
return fmt.Errorf("compose service %s has neither image nor build configuration", service.Name)
}
hasBuild = hasBuild || service.Build != nil
}
if u.req.DeleteImage {
dockerClient, clientErr := docker.NewClient()
if clientErr != nil {
return clientErr
}
u.oldImageIDs, err = getAppImageIDsByCompose(dockerClient, u.oldEnvContent, []byte(u.oldDockerCompose))
dockerClient.Close()
if err != nil {
return err
}
}
images := make([]string, 0, len(project.Services))
for _, service := range project.Services {
if service.Image != "" {
images = append(images, service.Image)
}
}
if err = prepareUpgradeImages(t, images, u.req.PullImage); err != nil {
return err
}
if u.candidate.App.Key == constant.AppOpenresty {
if err = u.prepareOpenresty(t, stagedInstall); err != nil {
return err
}
if err = verifyUpgradeImages(images); err != nil {
return err
}
} else if hasBuild {
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image"))
t.LogStart(logStr)
if err = compose.BuildWithTask(path.Join(u.stageDir, "docker-compose.yml"), project.Name, t); err != nil {
t.LogFailedWithErr(logStr, err)
return err
}
t.LogSuccess(logStr)
if err = verifyUpgradeImages(images); err != nil {
return err
}
}
if u.original.App.Resource == constant.AppResourceRemote {
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
}
u.phase = appUpgradePrepared
return nil
}
func (u *appUpgradeContext) prepareOpenresty(t *task.Task, stagedInstall model.AppInstall) error {
fileOp := files.NewFileOp()
detailBuildDir := path.Join(u.detailDir, nginxModuleBuildDir)
installBuildDir := path.Join(u.stageDir, nginxModuleBuildDir)
if !fileOp.Stat(installBuildDir) {
if err := fileOp.CreateDir(installBuildDir, constant.DirPerm); err != nil {
return err
}
}
if err := copyAppDetailMissing(fileOp, detailBuildDir, installBuildDir); err != nil {
return err
}
if err := fileOp.DeleteDir(path.Join(installBuildDir, nginxModuleTmpDir)); err != nil {
return err
}
if err := fileOp.CopyDir(path.Join(detailBuildDir, nginxModuleTmpDir), installBuildDir); err != nil {
return err
}
for _, fileName := range []string{"Dockerfile", "nginx.conf", "nginx.vh.default.conf"} {
if err := fileOp.CopyFile(path.Join(detailBuildDir, fileName), installBuildDir); err != nil {
return err
}
}
if err := syncNginxModuleBuilder(detailBuildDir, installBuildDir); err != nil {
return err
}
targetCatalogSource := path.Join(detailBuildDir, nginxModuleCatalogFile)
if !fileOp.Stat(targetCatalogSource) {
return fmt.Errorf("target OpenResty module catalog not found: %s", targetCatalogSource)
}
targetCatalogPath := path.Join(installBuildDir, nginxModuleCatalogPendingFile)
if err := stageNginxModuleCatalog(targetCatalogSource, targetCatalogPath); err != nil {
return err
}
stagedInstall.Name = path.Base(u.stageDir)
stagedInstall.Version = u.candidate.Version
stagedInstall.Env = u.candidate.Env
stagedInstall.DockerCompose = u.candidate.DockerCompose
return buildNginx(t, stagedInstall, targetCatalogPath)
}
func (u *appUpgradeContext) cutover(t *task.Task) error {
u.stopAttempted = true
t.LogStart(i18n.GetMsgByKey("UpgradeStop"))
if out, err := compose.Stop(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
t.LogFailedWithErr(i18n.GetMsgByKey("UpgradeStop"), err)
return err
}
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
u.phase = appUpgradeStopped
var err error
if u.original.App.Key == constant.AppOpenresty {
u.snapshot, err = createOpenrestyUpgradeSnapshot(u.original.GetPath())
} else {
snapshotPaths := []string{".env", "docker-compose.yml", "scripts"}
if u.original.App.Key == constant.AppOpenclaw {
snapshotPaths = append(snapshotPaths, path.Join("data", "conf", "openclaw.json"))
}
u.snapshot, err = createUpgradeFileSnapshot(u.original.GetPath(), snapshotPaths)
}
if err != nil {
return err
}
if u.req.Backup {
if err = u.backup(t); err != nil {
return err
}
u.phase = appUpgradeBackedUp
} else {
t.Log(i18n.GetMsgByKey("UpgradeBackupDisabled"))
}
u.downAttempted = true
if out, downErr := compose.Down(u.original.GetComposePath()); downErr != nil {
if out != "" {
downErr = fmt.Errorf("%s: %w", out, downErr)
}
return downErr
}
u.phase = appUpgradeDown
u.phase = appUpgradeMutated
if err = u.applyStagedFiles(); err != nil {
return err
}
if err = writeUpgradeFile(u.original.GetEnvPath(), u.envContent, constant.FilePerm); err != nil {
return err
}
if err = runScript(t, &u.candidate, "upgrade"); err != nil {
return err
}
if err = writeUpgradeFile(u.original.GetComposePath(), []byte(u.candidate.DockerCompose), constant.FilePerm); err != nil {
return err
}
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App"))
t.LogStart(logStr)
if out, upErr := compose.UpWithoutBuild(u.original.GetComposePath()); upErr != nil {
if out != "" {
upErr = fmt.Errorf("%s: %w", out, upErr)
}
t.LogFailedWithErr(logStr, upErr)
return upErr
}
t.LogSuccess(logStr)
u.phase = appUpgradeStarted
t.LogStart(i18n.GetMsgByKey("UpgradeWaitReady"))
containerNames, err := waitAppContainersReady(context.Background(), u.candidate)
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("UpgradeWaitReady"), err)
return err
}
t.LogSuccess(i18n.GetMsgByKey("UpgradeWaitReady"))
u.phase = appUpgradeReady
u.candidate.ContainerName = strings.Join(containerNames, ",")
u.candidate.Status = constant.StatusRunning
u.candidate.Message = ""
if u.candidate.App.Key == constant.AppOpenresty {
liveCatalogPath := path.Join(u.candidate.GetPath(), nginxModuleBuildDir, nginxModuleCatalogPendingFile)
if err = commitStaticNginxModuleBuilds(u.candidate, liveCatalogPath, t); err != nil {
return err
}
activeCatalogPath := path.Join(u.candidate.GetPath(), nginxModuleBuildDir, nginxModuleCatalogFile)
if err = activateNginxModuleCatalogAndCommit(liveCatalogPath, activeCatalogPath, func() error {
return appInstallRepo.Save(context.Background(), &u.candidate)
}); err != nil {
return err
}
} else if err = appInstallRepo.Save(context.Background(), &u.candidate); err != nil {
return err
}
u.phase = appUpgradeCommitted
u.deleteOldImages(t)
return nil
}
func (u *appUpgradeContext) backup(t *task.Task) error {
fileName := fmt.Sprintf("upgrade_backup_%s_%s.tar.gz", u.original.Name, time.Now().Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5))
record, err := backupAppWithParentTask(&u.original, t, fileName)
if err != nil {
return buserr.WithNameAndErr("ErrAppBackup", u.original.Name, err)
}
u.backupFile = path.Join(global.Dir.LocalBackupDir, record.FileDir, record.FileName)
info, err := os.Stat(u.backupFile)
if err != nil || info.Size() == 0 || record.Status != constant.StatusSuccess {
if err == nil {
err = errors.New("backup archive is empty or incomplete")
}
markBackupFailed(record.ID, err)
return buserr.WithNameAndErr("ErrAppBackup", u.original.Name, err)
}
backupRecordService := NewIBackupRecordService()
backups, _ := backupRecordService.ListAppRecords(u.original.App.Key, u.original.Name, "upgrade_backup")
if len(backups) > 3 {
deleteIDs := make([]uint, 0, len(backups)-3)
for _, backup := range backups[:len(backups)-3] {
deleteIDs = append(deleteIDs, backup.ID)
}
_ = backupRecordService.BatchDeleteRecord(deleteIDs)
}
return nil
}
func (u *appUpgradeContext) applyStagedFiles() error {
fileOp := files.NewFileOp()
if err := copyAppDetailMissingTracked(fileOp, u.detailDir, u.original.GetPath(), &u.createdPaths); err != nil {
return err
}
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), "scripts"); err != nil {
return err
}
if u.original.App.Key == constant.AppOpenclaw {
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), path.Join("data", "conf", "openclaw.json")); err != nil {
return err
}
}
if u.original.App.Key == constant.AppOpenresty {
for _, relativePath := range []string{
nginxModuleBuildDir,
nginxModuleModulesDir,
path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir),
} {
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), relativePath); err != nil {
return err
}
}
}
return nil
}
func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
if !u.stopAttempted {
return nil
}
logStr := i18n.GetWithName("AppRecover", u.original.Name)
t.LogStart(logStr)
defer func() {
if rollbackErr != nil {
t.LogFailedWithErr(logStr, rollbackErr)
} else {
t.LogSuccess(logStr)
}
}()
if !u.downAttempted {
if out, err := compose.Operate(u.original.GetComposePath(), "start"); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
return err
}
return u.finishRollback()
}
if u.phase < appUpgradeMutated {
if out, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
return err
}
return u.finishRollback()
}
if out, err := compose.Down(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
rollbackErr = err
}
if u.backupFile != "" {
_ = u.restoreManagedFiles()
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
_, _ = compose.UpWithoutBuild(u.original.GetComposePath())
return errors.Join(rollbackErr, err)
}
} else {
if err := u.restoreManagedFiles(); err != nil {
return errors.Join(rollbackErr, err)
}
if out, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
return errors.Join(rollbackErr, err)
}
}
return errors.Join(rollbackErr, u.finishRollback())
}
func (u *appUpgradeContext) finishRollback() error {
if _, err := waitAppContainersReady(context.Background(), u.original); err != nil {
return err
}
restored := u.original
if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
return err
}
return nil
}
func (u *appUpgradeContext) restoreManagedFiles() error {
var restoreErr error
if u.snapshot != nil {
restoreErr = u.snapshot.Restore()
}
for index := len(u.createdPaths) - 1; index >= 0; index-- {
if err := os.RemoveAll(u.createdPaths[index]); err != nil {
restoreErr = errors.Join(restoreErr, err)
}
}
return restoreErr
}
func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
if !u.req.DeleteImage {
return
}
excludeImages, err := docker.GetImagesFromDockerCompose(u.envContent, []byte(u.candidate.DockerCompose))
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
return
}
dockerClient, err := docker.NewClient()
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
return
}
defer dockerClient.Close()
if err = deleteAppImagesByIDs(t, dockerClient, u.oldImageIDs, excludeImages); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
}
}
func (u *appUpgradeContext) cleanup() {
if u.snapshot != nil {
u.snapshot.Cleanup()
}
if u.stageDir != "" {
_ = os.RemoveAll(u.stageDir)
}
}
type upgradeImageClient interface {
PullImageWithProcess(*task.Task, string) error
ImageExists(string) (bool, error)
Close()
}
func prepareUpgradeImages(t *task.Task, images []string, pull bool) error {
dockerClient, err := docker.NewClient()
if err != nil {
return err
}
return prepareUpgradeImagesWithClient(t, dockerClient, images, pull)
}
func prepareUpgradeImagesWithClient(t *task.Task, dockerClient upgradeImageClient, images []string, pull bool) error {
defer dockerClient.Close()
seen := make(map[string]struct{}, len(images))
for _, image := range images {
image = strings.TrimSpace(image)
if image == "" {
continue
}
if _, ok := seen[image]; ok {
continue
}
seen[image] = struct{}{}
if pull {
if t != nil {
t.Log(i18n.GetWithName("PullImageStart", image))
}
if pullErr := dockerClient.PullImageWithProcess(t, image); pullErr != nil {
if exists, _ := dockerClient.ImageExists(image); exists {
if t != nil {
t.Log(i18n.GetMsgByKey("UseExistImage"))
}
continue
}
return buserr.WithNameAndErr("ErrDockerPullImage", "", pullErr)
}
}
exists, inspectErr := dockerClient.ImageExists(image)
if inspectErr != nil || !exists {
return buserr.WithNameAndErr("ErrDockerPullImage", "", fmt.Errorf("image %s is not available locally: %v", image, inspectErr))
}
if pull && t != nil {
t.LogSuccess(i18n.GetMsgByKey("PullImage"))
}
}
return nil
}
func verifyUpgradeImages(images []string) error {
dockerClient, err := docker.NewClient()
if err != nil {
return err
}
defer dockerClient.Close()
for _, image := range images {
exists, inspectErr := dockerClient.ImageExists(image)
if inspectErr != nil || !exists {
return buserr.WithNameAndErr("ErrDockerPullImage", "", fmt.Errorf("image %s is not available locally: %v", image, inspectErr))
}
}
return nil
}
func renderUpgradeEnv(install *model.AppInstall, original []byte) ([]byte, error) {
originalEnv := make(map[string]string)
if len(original) > 0 {
var err error
originalEnv, err = godotenv.UnmarshalBytes(original)
if err != nil {
return nil, err
}
}
params := make(map[string]string, len(originalEnv))
maps.Copy(params, originalEnv)
envs := make(map[string]interface{})
if err := json.Unmarshal([]byte(install.Env), &envs); err != nil {
return nil, err
}
handleMap(envs, params)
if install.App.Key == constant.AppOpenresty {
for _, key := range []string{"CONTAINER_PACKAGE_URL", "RESTY_ADD_PACKAGE_BUILDDEPS", "RESTY_CONFIG_OPTIONS_MORE"} {
if value, ok := originalEnv[key]; ok {
params[key] = value
}
}
if websiteDir := strings.TrimSpace(originalEnv["WEBSITE_DIR"]); websiteDir != "" {
params["WEBSITE_DIR"] = websiteDir
}
websiteDir := strings.TrimSpace(params["WEBSITE_DIR"])
if websiteDir == "" {
websiteDir = NewISettingService().GetWebsiteDir()
}
if !path.IsAbs(websiteDir) {
websiteDir = path.Join(global.Dir.DataDir, websiteDir)
}
params["WEBSITE_DIR"] = websiteDir
envs["WEBSITE_DIR"] = websiteDir
content, marshalErr := json.Marshal(envs)
if marshalErr != nil {
return nil, marshalErr
}
install.Env = string(content)
}
content, err := godotenv.Marshal(params)
if err != nil {
return nil, err
}
return []byte(content), nil
}
func renderUpgradeCompose(install model.AppInstall, detail model.AppDetail, customCompose string) (string, error) {
if customCompose != "" {
return customCompose, nil
}
if install.App.Key == vllmAppKeyForUpgrade {
return install.DockerCompose, nil
}
return getUpgradeCompose(install, detail)
}
func writeUpgradeFile(filePath string, content []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(path.Dir(filePath), "."+path.Base(filePath)+".*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err = tmp.Chmod(mode); err == nil {
_, err = tmp.Write(content)
}
if err == nil {
err = tmp.Sync()
}
if closeErr := tmp.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(tmpPath, filePath)
}
func copyUpgradeStageFile(sourceRoot, targetRoot, relativePath string) error {
source := path.Join(sourceRoot, relativePath)
if _, err := os.Stat(source); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
target := path.Join(targetRoot, relativePath)
_ = os.RemoveAll(target)
return copyOpenrestyUpgradeSnapshotEntry(source, target)
}
func replaceUpgradePath(sourceRoot, targetRoot, relativePath string) error {
source := path.Join(sourceRoot, relativePath)
if _, err := os.Stat(source); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
target := path.Join(targetRoot, relativePath)
if err := os.RemoveAll(target); err != nil {
return err
}
return copyOpenrestyUpgradeSnapshotEntry(source, target)
}
func createUpgradeFileSnapshot(installPath string, paths []string) (*upgradeFileSnapshot, error) {
backupPath, err := os.MkdirTemp("", "1panel-app-upgrade-*")
if err != nil {
return nil, err
}
snapshot := &upgradeFileSnapshot{
installPath: installPath,
backupPath: backupPath,
paths: paths,
existing: make(map[string]bool, len(paths)),
}
for _, relativePath := range paths {
source := path.Join(installPath, relativePath)
if _, err = os.Stat(source); err != nil {
if os.IsNotExist(err) {
continue
}
snapshot.Cleanup()
return nil, err
}
snapshot.existing[relativePath] = true
if err = copyOpenrestyUpgradeSnapshotEntry(source, path.Join(backupPath, relativePath)); err != nil {
snapshot.Cleanup()
return nil, err
}
}
return snapshot, nil
}
func (s *upgradeFileSnapshot) Restore() error {
for _, relativePath := range s.paths {
target := path.Join(s.installPath, relativePath)
if err := os.RemoveAll(target); err != nil {
return err
}
if !s.existing[relativePath] {
continue
}
if err := copyOpenrestyUpgradeSnapshotEntry(path.Join(s.backupPath, relativePath), target); err != nil {
return err
}
}
return nil
}
func (s *upgradeFileSnapshot) Cleanup() {
if s != nil && s.backupPath != "" {
_ = os.RemoveAll(s.backupPath)
}
}
type appContainerReadinessClient interface {
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
ContainerInspect(context.Context, string) (container.InspectResponse, error)
}
func waitAppContainersReady(ctx context.Context, install model.AppInstall) ([]string, error) {
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
return waitAppContainersReadyWithClient(ctx, client, install)
}
func waitAppContainersReadyWithClient(ctx context.Context, client appContainerReadinessClient, install model.AppInstall) ([]string, error) {
envContent, err := os.ReadFile(install.GetEnvPath())
if err != nil {
envContent, err = renderUpgradeEnv(&install, nil)
if err != nil {
return nil, err
}
}
project, err := docker.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), envContent, false)
if err != nil {
return nil, err
}
expectedServices := make(map[string]struct{})
for _, service := range project.Services {
if !skipCheckStatus(service) {
expectedServices[service.Name] = struct{}{}
}
}
if len(expectedServices) == 0 {
return strings.Split(install.ContainerName, ","), nil
}
options := container.ListOptions{
All: true,
Filters: filters.NewArgs(
filters.Arg("label", composeWorkdirLabel+"="+install.GetPath()),
),
}
containers, err := client.ContainerList(ctx, options)
if err != nil {
return nil, err
}
foundServices := make(map[string]bool, len(expectedServices))
containerNames := make([]string, 0, len(containers))
for _, item := range containers {
serviceName := item.Labels[composeServiceLabel]
if _, ok := expectedServices[serviceName]; !ok {
continue
}
if err = waitContainerReady(ctx, client, item.ID); err != nil {
return nil, fmt.Errorf("container %s is not ready: %w", serviceName, err)
}
foundServices[serviceName] = true
if len(item.Names) > 0 {
containerNames = append(containerNames, strings.TrimPrefix(item.Names[0], "/"))
}
}
for serviceName := range expectedServices {
if !foundServices[serviceName] {
return nil, fmt.Errorf("container for service %s was not created", serviceName)
}
}
sort.Strings(containerNames)
return containerNames, nil
}
+77 -416
View File
@@ -9,7 +9,6 @@ import (
"math"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"reflect"
@@ -54,6 +53,62 @@ var (
Delete DatabaseOp = "delete"
)
func isDemoReadOnly(readOnly ...bool) bool {
return global.CONF.Base.IsDemo || len(readOnly) > 0 && readOnly[0]
}
func normalizeConfigKey(key string) string {
return strings.ToLower(strings.NewReplacer("_", "", "-", "", ".", "", " ", "").Replace(key))
}
func isSensitiveConfigKey(key string) bool {
normalized := normalizeConfigKey(key)
for _, marker := range []string{"password", "passwd", "passphrase", "secret", "token", "credential", "privatekey", "authorization"} {
if strings.Contains(normalized, marker) {
return true
}
}
return normalized == "key" || strings.HasSuffix(normalized, "key")
}
func redactSensitiveValues(values map[string]interface{}) {
redactSensitiveData(values)
}
func redactSensitiveData(value interface{}) {
switch data := value.(type) {
case map[string]interface{}:
for key, item := range data {
if isSensitiveConfigKey(key) {
data[key] = ""
} else {
redactSensitiveData(item)
}
}
case []interface{}:
for _, item := range data {
redactSensitiveData(item)
}
}
}
func redactSensitiveAppParams(params []response.AppParam) {
for i := range params {
if strings.EqualFold(params[i].Type, "password") || isSensitiveConfigKey(params[i].Key) {
params[i].Value = ""
params[i].ShowValue = ""
}
}
}
func redactSensitiveEnvironments(environments []request.Environment) {
for i := range environments {
if isSensitiveConfigKey(environments[i].Key) {
environments[i].Value = ""
}
}
}
func checkPort(key string, params map[string]interface{}) (int, error) {
port, ok := params[key]
if ok {
@@ -783,416 +838,6 @@ func buildNginx(parentTask *task.Task, nginxInstall model.AppInstall, catalogPat
return commitNginxModuleBuilds(nginxInstall, previousModules, modules, false, catalogPath)
}
func upgradeInstall(req request.AppInstallUpgrade) error {
install, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if err != nil {
return err
}
originalInstall := install
oldVersion := install.Version
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil {
return err
}
if install.App.Key == vllmAppKeyForUpgrade && !isVllmUpgradeVersionAllowed(install.Version, detail.Version, loadVllmImageFromEnv(install.Env)) {
return errors.New("vLLM can only upgrade within the same image type")
}
if install.Version == detail.Version {
return errors.New("two version is same")
}
upgradeTask, err := task.NewTaskWithOps(install.Name, task.TaskUpgrade, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil {
return err
}
install.Status = constant.StatusUpgrading
var (
upErr error
backupFile string
nginxUpgradeSnapshot *openrestyUpgradeSnapshot
)
backUpApp := func(t *task.Task) error {
backupService := NewIBackupService()
backupRecordService := NewIBackupRecordService()
fileName := fmt.Sprintf("upgrade_backup_%s_%s.tar.gz", install.Name, time.Now().Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5))
backupRecord, err := backupService.AppBackup(dto.CommonBackup{Name: install.App.Key, DetailName: install.Name, FileName: fileName})
if err == nil {
backups, _ := backupRecordService.ListAppRecords(install.App.Key, install.Name, "upgrade_backup")
if len(backups) > 3 {
backupsToDelete := backups[:len(backups)-3]
var deleteIDs []uint
for _, backup := range backupsToDelete {
deleteIDs = append(deleteIDs, backup.ID)
}
_ = backupRecordService.BatchDeleteRecord(deleteIDs)
}
backupFile = path.Join(global.Dir.LocalBackupDir, backupRecord.FileDir, backupRecord.FileName)
} else {
return buserr.WithNameAndErr("ErrAppBackup", install.Name, err)
}
return nil
}
if req.Backup {
upgradeTask.AddSubTask(task.GetTaskName(install.Name, task.TaskBackup, task.TaskScopeApp), backUpApp, nil)
}
upgradeApp := func(t *task.Task) error {
fileOp := files.NewFileOp()
detailDir := path.Join(global.Dir.ResourceDir, "apps", install.App.Resource, install.App.Key, detail.Version)
if install.App.Resource == constant.AppResourceRemote {
if err = downloadApp(install.App, detail, &install, t.Logger); err != nil {
return err
}
if detail.DockerCompose == "" {
composeDetail, err := fileOp.GetContent(path.Join(detailDir, "docker-compose.yml"))
if err != nil {
return err
}
detail.DockerCompose = string(composeDetail)
_ = appDetailRepo.Update(context.Background(), detail)
}
go func() {
RequestDownloadCallBack(detail.DownloadCallBackUrl)
}()
}
if install.App.Resource == constant.AppResourceLocal {
detailDir = path.Join(global.Dir.ResourceDir, "apps", "local", strings.TrimPrefix(install.App.Key, "local"), detail.Version)
}
content, err := fileOp.GetContent(install.GetEnvPath())
if err != nil {
return err
}
oldEnvContent := append([]byte(nil), content...)
oldDockerCompose := install.DockerCompose
targetNginxCatalogPath := ""
if install.App.Key == constant.AppOpenresty {
nginxUpgradeSnapshot, err = createOpenrestyUpgradeSnapshot(install.GetPath())
if err != nil {
return err
}
}
if install.App.Key == vllmAppKeyForUpgrade {
envs := make(map[string]interface{})
if err = json.Unmarshal([]byte(install.Env), &envs); err != nil {
return err
}
image := buildVllmUpgradeImage(loadVllmImageFromEnv(install.Env), oldVersion, detail.Version)
envs[vllmImageEnvKey] = image
paramByte, err := json.Marshal(envs)
if err != nil {
return err
}
install.Env = string(paramByte)
content = setVllmImageInEnvContent(content, image)
}
_ = copyAppDetailMissing(fileOp, detailDir, install.GetPath())
if install.App.Key == constant.AppOpenresty {
installBuildDir := path.Join(install.GetPath(), nginxModuleBuildDir)
detailBuildDir := path.Join(detailDir, nginxModuleBuildDir)
if !fileOp.Stat(installBuildDir) {
if err := fileOp.CreateDir(installBuildDir, constant.DirPerm); err != nil {
return err
}
}
if err := fileOp.DeleteDir(path.Join(installBuildDir, nginxModuleTmpDir)); err != nil {
return err
}
if err := fileOp.CopyDir(path.Join(detailBuildDir, nginxModuleTmpDir), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "Dockerfile"), installBuildDir); err != nil {
return err
}
if err := syncNginxModuleBuilder(detailBuildDir, installBuildDir); err != nil {
return err
}
targetCatalogSource := path.Join(detailBuildDir, nginxModuleCatalogFile)
if !fileOp.Stat(targetCatalogSource) {
return fmt.Errorf("target OpenResty module catalog not found: %s", targetCatalogSource)
}
targetNginxCatalogPath = path.Join(installBuildDir, nginxModuleCatalogPendingFile)
if err := stageNginxModuleCatalog(targetCatalogSource, targetNginxCatalogPath); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.conf"), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.vh.default.conf"), installBuildDir); err != nil {
return err
}
}
sourceScripts := path.Join(detailDir, "scripts")
if fileOp.Stat(sourceScripts) {
dstScripts := path.Join(install.GetPath(), "scripts")
_ = fileOp.DeleteDir(dstScripts)
_ = fileOp.CreateDir(dstScripts, constant.DirPerm)
scriptCmd := exec.Command("cp", "-rf", sourceScripts+"/.", dstScripts+"/")
_, _ = scriptCmd.CombinedOutput()
}
var newCompose string
if err = migrateOpenclawProtocolUpgrade(&install, oldVersion, detail.Version); err != nil {
return err
}
if req.DockerCompose == "" {
if install.App.Key == vllmAppKeyForUpgrade {
newCompose = install.DockerCompose
} else {
newCompose, err = getUpgradeCompose(install, detail)
if err != nil {
return err
}
}
} else {
newCompose = req.DockerCompose
}
install.DockerCompose = newCompose
install.Version = detail.Version
install.AppDetailId = req.DetailID
var oldImageIDs []appImageID
if req.DeleteImage {
dockerCLi, err := docker.NewClient()
if err != nil {
return err
}
oldImageIDs, err = getAppImageIDsByCompose(dockerCLi, oldEnvContent, []byte(oldDockerCompose))
dockerCLi.Close()
if err != nil {
return err
}
}
if req.PullImage {
images, err := docker.GetImagesFromDockerCompose(content, []byte(install.DockerCompose))
if err != nil {
return err
}
dockerCLi, err := docker.NewClient()
if err != nil {
return err
}
defer dockerCLi.Close()
for _, image := range images {
t.Log(i18n.GetWithName("PullImageStart", image))
if pullErr := dockerCLi.PullImageWithProcess(t, image); pullErr != nil {
if exist, _ := dockerCLi.ImageExists(image); exist {
t.Log(i18n.GetMsgByKey("UseExistImage"))
continue
}
return buserr.WithNameAndErr("ErrDockerPullImage", "", pullErr)
}
exist, err := dockerCLi.ImageExists(image)
if err != nil || !exist {
return buserr.WithNameAndErr("ErrDockerPullImage", "", fmt.Errorf("image %s does not exist after pull: %v", image, err))
}
t.LogSuccess(i18n.GetMsgByKey("PullImage"))
}
}
if install.App.Key == constant.AppOpenresty {
modules, moduleErr := loadNginxModulesWithCatalog(install, targetNginxCatalogPath)
if moduleErr != nil {
return moduleErr
}
// Build dynamic modules for the target version before stopping the
// current container. Static modules retain the full rebuild path.
if !hasEnabledStaticNginxModules(modules) {
previousModules := cloneNginxModules(modules)
modules, moduleErr = buildDynamicNginxModules(install, modules, nil, false, "", targetNginxCatalogPath, t)
if moduleErr != nil {
return moduleErr
}
if moduleErr = saveNginxModulesWithCatalog(install, modules, targetNginxCatalogPath); moduleErr != nil {
removeNginxModuleOutputsNotReferenced(install, modules, previousModules)
return moduleErr
}
}
}
if out, err := compose.Down(install.GetComposePath()); err != nil {
if out != "" {
upErr = errors.New(out)
return upErr
}
return err
}
envs := make(map[string]interface{})
if err = json.Unmarshal([]byte(install.Env), &envs); err != nil {
return err
}
envParams := make(map[string]string, len(envs))
if install.App.Key == constant.AppOpenresty {
packageUrl, _ := env.GetEnvValueByKey(install.GetEnvPath(), "CONTAINER_PACKAGE_URL")
addPackage, _ := env.GetEnvValueByKey(install.GetEnvPath(), "RESTY_ADD_PACKAGE_BUILDDEPS")
options, _ := env.GetEnvValueByKey(install.GetEnvPath(), "RESTY_CONFIG_OPTIONS_MORE")
envParams["CONTAINER_PACKAGE_URL"] = packageUrl
envParams["RESTY_ADD_PACKAGE_BUILDDEPS"] = addPackage
envParams["RESTY_CONFIG_OPTIONS_MORE"] = options
}
handleMap(envs, envParams)
if err = env.Write(envParams, install.GetEnvPath()); err != nil {
return err
}
if err = runScript(t, &install, "upgrade"); err != nil {
return err
}
if err = fileOp.WriteFile(install.GetComposePath(), strings.NewReader(install.DockerCompose), constant.FilePerm); err != nil {
return err
}
if install.App.Key == constant.AppOpenresty {
if err = buildNginx(t, install, targetNginxCatalogPath); err != nil {
t.Log(err.Error())
return err
}
}
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App"))
t.Log(logStr)
if out, err := compose.Up(install.GetComposePath()); err != nil {
if out != "" {
return errors.New(out)
}
return err
}
t.LogSuccess(logStr)
install.Status = constant.StatusRunning
if install.App.Key == constant.AppOpenresty {
if err = commitStaticNginxModuleBuilds(install, targetNginxCatalogPath, t); err != nil {
return err
}
activeCatalogPath := path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleCatalogFile)
if err = activateNginxModuleCatalogAndCommit(targetNginxCatalogPath, activeCatalogPath, func() error {
return appInstallRepo.Save(context.Background(), &install)
}); err != nil {
return err
}
} else {
if err = appInstallRepo.Save(context.Background(), &install); err != nil {
return err
}
}
if nginxUpgradeSnapshot != nil {
nginxUpgradeSnapshot.Cleanup()
nginxUpgradeSnapshot = nil
}
if req.DeleteImage {
newEnvContent, err := fileOp.GetContent(install.GetEnvPath())
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
return nil
}
excludeImages, err := docker.GetImagesFromDockerCompose(newEnvContent, []byte(install.DockerCompose))
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
return nil
}
dockerCLi, err := docker.NewClient()
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
return nil
}
defer dockerCLi.Close()
if err = deleteAppImagesByIDs(t, dockerCLi, oldImageIDs, excludeImages); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
}
}
return nil
}
rollBackApp := func(t *task.Task) {
if req.Backup {
t.Log(i18n.GetWithName("AppRecover", install.Name))
recoverErr := NewIBackupService().AppRecover(dto.CommonRecover{
Name: install.App.Key, DetailName: install.Name, Type: "app", DownloadAccountID: 1, File: backupFile,
})
if recoverErr == nil {
if nginxUpgradeSnapshot != nil {
nginxUpgradeSnapshot.Cleanup()
nginxUpgradeSnapshot = nil
}
t.LogSuccess(i18n.GetWithName("AppRecover", install.Name))
return
}
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), recoverErr)
if install.App.Key != constant.AppOpenresty {
return
}
}
if install.App.Key == constant.AppOpenresty && nginxUpgradeSnapshot != nil {
if out, rollbackErr := compose.Down(install.GetComposePath()); rollbackErr != nil {
if out != "" {
rollbackErr = fmt.Errorf("%s: %w", out, rollbackErr)
}
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), rollbackErr)
}
if rollbackErr := nginxUpgradeSnapshot.Restore(); rollbackErr != nil {
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), rollbackErr)
return
}
nginxUpgradeSnapshot.Cleanup()
nginxUpgradeSnapshot = nil
if out, rollbackErr := compose.Up(originalInstall.GetComposePath()); rollbackErr != nil {
if out != "" {
rollbackErr = fmt.Errorf("%s: %w", out, rollbackErr)
}
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), rollbackErr)
return
}
originalInstall.Status = constant.StatusRunning
originalInstall.Message = ""
if rollbackErr := appInstallRepo.Save(context.Background(), &originalInstall); rollbackErr != nil {
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), rollbackErr)
return
}
install = originalInstall
t.LogSuccess(i18n.GetWithName("AppRecover", install.Name))
return
}
if install.App.Key == constant.AppOpenresty {
if rollbackErr := appInstallRepo.Save(context.Background(), &originalInstall); rollbackErr != nil {
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), rollbackErr)
return
}
install = originalInstall
t.LogSuccess(i18n.GetWithName("AppRecover", install.Name))
}
}
upgradeTimeout := 1 * time.Hour
if install.App.Key == constant.AppOpenresty {
// Dynamic modules are built serially and each Docker build has its own
// timeout. An outer deadline would start rollback while upgradeApp is
// still mutating the installation because SubTask does not stop its
// action goroutine on timeout.
upgradeTimeout = 0
}
upgradeTask.AddSubTaskWithOps(task.GetTaskName(install.Name, task.TaskUpgrade, task.TaskScopeApp), upgradeApp, rollBackApp, 0, upgradeTimeout)
upgradingInstall := install
if err = appInstallRepo.Save(context.Background(), &upgradingInstall); err != nil {
return err
}
go func() {
if taskErr := upgradeTask.Execute(); taskErr != nil {
existInstall, _ := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if existInstall.ID > 0 && existInstall.Status != constant.StatusRunning {
existInstall.Status = constant.StatusUpgradeErr
existInstall.Message = taskErr.Error()
_ = appInstallRepo.Save(context.Background(), &existInstall)
}
}
}()
return nil
}
func skipCheckStatus(service types.ServiceConfig) bool {
for key := range service.Labels {
if key == "skipStatusCheck" {
@@ -1798,7 +1443,9 @@ func synAppInstall(containers map[string]container.Summary, appInstall *model.Ap
}
appInstall.Status = constant.StatusError
appInstall.Message = buserr.WithName("ErrContainerNotFound", strings.Join(containerNames, ",")).Error()
_ = appInstallRepo.Save(context.Background(), appInstall)
if !global.CONF.Base.IsDemo {
_ = appInstallRepo.Save(context.Background(), appInstall)
}
return
}
notFoundNames := make([]string, 0)
@@ -1857,10 +1504,12 @@ func synAppInstall(containers map[string]container.Summary, appInstall *model.Ap
appInstall.Message = msg
appInstall.Status = constant.StatusUnHealthy
}
_ = appInstallRepo.Save(context.Background(), appInstall)
if !global.CONF.Base.IsDemo {
_ = appInstallRepo.Save(context.Background(), appInstall)
}
}
func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpdate bool) ([]response.AppInstallDTO, error) {
func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpdate, readOnly bool) ([]response.AppInstallDTO, error) {
var (
res []response.AppInstallDTO
containersMap map[string]container.Summary
@@ -1891,6 +1540,9 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
resourceKeys := getAppInstallResourceKeys(installed.ID)
envMap := make(map[string]interface{})
_ = json.Unmarshal([]byte(installed.Env), &envMap)
if isDemoReadOnly(readOnly) {
redactSensitiveValues(envMap)
}
installDTO := response.AppInstallDTO{
ID: installed.ID,
Name: installed.Name,
@@ -1935,7 +1587,9 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
continue
}
installDTO.DockerCompose = installed.DockerCompose
if !isDemoReadOnly(readOnly) {
installDTO.DockerCompose = installed.DockerCompose
}
installDTO.IsEdit = isEditCompose(installed)
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(installed.App.ID))
@@ -2233,6 +1887,10 @@ func isHostModel(dockerCompose string) bool {
}
func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
return copyAppDetailMissingTracked(fileOp, srcDir, dstDir, nil)
}
func copyAppDetailMissingTracked(fileOp files.FileOp, srcDir, dstDir string, createdPaths *[]string) error {
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
@@ -2244,6 +1902,9 @@ func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
srcPath := path.Join(srcDir, entry.Name())
dstPath := path.Join(dstDir, entry.Name())
if !fileOp.Stat(dstPath) {
if createdPaths != nil {
*createdPaths = append(*createdPaths, dstPath)
}
if entry.IsDir() {
if err := fileOp.CopyDir(srcPath, dstDir); err != nil {
return err
@@ -2258,7 +1919,7 @@ func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
if !entry.IsDir() {
continue
}
if err := copyAppDetailMissing(fileOp, srcPath, dstPath); err != nil {
if err := copyAppDetailMissingTracked(fileOp, srcPath, dstPath, createdPaths); err != nil {
return err
}
}
+27 -2
View File
@@ -33,7 +33,7 @@ type IBackupService interface {
CheckUsed(name string, isPublic bool) error
LoadBackupOptions() ([]dto.BackupOption, error)
SearchWithPage(search dto.SearchPageWithType) (int64, interface{}, error)
SearchWithPage(search dto.SearchPageWithType, readOnly ...bool) (int64, interface{}, error)
Create(backupDto dto.BackupOperate) error
CheckConn(req dto.BackupOperate) dto.BackupCheckRes
GetBuckets(backupDto dto.ForBuckets) ([]interface{}, error)
@@ -80,7 +80,7 @@ func (u *BackupService) GetLocalDir() (string, error) {
return account.BackupPath, nil
}
func (u *BackupService) SearchWithPage(req dto.SearchPageWithType) (int64, interface{}, error) {
func (u *BackupService) SearchWithPage(req dto.SearchPageWithType, readOnly ...bool) (int64, interface{}, error) {
options := []repo.DBOption{repo.WithOrderDesc("created_at")}
if len(req.Type) != 0 {
options = append(options, repo.WithByType(req.Type))
@@ -128,11 +128,36 @@ func (u *BackupService) SearchWithPage(req dto.SearchPageWithType) (int64, inter
itemVars, _ := json.Marshal(varMap)
item.Vars = string(itemVars)
}
if isDemoReadOnly(readOnly...) {
item.AccessKey = ""
item.Credential = ""
item.Vars = sanitizeBackupVars(item.Vars)
}
data = append(data, item)
}
return count, data, nil
}
func sanitizeBackupVars(vars string) string {
if vars == "" {
return vars
}
var values map[string]interface{}
if err := json.Unmarshal([]byte(vars), &values); err != nil {
return ""
}
for key := range values {
if isSensitiveConfigKey(key) || normalizeConfigKey(key) == "code" {
delete(values, key)
}
}
data, err := json.Marshal(values)
if err != nil {
return ""
}
return string(data)
}
func (u *BackupService) CheckConn(req dto.BackupOperate) dto.BackupCheckRes {
var res dto.BackupCheckRes
var backup model.BackupAccount
+33 -1
View File
@@ -90,6 +90,34 @@ func (u *BackupService) AppBackup(req dto.CommonBackup) (*model.BackupRecord, er
return record, nil
}
func backupAppWithParentTask(install *model.AppInstall, parentTask *task.Task, fileName string) (*model.BackupRecord, error) {
itemDir := fmt.Sprintf("app/%s/%s", install.App.Key, install.Name)
backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)
record := &model.BackupRecord{
Type: "app",
Name: install.App.Key,
DetailName: install.Name,
SourceAccountIDs: "1",
DownloadAccountID: 1,
FileDir: itemDir,
FileName: fileName,
TaskID: parentTask.TaskID,
Status: constant.StatusWaiting,
}
if err := backupRepo.CreateRecord(record); err != nil {
return nil, err
}
if err := handleAppBackup(install, parentTask, record.ID, backupDir, fileName, "", "", parentTask.TaskID); err != nil {
markBackupFailed(record.ID, err)
record.Status = constant.StatusFailed
record.Message = err.Error()
return record, err
}
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusSuccess})
record.Status = constant.StatusSuccess
return record, nil
}
func (u *BackupService) AppRecover(req dto.CommonRecover) error {
app, err := appRepo.GetFirst(appRepo.WithKey(req.Name))
if err != nil {
@@ -203,7 +231,11 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
return err
}
defer func() {
_, _ = compose.Up(install.GetComposePath())
if isRollback {
_, _ = compose.UpWithoutBuild(install.GetComposePath())
} else {
_, _ = compose.Up(install.GetComposePath())
}
_ = os.RemoveAll(strings.ReplaceAll(recoverFile, ".tar.gz", ""))
}()
+41
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"fmt"
"os"
"path"
@@ -39,6 +40,7 @@ func (u *BackupService) MysqlBackup(req dto.CommonBackup) error {
TaskID: req.TaskID,
Status: constant.StatusWaiting,
Description: req.Description,
Args: encodeBackupArgs(req.Args),
}
if err := backupRepo.CreateRecord(record); err != nil {
global.LOG.Errorf("save backup record failed, err: %v", err)
@@ -143,6 +145,14 @@ func handleMysqlRecover(req dto.CommonRecover, parentTask *task.Task, isRollback
if !isRollback {
rollbackFile := path.Join(global.Dir.TmpDir, fmt.Sprintf("database/%s/%s_%s.sql.gz", req.Type, req.DetailName, time.Now().Format(constant.DateTimeSlimLayout)))
var rollbackArgs []string
if req.BackupRecordID != 0 {
record, err := backupRepo.GetRecord(repo.WithByID(req.BackupRecordID))
if err != nil {
return err
}
rollbackArgs = decodeBackupArgs(record.Args)
}
if err := cli.Backup(client.BackupInfo{
Name: req.DetailName,
Type: req.Type,
@@ -150,6 +160,7 @@ func handleMysqlRecover(req dto.CommonRecover, parentTask *task.Task, isRollback
Format: dbInfo.Format,
TargetDir: path.Dir(rollbackFile),
FileName: path.Base(rollbackFile),
Args: rollbackArgs,
}); err != nil {
return fmt.Errorf("backup mysql db %s for rollback before recover failed, err: %v", req.DetailName, err)
}
@@ -242,6 +253,36 @@ func doMysqlBackup(db DatabaseHelper, targetDir, fileName, secret string) error
return nil
}
func encodeBackupArgs(args []string) string {
var items []string
for _, arg := range args {
if len(arg) != 0 {
items = append(items, arg)
}
}
if len(items) == 0 {
return ""
}
data, err := json.Marshal(items)
if err != nil {
global.LOG.Warnf("marshal backup args failed: %v", err)
return ""
}
return string(data)
}
func decodeBackupArgs(value string) []string {
if len(value) == 0 {
return nil
}
var args []string
if err := json.Unmarshal([]byte(value), &args); err != nil {
global.LOG.Warnf("unmarshal backup args failed: %v", err)
return nil
}
return args
}
func loadSqlFile(file string) (string, error) {
if !strings.HasSuffix(file, ".tar.gz") && !strings.HasSuffix(file, ".zip") {
return file, nil
+3 -3
View File
@@ -33,7 +33,7 @@ type ClamService struct {
}
type IClamService interface {
LoadBaseInfo() (dto.ClamBaseInfo, error)
LoadBaseInfo(readOnly bool) (dto.ClamBaseInfo, error)
Operate(operate string) error
SearchWithPage(search dto.SearchClamWithPage) (int64, interface{}, error)
Create(req dto.ClamCreate, operator string) error
@@ -53,7 +53,7 @@ func NewIClamService() IClamService {
return &ClamService{}
}
func (c *ClamService) LoadBaseInfo() (dto.ClamBaseInfo, error) {
func (c *ClamService) LoadBaseInfo(readOnly bool) (dto.ClamBaseInfo, error) {
var baseInfo dto.ClamBaseInfo
baseInfo.Version = "-"
baseInfo.FreshVersion = "-"
@@ -96,7 +96,7 @@ func (c *ClamService) LoadBaseInfo() (dto.ClamBaseInfo, error) {
baseInfo.Version = strings.TrimPrefix(version, "ClamAV ")
}
}
} else {
} else if !readOnly && !global.CONF.Base.IsDemo {
_ = clam.CheckWithStopAll(false, clamRepo)
}
if baseInfo.FreshIsActive {
+10 -4
View File
@@ -11,8 +11,8 @@ import (
type ComposeTemplateService struct{}
type IComposeTemplateService interface {
List() ([]dto.ComposeTemplateInfo, error)
SearchWithPage(search dto.SearchWithPage) (int64, interface{}, error)
List(readOnly ...bool) ([]dto.ComposeTemplateInfo, error)
SearchWithPage(search dto.SearchWithPage, readOnly ...bool) (int64, interface{}, error)
Create(req dto.ComposeTemplateCreate) error
Update(id uint, upMap map[string]interface{}) error
Batch(req dto.ComposeTemplateBatch) error
@@ -23,7 +23,7 @@ func NewIComposeTemplateService() IComposeTemplateService {
return &ComposeTemplateService{}
}
func (u *ComposeTemplateService) List() ([]dto.ComposeTemplateInfo, error) {
func (u *ComposeTemplateService) List(readOnly ...bool) ([]dto.ComposeTemplateInfo, error) {
composes, err := composeRepo.List()
if err != nil {
return nil, buserr.New("ErrRecordNotFound")
@@ -34,12 +34,15 @@ func (u *ComposeTemplateService) List() ([]dto.ComposeTemplateInfo, error) {
if err := copier.Copy(&item, &compose); err != nil {
return nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
if isDemoReadOnly(readOnly...) {
item.Content = ""
}
dtoLists = append(dtoLists, item)
}
return dtoLists, err
}
func (u *ComposeTemplateService) SearchWithPage(req dto.SearchWithPage) (int64, interface{}, error) {
func (u *ComposeTemplateService) SearchWithPage(req dto.SearchWithPage, readOnly ...bool) (int64, interface{}, error) {
total, composes, err := composeRepo.Page(req.Page, req.PageSize, repo.WithByLikeName(req.Info))
var dtoComposeTemplates []dto.ComposeTemplateInfo
for _, compose := range composes {
@@ -47,6 +50,9 @@ func (u *ComposeTemplateService) SearchWithPage(req dto.SearchWithPage) (int64,
if err := copier.Copy(&item, &compose); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
if isDemoReadOnly(readOnly...) {
item.Content = ""
}
dtoComposeTemplates = append(dtoComposeTemplates, item)
}
return total, dtoComposeTemplates, err
+10 -3
View File
@@ -65,18 +65,19 @@ type IContainerService interface {
PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
ListVolume() ([]dto.Options, error)
PageCompose(req dto.SearchWithPage) (int64, interface{}, error)
PageCompose(req dto.SearchWithPage, readOnly ...bool) (int64, interface{}, error)
LoadComposeEnv(name string) (string, error)
CreateCompose(req dto.ComposeCreate) error
ComposeOperation(req dto.ComposeOperation) error
TestCompose(req dto.ComposeCreate) (bool, error)
ComposeUpdate(req dto.ComposeUpdate) error
ComposePin(req dto.ComposePin) error
ComposeLogClean(req dto.ComposeLogClean) error
ContainerCreate(req dto.ContainerOperate, inThread bool) error
ContainerUpdate(req dto.ContainerOperate) error
ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
ContainerInfo(req dto.OperationWithName, readOnly ...bool) (*dto.ContainerOperate, error)
ContainerListStats() ([]dto.ContainerListStats, error)
ContainerItemStats(req dto.OperationWithName) (dto.ContainerItemStats, error)
LoadResourceLimit() (*dto.ResourceLimit, error)
@@ -567,7 +568,7 @@ func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bo
return taskItem.Execute()
}
func (u *ContainerService) ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error) {
func (u *ContainerService) ContainerInfo(req dto.OperationWithName, readOnly ...bool) (*dto.ContainerOperate, error) {
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
@@ -611,6 +612,9 @@ func (u *ContainerService) ContainerInfo(req dto.OperationWithName) (*dto.Contai
data.Tty = oldContainer.Config.Tty
data.Entrypoint = oldContainer.Config.Entrypoint
data.Env = oldContainer.Config.Env
if isDemoReadOnly(readOnly...) {
data.Env = nil
}
data.CPUShares = oldContainer.HostConfig.CPUShares
for key, val := range oldContainer.Config.Labels {
data.Labels = append(data.Labels, fmt.Sprintf("%s=%s", key, val))
@@ -2025,6 +2029,9 @@ func loadComposeCount(client *client.Client) int {
}
}
for _, compose := range composeCreatedByLocal {
if len(compose.Path) == 0 {
continue
}
if _, has := composeMap[compose.Name]; !has {
composeMap[compose.Name] = struct{}{}
}
+327 -38
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
@@ -23,8 +24,10 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"gopkg.in/yaml.v3"
)
const composeProjectLabel = "com.docker.compose.project"
@@ -32,7 +35,7 @@ const composeConfigLabel = "com.docker.compose.project.config_files"
const composeWorkdirLabel = "com.docker.compose.project.working_dir"
const composeCreatedBy = "createdBy"
func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface{}, error) {
func (u *ContainerService) PageCompose(req dto.SearchWithPage, readOnly ...bool) (int64, interface{}, error) {
var (
records []dto.ComposeInfo
BackDatas []dto.ComposeInfo
@@ -52,7 +55,15 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
return 0, nil, err
}
composeCreatedByLocal, _ := composeRepo.ListRecord()
composeRecords, _ := composeRepo.ListRecord()
pinnedByName := make(map[string]bool, len(composeRecords))
composeCreatedByLocal := make([]model.Compose, 0, len(composeRecords))
for _, record := range composeRecords {
pinnedByName[record.Name] = record.IsPinned
if len(record.Path) != 0 {
composeCreatedByLocal = append(composeCreatedByLocal, record)
}
}
composeLocalMap := make(map[string]dto.ComposeInfo)
for _, localItem := range composeCreatedByLocal {
composeItemLocal := dto.ComposeInfo{
@@ -136,6 +147,7 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
for key, value := range mergedMap {
value.Name = key
value.ComposeFileExists = composeFileExists(value.Workdir, value.ConfigFile)
value.IsPinned = pinnedByName[key]
records = append(records, value)
}
if len(req.Info) != 0 {
@@ -149,7 +161,21 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
}
}
}
if req.ExcludeAppStore {
length, count := len(records), 0
for count < length {
if records[count].CreatedBy == "Apps" {
records = append(records[:count], records[(count+1):]...)
length--
} else {
count++
}
}
}
sort.Slice(records, func(i, j int) bool {
if records[i].IsPinned != records[j].IsPinned {
return records[i].IsPinned
}
return records[i].CreatedAt > records[j].CreatedAt
})
total, start, end := len(records), (req.Page-1)*req.PageSize, req.Page*req.PageSize
@@ -161,7 +187,10 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
}
BackDatas = records[start:end]
}
listItem := loadEnv(BackDatas)
listItem := BackDatas
if !isDemoReadOnly(readOnly...) {
listItem = loadEnv(BackDatas)
}
return int64(total), listItem, nil
}
@@ -189,53 +218,62 @@ func composeFileExists(workdir, configFile string) bool {
}
func (u *ContainerService) TestCompose(req dto.ComposeCreate) (bool, error) {
if cmd.CheckIllegal(req.Path) {
if err := validateComposeCreateName(req); err != nil {
return false, err
}
if hasIllegalComposeCreateInput(req) {
return false, buserr.New("ErrCmdIllegal")
}
composeItem, _ := composeRepo.GetRecord(repo.WithByName(req.Name))
if composeItem.ID != 0 {
return false, buserr.New("ErrRecordExist")
}
if err := u.loadPath(&req); err != nil {
return false, err
}
if err := newComposeEnv(req.Path, req.Env); err != nil {
return false, err
}
cmd := getComposeCmd(req.Path, "config")
stdout, err := cmd.CombinedOutput()
projectName, err := resolveComposeCreateProjectName(req)
if err != nil {
return false, fmt.Errorf("docker-compose config failed, std: %s, err: %v", string(stdout), err)
return false, err
}
if err := checkComposeCreateDuplicate(req, projectName); err != nil {
return false, err
}
return true, nil
}
func (u *ContainerService) CreateCompose(req dto.ComposeCreate) error {
if cmd.CheckIllegal(req.Name, req.Path) {
if err := validateComposeCreateName(req); err != nil {
return err
}
if hasIllegalComposeCreateInput(req) {
return buserr.New("ErrCmdIllegal")
}
projectName, err := resolveComposeCreateProjectName(req)
if err != nil {
return err
}
if err := checkComposeCreateDuplicate(req, projectName); err != nil {
return err
}
if err := u.loadPath(&req); err != nil {
return err
}
if req.From == "path" {
req.Name = path.Base(path.Dir(req.Path))
if err := newComposeEnv(req.Path, req.Env); err != nil {
return err
}
req.Name = projectName
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskCreate, task.TaskScopeCompose, req.TaskID, 1)
if err != nil {
return fmt.Errorf("new task for image build failed, err: %v", err)
}
if err := newComposeEnv(req.Path, req.Env); err != nil {
return err
}
go func() {
taskItem.AddSubTask(i18n.GetMsgByKey("ComposeCreate"), func(t *task.Task) error {
err := compose.UpWithTask(req.Path, t, req.ForcePull)
err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name)
t.LogWithStatus(i18n.GetMsgByKey("ComposeCreate"), err)
if err != nil {
_, _ = compose.Down(req.Path)
_, _ = compose.Down(req.Path, req.Name)
return err
}
_ = composeRepo.CreateRecord(&model.Compose{Name: strings.ToLower(req.Name), Path: req.Path})
recordName := strings.ToLower(req.Name)
record, _ := composeRepo.GetRecord(repo.WithByName(recordName))
if record.ID == 0 {
_ = composeRepo.CreateRecord(&model.Compose{Name: recordName, Path: req.Path})
} else {
_ = composeRepo.UpdateRecord(recordName, map[string]interface{}{"path": req.Path})
}
return nil
}, nil)
_ = taskItem.Execute()
@@ -244,6 +282,243 @@ func (u *ContainerService) CreateCompose(req dto.ComposeCreate) error {
return nil
}
func checkComposeRecordName(name string) error {
composeItem, _ := composeRepo.GetRecord(repo.WithByName(name))
if composeItem.ID != 0 && len(composeItem.Path) != 0 {
return buserr.New("ErrRecordExist")
}
return nil
}
func checkComposeCreateDuplicate(req dto.ComposeCreate, projectName string) error {
if err := checkComposeRecordName(projectName); err != nil {
return err
}
if req.From == "path" {
return nil
}
composeItem, _ := composeRepo.GetRecord(repo.WithByPath(composeCreatePath(req)))
if composeItem.ID != 0 && composeItem.Path != "" {
return buserr.New("ErrRecordExist")
}
return nil
}
func validateComposeCreateName(req dto.ComposeCreate) error {
if req.From == "path" {
name := strings.TrimSpace(req.Name)
if name != "" && !re.GetRegex(re.ComposeNamePattern).MatchString(name) {
return buserr.New("ErrComposeNameInvalid")
}
return nil
}
if !re.GetRegex(re.ComposeNamePattern).MatchString(composeCreateDirName(req)) {
return buserr.New("ErrComposeNameInvalid")
}
return nil
}
func hasIllegalComposeCreateInput(req dto.ComposeCreate) bool {
if req.From == "path" {
return cmd.CheckIllegal(req.Name, req.Path)
}
return cmd.CheckIllegal(composeCreateDirName(req))
}
func composeCreateDirName(req dto.ComposeCreate) string {
dirName := strings.TrimSpace(req.DirName)
if dirName == "" {
// Keep compatibility with callers that used name as both the directory and
// Compose project name before dirName was introduced.
return strings.TrimSpace(req.Name)
}
return dirName
}
func composeCreatePath(req dto.ComposeCreate) string {
return filepath.Join(global.Dir.DataDir, "docker", "compose", composeCreateDirName(req), "docker-compose.yml")
}
func resolveComposeCreateProjectName(req dto.ComposeCreate) (string, error) {
if req.From == "path" {
envPath, err := createComposeTempFile(
filepath.Dir(primaryComposePath(req.Path)),
".1panel-compose-*.env",
req.Env,
)
if err != nil {
return "", err
}
defer os.Remove(envPath)
return resolveComposeProjectName(req.Path, req.Name, envPath)
}
dir := filepath.Dir(composeCreatePath(req))
cleanupDir, err := prepareComposeStagingDir(dir)
if err != nil {
return "", err
}
defer cleanupDir()
composePath, err := createComposeTempFile(dir, ".1panel-compose-*.yml", req.File)
if err != nil {
return "", err
}
defer os.Remove(composePath)
envPath, err := createComposeTempFile(dir, ".1panel-compose-*.env", req.Env)
if err != nil {
return "", err
}
defer os.Remove(envPath)
return resolveComposeProjectName(composePath, "", envPath)
}
func createComposeTempFile(dir, pattern, content string) (string, error) {
file, err := os.CreateTemp(dir, pattern)
if err != nil {
return "", err
}
filePath := file.Name()
if _, err := file.WriteString(content); err != nil {
_ = file.Close()
_ = os.Remove(filePath)
return "", err
}
if err := file.Close(); err != nil {
_ = os.Remove(filePath)
return "", err
}
return filePath, nil
}
func prepareComposeStagingDir(dir string) (func(), error) {
if err := os.MkdirAll(filepath.Dir(dir), os.ModePerm); err != nil {
return nil, err
}
created := false
if err := os.Mkdir(dir, os.ModePerm); err != nil {
if !errors.Is(err, os.ErrExist) {
return nil, err
}
} else {
created = true
}
return func() {
if created {
_ = os.Remove(dir)
}
}, nil
}
func resolveComposeProjectName(composePath, fallbackName, envFile string) (string, error) {
// Preserve the name resolved by Compose (including a top-level name) so the
// container label and the local record always use the same project identity.
parentName := normalizeComposeProjectName(path.Base(path.Dir(primaryComposePath(composePath))))
fallbackName = strings.TrimSpace(fallbackName)
stdout, err := runComposeConfig(composePath, "", envFile)
if err == nil {
projectName, parseErr := loadComposeProjectName(stdout)
if parseErr != nil {
return "", parseErr
}
if projectName != "" {
if !re.GetRegex(re.ComposeNamePattern).MatchString(projectName) {
return "", buserr.New("ErrComposeNameInvalid")
}
return projectName, nil
}
if parentName != "" {
return parentName, nil
}
if fallbackName != "" {
if _, fallbackErr := runComposeConfig(composePath, fallbackName, envFile); fallbackErr != nil {
return "", fallbackErr
}
return fallbackName, nil
}
return "", buserr.New("ErrComposeProjectNameEmpty")
}
if !isComposeProjectNameEmptyError(err) {
return "", err
}
resolveErr := err
if parentName != "" {
if _, parentErr := runComposeConfig(composePath, parentName, envFile); parentErr == nil {
return parentName, nil
} else {
resolveErr = parentErr
}
}
if fallbackName != "" && fallbackName != parentName {
if _, fallbackErr := runComposeConfig(composePath, fallbackName, envFile); fallbackErr == nil {
return fallbackName, nil
} else {
return "", fallbackErr
}
}
if parentName == "" && fallbackName == "" {
return "", buserr.New("ErrComposeProjectNameEmpty")
}
return "", resolveErr
}
func runComposeConfig(composePath, projectName, envFile string) ([]byte, error) {
configCmd := getComposeCmdWithEnv(composePath, "config", envFile, projectName)
stdout, err := configCmd.Output()
if err != nil {
var stderr []byte
if exitErr, ok := err.(*exec.ExitError); ok {
stderr = exitErr.Stderr
}
return nil, fmt.Errorf("docker-compose config failed, std: %s, err: %v", mergeComposeOutput(stdout, stderr), err)
}
return stdout, nil
}
func mergeComposeOutput(stdout, stderr []byte) string {
outputs := make([]string, 0, 2)
if output := strings.TrimSpace(string(stdout)); output != "" {
outputs = append(outputs, output)
}
if output := strings.TrimSpace(string(stderr)); output != "" {
outputs = append(outputs, output)
}
return strings.Join(outputs, "\n")
}
func loadComposeProjectName(config []byte) (string, error) {
var project struct {
Name string `yaml:"name"`
}
if err := yaml.Unmarshal(config, &project); err != nil {
return "", buserr.WithDetail("ErrComposeProjectNameParse", err.Error(), err)
}
return strings.TrimSpace(project.Name), nil
}
func primaryComposePath(composePath string) string {
if index := strings.Index(composePath, ","); index >= 0 {
return composePath[:index]
}
return composePath
}
func normalizeComposeProjectName(name string) string {
name = re.GetRegex(re.ComposeDisallowedCharsPattern).
ReplaceAllString(strings.ToLower(strings.TrimSpace(name)), "")
return strings.TrimLeft(name, "_-")
}
func isComposeProjectNameEmptyError(err error) bool {
message := strings.ToLower(err.Error())
return strings.Contains(message, "project name must not be empty") ||
strings.Contains(message, "project name can't be empty")
}
func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error {
if len(req.Path) == 0 && req.Operation == "delete" {
_ = composeRepo.DeleteRecord(repo.WithByName(req.Name))
@@ -267,15 +542,15 @@ func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error {
return nil
}
if req.Operation == "up" {
if stdout, err := compose.Up(req.Path); err != nil {
if stdout, err := compose.Up(req.Path, req.Name); err != nil {
return fmt.Errorf("docker-compose up failed, std: %s, err: %v", stdout, err)
}
} else if req.Operation == "rebuild" {
if stdout, err := compose.DownAndUp(req.Path); err != nil {
if stdout, err := compose.DownAndUp(req.Path, req.Name); err != nil {
return fmt.Errorf("docker-compose rebuild failed, std: %s, err: %v", stdout, err)
}
} else {
if stdout, err := compose.Operate(req.Path, req.Operation); err != nil {
if stdout, err := compose.Operate(req.Path, req.Operation, req.Name); err != nil {
return fmt.Errorf("docker-compose %s failed, std: %s, err: %v", req.Operation, stdout, err)
}
}
@@ -311,9 +586,9 @@ func (u *ContainerService) ComposeUpdate(req dto.ComposeUpdate) error {
return err
}
if err := compose.UpWithTask(req.Path, t, req.ForcePull); err != nil {
if err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name); err != nil {
global.LOG.Errorf("update failed when handle compose up, err: %s, now try to recreate the old compose file", err)
if err := recreateCompose(string(oldFile), req.Path); err != nil {
if err := recreateCompose(string(oldFile), req.Path, req.Name); err != nil {
return fmt.Errorf("update failed and recreate old compose file also failed, err: %v", err)
}
return fmt.Errorf("update failed when handle compose up, err: %s", err)
@@ -327,6 +602,20 @@ func (u *ContainerService) ComposeUpdate(req dto.ComposeUpdate) error {
return nil
}
func (u *ContainerService) ComposePin(req dto.ComposePin) error {
record, _ := composeRepo.GetRecord(repo.WithByName(req.Name))
if record.ID == 0 {
if !req.IsPinned {
return nil
}
return composeRepo.CreateRecord(&model.Compose{Name: req.Name, IsPinned: true})
}
if !req.IsPinned && len(record.Path) == 0 {
return composeRepo.DeleteRecord(repo.WithByName(req.Name))
}
return composeRepo.UpdateRecord(req.Name, map[string]interface{}{"is_pinned": req.IsPinned})
}
func (u *ContainerService) ComposeLogClean(req dto.ComposeLogClean) error {
client, err := docker.NewDockerClient()
if err != nil {
@@ -389,15 +678,15 @@ func (u *ContainerService) LoadComposeEnv(name string) (string, error) {
func (u *ContainerService) loadPath(req *dto.ComposeCreate) error {
if req.From == "template" || req.From == "edit" {
dir := fmt.Sprintf("%s/docker/compose/%s", global.Dir.DataDir, req.Name)
composePath := composeCreatePath(*req)
dir := filepath.Dir(composePath)
if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) {
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
}
path := fmt.Sprintf("%s/docker-compose.yml", dir)
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, constant.FilePerm)
file, err := os.OpenFile(composePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, constant.FilePerm)
if err != nil {
return err
}
@@ -405,14 +694,14 @@ func (u *ContainerService) loadPath(req *dto.ComposeCreate) error {
write := bufio.NewWriter(file)
_, _ = write.WriteString(string(req.File))
write.Flush()
req.Path = path
req.Path = composePath
}
return nil
}
func removeContainerForCompose(composeName, composePath string) error {
if _, err := os.Stat(composePath); err == nil {
if stdout, err := compose.Operate(composePath, "down"); err != nil {
if stdout, err := compose.Operate(composePath, "down", composeName); err != nil {
return errors.New(stdout)
}
return nil
@@ -437,7 +726,7 @@ func removeContainerForCompose(composeName, composePath string) error {
return nil
}
func recreateCompose(content, path string) error {
func recreateCompose(content, path, projectName string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0640)
if err != nil {
return err
@@ -447,7 +736,7 @@ func recreateCompose(content, path string) error {
_, _ = write.WriteString(content)
write.Flush()
if stdout, err := compose.Up(path); err != nil {
if stdout, err := compose.Up(path, projectName); err != nil {
return errors.New(string(stdout))
}
return nil
+16 -10
View File
@@ -170,6 +170,10 @@ type containerSwitchClient interface {
NetworkDisconnect(context.Context, string, string, bool) error
}
type containerInspectClient interface {
ContainerInspect(context.Context, string) (container.InspectResponse, error)
}
type containerOperationMutex struct {
mutex sync.Mutex
locks map[string]*containerOperationLockEntry
@@ -335,7 +339,7 @@ const (
containerHealthCheckMaxWait = 10 * time.Minute
)
func waitContainerReady(ctx context.Context, cli containerSwitchClient, containerID string) error {
func waitContainerReady(ctx context.Context, cli containerInspectClient, containerID string) error {
info, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
return err
@@ -347,14 +351,15 @@ func waitContainerReady(ctx context.Context, cli containerSwitchClient, containe
return waitContainerStable(ctx, cli, containerID, info)
}
initialRestartCount := info.RestartCount
timeout := containerHealthCheckTimeout(info.Config)
deadline := time.NewTimer(timeout)
ticker := time.NewTicker(time.Second)
defer deadline.Stop()
defer ticker.Stop()
for {
if info.State.Restarting || info.RestartCount != 0 {
return fmt.Errorf("container restarted %d times during startup", info.RestartCount)
if info.State.Restarting || info.RestartCount != initialRestartCount {
return fmt.Errorf("container restart count changed from %d to %d during startup", initialRestartCount, info.RestartCount)
}
if info.State.Health == nil {
return fmt.Errorf("container health status is unavailable")
@@ -382,9 +387,10 @@ func waitContainerReady(ctx context.Context, cli containerSwitchClient, containe
}
}
func waitContainerStable(ctx context.Context, cli containerSwitchClient, containerID string, initial container.InspectResponse) error {
func waitContainerStable(ctx context.Context, cli containerInspectClient, containerID string, initial container.InspectResponse) error {
startedAt := initial.State.StartedAt
if err := checkContainerStableState(initial, startedAt); err != nil {
restartCount := initial.RestartCount
if err := checkContainerStableState(initial, startedAt, restartCount); err != nil {
return err
}
deadline := time.NewTimer(containerStartStabilization)
@@ -400,25 +406,25 @@ func waitContainerStable(ctx context.Context, cli containerSwitchClient, contain
if err != nil {
return err
}
return checkContainerStableState(info, startedAt)
return checkContainerStableState(info, startedAt, restartCount)
case <-ticker.C:
info, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
return err
}
if err := checkContainerStableState(info, startedAt); err != nil {
if err := checkContainerStableState(info, startedAt, restartCount); err != nil {
return err
}
}
}
}
func checkContainerStableState(info container.InspectResponse, startedAt string) error {
func checkContainerStableState(info container.InspectResponse, startedAt string, restartCount int) error {
if err := checkContainerRunningState(info); err != nil {
return err
}
if info.State.Restarting || info.RestartCount != 0 {
return fmt.Errorf("container restarted %d times during startup", info.RestartCount)
if info.State.Restarting || info.RestartCount != restartCount {
return fmt.Errorf("container restart count changed from %d to %d during startup", restartCount, info.RestartCount)
}
if startedAt != "" && info.State.StartedAt != startedAt {
return fmt.Errorf("container start time changed during startup")
+11 -5
View File
@@ -25,7 +25,7 @@ import (
type CronjobService struct{}
type ICronjobService interface {
SearchWithPage(search dto.PageCronjob) (int64, interface{}, error)
SearchWithPage(search dto.PageCronjob, readOnly ...bool) (int64, interface{}, error)
SearchRecords(search dto.SearchRecord) (int64, interface{}, error)
Create(cronjobDto dto.CronjobOperate, operator string) error
LoadNextHandle(spec string) ([]string, error)
@@ -50,7 +50,7 @@ func NewICronjobService() ICronjobService {
return &CronjobService{}
}
func (u *CronjobService) SearchWithPage(search dto.PageCronjob) (int64, interface{}, error) {
func (u *CronjobService) SearchWithPage(search dto.PageCronjob, readOnly ...bool) (int64, interface{}, error) {
total, cronjobs, err := cronjobRepo.Page(search.Page,
search.PageSize,
repo.WithByGroups(search.GroupIDs),
@@ -83,6 +83,9 @@ func (u *CronjobService) SearchWithPage(search dto.PageCronjob) (int64, interfac
if cronjob.Type == "snapshot" && len(cronjob.SnapshotRule) != 0 {
_ = json.Unmarshal([]byte(cronjob.SnapshotRule), &item.SnapshotRule)
}
if isDemoReadOnly(readOnly...) {
item.Secret = ""
}
dtoCronjobs = append(dtoCronjobs, item)
}
return total, dtoCronjobs, err
@@ -538,11 +541,14 @@ func (u *CronjobService) CleanRecord(req dto.CronjobClean) error {
return err
}
for _, del := range delRecords {
if del.Status == constant.StatusWaiting || del.Status == constant.StatusRunning {
continue
}
if err := cronjobRepo.DeleteRecord(repo.WithByID(del.ID)); err != nil {
return err
}
_ = os.RemoveAll(del.Records)
}
if err := cronjobRepo.DeleteRecord(cronjobRepo.WithByJobID(int(req.CronjobID))); err != nil {
return err
}
return nil
}
+1
View File
@@ -163,6 +163,7 @@ func (u *CronjobService) handleDatabase(cronjob model.Cronjob, startTime time.Ti
record.Name = dbInfo.Database
record.DetailName = dbInfo.Name
record.DownloadAccountID, record.SourceAccountIDs = cronjob.DownloadAccountID, cronjob.SourceAccountIDs
record.Args = encodeBackupArgs(dbInfo.Args)
backupDir := path.Join(global.Dir.LocalBackupDir, fmt.Sprintf("tmp/database/%s/%s/%s", dbInfo.DBType, record.Name, dbInfo.Name))
switch dbInfo.DBType {
+25 -5
View File
@@ -184,10 +184,30 @@ func (u *DashboardService) LoadBaseInfo(ioOption string, netOption string) (*dto
func (u *DashboardService) LoadCurrentInfo(ioOption string, netOption string) *dto.DashboardCurrent {
var currentInfo dto.DashboardCurrent
hostInfo, _ := psutil.HOST.GetHostInfo(false)
currentInfo.Uptime = hostInfo.Uptime
currentInfo.TimeSinceUptime = time.Unix(int64(hostInfo.BootTime), 0).Format(constant.DateTimeLayout)
currentInfo.RunningTime = loadRunningTime(hostInfo.Uptime)
shotTime := time.Now()
hostInfo, err := psutil.HOST.GetHostInfo(false)
if err != nil {
global.LOG.Errorf("load host info failed: %v", err)
currentInfo.ShotTime = shotTime
return &currentInfo
}
uptime := hostInfo.Uptime
var bootTime uint64
if now := shotTime.Unix(); now > 0 {
nowUnix := uint64(now)
if hostInfo.BootTime > 0 && hostInfo.BootTime <= nowUnix {
bootTime = hostInfo.BootTime
uptime = nowUnix - bootTime
} else if uptime <= nowUnix {
bootTime = nowUnix - uptime
}
}
currentInfo.Uptime = uptime
currentInfo.RunningTime = loadRunningTime(uptime)
if bootTime > 0 {
currentInfo.TimeSinceUptime = time.Unix(int64(bootTime), 0).Format(constant.DateTimeLayout)
}
currentInfo.Procs = hostInfo.Procs
currentInfo.CPUTotal, _ = psutil.CPUInfo.GetLogicalCores(false)
@@ -263,7 +283,7 @@ func (u *DashboardService) LoadCurrentInfo(ioOption string, netOption string) *d
}
}
currentInfo.ShotTime = time.Now()
currentInfo.ShotTime = shotTime
return &currentInfo
}
+12 -4
View File
@@ -24,8 +24,8 @@ import (
type DatabaseService struct{}
type IDatabaseService interface {
Get(name string) (dto.DatabaseInfo, error)
SearchWithPage(search dto.DatabaseSearch) (int64, interface{}, error)
Get(name string, readOnly ...bool) (dto.DatabaseInfo, error)
SearchWithPage(search dto.DatabaseSearch, readOnly ...bool) (int64, interface{}, error)
CheckDatabase(req dto.DatabaseCreate) bool
Create(req dto.DatabaseCreate) error
Update(req dto.DatabaseUpdate) error
@@ -39,7 +39,7 @@ func NewIDatabaseService() IDatabaseService {
return &DatabaseService{}
}
func (u *DatabaseService) SearchWithPage(search dto.DatabaseSearch) (int64, interface{}, error) {
func (u *DatabaseService) SearchWithPage(search dto.DatabaseSearch, readOnly ...bool) (int64, interface{}, error) {
total, dbs, err := databaseRepo.Page(search.Page, search.PageSize,
databaseRepo.WithTypeList(search.Type),
repo.WithByLikeName(search.Info),
@@ -52,12 +52,16 @@ func (u *DatabaseService) SearchWithPage(search dto.DatabaseSearch) (int64, inte
if err := copier.Copy(&item, &db); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
if isDemoReadOnly(readOnly...) {
item.Password = ""
item.ClientKey = ""
}
datas = append(datas, item)
}
return total, datas, err
}
func (u *DatabaseService) Get(name string) (dto.DatabaseInfo, error) {
func (u *DatabaseService) Get(name string, readOnly ...bool) (dto.DatabaseInfo, error) {
var data dto.DatabaseInfo
remote, err := databaseRepo.Get(repo.WithByName(name))
if err != nil {
@@ -66,6 +70,10 @@ func (u *DatabaseService) Get(name string) (dto.DatabaseInfo, error) {
if err := copier.Copy(&data, &remote); err != nil {
return data, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
if isDemoReadOnly(readOnly...) {
data.Password = ""
data.ClientKey = ""
}
return data, nil
}
+5 -2
View File
@@ -23,7 +23,7 @@ import (
type MongodbService struct{}
type IMongodbService interface {
SearchWithPage(search dto.MongodbDBSearch) (int64, interface{}, error)
SearchWithPage(search dto.MongodbDBSearch, readOnly ...bool) (int64, interface{}, error)
Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error)
LoadFromRemote(req dto.MongodbLoadDB) error
UpdateDescription(req dto.UpdateDescription) error
@@ -40,7 +40,7 @@ func NewIMongodbService() IMongodbService {
return &MongodbService{}
}
func (u *MongodbService) SearchWithPage(search dto.MongodbDBSearch) (int64, interface{}, error) {
func (u *MongodbService) SearchWithPage(search dto.MongodbDBSearch, readOnly ...bool) (int64, interface{}, error) {
total, mongodbs, err := mongodbRepo.Page(
search.Page,
search.PageSize,
@@ -54,6 +54,9 @@ func (u *MongodbService) SearchWithPage(search dto.MongodbDBSearch) (int64, inte
if err := copier.Copy(&item, &mongodb); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
if isDemoReadOnly(readOnly...) {
item.Password = ""
}
dtoMongodbs = append(dtoMongodbs, item)
}
return total, dtoMongodbs, err
+7 -3
View File
@@ -46,7 +46,7 @@ type IMysqlService interface {
DeleteCheck(req dto.MysqlDBDeleteCheck) ([]dto.DBResource, error)
Delete(ctx context.Context, req dto.MysqlDBDelete) error
ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, error)
ListUsers(req dto.MysqlUserSearch, readOnly ...bool) ([]dto.MysqlUser, error)
ListGrants(req dto.MysqlUserSearch) ([]dto.MysqlGrant, error)
ListGrantSummary(req dto.MysqlGrantSummarySearch) (map[string][]dto.MysqlUser, error)
CreateUser(req dto.MysqlUserCreate) error
@@ -506,7 +506,7 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
return &createItem, nil
}
func (u *MysqlService) ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, error) {
func (u *MysqlService) ListUsers(req dto.MysqlUserSearch, readOnly ...bool) ([]dto.MysqlUser, error) {
dbType, err := resolveDatabaseUserType(req.Database)
if err != nil {
return nil, err
@@ -520,10 +520,14 @@ func (u *MysqlService) ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, erro
if isMysqlSystemUser(user.Username) {
continue
}
password := user.Password
if isDemoReadOnly(readOnly...) {
password = ""
}
res = append(res, dto.MysqlUser{
Username: user.Username,
Host: user.Host,
Password: user.Password,
Password: password,
Description: user.Description,
IsDelete: user.IsDelete,
})
+5 -2
View File
@@ -26,7 +26,7 @@ import (
type PostgresqlService struct{}
type IPostgresqlService interface {
SearchWithPage(search dto.PostgresqlDBSearch) (int64, interface{}, error)
SearchWithPage(search dto.PostgresqlDBSearch, readOnly ...bool) (int64, interface{}, error)
ListDBOption() ([]dto.PostgresqlOption, error)
BindUser(req dto.PostgresqlBindUser) error
Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error)
@@ -42,7 +42,7 @@ func NewIPostgresqlService() IPostgresqlService {
return &PostgresqlService{}
}
func (u *PostgresqlService) SearchWithPage(search dto.PostgresqlDBSearch) (int64, interface{}, error) {
func (u *PostgresqlService) SearchWithPage(search dto.PostgresqlDBSearch, readOnly ...bool) (int64, interface{}, error) {
total, postgresqls, err := postgresqlRepo.Page(search.Page, search.PageSize,
postgresqlRepo.WithByPostgresqlName(search.Database),
repo.WithByLikeName(search.Info),
@@ -54,6 +54,9 @@ func (u *PostgresqlService) SearchWithPage(search dto.PostgresqlDBSearch) (int64
if err := copier.Copy(&item, &pg); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
if isDemoReadOnly(readOnly...) {
item.Password = ""
}
dtoPostgresqls = append(dtoPostgresqls, item)
}
return total, dtoPostgresqls, err
+69 -20
View File
@@ -28,10 +28,11 @@ import (
)
const (
rollbackPath = "1panel/tmp"
upgradePath = "1panel/tmp/upgrade"
uploadPath = "1panel/uploads"
downloadPath = "1panel/download"
rollbackPath = "1panel/tmp"
communityRestorePath = "1panel/tmp/community-restore"
upgradePath = "1panel/tmp/upgrade"
uploadPath = "1panel/uploads"
downloadPath = "1panel/download"
)
func (u *DeviceService) Scan() dto.CleanData {
@@ -58,7 +59,7 @@ func (u *DeviceService) Scan() dto.CleanData {
SystemClean.BackupClean = loadBackupTree(fileOp)
rollBackTree := loadRollBackTree(fileOp)
rollBackTree := loadRollBackTree()
rollbackSize := uint64(0)
for _, rollback := range rollBackTree {
rollbackSize += rollback.Size
@@ -113,12 +114,15 @@ func (u *DeviceService) Clean(req []dto.Clean) {
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "app"))
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "database"))
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "website"))
dropFileOrDir(path.Join(global.Dir.BaseDir, communityRestorePath))
case "rollback_app":
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "app", item.Name))
case "rollback_database":
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "database", item.Name))
case "rollback_website":
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "website", item.Name))
case "rollback_community_restore":
dropFileOrDir(path.Join(global.Dir.BaseDir, communityRestorePath, item.Name))
case "upload":
dropFileOrDir(path.Join(global.Dir.BaseDir, uploadPath, item.Name))
@@ -214,6 +218,7 @@ func doSystemClean(taskItem *task.Task) func(t *task.Task) error {
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "app"), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "website"), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "database"), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, communityRestorePath), taskItem, &size, &fileCount)
upgrades := path.Join(global.Dir.BaseDir, upgradePath)
oldUpgradeFiles, _ := os.ReadDir(upgrades)
@@ -606,20 +611,21 @@ func isExactPathMatch(path string, excludePaths []string) bool {
return false
}
func loadRollBackTree(fileOp fileUtils.FileOp) []dto.CleanTree {
func loadRollBackTree() []dto.CleanTree {
var treeData []dto.CleanTree
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "app"), "rollback_app", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "website"), "rollback_website", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "database"), "rollback_database", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "app"), "rollback_app")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "website"), "rollback_website")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "database"), "rollback_database")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, communityRestorePath), "rollback_community_restore")
return treeData
}
func loadUploadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
var treeData []dto.CleanTree
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "app"), "upload_app", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "website"), "upload_website", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "database"), "upload_database", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "app"), "upload_app")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "website"), "upload_website")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "database"), "upload_database")
path5 := path.Join(global.Dir.BaseDir, uploadPath)
uploadTreeData := loadTreeWithAllFile(true, path5, "upload", path5, fileOp)
@@ -630,9 +636,9 @@ func loadUploadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
func loadDownloadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
var treeData []dto.CleanTree
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "app"), "download_app", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "website"), "download_website", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "database"), "download_database", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "app"), "download_app")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "website"), "download_website")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "database"), "download_database")
path5 := path.Join(global.Dir.BaseDir, downloadPath)
uploadTreeData := loadTreeWithAllFile(true, path5, "download", path5, fileOp)
@@ -814,16 +820,59 @@ func loadContainerTree() []dto.CleanTree {
return treeData
}
func loadTreeWithCheck(treeData []dto.CleanTree, pathItem, treeType string, fileOp fileUtils.FileOp) []dto.CleanTree {
size, _ := fileOp.GetDirSize(pathItem)
if size == 0 {
func loadTreeWithCheck(treeData []dto.CleanTree, pathItem, treeType string) []dto.CleanTree {
list, size := loadTreeWithFileSize(true, pathItem, treeType, pathItem)
if len(list) == 0 || size == 0 {
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, CanDelete: false})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: treeType, Size: size, IsCheck: size > 0, Children: list, Type: treeType, IsRecommend: true, CanDelete: false})
return treeData
}
func loadTreeWithFileSize(isCheck bool, originalPath, treeType, pathItem string) ([]dto.CleanTree, uint64) {
var (
lists []dto.CleanTree
total uint64
)
entries, err := os.ReadDir(pathItem)
if err != nil {
return lists, total
}
for _, entry := range entries {
item := dto.CleanTree{
ID: uuid.NewString(),
Label: entry.Name(),
Type: treeType,
Name: strings.TrimPrefix(path.Join(pathItem, entry.Name()), originalPath+"/"),
IsCheck: isCheck,
IsRecommend: isCheck,
CanDelete: true,
}
entryPath := path.Join(pathItem, entry.Name())
if entry.IsDir() {
children, size := loadTreeWithFileSize(isCheck, originalPath, treeType, entryPath)
if len(children) == 0 {
continue
}
item.Children = children
item.Size = size
} else {
info, err := entry.Info()
if err != nil {
continue
}
item.Size = uint64(info.Size())
}
if item.Size == 0 {
continue
}
total += item.Size
lists = append(lists, item)
}
return lists, total
}
func loadTreeWithDir(isCheck bool, treeType, pathItem string, fileOp fileUtils.FileOp) []dto.CleanTree {
var lists []dto.CleanTree
files, err := os.ReadDir(pathItem)
+47 -27
View File
@@ -61,18 +61,21 @@ func parseDevice(dev LsblkDevice) []response.DiskBasicInfo {
var used, avail, totalSize string
var usePercent int
isMounted := mountPoint != ""
isMounted := mountPoint != "" && mountPoint != "-"
isSystem := false
if dev.Fstype == "LVM2_member" && len(dev.Children) > 0 {
for _, child := range dev.Children {
if child.Type == "lvm" && child.Mountpoint != "" {
devicePath := "/dev/mapper/" + child.Name
totalSize, used, avail, usePercent, _ := getDiskUsageInfo(devicePath)
if child.Type == "lvm" && child.Mountpoint != "" && child.Mountpoint != "-" {
totalSize, used, avail, usePercent, _ := getDiskUsageInfo(child.Mountpoint)
childSize := child.Size
if totalSize != "" {
childSize = totalSize
}
childInfo := response.DiskBasicInfo{
Device: dev.Name,
Size: totalSize,
Size: childSize,
Model: dev.Model,
DiskType: diskType,
Filesystem: child.Fstype,
@@ -91,8 +94,7 @@ func parseDevice(dev LsblkDevice) []response.DiskBasicInfo {
return list
} else if isMounted {
isSystem = isSystemDisk(mountPoint)
devicePath := "/dev/" + dev.Name
totalSize, used, avail, usePercent, _ = getDiskUsageInfo(devicePath)
totalSize, used, avail, usePercent, _ = getDiskUsageInfo(mountPoint)
if totalSize != "" {
size = totalSize
}
@@ -229,9 +231,14 @@ func parseLsblkOutput(output string) ([]response.DiskBasicInfo, error) {
size := fields["SIZE"]
if diskType == "lvm" {
total, used, avail, usePercent, _ := getDiskUsageInfo("/dev/mapper/" + name)
if total != "" && fsType != "" {
size = total
var total, used, avail string
var usePercent int
isMounted := mountPoint != "" && mountPoint != "-"
if isMounted {
total, used, avail, usePercent, _ = getDiskUsageInfo(mountPoint)
if total != "" && fsType != "" {
size = total
}
}
lvmInfo := response.DiskBasicInfo{
@@ -246,7 +253,7 @@ func parseLsblkOutput(output string) ([]response.DiskBasicInfo, error) {
Avail: avail,
UsePercent: usePercent,
MountPoint: mountPoint,
IsMounted: mountPoint != "" && mountPoint != "-",
IsMounted: isMounted,
Serial: fields["SERIAL"],
}
lvmMap[name] = lvmInfo
@@ -269,8 +276,8 @@ func parseLsblkOutput(output string) ([]response.DiskBasicInfo, error) {
used, avail, totalSize string
usePercent int
)
if mountPoint != "" {
totalSize, used, avail, usePercent, _ = getDiskUsageInfo("/dev/" + name)
if mountPoint != "" && mountPoint != "-" {
totalSize, used, avail, usePercent, _ = getDiskUsageInfo(mountPoint)
if totalSize != "" {
size = totalSize
}
@@ -387,26 +394,39 @@ func getParentDevice(device string) string {
return device
}
func getDiskUsageInfo(device string) (size, used, avail string, usePercent int, err error) {
output, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("df", "-h", device)
func getDiskUsageInfo(mountPoint string) (size, used, avail string, usePercent int, err error) {
// Query by mount point instead of a reconstructed device path. The mount table may record
// a different device alias such as /dev/root.
output, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("df", "-h", "-P", mountPoint)
if err != nil {
return "", "", "", 0, nil
}
return parseDiskUsageOutput(output)
}
func parseDiskUsageOutput(output string) (size, used, avail string, usePercent int, err error) {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) > 1 {
output = lines[len(lines)-1]
for i := len(lines) - 1; i >= 0; i-- {
fields := strings.Fields(lines[i])
for index, field := range fields {
if index < 3 || !strings.HasSuffix(field, "%") {
continue
}
percent, parseErr := strconv.Atoi(strings.TrimSuffix(field, "%"))
if parseErr != nil {
continue
}
return fields[index-3], fields[index-2], fields[index-1], percent, nil
}
for index, field := range fields {
if index < 3 || index+1 >= len(fields) || field != "-" || !strings.HasPrefix(fields[index+1], "/") {
continue
}
return fields[index-3], fields[index-2], fields[index-1], 0, nil
}
}
fields := strings.Fields(output)
if len(fields) >= 5 {
size = fields[1]
used = fields[2]
avail = fields[3]
usePercentStr := strings.TrimSuffix(fields[4], "%")
usePercent, _ = strconv.Atoi(usePercentStr)
}
return size, used, avail, usePercent, nil
return "", "", "", 0, nil
}
func formatDisk(req dto.DiskFormatRequest) error {
+8 -6
View File
@@ -40,12 +40,14 @@ var (
settingRepo = repo.NewISettingRepo()
backupRepo = repo.NewIBackupRepo()
websiteRepo = repo.NewIWebsiteRepo()
websiteDomainRepo = repo.NewIWebsiteDomainRepo()
websiteDnsRepo = repo.NewIWebsiteDnsAccountRepo()
websiteSSLRepo = repo.NewISSLRepo()
websiteAcmeRepo = repo.NewIAcmeAccountRepo()
websiteCARepo = repo.NewIWebsiteCARepo()
websiteRepo = repo.NewIWebsiteRepo()
websiteDomainRepo = repo.NewIWebsiteDomainRepo()
websiteDnsRepo = repo.NewIWebsiteDnsAccountRepo()
websiteSSLRepo = repo.NewISSLRepo()
websiteAcmeRepo = repo.NewIAcmeAccountRepo()
websiteCARepo = repo.NewIWebsiteCARepo()
websiteTemplateRepo = repo.NewIWebsiteTemplateRepo()
websiteTemplateOutputRepo = repo.NewIWebsiteTemplateOutputRepo()
snapshotRepo = repo.NewISnapshotRepo()
+130 -27
View File
@@ -16,6 +16,7 @@ import (
"sort"
"strconv"
"strings"
"syscall"
"time"
"unicode/utf8"
@@ -51,6 +52,8 @@ type FileService struct {
const fileHistorySnapshotMaxSize = 10 * 1024 * 1024
var fileTransferLocks = newFileTransferLocks()
type IFileService interface {
GetFileList(op request.FileOption) (response.FileInfo, error)
SearchUploadWithPage(req request.SearchUploadWithPage) (int64, interface{}, error)
@@ -71,6 +74,7 @@ type IFileService interface {
ChangeName(req request.FileRename) error
Wget(w request.FileWget) (string, error)
MvFile(m request.FileMove) error
StopMvFile(taskID string) error
ChangeOwner(req request.FileRoleUpdate) error
ChangeMode(op request.FileCreate) error
BatchChangeModeAndOwner(op request.FileRoleReq) error
@@ -83,7 +87,7 @@ type IFileService interface {
ConvertLog(req dto.PageInfo) (int64, []response.FileConvertLog, error)
BatchGetRemarks(req request.FileRemarkBatch) map[string]string
SetRemark(req request.FileRemarkUpdate) error
AISearch(req request.FileAISearch) (*response.FileAISearchResult, error)
AISearch(req request.FileAISearch, readOnly ...bool) (*response.FileAISearchResult, error)
}
const (
@@ -466,7 +470,7 @@ func (f *FileService) Compress(c request.FileCompress) error {
func preflightCompressTool(compressType files.CompressType) error {
switch compressType {
case files.TarGz, files.Rar, files.X7z:
case files.Tar, files.Gz, files.Bz2, files.TarBz2, files.Tgz, files.TarGz, files.Xz, files.TarXz, files.Rar, files.X7z:
_, err := files.NewShellArchiver(compressType)
return err
default:
@@ -476,7 +480,7 @@ func preflightCompressTool(compressType files.CompressType) error {
func preflightDecompressTool(decompressType files.CompressType) error {
switch decompressType {
case files.Rar, files.X7z:
case files.Rar:
_, err := files.NewExtractShellArchiver(decompressType)
return err
default:
@@ -533,7 +537,10 @@ func (f *FileService) DeCompress(c request.FileDeCompress) error {
_ = os.RemoveAll(c.Dst)
}
}()
if err := fo.Decompress(t.TaskCtx, c.Path, tempDst, files.CompressType(c.Type), c.Secret); err != nil {
if err := fo.DecompressWithOptions(t.TaskCtx, c.Path, tempDst, files.CompressType(c.Type), c.Secret, files.DecompressOptions{
PreserveOwner: true,
AllowCLIReextract: true,
}); err != nil {
return err
}
if err := fo.CreateDir(c.Dst, constant.DirPerm); err != nil {
@@ -551,19 +558,42 @@ func (f *FileService) DeCompress(c request.FileDeCompress) error {
}
func copyDecompressTree(ctx context.Context, srcDir, dstDir string) error {
state := decompressCopyState{hardlinks: make(map[decompressFileIdentity]string)}
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, entry := range entries {
if err := copyDecompressEntry(ctx, filepath.Join(srcDir, entry.Name()), filepath.Join(dstDir, entry.Name())); err != nil {
if err := copyDecompressEntryWithState(ctx, filepath.Join(srcDir, entry.Name()), filepath.Join(dstDir, entry.Name()), &state); err != nil {
return err
}
}
return nil
}
type decompressFileIdentity struct {
device uint64
inode uint64
}
type decompressCopyState struct {
hardlinks map[decompressFileIdentity]string
}
func decompressHardlinkIdentity(info os.FileInfo) (decompressFileIdentity, bool) {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Nlink < 2 {
return decompressFileIdentity{}, false
}
return decompressFileIdentity{device: uint64(stat.Dev), inode: uint64(stat.Ino)}, true
}
func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr error) {
state := decompressCopyState{hardlinks: make(map[decompressFileIdentity]string)}
return copyDecompressEntryWithState(ctx, srcPath, dstPath, &state)
}
func copyDecompressEntryWithState(ctx context.Context, srcPath, dstPath string, state *decompressCopyState) (retErr error) {
if err := ctx.Err(); err != nil {
return err
}
@@ -605,13 +635,16 @@ func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr e
if err := applyDecompressOwnership(srcPath, dstPath); err != nil {
return err
}
if err := os.Chmod(dstPath, info.Mode().Perm()); err != nil {
return err
}
}
entries, err := os.ReadDir(srcPath)
if err != nil {
return err
}
for _, entry := range entries {
if err := copyDecompressEntry(ctx, filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name())); err != nil {
if err := copyDecompressEntryWithState(ctx, filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name()), state); err != nil {
return err
}
}
@@ -634,6 +667,15 @@ func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr e
if err := os.MkdirAll(filepath.Dir(dstPath), constant.DirPerm); err != nil {
return err
}
identity, isHardlink := decompressHardlinkIdentity(info)
if !keepExistingFile && isHardlink {
if existingPath, ok := state.hardlinks[identity]; ok {
if err := os.Link(existingPath, dstPath); err != nil {
return err
}
return os.Chtimes(dstPath, info.ModTime(), info.ModTime())
}
}
srcFile, err := os.Open(srcPath)
if err != nil {
@@ -658,6 +700,12 @@ func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr e
if err := applyDecompressOwnership(srcPath, dstPath); err != nil {
return err
}
if err := os.Chmod(dstPath, info.Mode().Perm()); err != nil {
return err
}
if isHardlink {
state.hardlinks[identity] = dstPath
}
}
return os.Chtimes(dstPath, info.ModTime(), info.ModTime())
}
@@ -667,7 +715,7 @@ func applyDecompressOwnership(srcPath, dstPath string) error {
if err != nil {
return err
}
stat, ok := info.Sys().(*unix.Stat_t)
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return nil
}
@@ -863,17 +911,62 @@ func (f *FileService) Wget(w request.FileWget) (string, error) {
func (f *FileService) MvFile(m request.FileMove) error {
fo := files.NewFileOp()
if err := validateFileMove(fo, m); err != nil {
return err
}
if m.TaskID == "" {
m.TaskID = common.GetUuid()
}
if !fileTransferLocks.Acquire(m.TaskID, getFileTransferPaths(m)) {
return buserr.New("TaskIsExecuting")
}
taskItem, err := task.NewTask(m.NewPath, task.TaskExec, task.TaskScopeTask, m.TaskID, 1)
if err != nil {
fileTransferLocks.Release(m.TaskID)
return err
}
go func() {
defer fileTransferLocks.Release(m.TaskID)
taskItem.AddSubTaskWithOps(m.NewPath, func(t *task.Task) error {
t.LogStart(m.NewPath)
err := f.moveFileWithContext(t.TaskCtx, m)
if err != nil && t.TaskCtx.Err() != nil {
return t.TaskCtx.Err()
}
return err
}, nil, 0, 0)
_ = taskItem.Execute()
}()
return nil
}
func (f *FileService) StopMvFile(taskID string) error {
if cancel, ok := global.LoadTaskCancel(taskID); ok {
cancel()
return nil
}
return buserr.New("TaskNotFound")
}
func validateFileMove(fo files.FileOp, m request.FileMove) error {
if !fo.Stat(m.NewPath) {
return buserr.New("ErrPathNotFound")
}
for _, oldPath := range m.OldPaths {
for _, oldPath := range append(append([]string{}, m.OldPaths...), m.CoverPaths...) {
if !fo.Stat(oldPath) {
return buserr.WithName("ErrFileNotFound", oldPath)
}
if oldPath == m.NewPath || strings.Contains(m.NewPath, filepath.Clean(oldPath)+"/") {
oldPath = filepath.Clean(oldPath)
newPath := filepath.Clean(m.NewPath)
if oldPath == newPath || strings.HasPrefix(newPath, oldPath+string(filepath.Separator)) {
return buserr.New("ErrMovePathFailed")
}
}
return nil
}
func (f *FileService) moveFileWithContext(ctx context.Context, m request.FileMove) error {
fo := files.NewFileOp()
type moveSnapshot struct {
path string
content []byte
@@ -889,13 +982,25 @@ func (f *FileService) MvFile(m request.FileMove) error {
}
if len(m.CoverPaths) > 0 {
for _, src := range m.CoverPaths {
if err := fo.CopyAndReName(src, m.NewPath, "", true); err != nil {
if err := ctx.Err(); err != nil {
return err
}
if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, "", true); err != nil {
errs = append(errs, err)
global.LOG.Errorf("cut copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error())
continue
}
if err := ctx.Err(); err != nil {
return err
}
if err := fo.DeleteDir(src); err != nil {
removeErr := fmt.Errorf("remove merged source [%s] failed: %w", src, err)
errs = append(errs, removeErr)
global.LOG.Errorf("%s", removeErr.Error())
}
}
}
if err := fo.Cut(m.OldPaths, m.NewPath, m.Name, m.Cover); err != nil {
if err := fo.CutWithContext(ctx, m.OldPaths, m.NewPath, m.Name, m.Cover); err != nil {
return err
}
for _, snapshot := range snapshots {
@@ -906,18 +1011,18 @@ func (f *FileService) MvFile(m request.FileMove) error {
}
}
}
return nil
return aggregateFileMoveErrors(errs)
}
if m.Type == "copy" {
for _, src := range m.OldPaths {
if err := fo.CopyAndReName(src, m.NewPath, m.Name, m.Cover); err != nil {
if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, m.Name, m.Cover); err != nil {
errs = append(errs, err)
global.LOG.Errorf("copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error())
}
}
if len(m.CoverPaths) > 0 {
for _, src := range m.CoverPaths {
if err := fo.CopyAndReName(src, m.NewPath, "", true); err != nil {
if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, "", true); err != nil {
errs = append(errs, err)
global.LOG.Errorf("copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error())
}
@@ -925,14 +1030,7 @@ func (f *FileService) MvFile(m request.FileMove) error {
}
}
var errString string
for _, err := range errs {
errString += err.Error() + "\n"
}
if errString != "" {
return errors.New(errString)
}
return nil
return aggregateFileMoveErrors(errs)
}
func readEditableFileHistoryContent(filePath string) ([]byte, os.FileMode, bool) {
@@ -1462,7 +1560,7 @@ 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) {
func (f *FileService) AISearch(req request.FileAISearch, readOnly ...bool) (*response.FileAISearchResult, error) {
root := filepath.Clean(strings.TrimSpace(req.Path))
if root == "" {
return nil, buserr.WithDetail("ErrInvalidParams", "path is required", nil)
@@ -1515,10 +1613,15 @@ func (f *FileService) AISearch(req request.FileAISearch) (*response.FileAISearch
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
var cfg terminalai.GeneratorConfig
var timeout time.Duration
aiEnabled := false
if !isDemoReadOnly(readOnly...) {
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)
+32 -22
View File
@@ -37,14 +37,14 @@ 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)
Page(req dto.PageInfo, readOnly ...bool) (int64, []response.FileShareInfo, error)
GetByPath(path string, readOnly ...bool) (*response.FileShareInfo, error)
GetByCode(code string, readOnly ...bool) (*response.FileShareInfo, error)
GetPublicByCode(code string, readOnly ...bool) (*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)
Check(code, password string, readOnly ...bool) error
PrepareDownload(code, password string, readOnly ...bool) (filePath, fileName string, err error)
}
func NewIFileShareService() IFileShareService {
@@ -212,14 +212,14 @@ func (s *FileShareService) Create(req request.FileShareCreate) (*response.FileSh
return &res, nil
}
func (s *FileShareService) Page(req dto.PageInfo) (int64, []response.FileShareInfo, error) {
func (s *FileShareService) Page(req dto.PageInfo, readOnly ...bool) (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 {
if err := s.pruneInvalidShare(item, readOnly...); err != nil {
return 0, nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
@@ -239,7 +239,7 @@ func (s *FileShareService) Page(req dto.PageInfo) (int64, []response.FileShareIn
return int64(total), result[start:end], nil
}
func (s *FileShareService) GetByPath(path string) (*response.FileShareInfo, error) {
func (s *FileShareService) GetByPath(path string, readOnly ...bool) (*response.FileShareInfo, error) {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByPath(strings.TrimSpace(path)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -247,7 +247,7 @@ func (s *FileShareService) GetByPath(path string) (*response.FileShareInfo, erro
}
return nil, err
}
if err := s.pruneInvalidShare(item); err != nil {
if err := s.pruneInvalidShare(item, readOnly...); err != nil {
return nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
@@ -258,7 +258,7 @@ func (s *FileShareService) GetByPath(path string) (*response.FileShareInfo, erro
return &info, nil
}
func (s *FileShareService) GetByCode(code string) (*response.FileShareInfo, error) {
func (s *FileShareService) GetByCode(code string, readOnly ...bool) (*response.FileShareInfo, error) {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByCode(strings.TrimSpace(code)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -266,7 +266,7 @@ func (s *FileShareService) GetByCode(code string) (*response.FileShareInfo, erro
}
return nil, err
}
if err := s.pruneInvalidShare(item); err != nil {
if err := s.pruneInvalidShare(item, readOnly...); err != nil {
return nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
@@ -276,7 +276,7 @@ func (s *FileShareService) GetByCode(code string) (*response.FileShareInfo, erro
return &info, nil
}
func (s *FileShareService) GetPublicByCode(code string) (*response.FileSharePublicInfo, error) {
func (s *FileShareService) GetPublicByCode(code string, readOnly ...bool) (*response.FileSharePublicInfo, error) {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByCode(strings.TrimSpace(code)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -284,7 +284,7 @@ func (s *FileShareService) GetPublicByCode(code string) (*response.FileSharePubl
}
return nil, err
}
if err := s.pruneInvalidShare(item); err != nil {
if err := s.pruneInvalidShare(item, readOnly...); err != nil {
return nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
@@ -324,32 +324,38 @@ func (s *FileShareService) SharePathCodeMap() (map[string]string, error) {
return result, nil
}
func (s *FileShareService) Check(code, password string) error {
_, err := s.check(code, password)
func (s *FileShareService) Check(code, password string, readOnly ...bool) error {
_, err := s.check(code, password, readOnly...)
return err
}
func (s *FileShareService) PrepareDownload(code, password string) (string, string, error) {
item, err := s.check(code, password)
func (s *FileShareService) PrepareDownload(code, password string, readOnly ...bool) (string, string, error) {
item, err := s.check(code, password, readOnly...)
if err != nil {
return "", "", err
}
return item.Path, item.FileName, nil
}
func (s *FileShareService) pruneInvalidShare(item model.FileShare) error {
func (s *FileShareService) pruneInvalidShare(item model.FileShare, readOnly ...bool) error {
now := time.Now().Unix()
if item.ExpiresUnix > 0 && now > item.ExpiresUnix {
if isDemoReadOnly(readOnly...) {
return nil
}
return fileShareRepo.Delete(repo.WithByID(item.ID))
}
info, err := os.Stat(item.Path)
if err != nil || info.IsDir() {
if isDemoReadOnly(readOnly...) {
return nil
}
return fileShareRepo.Delete(repo.WithByID(item.ID))
}
return nil
}
func (s *FileShareService) check(code, password string) (*model.FileShare, error) {
func (s *FileShareService) check(code, password string, readOnly ...bool) (*model.FileShare, error) {
code = strings.TrimSpace(code)
password = strings.TrimSpace(password)
if code == "" {
@@ -366,7 +372,9 @@ func (s *FileShareService) check(code, password string) (*model.FileShare, error
now := time.Now().Unix()
if item.ExpiresUnix > 0 && now > item.ExpiresUnix {
_ = fileShareRepo.Delete(repo.WithByID(item.ID))
if !isDemoReadOnly(readOnly...) {
_ = fileShareRepo.Delete(repo.WithByID(item.ID))
}
return nil, buserr.New("ErrFileShareExpired")
}
if item.PasswordHash != "" {
@@ -377,7 +385,9 @@ func (s *FileShareService) check(code, password string) (*model.FileShare, error
info, err := os.Stat(item.Path)
if err != nil || info.IsDir() {
_ = fileShareRepo.Delete(repo.WithByID(item.ID))
if !isDemoReadOnly(readOnly...) {
_ = fileShareRepo.Delete(repo.WithByID(item.ID))
}
return nil, buserr.New("ErrFileSharePath")
}
+77
View File
@@ -0,0 +1,77 @@
package service
import (
"errors"
"path/filepath"
"strings"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
)
type fileTransferLockSet struct {
mu sync.Mutex
paths map[string][]string
}
func newFileTransferLocks() *fileTransferLockSet {
return &fileTransferLockSet{paths: make(map[string][]string)}
}
func (s *fileTransferLockSet) Acquire(taskID string, transferPaths []string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, activePaths := range s.paths {
for _, activePath := range activePaths {
for _, transferPath := range transferPaths {
if fileTransferPathsOverlap(activePath, transferPath) {
return false
}
}
}
}
s.paths[taskID] = transferPaths
return true
}
func (s *fileTransferLockSet) Release(taskID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.paths, taskID)
}
func getFileTransferPaths(req request.FileMove) []string {
paths := make([]string, 0, 1+len(req.OldPaths)+len(req.CoverPaths))
paths = append(paths, req.NewPath)
paths = append(paths, req.OldPaths...)
paths = append(paths, req.CoverPaths...)
unique := make(map[string]struct{}, len(paths))
result := make([]string, 0, len(paths))
for _, item := range paths {
item = filepath.Clean(item)
if _, ok := unique[item]; ok {
continue
}
unique[item] = struct{}{}
result = append(result, item)
}
return result
}
func fileTransferPathsOverlap(first, second string) bool {
return first == second || strings.HasPrefix(first, second+string(filepath.Separator)) || strings.HasPrefix(second, first+string(filepath.Separator))
}
func aggregateFileMoveErrors(errs []error) error {
if len(errs) == 0 {
return nil
}
var errString strings.Builder
for _, err := range errs {
errString.WriteString(err.Error())
errString.WriteByte('\n')
}
return errors.New(errString.String())
}
+3 -98
View File
@@ -3,14 +3,12 @@ package service
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"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"
@@ -28,7 +26,6 @@ type IFirewallService interface {
SearchWithPage(search dto.RuleSearch) (int64, interface{}, error)
OperateFirewall(req dto.FirewallOperation) error
OperatePortRule(req dto.PortRuleOperate, reload bool) error
OperateForwardRule(req dto.ForwardRuleOperate) error
OperateAddressRule(req dto.AddrRuleOperate, reload bool) error
UpdatePortRule(req dto.PortRuleUpdate) error
UpdateAddrRule(req dto.AddrRuleUpdate) error
@@ -84,8 +81,6 @@ func (u *FirewallService) SearchWithPage(req dto.RuleSearch) (int64, interface{}
switch req.Type {
case "port":
rules, err = client.ListPort()
case "forward":
rules, err = client.ListForward()
case "address":
rules, err = client.ListAddress()
}
@@ -317,96 +312,6 @@ func (u *FirewallService) OperatePortRule(req dto.PortRuleOperate, reload bool)
return nil
}
func (u *FirewallService) OperateForwardRule(req dto.ForwardRuleOperate) error {
client, err := firewall.NewFirewallClient()
if err != nil {
return err
}
rules, _ := client.ListForward()
i := 0
for _, rule := range rules {
shouldKeep := true
for i := range req.Rules {
reqRule := &req.Rules[i]
if reqRule.TargetIP == "" {
reqRule.TargetIP = "127.0.0.1"
}
if reqRule.Operation == "remove" {
for _, proto := range strings.Split(reqRule.Protocol, "/") {
if reqRule.Port == rule.Port &&
reqRule.TargetPort == rule.TargetPort &&
reqRule.TargetIP == rule.TargetIP &&
proto == rule.Protocol &&
reqRule.Interface == rule.Interface {
shouldKeep = false
break
}
}
}
}
if shouldKeep {
rules[i] = rule
i++
}
}
rules = rules[:i]
for _, rule := range rules {
for _, reqRule := range req.Rules {
if reqRule.Operation == "remove" {
continue
}
for _, proto := range strings.Split(reqRule.Protocol, "/") {
if reqRule.Port == rule.Port &&
reqRule.TargetPort == rule.TargetPort &&
reqRule.TargetIP == rule.TargetIP &&
proto == rule.Protocol &&
reqRule.Interface == rule.Interface {
return buserr.New("ErrRecordExist")
}
}
}
}
sort.SliceStable(req.Rules, func(i, j int) bool {
if req.Rules[i].Operation == "remove" && req.Rules[j].Operation != "remove" {
return true
}
if req.Rules[i].Operation != "remove" && req.Rules[j].Operation == "remove" {
return false
}
n1, _ := strconv.Atoi(req.Rules[i].Num)
n2, _ := strconv.Atoi(req.Rules[j].Num)
return n1 > n2
})
for _, r := range req.Rules {
for _, p := range strings.Split(r.Protocol, "/") {
if r.TargetIP == "" {
r.TargetIP = "127.0.0.1"
}
if err = client.PortForward(fireClient.Forward{
Num: r.Num,
Protocol: p,
Port: r.Port,
TargetIP: r.TargetIP,
TargetPort: r.TargetPort,
Interface: r.Interface,
}, r.Operation); err != nil {
if req.ForceDelete {
global.LOG.Error(err)
continue
}
return err
}
}
}
return nil
}
func (u *FirewallService) OperateAddressRule(req dto.AddrRuleOperate, reload bool) error {
client, err := firewall.NewFirewallClient()
if err != nil {
@@ -521,7 +426,7 @@ func OperateFirewallPort(oldPorts, newPorts []int) error {
return client.Reload()
}
func (u *FirewallService) operatePort(client firewall.FirewallClient, req dto.PortRuleOperate) error {
func (u *FirewallService) operatePort(client firewall.FilterClient, req dto.PortRuleOperate) error {
var fireInfo fireClient.FireInfo
if err := copier.Copy(&fireInfo, &req); err != nil {
return err
@@ -589,7 +494,7 @@ func (u *FirewallService) loadPortByApp() []portOfApp {
return datas
}
func (u *FirewallService) cleanUnUsedData(client firewall.FirewallClient) {
func (u *FirewallService) cleanUnUsedData(client firewall.FilterClient) {
list, _ := client.ListPort()
addressList, _ := client.ListAddress()
list = append(list, addressList...)
@@ -613,7 +518,7 @@ func (u *FirewallService) cleanUnUsedData(client firewall.FirewallClient) {
}
}
func (u *FirewallService) addPortsBeforeStart(client firewall.FirewallClient) error {
func (u *FirewallService) addPortsBeforeStart(client firewall.FilterClient) error {
if client.Name() == "iptables" {
isInit, _ := iptables.LoadInitStatus("iptables", "base")
if !isInit {
+1 -1
View File
@@ -138,7 +138,7 @@ func syncFirewallPortWhiteListAfterUpdate(oldValue string) error {
return syncFirewallClientPortWhiteList(client, oldPortWhiteList, portWhiteList)
}
func syncFirewallClientPortWhiteList(client firewall.FirewallClient, oldPortWhiteList, portWhiteList []firewallPortWhitelist) error {
func syncFirewallClientPortWhiteList(client firewall.FilterClient, oldPortWhiteList, portWhiteList []firewallPortWhitelist) error {
oldPorts := firewallPortWhiteListMap(oldPortWhiteList)
newPorts := firewallPortWhiteListMap(portWhiteList)
for _, item := range oldPortWhiteList {
+235
View File
@@ -0,0 +1,235 @@
package service
import (
"sort"
"strconv"
"strings"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"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/firewall"
forwardClient "github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
)
type IForwardingService interface {
LoadBaseInfo() (dto.FirewallBaseInfo, error)
SearchWithPage(search dto.ForwardRuleSearch) (int64, interface{}, error)
Operate(req dto.ForwardRuleOperate) error
Enable() error
Replay() error
}
type ForwardingService struct {
adapterFactory func() (forwardClient.Adapter, error)
filterFactory func() (firewall.FilterClient, error)
}
func NewIForwardingService() IForwardingService {
return &ForwardingService{
adapterFactory: newForwardingAdapter,
filterFactory: firewall.NewFirewallClient,
}
}
func newForwardingAdapter() (forwardClient.Adapter, error) {
client, err := firewall.NewFirewallClient()
if err != nil {
return nil, err
}
return forwardClient.NewAdapter(client.Name())
}
func (s *ForwardingService) LoadBaseInfo() (dto.FirewallBaseInfo, error) {
baseInfo := dto.FirewallBaseInfo{Version: "-", Name: "-"}
adapter, err := s.adapterFactory()
if err != nil {
global.LOG.Errorf("load forwarding failed, err: %v", err)
return baseInfo, nil
}
filter, err := s.filterFactory()
if err != nil {
global.LOG.Errorf("load firewall status failed, err: %v", err)
return baseInfo, nil
}
baseInfo.IsExist = true
baseInfo.Name = adapter.Name()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
baseInfo.PingStatus = firewall.LoadPingStatus()
baseInfo.Version, _ = filter.Version()
}()
go func() {
defer wg.Done()
baseInfo.IsActive, _ = filter.Status()
baseInfo.IsInit, baseInfo.IsBind = adapter.InitStatus()
}()
wg.Wait()
return baseInfo, nil
}
func (s *ForwardingService) SearchWithPage(req dto.ForwardRuleSearch) (int64, interface{}, error) {
adapter, err := s.adapterFactory()
if err != nil {
return 0, nil, err
}
rules, err := adapter.List()
if err != nil {
return 0, nil, err
}
if req.Strategy != "" {
return 0, nil, nil
}
var filtered []forwardClient.Rule
for _, rule := range rules {
if req.Info != "" && !strings.Contains(rule.Port, req.Info) &&
!strings.Contains(rule.TargetPort, req.Info) && !strings.Contains(rule.TargetIP, req.Info) {
continue
}
filtered = append(filtered, rule)
}
total := len(filtered)
start, end := (req.Page-1)*req.PageSize, req.Page*req.PageSize
if start > total {
return int64(total), make([]dto.ForwardRule, 0), nil
}
if end > total {
end = total
}
pageRules := filtered[start:end]
var items []dto.ForwardRule
if pageRules != nil {
items = make([]dto.ForwardRule, 0, len(pageRules))
}
for _, rule := range pageRules {
items = append(items, dto.ForwardRule{
Num: rule.Num,
Protocol: rule.Protocol,
Port: rule.Port,
TargetIP: rule.TargetIP,
TargetPort: rule.TargetPort,
Interface: rule.Interface,
})
}
return int64(total), items, nil
}
func (s *ForwardingService) Operate(req dto.ForwardRuleOperate) error {
adapter, err := s.adapterFactory()
if err != nil {
return err
}
rules, _ := adapter.List()
kept := rules[:0]
for _, rule := range rules {
shouldKeep := true
for i := range req.Rules {
reqRule := &req.Rules[i]
if reqRule.TargetIP == "" {
reqRule.TargetIP = "127.0.0.1"
}
if reqRule.Operation == "remove" && requestMatchesForwardRule(*reqRule, rule) {
shouldKeep = false
break
}
}
if shouldKeep {
kept = append(kept, rule)
}
}
for _, rule := range kept {
for _, reqRule := range req.Rules {
if reqRule.Operation != "remove" && requestMatchesForwardRule(reqRule, rule) {
return buserr.New("ErrRecordExist")
}
}
}
sort.SliceStable(req.Rules, func(i, j int) bool {
if req.Rules[i].Operation == "remove" && req.Rules[j].Operation != "remove" {
return true
}
if req.Rules[i].Operation != "remove" && req.Rules[j].Operation == "remove" {
return false
}
n1, _ := strconv.Atoi(req.Rules[i].Num)
n2, _ := strconv.Atoi(req.Rules[j].Num)
return n1 > n2
})
for _, rule := range req.Rules {
for _, protocol := range strings.Split(rule.Protocol, "/") {
targetIP := rule.TargetIP
if targetIP == "" {
targetIP = "127.0.0.1"
}
err := adapter.Operate(forwardClient.Rule{
Num: rule.Num,
Protocol: protocol,
Port: rule.Port,
TargetIP: targetIP,
TargetPort: rule.TargetPort,
Interface: rule.Interface,
}, rule.Operation)
if err == nil {
continue
}
if req.ForceDelete {
global.LOG.Error(err)
continue
}
return err
}
}
return nil
}
func requestMatchesForwardRule(req dto.ForwardRuleOperation, rule forwardClient.Rule) bool {
for _, protocol := range strings.Split(req.Protocol, "/") {
if req.Port == rule.Port && req.TargetPort == rule.TargetPort && req.TargetIP == rule.TargetIP &&
protocol == rule.Protocol && req.Interface == rule.Interface {
return true
}
}
return false
}
func (s *ForwardingService) Enable() error {
adapter, err := s.adapterFactory()
if err != nil {
return err
}
if err := adapter.Enable(); err != nil {
return err
}
if adapter.Name() != "firewalld" {
_ = settingRepo.Update("IptablesForwardStatus", constant.StatusEnable)
}
return nil
}
func (s *ForwardingService) Replay() error {
adapter, err := s.adapterFactory()
if err != nil {
return err
}
if err := adapter.Replay(); err != nil {
return err
}
if adapter.Name() == "firewalld" {
return nil
}
status, _ := settingRepo.GetValueByKey("IptablesForwardStatus")
if status == constant.StatusEnable {
return adapter.Enable()
}
return nil
}
@@ -0,0 +1,152 @@
package service
import (
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/firewall"
forwardClient "github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
"github.com/go-playground/validator/v10"
)
type forwardingCall struct {
rule forwardClient.Rule
operation string
}
type fakeForwardingAdapter struct {
name string
rules []forwardClient.Rule
listErr error
operateErr error
calls []forwardingCall
}
func (f *fakeForwardingAdapter) Name() string { return f.name }
func (f *fakeForwardingAdapter) List() ([]forwardClient.Rule, error) {
return append([]forwardClient.Rule(nil), f.rules...), f.listErr
}
func (f *fakeForwardingAdapter) Operate(rule forwardClient.Rule, operation string) error {
f.calls = append(f.calls, forwardingCall{rule: rule, operation: operation})
return f.operateErr
}
func (f *fakeForwardingAdapter) Enable() error { return nil }
func (f *fakeForwardingAdapter) InitStatus() (bool, bool) { return true, true }
func (f *fakeForwardingAdapter) Replay() error { return nil }
func forwardingServiceWithAdapter(adapter forwardClient.Adapter) *ForwardingService {
return &ForwardingService{
adapterFactory: func() (forwardClient.Adapter, error) { return adapter, nil },
filterFactory: firewall.NewFirewallClient,
}
}
func TestForwardingAndFilterInterfacesAreSeparated(t *testing.T) {
filterType := reflect.TypeOf((*firewall.FilterClient)(nil)).Elem()
for _, method := range []string{"ListForward", "PortForward", "EnableForward"} {
if _, ok := filterType.MethodByName(method); ok {
t.Fatalf("filter interface still exposes %s", method)
}
}
firewallServiceType := reflect.TypeOf((*IFirewallService)(nil)).Elem()
if _, ok := firewallServiceType.MethodByName("OperateForwardRule"); ok {
t.Fatal("firewall service still owns forwarding writes")
}
forwardingServiceType := reflect.TypeOf((*IForwardingService)(nil)).Elem()
for _, method := range []string{"LoadBaseInfo", "SearchWithPage", "Operate", "Enable", "Replay"} {
if _, ok := forwardingServiceType.MethodByName(method); !ok {
t.Fatalf("forwarding service missing %s", method)
}
}
}
func TestForwardingInitRequestContract(t *testing.T) {
req := dto.IptablesOp{Name: "1PANEL_FORWARD", Operate: "init-forward"}
if err := validator.New().Struct(req); err != nil {
t.Fatalf("frontend forwarding initialization request must remain valid: %v", err)
}
}
func TestForwardingSearchPreservesAPIShapeAndPagination(t *testing.T) {
adapter := &fakeForwardingAdapter{name: "iptables", rules: []forwardClient.Rule{
{Num: "1", Protocol: "tcp", Port: "8080", TargetIP: "10.0.0.2", TargetPort: "80", Interface: "eth0"},
{Num: "2", Protocol: "udp", Port: "5353", TargetIP: "127.0.0.1", TargetPort: "53"},
}}
service := forwardingServiceWithAdapter(adapter)
total, value, err := service.SearchWithPage(dto.ForwardRuleSearch{PageInfo: dto.PageInfo{Page: 1, PageSize: 10}, Info: "10.0.0.2"})
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Fatalf("got total %d want 1", total)
}
items, ok := value.([]dto.ForwardRule)
if !ok || len(items) != 1 || items[0].Port != "8080" {
t.Fatalf("unexpected items: %#v", value)
}
data, err := json.Marshal(items[0])
if err != nil {
t.Fatal(err)
}
var fields map[string]interface{}
if err := json.Unmarshal(data, &fields); err != nil {
t.Fatal(err)
}
wantFields := []string{"id", "chain", "family", "address", "port", "protocol", "strategy", "num", "targetIP", "targetPort", "interface", "usedStatus", "description"}
for _, field := range wantFields {
if _, ok := fields[field]; !ok {
t.Fatalf("forward response dropped compatibility field %q: %s", field, data)
}
}
}
func TestForwardingOperatePreservesDuplicateAndOrderingContracts(t *testing.T) {
existing := &fakeForwardingAdapter{name: "ufw", rules: []forwardClient.Rule{
{Protocol: "tcp", Port: "8080", TargetIP: "127.0.0.1", TargetPort: "80"},
}}
service := forwardingServiceWithAdapter(existing)
err := service.Operate(dto.ForwardRuleOperate{Rules: []dto.ForwardRuleOperation{{
Operation: "add", Protocol: "tcp", Port: "8080", TargetPort: "80",
}}})
if err == nil {
t.Fatal("duplicate forwarding rule must be rejected")
}
if len(existing.calls) != 0 {
t.Fatalf("duplicate check wrote forwarding state: %#v", existing.calls)
}
adapter := &fakeForwardingAdapter{name: "iptables"}
service = forwardingServiceWithAdapter(adapter)
err = service.Operate(dto.ForwardRuleOperate{Rules: []dto.ForwardRuleOperation{
{Operation: "add", Protocol: "tcp/udp", Port: "9000", TargetIP: "10.0.0.2", TargetPort: "90"},
{Operation: "remove", Num: "1", Protocol: "tcp", Port: "8001", TargetIP: "10.0.0.2", TargetPort: "81"},
{Operation: "remove", Num: "3", Protocol: "tcp", Port: "8003", TargetIP: "10.0.0.2", TargetPort: "83"},
}})
if err != nil {
t.Fatal(err)
}
want := []forwardingCall{
{operation: "remove", rule: forwardClient.Rule{Num: "3", Protocol: "tcp", Port: "8003", TargetIP: "10.0.0.2", TargetPort: "83"}},
{operation: "remove", rule: forwardClient.Rule{Num: "1", Protocol: "tcp", Port: "8001", TargetIP: "10.0.0.2", TargetPort: "81"}},
{operation: "add", rule: forwardClient.Rule{Protocol: "tcp", Port: "9000", TargetIP: "10.0.0.2", TargetPort: "90"}},
{operation: "add", rule: forwardClient.Rule{Protocol: "udp", Port: "9000", TargetIP: "10.0.0.2", TargetPort: "90"}},
}
if !reflect.DeepEqual(adapter.calls, want) {
t.Fatalf("operation order changed\ngot %#v\nwant %#v", adapter.calls, want)
}
}
func TestForwardingSearchReturnsAdapterError(t *testing.T) {
wantErr := errors.New("list failed")
service := forwardingServiceWithAdapter(&fakeForwardingAdapter{name: "firewalld", listErr: wantErr})
_, _, err := service.SearchWithPage(dto.ForwardRuleSearch{PageInfo: dto.PageInfo{Page: 1, PageSize: 20}})
if !errors.Is(err, wantErr) {
t.Fatalf("got %v want %v", err, wantErr)
}
}
+68 -24
View File
@@ -1,6 +1,8 @@
package service
import (
"errors"
"fmt"
"os"
"sort"
@@ -19,9 +21,10 @@ type FtpService struct{}
type IFtpService interface {
LoadBaseInfo() (dto.FtpBaseInfo, error)
SearchWithPage(search dto.SearchWithPage) (int64, interface{}, error)
SearchWithPage(search dto.SearchWithPage, readOnly ...bool) (int64, interface{}, error)
Operate(operation string) error
Create(req dto.FtpCreate) (uint, error)
CreateWebsite(req dto.FtpCreate) (uint, error)
Delete(req dto.BatchDeleteReq) error
Update(req dto.FtpUpdate) error
Sync() error
@@ -34,11 +37,7 @@ func NewIFtpService() IFtpService {
func (f *FtpService) LoadBaseInfo() (dto.FtpBaseInfo, error) {
var baseInfo dto.FtpBaseInfo
client, err := toolbox.NewFtpClient()
if err != nil {
return baseInfo, err
}
baseInfo.IsActive, baseInfo.IsExist = client.Status()
baseInfo.IsActive, baseInfo.IsExist = toolbox.FtpStatus()
return baseInfo, nil
}
@@ -75,7 +74,7 @@ func (u *FtpService) Operate(operation string) error {
return client.Operate(operation)
}
func (f *FtpService) SearchWithPage(req dto.SearchWithPage) (int64, interface{}, error) {
func (f *FtpService) SearchWithPage(req dto.SearchWithPage, readOnly ...bool) (int64, interface{}, error) {
total, lists, err := ftpRepo.Page(req.Page, req.PageSize, ftpRepo.WithLikeUser(req.Info), repo.WithOrderDesc("created_at"))
if err != nil {
return 0, nil, err
@@ -86,7 +85,11 @@ func (f *FtpService) SearchWithPage(req dto.SearchWithPage) (int64, interface{},
if err := copier.Copy(&item, &user); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
item.Password, _ = encrypt.StringDecrypt(item.Password)
if isDemoReadOnly(readOnly...) {
item.Password = ""
} else {
item.Password, _ = encrypt.StringDecrypt(item.Password)
}
users = append(users, item)
}
return total, users, err
@@ -99,7 +102,7 @@ func (f *FtpService) Sync() error {
}
lists, err := client.LoadList()
if err != nil {
return nil
return err
}
listsInDB, err := ftpRepo.GetList()
if err != nil {
@@ -113,13 +116,24 @@ func (f *FtpService) Sync() error {
for _, item := range lists {
if itemInDB, ok := currentData[item.User]; ok {
sameData[item.User] = struct{}{}
if item.Path != itemInDB.Path || item.Status != itemInDB.Status {
if err := ftpRepo.Update(itemInDB.ID, map[string]interface{}{"path": item.Path, "status": item.Status}); err != nil {
if item.Path != itemInDB.Path || item.Status != itemInDB.Status || item.UID != itemInDB.UID || item.GID != itemInDB.GID {
if err := ftpRepo.Update(itemInDB.ID, map[string]interface{}{
"path": item.Path,
"status": item.Status,
"uid": item.UID,
"gid": item.GID,
}); err != nil {
return err
}
}
} else {
if err := ftpRepo.Create(&model.Ftp{User: item.User, Path: item.Path, Status: item.Status}); err != nil {
if err := ftpRepo.Create(&model.Ftp{
User: item.User,
Path: item.Path,
Status: item.Status,
UID: item.UID,
GID: item.GID,
}); err != nil {
return err
}
}
@@ -133,6 +147,21 @@ func (f *FtpService) Sync() error {
}
func (f *FtpService) Create(req dto.FtpCreate) (uint, error) {
return f.create(req, false)
}
func (f *FtpService) CreateWebsite(req dto.FtpCreate) (uint, error) {
return f.create(req, true)
}
func (f *FtpService) create(req dto.FtpCreate, website bool) (uint, error) {
if err := toolbox.ValidateFtpRootPath(req.Path); err != nil {
return 0, err
}
client, err := toolbox.NewFtpClient()
if err != nil {
return 0, err
}
if _, err := os.Stat(req.Path); err != nil {
if os.IsNotExist(err) {
if err := os.MkdirAll(req.Path, os.ModePerm); err != nil {
@@ -150,20 +179,28 @@ func (f *FtpService) Create(req dto.FtpCreate) (uint, error) {
if userInDB.ID != 0 {
return 0, buserr.New("ErrRecordExist")
}
client, err := toolbox.NewFtpClient()
if err != nil {
return 0, err
}
if err := client.UserAdd(req.User, req.Password, req.Path); err != nil {
return 0, err
}
var ftp model.Ftp
if err := copier.Copy(&ftp, &req); err != nil {
return 0, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
uid, gid := uint(constant.WebsiteUID), uint(constant.WebsiteGID)
if !website {
uid, gid, err = toolbox.EnsureStandaloneFtpIdentity()
if err != nil {
return 0, err
}
}
if err := client.UserAdd(req.User, req.Password, req.Path, uid, gid); err != nil {
return 0, err
}
ftp.Status = constant.StatusEnable
ftp.Password = pass
ftp.UID = uid
ftp.GID = gid
if err := ftpRepo.Create(&ftp); err != nil {
if rollbackErr := client.UserDel(req.User); rollbackErr != nil {
return 0, errors.Join(err, fmt.Errorf("rollback FTP user %s failed: %w", req.User, rollbackErr))
}
return 0, err
}
return ftp.ID, nil
@@ -186,6 +223,13 @@ func (f *FtpService) Delete(req dto.BatchDeleteReq) error {
}
func (f *FtpService) Update(req dto.FtpUpdate) error {
if err := toolbox.ValidateFtpRootPath(req.Path); err != nil {
return err
}
client, err := toolbox.NewFtpClient()
if err != nil {
return err
}
if _, err := os.Stat(req.Path); err != nil {
if os.IsNotExist(err) {
if err := os.MkdirAll(req.Path, os.ModePerm); err != nil {
@@ -209,10 +253,6 @@ func (f *FtpService) Update(req dto.FtpUpdate) error {
return err
}
client, err := toolbox.NewFtpClient()
if err != nil {
return err
}
needReload := false
updates := make(map[string]interface{})
if req.Password != passItem {
@@ -230,7 +270,11 @@ func (f *FtpService) Update(req dto.FtpUpdate) error {
needReload = true
}
if req.Path != ftpItem.Path {
if err := client.SetPath(ftpItem.User, req.Path); err != nil {
uid, gid := ftpItem.UID, ftpItem.GID
if uid == 0 || gid == 0 {
uid, gid = uint(constant.WebsiteUID), uint(constant.WebsiteGID)
}
if err := client.SetPath(ftpItem.User, req.Path, uid, gid); err != nil {
return err
}
updates["path"] = req.Path
+7 -2
View File
@@ -21,7 +21,7 @@ type IHostService interface {
TestByInfo(req dto.HostConnTest) bool
GetHostByID(id uint) (*dto.HostInfo, error)
SearchForTree(search dto.SearchForTree) ([]dto.HostTree, error)
SearchWithPage(search dto.SearchPageWithGroup) (int64, interface{}, error)
SearchWithPage(search dto.SearchPageWithGroup, readOnly ...bool) (int64, interface{}, error)
Create(req dto.HostOperate) (*dto.HostInfo, error)
Update(id uint, upMap map[string]interface{}) (*dto.HostInfo, error)
Delete(id []uint) error
@@ -111,7 +111,7 @@ func (u *HostService) TestLocalConn(id uint) bool {
return true
}
func (u *HostService) SearchWithPage(req dto.SearchPageWithGroup) (int64, interface{}, error) {
func (u *HostService) SearchWithPage(req dto.SearchPageWithGroup, readOnly ...bool) (int64, interface{}, error) {
var options []repo.DBOption
if len(req.Info) != 0 {
options = append(options, hostRepo.WithByInfo(req.Info))
@@ -155,6 +155,11 @@ func (u *HostService) SearchWithPage(req dto.SearchPageWithGroup) (int64, interf
}
}
}
if isDemoReadOnly(readOnly...) {
item.Password = ""
item.PrivateKey = ""
item.PassPhrase = ""
}
dtoHosts = append(dtoHosts, item)
}
return total, dtoHosts, err
+5 -2
View File
@@ -24,7 +24,7 @@ import (
type ImageRepoService struct{}
type IImageRepoService interface {
Page(search dto.SearchWithPage) (int64, interface{}, error)
Page(search dto.SearchWithPage, readOnly ...bool) (int64, interface{}, error)
List() ([]dto.ImageRepoOption, error)
Login(req dto.OperateByID) error
Create(req dto.ImageRepoCreate) error
@@ -36,10 +36,13 @@ func NewIImageRepoService() IImageRepoService {
return &ImageRepoService{}
}
func (u *ImageRepoService) Page(req dto.SearchWithPage) (int64, interface{}, error) {
func (u *ImageRepoService) Page(req dto.SearchWithPage, readOnly ...bool) (int64, interface{}, error) {
total, ops, err := imageRepoRepo.Page(req.Page, req.PageSize, repo.WithByLikeName(req.Info), repo.WithOrderDesc("created_at"))
var dtoOps []dto.ImageRepoInfo
for _, op := range ops {
if isDemoReadOnly(readOnly...) {
op.Password = ""
}
var item dto.ImageRepoInfo
if err := copier.Copy(&item, &op); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)

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