Added support for password resets.

Sha256 hashes are now stored in the SFile structures, this will come of use later.
Rows should be properly closed in DefaultTopicStore.BulkGetMap.
All errors should be properly reported now in DefaultTopicStore.BulkGetMap.
Rows should be properly closed in DefaultUserStore.BulkGetMap.
All errors should be properly reported now in DefaultUserStore.BulkGetMap.
Don't have an account on the login page should now be linkified.
Renamed tempra-simple to tempra_simple to avoid breaking the template transpiler.
Fixed up bits and pieces of login.html on every theme.
Removed an old commented code chunk from template_init.go
widget_wol widgets should now get minified.
bindToAlerts() should now unbind the alert items before attempting to bind to them.
Tweaked the SendValidationEmail phrase.
Removed a layer of indentation from DefaultAuth.ValidateMFAToken and added the ErrNoMFAToken error for when MFA isn't setup on the specified account.
Email validation now uses a constant time compare to mitigate certain classes of timing attacks.

Added the /accounts/password-reset/ route.
Added the /accounts/password-reset/submit/ route.
Added the /accounts/password-reset/token/ route.
Added the /accounts/password-reset/token/submit/ route.
Added the password_resets table.

Added the password_reset_email_fail phrase.
Added the password_reset phrase.
Added the password_reset_token phrase.
Added the password_reset_email_sent phrase.
Added the password_reset_token_token_verified phrase.
Added the login_forgot_password phrase.
Added the password_reset_head phrase.
Added the password_reset_username phrase.
Added the password_reset_button phrase.
Added the password_reset_subject phrase.
Added the password_reset_body phrase.
Added the password_reset_token_head phrase.
Added the password_reset_token_password phrase.
Added the password_reset_token_confirm_password phrase.
Added the password_reset_mfa_token phrase.
Added the password_reset_token_button phrase.

You will need to run the updater or patcher for this commit.
This commit is contained in:
Azareal 2019-03-11 18:47:45 +10:00
parent 93b292acc0
commit e22ddfec40
42 changed files with 634 additions and 173 deletions

View File

