1
0
mirror of https://github.com/1Panel-dev/1Panel.git synced 2025-03-17 03:04:46 +08:00

feat:网站目录支持三级 增加网站目录权限校验 (#2190)

Refs https://github.com/1Panel-dev/1Panel/issues/2140
This commit is contained in:
zhengkunwang 2023-09-05 22:28:11 +08:00 committed by GitHub
parent 31cd6691c9
commit 4d8118d9ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 259 additions and 165 deletions

View File

@ -912,3 +912,25 @@ func (b *BaseApi) UpdateRedirectConfigFile(c *gin.Context) {
} }
helper.SuccessWithOutData(c) helper.SuccessWithOutData(c)
} }
// @Tags Website
// @Summary Get website dir
// @Description 获取网站目录配置
// @Accept json
// @Param request body request.WebsiteCommonReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /websites/dir [post]
func (b *BaseApi) GetDirConfig(c *gin.Context) {
var req request.WebsiteCommonReq
if err := c.ShouldBindJSON(&req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
res, err := websiteService.LoadWebsiteDirConfig(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, res)
}

View File

@ -213,3 +213,7 @@ type WebsiteWafFileUpdate struct {
Content string `json:"content" validate:"required"` Content string `json:"content" validate:"required"`
Type string `json:"type" validate:"required,oneof=cc ip_white ip_block url_white url_block cookie_block args_check post_check ua_check file_ext_block"` Type string `json:"type" validate:"required,oneof=cc ip_white ip_block url_white url_block cookie_block args_check post_check ua_check file_ext_block"`
} }
type WebsiteCommonReq struct {
ID uint `json:"id" validate:"required"`
}

View File

