From a6de8f0790a9bc8d27302b269f846d631ce52947 Mon Sep 17 00:00:00 2001 From: shylinux Date: Mon, 28 Jan 2019 10:27:45 +0800 Subject: [PATCH] add chat.chat&chat.mp --- etc/init.shy | 16 ++- src/contexts/aaa/aaa.go | 8 +- src/contexts/ctx/ctx_cgi.go | 14 ++- src/contexts/nfs/nfs.go | 4 - src/contexts/tcp/tcp.go | 1 - src/contexts/web/web.go | 61 ++++++----- src/examples/app/bench.go | 6 +- src/examples/chat/chat.go | 199 +++++++++++++++++++++++++++++++++--- src/examples/code/code.go | 1 + usr/librarys/context.js | 10 ++ usr/template/code/code.tmpl | 72 +++++++++++++ 11 files changed, 323 insertions(+), 69 deletions(-) diff --git a/etc/init.shy b/etc/init.shy index 0a2cc917..c017f21f 100644 --- a/etc/init.shy +++ b/etc/init.shy @@ -1,11 +1,23 @@ +source local.shy + ~ssh - remote listen :9090 + remote listen :9090 right sub ~aaa + role void componet source js_token + + role tech componet mp command share + role tech componet chat command share + role tech componet chat command nfs.pwd ls + role tech componet index command source role tech componet index command context + role tech componet remote command nfs.pwd ls + role tech componet source command nfs.pwd ls - user tech shy shy + user root shy shy + user tech sub sub ~web + config spide "" client "localhost:9094" serve diff --git a/src/contexts/aaa/aaa.go b/src/contexts/aaa/aaa.go index c7cbabe2..70bc4664 100644 --- a/src/contexts/aaa/aaa.go +++ b/src/contexts/aaa/aaa.go @@ -180,7 +180,7 @@ var Index = &ctx.Context{Name: "aaa", Help: "认证中心", s, t, a := "", "", "" if v := m.Confm("auth", arg[0]); v != nil { - s, t, arg = arg[0], v["type"].(string), arg[1:] + s, t, a, arg = arg[0], kit.Format(v["type"]), kit.Format(v["meta"]), arg[1:] } if len(arg) > 0 && arg[0] == "delete" { @@ -370,7 +370,7 @@ var Index = &ctx.Context{Name: "aaa", Help: "认证中心", h := m.Cmdx("aaa.hash", meta) if !m.Confs("auth", h) { - if m.Confs("auth_type", []string{arg[i], "single"}) && m.Cmds("aaa.auth", p, arg[i]) { + if m.Confs("auth_type", []string{arg[i], "single"}) && m.Confs("auth", p) && m.Cmds("aaa.auth", p, arg[i]) { m.Set("result") return // 单点认证失败 } @@ -385,7 +385,7 @@ var Index = &ctx.Context{Name: "aaa", Help: "认证中心", } if p != "" { // 创建父链接 chain = append(chain, map[string]string{"node": p, "ship": "1", "hash": h, "type": arg[i], "meta": meta[1]}) - chain = append(chain, map[string]string{"node": h, "ship": "0", "hash": p, "type": t, "meta": ""}) + chain = append(chain, map[string]string{"node": h, "ship": "0", "hash": p, "type": t, "meta": a}) } p, t, a = h, arg[i], meta[1] @@ -441,7 +441,7 @@ var Index = &ctx.Context{Name: "aaa", Help: "认证中心", } } - m.Log("info", "block: %v chain: %v", len(block), len(chain)) + m.Log("debug", "block: %v chain: %v", len(block), len(chain)) for _, b := range block { // 添加节点 m.Confv("auth", b["hash"], map[string]interface{}{"create_time": m.Time(), "type": b["type"], "meta": b["meta"]}) } diff --git a/src/contexts/ctx/ctx_cgi.go b/src/contexts/ctx/ctx_cgi.go index 8d6e77ef..fda1bab7 100644 --- a/src/contexts/ctx/ctx_cgi.go +++ b/src/contexts/ctx/ctx_cgi.go @@ -223,12 +223,12 @@ var CGI = template.FuncMap{ } return "" }, - "cmd": func(arg ...interface{}) string { - if len(arg) == 0 { - return "" + "cmd": func(m *Message, args ...interface{}) *Message { + if len(args) == 0 { + return m } - return strings.Join(Pulse.Sess("cli").Cmd(arg).Meta["result"], "") + return m.Sess("cli").Put("option", "bench", "").Cmd("source", args) }, "detail": func(arg ...interface{}) interface{} { @@ -482,12 +482,10 @@ var CGI = template.FuncMap{ } return nil }, - "parse": func(m *Message, arg ...string) interface{} { + "parse": func(m *Message, arg ...interface{}) interface{} { switch len(arg) { case 1: - if len(arg[0]) > 0 { - return m.Parse(arg[0]) - } + return m.Parse(kit.Format(arg[0])) } return nil }, diff --git a/src/contexts/nfs/nfs.go b/src/contexts/nfs/nfs.go index 97f2277b..232a7bbc 100644 --- a/src/contexts/nfs/nfs.go +++ b/src/contexts/nfs/nfs.go @@ -656,23 +656,19 @@ func (nfs *NFS) Term(msg *ctx.Message, action string, args ...interface{}) *NFS break } if n%bottom > 0 { - m.Log("fuck", "-----scroll %v %v %v %v", m.Conf("term", "begin_row"), m.Conf("term", "begin_col"), y, n) nfs.Term(m, "scroll", n%bottom+1) n -= n % bottom x = m.Confi("term", "cursor_x") y = m.Confi("term", "cursor_y") - m.Log("fuck", "-----scroll %v %v %v %v", m.Conf("term", "begin_row"), m.Conf("term", "begin_col"), y, n) } else if n > 0 { - m.Log("fuck", "-----scroll %v %v %v %v", m.Conf("term", "begin_row"), m.Conf("term", "begin_col"), y, n) nfs.Term(m, "scroll", bottom) n -= bottom x = m.Confi("term", "cursor_x") y = m.Confi("term", "cursor_y") - m.Log("fuck", "-----scroll %v %v %v %v", m.Conf("term", "begin_row"), m.Conf("term", "begin_col"), y, n) } } } diff --git a/src/contexts/tcp/tcp.go b/src/contexts/tcp/tcp.go index 83415382..6a43e531 100644 --- a/src/contexts/tcp/tcp.go +++ b/src/contexts/tcp/tcp.go @@ -129,7 +129,6 @@ func (tcp *TCP) Start(m *ctx.Message, arg ...string) bool { m.Cap("stream", fmt.Sprintf("%s", tcp.Addr()))) addr := strings.Split(tcp.Addr().String(), ":") - m.Log("fuck", "what %v", addr) m.Back(m.Spawn(m.Source()).Add("option", "hostport", fmt.Sprintf("%s:%s", m.Cmd("tcp.ifconfig", "eth0").Append("ip"), addr[len(addr)-1]))) } diff --git a/src/contexts/web/web.go b/src/contexts/web/web.go index 1d27d139..a2f1ff44 100644 --- a/src/contexts/web/web.go +++ b/src/contexts/web/web.go @@ -106,13 +106,11 @@ func (web *WEB) Login(msg *ctx.Message, w http.ResponseWriter, r *http.Request) } if msg.Options("ticket") { - msg.Log("fuck", "what %v", msg.Meta) msg.Option("uuid", msg.Option(msg.Conf("login", "cas_uuid"))) msg.Option("username", cas.Username(r)) if lark := msg.Find("web.chat.lark"); lark != nil { msg.Option("username", lark.Cmdx("user", msg.Option("email"), "id")) } - msg.Log("fuck", "what %v", msg.Meta) http.SetCookie(w, &http.Cookie{Name: "sessid", Value: msg.Cmdx("web.session", "login", "uuid"), Path: "/"}) http.Redirect(w, r, merge(msg, r.Header.Get("index_url"), "ticket", ""), http.StatusTemporaryRedirect) @@ -142,6 +140,7 @@ func (web *WEB) HandleCmd(m *ctx.Message, key string, cmd *ctx.Command) { msg.Option("accept", r.Header.Get("Accept")) msg.Option("method", r.Method) msg.Option("path", r.URL.Path) + msg.Optionv("debug", false) msg.Option("dir_root", msg.Cap("directory")) for _, v := range r.Cookies() { @@ -346,15 +345,15 @@ var Index = &ctx.Context{Name: "web", Help: "应用中心", }, }, Help: "爬虫配置"}, "serve": &ctx.Config{Name: "serve", Value: map[string]interface{}{ - "loggheaders": true, - "form_size": "102400", - "directory": "usr", - "protocol": "http", - "address": ":9094", - "cert": "etc/cert.pem", - "key": "etc/key.pem", - "site": "", - "index": "/code/", + "logheaders": false, + "form_size": "102400", + "directory": "usr", + "protocol": "http", + "address": ":9094", + "cert": "etc/cert.pem", + "key": "etc/key.pem", + "site": "", + "index": "/code/", }, Help: "服务配置"}, "login": &ctx.Config{Name: "login", Value: map[string]interface{}{ "check": true, @@ -512,8 +511,10 @@ var Index = &ctx.Context{Name: "web", Help: "应用中心", m.Log("info", "%s %s", req.Method, req.URL) m.Confm("spide", []string{which, "header"}, func(key string, value string) { - req.Header.Set(key, value) - m.Log("info", "header %v %v", key, value) + if key != "" { + req.Header.Set(key, value) + m.Log("info", "header %v %v", key, value) + } }) for i := 0; i < len(m.Meta["headers"]); i += 2 { req.Header.Set(m.Meta["headers"][i], m.Meta["headers"][i+1]) @@ -522,8 +523,10 @@ var Index = &ctx.Context{Name: "web", Help: "应用中心", req.Header.Set("Content-Type", m.Option("content_type")) } m.Confm("spide", []string{which, "cookie"}, func(key string, value string) { - req.AddCookie(&http.Cookie{Name: key, Value: value}) - m.Log("info", "set-cookie %s: %v", key, value) + if key != "" { + req.AddCookie(&http.Cookie{Name: key, Value: value}) + m.Log("info", "set-cookie %s: %v", key, value) + } }) if kit.Right(client["logheaders"]) { for k, v := range req.Header { @@ -537,7 +540,6 @@ var Index = &ctx.Context{Name: "web", Help: "应用中心", } res, e := web.Client.Do(req) - m.Log("info", "response %v %v", res.StatusCode, res.Status) if m.Assert(e); kit.Right(client["logheaders"]) { for k, v := range res.Header { m.Log("info", "%s: %v", k, v) @@ -829,6 +831,10 @@ var Index = &ctx.Context{Name: "web", Help: "应用中心", return }}, + "/MP_verify_0xp0zkW3fIzIq2Bo.txt": &ctx.Command{Name: "/proxy/which/method/url", Help: "服务代理", Hand: func(m *ctx.Message, c *ctx.Context, key string, arg ...string) (e error) { + m.Echo("0xp0zkW3fIzIq2Bo") + return + }}, "/proxy/": &ctx.Command{Name: "/proxy/which/method/url", Help: "服务代理", Hand: func(m *ctx.Message, c *ctx.Context, key string, arg ...string) (e error) { fields := strings.Split(key, "/") m.Cmdy("web.get", "which", fields[2], "method", fields[3], strings.Join(fields, "/")) @@ -888,14 +894,15 @@ var Index = &ctx.Context{Name: "web", Help: "应用中心", continue } - // 查找模块 - context := m.Cap("module") - if val["componet_ctx"] != nil { - context = val["componet_ctx"].(string) - } - msg := m.Find(context) + msg := m + if val["componet_cmd"] != nil { + // 查找模块 + context := m.Cap("module") + if val["componet_ctx"] != nil { + context = val["componet_ctx"].(string) + } + msg = m.Find(context) - if msg != nil && val["componet_cmd"] != nil { // 添加参数值 args := []string{val["componet_cmd"].(string)} if val["arguments"] != nil { @@ -938,13 +945,6 @@ var Index = &ctx.Context{Name: "web", Help: "应用中心", } } - if m.Options("sessid") { - m.Magic("session", "what", 1) - m.Log("fuck", "what %v", m.Magic("bench", "what")) - m.Magic("bench", "what", 2) - m.Log("fuck", "what %v", m.Magic("bench", "what")) - } - // 执行命令 if order != "" || kit.Right(val["pre_run"]) { if list := m.Confv("auth", []string{m.Option("bench"), "data", "action", msg.Option("componet_name"), "cmd"}); list != nil && order == "" { @@ -961,7 +961,6 @@ var Index = &ctx.Context{Name: "web", Help: "应用中心", } } } else { - msg = m } // 添加响应 diff --git a/src/examples/app/bench.go b/src/examples/app/bench.go index b812e737..88f10093 100644 --- a/src/examples/app/bench.go +++ b/src/examples/app/bench.go @@ -18,11 +18,11 @@ import ( _ "contexts/web" //应用中心 // 应用层 + _ "examples/chat" //会议中心 _ "examples/code" //代码中心 - // _ "examples/jira" //任务中心 - // _ "examples/lark" //会议中心 - // _ "examples/mall" //交易中心 _ "examples/wiki" //文档中心 + // _ "examples/jira" //任务中心 + // _ "examples/mall" //交易中心 ) func main() { diff --git a/src/examples/chat/chat.go b/src/examples/chat/chat.go index 2ffe9444..9852e81d 100644 --- a/src/examples/chat/chat.go +++ b/src/examples/chat/chat.go @@ -3,12 +3,36 @@ package chat import ( "contexts/ctx" "contexts/web" + "crypto/sha1" + "encoding/hex" "encoding/json" + "encoding/xml" "fmt" - "io/ioutil" "net/http" + "sort" + "strings" + "time" + "toolkit" ) +func Marshal(m *ctx.Message, meta string) string { + b, e := xml.Marshal(struct { + CreateTime int64 + FromUserName string + ToUserName string + MsgType string + Content string + XMLName xml.Name `xml:"xml"` + }{ + time.Now().Unix(), + m.Option("selfname"), m.Option("username"), + meta, strings.Join(m.Meta["result"], ""), xml.Name{}, + }) + m.Assert(e) + m.Set("append").Set("result").Echo(string(b)) + return string(b) +} + var Index = &ctx.Context{Name: "chat", Help: "会议中心", Caches: map[string]*ctx.Cache{}, Configs: map[string]*ctx.Config{ @@ -22,28 +46,171 @@ var Index = &ctx.Context{Name: "chat", Help: "会议中心", "sinas_site": &ctx.Config{Name: "sinas_site", Value: "http://www.sina.com.cn/mid/search.shtml?range=all&c=news&q=%s&from=home&ie=utf-8", Help: "聊天记录"}, "zhihu_site": &ctx.Config{Name: "zhihu_site", Value: "https://www.zhihu.com/search?type=content&q=%s", Help: "聊天记录"}, "toutiao_site": &ctx.Config{Name: "toutiao_site", Value: "https://www.toutiao.com/search/?keyword=%s", Help: "聊天记录"}, + + "chat": &ctx.Config{Name: "chat", Value: map[string]interface{}{ + "appid": "", "appmm": "", "token": "", "site": "https://shylinux.com", + "access": map[string]interface{}{"token": "", "expire": 0, "url": "/cgi-bin/token?grant_type=client_credential"}, + "ticket": map[string]interface{}{"value": "", "expire": 0, "url": "/cgi-bin/ticket/getticket?type=jsapi"}, + }, Help: "聊天记录"}, + "mp": &ctx.Config{Name: "chat", Value: map[string]interface{}{ + "appid": "", "appmm": "", "token": "", "site": "https://shylinux.com", + "auth": "/sns/jscode2session?grant_type=authorization_code", + }, Help: "聊天记录"}, }, Commands: map[string]*ctx.Command{ "/chat": &ctx.Command{Name: "user", Help: "应用示例", Hand: func(m *ctx.Message, c *ctx.Context, key string, arg ...string) (e error) { - r := m.Optionv("request").(*http.Request) - w := m.Optionv("response").(http.ResponseWriter) - - data := map[string]interface{}{} - switch r.Header.Get("Content-Type") { - case "application/json": - b, e := ioutil.ReadAll(r.Body) - e = json.Unmarshal(b, &data) - m.Assert(e) - } - - if _, ok := data["challenge"]; ok { - w.Header().Set("Content-Type", "application/javascript") - fmt.Fprintf(w, "{\"challenge\": \"%s\"}", data["challenge"]) + // 信息验证 + nonce := []string{m.Option("timestamp"), m.Option("nonce"), m.Conf("chat", "token")} + sort.Strings(nonce) + h := sha1.Sum([]byte(strings.Join(nonce, ""))) + if hex.EncodeToString(h[:]) == m.Option("signature") { + // m.Echo(m.Option("echostr")) + } else { return } - m.Confv("chat_msg", "-1", data) + + // 解析数据 + var data struct { + MsgId int64 + CreateTime int64 + ToUserName string + FromUserName string + MsgType string + Content string + } + r := m.Optionv("request").(*http.Request) + m.Assert(xml.NewDecoder(r.Body).Decode(&data)) + m.Option("username", data.FromUserName) + m.Option("selfname", data.ToUserName) + + // 创建会话 + if m.Option("sessid", m.Cmd("aaa.user", m.Option("username", data.FromUserName), "chat").Append("key")) == "" { + m.Cmd("aaa.sess", m.Option("sessid", m.Cmdx("aaa.sess", "chat", "ip", "what")), m.Option("username"), "ppid", "what") + } + + // 创建空间 + if m.Option("bench", m.Cmd("aaa.sess", m.Option("sessid"), "bench").Append("key")) == "" { + m.Option("bench", m.Cmdx("aaa.work", m.Option("sessid"), "chat")) + } + m.Option("current_ctx", kit.Select("chat", m.Magic("bench", "current_ctx"))) + + switch data.MsgType { + case "text": + // 执行命令 + cmd := strings.Split(data.Content, " ") + if !m.Cmds("aaa.work", m.Option("bench"), "right", data.FromUserName, "chat", cmd[0]) { + m.Echo("no right %s %s", "chat", cmd[0]) + } else if m.Cmdy("cli.source", data.Content); m.Appends("redirect") { + } + Marshal(m, "text") + } return }}, + "access": &ctx.Command{Name: "access", Help: "", Hand: func(m *ctx.Message, c *ctx.Context, key string, arg ...string) (e error) { + m.Option("format", "object") + now := kit.Int(time.Now().Unix()) + + access := m.Confm("chat", "access") + if kit.Int(access["expire"]) < now { + msg := m.Cmd("web.get", "wexin", access["url"], "appid", m.Conf("chat", "appid"), "secret", m.Conf("chat", "appmm"), "temp", "data") + access["token"] = msg.Append("access_token") + access["expire"] = msg.Appendi("expires_in") + now + } + m.Echo("%v", access["token"]) + return + }}, + "ticket": &ctx.Command{Name: "ticket", Help: "", Hand: func(m *ctx.Message, c *ctx.Context, key string, arg ...string) (e error) { + m.Option("format", "object") + now := kit.Int(time.Now().Unix()) + + ticket := m.Confm("chat", "ticket") + if kit.Int(ticket["expire"]) < now { + msg := m.Cmd("web.get", "wexin", ticket["url"], "access_token", m.Cmdx(".access"), "temp", "data") + ticket["value"] = msg.Append("ticket") + ticket["expire"] = msg.Appendi("expires_in") + now + } + m.Echo("%v", ticket["value"]) + return + }}, + "js_token": &ctx.Command{Name: "js_token", Help: "zhihu", Hand: func(m *ctx.Message, c *ctx.Context, key string, arg ...string) (e error) { + nonce := []string{ + "jsapi_ticket=" + m.Cmdx(".ticket"), + "noncestr=" + m.Append("nonce", "what"), + "timestamp=" + m.Append("timestamp", kit.Int(time.Now())), + "url=" + m.Append("url", m.Conf("chat", "site")+m.Option("index_url")), + } + sort.Strings(nonce) + h := sha1.Sum([]byte(strings.Join(nonce, "&"))) + + m.Append("signature", hex.EncodeToString(h[:])) + m.Append("appid", m.Conf("chat", "appid")) + return + }}, + "share": &ctx.Command{Name: "share", Help: "", Hand: func(m *ctx.Message, c *ctx.Context, key string, arg ...string) (e error) { + m.Echo("%s?bench=%s&sessid=%s", m.Conf("chat", "site"), m.Option("bench"), m.Option("sessid")) + return + }}, + "check": &ctx.Command{Name: "check", Help: "", Hand: func(m *ctx.Message, c *ctx.Context, key string, arg ...string) (e error) { + sort.Strings(arg) + h := sha1.Sum([]byte(strings.Join(arg, ""))) + if hex.EncodeToString(h[:]) == m.Option("signature") { + m.Echo("true") + } + return + }}, + + "/mp": &ctx.Command{Name: "/mp", Help: "", Hand: func(m *ctx.Message, c *ctx.Context, key string, arg ...string) (e error) { + // 用户登录 + if m.Options("code") { + m.Option("format", "object") + msg := m.Cmd("web.get", "wexin", m.Conf("mp", "auth"), "js_code", m.Option("code"), "appid", m.Conf("mp", "appid"), "secret", m.Conf("mp", "appmm"), "parse", "json", "temp", "data") + + // 创建会话 + if !m.Options("sessid") { + m.Cmd("aaa.sess", m.Option("sessid", m.Cmdx("aaa.sess", "mp", "ip", "what")), msg.Append("openid"), "ppid", "what") + defer func() { + m.Set("result").Echo(m.Option("sessid")) + }() + } + + m.Magic("session", "user.openid", msg.Append("openid")) + m.Magic("session", "user.expires_in", kit.Int(msg.Append("expires_in"), time.Now())) + m.Magic("session", "user.session_key", msg.Append("session_key")) + } + + // 用户信息 + if m.Options("userInfo") && m.Options("rawData") { + h := sha1.Sum([]byte(strings.Join([]string{m.Option("rawData"), kit.Format(m.Magic("session", "user.session_key"))}, ""))) + if hex.EncodeToString(h[:]) == m.Option("signature") { + var info interface{} + json.Unmarshal([]byte(m.Option("userInfo")), &info) + m.Log("info", "user %v %v", m.Option("sessid"), info) + + m.Magic("session", "user.info", info) + m.Magic("session", "user.encryptedData", m.Option("encryptedData")) + m.Magic("session", "user.iv", m.Option("iv")) + } + } + + if m.Option("username", m.Magic("session", "user.openid")) == "" || m.Option("cmd") == "" { + return + } + + // 创建空间 + if !m.Options("bench") && m.Option("bench", m.Cmd("aaa.sess", m.Option("sessid"), "bench").Append("key")) == "" { + m.Option("bench", m.Cmdx("aaa.work", m.Option("sessid"), "mp")) + } + m.Option("current_ctx", kit.Select("chat", m.Magic("bench", "current_ctx"))) + + // 执行命令 + cmd := strings.Split(m.Option("cmd"), " ") + if !m.Cmds("aaa.work", m.Option("bench"), "right", m.Option("username"), "mp", cmd[0]) { + m.Echo("no right %s %s", "chat", cmd[0]) + } else if m.Cmdy("cli.source", m.Option("cmd")); m.Appends("redirect") { + } + return + }}, + "talk": &ctx.Command{Name: "talk", Help: "talk", Hand: func(m *ctx.Message, c *ctx.Context, key string, arg ...string) (e error) { if m.Confs("default") { m.Echo(m.Conf("default")) diff --git a/src/examples/code/code.go b/src/examples/code/code.go index 40f165dd..06aa8794 100644 --- a/src/examples/code/code.go +++ b/src/examples/code/code.go @@ -209,6 +209,7 @@ var Index = &ctx.Context{Name: "code", Help: "代码中心", // "pre_run": true, // "display_result": "", // }, + map[string]interface{}{"name": "mp", "template": "mp"}, map[string]interface{}{"name": "tail", "template": "tail"}, }, }, Help: "组件列表"}, diff --git a/usr/librarys/context.js b/usr/librarys/context.js index 82d4465d..b4a08a7e 100644 --- a/usr/librarys/context.js +++ b/usr/librarys/context.js @@ -59,6 +59,16 @@ context = { document.cookie = key+"="+value+";path=/"; return this.Cookie(key); }, + Command: function(cmd, option, cb) { + option = option || {} + option["componet_index"] = "index" + option["componet_name"] = "source" + option["cmd"] = cmd + + this.GET("", option, function(msg) { + typeof cb == "function" && (msg && msg[0]? cb(msg[0]): cb()) + }) + }, Cache: function(key, cb, sync) { if (key == undefined) { return this.cache diff --git a/usr/template/code/code.tmpl b/usr/template/code/code.tmpl index 8f43e1b5..fef1ecf0 100644 --- a/usr/template/code/code.tmpl +++ b/usr/template/code/code.tmpl @@ -300,6 +300,78 @@ {{end}} +{{define "mp"}} + + + + + + +{{end}} + {{define "tail"}}