From 66957ca86dc97e24068e6eb29a7954286a2f0a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Thu, 8 Dec 2022 00:10:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=89=B9=E6=80=A7=EF=BC=88=E7=BD=91=E7=AB=99?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=89=EF=BC=9A=E6=96=B0=E5=A2=9E=E5=85=8D?= =?UTF-8?q?=E8=B4=B9SSL=E8=AF=81=E4=B9=A6=E7=94=B3=E8=AF=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Api/SettingsController.php | 8 +- .../Controllers/Api/WebsitesController.php | 156 +++++++ composer.json | 3 +- composer.lock | 46 +- config/panel.php | 2 +- public/panel/modules/file.js | 401 ++++++++++++++++++ resources/views/setting.blade.php | 13 +- resources/views/website/edit.blade.php | 46 +- routes/api.php | 2 + 9 files changed, 668 insertions(+), 9 deletions(-) create mode 100644 public/panel/modules/file.js diff --git a/app/Http/Controllers/Api/SettingsController.php b/app/Http/Controllers/Api/SettingsController.php index fd10b6d8..168e6995 100644 --- a/app/Http/Controllers/Api/SettingsController.php +++ b/app/Http/Controllers/Api/SettingsController.php @@ -57,6 +57,7 @@ class SettingsController extends Controller $data = [ 'name' => $settingArr['name'], 'username' => $request->user()->username, + 'email' => $request->user()->email, 'password' => '', 'port' => $matches[1], 'api' => $api, @@ -80,19 +81,22 @@ class SettingsController extends Controller $settings = $request->all(); // 将数据入库 foreach ($settings as $key => $value) { - if ($key == 'access_token' || $key == 'username' || $key == 'password' || $key == 'api_token' || $key == 'api' || $key == 'port') { + if ($key == 'access_token' || $key == 'username' || $key == 'email' || $key == 'password' || $key == 'api_token' || $key == 'api' || $key == 'port') { continue; } // 创建或更新 Setting::query()->updateOrCreate(['name' => $key], ['value' => $value]); } - // 单独处理用户名和密码 + // 单独处理用户名、密码、邮箱 if ($request->input('username') != $request->user()->username) { $request->user()->update(['username' => $request->input('username')]); } if ($request->input('password') != '') { $request->user()->update(['password' => Hash::make($request->input('password'))]); } + if ($request->input('email') != $request->user()->email) { + $request->user()->update(['email' => $request->input('email')]); + } // 处理面板端口 $port = $request->input('port'); $nginxConf = file_get_contents('/www/server/nginx/conf/nginx.conf'); diff --git a/app/Http/Controllers/Api/WebsitesController.php b/app/Http/Controllers/Api/WebsitesController.php index 8d05dd55..58e585ee 100644 --- a/app/Http/Controllers/Api/WebsitesController.php +++ b/app/Http/Controllers/Api/WebsitesController.php @@ -9,9 +9,12 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Website; use App\Models\Setting; +use Exception; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; +use skoerfgen\ACMECert\ACME_Exception; +use skoerfgen\ACMECert\ACMECert; class WebsitesController extends Controller { @@ -372,6 +375,13 @@ EOF; $website['ssl_certificate_key'] = file_get_contents($matches5[1][0]); $website['http_redirect'] = str_contains($nginx_config, '# http重定向标记位'); $website['hsts'] = str_contains($nginx_config, '# hsts标记位'); + try { + $sslDate = (new ACMECert())->getRemainingDays($website['ssl_certificate']); + $sslDate = round($sslDate, 2); + } catch (Exception $e) { + $sslDate = '未知'; + } + $website['ssl_date'] = $sslDate; } else { $website['ssl_certificate'] = @file_get_contents('/www/server/vhost/ssl/'.$name.'.pem'); $website['ssl_certificate_key'] = @file_get_contents('/www/server/vhost/ssl/'.$name.'.key'); @@ -1027,6 +1037,152 @@ EOF; return response()->json($res); } + /** + * 签发SSL证书 + * @param Request $request + * @return JsonResponse + * @throws ACME_Exception|Exception + */ + public function issueSsl(Request $request): JsonResponse + { + try { + $input = $this->validate($request, [ + 'type' => 'required|in:lets,buypass,google,sslcom,zerossl', + 'name' => 'required', + ]); + } catch (ValidationException $e) { + return response()->json([ + 'code' => 1, + 'msg' => '参数错误:'.$e->getMessage(), + 'errors' => $e->errors() + ], 200); + } + + $user = $request->user(); + + // 检查网站是否存在 + $website = Website::query()->where('name', $input['name'])->first(); + if (!$website) { + return response()->json([ + 'code' => 1, + 'msg' => '网站不存在', + ], 200); + } + // 从配置文件中获取网站域名 + $nginxConfig = file_get_contents('/www/server/vhost/'.$website['name'].'.conf'); + $domainConfig = $this->cut('# server_name标记位开始', '# server_name标记位结束', $nginxConfig); + preg_match_all('/server_name\s+(.+);/', $domainConfig, $matches1); + $domains = explode(" ", $matches1[1][0]); + // 从配置文件中获取网站目录 + $pathConfig = $this->cut('# root标记位开始', '# root标记位结束', $nginxConfig); + preg_match_all('/root\s+(.+);/', $pathConfig, $matches2); + $path = $matches2[1][0]; + + /** + * 对域名需要进行一下处理,如果域名是泛域名,返回暂不支持泛域名 + */ + foreach ($domains as $domain) { + if (str_contains($domain, '*')) { + return response()->json([ + 'code' => 1, + 'msg' => '暂不支持泛域名', + ], 200); + } + } + + switch ($input['type']) { + case 'lets': + $ac = new ACMECert('https://acme-v02.api.letsencrypt.org/directory'); + break; + case 'buypass': + $ac = new ACMECert('https://api.buypass.com/acme/directory'); + break; + case 'google': + $ac = new ACMECert('https://dv.acme-v02.api.pki.goog/directory'); + break; + case 'sslcom': + $ac = new ACMECert('https://acme.ssl.com/sslcom-dv-rsa'); + break; + case 'zerossl': + $ac = new ACMECert('https://acme.zerossl.com/v2/DV90'); + break; + default: + $res = [ + 'code' => 1, + 'msg' => '参数错误:type', + ]; + return response()->json($res); + break; + } + + try { + $accountKey = $ac->generateECKey('P-384'); + $certKey = $ac->generateECKey('P-384'); + } catch (Exception $e) { + return response()->json([ + 'code' => 1, + 'msg' => '生成密钥失败:'.$e->getMessage(), + ], 200); + } + try { + $ac->loadAccountKey($accountKey); + } catch (Exception $e) { + return response()->json([ + 'code' => 1, + 'msg' => '加载密钥失败:'.$e->getMessage(), + ], 200); + } + try { + $ac->register(true, $user->email); + } catch (Exception $e) { + return response()->json([ + 'code' => 1, + 'msg' => '注册CA账户失败:'.$e->getMessage(), + ], 200); + } + + // 初始化域名数组 + $domainConfig = []; + foreach ($domains as $domain) { + $domainConfig[$domain] = [ + 'challenge' => 'http-01', + 'docroot' => $path + ]; + } + + $handler = function ($opts) { + $fn = $opts['config']['docroot'].$opts['key']; + @mkdir(dirname($fn), 0777, true); + file_put_contents($fn, $opts['value']); + return function ($opts) { + unlink($opts['config']['docroot'].$opts['key']); + }; + }; + + // 申请证书 + try { + $fullchain = $ac->getCertificateChain($certKey, $domainConfig, $handler); + } catch (ACME_Exception $e) { + return response()->json([ + 'code' => 1, + 'msg' => '申请证书失败:'.$e->getMessage(), + ], 200); + } + + // 写入证书 + $sslDir = '/www/server/vhost/ssl/'; + file_put_contents($sslDir.$website['name'].'.key', $certKey); + file_put_contents($sslDir.$website['name'].'.pem', $fullchain); + + // 返回 + $res = [ + 'code' => 0, + 'msg' => 'success', + ]; + return response()->json($res); + } + + /** * 裁剪字符串 * @param $begin diff --git a/composer.json b/composer.json index 00024ac0..2f6a5fef 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ "laravel/framework": "^9.19", "laravel/sanctum": "^3.0", "laravel/tinker": "^2.7", - "overtrue/laravel-lang": "^6.0" + "overtrue/laravel-lang": "^6.0", + "skoerfgen/acmecert": "^3.2" }, "require-dev": { "fakerphp/faker": "^1.9.1", diff --git a/composer.lock b/composer.lock index cdaf39d0..4b52f804 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "efb63260d6e5ee652c84bfee6f458ada", + "content-hash": "199471d49176a915109df8e3774d6e7e", "packages": [ { "name": "brick/math", @@ -2980,6 +2980,50 @@ ], "time": "2022-09-16T03:22:46+00:00" }, + { + "name": "skoerfgen/acmecert", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/skoerfgen/ACMECert.git", + "reference": "5cf724cebf4bcab13ec609308d6a0cbd136ccdfd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/skoerfgen/ACMECert/zipball/5cf724cebf4bcab13ec609308d6a0cbd136ccdfd", + "reference": "5cf724cebf4bcab13ec609308d6a0cbd136ccdfd", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=5.6.0" + }, + "suggest": { + "ext-curl": "Optional for better http performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "skoerfgen\\ACMECert\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stefan Körfgen", + "homepage": "https://github.com/skoerfgen" + } + ], + "description": "PHP client library for Let's Encrypt and other ACME v2 - RFC 8555 compatible Certificate Authorities", + "support": { + "issues": "https://github.com/skoerfgen/ACMECert/issues", + "source": "https://github.com/skoerfgen/ACMECert" + }, + "time": "2022-08-03T21:36:49+00:00" + }, { "name": "symfony/console", "version": "v6.1.7", diff --git a/config/panel.php b/config/panel.php index 27284c5f..18637972 100644 --- a/config/panel.php +++ b/config/panel.php @@ -1,6 +1,6 @@ '耗子Linux面板', - 'version' => '20221205', + 'version' => '20221208', 'plugin_dir' => '/www/panel/plugins', ]; \ No newline at end of file diff --git a/public/panel/modules/file.js b/public/panel/modules/file.js new file mode 100644 index 00000000..7fbb0de9 --- /dev/null +++ b/public/panel/modules/file.js @@ -0,0 +1,401 @@ +layui.define(['jquery', 'layer', 'laypage'], function(exports) { //提示:模块也可以依赖其它模块,如:layui.define('layer', callback); + var $ = layui.jquery, + layer = layui.layer, + laypage = layui.laypage; + //外部接口 + var fm = { + config:{'test':'test','thumb':{'nopic':'',width:100,height:100},icon_url:'ico/',btn_upload:true,btn_create:true} + ,cache: {} //数据缓存 + ,index: layui.fm ? (layui.fm.index + 10000) : 0 + //设置全局项 + ,set: function(options){ + var that = this; + that.config = $.extend({}, that.config, options); + return that; + } + //事件监听 + ,on: function(events, callback){ + return layui.onevent.call(this, 'file', events, callback); + } + ,dirRoot:[{'path':'','name': '根目录'}] + ,v:'1.0.1.2019.12.26' + } + //操作当前实例 + , thisFm = function() { + var that = this, + options = that.config, + id = options.id || options.index; + + // console.log(id) + if (id) { + thisFm.that[id] = that; //记录当前实例对象 + thisFm.config[id] = options; //记录当前实例配置项 + } + return { + config: options, + reload: function(options) { + that.reload.call(that, options); + } + } + } + //获取当前实例配置项 + ,getThisFmConfig = function(id){ + var config = thisFm.config[id]; + if(!config) hint.error('The ID option was not found in the fm instance'); + return config || null; + } + //构造器 + ,Class = function(options){ + var that = this; + that.config = $.extend({}, that.config, fm.config, options); + //记录所有实例 + thisFm.that = {}; //记录所有实例对象 + thisFm.config = {}; //记录所有实例配置项 + // console.log(that.config) + that.render(); + }; + //渲染 + Class.prototype.render = function(){ + var that = this + ,options = that.config; + + options.elem = $(options.elem); + options.where = options.where || {}; + options.id = options.id || options.elem.attr('id') || that.index; + + //请求参数的自定义格式 + options.request = $.extend({ + pageName: 'page' + ,limitName: 'limit' + }, options.request) + + //响应数据的自定义格式 + options.response = $.extend({ + statusName: 'code' + ,statusCode: 0 + ,msgName: 'msg' + ,dataName: 'data' + ,countName: 'count' + }, options.response); + + //如果 page 传入 laypage 对象 + if(typeof options.page === 'object'){ + options.limit = options.page.limit || options.limit; + options.limits = options.page.limits || options.limits; + that.page = options.page.curr = options.page.curr || 1; + delete options.page.elem; + delete options.page.jump; + } + + if(!options.elem[0]) return that; + //渲染主体 + var _btn = '' + if(options.btn_create){ + _btn +=''; + } + if(options.btn_upload){ + _btn +=''; + } + var _html = '
' + + '
' + + '
' + + _btn+ + '' + + '
' + + '
' + + '根目录' + + '
' + + '

' + + '
' + + '
    ' + + '
' + + '
' + + '
'; + + options.elem.html(_html); + + options.index = that.index; + that.key = options.id || options.index; + //各级容器 + that.layPage = options.elem.find('.layui_page_'+options.id); + that.layBody = options.elem.find('.fm_body'); + that.layPathBar = options.elem.find('.path_bar'); + that.layToolBar = options.elem.find('.tool_bar'); + that.pullData(that.page); //请求数据 + that.events(); //事件 + } + + //页码 + Class.prototype.page = 1; + + //获得数据 + Class.prototype.pullData = function(curr) { + var that = this, + options = that.config, + request = options.request, + response = options.response, + _status = false; + + that.startTime = new Date().getTime(); //渲染开始时间 + if (options.url) { //Ajax请求 + var params = {}; + params[request.pageName] = curr; + params[request.limitName] = options.limit; + + //参数 + var data = $.extend(params, options.where); + if (options.contentType && options.contentType.indexOf("application/json") == 0) { //提交 json 格式 + data = JSON.stringify(data); + } + + that.loading(); + + $.ajax({ + type: options.method || 'get', + url: options.url, + contentType: options.contentType, + data: data, + async: false, + dataType: 'json', + headers: options.headers || {}, + success: function(res) { + //如果有数据解析的回调,则获得其返回的数据 + if (typeof options.parseData === 'function') { + res = options.parseData(res) || res; + } + //检查数据格式是否符合规范 + if (res[response.statusName] != response.statusCode) { + + that.errorView( + res[response.msgName] || + ('返回的数据不符合规范,正确的成功状态码应为:"' + response.statusName + '": ' + response.statusCode) + ); + } else { + // console.log(res, curr, res[response.countName]); + that.renderData(res, curr, res[response.countName]); + + options.time = (new Date().getTime() - that.startTime) + ' ms'; //耗时(接口请求+视图渲染) + } + typeof options.done === 'function' && options.done(res, curr, res[response.countName]); + _status = true; + }, + error: function(e, m) { + that.errorView('数据接口请求异常:' + m); + + } + }); + } + return _status; + }; + //数据渲染 + Class.prototype.renderData = function(res, curr, count){ + var that = this + ,options = that.config + ,data = res[options.response.dataName] || [] + + //渲染数据 + var _content = '' + layui.each(data,function(i,v){ + let _img,_type; + _type = v.type; + switch (v.type) { + case 'directory': + _img = '
'; + _type = 'DIR'; + break; + default: + + if (v.type == 'png' || v.type == 'gif' || v.type == 'jpg' || v.type == 'image') { + _img = ''; + } else { + _img = '
'; + } + break; + } + _content+='
  • ' + + '
    '+ + _img + + '

    ' + v.name + '

    ' + + '
    ' + + '
  • '; + }); + options.elem.find('.file').html(_content); + fm.cache[options.id] = data; //记录数据 + //显示隐藏分页栏 + // console.log(that.layPage) + that.layPage[(count == 0 || (data.length === 0 && curr == 1)) ? 'addClass' : 'removeClass']('layui-hide'); + if(data.length === 0){ + return that.errorView('空目录'); + } else { + //that.layFixed.removeClass('layui-hide'); + } + //同步分页状态 + if(options.page){ + // console.log(options,'layui_page_' + options.id) + options.page = $.extend({ + elem: 'layui_page_' + options.id + ,count: count + ,limit: options.limit + ,limits: options.limits || [10,20,30,40,50,60,70,80,90] + ,groups: 3 + ,layout: ['prev', 'page', 'next', 'skip', 'count', 'limit'] + ,prev: '' + ,next: '' + ,jump: function(obj, first){ + if(!first){ + //分页本身并非需要做以下更新,下面参数的同步,主要是因为其它处理统一用到了它们 + //而并非用的是 options.page 中的参数(以确保分页未开启的情况仍能正常使用) + that.page = obj.curr; //更新页码 + options.limit = obj.limit; //更新每页条数 + + that.pullData(obj.curr); + } + } + }, options.page); + options.page.count = count; //更新总条数 + laypage.render(options.page); + } + }; + //更新路径工具条 + Class.prototype.updatePathBar = function(){ + // console.log('updatePathBar',fm.dirRoot); + var that = this + ,options = that.config; + //请求数据 + let dir_cur = fm.dirRoot[fm.dirRoot.length -1]; + options.where = {'path':dir_cur['path']} + let _rs = that.pullData(1); + // console.log(_rs) + if(false == _rs) return; + that.layPathBar.html(''); + + fm.dirRoot.map(function(item,index,arr){ + let icon = index==0 ?'layui-icon-more-vertical':'layui-icon-right'; + let html = ''+ + '' + item.name + '' + that.layPathBar.append(html); + }) + + + } + //事件处理 + Class.prototype.events = function(){ + var that = this + ,options = that.config + ,_BODY = $('body') + ,dict = {} + ,filter = options.elem.attr('lay-filter'); + //文件事件 + that.layBody.on('click', 'li', function(){ //单击行 + setPicEvent.call(this, 'pic'); + }); + //文件夹事件 + that.layBody.on('click', 'li[data-type=DIR]', function(){ //单击行 + var othis = $(this); + var data = fm.cache[options.id]; + var index = othis.data('index'); + data = data[index] || {}; + + //导航图标 + fm.dirRoot.push({'path':data.path,'name': data.name}); + that.updatePathBar(); + }); + //返回上一级目录 + that.layToolBar.on('click', '#back', function(){ + var othis = $(this); + if(fm.dirRoot.length == 1) return layer.msg('已经是根目录'); + + fm.dirRoot.length >1 && fm.dirRoot.pop() + that.updatePathBar(); + + // console.log('back'); + }); + //上传文件 + that.layToolBar.on('click', '#uploadfile', function(){ + var othis = $(this); + let eventType = 'uploadfile'; + layui.event.call(this, + 'file', eventType + '('+ filter +')' + ,{obj:othis,path:fm.dirRoot[fm.dirRoot.length -1]['path']} + ); + // console.log('uploadfile'); + }); + //新建文件夹 + that.layToolBar.on('click', '#new_dir', function(){ + var othis = $(this); + let eventType = 'new_dir'; + layer.prompt({ title: '请输入新文件夹名字', formType: 0 }, function(name, index) { + layer.close(index); + //新建文件夹 + layui.event.call(this, + 'file', eventType + '('+ filter +')' + ,{obj:othis,folder:name,path:fm.dirRoot[fm.dirRoot.length -1]['path']} + ); + }); + }); + //创建点击文件事件监听 + var setPicEvent = function(eventType) { + var othis = $(this); + var data = fm.cache[options.id]; + var index = othis.data('index'); + if (othis.data('type')=='DIR') return; //不触发事件 + data = data[index] || {}; + layui.event.call(this, + 'file', eventType + '('+ filter +')' + ,{obj:othis,data:data} + ); + }; + }; + + //请求loading + Class.prototype.loading = function(hide){ + var that = this + ,options = that.config; + if(options.loading){ + if(hide){ + that.layInit && that.layInit.remove(); + delete that.layInit; + that.layBox.find(ELEM_INIT).remove(); + } else { + that.layInit = $(['
    ' + ,'' + ,'
    '].join('')); + that.layBox.append(that.layInit); + } + } + }; + //异常提示 + Class.prototype.errorView = function(html){ + var that = this + layer.msg(html); + + }; + //重载 + Class.prototype.reload = function(options){ + var that = this; + + options = options || {}; + delete that.haveInit; + + if(options.data && options.data.constructor === Array) delete that.config.data; + that.config = $.extend(true, {}, that.config, options); + + that.render(); + }; + //重载 + fm.reload = function(id, options){ + var config = getThisFmConfig(id); //获取当前实例配置项 + if(!config) return; + + var that = thisFm.that[id]; + that.reload(options); + + return thisFm.call(that); + }; + //核心入口 + fm.render = function(options){ + var inst = new Class(options); + return thisFm.call(inst); + }; + exports('file', fm); +}); diff --git a/resources/views/setting.blade.php b/resources/views/setting.blade.php index 6d2a1dd5..6d9b5f82 100755 --- a/resources/views/setting.blade.php +++ b/resources/views/setting.blade.php @@ -1,7 +1,7 @@ 面板设置
    @@ -31,7 +31,9 @@ Date: 2022-12-01
    -
    开启后将允许多设备同时登录面板,可能具有一定安全隐患
    +
    + 开启后将允许多设备同时登录面板,可能具有一定安全隐患 +
    @@ -54,6 +56,13 @@ Date: 2022-12-01
    修改面板的登录密码(留空不修改)
    +
    + +
    + +
    +
    修改面板账号的邮箱,目前用于签发免费SSL证书
    +
    diff --git a/resources/views/website/edit.blade.php b/resources/views/website/edit.blade.php index f1bebd91..f1b7c0ed 100644 --- a/resources/views/website/edit.blade.php +++ b/resources/views/website/edit.blade.php @@ -1,7 +1,7 @@ \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index a2f2fbac..6cb6f210 100644 --- a/routes/api.php +++ b/routes/api.php @@ -84,6 +84,8 @@ Route::prefix('panel')->group(function () { Route::post('deleteBackup', [WebsitesController::class, 'deleteBackup']); // 重置网站配置 Route::post('resetSiteConfig', [WebsitesController::class, 'resetSiteConfig']); + // 签发SSL证书 + Route::post('issueSsl', [WebsitesController::class, 'issueSsl']); }); // 监控 Route::middleware('auth:sanctum')->prefix('monitor')->group(function () {