2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 23:27:17 +08:00

feat: s3fs 插件

This commit is contained in:
耗子
2023-07-25 00:16:00 +08:00
parent d9b4162e63
commit 44ae4b9d67
11 changed files with 478 additions and 44 deletions

View File

@@ -0,0 +1,222 @@
package s3fs
import (
"strings"
"github.com/bytedance/sonic"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support/carbon"
"github.com/spf13/cast"
"panel/app/http/controllers"
"panel/app/services"
"panel/pkg/tools"
)
type S3fsController struct {
setting services.Setting
}
type s3fs struct {
ID int64 `json:"id"`
Path string `json:"path"`
Bucket string `json:"bucket"`
Url string `json:"url"`
}
func NewS3fsController() *S3fsController {
return &S3fsController{
setting: services.NewSettingImpl(),
}
}
// List 所有 S3fs 挂载
func (c *S3fsController) List(ctx http.Context) {
if !controllers.Check(ctx, "s3fs") {
return
}
page := ctx.Request().QueryInt("page", 1)
limit := ctx.Request().QueryInt("limit", 10)
var s3fsList []s3fs
err := sonic.UnmarshalString(c.setting.Get("s3fs", "[]"), &s3fsList)
if err != nil {
controllers.Error(ctx, http.StatusBadRequest, "获取 S3fs 挂载失败")
return
}
startIndex := (page - 1) * limit
endIndex := page * limit
if startIndex > len(s3fsList) {
controllers.Success(ctx, http.Json{
"total": 0,
"items": []s3fs{},
})
return
}
if endIndex > len(s3fsList) {
endIndex = len(s3fsList)
}
pagedS3fsList := s3fsList[startIndex:endIndex]
controllers.Success(ctx, http.Json{
"total": len(s3fsList),
"items": pagedS3fsList,
})
}
// Add 添加 S3fs 挂载
func (c *S3fsController) Add(ctx http.Context) {
if !controllers.Check(ctx, "s3fs") {
return
}
validator, err := ctx.Request().Validate(map[string]string{
"ak": "required|regex:^[a-zA-Z0-9]*$",
"sk": "required|regex:^[a-zA-Z0-9]*$",
"bucket": "required|regex:^[a-zA-Z0-9_-]*$",
"url": "required|full_url",
"path": "required|regex:^/[a-zA-Z0-9_-]+$",
})
if err != nil {
controllers.Error(ctx, http.StatusUnprocessableEntity, err.Error())
return
}
if validator.Fails() {
controllers.Error(ctx, http.StatusUnprocessableEntity, validator.Errors().One())
return
}
ak := ctx.Request().Input("ak")
sk := ctx.Request().Input("sk")
path := ctx.Request().Input("path")
bucket := ctx.Request().Input("bucket")
url := ctx.Request().Input("url")
// 检查下地域节点中是否包含bucket如果包含了肯定是错误的
if strings.Contains(url, bucket) {
controllers.Error(ctx, http.StatusUnprocessableEntity, "地域节点不能包含 Bucket 名称")
return
}
// 检查挂载目录是否存在且为空
if !tools.Exists(path) {
tools.Mkdir(path, 0755)
}
if !tools.Empty(path) {
controllers.Error(ctx, http.StatusUnprocessableEntity, "挂载目录必须为空")
return
}
var s3fsList []s3fs
err = sonic.UnmarshalString(c.setting.Get("s3fs", "[]"), &s3fsList)
if err != nil {
controllers.Error(ctx, http.StatusInternalServerError, "获取 S3fs 挂载失败")
return
}
for _, s := range s3fsList {
if s.Path == path {
controllers.Error(ctx, http.StatusUnprocessableEntity, "路径已存在")
return
}
}
id := carbon.Now().TimestampMilli()
password := ak + ":" + sk
tools.WriteFile("/etc/passwd-s3fs-"+cast.ToString(id), password, 0600)
tools.ExecShell(`echo 's3fs#` + bucket + ` ` + path + ` fuse _netdev,allow_other,nonempty,url=` + url + `,passwd_file=/etc/passwd-s3fs-` + cast.ToString(id) + ` 0 0' >> /etc/fstab`)
check := tools.ExecShell("mount -a 2>&1")
if len(check) != 0 {
tools.ExecShell(`sed -i 's@^s3fs#` + bucket + `\s` + path + `.*$@@g' /etc/fstab`)
controllers.Error(ctx, http.StatusInternalServerError, "检测到/etc/fstab有误: "+check)
return
}
check2 := tools.ExecShell("df -h | grep " + path + " 2>&1")
if len(check2) == 0 {
tools.ExecShell(`sed -i 's@^s3fs#` + bucket + `\s` + path + `.*$@@g' /etc/fstab`)
controllers.Error(ctx, http.StatusInternalServerError, "挂载失败,请检查配置是否正确")
return
}
s3fsList = append(s3fsList, s3fs{
ID: id,
Path: path,
Bucket: bucket,
Url: url,
})
json, err := sonic.MarshalString(s3fsList)
if err != nil {
controllers.Error(ctx, http.StatusInternalServerError, "添加 S3fs 挂载失败")
return
}
err = c.setting.Set("s3fs", json)
if err != nil {
controllers.Error(ctx, http.StatusInternalServerError, "添加 S3fs 挂载失败")
return
}
controllers.Success(ctx, nil)
}
// Delete 删除 S3fs 挂载
func (c *S3fsController) Delete(ctx http.Context) {
if !controllers.Check(ctx, "s3fs") {
return
}
id := ctx.Request().Input("id")
if len(id) == 0 {
controllers.Error(ctx, http.StatusUnprocessableEntity, "挂载ID不能为空")
return
}
var s3fsList []s3fs
err := sonic.UnmarshalString(c.setting.Get("s3fs", "[]"), &s3fsList)
if err != nil {
controllers.Error(ctx, http.StatusInternalServerError, "获取 S3fs 挂载失败")
return
}
var mount s3fs
for _, s := range s3fsList {
if cast.ToString(s.ID) == id {
mount = s
break
}
}
if mount.ID == 0 {
controllers.Error(ctx, http.StatusUnprocessableEntity, "挂载ID不存在")
return
}
tools.ExecShell(`fusermount -u '` + mount.Path + `'`)
tools.ExecShell(`umount '` + mount.Path + `'`)
tools.ExecShell(`sed -i 's@^s3fs#` + mount.Bucket + `\s` + mount.Path + `.*$@@g' /etc/fstab`)
check := tools.ExecShell("mount -a 2>&1")
if len(check) != 0 {
controllers.Error(ctx, http.StatusInternalServerError, "检测到/etc/fstab有误: "+check)
return
}
tools.RemoveFile("/etc/passwd-s3fs-" + cast.ToString(mount.ID))
var newS3fsList []s3fs
for _, s := range s3fsList {
if s.ID != mount.ID {
newS3fsList = append(newS3fsList, s)
}
}
json, err := sonic.MarshalString(newS3fsList)
if err != nil {
controllers.Error(ctx, http.StatusInternalServerError, "删除 S3fs 挂载失败")
return
}
err = c.setting.Set("s3fs", json)
if err != nil {
controllers.Error(ctx, http.StatusInternalServerError, "删除 S3fs 挂载失败")
return
}
controllers.Success(ctx, nil)
}

