diff --git a/cmd/picgo.go b/cmd/picgo.go index d71bc5b..0ada9d8 100644 --- a/cmd/picgo.go +++ b/cmd/picgo.go @@ -44,8 +44,8 @@ var picgoCmd = &cobra.Command{ // 创建路由 router.InitRouter() // 启动HTTP服务器 - log.Println("Serving on http://localhost:8082") - err := http.ListenAndServe(":8082", nil) + corelib.Logger.Infoln("Serving on http://", fmt.Sprintf("%s:%d", configs.Settings.Server.Host, configs.Settings.Server.Port)) + err := http.ListenAndServe(fmt.Sprintf("%s:%d", configs.Settings.Server.Host, configs.Settings.Server.Port), nil) if err != nil { log.Fatalf("Failed to start server: %v", err) } diff --git a/corelib/log.go b/corelib/log.go index 4dfdb5e..1a9fb8d 100644 --- a/corelib/log.go +++ b/corelib/log.go @@ -1,53 +1,103 @@ package corelib +//import ( +// "go.uber.org/zap" +// "go.uber.org/zap/zapcore" +//) + +//var Logger *zap.SugaredLogger +// +//func NewLogger() { +// writeSyncer := getLogWriter() +// encoder := getEncoder() +// core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel) +// logger := zap.New(core, zap.AddCaller()) +// Logger = logger.Sugar() +//} +// +//func getEncoder() zapcore.Encoder { +// encoderConfig := zap.NewProductionEncoderConfig() +// encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder +// encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder +// return zapcore.NewConsoleEncoder(encoderConfig) +//} +// +//func getLogWriter() zapcore.WriteSyncer { +// var ( +// err error +// currentDir string +// ) +// // 获取当前工作目录 +// if currentDir, err = os.Getwd(); err != nil { +// log.Panic("获取当前工作目录失败: ", err) +// } +// logPath := path.Join(currentDir, configs.Settings.Server.LogPath) +// // Filename: 日志文件的位置 +// // MaxSize:在进行切割之前,日志文件的最大大小(以 MB 为单位) +// // MaxBackups:保留旧文件的最大个数 +// // MaxAges:保留旧文件的最大天数 +// // Compress:是否压缩 / 归档旧文件 +// lumberJackLogger := &lumberjack.Logger{ +// Filename: logPath, +// MaxSize: 50, +// MaxBackups: 10, +// MaxAge: 1, +// Compress: false, +// } +// return zapcore.AddSync(lumberJackLogger) +//} + import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" - "gopkg.in/natefinch/lumberjack.v2" - "log" - "os" - "path" - "picgo/configs" ) var Logger *zap.SugaredLogger func NewLogger() { - writeSyncer := getLogWriter() - encoder := getEncoder() - core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel) - logger := zap.New(core, zap.AddCaller()) + config := zap.NewProductionConfig() + config.OutputPaths = []string{ + "stdout", + "tmp/picgo-info.log", + } + config.ErrorOutputPaths = []string{ + "stderr", + "tmp/picgo-error.log", + } + config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + + logger, err := config.Build() + if err != nil { + panic(err) + } + Logger = logger.Sugar() + } -func getEncoder() zapcore.Encoder { - encoderConfig := zap.NewProductionEncoderConfig() - encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder - encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder - return zapcore.NewConsoleEncoder(encoderConfig) +func Panic(args ...interface{}) { + Logger.Panic(args...) +} +func Debug(args ...interface{}) { + Logger.Debug(args...) } -func getLogWriter() zapcore.WriteSyncer { - var ( - err error - currentDir string - ) - // 获取当前工作目录 - if currentDir, err = os.Getwd(); err != nil { - log.Panic("获取当前工作目录失败: ", err) - } - logPath := path.Join(currentDir, configs.Settings.Server.LogPath) - // Filename: 日志文件的位置 - // MaxSize:在进行切割之前,日志文件的最大大小(以 MB 为单位) - // MaxBackups:保留旧文件的最大个数 - // MaxAges:保留旧文件的最大天数 - // Compress:是否压缩 / 归档旧文件 - lumberJackLogger := &lumberjack.Logger{ - Filename: logPath, - MaxSize: 50, - MaxBackups: 10, - MaxAge: 1, - Compress: false, - } - return zapcore.AddSync(lumberJackLogger) +func Info(args ...interface{}) { + Logger.Info(args...) +} + +func Warn(args ...interface{}) { + Logger.Warn(args...) +} + +func Error(args ...interface{}) { + Logger.Error(args...) +} + +func Fatal(args ...interface{}) { + Logger.Fatal(args...) +} + +func Sync() { + Logger.Sync() } diff --git a/corelib/msyql.go b/corelib/msyql.go index 4961de7..12e8147 100644 --- a/corelib/msyql.go +++ b/corelib/msyql.go @@ -1,15 +1,65 @@ package corelib import ( + "context" "database/sql" + "go.uber.org/zap" "gorm.io/driver/mysql" "gorm.io/gorm" - "gorm.io/gorm/logger" + gormLogger "gorm.io/gorm/logger" "gorm.io/gorm/schema" "picgo/configs" "time" ) +type GormZapLogger struct { + logger *zap.SugaredLogger + gormLogger.Config +} + +func (l *GormZapLogger) LogMode(level gormLogger.LogLevel) gormLogger.Interface { + newLogger := *l + newLogger.LogLevel = level + return &newLogger +} + +func (l *GormZapLogger) Info(ctx context.Context, msg string, data ...interface{}) { + if l.LogLevel >= gormLogger.Info { + l.logger.Infof(msg, data...) + } +} + +func (l *GormZapLogger) Warn(ctx context.Context, msg string, data ...interface{}) { + if l.LogLevel >= gormLogger.Warn { + l.logger.Warnf(msg, data...) + } +} + +func (l *GormZapLogger) Error(ctx context.Context, msg string, data ...interface{}) { + if l.LogLevel >= gormLogger.Error { + l.logger.Errorf(msg, data...) + } +} + +func (l *GormZapLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + if l.LogLevel <= 0 { + return + } + + elapsed := time.Since(begin) + switch { + case err != nil && l.LogLevel >= gormLogger.Error: + sql, rows := fc() + l.logger.Errorf("%s [%.2fms] [rows:%v] %s", err, float64(elapsed.Nanoseconds())/1e6, rows, sql) + case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= gormLogger.Warn: + sql, rows := fc() + l.logger.Warnf("SLOW SQL >= %v [%.2fms] [rows:%v] %s", l.SlowThreshold, float64(elapsed.Nanoseconds())/1e6, rows, sql) + case l.LogLevel >= gormLogger.Info: + sql, rows := fc() + l.logger.Infof("[%.2fms] [rows:%v] %s", float64(elapsed.Nanoseconds())/1e6, rows, sql) + } +} + var ( DbMysql *gorm.DB ) @@ -22,11 +72,23 @@ func NewMysql() { sqlDB *sql.DB dsn string ) + // 使用 zapgorm2 创建 GORM logger + dsn = configs.Settings.Mysql.MysqlDNS + zapLogger := Logger + newLogger := &GormZapLogger{ + logger: zapLogger, + Config: gormLogger.Config{ + SlowThreshold: time.Second, + LogLevel: gormLogger.Info, + Colorful: false, + }, + } if db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ // 配置日志级别,打印出所有的sql - Logger: logger.Default.LogMode(logger.Info), + //Logger: logger.Default.LogMode(logger.Info), + Logger: newLogger, NamingStrategy: schema.NamingStrategy{ //TablePrefix: configs.Setting.TablePrefix, // 表前缀 SingularTable: true, // 设置全局表名禁用复数 diff --git a/deploy/picgo-dev.yml b/deploy/picgo-dev.yml index 4152c34..4f8dce6 100644 --- a/deploy/picgo-dev.yml +++ b/deploy/picgo-dev.yml @@ -3,7 +3,7 @@ #------------------------ # 服务 server: - port: 8181 + port: 8282 host: localhost node_id: 1 log_path: tmp/picgo.log diff --git a/handler/index.go b/handler/index.go index 029daee..db71a97 100644 --- a/handler/index.go +++ b/handler/index.go @@ -1,19 +1,28 @@ package handler import ( + "github.com/gorilla/csrf" "net/http" "picgo/corelib" ) func IndexHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { - data := struct { - Title string - Active string - }{ - Title: "Dashboard", - Active: r.URL.Path, + tmpData := map[string]interface{}{ + csrf.TemplateTag: csrf.TemplateField(r), } - corelib.TemplateHandler(w, data, "view/layout.html", "view/index.html") + tmpData["Title"] = "Dashboard" + tmpData["Active"] = r.URL.Path + //data := struct { + // Title string + // Active string + // //csrfField string + //}{ + // Title: "Dashboard", + // Active: r.URL.Path, + // //csrfField: string(csrf.TemplateField(r)), + //} + w.Header().Add("X-CSRF-Token", csrf.Token(r)) + corelib.TemplateHandler(w, tmpData, "view/layout.html", "view/index.html") } } diff --git a/handler/login.go b/handler/login.go index 15e917c..b30b20d 100644 --- a/handler/login.go +++ b/handler/login.go @@ -14,23 +14,10 @@ import ( func LoginHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - data := map[string]interface{}{ + tmpData := map[string]interface{}{ csrf.TemplateTag: csrf.TemplateField(r), } - //data := map[string]interface{}{csrf.TemplateTag: csrf.TemplateField(r)} - corelib.Logger.Info("data: ", data, data["csrfToken"]) - corelib.TemplateHandler(w, data, "view/login.html") - //tmpl, err := template.ParseFiles("view/login.html") - //if err != nil { - // http.Error(w, "Internal Server Error", http.StatusInternalServerError) - // return - //} - //if err = tmpl.Execute(w, map[string]interface{}{ - // csrf.TemplateTag: csrf.TemplateField(r), - //}); err != nil { - // http.Error(w, "Internal Server Error", http.StatusInternalServerError) - // return - //} + corelib.TemplateHandler(w, tmpData, "view/login.html") case http.MethodPost: loginService(w, r) default: diff --git a/static/css/custom.css b/static/css/custom.css deleted file mode 100644 index d0d6981..0000000 --- a/static/css/custom.css +++ /dev/null @@ -1,19 +0,0 @@ -.navbar-nav .nav-link { - font-size: 1.1em; - padding: 10px 20px; -} - -.sidebar .nav-link { - font-size: 1.1em; - padding: 10px 15px; -} - -.sidebar .nav-link:hover { - background-color: #f8f9fa; - color: #495057; -} - -.sidebar .nav-link.active { - background-color: #e9ecef; - color: #007bff; -} diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000..e69de29 diff --git a/static/js/common.js b/static/js/common.js index e2c546d..f08d23c 100644 --- a/static/js/common.js +++ b/static/js/common.js @@ -52,3 +52,4 @@ Common.prototype.AlertSuccessToast = function (msg) { var Alert = new Common() + diff --git a/static/js/index.js b/static/js/index.js index 3f9c6ca..7483a7f 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,338 +1,102 @@ -function Domain() { - this.tableData = null - this.model = false - this.editIndex = -1 - // this.url = "http://127.0.0.1:8300/domain" - this.url = "https://monitor.api.ggbba.top/domain" - this.currentPage = 1 - this.pageData = null - this.searchText = "" - this.numPages = 0 -} +function Index() { -// 搜索域名 -Domain.prototype.searchDomain = function () { - let self = this - $('#search-btn').click(function () { - let search = $('#search-input').val() - self.searchText = search - if (self.searchText) { - self.currentPage = 1 - self.initTableData(self.currentPage, self.searchText) - } else { - self.initTableData(1, "") +} +//阻止浏览器默认行。 +Index.prototype.HoldbackBorwser = function () { + $(document).on({ + dragleave: function (e) { //拖离 + e.preventDefault(); + }, + drop: function (e) { //拖后放 + e.preventDefault(); + }, + dragenter: function (e) { //拖进 + e.preventDefault(); + }, + dragover: function (e) { //拖来拖去 + e.preventDefault(); } - }) + }); } -// 点击分页按钮 -Domain.prototype.domainPageBtn = function () { +//监听拖拽事件 +Index.prototype.MonitorDrop = function () { let self = this - $('.page-btn').click(function () { - self.currentPage = parseInt($(this).attr('data-p')) - self.initTableData(self.currentPage, self.searchText) - }) -} - -// 分页初始化 -Domain.prototype.domainPageInit = function () { - let self = this - // 是否是最后一页 - let is_finally = false - // 是否是第一页 - let is_first = false - // 总页数 - let num_pages = self.pageData['num_pages'] - - if (self.currentPage === 1) { - is_first = true - } - if (self.currentPage === num_pages) { - is_finally = true - } - self.pageData['is_first'] = is_first - self.pageData['is_finally'] = is_finally -} - -// 表单验证 -Domain.prototype.verifyDomain = function (domain) { - let patt = /(https?|ftp|file):\/\/[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]/i - return patt.test(domain) -} - -// 错误弹窗 -Domain.prototype.alertError = function (msg) { - swal('提示', msg, 'error'); -} - -// 弹窗 -Domain.prototype.alertToast = function (msg, type) { - swal({ - 'title': msg, - 'text': '', - 'type': type, - 'showCancelButton': false, - 'showConfirmButton': false, - 'timer': 1000 - }) -} - -// 带确认按钮的弹窗 -Domain.prototype.alertConfirm = function (params) { - swal({ - 'title': params['title'] ? params['title'] : '提示', - 'showCancelButton': true, - 'showConfirmButton': true, - 'type': params['type'] ? params['type'] : '', - 'confirmButtonText': params['confirmText'] ? params['confirmText'] : '确定', - 'cancelButtonText': params['cancelText'] ? params['cancelText'] : '取消', - 'text': params['text'] ? params['text'] : '' - }, function (isConfirm) { - if (isConfirm) { - if (params['confirmCallback']) { - params['confirmCallback']() - } - } else { - if (params['cancelCallback']) { - params['cancelCallback']() + let box = document.getElementById('upload-btn-click'); //拖拽区域 + box.addEventListener("drop", function (e) { + e.preventDefault(); //取消默认浏览器拖拽效果 + let fileList = e.dataTransfer.files; //获取文件对象 + //循环遍历文件对象 + for (let i = 0; i < fileList.length; i++) { + //检测文件是不是图片 + if (fileList[i].type.indexOf('image') === -1) { + Alert.AlertError("您拖的不是图片!") + return false; } + //拖拉图片到浏览器,可以实现预览功能 + // let img = window.URL.createObjectURL(fileList[i]); + // let filename = fileList[i].name; //图片名称 + // let filesize = Math.floor((fileList[0].size) / 1024); + // if (filesize > 2048) { + // Alert.AlertError("上传大小不能超过2M.") + // return false; + // } + //上传 + self.UploadFile(fileList[i]); } - }) + }, false); } -// 成功弹窗 -Domain.prototype.alertSuccessToast = function (msg) { - if (!msg) { - msg = '成功!' - } - this.alertToast(msg, 'success') -} - -// 渲染模板 -Domain.prototype.renderPage = function () { - let self = this; - let html = template('tpl-table-tr', {domainList: self.tableData}) - $('#tbody-domin').empty().append(html) - html = template('tpl-page-li', {page_data: self.pageData}) - $('#page-li').empty().append(html) - self.editDomain() - self.deleteDomain() - self.domainPageBtn() -} - -// 初始化数据 -Domain.prototype.initTableData = function (num, query) { - let self = this; - let requestUrl = self.url + '/page/' + num - if (query) { - requestUrl += "?search=" + query - } - $.get(requestUrl, function (data, status) { - if (data['code'] === 200) { - self.currentPage = num - self.tableData = data['data']['domain_data'] - self.pageData = data['data']['pagination_data'] - self.numPages = self.pageData['num_pages'] - self.domainPageInit() - self.renderPage() - } - }) -} - -// 添加域名 -Domain.prototype.addDomain = function () { - let self = this; - $("#add-domain").click(function () { - self.initAddForm() - }) -} - -// 初始化添加域名表单弹窗 -Domain.prototype.initAddForm = function () { - this.model = false - $('#DomainModalLabel').text("添加域名") - $('#cdn-f').prop('checked', true) - $('#boce-f').prop('checked', true) - $('#form-key').val("") - $('#form-domain').val("") - $('#form-remark').val("") -} - -// 提交添加域名表单 -Domain.prototype.addSubmit = function () { - let self = this - - let boceId = $("input[name='options-boce']:checked").attr("id") - let boce = false - if (boceId === "boce-t") { - boce = true - } - - let cdnId = $("input[name='options-cdn']:checked").attr("id") - let cdn = false - if (cdnId === "cdn-t") { - cdn = true - } - - let domain = $('#form-domain').val() - let autoKey = $('#form-key').val() - let remark = $('#form-remark').val() - let requestData = {'name': domain,'auth_key': auto_Key, 'remark': remark, 'boce': boce, 'category': cdn} - - $("#DomainModal").modal("hide") - if (!self.verifyDomain(domain)) { - self.alertError("域名格式不正确") - return +//上传图片 +Index.prototype.UploadFile = function (file) { + let filesize = Math.floor((file.size) / 1024); + if (filesize > 2048) { + Alert.AlertError("上传大小不能超过2M.") + return false; } + let formData = new FormData(); + formData.append('file', file); + let csrfToken = document.getElementsByName("gorilla.csrf.Token")[0].value + //开始上传图片 $.ajax({ - type: "post", - url: self.url, - data: JSON.stringify(requestData), - contentType: "application/json; charset=utf-8", - dataType: "json", + url: '/upload', + type: 'POST', + data: formData, + contentType: false, + processData: false, + headers: { + "X-CSRF-Token": csrfToken + }, success: function (result) { - self.alertSuccessToast("添加成功!") - self.initTableData(self.numPages, '') - } - }) -} - -// 编辑域名 -Domain.prototype.editDomain = function () { - let self = this; - $(".edit-domain").click(function () { - let index = $(this).attr('data-index') - self.initEditForm(index) - }) -} - -// 初始化编辑域名表单弹窗 -Domain.prototype.initEditForm = function (index) { - this.editIndex = index - this.model = true - $('#DomainModalLabel').text("编辑域名") - let data = this.tableData[index] - if (data['category']) { - $('#cdn-t').prop('checked', true) - } else { - $('#cdn-f').prop('checked', true) - } - if (data['boce']) { - $('#boce-t').prop('checked', true) - } else { - $('#boce-f').prop('checked', true) - } - $('#form-key').val(data['auth_key']) - $('#form-domain').val(data['name']) - $('#form-remark').val(data['remark']) -} - -// 编辑域名表单提交 -Domain.prototype.editSubmit = function () { - let self = this - if (self.model) { - let data = this.tableData[self.editIndex] - let id = data['id'] - - let boceId = $("input[name='options-boce']:checked").attr("id") - let boce = false - if (boceId === "boce-t") { - boce = true - } - - let cdnId = $("input[name='options-cdn']:checked").attr("id") - let cdn = false - if (cdnId === "cdn-t") { - cdn = true - } - - let domain = $('#form-domain').val() - let autoKey = $('#form-key').val() - let remark = $('#form-remark').val() - let requestData = {'id': id, 'name': domain, 'auth_key': autoKey, 'remark': remark, 'boce': boce, 'category': cdn} - - $("#DomainModal").modal("hide") - if (!self.verifyDomain(domain)) { - self.alertError("域名格式不正确") - return - } - $.ajax({ - type: "put", - url: self.url, - data: JSON.stringify(requestData), - contentType: "application/json; charset=utf-8", - dataType: "json", - success: function (result) { - self.alertSuccessToast("编辑成功!") - self.tableData[self.editIndex] = requestData - self.renderPage() - } - }) - } -} - -// 表单提交策略 -Domain.prototype.submitData = function () { - let self = this; - $('#submit-data').click(function () { - if (self.model) { - self.editSubmit() - } else { - self.addSubmit() - } - }) -} - -// 删除域名 -Domain.prototype.deleteDomain = function () { - let self = this; - $('.delete-domain').click(function () { - let index = $(this).attr('data-index') - let requestData = {'id': self.tableData[index]['id']} - self.alertConfirm({ - 'text': '您确定要删除' + self.tableData[index]['name'] + '吗?', - 'confirmCallback': function () { - $.ajax({ - type: "delete", - url: self.url, - data: JSON.stringify(requestData), - contentType: "application/json; charset=utf-8", - dataType: "json", - success: function (result) { - self.alertSuccessToast("删除成功!") - self.initTableData(self.currentPage, self.searchText) - } - }) - } - }) - }) -} - -// 入口方法 -Domain.prototype.run = function () { - let self = this - self.initTableData(self.currentPage, '') - self.addDomain() - self.editDomain() - self.submitData() - self.deleteDomain() - self.domainPageBtn() - self.searchDomain() -} - -// 构造执行入口 -$(function () { - // 模板过滤方法 - if (window.template) { - template.defaults.imports.domainSubstring = function (dateValue) { - if (dateValue.length > 40 ) { - return dateValue.substring(0, 37) + "..." + if (result.status === 200) { + Alert.AlertSuccessToast("上传成功!") + // alert("上传成功!文件名为:" + result.data); } else { - return dateValue + Alert.AlertError(result.message) + return false; } + }, + error: function () { + Alert.AlertError("服务器内部错误") } - } - let domain = new Domain() - domain.run() -}) + }) +} + + +Index.prototype.UploadClick = function () { + $("#upload-btn-click").click(function () { + $("#upload-input-file").click() + }) +} + +Index.prototype.run = function () { + this.HoldbackBorwser() + this.MonitorDrop() + this.UploadClick() +} + +$(function () { + let index = new Index() + index.run() +}) \ No newline at end of file diff --git a/static/js/login.js b/static/js/login.js index 0203559..09d2563 100644 --- a/static/js/login.js +++ b/static/js/login.js @@ -17,7 +17,8 @@ Login.prototype.RefreshCaptcha = function () { Login.prototype.LoginClick = function () { let self = this; let csrfToken = document.getElementsByName("gorilla.csrf.Token")[0].value - $("#login-btn").click(function () { + $("#login-btn").click(function (e) { + e.preventDefault(); // 获取参数 let username = $("#login-username").val() let password = $("#login-password").val() @@ -36,11 +37,11 @@ Login.prototype.LoginClick = function () { "X-CSRF-Token": csrfToken }, success: function (result) { - if (result["status"] === 200) { - Alert.AlertSuccessToast(result["message"]) + if (result.status === 200) { + Alert.AlertSuccessToast(result.message) window.location.href = "/" } else { - Alert.AlertError(result["message"]) + Alert.AlertError(result.message) } }, error: function (xhr, status, error) { diff --git a/view/index.html b/view/index.html index 30be434..8dc50e7 100644 --- a/view/index.html +++ b/view/index.html @@ -1,23 +1,49 @@ +{{define "style"}} + +{{end}} {{define "content"}} -
-
- -
- - -
-

- -

-

拖拽文件到此区域或 - 点此上传 -

-

支持上传照片、视频、ZIP、pdf等多种文件,最大支持100M,上传后支持复制url、base64、Markdown 照片三种方式

-
-
+
+
+

图片上传

+
+
+ +
+
+
+

+

拖拽文件到此区域或点此上传

+

+ 只支持上传图片文件,最大支持2M,上传后支持复制url

- +
+
+ +
+
+ {{end}} + +{{define "script"}} +{{/* + */}} + +{{end}} \ No newline at end of file diff --git a/view/layout.html b/view/layout.html index 9ab4a09..d247ba7 100644 --- a/view/layout.html +++ b/view/layout.html @@ -5,7 +5,14 @@ - + + + + + + + + {{template "style" .}} {{.Title}} @@ -37,14 +44,9 @@
+ {{ .csrfField }} {{template "content" .}}
- - - - - - - +{{template "script" .}}