Initial work on Plugin Hyperdrive.
Reduced the amount of boilerplate in Plugin Markdown. Reduced the amount of boilerplate in Plugin Sendmail. Reduced the amount of boilerplate in sample plugins Heythere and Skeleton. Fixed up Plugin GeoIP. It's not ready for use though. Added the routes.FootHeaders function. Added the route_topic_list_start plugin hook.
This commit is contained in:
parent
114afe0b13
commit
b4ffaa2cd6
|
@ -83,6 +83,8 @@ var hookTable = &HookTable{
|
||||||
"simple_forum_check_pre_perms": nil,
|
"simple_forum_check_pre_perms": nil,
|
||||||
"forum_check_pre_perms": nil,
|
"forum_check_pre_perms": nil,
|
||||||
|
|
||||||
|
"route_topic_list_start": nil,
|
||||||
|
|
||||||
"action_end_create_topic": nil,
|
"action_end_create_topic": nil,
|
||||||
"action_end_edit_topic":nil,
|
"action_end_edit_topic":nil,
|
||||||
"action_end_delete_topic":nil,
|
"action_end_delete_topic":nil,
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
import c "github.com/Azareal/Gosora/common"
|
||||||
import "github.com/oschwald/geoip2-golang"
|
import "github.com/oschwald/geoip2-golang"
|
||||||
|
|
||||||
var geoip_db *geoip.DB
|
var geoipDB *geoip.DB
|
||||||
var geoip_db_location string = "geoip_db.mmdb"
|
var geoipDBLocation = "geoip_db.mmdb"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
plugins["geoip"] = NewPlugin("geoip","Geoip","Azareal","http://github.com/Azareal","","","",init_geoip,nil,deactivate_geoip,nil,nil)
|
c.Plugins.Add(&c.Plugin{UName: "geoip", Name: "Geoip", Author: "Azareal", Init: initGeoip, Deactivate: deactivateGeoip})
|
||||||
}
|
}
|
||||||
|
|
||||||
func init_geoip() (err error) {
|
func initGeoip(plugin *c.Plugin) (err error) {
|
||||||
geoip_db, err = geoip2.Open(geoip_db_location)
|
geoipDB, err = geoip2.Open(geoipDBLocation)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivate_geoip() {
|
func deactivateGeoip(plugin *c.Plugin) {
|
||||||
geoip_db.Close()
|
geoipDB.Close()
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,33 +2,101 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
"bytes"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
"github.com/Azareal/Gosora/common"
|
c "github.com/Azareal/Gosora/common"
|
||||||
|
"github.com/Azareal/Gosora/routes"
|
||||||
)
|
)
|
||||||
|
|
||||||
var hyperPageCache *HyperPageCache
|
var hyperspace *Hyperspace
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
common.Plugins.Add(&common.Plugin{UName: "hyperdrive", Name: "Hyperdrive", Author: "Azareal", Init: initHyperdrive, Deactivate: deactivateHyperdrive})
|
c.Plugins.Add(&c.Plugin{UName: "hyperdrive", Name: "Hyperdrive", Author: "Azareal", Init: initHdrive, Deactivate: deactivateHdrive})
|
||||||
}
|
}
|
||||||
|
|
||||||
func initHyperdrive(plugin *common.Plugin) error {
|
func initHdrive(plugin *c.Plugin) error {
|
||||||
hyperPageCache = newHyperPageCache()
|
hyperspace = newHyperspace()
|
||||||
plugin.AddHook("somewhere", deactivateHyperdrive)
|
plugin.AddHook("tasks_tick_topic_list",tickHdrive)
|
||||||
|
plugin.AddHook("route_topic_list_start",jumpHdrive)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivateHyperdrive(plugin *common.Plugin) {
|
func deactivateHdrive(plugin *c.Plugin) {
|
||||||
hyperPageCache = nil
|
plugin.RemoveHook("tasks_tick_topic_list",tickHdrive)
|
||||||
|
plugin.RemoveHook("route_topic_list_start",jumpHdrive)
|
||||||
|
hyperspace = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type HyperPageCache struct {
|
type Hyperspace struct {
|
||||||
topicList atomic.Value
|
topicList atomic.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHyperPageCache() *HyperPageCache {
|
func newHyperspace() *Hyperspace {
|
||||||
pageCache := new(HyperPageCache)
|
pageCache := new(Hyperspace)
|
||||||
pageCache.topicList.Store([]byte(""))
|
pageCache.topicList.Store([]byte(""))
|
||||||
return pageCache
|
return pageCache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Find a better way of doing this
|
||||||
|
func tickHdrive(args ...interface{}) (skip bool, rerr c.RouteError) {
|
||||||
|
log.Print("Refueling...")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("get", "/topics/", bytes.NewReader(nil))
|
||||||
|
user := c.GuestUser
|
||||||
|
|
||||||
|
head, err := c.UserCheck(w, req, &user)
|
||||||
|
if err != nil {
|
||||||
|
c.LogWarning(err)
|
||||||
|
return true, rerr
|
||||||
|
}
|
||||||
|
|
||||||
|
rerr = routes.TopicList(w, req, user, head)
|
||||||
|
if rerr != nil {
|
||||||
|
c.LogWarning(err)
|
||||||
|
return true, rerr
|
||||||
|
}
|
||||||
|
if w.Code != 200 {
|
||||||
|
c.LogWarning(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
buf.ReadFrom(w.Result().Body)
|
||||||
|
hyperspace.topicList.Store(buf.Bytes())
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jumpHdrive(args ...interface{}) (skip bool, rerr c.RouteError) {
|
||||||
|
tList := hyperspace.topicList.Load().([]byte)
|
||||||
|
if len(tList) == 0 {
|
||||||
|
log.Print("no topiclist in hyperspace")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid intercepting user requests as we only have guests in cache right now
|
||||||
|
user := args[2].(*c.User)
|
||||||
|
if user.ID != 0 {
|
||||||
|
log.Print("not guest")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid intercepting search requests and filters as we don't have those in cache
|
||||||
|
r := args[1].(*http.Request)
|
||||||
|
//log.Print("r.URL.Path:",r.URL.Path)
|
||||||
|
log.Print("r.URL.RawQuery:",r.URL.RawQuery)
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
log.Print("Successful jump")
|
||||||
|
|
||||||
|
w := args[0].(http.ResponseWriter)
|
||||||
|
header := args[3].(*c.Header)
|
||||||
|
routes.FootHeaders(w, header)
|
||||||
|
w.Write(tList)
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/Azareal/Gosora/common"
|
c "github.com/Azareal/Gosora/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -18,17 +18,17 @@ func init() {
|
||||||
if runtime.GOOS != "linux" {
|
if runtime.GOOS != "linux" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.Plugins.Add(&common.Plugin{UName: "sendmail", Name: "Sendmail", Author: "Azareal", URL: "http://github.com/Azareal", Tag: "Linux Only", Init: initSendmail, Activate: activateSendmail, Deactivate: deactivateSendmail})
|
c.Plugins.Add(&c.Plugin{UName: "sendmail", Name: "Sendmail", Author: "Azareal", URL: "http://github.com/Azareal", Tag: "Linux Only", Init: initSendmail, Activate: activateSendmail, Deactivate: deactivateSendmail})
|
||||||
}
|
}
|
||||||
|
|
||||||
func initSendmail(plugin *common.Plugin) error {
|
func initSendmail(plugin *c.Plugin) error {
|
||||||
plugin.AddHook("email_send_intercept", sendSendmail)
|
plugin.AddHook("email_send_intercept", sendSendmail)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// /usr/sbin/sendmail is only available on Linux
|
// /usr/sbin/sendmail is only available on Linux
|
||||||
func activateSendmail(plugin *common.Plugin) error {
|
func activateSendmail(plugin *c.Plugin) error {
|
||||||
if !common.Site.EnableEmails {
|
if !c.Site.EnableEmails {
|
||||||
return errors.New("You have emails disabled in your configuration file")
|
return errors.New("You have emails disabled in your configuration file")
|
||||||
}
|
}
|
||||||
if runtime.GOOS != "linux" {
|
if runtime.GOOS != "linux" {
|
||||||
|
@ -37,7 +37,7 @@ func activateSendmail(plugin *common.Plugin) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivateSendmail(plugin *common.Plugin) {
|
func deactivateSendmail(plugin *c.Plugin) {
|
||||||
plugin.RemoveHook("email_send_intercept", sendSendmail)
|
plugin.RemoveHook("email_send_intercept", sendSendmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,7 +46,7 @@ func sendSendmail(data ...interface{}) interface{} {
|
||||||
subject := data[1].(string)
|
subject := data[1].(string)
|
||||||
body := data[2].(string)
|
body := data[2].(string)
|
||||||
|
|
||||||
msg := "From: " + common.Site.Email + "\n"
|
msg := "From: " + c.Site.Email + "\n"
|
||||||
msg += "To: " + to + "\n"
|
msg += "To: " + to + "\n"
|
||||||
msg += "Subject: " + subject + "\n\n"
|
msg += "Subject: " + subject + "\n\n"
|
||||||
msg += body + "\n"
|
msg += body + "\n"
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/Azareal/Gosora/common"
|
import c "github.com/Azareal/Gosora/common"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
common.Plugins.Add(&common.Plugin{UName: "heythere", Name: "Hey There", Author: "Azareal", URL: "https://github.com/Azareal", Init: initHeythere, Deactivate: deactivateHeythere})
|
c.Plugins.Add(&c.Plugin{UName: "heythere", Name: "Hey There", Author: "Azareal", URL: "https://github.com/Azareal", Init: initHeythere, Deactivate: deactivateHeythere})
|
||||||
}
|
}
|
||||||
|
|
||||||
// init_heythere is separate from init() as we don't want the plugin to run if the plugin is disabled
|
// init_heythere is separate from init() as we don't want the plugin to run if the plugin is disabled
|
||||||
func initHeythere(plugin *common.Plugin) error {
|
func initHeythere(plugin *c.Plugin) error {
|
||||||
plugin.AddHook("topic_reply_row_assign", heythereReply)
|
plugin.AddHook("topic_reply_row_assign", heythereReply)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivateHeythere(plugin *common.Plugin) {
|
func deactivateHeythere(plugin *c.Plugin) {
|
||||||
plugin.RemoveHook("topic_reply_row_assign", heythereReply)
|
plugin.RemoveHook("topic_reply_row_assign", heythereReply)
|
||||||
}
|
}
|
||||||
|
|
||||||
func heythereReply(data ...interface{}) interface{} {
|
func heythereReply(data ...interface{}) interface{} {
|
||||||
currentUser := data[0].(*common.TopicPage).Header.CurrentUser
|
currentUser := data[0].(*c.TopicPage).Header.CurrentUser
|
||||||
reply := data[1].(*common.ReplyUser)
|
reply := data[1].(*c.ReplyUser)
|
||||||
reply.Content = "Hey there, " + currentUser.Name + "!"
|
reply.Content = "Hey there, " + currentUser.Name + "!"
|
||||||
reply.ContentHtml = "Hey there, " + currentUser.Name + "!"
|
reply.ContentHtml = "Hey there, " + currentUser.Name + "!"
|
||||||
reply.Tag = "Auto"
|
reply.Tag = "Auto"
|
||||||
|
|
|
@ -3,7 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Azareal/Gosora/common"
|
c "github.com/Azareal/Gosora/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
var markdownMaxDepth = 25 // How deep the parser will go when parsing Markdown strings
|
var markdownMaxDepth = 25 // How deep the parser will go when parsing Markdown strings
|
||||||
|
@ -23,10 +23,10 @@ var markdownH1TagOpen []byte
|
||||||
var markdownH1TagClose []byte
|
var markdownH1TagClose []byte
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
common.Plugins.Add(&common.Plugin{UName: "markdown", Name: "Markdown", Author: "Azareal", URL: "https://github.com/Azareal", Init: initMarkdown, Deactivate: deactivateMarkdown})
|
c.Plugins.Add(&c.Plugin{UName: "markdown", Name: "Markdown", Author: "Azareal", URL: "https://github.com/Azareal", Init: initMarkdown, Deactivate: deactivateMarkdown})
|
||||||
}
|
}
|
||||||
|
|
||||||
func initMarkdown(plugin *common.Plugin) error {
|
func initMarkdown(plugin *c.Plugin) error {
|
||||||
plugin.AddHook("parse_assign", markdownParse)
|
plugin.AddHook("parse_assign", markdownParse)
|
||||||
|
|
||||||
markdownUnclosedElement = []byte("<red>[Unclosed Element]</red>")
|
markdownUnclosedElement = []byte("<red>[Unclosed Element]</red>")
|
||||||
|
@ -46,7 +46,7 @@ func initMarkdown(plugin *common.Plugin) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivateMarkdown(plugin *common.Plugin) {
|
func deactivateMarkdown(plugin *c.Plugin) {
|
||||||
plugin.RemoveHook("parse_assign", markdownParse)
|
plugin.RemoveHook("parse_assign", markdownParse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ func _markdownParse(msg string, n int) string {
|
||||||
var outbytes []byte
|
var outbytes []byte
|
||||||
var lastElement int
|
var lastElement int
|
||||||
var breaking = false
|
var breaking = false
|
||||||
common.DebugLogf("Initial Message: %+v\n", strings.Replace(msg, "\r", "\\r", -1))
|
c.DebugLogf("Initial Message: %+v\n", strings.Replace(msg, "\r", "\\r", -1))
|
||||||
|
|
||||||
for index := 0; index < len(msg); index++ {
|
for index := 0; index < len(msg); index++ {
|
||||||
var simpleMatch = func(char byte, o []byte, c []byte) {
|
var simpleMatch = func(char byte, o []byte, c []byte) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/Azareal/Gosora/common"
|
import c "github.com/Azareal/Gosora/common"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
/*
|
/*
|
||||||
|
@ -28,12 +28,12 @@ func init() {
|
||||||
|
|
||||||
That Uninstallation field which is currently unused is for not only deactivating this plugin, but for purging any data associated with it such a new tables or data produced by the end-user.
|
That Uninstallation field which is currently unused is for not only deactivating this plugin, but for purging any data associated with it such a new tables or data produced by the end-user.
|
||||||
*/
|
*/
|
||||||
common.Plugins.Add(&common.Plugin{UName: "skeleton", Name: "Skeleton", Author: "Azareal", Init: initSkeleton, Activate: activateSkeleton, Deactivate: deactivateSkeleton})
|
c.Plugins.Add(&c.Plugin{UName: "skeleton", Name: "Skeleton", Author: "Azareal", Init: initSkeleton, Activate: activateSkeleton, Deactivate: deactivateSkeleton})
|
||||||
}
|
}
|
||||||
|
|
||||||
func initSkeleton(plugin *common.Plugin) error { return nil }
|
func initSkeleton(plugin *c.Plugin) error { return nil }
|
||||||
|
|
||||||
// Any errors encountered while trying to activate the plugin are reported back to the admin and the activation is aborted
|
// Any errors encountered while trying to activate the plugin are reported back to the admin and the activation is aborted
|
||||||
func activateSkeleton(plugin *common.Plugin) error { return nil }
|
func activateSkeleton(plugin *c.Plugin) error { return nil }
|
||||||
|
|
||||||
func deactivateSkeleton(plugin *common.Plugin) {}
|
func deactivateSkeleton(plugin *c.Plugin) {}
|
||||||
|
|
|
@ -70,6 +70,7 @@ func doPush(w http.ResponseWriter, header *c.Header) {
|
||||||
var push = func(in []string) {
|
var push = func(in []string) {
|
||||||
for _, path := range in {
|
for _, path := range in {
|
||||||
//fmt.Println("pushing /static/" + path)
|
//fmt.Println("pushing /static/" + path)
|
||||||
|
// TODO: Avoid concatenating here
|
||||||
err := pusher.Push("/static/"+path, nil)
|
err := pusher.Push("/static/"+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
|
@ -96,20 +97,11 @@ func renderTemplate2(tmplName string, hookName string, w http.ResponseWriter, r
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTemplate3(tmplName string, hookName string, w http.ResponseWriter, r *http.Request, header *c.Header, pi interface{}) error {
|
func FootHeaders(w http.ResponseWriter, header *c.Header) {
|
||||||
c.PrepResources(&header.CurrentUser, header, header.Theme)
|
|
||||||
|
|
||||||
if header.CurrentUser.Loggedin {
|
|
||||||
header.MetaDesc = ""
|
|
||||||
header.OGDesc = ""
|
|
||||||
} else if header.MetaDesc != "" && header.OGDesc == "" {
|
|
||||||
header.OGDesc = header.MetaDesc
|
|
||||||
}
|
|
||||||
// TODO: Expand this to non-HTTPS requests too
|
// TODO: Expand this to non-HTTPS requests too
|
||||||
if !header.LooseCSP && c.Site.EnableSsl {
|
if !header.LooseCSP && c.Site.EnableSsl {
|
||||||
w.Header().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;upgrade-insecure-requests")
|
w.Header().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;upgrade-insecure-requests")
|
||||||
}
|
}
|
||||||
header.AddScript("global.js")
|
|
||||||
|
|
||||||
// Server pushes can backfire on certain browsers, so we want to make sure it's only triggered for ones where it'll help
|
// Server pushes can backfire on certain browsers, so we want to make sure it's only triggered for ones where it'll help
|
||||||
lastAgent := header.CurrentUser.LastAgent
|
lastAgent := header.CurrentUser.LastAgent
|
||||||
|
@ -117,7 +109,19 @@ func renderTemplate3(tmplName string, hookName string, w http.ResponseWriter, r
|
||||||
if lastAgent == "chrome" || lastAgent == "firefox" {
|
if lastAgent == "chrome" || lastAgent == "firefox" {
|
||||||
doPush(w, header)
|
doPush(w, header)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTemplate3(tmplName string, hookName string, w http.ResponseWriter, r *http.Request, header *c.Header, pi interface{}) error {
|
||||||
|
c.PrepResources(&header.CurrentUser, header, header.Theme)
|
||||||
|
if header.CurrentUser.Loggedin {
|
||||||
|
header.MetaDesc = ""
|
||||||
|
header.OGDesc = ""
|
||||||
|
} else if header.MetaDesc != "" && header.OGDesc == "" {
|
||||||
|
header.OGDesc = header.MetaDesc
|
||||||
|
}
|
||||||
|
header.AddScript("global.js")
|
||||||
|
|
||||||
|
FootHeaders(w, header)
|
||||||
if header.CurrentUser.IsAdmin {
|
if header.CurrentUser.IsAdmin {
|
||||||
header.Elapsed1 = time.Since(header.StartedAt).String()
|
header.Elapsed1 = time.Since(header.StartedAt).String()
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,10 @@ func wsTopicList(topicList []*c.TopicsRow, lastPage int) *c.WsTopicList {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TopicList(w http.ResponseWriter, r *http.Request, user c.User, header *c.Header) c.RouteError {
|
func TopicList(w http.ResponseWriter, r *http.Request, user c.User, header *c.Header) c.RouteError {
|
||||||
|
skip, rerr := header.Hooks.VhookSkippable("route_topic_list_start", w, r, &user, header)
|
||||||
|
if skip || rerr != nil {
|
||||||
|
return rerr
|
||||||
|
}
|
||||||
return TopicListCommon(w, r, user, header, "lastupdated", "")
|
return TopicListCommon(w, r, user, header, "lastupdated", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,7 +110,7 @@ func TopicListCommon(w http.ResponseWriter, r *http.Request, user c.User, header
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
return c.InternalError(err, w, r)
|
return c.InternalError(err, w, r)
|
||||||
}
|
}
|
||||||
//fmt.Printf("tids %+v\n", tids)
|
//log.Printf("tids %+v\n", tids)
|
||||||
// TODO: Handle the case where there aren't any items...
|
// TODO: Handle the case where there aren't any items...
|
||||||
// TODO: Add a BulkGet method which returns a slice?
|
// TODO: Add a BulkGet method which returns a slice?
|
||||||
tMap, err := c.Topics.BulkGetMap(tids)
|
tMap, err := c.Topics.BulkGetMap(tids)
|
||||||
|
|
Loading…
Reference in New Issue