14
app/plugins/s3fs/s3fs.go Normal file
View File

@@ -0,0 +1,14 @@
package s3fs
var (
Name = "S3fs"
Author = "耗子"
Description = "S3fs 通过 FUSE 挂载兼容 S3 标准的存储桶例如Amazon S3、阿里云OSS、腾讯云COS、七牛云Kodo等。"
Slug = "s3fs"
Version = "1.9"
Requires = []string{}
Excludes = []string{}
Install = `bash /www/panel/scripts/s3fs/install.sh`
Uninstall = `bash /www/panel/scripts/s3fs/uninstall.sh`
Update = `bash /www/panel/scripts/s3fs/install.sh`
)

View File

@@ -11,6 +11,7 @@ import (
"panel/app/plugins/php74"
"panel/app/plugins/php80"
"panel/app/plugins/phpmyadmin"
"panel/app/plugins/s3fs"
)
// PanelPlugin 插件元数据结构
@@ -127,6 +128,18 @@ func (r *PluginImpl) All() []PanelPlugin {
Uninstall: phpmyadmin.Uninstall,
Update: phpmyadmin.Update,
})
p = append(p, PanelPlugin{
Name: s3fs.Name,
Author: s3fs.Author,
Description: s3fs.Description,
Slug: s3fs.Slug,
Version: s3fs.Version,
Requires: s3fs.Requires,
Excludes: s3fs.Excludes,
Install: s3fs.Install,
Uninstall: s3fs.Uninstall,
Update: s3fs.Update,
})
return p
}

View File