@ -53,3 +53,10 @@ type PHPConfig struct {
type NginxRewriteRes struct { type NginxRewriteRes struct {
Content string `json:"content"` Content string `json:"content"`
} }
type WebsiteDirConfig struct {
Dirs []string `json:"dirs"`
User string `json:"user"`
UserGroup string `json:"userGroup"`
Msg string `json:"msg"`
}

View File

@ -9,13 +9,16 @@ import (
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
"github.com/1Panel-dev/1Panel/backend/i18n"
"github.com/1Panel-dev/1Panel/backend/utils/common" "github.com/1Panel-dev/1Panel/backend/utils/common"
"github.com/spf13/afero"
"os" "os"
"path" "path"
"reflect" "reflect"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"syscall"
"time" "time"
"github.com/1Panel-dev/1Panel/backend/utils/compose" "github.com/1Panel-dev/1Panel/backend/utils/compose"
@ -80,6 +83,7 @@ type IWebsiteService interface {
GetRewriteConfig(req request.NginxRewriteReq) (*response.NginxRewriteRes, error) GetRewriteConfig(req request.NginxRewriteReq) (*response.NginxRewriteRes, error)
UpdateRewriteConfig(req request.NginxRewriteUpdate) error UpdateRewriteConfig(req request.NginxRewriteUpdate) error
LoadWebsiteDirConfig(req request.WebsiteCommonReq) (*response.WebsiteDirConfig, error)
UpdateSiteDir(req request.WebsiteUpdateDir) error UpdateSiteDir(req request.WebsiteUpdateDir) error
UpdateSitePermission(req request.WebsiteUpdateDirPermission) error UpdateSitePermission(req request.WebsiteUpdateDirPermission) error
OperateProxy(req request.WebsiteProxyConfig) (err error) OperateProxy(req request.WebsiteProxyConfig) (err error)
@ -1389,7 +1393,7 @@ func (w WebsiteService) UpdateSiteDir(req request.WebsiteUpdateDir) error {
runDir := req.SiteDir runDir := req.SiteDir
siteDir := path.Join("/www/sites", website.Alias, "index") siteDir := path.Join("/www/sites", website.Alias, "index")
if req.SiteDir != "/" { if req.SiteDir != "/" {
siteDir = fmt.Sprintf("%s/%s", siteDir, req.SiteDir) siteDir = fmt.Sprintf("%s%s", siteDir, req.SiteDir)
} }
if err := updateNginxConfig(constant.NginxScopeServer, []dto.NginxParam{{Name: "root", Params: []string{siteDir}}}, &website); err != nil { if err := updateNginxConfig(constant.NginxScopeServer, []dto.NginxParam{{Name: "root", Params: []string{siteDir}}}, &website); err != nil {
return err return err
@ -1408,9 +1412,6 @@ func (w WebsiteService) UpdateSitePermission(req request.WebsiteUpdateDirPermiss
return err return err
} }
absoluteIndexPath := path.Join(nginxInstall.GetPath(), "www", "sites", website.Alias, "index") absoluteIndexPath := path.Join(nginxInstall.GetPath(), "www", "sites", website.Alias, "index")
if website.SiteDir != "/" {
absoluteIndexPath = path.Join(absoluteIndexPath, website.SiteDir)
}
chownCmd := fmt.Sprintf("chown -R %s:%s %s", req.User, req.Group, absoluteIndexPath) chownCmd := fmt.Sprintf("chown -R %s:%s %s", req.User, req.Group, absoluteIndexPath)
if cmd.HasNoPasswordSudo() { if cmd.HasNoPasswordSudo() {
chownCmd = fmt.Sprintf("sudo %s", chownCmd) chownCmd = fmt.Sprintf("sudo %s", chownCmd)
@ -2318,3 +2319,52 @@ func (w WebsiteService) UpdateWafFile(req request.WebsiteWafFileUpdate) (err err
rulePath := path.Join(nginxInstall.GetPath(), "www", "sites", website.Alias, "waf", "rules", fmt.Sprintf("%s.json", req.Type)) rulePath := path.Join(nginxInstall.GetPath(), "www", "sites", website.Alias, "waf", "rules", fmt.Sprintf("%s.json", req.Type))
return files.NewFileOp().WriteFile(rulePath, strings.NewReader(req.Content), 0755) return files.NewFileOp().WriteFile(rulePath, strings.NewReader(req.Content), 0755)
} }
func (w WebsiteService) LoadWebsiteDirConfig(req request.WebsiteCommonReq) (*response.WebsiteDirConfig, error) {
website, err := websiteRepo.GetFirst(commonRepo.WithByID(req.ID))
if err != nil {
return nil, err
}
res := &response.WebsiteDirConfig{}
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return nil, err
}
absoluteIndexPath := path.Join(nginxInstall.GetPath(), "www", "sites", website.Alias, "index")
var appFs = afero.NewOsFs()
info, err := appFs.Stat(absoluteIndexPath)
if err != nil {
return nil, err
}
res.User = strconv.FormatUint(uint64(info.Sys().(*syscall.Stat_t).Uid), 10)
res.UserGroup = strconv.FormatUint(uint64(info.Sys().(*syscall.Stat_t).Gid), 10)
indexFiles, err := os.ReadDir(absoluteIndexPath)
if err != nil {
return nil, err
}
res.Dirs = []string{"/"}
for _, file := range indexFiles {
if !file.IsDir() {
continue
}
res.Dirs = append(res.Dirs, fmt.Sprintf("/%s", file.Name()))
fileInfo, _ := file.Info()
if fileInfo.Sys().(*syscall.Stat_t).Uid != 1000 || fileInfo.Sys().(*syscall.Stat_t).Gid != 1000 {
res.Msg = i18n.GetMsgByKey("ErrPathPermission")
}
childFiles, _ := os.ReadDir(absoluteIndexPath + "/" + file.Name())
for _, childFile := range childFiles {
if !childFile.IsDir() {
continue
}
childInfo, _ := childFile.Info()
if childInfo.Sys().(*syscall.Stat_t).Uid != 1000 || childInfo.Sys().(*syscall.Stat_t).Gid != 1000 {
res.Msg = i18n.GetMsgByKey("ErrPathPermission")
}
res.Dirs = append(res.Dirs, fmt.Sprintf("/%s/%s", file.Name(), childFile.Name()))
}
}
return res, nil
}

View File

@ -639,3 +639,7 @@ func chownRootDir(path string) error {
} }
return nil return nil
} }
func checkWebsiteDirPermission(path string) error {
return nil
}

View File

@ -66,6 +66,7 @@ ErrGroupIsUsed: 'The group is in use and cannot be deleted'
ErrBackupMatch: 'the backup file does not match the current partial data of the website: {{ .detail}}"' ErrBackupMatch: 'the backup file does not match the current partial data of the website: {{ .detail}}"'
ErrBackupExist: 'the backup file corresponds to a portion of the original data that does not exist: {{ .detail}}"' ErrBackupExist: 'the backup file corresponds to a portion of the original data that does not exist: {{ .detail}}"'
ErrPHPResource: 'The local runtime does not support switching' ErrPHPResource: 'The local runtime does not support switching'
ErrPathPermission: 'A folder with non-1000:1000 permissions was detected in the index directory, which may cause Access denied errors when accessing the website.'
#ssl #ssl
ErrSSLCannotDelete: "The certificate is being used by the website and cannot be removed" ErrSSLCannotDelete: "The certificate is being used by the website and cannot be removed"

View File

@ -66,6 +66,7 @@ ErrGroupIsUsed: '分組正在使用中,無法刪除'
ErrBackupMatch: '該備份文件與當前網站部分數據不匹配: {{ .detail}}"' ErrBackupMatch: '該備份文件與當前網站部分數據不匹配: {{ .detail}}"'
ErrBackupExist: '該備份文件對應部分原數據不存在: {{ .detail}}"' ErrBackupExist: '該備份文件對應部分原數據不存在: {{ .detail}}"'
ErrPHPResource: '本地運行環境不支持切換!' ErrPHPResource: '本地運行環境不支持切換!'
ErrPathPermission: 'index 目錄下檢測到非 1000:1000 權限文件夾,可能導致網站訪問 Access denied 錯誤'
#ssl #ssl
ErrSSLCannotDelete: "證書正在被網站使用,無法刪除" ErrSSLCannotDelete: "證書正在被網站使用,無法刪除"

View File

@ -66,6 +66,7 @@ ErrGroupIsUsed: '分组正在使用中,无法删除'
ErrBackupMatch: '该备份文件与当前网站部分数据不匹配 {{ .detail}}"' ErrBackupMatch: '该备份文件与当前网站部分数据不匹配 {{ .detail}}"'
ErrBackupExist: '该备份文件对应部分源数据不存在 {{ .detail}}"' ErrBackupExist: '该备份文件对应部分源数据不存在 {{ .detail}}"'
ErrPHPResource: '本地运行环境不支持切换!' ErrPHPResource: '本地运行环境不支持切换!'
ErrPathPermission: 'index 目录下检测到非 1000:1000 权限文件夹,可能导致网站访问 Access denied 错误'
#ssl #ssl
ErrSSLCannotDelete: "证书正在被网站使用,无法删除" ErrSSLCannotDelete: "证书正在被网站使用,无法删除"

View File

@ -53,6 +53,7 @@ func (a *WebsiteRouter) InitWebsiteRouter(Router *gin.RouterGroup) {
groupRouter.POST("/dir/update", baseApi.UpdateSiteDir) groupRouter.POST("/dir/update", baseApi.UpdateSiteDir)
groupRouter.POST("/dir/permission", baseApi.UpdateSiteDirPermission) groupRouter.POST("/dir/permission", baseApi.UpdateSiteDirPermission)
groupRouter.POST("/dir", baseApi.GetDirConfig)
groupRouter.POST("/proxies", baseApi.GetProxyConfig) groupRouter.POST("/proxies", baseApi.GetProxyConfig)
groupRouter.POST("/proxies/update", baseApi.UpdateProxyConfig) groupRouter.POST("/proxies/update", baseApi.UpdateProxyConfig)

View File

@ -1,5 +1,5 @@
// Code generated by swaggo/swag. DO NOT EDIT. // Package docs GENERATED BY SWAG; DO NOT EDIT
// This file was generated by swaggo/swag
package docs package docs
import "github.com/swaggo/swag" import "github.com/swaggo/swag"
@ -6446,31 +6446,6 @@ const docTemplate = `{
} }
} }
}, },
"/host/tool/supervisor/process/load": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "获取 Supervisor 进程状态",
"tags": [
"Host tool"
],
"summary": "Load Supervisor process status",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/response.ProcessStatus"
}
}
}
}
}
},
"/hosts": { "/hosts": {
"post": { "post": {
"security": [ "security": [
@ -9907,6 +9882,39 @@ const docTemplate = `{
} }
} }
}, },
"/websites/dir": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "获取网站目录配置",
"consumes": [
"application/json"
],
"tags": [
"Website"
],
"summary": "Get website dir",
"parameters": [
{
"description": "request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/request.WebsiteCommonReq"
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/websites/dir/permission": { "/websites/dir/permission": {
"post": { "post": {
"security": [ "security": [
@ -12781,10 +12789,7 @@ const docTemplate = `{
"type": "integer" "type": "integer"
}, },
"type": { "type": {
"type": "string", "type": "string"
"enum": [
"mysql"
]
}, },
"username": { "username": {
"type": "string" "type": "string"
@ -12822,6 +12827,9 @@ const docTemplate = `{
"port": { "port": {
"type": "integer" "type": "integer"
}, },
"type": {
"type": "string"
},
"username": { "username": {
"type": "string" "type": "string"
}, },
@ -16401,6 +16409,17 @@ const docTemplate = `{
} }
} }
}, },
"request.WebsiteCommonReq": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "integer"
}
}
},
"request.WebsiteCreate": { "request.WebsiteCreate": {
"type": "object", "type": "object",
"required": [ "required": [
@ -17444,26 +17463,6 @@ const docTemplate = `{
} }
} }
}, },
"response.ProcessStatus": {
"type": "object",
"properties": {
"PID": {
"type": "string"
},
"msg": {
"type": "string"
},
"name": {
"type": "string"
},
"status": {
"type": "string"
},
"uptime": {
"type": "string"
}
}
},
"response.WebsiteAcmeAccountDTO": { "response.WebsiteAcmeAccountDTO": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -6439,31 +6439,6 @@
} }
} }
}, },
"/host/tool/supervisor/process/load": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "获取 Supervisor 进程状态",
"tags": [
"Host tool"
],
"summary": "Load Supervisor process status",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/response.ProcessStatus"
}
}
}
}
}
},
"/hosts": { "/hosts": {
"post": { "post": {
"security": [ "security": [
@ -9900,6 +9875,39 @@
} }
} }
}, },
"/websites/dir": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "获取网站目录配置",
"consumes": [
"application/json"
],
"tags": [
"Website"
],
"summary": "Get website dir",
"parameters": [
{
"description": "request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/request.WebsiteCommonReq"
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/websites/dir/permission": { "/websites/dir/permission": {
"post": { "post": {
"security": [ "security": [
@ -12774,10 +12782,7 @@
"type": "integer" "type": "integer"
}, },
"type": { "type": {
"type": "string", "type": "string"
"enum": [
"mysql"
]
}, },
"username": { "username": {
"type": "string" "type": "string"
@ -12815,6 +12820,9 @@
"port": { "port": {
"type": "integer" "type": "integer"
}, },
"type": {
"type": "string"
},
"username": { "username": {
"type": "string" "type": "string"
}, },
@ -16394,6 +16402,17 @@
} }
} }
}, },
"request.WebsiteCommonReq": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "integer"
}
}
},
"request.WebsiteCreate": { "request.WebsiteCreate": {
"type": "object", "type": "object",
"required": [ "required": [
@ -17437,26 +17456,6 @@
} }
} }
}, },
"response.ProcessStatus": {
"type": "object",
"properties": {
"PID": {
"type": "string"
},
"msg": {
"type": "string"
},
"name": {
"type": "string"
},
"status": {
"type": "string"
},
"uptime": {
"type": "string"
}
}
},
"response.WebsiteAcmeAccountDTO": { "response.WebsiteAcmeAccountDTO": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -737,8 +737,6 @@ definitions:
port: port:
type: integer type: integer
type: type:
enum:
- mysql
type: string type: string
username: username:
type: string type: string
@ -771,6 +769,8 @@ definitions:
type: string type: string
port: port:
type: integer type: integer
type:
type: string
username: username:
type: string type: string
version: version:
@ -3169,6 +3169,13 @@ definitions:
required: required:
- email - email
type: object type: object
request.WebsiteCommonReq:
properties:
id:
type: integer
required:
- id
type: object
request.WebsiteCreate: request.WebsiteCreate:
properties: properties:
IPV6: IPV6:
@ -3869,19 +3876,6 @@ definitions:
uploadMaxSize: uploadMaxSize:
type: string type: string
type: object type: object
response.ProcessStatus:
properties:
PID:
type: string
msg:
type: string
name:
type: string
status:
type: string
uptime:
type: string
type: object
response.WebsiteAcmeAccountDTO: response.WebsiteAcmeAccountDTO:
properties: properties:
createdAt: createdAt:
@ -8116,21 +8110,6 @@ paths:
formatEN: '[operate] Supervisor Process Config file' formatEN: '[operate] Supervisor Process Config file'
formatZH: '[operate] Supervisor 进程文件 ' formatZH: '[operate] Supervisor 进程文件 '
paramKeys: [] paramKeys: []
/host/tool/supervisor/process/load:
post:
description: 获取 Supervisor 进程状态
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/response.ProcessStatus'
type: array
security:
- ApiKeyAuth: []
summary: Load Supervisor process status
tags:
- Host tool
/hosts: /hosts:
post: post:
consumes: consumes:
@ -10305,6 +10284,26 @@ paths:
formatEN: Delete website [domain] formatEN: Delete website [domain]
formatZH: 删除网站 [domain] formatZH: 删除网站 [domain]
paramKeys: [] paramKeys: []
/websites/dir:
post:
consumes:
- application/json
description: 获取网站目录配置
parameters:
- description: request
in: body
name: request
required: true
schema:
$ref: '#/definitions/request.WebsiteCommonReq'
responses:
"200":
description: OK
security:
- ApiKeyAuth: []
summary: Get website dir
tags:
- Website
/websites/dir/permission: /websites/dir/permission:
post: post:
consumes: consumes:

View File

@ -431,4 +431,11 @@ export namespace Website {
runtimeID: number; runtimeID: number;
retainConfig: boolean; retainConfig: boolean;
} }
export interface DirConfig {
dirs: string[];
user: string;
userGroup: string;
msg: string;
}
} }

