wip allow for more cdns

add res template function
add ExtraCSPOrigins config setting
add StaticResBase config setting
skip flush directives
This commit is contained in:
Azareal 2020-07-30 18:10:29 +10:00
parent d4fd85f75c
commit 43d72e6f3b
9 changed files with 136 additions and 65 deletions

View File

@ -10,6 +10,7 @@ import (
"io/ioutil"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@ -23,14 +24,16 @@ import (
//type SFileList map[string]*SFile
//type SFileListShort map[string]*SFile
var StaticFiles = SFileList{make(map[string]*SFile),make(map[string]*SFile)}
var StaticFiles = SFileList{"/s/", make(map[string]*SFile), make(map[string]*SFile)}
//var StaticFilesShort SFileList = make(map[string]*SFile)
var staticFileMutex sync.RWMutex
// ? Is it efficient to have two maps for this?
type SFileList struct {
Long map[string]*SFile
Short map[string]*SFile
Prefix string
Long map[string]*SFile
Short map[string]*SFile
}
type SFile struct {
@ -305,7 +308,7 @@ func (l SFileList) JSTmplInit() error {
hasher.Write(data)
checksum := hex.EncodeToString(hasher.Sum(nil))
l.Set("/s/"+path, &SFile{data, gzipData, brData, checksum, path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
l.Set(l.Prefix+path, &SFile{data, gzipData, brData, checksum, l.Prefix + path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
DebugLogf("Added the '%s' static file.", path)
return nil
@ -367,7 +370,7 @@ func (l SFileList) Init() error {
}
}
l.Set("/s/"+path, &SFile{data, gzipData, brData, checksum, path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mimetype, f, f.ModTime().UTC().Format(http.TimeFormat)})
l.Set(l.Prefix+path, &SFile{data, gzipData, brData, checksum, l.Prefix + path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mimetype, f, f.ModTime().UTC().Format(http.TimeFormat)})
DebugLogf("Added the '%s' static file.", path)
return nil
@ -424,7 +427,7 @@ func (l SFileList) Add(path, prefix string) error {
hasher.Write(data)
checksum := hex.EncodeToString(hasher.Sum(nil))
l.Set("/s/"+path, &SFile{data, gzipData, brData, checksum, path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
l.Set(l.Prefix+path, &SFile{data, gzipData, brData, checksum, l.Prefix + path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
DebugLogf("Added the '%s' static file", path)
return nil
@ -448,8 +451,13 @@ func (l SFileList) GetShort(name string) (file *SFile, exists bool) {
func (l SFileList) Set(name string, data *SFile) {
staticFileMutex.Lock()
defer staticFileMutex.Unlock()
l.Long[name] = data
l.Short[strings.TrimPrefix("/s/",name)] = data
// TODO: Propagate errors back up
uurl, err := url.Parse(name)
if err != nil {
return
}
l.Long[uurl.Path] = data
l.Short[strings.TrimPrefix(strings.TrimPrefix(name, l.Prefix), "/")] = data
}
var gzipBestCompress sync.Pool

View File

@ -2,11 +2,13 @@ package common
import (
"encoding/json"
"errors"
"io/ioutil"
"log"
"net/url"
"strconv"
"strings"
"github.com/pkg/errors"
)
// Site holds the basic settings which should be tweaked when setting up a site, we might move them to the settings table at some point
@ -119,6 +121,9 @@ type config struct {
RefNoRef bool
NoEmbed bool
ExtraCSPOrigins string
StaticResBase string // /s/
Noavatar string // ? - Move this into the settings table?
ItemsPerPage int // ? - Move this into the settings table?
MaxTopicTitleLength int
@ -238,6 +243,21 @@ func ProcessConfig() (err error) {
}
Site.MaxRequestSize = Config.MaxRequestSize
local := func(h string) bool {
return h == "localhost" || h == "127.0.0.1" || h == "::1" || h == Site.URL
}
uurl, err := url.Parse(Config.StaticResBase)
if err != nil {
return errors.Wrap(err, "failed to parse Config.StaticResBase: ")
}
host := uurl.Hostname()
if !local(host) {
Config.ExtraCSPOrigins += " " + host
}
if Config.StaticResBase != "" {
StaticFiles.Prefix = Config.StaticResBase
}
if Config.PostIPCutoff == 0 {
Config.PostIPCutoff = 120 // Default cutoff
}

View File

@ -878,6 +878,17 @@ func initDefaultTmplFuncMap() {
return nil
}
fmap["res"] = func(nameInt interface{}) interface{} {
n := nameInt.(string)
if n[0] == '/' && n[1] == '/' {
} else {
if f, ok := StaticFiles.GetShort(n); ok {
n = f.OName
}
}
return n
}
DefaultTemplateFuncMap = fmap
}

View File

@ -119,6 +119,7 @@ func NewCTemplateSet(in string) *CTemplateSet {
"js": true,
"index": true,
"flush": true,
"res": true,
},
logger: log.New(f, "", log.LstdFlags),
loggerf: f,
@ -683,8 +684,9 @@ func (c *CTemplateSet) compileSwitch(con CContext, node parse.Node) {
for _, cmd := range node.Pipe.Cmds {
c.detail("If Node Bit:", cmd)
c.detail("Bit Type:", reflect.ValueOf(cmd).Type().Name())
expr += c.compileExprSwitch(con, cmd)
c.detail("Expression Step:", c.compileExprSwitch(con, cmd))
exprStep := c.compileExprSwitch(con, cmd)
expr += exprStep
c.detail("Expression Step:", exprStep)
}
c.detail("Expression:", expr)
@ -1031,8 +1033,7 @@ func (c *CTemplateSet) compileExprSwitch(con CContext, node *parse.CommandNode)
case *parse.NilNode:
panic("Nil is not a command x.x")
case *parse.PipeNode:
c.detail("Pipe Node!")
c.detail(n)
c.detail("Pipe Node:", n)
c.detail("Node Args:", node.Args)
out += c.compileIdentSwitchN(con, node)
default:
@ -1043,9 +1044,9 @@ func (c *CTemplateSet) compileExprSwitch(con CContext, node *parse.CommandNode)
}
func (c *CTemplateSet) unknownNode(n parse.Node) {
elem := reflect.ValueOf(n).Elem()
c.logger.Println("Unknown Kind:", elem.Kind())
c.logger.Println("Unknown Type:", elem.Type().Name())
el := reflect.ValueOf(n).Elem()
c.logger.Println("Unknown Kind:", el.Kind())
c.logger.Println("Unknown Type:", el.Type().Name())
panic("I don't know what node this is! Grr...")
}
@ -1114,7 +1115,7 @@ func (c *CTemplateSet) compareJoin(con CContext, pos int, node *parse.CommandNod
return pos, out
}
func (c *CTemplateSet) compileIdentSwitch(con CContext, node *parse.CommandNode) (out string, val reflect.Value, literal, notident bool) {
func (c *CTemplateSet) compileIdentSwitch(con CContext, node *parse.CommandNode) (out string, val reflect.Value, literal, notIdent bool) {
c.dumpCall("compileIdentSwitch", con, node)
litString := func(inner string, bytes bool) {
if !bytes {
@ -1135,9 +1136,16 @@ ArgLoop:
var rout string
pos, rout = c.compareJoin(con, pos, node, c.funcMap[id.String()].(string)) // TODO: Test this
out += rout
case "le", "lt", "gt", "ge", "eq", "ne":
case "le", "lt", "gt", "ge":
out += c.compareFunc(con, pos, node, c.funcMap[id.String()].(string))
break ArgLoop
case "eq", "ne":
o := c.compareFunc(con, pos, node, c.funcMap[id.String()].(string))
if out == "!" {
o = "(" + o + ")"
}
out += o
break ArgLoop
case "add", "subtract", "divide", "multiply":
rout, rval := c.simpleMath(con, pos, node, c.funcMap[id.String()].(string))
out += rout
@ -1236,7 +1244,7 @@ ArgLoop:
// ! Slightly crude but it does the job
leftParam := strings.Replace(leftOp, "\"", "", -1)
c.langIndexToName = append(c.langIndexToName, leftParam)
notident = true
notIdent = true
con.PushPhrase(len(c.langIndexToName) - 1)
} else {
leftParam := leftOp
@ -1386,17 +1394,37 @@ ArgLoop:
// TODO: Refactor this
// TODO: Call the template function directly rather than going through RunThemeTemplate to eliminate a round of indirection?
out = "{\nerr := " + headParam + ".Theme.RunTmpl(" + nameParam + "," + pageParam + ",w)\n"
out += "if err != nil {\nreturn err\n}\n}\n"
out = "{\ne := " + headParam + ".Theme.RunTmpl(" + nameParam + "," + pageParam + ",w)\n"
out += "if e != nil {\nreturn e\n}\n}\n"
literal = true
break ArgLoop
case "flush":
if c.lang == "js" {
continue
}
out = "if fl, ok := iw.(http.Flusher); ok {\nfl.Flush()\n}\n"
literal = true
c.importMap["net/http"] = "net/http"
break ArgLoop
/*if c.lang == "js" {
continue
}
out = "if fl, ok := iw.(http.Flusher); ok {\nfl.Flush()\n}\n"
literal = true
c.importMap["net/http"] = "net/http"
break ArgLoop*/
// TODO: Test this
case "res":
leftOp := node.Args[pos+1].String()
if len(leftOp) == 0 {
panic("The leftoperand for function res cannot be left blank")
}
leftParam, _ := c.compileIfVarSub(con, leftOp)
literal = true
if leftParam[0] == '"' {
if leftParam[1] == '/' && leftParam[2] == '/' {
litString(leftParam, false)
break ArgLoop
}
out = "{n := " + leftParam + "\nif f, ok := c.StaticFiles.GetShort(n); ok {\nw.Write(StringToBytes(f.OName))\n} else {\nw.Write(StringToBytes(n))\n}}\n"
break ArgLoop
}
out = "{n := " + leftParam + "\nif n[0] == '/' && n[1] == '/' {\n} else {\nif f, ok := c.StaticFiles.GetShort(n); ok {\nn = f.OName\n}\nw.Write(StringToBytes(n))\n}\n"
break ArgLoop
default:
c.detail("Variable!")
@ -1410,7 +1438,7 @@ ArgLoop:
}
}
c.retCall("compileIdentSwitch", out, val, literal)
return out, val, literal, notident
return out, val, literal, notIdent
}
func (c *CTemplateSet) compileReflectSwitch(con CContext, node *parse.CommandNode) (out string, outVal reflect.Value) {

View File

@ -284,7 +284,7 @@ func (t *Theme) AddThemeStaticFiles() error {
hasher.Write(data)
checksum := hex.EncodeToString(hasher.Sum(nil))
StaticFiles.Set("/s/"+t.Name+path, &SFile{data, gzipData, brData, checksum, t.Name + path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
StaticFiles.Set(StaticFiles.Prefix+t.Name+path, &SFile{data, gzipData, brData, checksum, StaticFiles.Prefix + t.Name + path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
DebugLog("Added the '/" + t.Name + path + "' static file for theme " + t.Name + ".")
return nil

View File

@ -126,6 +126,10 @@ RefNoRef - This switch makes it so that if a user clicks on a link, then the inc
NoEmbed - Don't expand links into videos or images. Default: false
ExtraCSPOrigins - Extra origins which may want whitelisted in the default Content Security Policy.
StaticResBase - The default prefix for static resource files. May be a path or an external domain like a CDN domain. Default: /s/
NoAvatar - The default avatar to use for users when they don't have their own. The default for this may change in the near future to better utilise HTTP/2. Example: https://api.adorable.io/avatars/{width}/{id}.png
ItemsPerPage - The number of posts, topics, etc. you want on each page.

View File

@ -139,15 +139,15 @@ func FootHeaders(w http.ResponseWriter, h *c.Header) {
he := w.Header()
if c.Config.SslSchema {
if h.ExternalMedia {
he.Set("Content-Security-Policy", "default-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self' www.youtube-nocookie.com embed.nicovideo.jp;upgrade-insecure-requests")
he.Set("Content-Security-Policy", "default-src 'self' 'unsafe-eval'"+c.Config.ExtraCSPOrigins+"; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self' www.youtube-nocookie.com embed.nicovideo.jp;upgrade-insecure-requests")
} else {
he.Set("Content-Security-Policy", "default-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self';upgrade-insecure-requests")
he.Set("Content-Security-Policy", "default-src 'self' 'unsafe-eval'"+c.Config.ExtraCSPOrigins+"; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self';upgrade-insecure-requests")
}
} else {
if h.ExternalMedia {
he.Set("Content-Security-Policy", "default-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self' www.youtube-nocookie.com embed.nicovideo.jp")
he.Set("Content-Security-Policy", "default-src 'self' 'unsafe-eval'"+c.Config.ExtraCSPOrigins+"; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self' www.youtube-nocookie.com embed.nicovideo.jp")
} else {
he.Set("Content-Security-Policy", "default-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self'")
he.Set("Content-Security-Policy", "default-src 'self' 'unsafe-eval'"+c.Config.ExtraCSPOrigins+"; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self'")
}
}
}

View File

@ -3,16 +3,16 @@
<head>
<title>{{.Title}} | {{.Header.Site.Name}}</title>
{{range .Header.Stylesheets}}
<link href="/s/{{.}}"rel="stylesheet"type="text/css">{{end}}
<link href="{{.}}"rel="stylesheet"type="text/css">{{end}}
{{range .Header.PreScriptsAsync}}
<script async src="/s/{{.}}"></script>{{end}}
<script async src="{{.}}"></script>{{end}}
{{if .CurrentUser.Loggedin}}<meta property="x-mem"content="1">{{end}}
<script src="/s/init.js?i=12"></script>
<script src="{{res "init.js"}}"></script>
{{range .Header.ScriptsAsync}}
<script async src="/s/{{.}}"></script>{{end}}
<script src="/s/jquery-3.1.1.min.js"></script>
<script async src="{{.}}"></script>{{end}}
<script src="{{res "jquery-3.1.1.min.js"}}"></script>
{{range .Header.Scripts}}
<script src="/s/{{.}}"></script>{{end}}
<script src="{{.}}"></script>{{end}}
<meta name="viewport"content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
{{if .Header.MetaDesc}}<meta name="description"content="{{.Header.MetaDesc}}">{{end}}
{{/** TODO: Have page / forum / topic level tags and descriptions below as-well **/}}

View File

@ -1,37 +1,37 @@
<main id="topicPage">
{{if gt .Page 1}}<link rel="prev"href="{{.Topic.Link}}?page={{subtract .Page 1}}"/>{{end}}
{{if ne .LastPage .Page}}<link rel="prerender next"href="{{.Topic.Link}}?page={{add .Page 1}}"/>{{end}}
{{if not .CurrentUser.Loggedin}}<link rel="canonical" href="//{{.Site.URL}}{{.Topic.Link}}{{if gt .Page 1}}?page={{.Page}}{{end}}"/>{{end}}
{{if gt .Page 1}}<link rel="prev"href="{{.Topic.Link}}?page={{subtract .Page 1}}">{{end}}
{{if ne .LastPage .Page}}<link rel="prerender next"href="{{.Topic.Link}}?page={{add .Page 1}}">{{end}}
{{if not .CurrentUser.Loggedin}}<link rel="canonical"href="//{{.Site.URL}}{{.Topic.Link}}{{if gt .Page 1}}?page={{.Page}}{{end}}">{{end}}
<div {{scope "topic_title_block"}} class="rowblock rowhead topic_block" aria-label="{{lang "topic.topic_info_aria"}}">
<div {{scope "topic_title_block"}} class="rowblock rowhead topic_block"aria-label="{{lang "topic.topic_info_aria"}}">
<div class="rowitem topic_item{{if .Topic.Sticky}} topic_sticky_head{{else if .Topic.IsClosed}} topic_closed_head{{end}}">
<h1 class='topic_name hide_on_edit' title='{{.Topic.Title}}'>{{.Topic.Title}}</h1>
<h1 class='topic_name hide_on_edit'title='{{.Topic.Title}}'>{{.Topic.Title}}</h1>
<span class="topic_name_forum_sep hide_on_edit"> - </span>
<a href="{{.Forum.Link}}" class="topic_forum hide_on_edit">{{.Forum.Name}}</a>
<a href="{{.Forum.Link}}"class="topic_forum hide_on_edit">{{.Forum.Name}}</a>
{{/** TODO: Does this need to be guarded by a permission? It's only visible in edit mode anyway, which can't be triggered, if they don't have the permission **/}}
{{if .CurrentUser.Loggedin}}
{{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}}
{{if .CurrentUser.Perms.EditTopic}}
<form id="edit_topic_form" action='/topic/edit/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}' method="post"></form>
<input form="edit_topic_form" class='show_on_edit topic_name_input' name="topic_name" value='{{.Topic.Title}}' type="text" aria-label="{{lang "topic.title_input_aria"}}">
<button form="edit_topic_form" name="topic-button" class="formbutton show_on_edit submit_edit">{{lang "topic.update_button"}}</button>
<form id="edit_topic_form"action='/topic/edit/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'method="post"></form>
<input form="edit_topic_form"class='show_on_edit topic_name_input'name="topic_name"value='{{.Topic.Title}}'type="text"aria-label="{{lang "topic.title_input_aria"}}">
<button form="edit_topic_form"name="topic-button"class="formbutton show_on_edit submit_edit">{{lang "topic.update_button"}}</button>
{{end}}
{{end}}
{{end}}
<span class="topic_view_count hide_on_edit">{{.Topic.ViewCount}}</span>
{{/** TODO: Inline this CSS **/}}
{{if .Topic.IsClosed}}<span class='username hide_on_micro topic_status_e topic_status_closed hide_on_edit' title='{{lang "status.closed_tooltip"}}' aria-label='{{lang "topic.status_closed_aria"}}'>&#x1F512;&#xFE0E</span>{{end}}
{{if .Topic.IsClosed}}<span class='username hide_on_micro topic_status_e topic_status_closed hide_on_edit'title='{{lang "status.closed_tooltip"}}'aria-label='{{lang "topic.status_closed_aria"}}'>&#x1F512;&#xFE0E</span>{{end}}
</div>
</div>
<div class="rowblock post_container">
{{if .Poll}}{{template "topic_alt_poll.html" . }}{{end}}
<article {{scope "opening_post"}} itemscope itemtype="http://schema.org/CreativeWork" class="rowitem passive deletable_block editable_parent post_item top_post{{if .Topic.Attachments}} has_attachs{{end}}" aria-label="{{lang "topic.opening_post_aria"}}">
<article {{scope "opening_post"}} itemscope itemtype="http://schema.org/CreativeWork"class="rowitem passive deletable_block editable_parent post_item top_post{{if .Topic.Attachments}} has_attachs{{end}}"aria-label="{{lang "topic.opening_post_aria"}}">
{{template "topic_alt_userinfo.html" .Topic }}
<div class="content_container">
<div class="hide_on_edit topic_content user_content" itemprop="text">{{.Topic.ContentHTML}}</div>
{{if .CurrentUser.Loggedin}}<textarea name="topic_content" class="show_on_edit topic_content_input edit_source">{{.Topic.Content}}</textarea>
<div class="hide_on_edit topic_content user_content"itemprop="text">{{.Topic.ContentHTML}}</div>
{{if .CurrentUser.Loggedin}}<textarea name="topic_content"class="show_on_edit topic_content_input edit_source">{{.Topic.Content}}</textarea>
{{if .CurrentUser.Perms.EditTopic}}
<div class="show_on_edit attach_edit_bay"type="topic"id="{{.Topic.ID}}">
@ -45,8 +45,8 @@
{{end}}
<div class="attach_item attach_item_buttons">
{{if .CurrentUser.Perms.UploadFiles}}
<input name="upload_files" id="upload_files_op" multiple type="file" class="auto_hide">
<label for="upload_files_op" class="formbutton add_file_button">{{lang "topic.upload_button_text"}}</label>{{end}}
<input name="upload_files"id="upload_files_op"multiple type="file"class="auto_hide">
<label for="upload_files_op"class="formbutton add_file_button">{{lang "topic.upload_button_text"}}</label>{{end}}
<button class="attach_item_delete formbutton">{{lang "topic.delete_button_text"}}</button>
</div>
</div>
@ -56,26 +56,26 @@
<div class="action_button_left">
{{if .CurrentUser.Loggedin}}
{{if .CurrentUser.Perms.LikeItem}}{{if ne .CurrentUser.ID .Topic.CreatedBy}}
{{if .Topic.Liked}}<a href="/topic/unlike/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}" class="action_button like_item remove_like" aria-label="{{lang "topic.unlike_aria"}}" data-action="unlike"></a>{{else}}<a href="/topic/like/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}" class="action_button like_item add_like" aria-label="{{lang "topic.like_aria"}}" data-action="like"></a>{{end}}
{{if .Topic.Liked}}<a href="/topic/unlike/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}"class="action_button like_item remove_like"aria-label="{{lang "topic.unlike_aria"}}"data-action="unlike"></a>{{else}}<a href="/topic/like/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}"class="action_button like_item add_like"aria-label="{{lang "topic.like_aria"}}"data-action="like"></a>{{end}}
{{end}}{{end}}
<a href="" class="action_button quote_item" aria-label="{{lang "topic.quote_aria"}}" data-action="quote"></a>
<a href=""class="action_button quote_item"aria-label="{{lang "topic.quote_aria"}}"data-action="quote"></a>
{{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}}
{{if .CurrentUser.Perms.EditTopic}}<a href="/topic/edit/{{.Topic.ID}}" class="action_button open_edit" aria-label="{{lang "topic.edit_aria"}}" data-action="edit"></a>{{end}}
{{if .CurrentUser.Perms.EditTopic}}<a href="/topic/edit/{{.Topic.ID}}"class="action_button open_edit"aria-label="{{lang "topic.edit_aria"}}"data-action="edit"></a>{{end}}
{{end}}
{{if .Topic.Deletable}}<a href="/topic/delete/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}" class="action_button delete_item" aria-label="{{lang "topic.delete_aria"}}" data-action="delete"></a>{{end}}
{{if .Topic.Deletable}}<a href="/topic/delete/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}"class="action_button delete_item"aria-label="{{lang "topic.delete_aria"}}"data-action="delete"></a>{{end}}
{{if .CurrentUser.Perms.CloseTopic}}
{{if .Topic.IsClosed}}<a href='/topic/unlock/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}' class="action_button unlock_item" data-action="unlock" aria-label="{{lang "topic.unlock_aria"}}"></a>{{else}}<a href='/topic/lock/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}' class="action_button lock_item" data-action="lock" aria-label="{{lang "topic.lock_aria"}}"></a>{{end}}{{end}}
{{if .Topic.IsClosed}}<a href='/topic/unlock/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'class="action_button unlock_item"data-action="unlock"aria-label="{{lang "topic.unlock_aria"}}"></a>{{else}}<a href='/topic/lock/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'class="action_button lock_item"data-action="lock"aria-label="{{lang "topic.lock_aria"}}"></a>{{end}}{{end}}
{{if .CurrentUser.Perms.PinTopic}}
{{if .Topic.Sticky}}<a href='/topic/unstick/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}' class="action_button unpin_item" data-action="unpin" aria-label="{{lang "topic.unpin_aria"}}"></a>{{else}}<a href='/topic/stick/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}' class="action_button pin_item" data-action="pin" aria-label="{{lang "topic.pin_aria"}}"></a>{{end}}{{end}}
{{if .CurrentUser.Perms.ViewIPs}}<a href="/users/ips/?ip={{.Topic.IP}}" title="{{lang "topic.ip_full_tooltip"}}" class="action_button ip_item_button hide_on_big" aria-label="{{lang "topic.ip_full_aria"}}" data-action="ip"></a>{{end}}
<a href="/report/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}&type=topic" class="action_button report_item" aria-label="{{lang "topic.report_aria"}}" data-action="report"></a>
<a href="#" class="action_button button_menu"></a>
{{if .Topic.Sticky}}<a href='/topic/unstick/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'class="action_button unpin_item"data-action="unpin"aria-label="{{lang "topic.unpin_aria"}}"></a>{{else}}<a href='/topic/stick/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'class="action_button pin_item"data-action="pin"aria-label="{{lang "topic.pin_aria"}}"></a>{{end}}{{end}}
{{if .CurrentUser.Perms.ViewIPs}}<a href="/users/ips/?ip={{.Topic.IP}}"title="{{lang "topic.ip_full_tooltip"}}" class="action_button ip_item_button hide_on_big"aria-label="{{lang "topic.ip_full_aria"}}"data-action="ip"></a>{{end}}
<a href="/report/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}&type=topic"class="action_button report_item"aria-label="{{lang "topic.report_aria"}}"data-action="report"></a>
<a href="#"class="action_button button_menu"></a>
{{end}}
</div>
<div class="action_button_right">
<a class="action_button like_count hide_on_micro" aria-label="{{lang "topic.like_count_aria"}}">{{.Topic.LikeCount}}</a>
<a class="action_button created_at hide_on_mobile" title="{{abstime .Topic.CreatedAt}}">{{reltime .Topic.CreatedAt}}</a>
{{if .CurrentUser.Perms.ViewIPs}}<a href="/users/ips/?ip={{.Topic.IP}}" title="{{lang "topic.ip_full_tooltip"}}" class="action_button ip_item hide_on_mobile" aria-hidden="true">{{.Topic.IP}}</a>{{end}}
<a class="action_button like_count hide_on_micro"aria-label="{{lang "topic.like_count_aria"}}">{{.Topic.LikeCount}}</a>
<a class="action_button created_at hide_on_mobile"title="{{abstime .Topic.CreatedAt}}">{{reltime .Topic.CreatedAt}}</a>
{{if .CurrentUser.Perms.ViewIPs}}<a href="/users/ips/?ip={{.Topic.IP}}"title="{{lang "topic.ip_full_tooltip"}}"class="action_button ip_item hide_on_mobile"aria-hidden="true">{{.Topic.IP}}</a>{{end}}
</div>
</div>
</div><div style="clear:both;"></div>