@@ -5,36 +5,10 @@ import (
"crypto/rand"
"fmt"
"io"
"reflect"
"strings"
"unicode/utf8"
)
// Empty 类似于 PHP 的 empty() 函数
func Empty(val interface{}) bool {
if val == nil {
return true
}
v := reflect.ValueOf(val)
switch v.Kind() {
case reflect.String, reflect.Array:
return v.Len() == 0
case reflect.Map, reflect.Slice:
return v.Len() == 0 || v.IsNil()
case reflect.Bool:
return !v.Bool()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return v.Uint() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Interface, reflect.Ptr:
return v.IsNil()
}
return reflect.DeepEqual(val, reflect.Zero(v.Type()).Interface())
}
// FirstElement 安全地获取 args[0],避免 panic: runtime error: index out of range
func FirstElement(args []string) string {
if len(args) > 0 {

View File

@@ -14,23 +14,6 @@ func TestStringHelperTestSuite(t *testing.T) {
suite.Run(t, &StringHelperTestSuite{})
}
func (s *StringHelperTestSuite) TestEmpty() {
s.True(Empty(""))
s.True(Empty(nil))
s.True(Empty([]string{}))
s.True(Empty(map[string]string{}))
s.True(Empty(0))
s.True(Empty(0.0))
s.True(Empty(false))
s.False(Empty(" "))
s.False(Empty([]string{"Panel"}))
s.False(Empty(map[string]string{"Panel": "HaoZi"}))
s.False(Empty(1))
s.False(Empty(1.0))
s.False(Empty(true))
}
func (s *StringHelperTestSuite) TestFirstElement() {
s.Equal("HaoZi", FirstElement([]string{"HaoZi"}))
}

View File

@@ -114,3 +114,13 @@ func Exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Empty 判断路径是否为空
func Empty(path string) bool {
files, err := os.ReadDir(path)
if err != nil {
return true
}
return len(files) == 0
}

View File

@@ -99,3 +99,8 @@ func (s *SystemHelperTestSuite) TestExists() {
s.True(Exists("/tmp"))
s.False(Exists("/tmp/123"))
}
func (s *SystemHelperTestSuite) TestEmpty() {
s.True(Empty("/tmp/123"))
s.False(Empty("/tmp"))
}

View File

@@ -0,0 +1,102 @@
<!--
Name: S3fs
Author: 耗子
Date: 2023-07-25
-->
<title>S3fs</title>
<div class="layui-fluid" id="component-tabs">
<div class="layui-row">
<div class="layui-col-md12">
<div class="layui-card">
<div class="layui-card-header">S3fs 挂载列表</div>
<div class="layui-card-body">
<table class="layui-hide" id="s3fs-list" lay-filter="s3fs-list"></table>
<!-- 顶部工具栏 -->
<script type="text/html" id="s3fs-list-bar">
<div class="layui-btn-container">
<button class="layui-btn layui-btn-sm" lay-event="add_mount">新建挂载</button>
</div>
</script>
<!-- 右侧管理 -->
<script type="text/html" id="s3fs-list-control">
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="umount">卸载</a>
</script>
</div>
</div>
</div>
</div>
</div>
<script>
layui.use(['index', 'code', 'table'], function () {
let admin = layui.admin
, table = layui.table
, form = layui.form
, view = layui.view;
// 获取备份任务列表
table.render({
elem: '#s3fs-list'
, url: '/api/plugins/s3fs/list'
, toolbar: '#s3fs-list-bar'
, title: 'S3fs挂载列表'
, cols: [[
{field: 'id', hide: true, title: 'ID', sort: true}
, {field: 'bucket', title: 'Bucket', fixed: 'left', unresize: true, sort: true}
, {field: 'url', title: 'URL', sort: true}
, {field: 'path', title: '挂载目录', sort: true}
, {fixed: 'right', title: '操作', toolbar: '#s3fs-list-control', width: 150}
]]
, page: true
, text: {
none: '无挂载'
}
, parseData: function (res) {
return {
"code": res.code,
"msg": res.message,
"count": res.data.total,
"data": res.data.items
};
}
});
// 头工具栏事件
table.on('toolbar(s3fs-list)', function (obj) {
if (obj.event === 'add_mount') {
admin.popup({
title: '新建挂载'
, area: ['600px', '600px']
, id: 'LAY-popup-s3fs-mount-add'
, success: function (layer, index) {
view(this.id).render('plugins/s3fs/add_mount', {}).done(function () {
form.render(null, 'LAY-popup-s3fs-mount-add');
});
}
});
}
});
// 行工具事件
table.on('tool(s3fs-list)', function (obj) {
let data = obj.data;
if (obj.event === 'umount') {
layer.confirm('确定要卸载 <b style="color: red;">' + data.path + '</b> 吗?', function (index) {
index = layer.msg('请稍候...', {icon: 16, time: 0, shade: 0.3});
admin.req({
url: "/api/plugins/s3fs/delete"
, type: 'post'
, data: data
, success: function (result) {
layer.close(index);
if (result.code !== 0) {
return false;
}
obj.del();
layer.alert(data.path + ' 卸载成功!');
}
});
layer.close(index);
});
}
});
});
</script>

View File

@@ -0,0 +1,104 @@
<!--
Name: S3fs - 新增挂载
Author: 耗子
Date: 2023-07-25
-->
<script type="text/html" template lay-done="layui.data.sendParams(d.params)">
<form class="layui-form" action="" lay-filter="add-mount-s3fs-form">
<div class="layui-form-item">
<label class="layui-form-label">Bucket</label>
<div class="layui-input-block">
<input type="text" name="bucket" id="add-mount-s3fs-bucket"
lay-verify="required" placeholder="请输入Bucket名" class="layui-input"
value="">
</div>
<div class="layui-form-mid layui-word-aux">输入Bucket名字腾讯云COS为xxxx-用户ID</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">AK</label>
<div class="layui-input-block">
<input type="text" name="ak" id="add-mount-s3fs-ak"
lay-verify="required" placeholder="请输入AK密钥" class="layui-input"
value="">
</div>
<div class="layui-form-mid layui-word-aux">访问密钥中的Access Key需具备Bucket操作权限腾讯云为SecretId
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">SK</label>
<div class="layui-input-block">
<input type="text" name="sk" id="add-mount-s3fs-sk"
lay-verify="required" placeholder="请输入SK密钥" class="layui-input"
value="">
</div>
<div class="layui-form-mid layui-word-aux">访问密钥中的Access Key
Secret需具备Bucket操作权限腾讯云为SecretKey
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">地域节点</label>
<div class="layui-input-block">
<input type="text" name="url" id="add-mount-s3fs-url"
lay-verify="required" placeholder="请输入Bucket地域节点" class="layui-input"
value="">
</div>
<div class="layui-form-mid layui-word-aux">
地域节点可在<a target="_blank" href="https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3">https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3</a>查找
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">挂载目录</label>
<div class="layui-input-block">
<input type="text" name="path" id="add-mount-s3fs-path"
lay-verify="required" placeholder="请输入挂载目录" class="layui-input"
value="">
</div>
<div class="layui-form-mid layui-word-aux">挂载目录/data不存在将会自动创建</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<div class="layui-footer">
<button class="layui-btn" lay-submit="" lay-filter="add-mount-s3fs-submit">立即提交</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</div>
</form>
</script>
<script>
layui.data.sendParams = function (params) {
layui.use(['admin', 'form', 'jquery', 'cron'], function () {
var admin = layui.admin
, layer = layui.layer
, form = layui.form
, table = layui.table;
form.render();
form.on('submit(add-mount-s3fs-submit)', function (data) {
index = layer.msg('正在提交...', {icon: 16, time: 0, shade: 0.3});
admin.req({
url: "/api/plugins/s3fs/add"
, type: 'post'
, data: data.field
, success: function (result) {
layer.close(index);
if (result.code !== 0) {
return false;
}
table.reload('s3fs-list');
layer.alert('S3fs挂载已提交请自行检查是否挂载成功', {
icon: 1
, title: '提示'
, btn: ['确定']
, yes: function (index) {
layer.closeAll();
}
});
}
});
return false;
});
});
};
</script>

View File

@@ -10,6 +10,7 @@ import (
"panel/app/http/controllers/plugins/php74"
"panel/app/http/controllers/plugins/php80"
"panel/app/http/controllers/plugins/phpmyadmin"
"panel/app/http/controllers/plugins/s3fs"
"panel/app/http/middleware"
)
@@ -129,4 +130,10 @@ func Plugin() {
route.Get("info", phpMyAdminController.Info)
route.Post("port", phpMyAdminController.SetPort)
})
facades.Route().Prefix("api/plugins/s3fs").Middleware(middleware.Jwt()).Group(func(route route.Route) {
s3fsController := s3fs.NewS3fsController()
route.Get("list", s3fsController.List)
route.Post("add", s3fsController.Add)
route.Post("delete", s3fsController.Delete)
})
}

View File

@@ -30,4 +30,4 @@ else
exit 1
fi
panel writePlugin s3fs
panel writePlugin s3fs 1.9