View File

@ -234,3 +234,7 @@ export const UpdateRedirectConfigFile = (req: Website.RedirectFileUpdate) => {
export const ChangePHPVersion = (req: Website.PHPVersionChange) => { export const ChangePHPVersion = (req: Website.PHPVersionChange) => {
return http.post<any>(`/websites/php/version`, req); return http.post<any>(`/websites/php/version`, req);
}; };
export const GetDirConfig = (req: Website.ProxyReq) => {
return http.post<Website.DirConfig>(`/websites/dir`, req);
};

View File

@ -21,7 +21,6 @@
<el-form-item v-if="configDir" :label="$t('website.runDir')"> <el-form-item v-if="configDir" :label="$t('website.runDir')">
<el-space wrap> <el-space wrap>
<el-select v-model="update.siteDir"> <el-select v-model="update.siteDir">
<el-option :label="'/'" :value="'/'"></el-option>
<el-option <el-option
v-for="(item, index) in dirs" v-for="(item, index) in dirs"
:label="item" :label="item"
@ -56,6 +55,11 @@
<span class="warnHelper">{{ $t('website.runUserHelper') }}</span> <span class="warnHelper">{{ $t('website.runUserHelper') }}</span>
</template> </template>
</el-alert> </el-alert>
<el-alert :closable="false" type="error" v-if="dirConfig.msg != ''">
<template #default>
<span class="warnHelper">{{ dirConfig.msg }}</span>
</template>
</el-alert>
<br /> <br />
<el-descriptions :title="$t('website.folderTitle')" :column="1" border> <el-descriptions :title="$t('website.folderTitle')" :column="1" border>
<el-descriptions-item label="waf">{{ $t('website.wafFolder') }}</el-descriptions-item> <el-descriptions-item label="waf">{{ $t('website.wafFolder') }}</el-descriptions-item>
@ -67,8 +71,8 @@
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { GetFilesList } from '@/api/modules/files'; import { Website } from '@/api/interface/website';
import { GetWebsite, UpdateWebsiteDir, UpdateWebsiteDirPermission } from '@/api/modules/website'; import { GetDirConfig, GetWebsite, UpdateWebsiteDir, UpdateWebsiteDirPermission } from '@/api/modules/website';
import i18n from '@/lang'; import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message'; import { MsgSuccess } from '@/utils/message';
import { FormInstance } from 'element-plus'; import { FormInstance } from 'element-plus';
@ -98,17 +102,13 @@ const updatePermission = reactive({
group: '1000', group: '1000',
}); });
const siteForm = ref<FormInstance>(); const siteForm = ref<FormInstance>();
const dirReq = reactive({
path: '/',
expand: true,
showHidden: false,
page: 1,
pageSize: 100,
search: '',
containSub: false,
dir: true,
});
const dirs = ref([]); const dirs = ref([]);
const dirConfig = ref<Website.DirConfig>({
dirs: [''],
user: '',
userGroup: '',
msg: '',
});
const search = () => { const search = () => {
loading.value = true; loading.value = true;
@ -116,14 +116,15 @@ const search = () => {
.then((res) => { .then((res) => {
website.value = res.data; website.value = res.data;
update.id = website.value.id; update.id = website.value.id;
update.siteDir = website.value.siteDir; update.siteDir = website.value.siteDir.startsWith('/')
? website.value.siteDir
: '/' + website.value.siteDir;
updatePermission.id = website.value.id; updatePermission.id = website.value.id;
updatePermission.group = website.value.group === '' ? '1000' : website.value.group; updatePermission.group = website.value.group === '' ? '1000' : website.value.group;
updatePermission.user = website.value.user === '' ? '1000' : website.value.user; updatePermission.user = website.value.user === '' ? '1000' : website.value.user;
if (website.value.type === 'static' || website.value.runtimeID > 0) { if (website.value.type === 'static' || website.value.runtimeID > 0) {
configDir.value = true; configDir.value = true;
dirReq.path = website.value.sitePath + '/index'; getDirConfig();
getDirs();
} }
}) })
.finally(() => { .finally(() => {
@ -164,25 +165,19 @@ const submitPermission = async () => {
}); });
}; };
const getDirs = async () => {
loading.value = true;
await GetFilesList(dirReq)
.then((res) => {
dirs.value = [];
const items = res.data.items || [];
for (const item of items) {
dirs.value.push(item.name);
}
})
.finally(() => {
loading.value = false;
});
};
const initData = () => { const initData = () => {
dirs.value = []; dirs.value = [];
}; };
const getDirConfig = async () => {
try {
const res = await GetDirConfig({ id: props.id });
dirs.value = res.data.dirs;
dirConfig.value = res.data;
console.log(res);
} catch (error) {}
};
const toFolder = (folder: string) => { const toFolder = (folder: string) => {
router.push({ path: '/hosts/files', query: { path: folder } }); router.push({ path: '/hosts/files', query: { path: folder } });
}; };