@ -166,17 +166,15 @@ func createTables(adapter qgen.Adapter) error {
*/
// TODO: Implement password resets
/*qgen.Install.CreateTable("password_resets", "", "",
qgen.Install.CreateTable("password_resets", "", "",
[]tblColumn{
tblColumn{"email", "varchar", 200, false, false, ""},
tblColumn{"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key
tblColumn{"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key
tblColumn{"validated", "varchar", 200, false, false, ""}, // Token given once the one-use token is consumed, used to prevent multiple people consuming the same one-use token
tblColumn{"token", "varchar", 200, false, false, ""},
},
[]tblKey{
tblKey{"email", "unique"},
},
)*/
tblColumn{"createdAt", "createdAt", 0, false, false, ""},
}, nil,
)
qgen.Install.CreateTable("forums", mysqlPre, mysqlCol,
[]tblColumn{

View File

@ -16,8 +16,9 @@ import (
"strconv"
"strings"
"github.com/Azareal/Gosora/query_gen"
"github.com/Azareal/Gosora/common/gauth"
"github.com/Azareal/Gosora/query_gen"
//"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)
@ -40,6 +41,7 @@ var ErrPasswordTooLong = errors.New("The password you selected is too long")
var ErrWrongPassword = errors.New("That's not the correct password.")
var ErrBadMFAToken = errors.New("I'm not sure where you got that from, but that's not a valid 2FA token")
var ErrWrongMFAToken = errors.New("That 2FA token isn't correct")
var ErrNoMFAToken = errors.New("This user doesn't have 2FA setup")
var ErrSecretError = errors.New("There was a glitch in the system. Please contact your local administrator.")
var ErrNoUserByName = errors.New("We couldn't find an account with that username.")
var DefaultHashAlgo = "bcrypt" // Override this in the configuration file, not here
@ -132,23 +134,25 @@ func (auth *DefaultAuth) ValidateMFAToken(mfaToken string, uid int) error {
LogError(err)
return ErrSecretError
}
if err != ErrNoRows {
ok, err := VerifyGAuthToken(mfaItem.Secret, mfaToken)
if err != nil {
return ErrBadMFAToken
}
if ok {
return nil
}
for i, scratch := range mfaItem.Scratch {
if subtle.ConstantTimeCompare([]byte(scratch), []byte(mfaToken)) == 1 {
err = mfaItem.BurnScratch(i)
if err != nil {
LogError(err)
return ErrSecretError
}
return nil
if err == ErrNoRows {
return ErrNoMFAToken
}
ok, err := VerifyGAuthToken(mfaItem.Secret, mfaToken)
if err != nil {
return ErrBadMFAToken
}
if ok {
return nil
}
for i, scratch := range mfaItem.Scratch {
if subtle.ConstantTimeCompare([]byte(scratch), []byte(mfaToken)) == 1 {
err = mfaItem.BurnScratch(i)
if err != nil {
LogError(err)
return ErrSecretError
}
return nil
}
}
return ErrWrongMFAToken

View File

@ -23,7 +23,7 @@ func SendValidationEmail(username string, email string, token string) error {
// TODO: Move these to the phrase system
subject := "Validate Your Email - " + Site.Name
msg := "Dear " + username + ", following your registration on our forums, we ask you to validate your email, so that we can confirm that this email actually belongs to you.\n\nClick on the following link to do so. " + schema + "://" + Site.URL + "/user/edit/token/" + token + "\n\nIf you haven't created an account here, then please feel free to ignore this email.\nWe're sorry for the inconvenience this may have caused."
msg := "Dear " + username + ", to complete your registration on our forums, we need you to validate your email, so that we can confirm that this email actually belongs to you.\n\nClick on the following link to do so. " + schema + "://" + Site.URL + "/user/edit/token/" + token + "\n\nIf you haven't created an account here, then please feel free to ignore this email.\nWe're sorry for the inconvenience this may have caused."
return SendEmail(email, subject, msg)
}

View File

@ -3,6 +3,8 @@ package common
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io/ioutil"
@ -25,6 +27,7 @@ var staticFileMutex sync.RWMutex
type SFile struct {
Data []byte
GzipData []byte
Sha256 []byte
Pos int64
Length int64
GzipLength int64
@ -234,7 +237,12 @@ func (list SFileList) JSTmplInit() error {
return err
}
list.Set("/static/"+path, SFile{data, gzipData, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
// Get a checksum for CSPs and cache busting
hasher := sha256.New()
hasher.Write(data)
checksum := []byte(hex.EncodeToString(hasher.Sum(nil)))
list.Set("/static/"+path, SFile{data, gzipData, checksum, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
DebugLogf("Added the '%s' static file.", path)
return nil
@ -256,6 +264,11 @@ func (list SFileList) Init() error {
var ext = filepath.Ext("/public/" + path)
mimetype := mime.TypeByExtension(ext)
// Get a checksum for CSPs and cache busting
hasher := sha256.New()
hasher.Write(data)
checksum := []byte(hex.EncodeToString(hasher.Sum(nil)))
// Avoid double-compressing images
var gzipData []byte
if mimetype != "image/jpeg" && mimetype != "image/png" && mimetype != "image/gif" {
@ -274,7 +287,7 @@ func (list SFileList) Init() error {
}
}
list.Set("/static/"+path, SFile{data, gzipData, 0, int64(len(data)), int64(len(gzipData)), mimetype, f, f.ModTime().UTC().Format(http.TimeFormat)})
list.Set("/static/"+path, SFile{data, gzipData, checksum, 0, int64(len(data)), int64(len(gzipData)), mimetype, f, f.ModTime().UTC().Format(http.TimeFormat)})
DebugLogf("Added the '%s' static file.", path)
return nil
@ -302,7 +315,12 @@ func (list SFileList) Add(path string, prefix string) error {
return err
}
list.Set("/static"+path, SFile{data, gzipData, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
// Get a checksum for CSPs and cache busting
hasher := sha256.New()
hasher.Write(data)
checksum := []byte(hex.EncodeToString(hasher.Sum(nil)))
list.Set("/static"+path, SFile{data, gzipData, checksum, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
DebugLogf("Added the '%s' static file", path)
return nil

View File

@ -203,6 +203,13 @@ type LevelListPage struct {
Levels []LevelListItem
}
type ResetPage struct {
*Header
UID int
Token string
MFA bool
}
type PanelStats struct {
Users int
Groups int

65
common/password_reset.go Normal file
View File

@ -0,0 +1,65 @@
package common
import (
"crypto/subtle"
"database/sql"
"errors"
"github.com/Azareal/Gosora/query_gen"
)
var PasswordResetter *DefaultPasswordResetter
var ErrBadResetToken = errors.New("This reset token has expired.")
type DefaultPasswordResetter struct {
getTokens *sql.Stmt
create *sql.Stmt
delete *sql.Stmt
}
func NewDefaultPasswordResetter(acc *qgen.Accumulator) (*DefaultPasswordResetter, error) {
return &DefaultPasswordResetter{
getTokens: acc.Select("password_resets").Columns("token").Where("uid = ?").Prepare(),
create: acc.Insert("password_resets").Columns("email, uid, validated, token, createdAt").Fields("?,?,0,?,UTC_TIMESTAMP()").Prepare(),
delete: acc.Delete("password_resets").Where("uid =?").Prepare(),
}, acc.FirstError()
}
func (r *DefaultPasswordResetter) Create(email string, uid int, token string) error {
_, err := r.create.Exec(email, uid, token)
return err
}
func (r *DefaultPasswordResetter) FlushTokens(uid int) error {
_, err := r.delete.Exec(uid)
return err
}
func (r *DefaultPasswordResetter) ValidateToken(uid int, token string) error {
rows, err := r.getTokens.Query(uid)
if err != nil {
return err
}
defer rows.Close()
var success = false
for rows.Next() {
var rtoken string
err := rows.Scan(&rtoken)
if err != nil {
return err
}
if subtle.ConstantTimeCompare([]byte(token), []byte(rtoken)) == 1 {
success = true
}
}
err = rows.Err()
if err != nil {
return err
}
if !success {
return ErrBadResetToken
}
return nil
}

View File

@ -508,119 +508,12 @@ func compileJSTemplates(wg *sync.WaitGroup, c *tmpl.CTemplateSet, themeName stri
}
writeTemplate("alert", alertTmpl)
/*//writeTemplate("forum", forumTmpl)
writeTemplate("topics_topic", topicListItemTmpl)
writeTemplate("topic_posts", topicPostsTmpl)
writeTemplate("topic_alt_posts", topicAltPostsTmpl)
writeTemplate("paginator", paginatorTmpl)
//writeTemplate("panel_themes_widgets_widget", panelWidgetsWidgetTmpl)
writeTemplateList(c, &wg, dirPrefix)*/
return nil
}
/*func CompileJSTemplates() error {
log.Print("Compiling the JS templates")
var config tmpl.CTemplateConfig
config.Minify = Config.MinifyTemplates
config.Debug = Dev.DebugMode
config.SuperDebug = Dev.TemplateDebug
config.SkipHandles = true
config.SkipTmplPtrMap = true
config.SkipInitBlock = false
config.PackageName = "tmpl"
c := tmpl.NewCTemplateSet()
c.SetConfig(config)
c.SetBaseImportMap(map[string]string{
"io": "io",
"github.com/Azareal/Gosora/common/alerts": "github.com/Azareal/Gosora/common/alerts",
})
c.SetBuildTags("!no_templategen")
user, user2, user3 := tmplInitUsers()
header, _, _ := tmplInitHeaders(user, user2, user3)
now := time.Now()
var varList = make(map[string]tmpl.VarItem)
// TODO: Check what sort of path is sent exactly and use it here
alertItem := alerts.AlertItem{Avatar: "", ASID: 1, Path: "/", Message: "uh oh, something happened"}
alertTmpl, err := c.Compile("alert.html", "templates/", "alerts.AlertItem", alertItem, varList)
if err != nil {
return err
}
c.SetBaseImportMap(map[string]string{
"io": "io",
"github.com/Azareal/Gosora/common": "github.com/Azareal/Gosora/common",
})
// TODO: Fix the import loop so we don't have to use this hack anymore
c.SetBuildTags("!no_templategen,tmplgentopic")
var topicsRow = &TopicsRow{1, "topic-title", "Topic Title", "The topic content.", 1, false, false, now, now, user3.ID, 1, 1, "", "127.0.0.1", 1, 0, 1, 0, 1, "classname", "", &user2, "", 0, &user3, "General", "/forum/general.2"}
topicListItemTmpl, err := c.Compile("topics_topic.html", "templates/", "*common.TopicsRow", topicsRow, varList)
if err != nil {
return err
}
poll := Poll{ID: 1, Type: 0, Options: map[int]string{0: "Nothing", 1: "Something"}, Results: map[int]int{0: 5, 1: 2}, QuickOptions: []PollOption{
PollOption{0, "Nothing"},
PollOption{1, "Something"},
}, VoteCount: 7}
avatar, microAvatar := BuildAvatar(62, "")
miniAttach := []*MiniAttachment{&MiniAttachment{Path: "/"}}
topic := TopicUser{1, "blah", "Blah", "Hey there!", 62, false, false, now, now, 1, 1, 0, "", "127.0.0.1", 1, 0, 1, 0, "classname", poll.ID, "weird-data", BuildProfileURL("fake-user", 62), "Fake User", Config.DefaultGroup, avatar, microAvatar, 0, "", "", "", "", "", 58, false, miniAttach}
var replyList []ReplyUser
// TODO: Do we really want the UID here to be zero?
avatar, microAvatar = BuildAvatar(0, "")
replyList = append(replyList, ReplyUser{0, 0, "Yo!", "Yo!", 0, "alice", "Alice", Config.DefaultGroup, now, 0, 0, avatar, microAvatar, "", 0, "", "", "", "", 0, "127.0.0.1", false, 1, 1, "", "", miniAttach})
varList = make(map[string]tmpl.VarItem)
header.Title = "Topic Name"
tpage := TopicPage{header, replyList, topic, &Forum{ID: 1, Name: "Hahaha"}, poll, Paginator{[]int{1}, 1, 1}}
tpage.Forum.Link = BuildForumURL(NameToSlug(tpage.Forum.Name), tpage.Forum.ID)
topicPostsTmpl, err := c.Compile("topic_posts.html", "templates/", "common.TopicPage", tpage, varList)
if err != nil {
return err
}
topicAltPostsTmpl, err := c.Compile("topic_alt_posts.html", "templates/", "common.TopicPage", tpage, varList)
if err != nil {
return err
}
itemsPerPage := 25
_, page, lastPage := PageOffset(20, 1, itemsPerPage)
pageList := Paginate(20, itemsPerPage, 5)
paginatorTmpl, err := c.Compile("paginator.html", "templates/", "common.Paginator", Paginator{pageList, page, lastPage}, varList)
if err != nil {
return err
}
var dirPrefix = "./tmpl_client/"
var wg sync.WaitGroup
var writeTemplate = func(name string, content string) {
log.Print("Writing template '" + name + "'")
if content == "" {
return //log.Fatal("No content body")
}
wg.Add(1)
go func() {
err := writeFile(dirPrefix+"template_"+name+".go", content)
if err != nil {
log.Fatal(err)
}
wg.Done()
}()
}
writeTemplate("alert", alertTmpl)
//writeTemplate("forum", forumTmpl)
writeTemplate("topics_topic", topicListItemTmpl)
writeTemplate("topic_posts", topicPostsTmpl)
writeTemplate("topic_alt_posts", topicAltPostsTmpl)
writeTemplate("paginator", paginatorTmpl)
//writeTemplate("panel_themes_widgets_widget", panelWidgetsWidgetTmpl)
writeTemplateList(c, &wg, dirPrefix)
return nil
}*/
func getTemplateList(c *tmpl.CTemplateSet, wg *sync.WaitGroup, prefix string) string {
DebugLog("in getTemplateList")
pout := "\n// nolint\nfunc init() {\n"

View File

@ -3,7 +3,9 @@ package common
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
htmpl "html/template"
"io"
@ -157,7 +159,12 @@ func (theme *Theme) AddThemeStaticFiles() error {
return err
}
StaticFiles.Set("/static/"+theme.Name+path, SFile{data, gzipData, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
// Get a checksum for CSPs and cache busting
hasher := sha256.New()
hasher.Write(data)
checksum := []byte(hex.EncodeToString(hasher.Sum(nil)))
StaticFiles.Set("/static/"+theme.Name+path, SFile{data, gzipData, checksum, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})
DebugLog("Added the '/" + theme.Name + path + "' static file for theme " + theme.Name + ".")
return nil

View File

@ -152,6 +152,8 @@ func (s *DefaultTopicStore) BulkGetMap(ids []int) (list map[int]*Topic, err erro
if err != nil {
return list, err
}
defer rows.Close()
for rows.Next() {
topic := &Topic{}
err := rows.Scan(&topic.ID, &topic.Title, &topic.Content, &topic.CreatedBy, &topic.CreatedAt, &topic.LastReplyBy, &topic.LastReplyAt, &topic.LastReplyID, &topic.IsClosed, &topic.Sticky, &topic.ParentID, &topic.IPAddress, &topic.ViewCount, &topic.PostCount, &topic.LikeCount, &topic.AttachCount, &topic.Poll, &topic.Data)
@ -162,6 +164,10 @@ func (s *DefaultTopicStore) BulkGetMap(ids []int) (list map[int]*Topic, err erro
s.cache.Set(topic)
list[topic.ID] = topic
}
err = rows.Err()
if err != nil {
return list, err
}
// Did we miss any topics?
if idCount > len(list) {

View File

@ -186,6 +186,8 @@ func (mus *DefaultUserStore) BulkGetMap(ids []int) (list map[int]*User, err erro
if err != nil {
return list, err
}
defer rows.Close()
for rows.Next() {
user := &User{Loggedin: true}
err := rows.Scan(&user.ID, &user.Name, &user.Group, &user.Active, &user.IsSuperAdmin, &user.Session, &user.Email, &user.RawAvatar, &user.Message, &user.URLPrefix, &user.URLName, &user.Level, &user.Score, &user.Liked, &user.LastIP, &user.TempGroup)
@ -196,6 +198,10 @@ func (mus *DefaultUserStore) BulkGetMap(ids []int) (list map[int]*User, err erro
mus.cache.Set(user)
list[user.ID] = user
}
err = rows.Err()
if err != nil {
return list, err
}
// Did we miss any users?
if idCount > len(list) {

View File

@ -5,6 +5,7 @@ import (
"net/http/httptest"
"github.com/Azareal/Gosora/common/phrases"
min "github.com/Azareal/Gosora/common/templates"
)
type wolUsers struct {
@ -53,6 +54,10 @@ func wolTick(widget *Widget) error {
}
buf := new(bytes.Buffer)
buf.ReadFrom(w.Result().Body)
widget.TickMask.Store(buf.String())
bs := buf.String()
if Config.MinifyTemplates {
bs = min.Minify(bs)
}
widget.TickMask.Store(bs)
return nil
}

View File

@ -154,6 +154,10 @@ var RouteMap = map[string]interface{}{
"routes.AccountLoginMFAVerify": routes.AccountLoginMFAVerify,
"routes.AccountLoginMFAVerifySubmit": routes.AccountLoginMFAVerifySubmit,
"routes.AccountRegisterSubmit": routes.AccountRegisterSubmit,
"routes.AccountPasswordReset": routes.AccountPasswordReset,
"routes.AccountPasswordResetSubmit": routes.AccountPasswordResetSubmit,
"routes.AccountPasswordResetToken": routes.AccountPasswordResetToken,
"routes.AccountPasswordResetTokenSubmit": routes.AccountPasswordResetTokenSubmit,
"routes.DynamicRoute": routes.DynamicRoute,
"routes.UploadedFile": routes.UploadedFile,
"routes.StaticFile": routes.StaticFile,
@ -295,12 +299,16 @@ var routeMapEnum = map[string]int{
"routes.AccountLoginMFAVerify": 128,
"routes.AccountLoginMFAVerifySubmit": 129,
"routes.AccountRegisterSubmit": 130,
"routes.DynamicRoute": 131,
"routes.UploadedFile": 132,
"routes.StaticFile": 133,
"routes.RobotsTxt": 134,
"routes.SitemapXml": 135,
"routes.BadRoute": 136,
"routes.AccountPasswordReset": 131,
"routes.AccountPasswordResetSubmit": 132,
"routes.AccountPasswordResetToken": 133,
"routes.AccountPasswordResetTokenSubmit": 134,
"routes.DynamicRoute": 135,
"routes.UploadedFile": 136,
"routes.StaticFile": 137,
"routes.RobotsTxt": 138,
"routes.SitemapXml": 139,
"routes.BadRoute": 140,
}
var reverseRouteMapEnum = map[int]string{
0: "routes.Overview",
@ -434,12 +442,16 @@ var reverseRouteMapEnum = map[int]string{
128: "routes.AccountLoginMFAVerify",
129: "routes.AccountLoginMFAVerifySubmit",
130: "routes.AccountRegisterSubmit",
131: "routes.DynamicRoute",
132: "routes.UploadedFile",
133: "routes.StaticFile",
134: "routes.RobotsTxt",
135: "routes.SitemapXml",
136: "routes.BadRoute",
131: "routes.AccountPasswordReset",
132: "routes.AccountPasswordResetSubmit",
133: "routes.AccountPasswordResetToken",
134: "routes.AccountPasswordResetTokenSubmit",
135: "routes.DynamicRoute",
136: "routes.UploadedFile",
137: "routes.StaticFile",
138: "routes.RobotsTxt",
139: "routes.SitemapXml",
140: "routes.BadRoute",
}
var osMapEnum = map[string]int{
"unknown": 0,
@ -738,7 +750,7 @@ func (r *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
counters.GlobalViewCounter.Bump()
if prefix == "/static" {
counters.RouteViewCounter.Bump(133)
counters.RouteViewCounter.Bump(137)
req.URL.Path += extraData
routes.StaticFile(w, req)
return
@ -2085,6 +2097,36 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c
counters.RouteViewCounter.Bump(130)
err = routes.AccountRegisterSubmit(w,req,user)
case "/accounts/password-reset/":
counters.RouteViewCounter.Bump(131)
head, err := common.UserCheck(w,req,&user)
if err != nil {
return err
}
err = routes.AccountPasswordReset(w,req,user,head)
case "/accounts/password-reset/submit/":
err = common.ParseForm(w,req,user)
if err != nil {
return err
}
counters.RouteViewCounter.Bump(132)
err = routes.AccountPasswordResetSubmit(w,req,user)
case "/accounts/password-reset/token/":
counters.RouteViewCounter.Bump(133)
head, err := common.UserCheck(w,req,&user)
if err != nil {
return err
}
err = routes.AccountPasswordResetToken(w,req,user,head)
case "/accounts/password-reset/token/submit/":
err = common.ParseForm(w,req,user)
if err != nil {
return err
}
counters.RouteViewCounter.Bump(134)
err = routes.AccountPasswordResetTokenSubmit(w,req,user)
}
/*case "/sitemaps": // TODO: Count these views
req.URL.Path += extraData
@ -2099,7 +2141,7 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c
w.Header().Del("Content-Type")
w.Header().Del("Content-Encoding")
}
counters.RouteViewCounter.Bump(132)
counters.RouteViewCounter.Bump(136)
req.URL.Path += extraData
// TODO: Find a way to propagate errors up from this?
r.UploadHandler(w,req) // TODO: Count these views
@ -2109,7 +2151,7 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c
// TODO: Add support for favicons and robots.txt files
switch(extraData) {
case "robots.txt":
counters.RouteViewCounter.Bump(134)
counters.RouteViewCounter.Bump(138)
return routes.RobotsTxt(w,req)
case "favicon.ico":
req.URL.Path = "/static"+req.URL.Path+extraData
@ -2117,7 +2159,7 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c
routes.StaticFile(w,req)
return nil
/*case "sitemap.xml":
counters.RouteViewCounter.Bump(135)
counters.RouteViewCounter.Bump(139)
return routes.SitemapXml(w,req)*/
}
return common.NotFound(w,req,nil)
@ -2128,7 +2170,7 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c
r.RUnlock()
if ok {
counters.RouteViewCounter.Bump(131) // TODO: Be more specific about *which* dynamic route it is
counters.RouteViewCounter.Bump(135) // TODO: Be more specific about *which* dynamic route it is
req.URL.Path += extraData
return handle(w,req,user)
}
@ -2139,7 +2181,7 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c
} else {
r.DumpRequest(req,"Bad Route")
}
counters.RouteViewCounter.Bump(136)
counters.RouteViewCounter.Bump(140)
return common.NotFound(w,req,nil)
}
return err

View File

@ -100,6 +100,8 @@
"register_username_too_long_prefix":"The username is too long, max: ",
"register_email_fail":"We were unable to send the email for you to confirm that this email address belongs to you. You may not have access to some functionality until you do so. Please ask an administrator for assistance.",
"password_reset_email_fail":"We were unable to send a password reset email to this user.",
"alerts_no_actor":"Unable to find the actor",
"alerts_no_target_user":"Unable to find the target user",
"alerts_no_linked_topic":"Unable to find the linked topic",
@ -128,6 +130,8 @@
"login":"Login",
"login_mfa_verify":"2FA Verify",
"register":"Registration",
"password_reset":"Password Reset",
"password_reset_token":"Password Reset",
"ip_search":"IP Search",
"profile": "%s's Profile",
"account":"My Account",
@ -305,6 +309,8 @@
"account_mail_disabled":"The mail system is currently disabled.",
"account_mail_verify_success":"Your email was successfully verified.",
"account_mfa_setup_success":"Two-factor authentication was successfully setup for your account.",
"password_reset_email_sent":"An email was sent to you. Please follow the steps within.",
"password_reset_token_token_verified":"Your password was successfully updated.",
"panel_forum_created":"The forum was successfully created.",
"panel_forum_deleted":"The forum was successfully deleted.",
@ -446,6 +452,7 @@
"login_account_password":"Password",
"login_submit_button":"Login",
"login_no_account":"Don't have an account?",
"login_forgot_password":"Forgot your password?",
"login_mfa_verify_head":"2FA Verify",
"login_mfa_verify_explanation":"Please input the code from the authenticator app below.",
@ -460,6 +467,18 @@
"register_account_anti_spam":"Are you a spambot?",
"register_submit_button":"Create Account",
"password_reset_head":"Password Reset",
"password_reset_username":"Account Name",
"password_reset_button":"Send Email",
"password_reset_subject":"Reset your email",
"password_reset_body":"Dear %s, someone has requested that your password be reset. If this was you, then please click on the following link to do so, otherwise disregard this email.\n\n %s",
"password_reset_token_head":"Password Reset",
"password_reset_token_password":"New Password",
"password_reset_token_confirm_password":"Confirm Password",
"password_reset_mfa_token":"2FA Token",
"password_reset_token_button":"Update Account",
"account_menu_head":"My Account",
"account_menu_password":"Password",
"account_menu_email":"Email",

View File

@ -165,6 +165,10 @@ func afterDBInit() (err error) {
if err != nil {
return errors.WithStack(err)
}
common.PasswordResetter, err = common.NewDefaultPasswordResetter(acc)
if err != nil {
return errors.WithStack(err)
}
// TODO: Let the admin choose other thumbnailers, maybe ones defined in plugins
common.Thumbnailer = common.NewCaireThumbnailer()

View File

@ -27,6 +27,7 @@ func init() {
addPatch(13, patch13)
addPatch(14, patch14)
addPatch(15, patch15)
addPatch(16, patch16)
}
func patch0(scanner *bufio.Scanner) (err error) {
@ -537,3 +538,15 @@ func patch14(scanner *bufio.Scanner) error {
func patch15(scanner *bufio.Scanner) error {
return execStmt(qgen.Builder.SimpleInsert("settings", "name, content, type", "'google_site_verify','','html-attribute'"))
}
func patch16(scanner *bufio.Scanner) error {
return execStmt(qgen.Builder.CreateTable("password_resets", "", "",
[]tblColumn{
tblColumn{"email", "varchar", 200, false, false, ""},
tblColumn{"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key
tblColumn{"validated", "varchar", 200, false, false, ""}, // Token given once the one-use token is consumed, used to prevent multiple people consuming the same one-use token
tblColumn{"token", "varchar", 200, false, false, ""},
tblColumn{"createdAt", "createdAt", 0, false, false, ""},
}, nil,
))
}

View File

@ -30,6 +30,7 @@ function postLink(event) {
}
function bindToAlerts() {
$(".alertItem.withAvatar a").unbind("click");
$(".alertItem.withAvatar a").click(function(event) {
event.stopPropagation();
$.ajax({ url: "/api/?action=set&module=dismiss-alert", type: "POST", dataType: "json", error: ajaxError, data: { asid: $(this).attr("data-asid") } });

View File

@ -65,6 +65,7 @@ func userRoutes() *RouteGroup {
MemberView("routes.LevelList", "/user/levels/"),
//MemberView("routes.LevelRankings", "/user/rankings/"),
//MemberView("routes.Alerts", "/user/alerts/"),
)
}
@ -135,8 +136,11 @@ func accountRoutes() *RouteGroup {
View("routes.AccountLoginMFAVerify", "/accounts/mfa_verify/"),
AnonAction("routes.AccountLoginMFAVerifySubmit", "/accounts/mfa_verify/submit/"), // We have logic in here which filters out regular guests
AnonAction("routes.AccountRegisterSubmit", "/accounts/create/submit/"),
//View("routes.AccountPasswordReset", "/accounts/password-reset/"),
//AnonAction("routes.AccountPasswordResetSubmit", "/accounts/password-reset/submit/"),
View("routes.AccountPasswordReset", "/accounts/password-reset/"),
AnonAction("routes.AccountPasswordResetSubmit", "/accounts/password-reset/submit/"),
View("routes.AccountPasswordResetToken", "/accounts/password-reset/token/"),
AnonAction("routes.AccountPasswordResetTokenSubmit", "/accounts/password-reset/token/submit/"),
)
}

View File

@ -5,6 +5,7 @@ import (
"crypto/subtle"
"database/sql"
"encoding/hex"
"html"
"io"
"log"
"math"
@ -424,7 +425,7 @@ func AccountEditPasswordSubmit(w http.ResponseWriter, r *http.Request, user comm
if newPassword != confirmPassword {
return common.LocalError("The two passwords don't match.", w, r, user)
}
common.SetPassword(user.ID, newPassword)
common.SetPassword(user.ID, newPassword) // TODO: Limited version of WeakPassword()
// Log the user out as a safety precaution
common.Auth.ForceLogout(user.ID)
@ -693,7 +694,7 @@ func AccountEditEmailTokenSubmit(w http.ResponseWriter, r *http.Request, user co
return common.LocalError("You are not logged in", w, r, user)
}
for _, email := range emails {
if email.Token == token {
if subtle.ConstantTimeCompare([]byte(email.Token), []byte(token)) == 1 {
targetEmail = email
}
}
@ -761,3 +762,154 @@ func LevelList(w http.ResponseWriter, r *http.Request, user common.User, header
pi := common.LevelListPage{header, levels[1:]}
return renderTemplate("level_list", w, r, header, pi)
}
func Alerts(w http.ResponseWriter, r *http.Request, user common.User, header *common.Header) common.RouteError {
return nil
}
func AccountPasswordReset(w http.ResponseWriter, r *http.Request, user common.User, header *common.Header) common.RouteError {
if user.Loggedin {
return common.LocalError("You're already logged in.", w, r, user)
}
if !common.Site.EnableEmails {
return common.LocalError(phrases.GetNoticePhrase("account_mail_disabled"), w, r, user)
}
if r.FormValue("email_sent") == "1" {
header.AddNotice("password_reset_email_sent")
}
header.Title = phrases.GetTitlePhrase("password_reset")
pi := common.Page{header, tList, nil}
return renderTemplate("password_reset", w, r, header, pi)
}
// TODO: Ratelimit this
func AccountPasswordResetSubmit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError {
if user.Loggedin {
return common.LocalError("You're already logged in.", w, r, user)
}
if !common.Site.EnableEmails {
return common.LocalError(phrases.GetNoticePhrase("account_mail_disabled"), w, r, user)
}
username := r.PostFormValue("username")
tuser, err := common.Users.GetByName(username)
if err == sql.ErrNoRows {
// Someone trying to stir up trouble?
http.Redirect(w, r, "/accounts/password-reset/?email_sent=1", http.StatusSeeOther)
return nil
} else if err != nil {
return common.InternalError(err, w, r)
}
token, err := common.GenerateSafeString(80)
if err != nil {
return common.InternalError(err, w, r)
}
// TODO: Move this query somewhere else
var disc string
err = qgen.NewAcc().Select("password_resets").Columns("createdAt").DateCutoff("createdAt", 1, "hour").QueryRow().Scan(&disc)
if err != nil && err != sql.ErrNoRows {
return common.InternalError(err, w, r)
}
if err == nil {
return common.LocalError("You can only send a password reset email for a user once an hour", w, r, user)
}
err = common.PasswordResetter.Create(tuser.Email, tuser.ID, token)
if err != nil {
return common.InternalError(err, w, r)
}
var schema string
if common.Site.EnableSsl {
schema = "s"
}
err = common.SendEmail(tuser.Email, phrases.GetTmplPhrase("password_reset_subject"), phrases.GetTmplPhrasef("password_reset_body", tuser.Name, "http"+schema+"://"+common.Site.URL+"/accounts/password-reset/token/?uid="+strconv.Itoa(tuser.ID)+"&token="+token))
if err != nil {
return common.LocalError(phrases.GetErrorPhrase("password_reset_email_fail"), w, r, user)
}
http.Redirect(w, r, "/accounts/password-reset/?email_sent=1", http.StatusSeeOther)
return nil
}
func AccountPasswordResetToken(w http.ResponseWriter, r *http.Request, user common.User, header *common.Header) common.RouteError {
if user.Loggedin {
return common.LocalError("You're already logged in.", w, r, user)
}
// TODO: Find a way to flash this notice
/*if r.FormValue("token_verified") == "1" {
header.AddNotice("password_reset_token_token_verified")
}*/
token := r.FormValue("token")
uid, err := strconv.Atoi(r.FormValue("uid"))
if err != nil {
return common.LocalError("Invalid uid", w, r, user)
}
err = common.PasswordResetter.ValidateToken(uid, token)
if err == sql.ErrNoRows || err == common.ErrBadResetToken {
return common.LocalError("This reset token has expired.", w, r, user)
} else if err != nil {
return common.InternalError(err, w, r)
}
_, err = common.MFAstore.Get(uid)
if err != sql.ErrNoRows && err != nil {
return common.InternalError(err, w, r)
}
mfa := err != sql.ErrNoRows
header.Title = phrases.GetTitlePhrase("password_reset_token")
return renderTemplate("password_reset_token", w, r, header, common.ResetPage{header, uid, html.EscapeString(token), mfa})
}
func AccountPasswordResetTokenSubmit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError {
if user.Loggedin {
return common.LocalError("You're already logged in.", w, r, user)
}
token := r.FormValue("token")
uid, err := strconv.Atoi(r.FormValue("uid"))
if err != nil {
return common.LocalError("Invalid uid", w, r, user)
}
if !common.Users.Exists(uid) {
return common.LocalError("This reset token has expired.", w, r, user)
}
err = common.PasswordResetter.ValidateToken(uid, token)
if err == sql.ErrNoRows || err == common.ErrBadResetToken {
return common.LocalError("This reset token has expired.", w, r, user)
} else if err != nil {
return common.InternalError(err, w, r)
}
mfaToken := r.PostFormValue("mfa_token")
err = common.Auth.ValidateMFAToken(mfaToken, uid)
if err != nil && err != common.ErrNoMFAToken {
return common.LocalError(err.Error(), w, r, user)
}
newPassword := r.PostFormValue("password")
confirmPassword := r.PostFormValue("confirm_password")
if newPassword != confirmPassword {
return common.LocalError("The two passwords don't match.", w, r, user)
}
common.SetPassword(uid, newPassword) // TODO: Limited version of WeakPassword()
err = common.PasswordResetter.FlushTokens(uid)
if err != nil {
return common.InternalError(err, w, r)
}
// Log the user out as a safety precaution
common.Auth.ForceLogout(uid)
//http.Redirect(w, r, "/accounts/password-reset/token/?token_verified=1", http.StatusSeeOther)
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}

View File

@ -0,0 +1,7 @@
CREATE TABLE [password_resets] (
[email] nvarchar (200) not null,
[uid] int not null,
[validated] nvarchar (200) not null,
[token] nvarchar (200) not null,
[createdAt] datetime not null
);

View File

@ -0,0 +1,7 @@
CREATE TABLE `password_resets` (
`email` varchar(200) not null,
`uid` int not null,
`validated` varchar(200) not null,
`token` varchar(200) not null,
`createdAt` datetime not null
);

View File

@ -0,0 +1,7 @@
CREATE TABLE "password_resets" (
`email` varchar (200) not null,
`uid` int not null,
`validated` varchar (200) not null,
`token` varchar (200) not null,
`createdAt` timestamp not null
);

View File

@ -15,7 +15,12 @@
</div>
<div class="formrow login_button_row form_button_row">
<div class="formitem"><button name="login-button" class="formbutton">{{lang "login_submit_button"}}</button></div>
<div class="formitem dont_have_account">{{lang "login_no_account"}}</div>
<div class="formitem dont_have_account">
<a href="/accounts/create/">{{lang "login_no_account"}}
</div>
<div class="formitem forgot_password">
<a href="/accounts/password-reset/">{{lang "login_forgot_password"}}</a>
</div>
</div>
</form>
</div>

View File

@ -6,8 +6,8 @@
<div class="rowblock the_form">
<form action="/accounts/mfa_verify/submit/" method="post">
<div class="formrow real_first_child">
<div class="formitem formlabel"><a>{{lang "login_mfa_verify_explanation"}}</a></div>
</div>
<div class="formitem formlabel"><a>{{lang "login_mfa_verify_explanation"}}</a></div>
</div>
<div class="formrow login_mfa_token_row">
<div class="formitem formlabel"><a id="login_mfa_verify_label">{{lang "login_mfa_token"}}</a></div>
<div class="formitem"><input name="mfa_token" type="text" autocomplete="off" placeholder="*****" aria-labelledby="login_mfa_verify_label" required /></div>

View File

@ -0,0 +1,18 @@
{{template "header.html" . }}
<main id="password_reset_page">
<div class="rowblock rowhead">
<div class="rowitem"><h1>{{lang "password_reset_head"}}</h1></div>
</div>
<div class="rowblock the_form">
<form action="/accounts/password-reset/submit/" method="post">
<div class="formrow login_name_row">
<div class="formitem formlabel"><a id="login_name_label">{{lang "password_reset_username"}}</a></div>
<div class="formitem"><input name="username" type="text" aria-labelledby="login_name_label" required /></div>
</div>
<div class="formrow login_button_row form_button_row">
<div class="formitem"><button name="login-button" class="formbutton">{{lang "password_reset_button"}}</button></div>
</div>
</form>
</div>
</main>
{{template "footer.html" . }}

View File

@ -0,0 +1,30 @@
{{template "header.html" . }}
<main id="password_reset_page">
<div class="rowblock rowhead">
<div class="rowitem"><h1>{{lang "password_reset_token_head"}}</h1></div>
</div>
<div class="rowblock the_form">
<form action="/accounts/password-reset/token/submit/" method="post">
<input name="uid" value="{{.UID}}" type="hidden" />
<input name="token" value="{{.Token}}" type="hidden" />
<div class="formrow">
<div class="formitem formlabel"><a id="password_label">{{lang "password_reset_token_password"}}</a></div>
<div class="formitem"><input name="password" type="password" autocomplete="new-password" placeholder="*****" aria-labelledby="password_label" required /></div>
</div>
<div class="formrow">
<div class="formitem formlabel"><a id="confirm_password_label">{{lang "password_reset_token_confirm_password"}}</a></div>
<div class="formitem"><input name="confirm_password" type="password" placeholder="*****" autocomplete="new-password" aria-labelledby="confirm_password_label" required /></div>
</div>
{{if .MFA}}
<div class="formrow mfa_token_row">
<div class="formitem formlabel"><a id="mfa_token_label">{{lang "password_reset_mfa_token"}}</a></div>
<div class="formitem"><input name="mfa_token" type="text" autocomplete="off" placeholder="*****" aria-labelledby="mfa_token_label" required /></div>
</div>
{{end}}
<div class="formrow login_button_row form_button_row">
<div class="formitem"><button name="token-button" class="formbutton">{{lang "password_reset_token_button"}}</button></div>
</div>
</form>
</div>
</main>
{{template "footer.html" . }}

View File

@ -1387,12 +1387,19 @@ textarea {
.login_button_row {
display: flex;
}
.dont_have_account {
.dont_have_account, .forgot_password {
color: var(--primary-link-color);
font-size: 12px;
margin-left: auto;
margin-top: 23px;
}
.dont_have_account {
margin-left: auto;
}
.dont_have_account:after {
content: "|";
margin-left: 5px;
margin-right: 5px;
}
/* TODO: Highlight the one we're currently on? */
.pageset {

View File

@ -0,0 +1,30 @@
{{template "header.html" . }}
<main id="login_page">
<div class="rowblock rowhead">
<div class="rowitem"><h1>{{lang "login_head"}}</h1></div>
</div>
<div class="rowblock the_form">
<form action="/accounts/login/submit/" method="post">
<div class="formrow login_name_row">
<div class="formitem formlabel"><a id="login_name_label">{{lang "login_account_name"}}</a></div>
<div class="formitem"><input name="username" type="text" placeholder="{{lang "login_account_name"}}" aria-labelledby="login_name_label" required /></div>
</div>
<div class="formrow login_password_row">
<div class="formitem formlabel"><a id="login_password_label">{{lang "login_account_password"}}</a></div>
<div class="formitem"><input name="password" type="password" autocomplete="current-password" placeholder="*****" aria-labelledby="login_password_label" required /></div>
</div>
<div class="formrow login_button_row form_button_row">
<div class="formitem"><button name="login-button" class="formbutton">{{lang "login_submit_button"}}</button></div>
<div class="fall_opts">
<div class="formitem dont_have_account">
<a href="/accounts/create/">{{lang "login_no_account"}}
</div>
<div class="formitem forgot_password">
<a href="/accounts/password-reset/">{{lang "login_forgot_password"}}</a>
</div>
</div>
</div>
</form>
</div>
</main>
{{template "footer.html" . }}

View File

@ -614,6 +614,16 @@ button, .formbutton, .panel_right_button:not(.has_inner_button) {
.login_mfa_token_row .formlabel {
display: none;
}
.fall_opts {
display: flex;
}
.dont_have_account, .forgot_password {
margin-top: 12px;
margin-bottom: -8px;
}
.forgot_password {
margin-left: auto;
}
.pageset {
display: flex;

View File

@ -0,0 +1,30 @@
{{template "header.html" . }}
<main id="login_page">
<div class="rowblock rowhead">
<div class="rowitem"><h1>{{lang "login_head"}}</h1></div>
</div>
<div class="rowblock the_form">
<form action="/accounts/login/submit/" method="post">
<div class="formrow login_name_row">
<div class="formitem formlabel"><a id="login_name_label">{{lang "login_account_name"}}</a></div>
<div class="formitem"><input name="username" type="text" placeholder="{{lang "login_account_name"}}" aria-labelledby="login_name_label" required /></div>
</div>
<div class="formrow login_password_row">
<div class="formitem formlabel"><a id="login_password_label">{{lang "login_account_password"}}</a></div>
<div class="formitem"><input name="password" type="password" autocomplete="current-password" placeholder="*****" aria-labelledby="login_password_label" required /></div>
</div>
<div class="formrow login_button_row form_button_row">
<div class="formitem"><button name="login-button" class="formbutton">{{lang "login_submit_button"}}</button></div>
<div class="fall_opts">
<div class="formitem dont_have_account">
<a href="/accounts/create/">{{lang "login_no_account"}}
</div>
<div class="formitem forgot_password">
<a href="/accounts/password-reset/">{{lang "login_forgot_password"}}</a>
</div>
</div>
</div>
</form>
</div>
</main>
{{template "footer.html" . }}

View File

@ -346,11 +346,32 @@ h1, h2, h3 {
padding-bottom: 12px;
}
.login_button_row {
display: flex;
}
.login_button_row .formitem > * {
padding-top: 5px;
}
.fall_opts {
display: flex;
}
.dont_have_account {
color: #505050;
margin-left: auto;
padding-right: 0px;
}
.dont_have_account:after {
content: "|";
padding-left: 8px;
padding-right: 8px;
}
.forgot_password {
padding-left: 0px;
}
.formitem.dont_have_account, .formitem.forgot_password {
color: #909090;
font-size: 12px;
font-weight: normal;
float: right;
padding-top: 11px;
}
textarea {

View File

@ -0,0 +1,30 @@
{{template "header.html" . }}
<main id="login_page">
<div class="rowblock rowhead">
<div class="rowitem"><h1>{{lang "login_head"}}</h1></div>
</div>
<div class="rowblock the_form">
<form action="/accounts/login/submit/" method="post">
<div class="formrow login_name_row">
<div class="formitem formlabel"><a id="login_name_label">{{lang "login_account_name"}}</a></div>
<div class="formitem"><input name="username" type="text" placeholder="{{lang "login_account_name"}}" aria-labelledby="login_name_label" required /></div>
</div>
<div class="formrow login_password_row">
<div class="formitem formlabel"><a id="login_password_label">{{lang "login_account_password"}}</a></div>
<div class="formitem"><input name="password" type="password" autocomplete="current-password" placeholder="*****" aria-labelledby="login_password_label" required /></div>
</div>
<div class="formrow login_button_row form_button_row">
<div class="formitem"><button name="login-button" class="formbutton">{{lang "login_submit_button"}}</button></div>
<div class="fall_opts">
<div class="formitem dont_have_account">
<a href="/accounts/create/">{{lang "login_no_account"}}
</div>
<div class="formitem forgot_password">
<a href="/accounts/password-reset/">{{lang "login_forgot_password"}}</a>
</div>
</div>
</div>
</form>
</div>
</main>
{{template "footer.html" . }}

View File

@ -420,11 +420,26 @@ input, select {
border-color: hsl(0, 0%, 80%);
}
.dont_have_account {
color: #505050;
font-size: 12px;
font-weight: normal;
.fall_opts {
float: right;
display: flex;
}
.dont_have_account, .forgot_password {
color: #505050;
font-size: 14px;
margin-top: 6px;
border-right: none !important;
}
.dont_have_account:after {
content: "|";
margin-left: 5px;
margin-right: 5px;
}
.dont_have_account {
padding-right: 0px;
}
.forgot_password {
padding-left: 0px;
}
.ip_search_block {

View File

Before

Width:  |  Height:  |  Size: 539 B

After

Width:  |  Height:  |  Size: 539 B

View File

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 230 KiB

View File

@ -1,9 +1,9 @@
{
"Name": "tempra-simple",
"Name": "tempra_simple",
"FriendlyName": "Tempra Simple",
"Version": "0.1.0-dev",
"Creator": "Azareal",
"FullImage": "tempra-simple.png",
"FullImage": "tempra_simple.png",
"MobileFriendly": true,
"URL": "github.com/Azareal/Gosora",
"BgAvatars":true,
@ -16,7 +16,7 @@
],
"Resources": [
{
"Name":"tempra-simple/misc.js",
"Name":"tempra_simple/misc.js",
"Location":"global"
}
]