From f8f46b3c48027ccadc9110975c1b3ccb5c6a79c5 Mon Sep 17 00:00:00 2001 From: Azareal Date: Sun, 17 Jun 2018 17:28:18 +1000 Subject: [PATCH] Added support for two-factor authentication. Added the Account Dashboard and merged a few account views into it. BREAKING CHANGE: We now use config/config.json instead of config/config.go, be sure to setup one of these files, you can config_default.json as an example of what a config.json should look like. If you don't have an existing installation, you can just rely on the installer to do this for you. CSS Changes (does not include Nox Theme): Sidebar should no longer show up in the account manager in some odd situations or themes. Made a few CSS rules more generic. Forms have a new look in Cosora now. Config Changes: Removed the DefaultRoute config field. Added the DefaultPath config field. Added the MaxRequestSizeStr config field to make it easier for users to input custom max request sizes without having to use a calculator or figure out how many bytes there are in a megabyte. Removed the CacheTopicUser config field. Added the UserCache config field. Added the TopicCache config field Phrases: Removed ten english phrases. Added 21 english phrases. Changed eleven english phrases. Removed some duplicate indices in the english phrase pack. Removed some old benchmark code. Tweaked some things to make the linter happy. Added comments for all the MemoryUserCache and MemoryTopicCache methods. Added a comment for the null caches, consult the other caches for further information on the methods. Added a client-side check to make sure the user doesn't upload too much data in a single post. The server already did this, but it might be a while before feedback arrives from it. Simplified a lot of the control panel route code with the buildBasePage function. Renamed /user/edit/critical/ to /user/edit/password/ Renamed /user/edit/critical/submit/ to /user/edit/password/submit/ Made some small improvements to SEO with a couple of meta tags. Renamed some of the control panel templates so that they use _ instead of -. Fixed a bug where notices were being moved to the wrong place in some areas in Cosora. Added the writeJsonError function to help abstract writing json errors. Moved routePanelUsers to panel.Users Moved routePanelUsersEdit to panel.UsersEdit Moved routePanelUsersEditSubmit to panel.UsersEditSubmit Renamed routes.AccountEditCritical to routes.AccountEditPassword Renamed routes.AccountEditCriticalSubmit to routes.AccountEditPasswordSubmit Removed the routes.AccountEditAvatar and routes.AccountEditUsername routes. Fixed a data race in MemoryTopicCache.Add which could lead to the capacity limit being bypassed. Tweaked MemoryTopicCache.AddUnsafe under the assumption that it's not going to be safe anyway, but we might as-well try in case this call is properly synchronised. Fixed a data race in MemoryTopicCache.Remove which could lead to the length counter being decremented twice. Tweaked the behaviour of MemoryTopicCache.RemoveUnsafe to mirror that of Remove. Fixed a data race in MemoryUserCache.Add which could lead to the capacity limit being bypassed. User can no longer change their usernames to blank. Made a lot of progress on the Nox theme. Added modified FA5 SVGs as a dependency for Nox. Be sure to run the patcher or update script and don't forget to create a customised config/config.json file. --- common/auth.go | 99 ++++- common/cache.go | 6 - common/common.go | 6 +- common/errors.go | 20 +- common/extend.go | 18 +- common/forum_perms_store.go | 2 +- common/gauth/authenticator.go | 2 + common/mfa_store.go | 101 +++++ common/null_topic_cache.go | 20 +- common/null_user_cache.go | 19 +- common/pages.go | 5 + common/parser.go | 34 +- common/site.go | 83 +++- common/template_init.go | 3 +- common/theme.go | 9 + common/theme_list.go | 12 + common/topic_cache.go | 30 +- common/user.go | 21 +- common/user_cache.go | 18 +- common/utils.go | 35 +- config/config_example.json | 55 +++ config_default.noparse | 66 --- database.go | 7 +- docs/landing_page.md | 3 + gen_mssql.go | 9 - gen_mysql.go | 8 - gen_pgsql.go | 8 - gen_router.go | 414 ++++++++++-------- gen_tables.go | 33 +- general_test.go | 327 -------------- install/install.go | 124 +++--- langs/english.json | 71 +-- main.go | 21 +- panel_routes.go | 169 +------ patcher/main.go | 16 +- patcher/patches.go | 62 +++ plugin_bbcode.go | 6 +- public/account.js | 7 + public/global.js | 6 + query_gen/main.go | 4 +- query_gen/tables.go | 37 +- router_gen/main.go | 28 +- router_gen/routes.go | 20 +- routes/account.go | 339 +++++++++++--- routes/panel/analytics.go | 48 +- routes/panel/backups.go | 9 +- routes/panel/common.go | 10 + routes/panel/debug.go | 5 +- routes/panel/forums.go | 35 +- routes/panel/logs.go | 15 +- routes/panel/pages.go | 19 +- routes/panel/settings.go | 14 +- routes/panel/users.go | 171 ++++++++ schema/mssql/inserts.sql | 2 +- schema/mssql/query_users_2fa_keys.sql | 14 + schema/mysql/inserts.sql | 2 +- schema/mysql/query_users_2fa_keys.sql | 14 + schema/pgsql/inserts.sql | 2 +- schema/pgsql/query_users_2fa_keys.sql | 14 + schema/schema.json | 2 +- templates/account_menu.html | 8 +- templates/account_own_edit.html | 31 ++ templates/account_own_edit_avatar.html | 24 - templates/account_own_edit_mfa.html | 29 ++ templates/account_own_edit_mfa_setup.html | 26 ++ templates/account_own_edit_password.html | 2 +- templates/account_own_edit_username.html | 25 -- templates/header.html | 5 +- templates/login_mfa_verify.html | 21 + ...-forum-edit.html => panel_forum_edit.html} | 0 ...perms.html => panel_forum_edit_perms.html} | 0 ...-group-edit.html => panel_group_edit.html} | 0 ...perms.html => panel_group_edit_perms.html} | 0 ...el-user-edit.html => panel_user_edit.html} | 0 themes/cosora/public/account.css | 88 ++++ themes/cosora/public/main.css | 25 +- themes/cosora/public/misc.js | 6 + themes/nox/public/account.css | 69 +++ themes/nox/public/fa-svg/LICENSE.txt | 34 ++ themes/nox/public/fa-svg/README.md | 7 + themes/nox/public/fa-svg/pencil-alt-light.svg | 1 + themes/nox/public/fa-svg/pencil-alt.svg | 1 + themes/nox/public/main.css | 80 +++- themes/nox/public/panel.css | 60 +++ themes/nox/theme.json | 4 - themes/shadow/public/account.css | 51 +++ themes/shadow/public/main.css | 2 +- themes/tempra-conflux/public/account.css | 30 ++ themes/tempra-simple/public/account.css | 30 ++ 89 files changed, 2154 insertions(+), 1264 deletions(-) create mode 100644 common/mfa_store.go create mode 100644 config/config_example.json delete mode 100644 config_default.noparse create mode 100644 docs/landing_page.md create mode 100644 public/account.js create mode 100644 routes/panel/users.go create mode 100644 schema/mssql/query_users_2fa_keys.sql create mode 100644 schema/mysql/query_users_2fa_keys.sql create mode 100644 schema/pgsql/query_users_2fa_keys.sql create mode 100644 templates/account_own_edit.html delete mode 100644 templates/account_own_edit_avatar.html create mode 100644 templates/account_own_edit_mfa.html create mode 100644 templates/account_own_edit_mfa_setup.html delete mode 100644 templates/account_own_edit_username.html create mode 100644 templates/login_mfa_verify.html rename templates/{panel-forum-edit.html => panel_forum_edit.html} (100%) rename templates/{panel-forum-edit-perms.html => panel_forum_edit_perms.html} (100%) rename templates/{panel-group-edit.html => panel_group_edit.html} (100%) rename templates/{panel-group-edit-perms.html => panel_group_edit_perms.html} (100%) rename templates/{panel-user-edit.html => panel_user_edit.html} (100%) create mode 100644 themes/cosora/public/account.css create mode 100644 themes/nox/public/account.css create mode 100644 themes/nox/public/fa-svg/LICENSE.txt create mode 100644 themes/nox/public/fa-svg/README.md create mode 100644 themes/nox/public/fa-svg/pencil-alt-light.svg create mode 100644 themes/nox/public/fa-svg/pencil-alt.svg create mode 100644 themes/shadow/public/account.css create mode 100644 themes/tempra-conflux/public/account.css create mode 100644 themes/tempra-simple/public/account.css diff --git a/common/auth.go b/common/auth.go index 7349d83f..ae35a8d0 100644 --- a/common/auth.go +++ b/common/auth.go @@ -7,7 +7,10 @@ package common import ( + "crypto/sha256" + "crypto/subtle" "database/sql" + "encoding/hex" "errors" "net/http" "strconv" @@ -35,6 +38,8 @@ var ErrTooFewHashParams = errors.New("You haven't provided enough hash parameter // ErrPasswordTooLong is silly, but we don't want bcrypt to bork on us 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 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 @@ -58,13 +63,16 @@ var HashPrefixes = map[string]string{ // AuthInt is the main authentication interface. type AuthInt interface { - Authenticate(username string, password string) (uid int, err error) + Authenticate(username string, password string) (uid int, err error, requiresExtraAuth bool) + ValidateMFAToken(mfaToken string, uid int) error Logout(w http.ResponseWriter, uid int) ForceLogout(uid int) error SetCookies(w http.ResponseWriter, uid int, session string) + SetProvisionalCookies(w http.ResponseWriter, uid int, session string, signedSession string) // To avoid logging someone in until they've passed the MFA check GetCookies(r *http.Request) (uid int, session string, err error) SessionCheck(w http.ResponseWriter, r *http.Request) (user *User, halt bool) CreateSession(uid int) (session string, err error) + CreateProvisionalSession(uid int) (provSession string, signedSession string, err error) // To avoid logging someone in until they've passed the MFA check } // DefaultAuth is the default authenticator used by Gosora, may be swapped with an alternate authenticator in some situations. E.g. To support LDAP. @@ -85,26 +93,64 @@ func NewDefaultAuth() (*DefaultAuth, error) { } // Authenticate checks if a specific username and password is valid and returns the UID for the corresponding user, if so. Otherwise, a user safe error. +// IF MFA is enabled, then pass it back a flag telling the caller that authentication isn't complete yet // TODO: Find a better way of handling errors we don't want to reach the user -func (auth *DefaultAuth) Authenticate(username string, password string) (uid int, err error) { +func (auth *DefaultAuth) Authenticate(username string, password string) (uid int, err error, requiresExtraAuth bool) { var realPassword, salt string err = auth.login.QueryRow(username).Scan(&uid, &realPassword, &salt) if err == ErrNoRows { - return 0, ErrNoUserByName + return 0, ErrNoUserByName, false } else if err != nil { LogError(err) - return 0, ErrSecretError + return 0, ErrSecretError, false } err = CheckPassword(realPassword, password, salt) if err == ErrMismatchedHashAndPassword { - return 0, ErrWrongPassword + return 0, ErrWrongPassword, false } else if err != nil { LogError(err) - return 0, ErrSecretError + return 0, ErrSecretError, false } - return uid, nil + _, err = MFAstore.Get(uid) + if err != sql.ErrNoRows && err != nil { + LogError(err) + return 0, ErrSecretError, false + } + if err != ErrNoRows { + return uid, nil, true + } + + return uid, nil, false +} + +func (auth *DefaultAuth) ValidateMFAToken(mfaToken string, uid int) error { + mfaItem, err := MFAstore.Get(uid) + if err != sql.ErrNoRows && err != nil { + 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 + } + } + } + return ErrWrongMFAToken } // ForceLogout logs the user out of every computer, not just the one they logged out of @@ -141,6 +187,17 @@ func (auth *DefaultAuth) SetCookies(w http.ResponseWriter, uid int, session stri http.SetCookie(w, &cookie) } +// TODO: Set the cookie domain +// SetProvisionalCookies sets the two cookies required for guests to be recognised as having passed the initial login but not having passed the additional checks (e.g. multi-factor authentication) +func (auth *DefaultAuth) SetProvisionalCookies(w http.ResponseWriter, uid int, provSession string, signedSession string) { + cookie := http.Cookie{Name: "uid", Value: strconv.Itoa(uid), Path: "/", MaxAge: int(Year)} + http.SetCookie(w, &cookie) + cookie = http.Cookie{Name: "provSession", Value: provSession, Path: "/", MaxAge: int(Year)} + http.SetCookie(w, &cookie) + cookie = http.Cookie{Name: "signedSession", Value: signedSession, Path: "/", MaxAge: int(Year)} + http.SetCookie(w, &cookie) +} + // GetCookies fetches the current user's session cookies func (auth *DefaultAuth) GetCookies(r *http.Request) (uid int, session string, err error) { // Are there any session cookies..? @@ -202,6 +259,19 @@ func (auth *DefaultAuth) CreateSession(uid int) (session string, err error) { return session, nil } +func (auth *DefaultAuth) CreateProvisionalSession(uid int) (provSession string, signedSession string, err error) { + provSession, err = GenerateSafeString(SessionLength) + if err != nil { + return "", "", err + } + + h := sha256.New() + h.Write([]byte(SessionSigningKeyBox.Load().(string))) + h.Write([]byte(provSession)) + h.Write([]byte(strconv.Itoa(uid))) + return provSession, hex.EncodeToString(h.Sum(nil)), nil +} + func CheckPassword(realPassword string, password string, salt string) (err error) { blasted := strings.Split(realPassword, "$") prefix := blasted[0] @@ -274,11 +344,20 @@ func Argon2GeneratePassword(password string) (hash string, salt string, err erro } */ -// TODO: Not sure if these work, test them with Google Authenticator +// TODO: Test this with Google Authenticator proper +func FriendlyGAuthSecret(secret string) (out string) { + for i, char := range secret { + out += string(char) + if (i+1)%4 == 0 { + out += " " + } + } + return strings.TrimSpace(out) +} func GenerateGAuthSecret() (string, error) { - return GenerateSafeString(24) + return GenerateStd32SafeString(14) } func VerifyGAuthToken(secret string, token string) (bool, error) { trueToken, err := gauth.GetTOTPToken(secret) - return trueToken == token, err + return subtle.ConstantTimeCompare([]byte(trueToken), []byte(token)) == 1, err } diff --git a/common/cache.go b/common/cache.go index 849fd925..ea048c78 100644 --- a/common/cache.go +++ b/common/cache.go @@ -2,12 +2,6 @@ package common import "errors" -// Go away, linter. We need to differentiate constants from variables somehow ;) -// nolint -const CACHE_STATIC int = 0 -const CACHE_DYNAMIC int = 1 -const CACHE_SQL int = 2 - // nolint // ErrCacheDesync is thrown whenever a piece of data, for instance, a user is out of sync with the database. Currently unused. var ErrCacheDesync = errors.New("The cache is out of sync with the database.") // TODO: A cross-server synchronisation mechanism diff --git a/common/common.go b/common/common.go index b74b83de..2d7bbc30 100644 --- a/common/common.go +++ b/common/common.go @@ -25,7 +25,9 @@ var StartTime time.Time var TmplPtrMap = make(map[string]interface{}) // Anti-spam token with rotated key -var JSTokenBox atomic.Value // TODO: Move this and some of these other globals somewhere else +var JSTokenBox atomic.Value // TODO: Move this and some of these other globals somewhere else +var SessionSigningKeyBox atomic.Value // For MFA to avoid hitting the database unneccesarily +var OldSessionSigningKeyBox atomic.Value // Just in case we've signed with a key that's about to go stale so we don't annoy the user too much // ErrNoRows is an alias of sql.ErrNoRows, just in case we end up with non-database/sql datastores var ErrNoRows = sql.ErrNoRows @@ -60,6 +62,8 @@ var ExecutableFileExts = StringList{ func init() { JSTokenBox.Store("") + SessionSigningKeyBox.Store("") + OldSessionSigningKeyBox.Store("") } // TODO: Write a test for this diff --git a/common/errors.go b/common/errors.go index e5801e35..7fcefa53 100644 --- a/common/errors.go +++ b/common/errors.go @@ -4,6 +4,7 @@ import ( "log" "net/http" "runtime/debug" + "strings" "sync" ) @@ -114,7 +115,7 @@ func InternalErrorJSQ(err error, w http.ResponseWriter, r *http.Request, isJs bo // ? - Add a user parameter? func InternalErrorJS(err error, w http.ResponseWriter, r *http.Request) RouteError { w.WriteHeader(500) - _, _ = w.Write([]byte(`{"errmsg":"A problem has occurred in the system."}`)) + writeJsonError("A problem has occurred in the system.", w) LogError(err) return HandledRouteError() } @@ -148,7 +149,7 @@ func PreError(errmsg string, w http.ResponseWriter, r *http.Request) RouteError func PreErrorJS(errmsg string, w http.ResponseWriter, r *http.Request) RouteError { w.WriteHeader(500) - _, _ = w.Write([]byte(`{"errmsg":"` + errmsg + `"}`)) + writeJsonError(errmsg, w) return HandledRouteError() } @@ -177,7 +178,7 @@ func LocalErrorJSQ(errmsg string, w http.ResponseWriter, r *http.Request, user U func LocalErrorJS(errmsg string, w http.ResponseWriter, r *http.Request) RouteError { w.WriteHeader(500) - _, _ = w.Write([]byte(`{"errmsg": "` + errmsg + `"}`)) + writeJsonError(errmsg, w) return HandledRouteError() } @@ -199,7 +200,7 @@ func NoPermissionsJSQ(w http.ResponseWriter, r *http.Request, user User, isJs bo func NoPermissionsJS(w http.ResponseWriter, r *http.Request, user User) RouteError { w.WriteHeader(403) - _, _ = w.Write([]byte(`{"errmsg":"You don't have permission to do that."}`)) + writeJsonError("You don't have permission to do that.", w) return HandledRouteError() } @@ -222,7 +223,7 @@ func BannedJSQ(w http.ResponseWriter, r *http.Request, user User, isJs bool) Rou func BannedJS(w http.ResponseWriter, r *http.Request, user User) RouteError { w.WriteHeader(403) - _, _ = w.Write([]byte(`{"errmsg":"You have been banned from this site."}`)) + writeJsonError("You have been banned from this site.", w) return HandledRouteError() } @@ -246,7 +247,7 @@ func LoginRequired(w http.ResponseWriter, r *http.Request, user User) RouteError // nolint func LoginRequiredJS(w http.ResponseWriter, r *http.Request, user User) RouteError { w.WriteHeader(401) - _, _ = w.Write([]byte(`{"errmsg":"You need to login to do that."}`)) + writeJsonError("You need to login to do that.", w) return HandledRouteError() } @@ -297,10 +298,15 @@ func CustomErrorJSQ(errmsg string, errcode int, errtitle string, w http.Response // CustomErrorJS is the pure JSON version of CustomError func CustomErrorJS(errmsg string, errcode int, w http.ResponseWriter, r *http.Request, user User) RouteError { w.WriteHeader(errcode) - _, _ = w.Write([]byte(`{"errmsg":"` + errmsg + `"}`)) + writeJsonError(errmsg, w) return HandledRouteError() } +// TODO: Should we optimise this by caching these json strings? +func writeJsonError(errmsg string, w http.ResponseWriter) { + _, _ = w.Write([]byte(`{"errmsg":"` + strings.Replace(errmsg, "\"", "", -1) + `"}`)) +} + func handleErrorTemplate(w http.ResponseWriter, r *http.Request, pi ErrorPage) { // TODO: What to do about this hook? if RunPreRenderHook("pre_render_error", w, r, &pi.Header.CurrentUser, &pi) { diff --git a/common/extend.go b/common/extend.go index deacfbe9..34c4924a 100644 --- a/common/extend.go +++ b/common/extend.go @@ -93,14 +93,16 @@ var PreRenderHooks = map[string][]func(http.ResponseWriter, *http.Request, *User "pre_render_overview": nil, "pre_render_create_topic": nil, - "pre_render_account_own_edit_password": nil, - "pre_render_account_own_edit_avatar": nil, - "pre_render_account_own_edit_username": nil, - "pre_render_account_own_edit_email": nil, - "pre_render_login": nil, - "pre_render_register": nil, - "pre_render_ban": nil, - "pre_render_ip_search": nil, + "pre_render_account_own_edit": nil, + "pre_render_account_own_edit_password": nil, + "pre_render_account_own_edit_mfa": nil, + "pre_render_account_own_edit_mfa_setup": nil, + "pre_render_account_own_edit_email": nil, + "pre_render_login": nil, + "pre_render_login_mfa_verify": nil, + "pre_render_register": nil, + "pre_render_ban": nil, + "pre_render_ip_search": nil, "pre_render_panel_dashboard": nil, "pre_render_panel_forums": nil, diff --git a/common/forum_perms_store.go b/common/forum_perms_store.go index 9c2b8989..e6fcbf3e 100644 --- a/common/forum_perms_store.go +++ b/common/forum_perms_store.go @@ -121,7 +121,7 @@ func (fps *MemoryForumPermsStore) Reload(fid int) error { group.CanSee = []int{} for _, fid := range fids { DebugDetailf("Forum #%+v\n", fid) - var forumPerms = make(map[int]*ForumPerms) + var forumPerms map[int]*ForumPerms var ok bool if fid%2 == 0 { fps.evenLock.RLock() diff --git a/common/gauth/authenticator.go b/common/gauth/authenticator.go index 97460ee5..beb4323a 100644 --- a/common/gauth/authenticator.go +++ b/common/gauth/authenticator.go @@ -26,6 +26,8 @@ func prefix0(otp string) string { } func GetHOTPToken(secret string, interval int64) (string, error) { + secret = strings.Replace(secret, " ", "", -1) + // Converts secret to base32 Encoding. Base32 encoding desires a 32-character subset of the twenty-six letters A–Z and ten digits 0–9 key, err := base32.StdEncoding.DecodeString(strings.ToUpper(secret)) if err != nil { diff --git a/common/mfa_store.go b/common/mfa_store.go new file mode 100644 index 00000000..7b064d33 --- /dev/null +++ b/common/mfa_store.go @@ -0,0 +1,101 @@ +package common + +import ( + "database/sql" + "errors" + "strings" + + "../query_gen/lib" +) + +var MFAstore MFAStore +var ErrMFAScratchIndexOutOfBounds = errors.New("That MFA scratch index is out of bounds") + +type MFAItemStmts struct { + update *sql.Stmt + delete *sql.Stmt +} + +var mfaItemStmts MFAItemStmts + +func init() { + DbInits.Add(func(acc *qgen.Accumulator) error { + mfaItemStmts = MFAItemStmts{ + update: acc.Update("users_2fa_keys").Set("scratch1 = ?, scratch2, scratch3 = ?, scratch3 = ?, scratch4 = ?, scratch5 = ?, scratch6 = ?, scratch7 = ?, scratch8 = ?").Where("uid = ?").Prepare(), + delete: acc.Delete("users_2fa_keys").Where("uid = ?").Prepare(), + } + return acc.FirstError() + }) +} + +type MFAItem struct { + UID int + Secret string + Scratch []string +} + +func (item *MFAItem) BurnScratch(index int) error { + if index < 0 || len(item.Scratch) <= index { + return ErrMFAScratchIndexOutOfBounds + } + newScratch, err := mfaCreateScratch() + if err != nil { + return err + } + item.Scratch[index] = newScratch + + _, err = mfaItemStmts.update.Exec(item.Scratch[0], item.Scratch[1], item.Scratch[2], item.Scratch[3], item.Scratch[4], item.Scratch[5], item.Scratch[6], item.Scratch[7], item.UID) + return err +} + +func (item *MFAItem) Delete() error { + _, err := mfaItemStmts.delete.Exec(item.UID) + return err +} + +func mfaCreateScratch() (string, error) { + code, err := GenerateStd32SafeString(8) + return strings.Replace(code, "=", "", -1), err +} + +type MFAStore interface { + Get(id int) (*MFAItem, error) + Create(secret string, uid int) (err error) +} + +type SQLMFAStore struct { + get *sql.Stmt + create *sql.Stmt +} + +func NewSQLMFAStore(acc *qgen.Accumulator) (*SQLMFAStore, error) { + return &SQLMFAStore{ + get: acc.Select("users_2fa_keys").Columns("secret, scratch1, scratch2, scratch3, scratch4, scratch5, scratch6, scratch7, scratch8").Where("uid = ?").Prepare(), + create: acc.Insert("users_2fa_keys").Columns("uid, secret, scratch1, scratch2, scratch3, scratch4, scratch5, scratch6, scratch7, scratch8, createdAt").Fields("?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP()").Prepare(), + }, acc.FirstError() +} + +// TODO: Write a test for this +func (store *SQLMFAStore) Get(id int) (*MFAItem, error) { + item := MFAItem{UID: id, Scratch: make([]string, 8)} + err := store.get.QueryRow(id).Scan(&item.Secret, &item.Scratch[0], &item.Scratch[1], &item.Scratch[2], &item.Scratch[3], &item.Scratch[4], &item.Scratch[5], &item.Scratch[6], &item.Scratch[7]) + return &item, err + +} + +// TODO: Write a test for this +func (store *SQLMFAStore) Create(secret string, uid int) (err error) { + var params = make([]interface{}, 10) + params[0] = uid + params[1] = secret + for i := 2; i < len(params); i++ { + code, err := mfaCreateScratch() + if err != nil { + return err + } + params[i] = code + } + + _, err = store.create.Exec(params...) + return err +} diff --git a/common/null_topic_cache.go b/common/null_topic_cache.go index 0b14b354..27ddd851 100644 --- a/common/null_topic_cache.go +++ b/common/null_topic_cache.go @@ -1,5 +1,6 @@ package common +// NullTopicCache is a topic cache to be used when you don't want a cache and just want queries to passthrough to the database type NullTopicCache struct { } @@ -8,48 +9,35 @@ func NewNullTopicCache() *NullTopicCache { return &NullTopicCache{} } +// nolint func (mts *NullTopicCache) Get(id int) (*Topic, error) { return nil, ErrNoRows } - func (mts *NullTopicCache) GetUnsafe(id int) (*Topic, error) { return nil, ErrNoRows } - func (mts *NullTopicCache) Set(_ *Topic) error { return nil } - -func (mts *NullTopicCache) Add(item *Topic) error { - _ = item +func (mts *NullTopicCache) Add(_ *Topic) error { return nil } - -// TODO: Make these length increments thread-safe. Ditto for the other DataStores -func (mts *NullTopicCache) AddUnsafe(item *Topic) error { - _ = item +func (mts *NullTopicCache) AddUnsafe(_ *Topic) error { return nil } - -// TODO: Make these length decrements thread-safe. Ditto for the other DataStores func (mts *NullTopicCache) Remove(id int) error { return nil } - func (mts *NullTopicCache) RemoveUnsafe(id int) error { return nil } - func (mts *NullTopicCache) Flush() { } - func (mts *NullTopicCache) Length() int { return 0 } - func (mts *NullTopicCache) SetCapacity(_ int) { } - func (mts *NullTopicCache) GetCapacity() int { return 0 } diff --git a/common/null_user_cache.go b/common/null_user_cache.go index 46415f54..bcfd758d 100644 --- a/common/null_user_cache.go +++ b/common/null_user_cache.go @@ -1,5 +1,6 @@ package common +// NullUserCache is a user cache to be used when you don't want a cache and just want queries to passthrough to the database type NullUserCache struct { } @@ -8,50 +9,38 @@ func NewNullUserCache() *NullUserCache { return &NullUserCache{} } +// nolint func (mus *NullUserCache) Get(id int) (*User, error) { return nil, ErrNoRows } - func (mus *NullUserCache) BulkGet(_ []int) (list []*User) { return nil } - func (mus *NullUserCache) GetUnsafe(id int) (*User, error) { return nil, ErrNoRows } - func (mus *NullUserCache) Set(_ *User) error { return nil } - -func (mus *NullUserCache) Add(item *User) error { - _ = item +func (mus *NullUserCache) Add(_ *User) error { return nil } - -func (mus *NullUserCache) AddUnsafe(item *User) error { - _ = item +func (mus *NullUserCache) AddUnsafe(_ *User) error { return nil } - func (mus *NullUserCache) Remove(id int) error { return nil } - func (mus *NullUserCache) RemoveUnsafe(id int) error { return nil } - func (mus *NullUserCache) Flush() { } - func (mus *NullUserCache) Length() int { return 0 } - func (mus *NullUserCache) SetCapacity(_ int) { } - func (mus *NullUserCache) GetCapacity() int { return 0 } diff --git a/common/pages.go b/common/pages.go index 837784a1..5cc2454d 100644 --- a/common/pages.go +++ b/common/pages.go @@ -142,6 +142,11 @@ type EmailListPage struct { Something interface{} } +type AccountDashPage struct { + *Header + MFASetup bool +} + type PanelStats struct { Users int Groups int diff --git a/common/parser.go b/common/parser.go index f86231dd..649736bf 100644 --- a/common/parser.go +++ b/common/parser.go @@ -15,12 +15,12 @@ var InvalidTopic = []byte("[Invalid Topic]") var InvalidProfile = []byte("[Invalid Profile]") var InvalidForum = []byte("[Invalid Forum]") var unknownMedia = []byte("[Unknown Media]") -var UrlOpen = []byte("") +var URLOpen = []byte("") var bytesSinglequote = []byte("'") var bytesGreaterthan = []byte(">") var urlMention = []byte(" class='mention'") -var UrlClose = []byte("") +var URLClose = []byte("") var imageOpen = []byte("") @@ -319,13 +319,13 @@ func ParseMessage(msg string, sectionID int, sectionType string /*, user User*/) continue } - outbytes = append(outbytes, UrlOpen...) + outbytes = append(outbytes, URLOpen...) var urlBit = []byte(BuildTopicURL("", tid)) outbytes = append(outbytes, urlBit...) - outbytes = append(outbytes, UrlOpen2...) + outbytes = append(outbytes, URLOpen2...) var tidBit = []byte("#tid-" + strconv.Itoa(tid)) outbytes = append(outbytes, tidBit...) - outbytes = append(outbytes, UrlClose...) + outbytes = append(outbytes, URLClose...) lastItem = i } else if bytes.Equal(msgbytes[i+1:i+5], []byte("rid-")) { outbytes = append(outbytes, msgbytes[lastItem:i]...) @@ -341,13 +341,13 @@ func ParseMessage(msg string, sectionID int, sectionType string /*, user User*/) continue } - outbytes = append(outbytes, UrlOpen...) + outbytes = append(outbytes, URLOpen...) var urlBit = []byte(BuildTopicURL("", topic.ID)) outbytes = append(outbytes, urlBit...) - outbytes = append(outbytes, UrlOpen2...) + outbytes = append(outbytes, URLOpen2...) var ridBit = []byte("#rid-" + strconv.Itoa(rid)) outbytes = append(outbytes, ridBit...) - outbytes = append(outbytes, UrlClose...) + outbytes = append(outbytes, URLClose...) lastItem = i } else if bytes.Equal(msgbytes[i+1:i+5], []byte("fid-")) { outbytes = append(outbytes, msgbytes[lastItem:i]...) @@ -362,13 +362,13 @@ func ParseMessage(msg string, sectionID int, sectionType string /*, user User*/) continue } - outbytes = append(outbytes, UrlOpen...) + outbytes = append(outbytes, URLOpen...) var urlBit = []byte(BuildForumURL("", fid)) outbytes = append(outbytes, urlBit...) - outbytes = append(outbytes, UrlOpen2...) + outbytes = append(outbytes, URLOpen2...) var fidBit = []byte("#fid-" + strconv.Itoa(fid)) outbytes = append(outbytes, fidBit...) - outbytes = append(outbytes, UrlClose...) + outbytes = append(outbytes, URLClose...) lastItem = i } else { // TODO: Forum Shortcode Link @@ -387,7 +387,7 @@ func ParseMessage(msg string, sectionID int, sectionType string /*, user User*/) continue } - outbytes = append(outbytes, UrlOpen...) + outbytes = append(outbytes, URLOpen...) var urlBit = []byte(menUser.Link) outbytes = append(outbytes, urlBit...) outbytes = append(outbytes, bytesSinglequote...) @@ -395,7 +395,7 @@ func ParseMessage(msg string, sectionID int, sectionType string /*, user User*/) outbytes = append(outbytes, bytesGreaterthan...) var uidBit = []byte("@" + menUser.Name) outbytes = append(outbytes, uidBit...) - outbytes = append(outbytes, UrlClose...) + outbytes = append(outbytes, URLClose...) lastItem = i } else if msgbytes[i] == 'h' || msgbytes[i] == 'f' || msgbytes[i] == 'g' || msgbytes[i] == '/' { if msgbytes[i+1] == 't' && msgbytes[i+2] == 't' && msgbytes[i+3] == 'p' { @@ -463,11 +463,11 @@ func ParseMessage(msg string, sectionID int, sectionType string /*, user User*/) continue } - outbytes = append(outbytes, UrlOpen...) + outbytes = append(outbytes, URLOpen...) outbytes = append(outbytes, msgbytes[i:i+urlLen]...) - outbytes = append(outbytes, UrlOpen2...) + outbytes = append(outbytes, URLOpen2...) outbytes = append(outbytes, msgbytes[i:i+urlLen]...) - outbytes = append(outbytes, UrlClose...) + outbytes = append(outbytes, URLClose...) i += urlLen lastItem = i } diff --git a/common/site.go b/common/site.go index 5d51282a..4809a9da 100644 --- a/common/site.go +++ b/common/site.go @@ -1,7 +1,11 @@ package common import ( + "encoding/json" "errors" + "io/ioutil" + "log" + "strconv" "strings" ) @@ -9,13 +13,13 @@ import ( var Site = &site{Name: "Magical Fairy Land", Language: "english"} // DbConfig holds the database configuration -var DbConfig = dbConfig{Host: "localhost"} +var DbConfig = &dbConfig{Host: "localhost"} // Config holds the more technical settings -var Config config +var Config = new(config) // Dev holds build flags and other things which should only be modified during developers or to gather additional test data -var Dev devConfig +var Dev = new(devConfig) type site struct { ShortName string @@ -28,6 +32,8 @@ type site struct { EnableEmails bool HasProxy bool Language string + + MaxRequestSize int // Alias, do not modify, will be overwritten } type dbConfig struct { @@ -53,9 +59,11 @@ type config struct { SslFullchain string HashAlgo string // Defaults to bcrypt, and in the future, possibly something stronger + MaxRequestSizeStr string MaxRequestSize int - CacheTopicUser int + UserCache string UserCacheCapacity int + TopicCache string TopicCacheCapacity int SMTPServer string @@ -64,9 +72,9 @@ type config struct { SMTPPort string //SMTPEnableTLS bool - DefaultRoute string - DefaultGroup int - ActivationGroup int + DefaultPath string + DefaultGroup int // Should be a setting in the database + ActivationGroup int // Should be a setting in the database StaffCSS string // ? - Move this into the settings table? Might be better to implement this as Group CSS DefaultForum int // The forum posts go in by default, this used to be covered by the Uncategorised Forum, but we want to replace it with a more robust solution. Make this a setting? MinifyTemplates bool @@ -87,7 +95,35 @@ type devConfig struct { TestDB bool } -func ProcessConfig() error { +// configHolder is purely for having a big struct to unmarshal data into +type configHolder struct { + Site *site + Config *config + Database *dbConfig + Dev *devConfig +} + +func LoadConfig() error { + data, err := ioutil.ReadFile("./config/config.json") + if err != nil { + return err + } + + var config configHolder + err = json.Unmarshal(data, &config) + if err != nil { + return err + } + + Site = config.Site + Config = config.Config + DbConfig = config.Database + Dev = config.Dev + + return nil +} + +func ProcessConfig() (err error) { Config.Noavatar = strings.Replace(Config.Noavatar, "{site_url}", Site.URL, -1) Site.Host = Site.URL if Site.Port != "80" && Site.Port != "443" { @@ -96,6 +132,37 @@ func ProcessConfig() error { Site.URL = strings.TrimSuffix(Site.URL, ":") Site.URL = Site.URL + ":" + Site.Port } + if Config.DefaultPath == "" { + Config.DefaultPath = "/topics/" + } + + // TODO: Bump the size of max request size up, if it's too low + Config.MaxRequestSize, err = strconv.Atoi(Config.MaxRequestSizeStr) + if err != nil { + reqSizeStr := Config.MaxRequestSizeStr + if len(reqSizeStr) < 3 { + return errors.New("Invalid unit for MaxRequestSizeStr") + } + + quantity, err := strconv.Atoi(reqSizeStr[:len(reqSizeStr)-2]) + if err != nil { + return errors.New("Unable to convert quantity to integer in MaxRequestSizeStr, found " + reqSizeStr[:len(reqSizeStr)-2]) + } + unit := reqSizeStr[len(reqSizeStr)-2:] + + // TODO: Make it a named error just in case new errors are added in here in the future + Config.MaxRequestSize, err = FriendlyUnitToBytes(quantity, unit) + if err != nil { + return errors.New("Unable to recognise unit for MaxRequestSizeStr, found " + unit) + } + } + if Dev.DebugMode { + log.Print("Set MaxRequestSize to ", Config.MaxRequestSize) + } + if Config.MaxRequestSize <= 0 { + log.Fatal("MaxRequestSize should not be zero or below") + } + Site.MaxRequestSize = Config.MaxRequestSize // ? Find a way of making these unlimited if zero? It might rule out some optimisations, waste memory, and break layouts if Config.MaxTopicTitleLength == 0 { diff --git a/common/template_init.go b/common/template_init.go index 4be5a234..ab9198ed 100644 --- a/common/template_init.go +++ b/common/template_init.go @@ -434,7 +434,8 @@ func InitTemplates() error { if !ok { panic("phraseNameInt is not a string") } - return GetTmplPhrase(phraseName) // TODO: Log non-existent phrases? + // TODO: Log non-existent phrases? + return GetTmplPhrase(phraseName) } fmap["scope"] = func(name interface{}) interface{} { diff --git a/common/theme.go b/common/theme.go index ff146d6c..93c26223 100644 --- a/common/theme.go +++ b/common/theme.go @@ -212,6 +212,15 @@ func (theme *Theme) MapTemplates() { default: LogError(errors.New("The source and destination templates are incompatible")) } + case *func(AccountDashPage, io.Writer) error: + switch sTmplPtr := sourceTmplPtr.(type) { + case *func(AccountDashPage, io.Writer) error: + //overridenTemplates[themeTmpl.Name] = d_tmpl_ptr + overridenTemplates[themeTmpl.Name] = true + *dTmplPtr = *sTmplPtr + default: + LogError(errors.New("The source and destination templates are incompatible")) + } case *func(ErrorPage, io.Writer) error: switch sTmplPtr := sourceTmplPtr.(type) { case *func(ErrorPage, io.Writer) error: diff --git a/common/theme_list.go b/common/theme_list.go index adfaf6f1..5aa9b23c 100644 --- a/common/theme_list.go +++ b/common/theme_list.go @@ -250,6 +250,13 @@ func ResetTemplateOverrides() { default: LogError(errors.New("The source and destination templates are incompatible")) } + case func(AccountDashPage, io.Writer) error: + switch dPtr := destTmplPtr.(type) { + case *func(AccountDashPage, io.Writer) error: + *dPtr = oPtr + default: + LogError(errors.New("The source and destination templates are incompatible")) + } case func(ErrorPage, io.Writer) error: switch dPtr := destTmplPtr.(type) { case *func(ErrorPage, io.Writer) error: @@ -304,6 +311,9 @@ func RunThemeTemplate(theme string, template string, pi interface{}, w io.Writer case *func(IPSearchPage, io.Writer) error: var tmpl = *tmplO return tmpl(pi.(IPSearchPage), w) + case *func(AccountDashPage, io.Writer) error: + var tmpl = *tmplO + return tmpl(pi.(AccountDashPage), w) case *func(ErrorPage, io.Writer) error: var tmpl = *tmplO return tmpl(pi.(ErrorPage), w) @@ -326,6 +336,8 @@ func RunThemeTemplate(theme string, template string, pi interface{}, w io.Writer return tmplO(pi.(CreateTopicPage), w) case func(IPSearchPage, io.Writer) error: return tmplO(pi.(IPSearchPage), w) + case func(AccountDashPage, io.Writer) error: + return tmplO(pi.(AccountDashPage), w) case func(ErrorPage, io.Writer) error: return tmplO(pi.(ErrorPage), w) case func(Page, io.Writer) error: diff --git a/common/topic_cache.go b/common/topic_cache.go index e6694311..3ae28990 100644 --- a/common/topic_cache.go +++ b/common/topic_cache.go @@ -5,6 +5,7 @@ import ( "sync/atomic" ) +// TopicCache is an interface which spits out topics from a fast cache rather than the database, whether from memory or from an application like Redis. Topics may not be present in the cache but may be in the database type TopicCache interface { Get(id int) (*Topic, error) GetUnsafe(id int) (*Topic, error) @@ -19,6 +20,7 @@ type TopicCache interface { GetCapacity() int } +// MemoryTopicCache stores and pulls topics out of the current process' memory type MemoryTopicCache struct { items map[int]*Topic length int64 // sync/atomic only lets us operate on int32s and int64s @@ -35,6 +37,7 @@ func NewMemoryTopicCache(capacity int) *MemoryTopicCache { } } +// Get fetches a topic by ID. Returns ErrNoRows if not present. func (mts *MemoryTopicCache) Get(id int) (*Topic, error) { mts.RLock() item, ok := mts.items[id] @@ -45,6 +48,7 @@ func (mts *MemoryTopicCache) Get(id int) (*Topic, error) { return item, ErrNoRows } +// GetUnsafe fetches a topic by ID. Returns ErrNoRows if not present. THIS METHOD IS NOT THREAD-SAFE. func (mts *MemoryTopicCache) GetUnsafe(id int) (*Topic, error) { item, ok := mts.items[id] if ok { @@ -53,6 +57,7 @@ func (mts *MemoryTopicCache) GetUnsafe(id int) (*Topic, error) { return item, ErrNoRows } +// Set overwrites the value of a topic in the cache, whether it's present or not. May return a capacity overflow error. func (mts *MemoryTopicCache) Set(item *Topic) error { mts.Lock() _, ok := mts.items[item.ID] @@ -69,42 +74,56 @@ func (mts *MemoryTopicCache) Set(item *Topic) error { return nil } +// Add adds a topic to the cache, similar to Set, but it's only intended for new items. This method might be deprecated in the near future, use Set. May return a capacity overflow error. +// ? Is this redundant if we have Set? Are the efficiency wins worth this? Is this even used? func (mts *MemoryTopicCache) Add(item *Topic) error { + mts.Lock() if int(mts.length) >= mts.capacity { + mts.Unlock() return ErrStoreCapacityOverflow } - mts.Lock() mts.items[item.ID] = item mts.Unlock() atomic.AddInt64(&mts.length, 1) return nil } -// TODO: Make these length increments thread-safe. Ditto for the other DataStores +// AddUnsafe is the unsafe version of Add. May return a capacity overflow error. THIS METHOD IS NOT THREAD-SAFE. func (mts *MemoryTopicCache) AddUnsafe(item *Topic) error { if int(mts.length) >= mts.capacity { return ErrStoreCapacityOverflow } mts.items[item.ID] = item - atomic.AddInt64(&mts.length, 1) + mts.length = int64(len(mts.items)) return nil } -// TODO: Make these length decrements thread-safe. Ditto for the other DataStores +// Remove removes a topic from the cache by ID, if they exist. Returns ErrNoRows if no items exist. func (mts *MemoryTopicCache) Remove(id int) error { mts.Lock() + _, ok := mts.items[id] + if !ok { + mts.Unlock() + return ErrNoRows + } delete(mts.items, id) mts.Unlock() atomic.AddInt64(&mts.length, -1) return nil } +// RemoveUnsafe is the unsafe version of Remove. THIS METHOD IS NOT THREAD-SAFE. func (mts *MemoryTopicCache) RemoveUnsafe(id int) error { + _, ok := mts.items[id] + if !ok { + return ErrNoRows + } delete(mts.items, id) atomic.AddInt64(&mts.length, -1) return nil } +// Flush removes all the topics from the cache, useful for tests. func (mts *MemoryTopicCache) Flush() { mts.Lock() mts.items = make(map[int]*Topic) @@ -118,10 +137,13 @@ func (mts *MemoryTopicCache) Length() int { return int(mts.length) } +// SetCapacity sets the maximum number of topics which this cache can hold func (mts *MemoryTopicCache) SetCapacity(capacity int) { + // Ints are moved in a single instruction, so this should be thread-safe mts.capacity = capacity } +// GetCapacity returns the maximum number of topics this cache can hold func (mts *MemoryTopicCache) GetCapacity() int { return mts.capacity } diff --git a/common/user.go b/common/user.go index fcb5f00a..5fe59af9 100644 --- a/common/user.go +++ b/common/user.go @@ -61,6 +61,7 @@ type UserStmts struct { setUsername *sql.Stmt incrementTopics *sql.Stmt updateLevel *sql.Stmt + update *sql.Stmt // TODO: Split these into a sub-struct incrementScore *sql.Stmt @@ -81,13 +82,15 @@ func init() { DbInits.Add(func(acc *qgen.Accumulator) error { var where = "uid = ?" userStmts = UserStmts{ - activate: acc.SimpleUpdate("users", "active = 1", where), - changeGroup: acc.SimpleUpdate("users", "group = ?", where), // TODO: Implement user_count for users_groups here - delete: acc.SimpleDelete("users", where), - setAvatar: acc.Update("users").Set("avatar = ?").Where(where).Prepare(), - setUsername: acc.Update("users").Set("name = ?").Where(where).Prepare(), - incrementTopics: acc.SimpleUpdate("users", "topics = topics + ?", where), - updateLevel: acc.SimpleUpdate("users", "level = ?", where), + activate: acc.SimpleUpdate("users", "active = 1", where), + changeGroup: acc.SimpleUpdate("users", "group = ?", where), // TODO: Implement user_count for users_groups here + delete: acc.SimpleDelete("users", where), + setAvatar: acc.Update("users").Set("avatar = ?").Where(where).Prepare(), + setUsername: acc.Update("users").Set("name = ?").Where(where).Prepare(), + incrementTopics: acc.SimpleUpdate("users", "topics = topics + ?", where), + updateLevel: acc.SimpleUpdate("users", "level = ?", where), + update: acc.Update("users").Set("name = ?, email = ?, group = ?").Where("uid = ?").Prepare(), // TODO: Implement user_count for users_groups on things which use this + incrementScore: acc.SimpleUpdate("users", "score = score + ?", where), incrementPosts: acc.SimpleUpdate("users", "posts = posts + ?", where), incrementBigposts: acc.SimpleUpdate("users", "posts = posts + ?, bigposts = bigposts + ?", where), @@ -253,6 +256,10 @@ func (user *User) UpdateIP(host string) error { return err } +func (user *User) Update(newname string, newemail string, newgroup int) (err error) { + return user.bindStmt(userStmts.update, newname, newemail, newgroup) +} + func (user *User) IncreasePostStats(wcount int, topic bool) (err error) { var mod int baseScore := 1 diff --git a/common/user_cache.go b/common/user_cache.go index f10c9aba..f738a184 100644 --- a/common/user_cache.go +++ b/common/user_cache.go @@ -5,6 +5,7 @@ import ( "sync/atomic" ) +// UserCache is an interface which spits out users from a fast cache rather than the database, whether from memory or from an application like Redis. Users may not be present in the cache but may be in the database type UserCache interface { Get(id int) (*User, error) GetUnsafe(id int) (*User, error) @@ -20,6 +21,7 @@ type UserCache interface { GetCapacity() int } +// MemoryUserCache stores and pulls users out of the current process' memory type MemoryUserCache struct { items map[int]*User length int64 @@ -36,6 +38,7 @@ func NewMemoryUserCache(capacity int) *MemoryUserCache { } } +// Get fetches a user by ID. Returns ErrNoRows if not present. func (mus *MemoryUserCache) Get(id int) (*User, error) { mus.RLock() item, ok := mus.items[id] @@ -46,6 +49,7 @@ func (mus *MemoryUserCache) Get(id int) (*User, error) { return item, ErrNoRows } +// BulkGet fetches multiple users by their IDs. Indices without users will be set to nil, so make sure you check for those, we might want to change this behaviour to make it less confusing. func (mus *MemoryUserCache) BulkGet(ids []int) (list []*User) { list = make([]*User, len(ids)) mus.RLock() @@ -56,6 +60,7 @@ func (mus *MemoryUserCache) BulkGet(ids []int) (list []*User) { return list } +// GetUnsafe fetches a user by ID. Returns ErrNoRows if not present. THIS METHOD IS NOT THREAD-SAFE. func (mus *MemoryUserCache) GetUnsafe(id int) (*User, error) { item, ok := mus.items[id] if ok { @@ -64,6 +69,7 @@ func (mus *MemoryUserCache) GetUnsafe(id int) (*User, error) { return item, ErrNoRows } +// Set overwrites the value of a user in the cache, whether it's present or not. May return a capacity overflow error. func (mus *MemoryUserCache) Set(item *User) error { mus.Lock() user, ok := mus.items[item.ID] @@ -81,17 +87,21 @@ func (mus *MemoryUserCache) Set(item *User) error { return nil } +// Add adds a user to the cache, similar to Set, but it's only intended for new items. This method might be deprecated in the near future, use Set. May return a capacity overflow error. +// ? Is this redundant if we have Set? Are the efficiency wins worth this? Is this even used? func (mus *MemoryUserCache) Add(item *User) error { + mus.Lock() if int(mus.length) >= mus.capacity { + mus.Unlock() return ErrStoreCapacityOverflow } - mus.Lock() mus.items[item.ID] = item mus.length = int64(len(mus.items)) mus.Unlock() return nil } +// AddUnsafe is the unsafe version of Add. May return a capacity overflow error. THIS METHOD IS NOT THREAD-SAFE. func (mus *MemoryUserCache) AddUnsafe(item *User) error { if int(mus.length) >= mus.capacity { return ErrStoreCapacityOverflow @@ -101,6 +111,7 @@ func (mus *MemoryUserCache) AddUnsafe(item *User) error { return nil } +// Remove removes a user from the cache by ID, if they exist. Returns ErrNoRows if no items exist. func (mus *MemoryUserCache) Remove(id int) error { mus.Lock() _, ok := mus.items[id] @@ -114,6 +125,7 @@ func (mus *MemoryUserCache) Remove(id int) error { return nil } +// RemoveUnsafe is the unsafe version of Remove. THIS METHOD IS NOT THREAD-SAFE. func (mus *MemoryUserCache) RemoveUnsafe(id int) error { _, ok := mus.items[id] if !ok { @@ -124,6 +136,7 @@ func (mus *MemoryUserCache) RemoveUnsafe(id int) error { return nil } +// Flush removes all the users from the cache, useful for tests. func (mus *MemoryUserCache) Flush() { mus.Lock() mus.items = make(map[int]*User) @@ -137,10 +150,13 @@ func (mus *MemoryUserCache) Length() int { return int(mus.length) } +// SetCapacity sets the maximum number of users which this cache can hold func (mus *MemoryUserCache) SetCapacity(capacity int) { + // Ints are moved in a single instruction, so this should be thread-safe mus.capacity = capacity } +// GetCapacity returns the maximum number of users this cache can hold func (mus *MemoryUserCache) GetCapacity() int { return mus.capacity } diff --git a/common/utils.go b/common/utils.go index a95a7c6c..b47079e9 100644 --- a/common/utils.go +++ b/common/utils.go @@ -8,6 +8,7 @@ package common import ( "crypto/rand" + "encoding/base32" "encoding/base64" "errors" "fmt" @@ -41,7 +42,7 @@ func (version *Version) String() (out string) { return } -// GenerateSafeString is for generating a cryptographically secure set of random bytes... +// GenerateSafeString is for generating a cryptographically secure set of random bytes which is base64 encoded and safe for URLs // TODO: Write a test for this func GenerateSafeString(length int) (string, error) { rb := make([]byte, length) @@ -52,6 +53,17 @@ func GenerateSafeString(length int) (string, error) { return base64.URLEncoding.EncodeToString(rb), nil } +// GenerateStd32SafeString is for generating a cryptographically secure set of random bytes which is base32 encoded +// ? - Safe for URLs? Mostly likely due to the small range of characters +func GenerateStd32SafeString(length int) (string, error) { + rb := make([]byte, length) + _, err := rand.Read(rb) + if err != nil { + return "", err + } + return base32.StdEncoding.EncodeToString(rb), nil +} + // TODO: Write a test for this func RelativeTimeFromString(in string) (string, error) { if in == "" { @@ -147,6 +159,27 @@ func ConvertByteInUnit(bytes float64, unit string) (count float64) { return } +// TODO: Write a test for this +func FriendlyUnitToBytes(quantity int, unit string) (bytes int, err error) { + switch unit { + case "PB": + bytes = quantity * Petabyte + case "TB": + bytes = quantity * Terabyte + case "GB": + bytes = quantity * Gigabyte + case "MB": + bytes = quantity * Megabyte + case "KB": + bytes = quantity * Kilobyte + case "": + // Do nothing + default: + return bytes, errors.New("Unknown unit") + } + return bytes, nil +} + // TODO: Write a test for this // TODO: Re-add T as int64 func ConvertUnit(num int) (int, string) { diff --git a/config/config_example.json b/config/config_example.json new file mode 100644 index 00000000..85e0d3d0 --- /dev/null +++ b/config/config_example.json @@ -0,0 +1,55 @@ +{ + "Site": { + "ShortName":"Exa", + "Name":"Example", + "URL":"localhost", + "Port":"80", + "EnableSsl":false, + "EnableEmails":false, + "HasProxy":false, + "Language": "english" + }, + "Config": { + "SslPrivkey": "", + "SslFullchain": "", + "SMTPServer": "", + "SMTPUsername": "", + "SMTPPassword": "", + "SMTPPort": "25", + + "MaxRequestSizeStr":"5MB", + "UserCache":"static", + "TopicCache":"static", + "UserCacheCapacity":120, + "TopicCacheCapacity":200, + "DefaultPath":"/topics/", + "DefaultGroup":3, + "ActivationGroup":5, + "StaffCSS":"staff_post", + "DefaultForum":2, + "MinifyTemplates":true, + "BuildSlugs":true, + "ServerCount":1, + "Noavatar":"https://api.adorable.io/avatars/285/{id}@{site_url}.png", + "ItemsPerPage":25 + }, + "Database": { + "Adapter": "mysql", + "Host": "localhost", + "Username": "anything_but_root", + "Password": "please_use_a_password_that_is_actually_secure", + "Dbname": "gosora", + "Port": "3306", + + "TestAdapter": "mysql", + "TestHost": "localhost", + "TestUsername": "root", + "TestPassword": "password", + "TestDbname": "gosora_test", + "TestPort": "3306" + }, + "Dev": { + "DebugMode":true, + "SuperDebug":false + } +} \ No newline at end of file diff --git a/config_default.noparse b/config_default.noparse deleted file mode 100644 index 3d11ee93..00000000 --- a/config_default.noparse +++ /dev/null @@ -1,66 +0,0 @@ -package config - -import "../common" - -func Config() { - // Site Info - common.Site.ShortName = "Ts" // This should be less than three letters to fit in the navbar - common.Site.Name = "Test Site" - common.Site.Email = "" - common.Site.URL = "localhost" - common.Site.Port = "8080" // 8080 - common.Site.EnableSsl = false - common.Site.EnableEmails = false - common.Site.HasProxy = false // Cloudflare counts as this, if it's sitting in the middle - common.Config.SslPrivkey = "" - common.Config.SslFullchain = "" - common.Site.Language = "english" - - // Database details - common.DbConfig.Host = "localhost" - common.DbConfig.Username = "root" - common.DbConfig.Password = "password" - common.DbConfig.Dbname = "gosora" - common.DbConfig.Port = "3306" // You probably won't need to change this - - // MySQL Test Database details - common.DbConfig.TestHost = "localhost" - common.DbConfig.TestUsername = "root" - common.DbConfig.TestPassword = "" - common.DbConfig.TestDbname = "gosora_test" // The name of the test database, leave blank to disable. DON'T USE YOUR PRODUCTION DATABASE FOR THIS. LEAVE BLANK IF YOU DON'T KNOW WHAT THIS MEANS. - common.DbConfig.TestPort = "3306" - - // Limiters - common.Config.MaxRequestSize = 5 * common.Megabyte - - // Caching - common.Config.CacheTopicUser = common.CACHE_STATIC - common.Config.UserCacheCapacity = 120 // The max number of users held in memory - common.Config.TopicCacheCapacity = 200 // The max number of topics held in memory - - // Email - common.Config.SMTPServer = "" - common.Config.SMTPUsername = "" - common.Config.SMTPPassword = "" - common.Config.SMTPPort = "25" - - // Misc - common.Config.DefaultRoute = "routes.TopicList" - common.Config.DefaultGroup = 3 // Should be a setting in the database - common.Config.ActivationGroup = 5 // Should be a setting in the database - common.Config.StaffCSS = "staff_post" - common.Config.DefaultForum = 2 - common.Config.MinifyTemplates = true - common.Config.ServerCount = 1 // Experimental: Enable Cross-Server Synchronisation and several other features - - //common.Config.Noavatar = "https://api.adorable.io/avatars/{width}/{id}@{site_url}.png" - common.Config.Noavatar = "https://api.adorable.io/avatars/285/{id}@{site_url}.png" - common.Config.ItemsPerPage = 25 - - // Developer flags - //common.Dev.DebugMode = true - //common.Dev.SuperDebug = true - //common.Dev.TemplateDebug = true - //common.Dev.Profiling = true - //common.Dev.TestDB = true -} diff --git a/database.go b/database.go index c79c760d..bb501af6 100644 --- a/database.go +++ b/database.go @@ -47,9 +47,12 @@ func InitDatabase() (err error) { log.Print("Initialising the user and topic stores") var ucache common.UserCache - var tcache common.TopicCache - if common.Config.CacheTopicUser == common.CACHE_STATIC { + if common.Config.UserCache == "static" { ucache = common.NewMemoryUserCache(common.Config.UserCacheCapacity) + } + + var tcache common.TopicCache + if common.Config.TopicCache == "static" { tcache = common.NewMemoryTopicCache(common.Config.TopicCacheCapacity) } diff --git a/docs/landing_page.md b/docs/landing_page.md new file mode 100644 index 00000000..e3ba6450 --- /dev/null +++ b/docs/landing_page.md @@ -0,0 +1,3 @@ +# Landing Page + +You can change the landing page of your site (in other words, the page the user lands on by default, aka the index or `/`) by tweaking the DefaultPath configuration value. More on this later. \ No newline at end of file diff --git a/gen_mssql.go b/gen_mssql.go index 69b9db90..46ddf0be 100644 --- a/gen_mssql.go +++ b/gen_mssql.go @@ -21,7 +21,6 @@ type Stmts struct { updatePlugin *sql.Stmt updatePluginInstall *sql.Stmt updateTheme *sql.Stmt - updateUser *sql.Stmt updateGroupPerms *sql.Stmt updateGroup *sql.Stmt updateEmail *sql.Stmt @@ -141,14 +140,6 @@ func _gen_mssql() (err error) { return err } - common.DebugLog("Preparing updateUser statement.") - stmts.updateUser, err = db.Prepare("UPDATE [users] SET [name] = ?,[email] = ?,[group] = ? WHERE [uid] = ?") - if err != nil { - log.Print("Error in updateUser statement.") - log.Print("Bad Query: ","UPDATE [users] SET [name] = ?,[email] = ?,[group] = ? WHERE [uid] = ?") - return err - } - common.DebugLog("Preparing updateGroupPerms statement.") stmts.updateGroupPerms, err = db.Prepare("UPDATE [users_groups] SET [permissions] = ? WHERE [gid] = ?") if err != nil { diff --git a/gen_mysql.go b/gen_mysql.go index 89a230fe..030fde40 100644 --- a/gen_mysql.go +++ b/gen_mysql.go @@ -23,7 +23,6 @@ type Stmts struct { updatePlugin *sql.Stmt updatePluginInstall *sql.Stmt updateTheme *sql.Stmt - updateUser *sql.Stmt updateGroupPerms *sql.Stmt updateGroup *sql.Stmt updateEmail *sql.Stmt @@ -131,13 +130,6 @@ func _gen_mysql() (err error) { return err } - common.DebugLog("Preparing updateUser statement.") - stmts.updateUser, err = db.Prepare("UPDATE `users` SET `name` = ?,`email` = ?,`group` = ? WHERE `uid` = ?") - if err != nil { - log.Print("Error in updateUser statement.") - return err - } - common.DebugLog("Preparing updateGroupPerms statement.") stmts.updateGroupPerms, err = db.Prepare("UPDATE `users_groups` SET `permissions` = ? WHERE `gid` = ?") if err != nil { diff --git a/gen_pgsql.go b/gen_pgsql.go index 114840cd..e6897249 100644 --- a/gen_pgsql.go +++ b/gen_pgsql.go @@ -16,7 +16,6 @@ type Stmts struct { updatePlugin *sql.Stmt updatePluginInstall *sql.Stmt updateTheme *sql.Stmt - updateUser *sql.Stmt updateGroupPerms *sql.Stmt updateGroup *sql.Stmt updateEmail *sql.Stmt @@ -87,13 +86,6 @@ func _gen_pgsql() (err error) { return err } - common.DebugLog("Preparing updateUser statement.") - stmts.updateUser, err = db.Prepare("UPDATE `users` SET `name` = ?,`email` = ?,`group` = ? WHERE `uid` = ?") - if err != nil { - log.Print("Error in updateUser statement.") - return err - } - common.DebugLog("Preparing updateGroupPerms statement.") stmts.updateGroupPerms, err = db.Prepare("UPDATE `users_groups` SET `permissions` = ? WHERE `gid` = ?") if err != nil { diff --git a/gen_router.go b/gen_router.go index 441e96b0..5707a637 100644 --- a/gen_router.go +++ b/gen_router.go @@ -66,9 +66,9 @@ var RouteMap = map[string]interface{}{ "routePanelPluginsActivate": routePanelPluginsActivate, "routePanelPluginsDeactivate": routePanelPluginsDeactivate, "routePanelPluginsInstall": routePanelPluginsInstall, - "routePanelUsers": routePanelUsers, - "routePanelUsersEdit": routePanelUsersEdit, - "routePanelUsersEditSubmit": routePanelUsersEditSubmit, + "panel.Users": panel.Users, + "panel.UsersEdit": panel.UsersEdit, + "panel.UsersEditSubmit": panel.UsersEditSubmit, "panel.AnalyticsViews": panel.AnalyticsViews, "panel.AnalyticsRoutes": panel.AnalyticsRoutes, "panel.AnalyticsAgents": panel.AnalyticsAgents, @@ -95,12 +95,15 @@ var RouteMap = map[string]interface{}{ "panel.LogsMod": panel.LogsMod, "panel.Debug": panel.Debug, "routePanelDashboard": routePanelDashboard, - "routes.AccountEditCritical": routes.AccountEditCritical, - "routes.AccountEditCriticalSubmit": routes.AccountEditCriticalSubmit, - "routes.AccountEditAvatar": routes.AccountEditAvatar, + "routes.AccountEdit": routes.AccountEdit, + "routes.AccountEditPassword": routes.AccountEditPassword, + "routes.AccountEditPasswordSubmit": routes.AccountEditPasswordSubmit, "routes.AccountEditAvatarSubmit": routes.AccountEditAvatarSubmit, - "routes.AccountEditUsername": routes.AccountEditUsername, "routes.AccountEditUsernameSubmit": routes.AccountEditUsernameSubmit, + "routes.AccountEditMFA": routes.AccountEditMFA, + "routes.AccountEditMFASetup": routes.AccountEditMFASetup, + "routes.AccountEditMFASetupSubmit": routes.AccountEditMFASetupSubmit, + "routes.AccountEditMFADisableSubmit": routes.AccountEditMFADisableSubmit, "routes.AccountEditEmail": routes.AccountEditEmail, "routes.AccountEditEmailTokenSubmit": routes.AccountEditEmailTokenSubmit, "routes.ViewProfile": routes.ViewProfile, @@ -131,6 +134,8 @@ var RouteMap = map[string]interface{}{ "routes.AccountRegister": routes.AccountRegister, "routes.AccountLogout": routes.AccountLogout, "routes.AccountLoginSubmit": routes.AccountLoginSubmit, + "routes.AccountLoginMFAVerify": routes.AccountLoginMFAVerify, + "routes.AccountLoginMFAVerifySubmit": routes.AccountLoginMFAVerifySubmit, "routes.AccountRegisterSubmit": routes.AccountRegisterSubmit, "routes.DynamicRoute": routes.DynamicRoute, "routes.UploadedFile": routes.UploadedFile, @@ -188,9 +193,9 @@ var routeMapEnum = map[string]int{ "routePanelPluginsActivate": 43, "routePanelPluginsDeactivate": 44, "routePanelPluginsInstall": 45, - "routePanelUsers": 46, - "routePanelUsersEdit": 47, - "routePanelUsersEditSubmit": 48, + "panel.Users": 46, + "panel.UsersEdit": 47, + "panel.UsersEditSubmit": 48, "panel.AnalyticsViews": 49, "panel.AnalyticsRoutes": 50, "panel.AnalyticsAgents": 51, @@ -217,49 +222,54 @@ var routeMapEnum = map[string]int{ "panel.LogsMod": 72, "panel.Debug": 73, "routePanelDashboard": 74, - "routes.AccountEditCritical": 75, - "routes.AccountEditCriticalSubmit": 76, - "routes.AccountEditAvatar": 77, + "routes.AccountEdit": 75, + "routes.AccountEditPassword": 76, + "routes.AccountEditPasswordSubmit": 77, "routes.AccountEditAvatarSubmit": 78, - "routes.AccountEditUsername": 79, - "routes.AccountEditUsernameSubmit": 80, - "routes.AccountEditEmail": 81, - "routes.AccountEditEmailTokenSubmit": 82, - "routes.ViewProfile": 83, - "routes.BanUserSubmit": 84, - "routes.UnbanUser": 85, - "routes.ActivateUser": 86, - "routes.IPSearch": 87, - "routes.CreateTopicSubmit": 88, - "routes.EditTopicSubmit": 89, - "routes.DeleteTopicSubmit": 90, - "routes.StickTopicSubmit": 91, - "routes.UnstickTopicSubmit": 92, - "routes.LockTopicSubmit": 93, - "routes.UnlockTopicSubmit": 94, - "routes.MoveTopicSubmit": 95, - "routes.LikeTopicSubmit": 96, - "routes.ViewTopic": 97, - "routes.CreateReplySubmit": 98, - "routes.ReplyEditSubmit": 99, - "routes.ReplyDeleteSubmit": 100, - "routes.ReplyLikeSubmit": 101, - "routes.ProfileReplyCreateSubmit": 102, - "routes.ProfileReplyEditSubmit": 103, - "routes.ProfileReplyDeleteSubmit": 104, - "routes.PollVote": 105, - "routes.PollResults": 106, - "routes.AccountLogin": 107, - "routes.AccountRegister": 108, - "routes.AccountLogout": 109, - "routes.AccountLoginSubmit": 110, - "routes.AccountRegisterSubmit": 111, - "routes.DynamicRoute": 112, - "routes.UploadedFile": 113, - "routes.StaticFile": 114, - "routes.RobotsTxt": 115, - "routes.SitemapXml": 116, - "routes.BadRoute": 117, + "routes.AccountEditUsernameSubmit": 79, + "routes.AccountEditMFA": 80, + "routes.AccountEditMFASetup": 81, + "routes.AccountEditMFASetupSubmit": 82, + "routes.AccountEditMFADisableSubmit": 83, + "routes.AccountEditEmail": 84, + "routes.AccountEditEmailTokenSubmit": 85, + "routes.ViewProfile": 86, + "routes.BanUserSubmit": 87, + "routes.UnbanUser": 88, + "routes.ActivateUser": 89, + "routes.IPSearch": 90, + "routes.CreateTopicSubmit": 91, + "routes.EditTopicSubmit": 92, + "routes.DeleteTopicSubmit": 93, + "routes.StickTopicSubmit": 94, + "routes.UnstickTopicSubmit": 95, + "routes.LockTopicSubmit": 96, + "routes.UnlockTopicSubmit": 97, + "routes.MoveTopicSubmit": 98, + "routes.LikeTopicSubmit": 99, + "routes.ViewTopic": 100, + "routes.CreateReplySubmit": 101, + "routes.ReplyEditSubmit": 102, + "routes.ReplyDeleteSubmit": 103, + "routes.ReplyLikeSubmit": 104, + "routes.ProfileReplyCreateSubmit": 105, + "routes.ProfileReplyEditSubmit": 106, + "routes.ProfileReplyDeleteSubmit": 107, + "routes.PollVote": 108, + "routes.PollResults": 109, + "routes.AccountLogin": 110, + "routes.AccountRegister": 111, + "routes.AccountLogout": 112, + "routes.AccountLoginSubmit": 113, + "routes.AccountLoginMFAVerify": 114, + "routes.AccountLoginMFAVerifySubmit": 115, + "routes.AccountRegisterSubmit": 116, + "routes.DynamicRoute": 117, + "routes.UploadedFile": 118, + "routes.StaticFile": 119, + "routes.RobotsTxt": 120, + "routes.SitemapXml": 121, + "routes.BadRoute": 122, } var reverseRouteMapEnum = map[int]string{ 0: "routeAPI", @@ -308,9 +318,9 @@ var reverseRouteMapEnum = map[int]string{ 43: "routePanelPluginsActivate", 44: "routePanelPluginsDeactivate", 45: "routePanelPluginsInstall", - 46: "routePanelUsers", - 47: "routePanelUsersEdit", - 48: "routePanelUsersEditSubmit", + 46: "panel.Users", + 47: "panel.UsersEdit", + 48: "panel.UsersEditSubmit", 49: "panel.AnalyticsViews", 50: "panel.AnalyticsRoutes", 51: "panel.AnalyticsAgents", @@ -337,49 +347,54 @@ var reverseRouteMapEnum = map[int]string{ 72: "panel.LogsMod", 73: "panel.Debug", 74: "routePanelDashboard", - 75: "routes.AccountEditCritical", - 76: "routes.AccountEditCriticalSubmit", - 77: "routes.AccountEditAvatar", + 75: "routes.AccountEdit", + 76: "routes.AccountEditPassword", + 77: "routes.AccountEditPasswordSubmit", 78: "routes.AccountEditAvatarSubmit", - 79: "routes.AccountEditUsername", - 80: "routes.AccountEditUsernameSubmit", - 81: "routes.AccountEditEmail", - 82: "routes.AccountEditEmailTokenSubmit", - 83: "routes.ViewProfile", - 84: "routes.BanUserSubmit", - 85: "routes.UnbanUser", - 86: "routes.ActivateUser", - 87: "routes.IPSearch", - 88: "routes.CreateTopicSubmit", - 89: "routes.EditTopicSubmit", - 90: "routes.DeleteTopicSubmit", - 91: "routes.StickTopicSubmit", - 92: "routes.UnstickTopicSubmit", - 93: "routes.LockTopicSubmit", - 94: "routes.UnlockTopicSubmit", - 95: "routes.MoveTopicSubmit", - 96: "routes.LikeTopicSubmit", - 97: "routes.ViewTopic", - 98: "routes.CreateReplySubmit", - 99: "routes.ReplyEditSubmit", - 100: "routes.ReplyDeleteSubmit", - 101: "routes.ReplyLikeSubmit", - 102: "routes.ProfileReplyCreateSubmit", - 103: "routes.ProfileReplyEditSubmit", - 104: "routes.ProfileReplyDeleteSubmit", - 105: "routes.PollVote", - 106: "routes.PollResults", - 107: "routes.AccountLogin", - 108: "routes.AccountRegister", - 109: "routes.AccountLogout", - 110: "routes.AccountLoginSubmit", - 111: "routes.AccountRegisterSubmit", - 112: "routes.DynamicRoute", - 113: "routes.UploadedFile", - 114: "routes.StaticFile", - 115: "routes.RobotsTxt", - 116: "routes.SitemapXml", - 117: "routes.BadRoute", + 79: "routes.AccountEditUsernameSubmit", + 80: "routes.AccountEditMFA", + 81: "routes.AccountEditMFASetup", + 82: "routes.AccountEditMFASetupSubmit", + 83: "routes.AccountEditMFADisableSubmit", + 84: "routes.AccountEditEmail", + 85: "routes.AccountEditEmailTokenSubmit", + 86: "routes.ViewProfile", + 87: "routes.BanUserSubmit", + 88: "routes.UnbanUser", + 89: "routes.ActivateUser", + 90: "routes.IPSearch", + 91: "routes.CreateTopicSubmit", + 92: "routes.EditTopicSubmit", + 93: "routes.DeleteTopicSubmit", + 94: "routes.StickTopicSubmit", + 95: "routes.UnstickTopicSubmit", + 96: "routes.LockTopicSubmit", + 97: "routes.UnlockTopicSubmit", + 98: "routes.MoveTopicSubmit", + 99: "routes.LikeTopicSubmit", + 100: "routes.ViewTopic", + 101: "routes.CreateReplySubmit", + 102: "routes.ReplyEditSubmit", + 103: "routes.ReplyDeleteSubmit", + 104: "routes.ReplyLikeSubmit", + 105: "routes.ProfileReplyCreateSubmit", + 106: "routes.ProfileReplyEditSubmit", + 107: "routes.ProfileReplyDeleteSubmit", + 108: "routes.PollVote", + 109: "routes.PollResults", + 110: "routes.AccountLogin", + 111: "routes.AccountRegister", + 112: "routes.AccountLogout", + 113: "routes.AccountLoginSubmit", + 114: "routes.AccountLoginMFAVerify", + 115: "routes.AccountLoginMFAVerifySubmit", + 116: "routes.AccountRegisterSubmit", + 117: "routes.DynamicRoute", + 118: "routes.UploadedFile", + 119: "routes.StaticFile", + 120: "routes.RobotsTxt", + 121: "routes.SitemapXml", + 122: "routes.BadRoute", } var osMapEnum = map[string]int{ "unknown": 0, @@ -615,9 +630,9 @@ func (router *GenRouter) SuspiciousRequest(req *http.Request, prepend string) { counters.AgentViewCounter.Bump(27) } -// TODO: Pass the default route or config struct to the router rather than accessing it via a package global -// TODO: SetDefaultRoute -// TODO: GetDefaultRoute +// TODO: Pass the default path or config struct to the router rather than accessing it via a package global +// TODO: SetDefaultPath +// TODO: GetDefaultPath func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Redirect www. requests to the right place if req.Host == "www." + common.Site.Host { @@ -655,6 +670,11 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { if strings.Contains(req.URL.Path,"..") || strings.Contains(req.URL.Path,"--") || strings.Contains(lowerPath,".php") || strings.Contains(lowerPath,".asp") || strings.Contains(lowerPath,".cgi") || strings.Contains(lowerPath,".py") || strings.Contains(lowerPath,".sql") || strings.Contains(lowerPath,".action") { router.SuspiciousRequest(req,"") } + + // Indirect the default route onto a different one + if req.URL.Path == "/" { + req.URL.Path = common.Config.DefaultPath + } var prefix, extraData string prefix = req.URL.Path[0:strings.IndexByte(req.URL.Path[1:],'/') + 1] @@ -670,7 +690,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { counters.GlobalViewCounter.Bump() if prefix == "/static" { - counters.RouteViewCounter.Bump(114) + counters.RouteViewCounter.Bump(119) req.URL.Path += extraData routes.StaticFile(w, req) return @@ -1213,10 +1233,10 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { err = routePanelPluginsInstall(w,req,user,extraData) case "/panel/users/": counters.RouteViewCounter.Bump(46) - err = routePanelUsers(w,req,user) + err = panel.Users(w,req,user) case "/panel/users/edit/": counters.RouteViewCounter.Bump(47) - err = routePanelUsersEdit(w,req,user,extraData) + err = panel.UsersEdit(w,req,user,extraData) case "/panel/users/edit/submit/": err = common.NoSessionMismatch(w,req,user) if err != nil { @@ -1225,7 +1245,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { } counters.RouteViewCounter.Bump(48) - err = routePanelUsersEditSubmit(w,req,user,extraData) + err = panel.UsersEditSubmit(w,req,user,extraData) case "/panel/analytics/views/": err = common.ParseForm(w,req,user) if err != nil { @@ -1394,7 +1414,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { } case "/user": switch(req.URL.Path) { - case "/user/edit/critical/": + case "/user/edit/": err = common.MemberOnly(w,req,user) if err != nil { router.handleError(err,w,req,user) @@ -1402,8 +1422,17 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { } counters.RouteViewCounter.Bump(75) - err = routes.AccountEditCritical(w,req,user) - case "/user/edit/critical/submit/": + err = routes.AccountEdit(w,req,user) + case "/user/edit/password/": + err = common.MemberOnly(w,req,user) + if err != nil { + router.handleError(err,w,req,user) + return + } + + counters.RouteViewCounter.Bump(76) + err = routes.AccountEditPassword(w,req,user) + case "/user/edit/password/submit/": err = common.NoSessionMismatch(w,req,user) if err != nil { router.handleError(err,w,req,user) @@ -1416,17 +1445,8 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(76) - err = routes.AccountEditCriticalSubmit(w,req,user) - case "/user/edit/avatar/": - err = common.MemberOnly(w,req,user) - if err != nil { - router.handleError(err,w,req,user) - return - } - counters.RouteViewCounter.Bump(77) - err = routes.AccountEditAvatar(w,req,user) + err = routes.AccountEditPasswordSubmit(w,req,user) case "/user/edit/avatar/submit/": err = common.MemberOnly(w,req,user) if err != nil { @@ -1447,15 +1467,6 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { counters.RouteViewCounter.Bump(78) err = routes.AccountEditAvatarSubmit(w,req,user) - case "/user/edit/username/": - err = common.MemberOnly(w,req,user) - if err != nil { - router.handleError(err,w,req,user) - return - } - - counters.RouteViewCounter.Bump(79) - err = routes.AccountEditUsername(w,req,user) case "/user/edit/username/submit/": err = common.NoSessionMismatch(w,req,user) if err != nil { @@ -1469,9 +1480,18 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(80) + counters.RouteViewCounter.Bump(79) err = routes.AccountEditUsernameSubmit(w,req,user) - case "/user/edit/email/": + case "/user/edit/mfa/": + err = common.MemberOnly(w,req,user) + if err != nil { + router.handleError(err,w,req,user) + return + } + + counters.RouteViewCounter.Bump(80) + err = routes.AccountEditMFA(w,req,user) + case "/user/edit/mfa/setup/": err = common.MemberOnly(w,req,user) if err != nil { router.handleError(err,w,req,user) @@ -1479,6 +1499,45 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { } counters.RouteViewCounter.Bump(81) + err = routes.AccountEditMFASetup(w,req,user) + case "/user/edit/mfa/setup/submit/": + err = common.NoSessionMismatch(w,req,user) + if err != nil { + router.handleError(err,w,req,user) + return + } + + err = common.MemberOnly(w,req,user) + if err != nil { + router.handleError(err,w,req,user) + return + } + + counters.RouteViewCounter.Bump(82) + err = routes.AccountEditMFASetupSubmit(w,req,user) + case "/user/edit/mfa/disable/submit/": + err = common.NoSessionMismatch(w,req,user) + if err != nil { + router.handleError(err,w,req,user) + return + } + + err = common.MemberOnly(w,req,user) + if err != nil { + router.handleError(err,w,req,user) + return + } + + counters.RouteViewCounter.Bump(83) + err = routes.AccountEditMFADisableSubmit(w,req,user) + case "/user/edit/email/": + err = common.MemberOnly(w,req,user) + if err != nil { + router.handleError(err,w,req,user) + return + } + + counters.RouteViewCounter.Bump(84) err = routes.AccountEditEmail(w,req,user) case "/user/edit/token/": err = common.NoSessionMismatch(w,req,user) @@ -1493,11 +1552,11 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(82) + counters.RouteViewCounter.Bump(85) err = routes.AccountEditEmailTokenSubmit(w,req,user,extraData) default: req.URL.Path += extraData - counters.RouteViewCounter.Bump(83) + counters.RouteViewCounter.Bump(86) err = routes.ViewProfile(w,req,user) } if err != nil { @@ -1518,7 +1577,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(84) + counters.RouteViewCounter.Bump(87) err = routes.BanUserSubmit(w,req,user,extraData) case "/users/unban/": err = common.NoSessionMismatch(w,req,user) @@ -1533,7 +1592,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(85) + counters.RouteViewCounter.Bump(88) err = routes.UnbanUser(w,req,user,extraData) case "/users/activate/": err = common.NoSessionMismatch(w,req,user) @@ -1548,7 +1607,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(86) + counters.RouteViewCounter.Bump(89) err = routes.ActivateUser(w,req,user,extraData) case "/users/ips/": err = common.MemberOnly(w,req,user) @@ -1557,7 +1616,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(87) + counters.RouteViewCounter.Bump(90) err = routes.IPSearch(w,req,user) } if err != nil { @@ -1583,7 +1642,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(88) + counters.RouteViewCounter.Bump(91) err = routes.CreateTopicSubmit(w,req,user) case "/topic/edit/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1598,7 +1657,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(89) + counters.RouteViewCounter.Bump(92) err = routes.EditTopicSubmit(w,req,user,extraData) case "/topic/delete/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1614,7 +1673,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { } req.URL.Path += extraData - counters.RouteViewCounter.Bump(90) + counters.RouteViewCounter.Bump(93) err = routes.DeleteTopicSubmit(w,req,user) case "/topic/stick/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1629,7 +1688,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(91) + counters.RouteViewCounter.Bump(94) err = routes.StickTopicSubmit(w,req,user,extraData) case "/topic/unstick/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1644,7 +1703,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(92) + counters.RouteViewCounter.Bump(95) err = routes.UnstickTopicSubmit(w,req,user,extraData) case "/topic/lock/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1660,7 +1719,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { } req.URL.Path += extraData - counters.RouteViewCounter.Bump(93) + counters.RouteViewCounter.Bump(96) err = routes.LockTopicSubmit(w,req,user) case "/topic/unlock/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1675,7 +1734,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(94) + counters.RouteViewCounter.Bump(97) err = routes.UnlockTopicSubmit(w,req,user,extraData) case "/topic/move/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1690,7 +1749,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(95) + counters.RouteViewCounter.Bump(98) err = routes.MoveTopicSubmit(w,req,user,extraData) case "/topic/like/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1711,10 +1770,10 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(96) + counters.RouteViewCounter.Bump(99) err = routes.LikeTopicSubmit(w,req,user,extraData) default: - counters.RouteViewCounter.Bump(97) + counters.RouteViewCounter.Bump(100) err = routes.ViewTopic(w,req,user, extraData) } if err != nil { @@ -1740,7 +1799,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(98) + counters.RouteViewCounter.Bump(101) err = routes.CreateReplySubmit(w,req,user) case "/reply/edit/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1755,7 +1814,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(99) + counters.RouteViewCounter.Bump(102) err = routes.ReplyEditSubmit(w,req,user,extraData) case "/reply/delete/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1770,7 +1829,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(100) + counters.RouteViewCounter.Bump(103) err = routes.ReplyDeleteSubmit(w,req,user,extraData) case "/reply/like/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1791,7 +1850,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(101) + counters.RouteViewCounter.Bump(104) err = routes.ReplyLikeSubmit(w,req,user,extraData) } if err != nil { @@ -1812,7 +1871,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(102) + counters.RouteViewCounter.Bump(105) err = routes.ProfileReplyCreateSubmit(w,req,user) case "/profile/reply/edit/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1827,7 +1886,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(103) + counters.RouteViewCounter.Bump(106) err = routes.ProfileReplyEditSubmit(w,req,user,extraData) case "/profile/reply/delete/submit/": err = common.NoSessionMismatch(w,req,user) @@ -1842,7 +1901,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(104) + counters.RouteViewCounter.Bump(107) err = routes.ProfileReplyDeleteSubmit(w,req,user,extraData) } if err != nil { @@ -1863,10 +1922,10 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(105) + counters.RouteViewCounter.Bump(108) err = routes.PollVote(w,req,user,extraData) case "/poll/results/": - counters.RouteViewCounter.Bump(106) + counters.RouteViewCounter.Bump(109) err = routes.PollResults(w,req,user,extraData) } if err != nil { @@ -1875,10 +1934,10 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { case "/accounts": switch(req.URL.Path) { case "/accounts/login/": - counters.RouteViewCounter.Bump(107) + counters.RouteViewCounter.Bump(110) err = routes.AccountLogin(w,req,user) case "/accounts/create/": - counters.RouteViewCounter.Bump(108) + counters.RouteViewCounter.Bump(111) err = routes.AccountRegister(w,req,user) case "/accounts/logout/": err = common.NoSessionMismatch(w,req,user) @@ -1893,7 +1952,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(109) + counters.RouteViewCounter.Bump(112) err = routes.AccountLogout(w,req,user) case "/accounts/login/submit/": err = common.ParseForm(w,req,user) @@ -1902,8 +1961,20 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(110) + counters.RouteViewCounter.Bump(113) err = routes.AccountLoginSubmit(w,req,user) + case "/accounts/mfa_verify/": + counters.RouteViewCounter.Bump(114) + err = routes.AccountLoginMFAVerify(w,req,user) + case "/accounts/mfa_verify/submit/": + err = common.ParseForm(w,req,user) + if err != nil { + router.handleError(err,w,req,user) + return + } + + counters.RouteViewCounter.Bump(115) + err = routes.AccountLoginMFAVerifySubmit(w,req,user) case "/accounts/create/submit/": err = common.ParseForm(w,req,user) if err != nil { @@ -1911,7 +1982,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - counters.RouteViewCounter.Bump(111) + counters.RouteViewCounter.Bump(116) err = routes.AccountRegisterSubmit(w,req,user) } if err != nil { @@ -1928,7 +1999,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { common.NotFound(w,req,nil) return } - counters.RouteViewCounter.Bump(113) + counters.RouteViewCounter.Bump(118) req.URL.Path += extraData // TODO: Find a way to propagate errors up from this? router.UploadHandler(w,req) // TODO: Count these views @@ -1937,35 +2008,22 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { // TODO: Add support for favicons and robots.txt files switch(extraData) { case "robots.txt": - counters.RouteViewCounter.Bump(115) + counters.RouteViewCounter.Bump(120) err = routes.RobotsTxt(w,req) if err != nil { router.handleError(err,w,req,user) } return /*case "sitemap.xml": - counters.RouteViewCounter.Bump(116) + counters.RouteViewCounter.Bump(121) err = routes.SitemapXml(w,req) if err != nil { router.handleError(err,w,req,user) } return*/ } - if extraData != "" { - common.NotFound(w,req,nil) - return - } - - handle, ok := RouteMap[common.Config.DefaultRoute] - if !ok { - // TODO: Make this a startup error not a runtime one - router.requestLogger.Print("Unable to find the default route") - common.NotFound(w,req,nil) - return - } - counters.RouteViewCounter.Bump(routeMapEnum[common.Config.DefaultRoute]) - - handle.(func(http.ResponseWriter, *http.Request, common.User) common.RouteError)(w,req,user) + common.NotFound(w,req,nil) + return default: // A fallback for the routes which haven't been converted to the new router yet or plugins router.RLock() @@ -1973,7 +2031,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { router.RUnlock() if ok { - counters.RouteViewCounter.Bump(112) // TODO: Be more specific about *which* dynamic route it is + counters.RouteViewCounter.Bump(117) // TODO: Be more specific about *which* dynamic route it is req.URL.Path += extraData err = handle(w,req,user) if err != nil { @@ -1988,7 +2046,7 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { } else { router.DumpRequest(req,"Bad Route") } - counters.RouteViewCounter.Bump(117) + counters.RouteViewCounter.Bump(122) common.NotFound(w,req,nil) } } diff --git a/gen_tables.go b/gen_tables.go index 47e54b1b..bc71a247 100644 --- a/gen_tables.go +++ b/gen_tables.go @@ -2,20 +2,21 @@ package main var dbTablePrimaryKeys = map[string]string{ - "topics": "tid", - "attachments": "attachID", - "menus": "mid", - "users_groups": "gid", - "users_groups_scheduler": "uid", - "registration_logs": "rlid", - "word_filters": "wfid", - "menu_items": "miid", - "polls": "pollID", - "users_replies": "rid", - "activity_stream": "asid", - "pages": "pid", - "replies": "rid", - "revisions": "reviseID", - "users": "uid", - "forums": "fid", + "users_groups":"gid", + "attachments":"attachID", + "users_replies":"rid", + "menu_items":"miid", + "pages":"pid", + "polls":"pollID", + "activity_stream":"asid", + "users_groups_scheduler":"uid", + "replies":"rid", + "word_filters":"wfid", + "menus":"mid", + "registration_logs":"rlid", + "users":"uid", + "users_2fa_keys":"uid", + "forums":"fid", + "topics":"tid", + "revisions":"reviseID", } diff --git a/general_test.go b/general_test.go index 8ec587ce..77662121 100644 --- a/general_test.go +++ b/general_test.go @@ -924,333 +924,6 @@ func BenchmarkQueriesSerial(b *testing.B) { }) } -// Commented until I add logic for profiling the router generator, I'm not sure what the best way of doing that is -/*func addEmptyRoutesToMux(routes []string, serveMux *http.ServeMux) { - for _, route := range routes { - serveMux.HandleFunc(route, func(_ http.ResponseWriter,_ *http.Request){}) - } -} - -func BenchmarkDefaultGoRouterSerial(b *testing.B) { - w := httptest.NewRecorder() - req := httptest.NewRequest("get","/topics/",bytes.NewReader(nil)) - routes := make([]string, 0) - - routes = append(routes,"/test/") - serveMux := http.NewServeMux() - serveMux.HandleFunc("/test/", func(_ http.ResponseWriter,_ *http.Request){}) - b.Run("one-route", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - serveMux.ServeHTTP(w,req) - } - }) - - routes = append(routes,"/topic/") - routes = append(routes,"/forums/") - routes = append(routes,"/forum/") - routes = append(routes,"/panel/") - serveMux = http.NewServeMux() - addEmptyRoutesToMux(routes, serveMux) - b.Run("five-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - serveMux.ServeHTTP(w,req) - } - }) - - serveMux = http.NewServeMux() - routes = append(routes,"/panel/plugins/") - routes = append(routes,"/panel/groups/") - routes = append(routes,"/panel/settings/") - routes = append(routes,"/panel/users/") - routes = append(routes,"/panel/forums/") - addEmptyRoutesToMux(routes, serveMux) - b.Run("ten-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - serveMux.ServeHTTP(w,req) - } - }) - - serveMux = http.NewServeMux() - routes = append(routes,"/panel/forums/create/submit/") - routes = append(routes,"/panel/forums/delete/") - routes = append(routes,"/users/ban/") - routes = append(routes,"/panel/users/edit/") - routes = append(routes,"/panel/forums/create/") - routes = append(routes,"/users/unban/") - routes = append(routes,"/pages/") - routes = append(routes,"/users/activate/") - routes = append(routes,"/panel/forums/edit/submit/") - routes = append(routes,"/panel/plugins/activate/") - addEmptyRoutesToMux(routes, serveMux) - b.Run("twenty-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - serveMux.ServeHTTP(w,req) - } - }) - - serveMux = http.NewServeMux() - routes = append(routes,"/panel/plugins/deactivate/") - routes = append(routes,"/panel/plugins/install/") - routes = append(routes,"/panel/plugins/uninstall/") - routes = append(routes,"/panel/templates/") - routes = append(routes,"/panel/templates/edit/") - routes = append(routes,"/panel/templates/create/") - routes = append(routes,"/panel/templates/delete/") - routes = append(routes,"/panel/templates/edit/submit/") - routes = append(routes,"/panel/themes/") - routes = append(routes,"/panel/themes/edit/") - addEmptyRoutesToMux(routes, serveMux) - b.Run("thirty-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - serveMux.ServeHTTP(w,req) - } - }) - - serveMux = http.NewServeMux() - routes = append(routes,"/panel/themes/create/") - routes = append(routes,"/panel/themes/delete/") - routes = append(routes,"/panel/themes/delete/submit/") - routes = append(routes,"/panel/templates/create/submit/") - routes = append(routes,"/panel/templates/delete/submit/") - routes = append(routes,"/panel/widgets/") - routes = append(routes,"/panel/widgets/edit/") - routes = append(routes,"/panel/widgets/activate/") - routes = append(routes,"/panel/widgets/deactivate/") - routes = append(routes,"/panel/magical/wombat/path") - addEmptyRoutesToMux(routes, serveMux) - b.Run("forty-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - serveMux.ServeHTTP(w,req) - } - }) - - serveMux = http.NewServeMux() - routes = append(routes,"/report/") - routes = append(routes,"/report/submit/") - routes = append(routes,"/topic/create/submit/") - routes = append(routes,"/topics/create/") - routes = append(routes,"/overview/") - routes = append(routes,"/uploads/") - routes = append(routes,"/static/") - routes = append(routes,"/reply/edit/submit/") - routes = append(routes,"/reply/delete/submit/") - routes = append(routes,"/topic/edit/submit/") - addEmptyRoutesToMux(routes, serveMux) - b.Run("fifty-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - serveMux.ServeHTTP(w,req) - } - }) - - serveMux = http.NewServeMux() - routes = append(routes,"/topic/delete/submit/") - routes = append(routes,"/topic/stick/submit/") - routes = append(routes,"/topic/unstick/submit/") - routes = append(routes,"/accounts/login/") - routes = append(routes,"/accounts/create/") - routes = append(routes,"/accounts/logout/") - routes = append(routes,"/accounts/login/submit/") - routes = append(routes,"/accounts/create/submit/") - routes = append(routes,"/user/edit/critical/") - routes = append(routes,"/user/edit/critical/submit/") - addEmptyRoutesToMux(routes, serveMux) - b.Run("sixty-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - serveMux.ServeHTTP(w,req) - } - }) - - serveMux = http.NewServeMux() - routes = append(routes,"/user/edit/avatar/") - routes = append(routes,"/user/edit/avatar/submit/") - routes = append(routes,"/user/edit/username/") - routes = append(routes,"/user/edit/username/submit/") - routes = append(routes,"/profile/reply/create/") - routes = append(routes,"/profile/reply/edit/submit/") - routes = append(routes,"/profile/reply/delete/submit/") - routes = append(routes,"/arcane/tower/") - routes = append(routes,"/magical/kingdom/") - routes = append(routes,"/insert/name/here/") - addEmptyRoutesToMux(routes, serveMux) - b.Run("seventy-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - serveMux.ServeHTTP(w,req) - } - }) -} - -func addEmptyRoutesToCustom(routes []string, router *Router) { - for _, route := range routes { - router.HandleFunc(route, func(_ http.ResponseWriter,_ *http.Request){}) - } -} - -func BenchmarkCustomRouterSerial(b *testing.B) { - w := httptest.NewRecorder() - req := httptest.NewRequest("get","/topics/",bytes.NewReader(nil)) - routes := make([]string, 0) - - routes = append(routes,"/test/") - router := NewRouter() - router.HandleFunc("/test/", func(_ http.ResponseWriter,_ *http.Request){}) - b.Run("one-route", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - router.ServeHTTP(w,req) - } - }) - - routes = append(routes,"/topic/") - routes = append(routes,"/forums/") - routes = append(routes,"/forum/") - routes = append(routes,"/panel/") - router = NewRouter() - addEmptyRoutesToCustom(routes, router) - b.Run("five-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - router.ServeHTTP(w,req) - } - }) - - router = NewRouter() - routes = append(routes,"/panel/plugins/") - routes = append(routes,"/panel/groups/") - routes = append(routes,"/panel/settings/") - routes = append(routes,"/panel/users/") - routes = append(routes,"/panel/forums/") - addEmptyRoutesToCustom(routes, router) - b.Run("ten-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - router.ServeHTTP(w,req) - } - }) - - router = NewRouter() - routes = append(routes,"/panel/forums/create/submit/") - routes = append(routes,"/panel/forums/delete/") - routes = append(routes,"/users/ban/") - routes = append(routes,"/panel/users/edit/") - routes = append(routes,"/panel/forums/create/") - routes = append(routes,"/users/unban/") - routes = append(routes,"/pages/") - routes = append(routes,"/users/activate/") - routes = append(routes,"/panel/forums/edit/submit/") - routes = append(routes,"/panel/plugins/activate/") - addEmptyRoutesToCustom(routes, router) - b.Run("twenty-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - router.ServeHTTP(w,req) - } - }) - - router = NewRouter() - routes = append(routes,"/panel/plugins/deactivate/") - routes = append(routes,"/panel/plugins/install/") - routes = append(routes,"/panel/plugins/uninstall/") - routes = append(routes,"/panel/templates/") - routes = append(routes,"/panel/templates/edit/") - routes = append(routes,"/panel/templates/create/") - routes = append(routes,"/panel/templates/delete/") - routes = append(routes,"/panel/templates/edit/submit/") - routes = append(routes,"/panel/themes/") - routes = append(routes,"/panel/themes/edit/") - addEmptyRoutesToCustom(routes, router) - b.Run("thirty-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - router.ServeHTTP(w,req) - } - }) - - router = NewRouter() - routes = append(routes,"/panel/themes/create/") - routes = append(routes,"/panel/themes/delete/") - routes = append(routes,"/panel/themes/delete/submit/") - routes = append(routes,"/panel/templates/create/submit/") - routes = append(routes,"/panel/templates/delete/submit/") - routes = append(routes,"/panel/widgets/") - routes = append(routes,"/panel/widgets/edit/") - routes = append(routes,"/panel/widgets/activate/") - routes = append(routes,"/panel/widgets/deactivate/") - routes = append(routes,"/panel/magical/wombat/path") - addEmptyRoutesToCustom(routes, router) - b.Run("forty-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - router.ServeHTTP(w,req) - } - }) - - router = NewRouter() - routes = append(routes,"/report/") - routes = append(routes,"/report/submit/") - routes = append(routes,"/topic/create/submit/") - routes = append(routes,"/topics/create/") - routes = append(routes,"/overview/") - routes = append(routes,"/uploads/") - routes = append(routes,"/static/") - routes = append(routes,"/reply/edit/submit/") - routes = append(routes,"/reply/delete/submit/") - routes = append(routes,"/topic/edit/submit/") - addEmptyRoutesToCustom(routes, router) - b.Run("fifty-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - router.ServeHTTP(w,req) - } - }) - - router = NewRouter() - routes = append(routes,"/topic/delete/submit/") - routes = append(routes,"/topic/stick/submit/") - routes = append(routes,"/topic/unstick/submit/") - routes = append(routes,"/accounts/login/") - routes = append(routes,"/accounts/create/") - routes = append(routes,"/accounts/logout/") - routes = append(routes,"/accounts/login/submit/") - routes = append(routes,"/accounts/create/submit/") - routes = append(routes,"/user/edit/critical/") - routes = append(routes,"/user/edit/critical/submit/") - addEmptyRoutesToCustom(routes, router) - b.Run("sixty-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - router.ServeHTTP(w,req) - } - }) - - router = NewRouter() - routes = append(routes,"/user/edit/avatar/") - routes = append(routes,"/user/edit/avatar/submit/") - routes = append(routes,"/user/edit/username/") - routes = append(routes,"/user/edit/username/submit/") - routes = append(routes,"/profile/reply/create/") - routes = append(routes,"/profile/reply/edit/submit/") - routes = append(routes,"/profile/reply/delete/submit/") - routes = append(routes,"/arcane/tower/") - routes = append(routes,"/magical/kingdom/") - routes = append(routes,"/insert/name/here/") - addEmptyRoutesToCustom(routes, router) - b.Run("seventy-routes", func(b *testing.B) { - for i := 0; i < b.N; i++ { - req = httptest.NewRequest("get",routes[rand.Intn(len(routes))],bytes.NewReader(nil)) - router.ServeHTTP(w,req) - } - }) -}*/ - // TODO: Take the attachment system into account in these parser benches func BenchmarkParserSerial(b *testing.B) { b.ReportAllocs() diff --git a/install/install.go b/install/install.go index 09893081..2a73a777 100644 --- a/install/install.go +++ b/install/install.go @@ -96,79 +96,65 @@ func main() { return } - configContents := []byte(`package config + configContents := []byte(`{ + "Site": { + "ShortName":"` + siteShortName + `", + "Name":"` + siteName + `", + "URL":"` + siteURL + `", + "Port":"` + serverPort + `", + "EnableSsl":false, + "EnableEmails":false, + "HasProxy":false, + "Language": "english" + }, + "Config": { + "SslPrivkey": "", + "SslFullchain": "", + "SMTPServer": "", + "SMTPUsername": "", + "SMTPPassword": "", + "SMTPPort": "25", -import "../common" + "MaxRequestSizeStr":"5MB", + "UserCache":"static", + "TopicCache":"static", + "UserCacheCapacity":120, + "TopicCacheCapacity":200, + "DefaultPath":"/topics/", + "DefaultGroup":3, + "ActivationGroup":5, + "StaffCSS":"staff_post", + "DefaultForum":2, + "MinifyTemplates":true, + "BuildSlugs":true, + "ServerCount":1, + "Noavatar":"https://api.adorable.io/avatars/285/{id}@{site_url}.png", + "ItemsPerPage":25 + }, + "Database": { + "Adapter": "` + adap.Name() + `", + "Host": "` + adap.DBHost() + `", + "Username": "` + adap.DBUsername() + `", + "Password": "` + adap.DBPassword() + `", + "Dbname": "` + adap.DBName() + `", + "Port": "` + adap.DBPort() + `", -func Config() { - // Site Info - common.Site.ShortName = "` + siteShortName + `" // This should be less than three letters to fit in the navbar - common.Site.Name = "` + siteName + `" - common.Site.Email = "" - common.Site.URL = "` + siteURL + `" - common.Site.Port = "` + serverPort + `" - common.Site.EnableSsl = false - common.Site.EnableEmails = false - common.Site.HasProxy = false // Cloudflare counts as this, if it's sitting in the middle - common.Config.SslPrivkey = "" - common.Config.SslFullchain = "" - common.Site.Language = "english" - - // Database details - common.DbConfig.Adapter = "` + adap.Name() + `" - common.DbConfig.Host = "` + adap.DBHost() + `" - common.DbConfig.Username = "` + adap.DBUsername() + `" - common.DbConfig.Password = "` + adap.DBPassword() + `" - common.DbConfig.Dbname = "` + adap.DBName() + `" - common.DbConfig.Port = "` + adap.DBPort() + `" // You probably won't need to change this - - // Test Database details - common.DbConfig.TestAdapter = "` + adap.Name() + `" - common.DbConfig.TestHost = "" - common.DbConfig.TestUsername = "" - common.DbConfig.TestPassword = "" - common.DbConfig.TestDbname = "" // The name of the test database, leave blank to disable. DON'T USE YOUR PRODUCTION DATABASE FOR THIS. LEAVE BLANK IF YOU DON'T KNOW WHAT THIS MEANS. - common.DbConfig.TestPort = "" - - // Limiters - common.Config.MaxRequestSize = 5 * common.Megabyte - - // Caching - common.Config.CacheTopicUser = common.CACHE_STATIC - common.Config.UserCacheCapacity = 120 // The max number of users held in memory - common.Config.TopicCacheCapacity = 200 // The max number of topics held in memory - - // Email - common.Config.SMTPServer = "" - common.Config.SMTPUsername = "" - common.Config.SMTPPassword = "" - common.Config.SMTPPort = "25" - - // Misc - common.Config.DefaultRoute = "routes.TopicList" - common.Config.DefaultGroup = 3 // Should be a setting in the database - common.Config.ActivationGroup = 5 // Should be a setting in the database - common.Config.StaffCSS = "staff_post" - common.Config.DefaultForum = 2 - common.Config.MinifyTemplates = true - common.Config.BuildSlugs = true - common.Config.ServerCount = 1 // Experimental: Enable Cross-Server Synchronisation and several other features - - //common.Config.Noavatar = "https://api.adorable.io/avatars/{width}/{id}@{site_url}.png" - common.Config.Noavatar = "https://api.adorable.io/avatars/285/{id}@{site_url}.png" - common.Config.ItemsPerPage = 25 - - // Developer flags - common.Dev.DebugMode = true - //common.Dev.SuperDebug = true - //common.Dev.TemplateDebug = true - //common.Dev.Profiling = true - //common.Dev.TestDB = true -} -`) + "TestAdapter": "` + adap.Name() + `", + "TestHost": "", + "TestUsername": "", + "TestPassword": "", + "TestDbname": "", + "TestPort": "" + }, + "Dev": { + "DebugMode":true, + "SuperDebug":false + } +}`) + //"Noavatar": "https://api.adorable.io/avatars/{width}/{id}@{site_url}.png" Maybe allow this sort of syntax? fmt.Println("Opening the configuration file") - configFile, err := os.Create("./config/config.go") + configFile, err := os.Create("./config/config.json") if err != nil { abortError(err) return diff --git a/langs/english.json b/langs/english.json index 716a06d7..c6d78444 100644 --- a/langs/english.json +++ b/langs/english.json @@ -81,8 +81,10 @@ "login":"Login", "register":"Registration", "ip_search":"IP Search", - "account_username":"Edit Username", - "account_avatar":"Edit Avatar", + "account":"My Account", + "account_password":"Edit Password", + "account_mfa":"Manage 2FA", + "account_mfa_setup":"Setup 2FA", "account_email":"Email Manager", "panel_dashboard":"Control Panel Dashboard", @@ -244,19 +246,20 @@ "NoticePhrases": { "account_banned":"Your account has been suspended. Some of your permissions may have been revoked.", "account_inactive":"Your account hasn't been activated yet. Some features may remain unavailable until it is.", - "account_avatar_updated":"Your avatar was successfully updated", - "account_username_updated":"Your username was successfully updated", + "account_avatar_updated":"Your avatar was successfully updated.", + "account_username_updated":"Your username was successfully updated.", "account_mail_disabled":"The mail system is currently disabled.", - "account_mail_verify_success":"Your email was successfully verified", + "account_mail_verify_success":"Your email was successfully verified.", + "account_mfa_setup_success":"Two-factor authentication was successfully setup for your account.", - "panel_forum_created":"The forum was successfully created", - "panel_forum_deleted":"The forum was successfully deleted", - "panel_forum_updated":"The forum was successfully updated", - "panel_forum_perms_updated":"The forum permissions were successfully updated", - "panel_user_updated":"The user was successfully updated", - "panel_page_created":"The page was successfully created", - "panel_page_updated":"The page was successfully updated", - "panel_page_deleted":"The page was successfully deleted" + "panel_forum_created":"The forum was successfully created.", + "panel_forum_deleted":"The forum was successfully deleted.", + "panel_forum_updated":"The forum was successfully updated.", + "panel_forum_perms_updated":"The forum permissions were successfully updated.", + "panel_user_updated":"The user was successfully updated.", + "panel_page_created":"The page was successfully created.", + "panel_page_updated":"The page was successfully updated.", + "panel_page_deleted":"The page was successfully deleted." }, "TmplPhrases": { @@ -303,11 +306,13 @@ "panel_rank_guests":"Guests", "panel_rank_members":"Members", + "panel_preset_everyone":"Everyone", "panel_preset_announcements":"Announcements", "panel_preset_member_only":"Member Only", "panel_preset_staff_only":"Staff Only", "panel_preset_admin_only":"Admin Only", "panel_preset_archive":"Archive", + "panel_preset_custom":"Custom", "panel_preset_public":"Public", "panel_active_hidden":"Hidden", @@ -347,6 +352,10 @@ "login_submit_button":"Login", "login_no_account":"Don't have an account?", + "login_mfa_verify_head":"2FA Verify", + "login_mfa_verify_explanation":"Please input the code from the authenticator app below.", + "login_mfa_verify_button":"Confirm", + "register_head":"Create Account", "register_account_name":"Account Name", "register_account_email":"Email", @@ -356,16 +365,19 @@ "register_submit_button":"Create Account", "account_menu_head":"My Account", - "account_menu_avatar":"Avatar", - "account_menu_username":"Username", "account_menu_password":"Password", "account_menu_email":"Email", "account_menu_security":"Security", "account_menu_notifications":"Notifications", - "account_avatar_head":"Edit Avatar", - "account_avatar_upload_label":"Upload Avatar", - "account_avatar_update_button":"Update", + "account_coming_soon":"Coming Soon", + + "account_dash_2fa_setup":"Setup your two-factor authentication.", + "account_dash_2fa_manage":"Remove or manage your two-factor authentication.", + "account_dash_next_level":"Progress to next level.", + "account_dash_security_notice":"Security", + "account_avatar_select":"Select", + "account_avatar_update_button":"Upload", "account_email_head":"Emails", "account_email_primary":"Primary", @@ -373,17 +385,23 @@ "account_email_verified":"Verified", "account_email_resend_email":"Resend Verification Email", - "account_username_head":"Edit Username", - "account_username_current_username":"Current Username", - "account_username_new_username":"New Username", - "account_username_update_button":"Update", - "account_password_head":"Edit Password", "account_password_current_password":"Current Password", "account_password_new_password":"New Password", "account_password_confirm_password":"Confirm Password", "account_password_update_button":"Update", + "account_mfa_head":"Manage 2FA", + "account_mfa_disable_explanation":"You can disable two-factor authentication on your account and go back to logging in normal with just your password by clicking on the following button.", + "account_mfa_disable_button":"Disable 2FA", + "account_mfa_scratch_head":"One Time Codes", + "account_mfa_scratch_explanation":"You can use the following codes to login without having an authenticator app generate codes for you.\n\nEach code can only be used once, a new one will replace it when it's used. These are intended as a backup, if your app fails or device (e.g. your phone) dies, be sure to keep them somewhere safe.", + + "account_mfa_setup_head":"Setup 2FA", + "account_mfa_setup_explanation":"Type this secret into your Google Authenticator and type the code it gives you below. You will have to input codes provided by it for all future logins.", + "account_mfa_setup_verify":"Verify", + "account_mfa_setup_button":"Setup", + "areyousure_head":"Are you sure?", "areyousure_continue":"Continue", @@ -603,13 +621,6 @@ "panel_forums_create_description":"Where all the super secret stuff happens", "panel_forums_active_label":"Active", "panel_forums_preset_label":"Preset", - "panel_preset_everyone":"Everyone", - "panel_preset_announcements":"Announcements", - "panel_preset_member_only":"Member Only", - "panel_preset_staff_only":"Staff Only", - "panel_preset_admin_only":"Admin Only", - "panel_preset_archive":"Archive", - "panel_preset_custom":"Custom", "panel_forums_create_button":"Add Forum", "panel_forum_head_suffix":" Forum", diff --git a/main.go b/main.go index 7f613c64..84c84be8 100644 --- a/main.go +++ b/main.go @@ -21,7 +21,6 @@ import ( "./common" "./common/counters" - "./config" "./query_gen/lib" "./routes" "github.com/fsnotify/fsnotify" @@ -106,6 +105,10 @@ func afterDBInit() (err error) { } log.Print("Initialising the stores") + common.MFAstore, err = common.NewSQLMFAStore(acc) + if err != nil { + return err + } common.Pages, err = common.NewDefaultPageStore(acc) if err != nil { return err @@ -207,7 +210,6 @@ func main() { return } }()*/ - config.Config() // TODO: Have a file for each run with the time/date the server started as the file name? // TODO: Log panics with recover() @@ -227,6 +229,11 @@ func main() { } common.JSTokenBox.Store(jsToken) + log.Print("Loading the configuration data") + err = common.LoadConfig() + if err != nil { + log.Fatal(err) + } log.Print("Processing configuration data") err = common.ProcessConfig() if err != nil { @@ -344,6 +351,7 @@ func main() { } } + // TODO: Write tests for these // Run this goroutine once every half second halfSecondTicker := time.NewTicker(time.Second / 2) secondTicker := time.NewTicker(time.Second) @@ -394,11 +402,20 @@ func main() { runHook("after_fifteen_minute_tick") case <-hourTicker.C: runHook("before_hour_tick") + jsToken, err := common.GenerateSafeString(80) if err != nil { common.LogError(err) } common.JSTokenBox.Store(jsToken) + + common.OldSessionSigningKeyBox.Store(common.SessionSigningKeyBox.Load().(string)) // TODO: We probably don't need this type conversion + sessionSigningKey, err := common.GenerateSafeString(80) + if err != nil { + common.LogError(err) + } + common.SessionSigningKeyBox.Store(sessionSigningKey) + runTasks(common.ScheduledHourTasks) runHook("after_hour_tick") } diff --git a/panel_routes.go b/panel_routes.go index 9ae7ebf4..1a476438 100644 --- a/panel_routes.go +++ b/panel_routes.go @@ -476,171 +476,6 @@ func routePanelPluginsInstall(w http.ResponseWriter, r *http.Request, user commo return nil } -func routePanelUsers(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) - if ferr != nil { - return ferr - } - header.Title = common.GetTitlePhrase("panel_users") - - page, _ := strconv.Atoi(r.FormValue("page")) - perPage := 10 - offset, page, lastPage := common.PageOffset(stats.Users, page, perPage) - - users, err := common.Users.GetOffset(offset, perPage) - if err != nil { - return common.InternalError(err, w, r) - } - - pageList := common.Paginate(stats.Users, perPage, 5) - pi := common.PanelUserPage{&common.BasePanelPage{header, stats, "users", common.ReportForumID}, users, common.Paginator{pageList, page, lastPage}} - return panelRenderTemplate("panel_users", w, r, user, &pi) -} - -func routePanelUsersEdit(w http.ResponseWriter, r *http.Request, user common.User, suid string) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) - if ferr != nil { - return ferr - } - if !user.Perms.EditUser { - return common.NoPermissions(w, r, user) - } - header.Title = common.GetTitlePhrase("panel_edit_user") - - uid, err := strconv.Atoi(suid) - if err != nil { - return common.LocalError("The provided UserID is not a valid number.", w, r, user) - } - - targetUser, err := common.Users.Get(uid) - if err == ErrNoRows { - return common.LocalError("The user you're trying to edit doesn't exist.", w, r, user) - } else if err != nil { - return common.InternalError(err, w, r) - } - - if targetUser.IsAdmin && !user.IsAdmin { - return common.LocalError("Only administrators can edit the account of an administrator.", w, r, user) - } - - // ? - Should we stop admins from deleting all the groups? Maybe, protect the group they're currently using? - groups, err := common.Groups.GetRange(1, 0) // ? - 0 = Go to the end - if err != nil { - return common.InternalError(err, w, r) - } - - var groupList []interface{} - for _, group := range groups { - if !user.Perms.EditUserGroupAdmin && group.IsAdmin { - continue - } - if !user.Perms.EditUserGroupSuperMod && group.IsMod { - continue - } - groupList = append(groupList, group) - } - - if r.FormValue("updated") == "1" { - header.AddNotice("panel_user_updated") - } - - pi := common.PanelPage{&common.BasePanelPage{header, stats, "users", common.ReportForumID}, groupList, targetUser} - if common.RunPreRenderHook("pre_render_panel_edit_user", w, r, &user, &pi) { - return nil - } - err = common.Templates.ExecuteTemplate(w, "panel-user-edit.html", pi) - if err != nil { - return common.InternalError(err, w, r) - } - return nil -} - -func routePanelUsersEditSubmit(w http.ResponseWriter, r *http.Request, user common.User, suid string) common.RouteError { - _, ferr := common.SimplePanelUserCheck(w, r, &user) - if ferr != nil { - return ferr - } - if !user.Perms.EditUser { - return common.NoPermissions(w, r, user) - } - - uid, err := strconv.Atoi(suid) - if err != nil { - return common.LocalError("The provided UserID is not a valid number.", w, r, user) - } - - targetUser, err := common.Users.Get(uid) - if err == ErrNoRows { - return common.LocalError("The user you're trying to edit doesn't exist.", w, r, user) - } else if err != nil { - return common.InternalError(err, w, r) - } - - if targetUser.IsAdmin && !user.IsAdmin { - return common.LocalError("Only administrators can edit the account of other administrators.", w, r, user) - } - - newname := common.SanitiseSingleLine(r.PostFormValue("user-name")) - if newname == "" { - return common.LocalError("You didn't put in a username.", w, r, user) - } - - // TODO: How should activation factor into admin set emails? - // TODO: How should we handle secondary emails? Do we even have secondary emails implemented? - newemail := common.SanitiseSingleLine(r.PostFormValue("user-email")) - if newemail == "" { - return common.LocalError("You didn't put in an email address.", w, r, user) - } - if (newemail != targetUser.Email) && !user.Perms.EditUserEmail { - return common.LocalError("You need the EditUserEmail permission to edit the email address of a user.", w, r, user) - } - - newpassword := r.PostFormValue("user-password") - if newpassword != "" && !user.Perms.EditUserPassword { - return common.LocalError("You need the EditUserPassword permission to edit the password of a user.", w, r, user) - } - - newgroup, err := strconv.Atoi(r.PostFormValue("user-group")) - if err != nil { - return common.LocalError("You need to provide a whole number for the group ID", w, r, user) - } - - group, err := common.Groups.Get(newgroup) - if err == ErrNoRows { - return common.LocalError("The group you're trying to place this user in doesn't exist.", w, r, user) - } else if err != nil { - return common.InternalError(err, w, r) - } - - if !user.Perms.EditUserGroupAdmin && group.IsAdmin { - return common.LocalError("You need the EditUserGroupAdmin permission to assign someone to an administrator group.", w, r, user) - } - if !user.Perms.EditUserGroupSuperMod && group.IsMod { - return common.LocalError("You need the EditUserGroupSuperMod permission to assign someone to a super mod group.", w, r, user) - } - - // TODO: Move this query into common - _, err = stmts.updateUser.Exec(newname, newemail, newgroup, targetUser.ID) - if err != nil { - return common.InternalError(err, w, r) - } - - if newpassword != "" { - common.SetPassword(targetUser.ID, newpassword) - // Log the user out as a safety precaution - common.Auth.ForceLogout(targetUser.ID) - } - targetUser.CacheRemove() - - // If we're changing our own password, redirect to the index rather than to a noperms error due to the force logout - if targetUser.ID == user.ID { - http.Redirect(w, r, "/", http.StatusSeeOther) - } else { - http.Redirect(w, r, "/panel/users/edit/"+strconv.Itoa(targetUser.ID)+"?updated=1", http.StatusSeeOther) - } - return nil -} - func routePanelGroups(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { header, stats, ferr := common.PanelUserCheck(w, r, &user) if ferr != nil { @@ -748,7 +583,7 @@ func routePanelGroupsEdit(w http.ResponseWriter, r *http.Request, user common.Us if common.RunPreRenderHook("pre_render_panel_edit_group", w, r, &user, &pi) { return nil } - err = common.Templates.ExecuteTemplate(w, "panel-group-edit.html", pi) + err = common.Templates.ExecuteTemplate(w, "panel_group_edit.html", pi) if err != nil { return common.InternalError(err, w, r) } @@ -835,7 +670,7 @@ func routePanelGroupsEditPerms(w http.ResponseWriter, r *http.Request, user comm if common.RunPreRenderHook("pre_render_panel_edit_group_perms", w, r, &user, &pi) { return nil } - err = common.Templates.ExecuteTemplate(w, "panel-group-edit-perms.html", pi) + err = common.Templates.ExecuteTemplate(w, "panel_group_edit_perms.html", pi) if err != nil { return common.InternalError(err, w, r) } diff --git a/patcher/main.go b/patcher/main.go index 00ba46b0..c21cb7af 100644 --- a/patcher/main.go +++ b/patcher/main.go @@ -11,7 +11,6 @@ import ( "strconv" "../common" - "../config" "../query_gen/lib" _ "github.com/go-sql-driver/mysql" ) @@ -37,12 +36,23 @@ func main() { } }() - config.Config() + log.Print("Loading the configuration data") + err := common.LoadConfig() + if err != nil { + log.Fatal(err) + } + + log.Print("Processing configuration data") + err = common.ProcessConfig() + if err != nil { + log.Fatal(err) + } + if common.DbConfig.Adapter != "mysql" && common.DbConfig.Adapter != "" { log.Fatal("Only MySQL is supported for upgrades right now, please wait for a newer build of the patcher") } - err := prepMySQL() + err = prepMySQL() if err != nil { log.Fatal(err) } diff --git a/patcher/patches.go b/patcher/patches.go index d686a7e0..1c35ba59 100644 --- a/patcher/patches.go +++ b/patcher/patches.go @@ -13,6 +13,7 @@ func init() { addPatch(2, patch2) addPatch(3, patch3) addPatch(4, patch4) + addPatch(5, patch5) } func patch0(scanner *bufio.Scanner) (err error) { @@ -447,3 +448,64 @@ func patch4(scanner *bufio.Scanner) error { return nil } + +func patch5(scanner *bufio.Scanner) error { + // ! Don't reuse this function blindly, it doesn't escape apostrophes + var replaceTextWhere = func(replaceThis string, withThis string) error { + return execStmt(qgen.Builder.SimpleUpdate("viewchunks", "route = '"+withThis+"'", "route = '"+replaceThis+"'")) + } + + err := replaceTextWhere("routePanelUsers", "panel.Users") + if err != nil { + return err + } + + err = replaceTextWhere("routePanelUsersEdit", "panel.UsersEdit") + if err != nil { + return err + } + + err = replaceTextWhere("routePanelUsersEditSubmit", "panel.UsersEditSubmit") + if err != nil { + return err + } + + err = replaceTextWhere("routes.AccountEditCritical", "routes.AccountEditPassword") + if err != nil { + return err + } + + err = replaceTextWhere("routes.AccountEditCriticalSubmit", "routes.AccountEditPasswordSubmit") + if err != nil { + return err + } + + err = execStmt(qgen.Builder.SimpleUpdate("menu_items", "path = '/user/edit/'", "path = '/user/edit/critical/'")) + if err != nil { + return err + } + + err = execStmt(qgen.Builder.CreateTable("users_2fa_keys", "utf8mb4", "utf8mb4_general_ci", + []qgen.DBTableColumn{ + qgen.DBTableColumn{"uid", "int", 0, false, false, ""}, + qgen.DBTableColumn{"secret", "varchar", 100, false, false, ""}, + qgen.DBTableColumn{"scratch1", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch2", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch3", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch4", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch5", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch6", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch7", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch8", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"createdAt", "createdAt", 0, false, false, ""}, + }, + []qgen.DBTableKey{ + qgen.DBTableKey{"uid", "primary"}, + }, + )) + if err != nil { + return err + } + + return nil +} diff --git a/plugin_bbcode.go b/plugin_bbcode.go index 4b3faac4..3f8a9da5 100644 --- a/plugin_bbcode.go +++ b/plugin_bbcode.go @@ -343,11 +343,11 @@ func bbcodeParseURL(i int, start int, lastTag int, msgbytes []byte, outbytes []b return i, start, lastTag, outbytes } - outbytes = append(outbytes, common.UrlOpen...) + outbytes = append(outbytes, common.URLOpen...) outbytes = append(outbytes, msgbytes[start:i]...) - outbytes = append(outbytes, common.UrlOpen2...) + outbytes = append(outbytes, common.URLOpen2...) outbytes = append(outbytes, msgbytes[start:i]...) - outbytes = append(outbytes, common.UrlClose...) + outbytes = append(outbytes, common.URLClose...) i += 6 lastTag = i diff --git a/public/account.js b/public/account.js new file mode 100644 index 00000000..1c4e2bff --- /dev/null +++ b/public/account.js @@ -0,0 +1,7 @@ +"use strict" + +$(document).ready(function(){ + $("#dash_username input").click(function(){ + $("#dash_username button").show(); + }); +}); diff --git a/public/global.js b/public/global.js index 57d4b90b..fbdd4fd4 100644 --- a/public/global.js +++ b/public/global.js @@ -495,8 +495,11 @@ $(document).ready(function(){ files[i] = fileList[i]; // Iterate over the files + let totalSize = 0; for(let i = 0; i < files.length; i++) { console.log("files[" + i + "]",files[i]); + totalSize += files[i]["size"]; + let reader = new FileReader(); reader.onload = function(e) { var fileDock = document.getElementById("upload_file_dock"); @@ -540,6 +543,9 @@ $(document).ready(function(){ } reader.readAsDataURL(files[i]); } + if(totalSize>maxRequestSize) { + alert("You can't upload this much data at once, max: " + maxRequestSize); + } } var uploadFiles = document.getElementById("upload_files"); diff --git a/query_gen/main.go b/query_gen/main.go index e0505a25..b98d1235 100644 --- a/query_gen/main.go +++ b/query_gen/main.go @@ -225,7 +225,7 @@ func seedTables(adapter qgen.Adapter) error { addMenuItem(map[string]interface{}{"mid": 1, "htmlID": "general_alerts", "cssClass": "menu_alerts", "position": "right", "tmplName": "menu_alerts"}) - addMenuItem(map[string]interface{}{"mid": 1, "name": "{lang.menu_account}", "cssClass": "menu_account", "position": "left", "path": "/user/edit/critical/", "aria": "{lang.menu_account_aria}", "tooltip": "{lang.menu_account_tooltip}", "memberOnly": true}) + addMenuItem(map[string]interface{}{"mid": 1, "name": "{lang.menu_account}", "cssClass": "menu_account", "position": "left", "path": "/user/edit/", "aria": "{lang.menu_account_aria}", "tooltip": "{lang.menu_account_tooltip}", "memberOnly": true}) addMenuItem(map[string]interface{}{"mid": 1, "name": "{lang.menu_profile}", "cssClass": "menu_profile", "position": "left", "path": "{me.Link}", "aria": "{lang.menu_profile_aria}", "tooltip": "{lang.menu_profile_tooltip}", "memberOnly": true}) @@ -301,8 +301,6 @@ func writeUpdates(adapter qgen.Adapter) error { build.Update("updateTheme").Table("themes").Set("default = ?").Where("uname = ?").Parse() - build.Update("updateUser").Table("users").Set("name = ?, email = ?, group = ?").Where("uid = ?").Parse() // TODO: Implement user_count for users_groups on things which use this - build.Update("updateGroupPerms").Table("users_groups").Set("permissions = ?").Where("gid = ?").Parse() build.Update("updateGroup").Table("users_groups").Set("name = ?, tag = ?").Where("gid = ?").Parse() diff --git a/query_gen/tables.go b/query_gen/tables.go index 3d1535c5..6eedd79d 100644 --- a/query_gen/tables.go +++ b/query_gen/tables.go @@ -62,25 +62,24 @@ func createTables(adapter qgen.Adapter) error { }, ) - /* - qgen.Install.CreateTable("users_2fa_keys", "utf8mb4", "utf8mb4_general_ci", - []qgen.DBTableColumn{ - qgen.DBTableColumn{"uid", "int", 0, false, false, ""}, - qgen.DBTableColumn{"secret", "varchar", 100, false, false, ""}, - qgen.DBTableColumn{"scratch1", "varchar", 50, false, false, ""}, - qgen.DBTableColumn{"scratch2", "varchar", 50, false, false, ""}, - qgen.DBTableColumn{"scratch3", "varchar", 50, false, false, ""}, - qgen.DBTableColumn{"scratch4", "varchar", 50, false, false, ""}, - qgen.DBTableColumn{"scratch5", "varchar", 50, false, false, ""}, - qgen.DBTableColumn{"scratch6", "varchar", 50, false, false, ""}, - qgen.DBTableColumn{"scratch7", "varchar", 50, false, false, ""}, - qgen.DBTableColumn{"scratch8", "varchar", 50, false, false, ""}, - }, - []qgen.DBTableKey{ - qgen.DBTableKey{"uid", "primary"}, - }, - ) - */ + qgen.Install.CreateTable("users_2fa_keys", "utf8mb4", "utf8mb4_general_ci", + []qgen.DBTableColumn{ + qgen.DBTableColumn{"uid", "int", 0, false, false, ""}, + qgen.DBTableColumn{"secret", "varchar", 100, false, false, ""}, + qgen.DBTableColumn{"scratch1", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch2", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch3", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch4", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch5", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch6", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch7", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"scratch8", "varchar", 50, false, false, ""}, + qgen.DBTableColumn{"createdAt", "createdAt", 0, false, false, ""}, + }, + []qgen.DBTableKey{ + qgen.DBTableKey{"uid", "primary"}, + }, + ) // What should we do about global penalties? Put them on the users table for speed? Or keep them here? // Should we add IP Penalties? No, that's a stupid idea, just implement IP Bans properly. What about shadowbans? diff --git a/router_gen/main.go b/router_gen/main.go index 793d97e7..4d5aef5c 100644 --- a/router_gen/main.go +++ b/router_gen/main.go @@ -416,9 +416,9 @@ func (router *GenRouter) SuspiciousRequest(req *http.Request, prepend string) { counters.AgentViewCounter.Bump({{.AllAgentMap.suspicious}}) } -// TODO: Pass the default route or config struct to the router rather than accessing it via a package global -// TODO: SetDefaultRoute -// TODO: GetDefaultRoute +// TODO: Pass the default path or config struct to the router rather than accessing it via a package global +// TODO: SetDefaultPath +// TODO: GetDefaultPath func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Redirect www. requests to the right place if req.Host == "www." + common.Site.Host { @@ -456,6 +456,11 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { if strings.Contains(req.URL.Path,"..") || strings.Contains(req.URL.Path,"--") || strings.Contains(lowerPath,".php") || strings.Contains(lowerPath,".asp") || strings.Contains(lowerPath,".cgi") || strings.Contains(lowerPath,".py") || strings.Contains(lowerPath,".sql") || strings.Contains(lowerPath,".action") { router.SuspiciousRequest(req,"") } + + // Indirect the default route onto a different one + if req.URL.Path == "/" { + req.URL.Path = common.Config.DefaultPath + } var prefix, extraData string prefix = req.URL.Path[0:strings.IndexByte(req.URL.Path[1:],'/') + 1] @@ -671,21 +676,8 @@ func (router *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { } return*/ } - if extraData != "" { - common.NotFound(w,req,nil) - return - } - - handle, ok := RouteMap[common.Config.DefaultRoute] - if !ok { - // TODO: Make this a startup error not a runtime one - router.requestLogger.Print("Unable to find the default route") - common.NotFound(w,req,nil) - return - } - counters.RouteViewCounter.Bump(routeMapEnum[common.Config.DefaultRoute]) - - handle.(func(http.ResponseWriter, *http.Request, common.User) common.RouteError)(w,req,user) + common.NotFound(w,req,nil) + return default: // A fallback for the routes which haven't been converted to the new router yet or plugins router.RLock() diff --git a/router_gen/routes.go b/router_gen/routes.go index 06eb6b90..6c042125 100644 --- a/router_gen/routes.go +++ b/router_gen/routes.go @@ -40,12 +40,15 @@ func buildUserRoutes() { userGroup := newRouteGroup("/user/") userGroup.Routes( View("routes.ViewProfile", "/user/").LitBefore("req.URL.Path += extraData"), - MemberView("routes.AccountEditCritical", "/user/edit/critical/"), - Action("routes.AccountEditCriticalSubmit", "/user/edit/critical/submit/"), // TODO: Full test this - MemberView("routes.AccountEditAvatar", "/user/edit/avatar/"), + MemberView("routes.AccountEdit", "/user/edit/"), + MemberView("routes.AccountEditPassword", "/user/edit/password/"), + Action("routes.AccountEditPasswordSubmit", "/user/edit/password/submit/"), // TODO: Full test this UploadAction("routes.AccountEditAvatarSubmit", "/user/edit/avatar/submit/").MaxSizeVar("int(common.Config.MaxRequestSize)"), - MemberView("routes.AccountEditUsername", "/user/edit/username/"), Action("routes.AccountEditUsernameSubmit", "/user/edit/username/submit/"), // TODO: Full test this + MemberView("routes.AccountEditMFA", "/user/edit/mfa/"), + MemberView("routes.AccountEditMFASetup", "/user/edit/mfa/setup/"), + Action("routes.AccountEditMFASetupSubmit", "/user/edit/mfa/setup/submit/"), + Action("routes.AccountEditMFADisableSubmit", "/user/edit/mfa/disable/submit/"), MemberView("routes.AccountEditEmail", "/user/edit/email/"), Action("routes.AccountEditEmailTokenSubmit", "/user/edit/token/", "extraData"), ) @@ -95,7 +98,6 @@ func buildReplyRoutes() { // TODO: Move these into /user/? func buildProfileReplyRoutes() { - //router.HandleFunc("/user/edit/submit/", routeLogout) // routeLogout? what on earth? o.o pReplyGroup := newRouteGroup("/profile/") pReplyGroup.Routes( Action("routes.ProfileReplyCreateSubmit", "/profile/reply/create/"), // TODO: Add /submit/ to the end @@ -122,6 +124,8 @@ func buildAccountRoutes() { View("routes.AccountRegister", "/accounts/create/"), Action("routes.AccountLogout", "/accounts/logout/"), AnonAction("routes.AccountLoginSubmit", "/accounts/login/submit/"), // TODO: Guard this with a token, maybe the IP hashed with a rotated key? + 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/"), ) addRouteGroup(accReplyGroup) @@ -172,9 +176,9 @@ func buildPanelRoutes() { Action("routePanelPluginsDeactivate", "/panel/plugins/deactivate/", "extraData"), Action("routePanelPluginsInstall", "/panel/plugins/install/", "extraData"), - View("routePanelUsers", "/panel/users/"), - View("routePanelUsersEdit", "/panel/users/edit/", "extraData"), - Action("routePanelUsersEditSubmit", "/panel/users/edit/submit/", "extraData"), + View("panel.Users", "/panel/users/"), + View("panel.UsersEdit", "/panel/users/edit/", "extraData"), + Action("panel.UsersEditSubmit", "/panel/users/edit/submit/", "extraData"), View("panel.AnalyticsViews", "/panel/analytics/views/").Before("ParseForm"), View("panel.AnalyticsRoutes", "/panel/analytics/routes/").Before("ParseForm"), diff --git a/routes/account.go b/routes/account.go index 7808d280..414976a2 100644 --- a/routes/account.go +++ b/routes/account.go @@ -2,6 +2,7 @@ package routes import ( "crypto/sha256" + "crypto/subtle" "database/sql" "encoding/hex" "io" @@ -48,16 +49,30 @@ func AccountLoginSubmit(w http.ResponseWriter, r *http.Request, user common.User } username := common.SanitiseSingleLine(r.PostFormValue("username")) - uid, err := common.Auth.Authenticate(username, r.PostFormValue("password")) + uid, err, requiresExtraAuth := common.Auth.Authenticate(username, r.PostFormValue("password")) if err != nil { return common.LocalError(err.Error(), w, r, user) } + // TODO: Do we want to slacken this by only doing it when the IP changes? + if requiresExtraAuth { + provSession, signedSession, err := common.Auth.CreateProvisionalSession(uid) + if err != nil { + return common.InternalError(err, w, r) + } + common.Auth.SetProvisionalCookies(w, uid, provSession, signedSession) + http.Redirect(w, r, "/accounts/mfa_verify/", http.StatusSeeOther) + return nil + } + return loginSuccess(uid, w, r, &user) +} + +func loginSuccess(uid int, w http.ResponseWriter, r *http.Request, user *common.User) common.RouteError { userPtr, err := common.Users.Get(uid) if err != nil { - return common.LocalError("Bad account", w, r, user) + return common.LocalError("Bad account", w, r, *user) } - user = *userPtr + *user = *userPtr var session string if user.Session == "" { @@ -79,6 +94,97 @@ func AccountLoginSubmit(w http.ResponseWriter, r *http.Request, user common.User return nil } +func extractCookie(name string, r *http.Request) (string, error) { + cookie, err := r.Cookie(name) + if err != nil { + return "", err + } + return cookie.Value, nil +} + +func mfaGetCookies(r *http.Request) (uid int, provSession string, signedSession string, err error) { + suid, err := extractCookie("uid", r) + if err != nil { + return 0, "", "", err + } + uid, err = strconv.Atoi(suid) + if err != nil { + return 0, "", "", err + } + + provSession, err = extractCookie("provSession", r) + if err != nil { + return 0, "", "", err + } + signedSession, err = extractCookie("signedSession", r) + return uid, provSession, signedSession, err +} + +func mfaVerifySession(provSession string, signedSession string, uid int) bool { + h := sha256.New() + h.Write([]byte(common.SessionSigningKeyBox.Load().(string))) + h.Write([]byte(provSession)) + h.Write([]byte(strconv.Itoa(uid))) + expected := hex.EncodeToString(h.Sum(nil)) + if subtle.ConstantTimeCompare([]byte(signedSession), []byte(expected)) == 1 { + return true + } + + h = sha256.New() + h.Write([]byte(common.OldSessionSigningKeyBox.Load().(string))) + h.Write([]byte(provSession)) + h.Write([]byte(strconv.Itoa(uid))) + expected = hex.EncodeToString(h.Sum(nil)) + return subtle.ConstantTimeCompare([]byte(signedSession), []byte(expected)) == 1 +} + +func AccountLoginMFAVerify(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + header, ferr := common.UserCheck(w, r, &user) + if ferr != nil { + return ferr + } + if user.Loggedin { + return common.LocalError("You're already logged in.", w, r, user) + } + header.Title = common.GetTitlePhrase("login_mfa_verify") + + uid, provSession, signedSession, err := mfaGetCookies(r) + if err != nil { + return common.LocalError("Invalid cookie", w, r, user) + } + if !mfaVerifySession(provSession, signedSession, uid) { + return common.LocalError("Invalid session", w, r, user) + } + + pi := common.Page{header, tList, nil} + if common.RunPreRenderHook("pre_render_login_mfa_verify", w, r, &user, &pi) { + return nil + } + err = common.RunThemeTemplate(header.Theme.Name, "login_mfa_verify", pi, w) + if err != nil { + return common.InternalError(err, w, r) + } + return nil +} + +func AccountLoginMFAVerifySubmit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + uid, provSession, signedSession, err := mfaGetCookies(r) + if err != nil { + return common.LocalError("Invalid cookie", w, r, user) + } + if !mfaVerifySession(provSession, signedSession, uid) { + return common.LocalError("Invalid session", w, r, user) + } + var token = r.PostFormValue("mfa_token") + + err = common.Auth.ValidateMFAToken(token, uid) + if err != nil { + return common.LocalError(err.Error(), w, r, user) + } + + return loginSuccess(uid, w, r, &user) +} + func AccountLogout(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { common.Auth.Logout(w, user.ID) http.Redirect(w, r, "/", http.StatusSeeOther) @@ -233,14 +339,57 @@ func AccountRegisterSubmit(w http.ResponseWriter, r *http.Request, user common.U return nil } -// TODO: Rename this -func AccountEditCritical(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, ferr := common.UserCheck(w, r, &user) +// TODO: Figure a way of making this into middleware? +func accountEditHead(titlePhrase string, w http.ResponseWriter, r *http.Request, user *common.User) (*common.Header, common.RouteError) { + header, ferr := common.UserCheck(w, r, user) + if ferr != nil { + return nil, ferr + } + header.Title = common.GetTitlePhrase(titlePhrase) + header.AddSheet(header.Theme.Name + "/account.css") + header.AddScript("account.js") + return header, nil +} + +func AccountEdit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + header, ferr := accountEditHead("account", w, r, &user) + if ferr != nil { + return ferr + } + + if r.FormValue("avatar_updated") == "1" { + header.AddNotice("account_avatar_updated") + } else if r.FormValue("username_updated") == "1" { + header.AddNotice("account_username_updated") + } else if r.FormValue("mfa_setup_success") == "1" { + header.AddNotice("account_mfa_setup_success") + } + + // TODO: Find a more efficient way of doing this + var mfaSetup = false + _, err := common.MFAstore.Get(user.ID) + if err != sql.ErrNoRows && err != nil { + return common.InternalError(err, w, r) + } else if err != sql.ErrNoRows { + mfaSetup = true + } + + pi := common.AccountDashPage{header, mfaSetup} + if common.RunPreRenderHook("pre_render_account_own_edit", w, r, &user, &pi) { + return nil + } + err = common.Templates.ExecuteTemplate(w, "account_own_edit.html", pi) + if err != nil { + return common.InternalError(err, w, r) + } + return nil +} + +func AccountEditPassword(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + header, ferr := accountEditHead("account_password", w, r, &user) if ferr != nil { return ferr } - // TODO: Add a phrase for this - header.Title = "Edit Password" pi := common.Page{header, tList, nil} if common.RunPreRenderHook("pre_render_account_own_edit_password", w, r, &user, &pi) { @@ -253,8 +402,8 @@ func AccountEditCritical(w http.ResponseWriter, r *http.Request, user common.Use return nil } -// TODO: Rename this -func AccountEditCriticalSubmit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { +// TODO: Require re-authentication if the user hasn't logged in in a while +func AccountEditPasswordSubmit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { _, ferr := common.SimpleUserCheck(w, r, &user) if ferr != nil { return ferr @@ -291,27 +440,6 @@ func AccountEditCriticalSubmit(w http.ResponseWriter, r *http.Request, user comm return nil } -func AccountEditAvatar(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, ferr := common.UserCheck(w, r, &user) - if ferr != nil { - return ferr - } - header.Title = common.GetTitlePhrase("account_avatar") - if r.FormValue("updated") == "1" { - header.AddNotice("account_avatar_updated") - } - - pi := common.Page{header, tList, nil} - if common.RunPreRenderHook("pre_render_account_own_edit_avatar", w, r, &user, &pi) { - return nil - } - err := common.Templates.ExecuteTemplate(w, "account_own_edit_avatar.html", pi) - if err != nil { - return common.InternalError(err, w, r) - } - return nil -} - func AccountEditAvatarSubmit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { _, ferr := common.SimpleUserCheck(w, r, &user) if ferr != nil { @@ -377,28 +505,7 @@ func AccountEditAvatarSubmit(w http.ResponseWriter, r *http.Request, user common if err != nil { return common.InternalError(err, w, r) } - http.Redirect(w, r, "/user/edit/avatar/?updated=1", http.StatusSeeOther) - return nil -} - -func AccountEditUsername(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, ferr := common.UserCheck(w, r, &user) - if ferr != nil { - return ferr - } - header.Title = common.GetTitlePhrase("account_username") - if r.FormValue("updated") == "1" { - header.AddNotice("account_username_updated") - } - - pi := common.Page{header, tList, user.Name} - if common.RunPreRenderHook("pre_render_account_own_edit_username", w, r, &user, &pi) { - return nil - } - err := common.Templates.ExecuteTemplate(w, "account_own_edit_username.html", pi) - if err != nil { - return common.InternalError(err, w, r) - } + http.Redirect(w, r, "/user/edit/?avatar_updated=1", http.StatusSeeOther) return nil } @@ -409,22 +516,140 @@ func AccountEditUsernameSubmit(w http.ResponseWriter, r *http.Request, user comm } newUsername := common.SanitiseSingleLine(r.PostFormValue("account-new-username")) + if newUsername == "" { + return common.LocalError("You can't leave your username blank", w, r, user) + } err := user.ChangeName(newUsername) if err != nil { return common.LocalError("Unable to change the username. Does someone else already have this name?", w, r, user) } - http.Redirect(w, r, "/user/edit/username/?updated=1", http.StatusSeeOther) + http.Redirect(w, r, "/user/edit/?username_updated=1", http.StatusSeeOther) + return nil +} + +func AccountEditMFA(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + header, ferr := accountEditHead("account_mfa", w, r, &user) + if ferr != nil { + return ferr + } + + mfaItem, err := common.MFAstore.Get(user.ID) + if err != sql.ErrNoRows && err != nil { + return common.InternalError(err, w, r) + } else if err == sql.ErrNoRows { + return common.LocalError("Two-factor authentication hasn't been setup on your account", w, r, user) + } + + pi := common.Page{header, tList, mfaItem.Scratch} + if common.RunPreRenderHook("pre_render_account_own_edit_mfa", w, r, &user, &pi) { + return nil + } + err = common.Templates.ExecuteTemplate(w, "account_own_edit_mfa.html", pi) + if err != nil { + return common.InternalError(err, w, r) + } + return nil +} + +// If not setup, generate a string, otherwise give an option to disable mfa given the right code +func AccountEditMFASetup(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + header, ferr := accountEditHead("account_mfa_setup", w, r, &user) + if ferr != nil { + return ferr + } + + // Flash an error if mfa is already setup + _, err := common.MFAstore.Get(user.ID) + if err != sql.ErrNoRows && err != nil { + return common.InternalError(err, w, r) + } else if err != sql.ErrNoRows { + return common.LocalError("You have already setup two-factor authentication", w, r, user) + } + + // TODO: Entitise this? + code, err := common.GenerateGAuthSecret() + if err != nil { + return common.InternalError(err, w, r) + } + + pi := common.Page{header, tList, common.FriendlyGAuthSecret(code)} + if common.RunPreRenderHook("pre_render_account_own_edit_mfa_setup", w, r, &user, &pi) { + return nil + } + err = common.Templates.ExecuteTemplate(w, "account_own_edit_mfa_setup.html", pi) + if err != nil { + return common.InternalError(err, w, r) + } + return nil +} + +// Form should bounce the random mfa secret back and the otp to be verified server-side to reduce the chances of a bug arising on the JS side which makes every code mismatch +func AccountEditMFASetupSubmit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + _, ferr := common.SimpleUserCheck(w, r, &user) + if ferr != nil { + return ferr + } + + // Flash an error if mfa is already setup + _, err := common.MFAstore.Get(user.ID) + if err != sql.ErrNoRows && err != nil { + return common.InternalError(err, w, r) + } else if err != sql.ErrNoRows { + return common.LocalError("You have already setup two-factor authentication", w, r, user) + } + + var code = r.PostFormValue("code") + var otp = r.PostFormValue("otp") + ok, err := common.VerifyGAuthToken(code, otp) + if err != nil { + //fmt.Println("err: ", err) + return common.LocalError("Something weird happened", w, r, user) // TODO: Log this error? + } + // TODO: Use AJAX for this + if !ok { + return common.LocalError("The token isn't right", w, r, user) + } + + // TODO: How should we handle races where a mfa key is already setup? Right now, it's a fairly generic error, maybe try parsing the error message? + err = common.MFAstore.Create(code, user.ID) + if err != nil { + return common.InternalError(err, w, r) + } + + http.Redirect(w, r, "/user/edit/?mfa_setup_success=1", http.StatusSeeOther) + return nil +} + +// TODO: Implement this +func AccountEditMFADisableSubmit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + _, ferr := common.SimpleUserCheck(w, r, &user) + if ferr != nil { + return ferr + } + + // Flash an error if mfa is already setup + mfaItem, err := common.MFAstore.Get(user.ID) + if err != sql.ErrNoRows && err != nil { + return common.InternalError(err, w, r) + } else if err == sql.ErrNoRows { + return common.LocalError("You don't have two-factor enabled on your account", w, r, user) + } + + err = mfaItem.Delete() + if err != nil { + return common.InternalError(err, w, r) + } + + http.Redirect(w, r, "/user/edit/?mfa_disabled=1", http.StatusSeeOther) return nil } func AccountEditEmail(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, ferr := common.UserCheck(w, r, &user) + header, ferr := accountEditHead("account_email", w, r, &user) if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("account_email") - emails, err := common.Emails.GetEmailsByUser(&user) if err != nil { return common.InternalError(err, w, r) diff --git a/routes/panel/analytics.go b/routes/panel/analytics.go index e2a379fa..77c64ad6 100644 --- a/routes/panel/analytics.go +++ b/routes/panel/analytics.go @@ -108,17 +108,15 @@ func analyticsRowsToViewMap(rows *sql.Rows, labelList []int64, viewMap map[int64 } func PreAnalyticsDetail(w http.ResponseWriter, r *http.Request, user *common.User) (*common.BasePanelPage, common.RouteError) { - header, stats, ferr := common.PanelUserCheck(w, r, user) + basePage, ferr := buildBasePage(w, r, user, "analytics", "analytics") if ferr != nil { return nil, ferr } - header.Title = common.GetTitlePhrase("panel_analytics") - header.AddSheet("chartist/chartist.min.css") - header.AddScript("chartist/chartist.min.js") - header.AddScript("analytics.js") - - return &common.BasePanelPage{header, stats, "analytics", common.ReportForumID}, nil + basePage.AddSheet("chartist/chartist.min.css") + basePage.AddScript("chartist/chartist.min.js") + basePage.AddScript("analytics.js") + return basePage, nil } func AnalyticsViews(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { @@ -497,12 +495,10 @@ func analyticsRowsToNameMap(rows *sql.Rows) (map[string]int, error) { } func AnalyticsForums(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "analytics", "analytics") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_analytics") - timeRange, err := analyticsTimeRange(r.FormValue("timeRange")) if err != nil { return common.LocalError(err.Error(), w, r, user) @@ -537,17 +533,15 @@ func AnalyticsForums(w http.ResponseWriter, r *http.Request, user common.User) c }) } - pi := common.PanelAnalyticsAgentsPage{&common.BasePanelPage{header, stats, "analytics", common.ReportForumID}, forumItems, timeRange.Range} + pi := common.PanelAnalyticsAgentsPage{basePage, forumItems, timeRange.Range} return panelRenderTemplate("panel_analytics_forums", w, r, user, &pi) } func AnalyticsRoutes(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "analytics", "analytics") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_analytics") - timeRange, err := analyticsTimeRange(r.FormValue("timeRange")) if err != nil { return common.LocalError(err.Error(), w, r, user) @@ -573,17 +567,15 @@ func AnalyticsRoutes(w http.ResponseWriter, r *http.Request, user common.User) c }) } - pi := common.PanelAnalyticsRoutesPage{&common.BasePanelPage{header, stats, "analytics", common.ReportForumID}, routeItems, timeRange.Range} + pi := common.PanelAnalyticsRoutesPage{basePage, routeItems, timeRange.Range} return panelRenderTemplate("panel_analytics_routes", w, r, user, &pi) } func AnalyticsAgents(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "analytics", "analytics") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_analytics") - timeRange, err := analyticsTimeRange(r.FormValue("timeRange")) if err != nil { return common.LocalError(err.Error(), w, r, user) @@ -614,17 +606,15 @@ func AnalyticsAgents(w http.ResponseWriter, r *http.Request, user common.User) c }) } - pi := common.PanelAnalyticsAgentsPage{&common.BasePanelPage{header, stats, "analytics", common.ReportForumID}, agentItems, timeRange.Range} + pi := common.PanelAnalyticsAgentsPage{basePage, agentItems, timeRange.Range} return panelRenderTemplate("panel_analytics_agents", w, r, user, &pi) } func AnalyticsSystems(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "analytics", "analytics") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_analytics") - timeRange, err := analyticsTimeRange(r.FormValue("timeRange")) if err != nil { return common.LocalError(err.Error(), w, r, user) @@ -655,17 +645,15 @@ func AnalyticsSystems(w http.ResponseWriter, r *http.Request, user common.User) }) } - pi := common.PanelAnalyticsAgentsPage{&common.BasePanelPage{header, stats, "analytics", common.ReportForumID}, systemItems, timeRange.Range} + pi := common.PanelAnalyticsAgentsPage{basePage, systemItems, timeRange.Range} return panelRenderTemplate("panel_analytics_systems", w, r, user, &pi) } func AnalyticsLanguages(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "analytics", "analytics") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_analytics") - timeRange, err := analyticsTimeRange(r.FormValue("timeRange")) if err != nil { return common.LocalError(err.Error(), w, r, user) @@ -697,17 +685,15 @@ func AnalyticsLanguages(w http.ResponseWriter, r *http.Request, user common.User }) } - pi := common.PanelAnalyticsAgentsPage{&common.BasePanelPage{header, stats, "analytics", common.ReportForumID}, langItems, timeRange.Range} + pi := common.PanelAnalyticsAgentsPage{basePage, langItems, timeRange.Range} return panelRenderTemplate("panel_analytics_langs", w, r, user, &pi) } func AnalyticsReferrers(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "analytics", "analytics") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_analytics") - timeRange, err := analyticsTimeRange(r.FormValue("timeRange")) if err != nil { return common.LocalError(err.Error(), w, r, user) @@ -733,6 +719,6 @@ func AnalyticsReferrers(w http.ResponseWriter, r *http.Request, user common.User }) } - pi := common.PanelAnalyticsAgentsPage{&common.BasePanelPage{header, stats, "analytics", common.ReportForumID}, refItems, timeRange.Range} + pi := common.PanelAnalyticsAgentsPage{basePage, refItems, timeRange.Range} return panelRenderTemplate("panel_analytics_referrers", w, r, user, &pi) } diff --git a/routes/panel/backups.go b/routes/panel/backups.go index 131925eb..28cf7db4 100644 --- a/routes/panel/backups.go +++ b/routes/panel/backups.go @@ -11,11 +11,10 @@ import ( ) func Backups(w http.ResponseWriter, r *http.Request, user common.User, backupURL string) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "backups", "backups") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_backups") if backupURL != "" { // We don't want them trying to break out of this directory, it shouldn't hurt since it's a super admin, but it's always good to practice good security hygiene, especially if this is one of many instances on a managed server not controlled by the superadmin/s @@ -25,7 +24,7 @@ func Backups(w http.ResponseWriter, r *http.Request, user common.User, backupURL if ext == ".sql" { info, err := os.Stat("./backups/" + backupURL) if err != nil { - return common.NotFound(w, r, header) + return common.NotFound(w, r, basePage.Header) } // TODO: Change the served filename to gosora_backup_%timestamp%.sql, the time the file was generated, not when it was modified aka what the name of it should be w.Header().Set("Content-Disposition", "attachment; filename=gosora_backup.sql") @@ -34,7 +33,7 @@ func Backups(w http.ResponseWriter, r *http.Request, user common.User, backupURL http.ServeFile(w, r, "./backups/"+backupURL) return nil } - return common.NotFound(w, r, header) + return common.NotFound(w, r, basePage.Header) } var backupList []common.BackupItem @@ -50,6 +49,6 @@ func Backups(w http.ResponseWriter, r *http.Request, user common.User, backupURL backupList = append(backupList, common.BackupItem{backupFile.Name(), backupFile.ModTime()}) } - pi := common.PanelBackupPage{&common.BasePanelPage{header, stats, "backups", common.ReportForumID}, backupList} + pi := common.PanelBackupPage{basePage, backupList} return panelRenderTemplate("panel_backups", w, r, user, &pi) } diff --git a/routes/panel/common.go b/routes/panel/common.go index 5b3a8a06..4c41ebac 100644 --- a/routes/panel/common.go +++ b/routes/panel/common.go @@ -29,3 +29,13 @@ func panelRenderTemplate(tmplName string, w http.ResponseWriter, r *http.Request } return nil } + +func buildBasePage(w http.ResponseWriter, r *http.Request, user *common.User, titlePhrase string, zone string) (*common.BasePanelPage, common.RouteError) { + header, stats, ferr := common.PanelUserCheck(w, r, user) + if ferr != nil { + return nil, ferr + } + header.Title = common.GetTitlePhrase("panel_" + titlePhrase) + + return &common.BasePanelPage{header, stats, zone, common.ReportForumID}, nil +} diff --git a/routes/panel/debug.go b/routes/panel/debug.go index 1ae1bb2a..d4d346d5 100644 --- a/routes/panel/debug.go +++ b/routes/panel/debug.go @@ -11,11 +11,10 @@ import ( ) func Debug(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "debug", "debug") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_debug") goVersion := runtime.Version() dbVersion := qgen.Builder.DbVersion() @@ -38,6 +37,6 @@ func Debug(w http.ResponseWriter, r *http.Request, user common.User) common.Rout // Disk I/O? // TODO: Fetch the adapter from Builder rather than getting it from a global? - pi := common.PanelDebugPage{&common.BasePanelPage{header, stats, "debug", common.ReportForumID}, goVersion, dbVersion, uptime, openConnCount, qgen.Builder.GetAdapter().GetName()} + pi := common.PanelDebugPage{basePage, goVersion, dbVersion, uptime, openConnCount, qgen.Builder.GetAdapter().GetName()} return panelRenderTemplate("panel_debug", w, r, user, &pi) } diff --git a/routes/panel/forums.go b/routes/panel/forums.go index 8e2b4b76..2c6ad4af 100644 --- a/routes/panel/forums.go +++ b/routes/panel/forums.go @@ -11,14 +11,13 @@ import ( ) func Forums(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "forums", "forums") if ferr != nil { return ferr } if !user.Perms.ManageForums { return common.NoPermissions(w, r, user) } - header.Title = common.GetTitlePhrase("panel_forums") // TODO: Paginate this? var forumList []interface{} @@ -39,14 +38,14 @@ func Forums(w http.ResponseWriter, r *http.Request, user common.User) common.Rou } if r.FormValue("created") == "1" { - header.AddNotice("panel_forum_created") + basePage.AddNotice("panel_forum_created") } else if r.FormValue("deleted") == "1" { - header.AddNotice("panel_forum_deleted") + basePage.AddNotice("panel_forum_deleted") } else if r.FormValue("updated") == "1" { - header.AddNotice("panel_forum_updated") + basePage.AddNotice("panel_forum_updated") } - pi := common.PanelPage{&common.BasePanelPage{header, stats, "forums", common.ReportForumID}, forumList, nil} + pi := common.PanelPage{basePage, forumList, nil} return panelRenderTemplate("panel_forums", w, r, user, &pi) } @@ -76,14 +75,13 @@ func ForumsCreateSubmit(w http.ResponseWriter, r *http.Request, user common.User // TODO: Revamp this func ForumsDelete(w http.ResponseWriter, r *http.Request, user common.User, sfid string) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "delete_forum", "forums") if ferr != nil { return ferr } if !user.Perms.ManageForums { return common.NoPermissions(w, r, user) } - header.Title = common.GetTitlePhrase("panel_delete_forum") fid, err := strconv.Atoi(sfid) if err != nil { @@ -101,7 +99,7 @@ func ForumsDelete(w http.ResponseWriter, r *http.Request, user common.User, sfid confirmMsg := "Are you sure you want to delete the '" + forum.Name + "' forum?" yousure := common.AreYouSure{"/panel/forums/delete/submit/" + strconv.Itoa(fid), confirmMsg} - pi := common.PanelPage{&common.BasePanelPage{header, stats, "forums", common.ReportForumID}, tList, yousure} + pi := common.PanelPage{basePage, tList, yousure} if common.RunPreRenderHook("pre_render_panel_delete_forum", w, r, &user, &pi) { return nil } @@ -138,14 +136,13 @@ func ForumsDeleteSubmit(w http.ResponseWriter, r *http.Request, user common.User } func ForumsEdit(w http.ResponseWriter, r *http.Request, user common.User, sfid string) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "edit_forum", "forums") if ferr != nil { return ferr } if !user.Perms.ManageForums { return common.NoPermissions(w, r, user) } - header.Title = common.GetTitlePhrase("panel_edit_forum") fid, err := strconv.Atoi(sfid) if err != nil { @@ -158,7 +155,6 @@ func ForumsEdit(w http.ResponseWriter, r *http.Request, user common.User, sfid s } else if err != nil { return common.InternalError(err, w, r) } - if forum.Preset == "" { forum.Preset = "custom" } @@ -183,14 +179,14 @@ func ForumsEdit(w http.ResponseWriter, r *http.Request, user common.User, sfid s } if r.FormValue("updated") == "1" { - header.AddNotice("panel_forum_updated") + basePage.AddNotice("panel_forum_updated") } - pi := common.PanelEditForumPage{&common.BasePanelPage{header, stats, "forums", common.ReportForumID}, forum.ID, forum.Name, forum.Desc, forum.Active, forum.Preset, gplist} + pi := common.PanelEditForumPage{basePage, forum.ID, forum.Name, forum.Desc, forum.Active, forum.Preset, gplist} if common.RunPreRenderHook("pre_render_panel_edit_forum", w, r, &user, &pi) { return nil } - err = common.Templates.ExecuteTemplate(w, "panel-forum-edit.html", pi) + err = common.Templates.ExecuteTemplate(w, "panel_forum_edit.html", pi) if err != nil { return common.InternalError(err, w, r) } @@ -297,14 +293,13 @@ func forumPermsExtractDash(paramList string) (fid int, gid int, err error) { } func ForumsEditPermsAdvance(w http.ResponseWriter, r *http.Request, user common.User, paramList string) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "edit_forum", "forums") if ferr != nil { return ferr } if !user.Perms.ManageForums { return common.NoPermissions(w, r, user) } - header.Title = common.GetTitlePhrase("panel_edit_forum") fid, gid, err := forumPermsExtractDash(paramList) if err != nil { @@ -350,14 +345,14 @@ func ForumsEditPermsAdvance(w http.ResponseWriter, r *http.Request, user common. addNameLangToggle("MoveTopic", forumPerms.MoveTopic) if r.FormValue("updated") == "1" { - header.AddNotice("panel_forums_perms_updated") + basePage.AddNotice("panel_forums_perms_updated") } - pi := common.PanelEditForumGroupPage{&common.BasePanelPage{header, stats, "forums", common.ReportForumID}, forum.ID, gid, forum.Name, forum.Desc, forum.Active, forum.Preset, formattedPermList} + pi := common.PanelEditForumGroupPage{basePage, forum.ID, gid, forum.Name, forum.Desc, forum.Active, forum.Preset, formattedPermList} if common.RunPreRenderHook("pre_render_panel_edit_forum", w, r, &user, &pi) { return nil } - err = common.Templates.ExecuteTemplate(w, "panel-forum-edit-perms.html", pi) + err = common.Templates.ExecuteTemplate(w, "panel_forum_edit_perms.html", pi) if err != nil { return common.InternalError(err, w, r) } diff --git a/routes/panel/logs.go b/routes/panel/logs.go index b9f34429..948c1909 100644 --- a/routes/panel/logs.go +++ b/routes/panel/logs.go @@ -11,11 +11,10 @@ import ( ) func LogsRegs(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "registration_logs", "logs") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_registration_logs") logCount := common.RegLogs.GlobalCount() page, _ := strconv.Atoi(r.FormValue("page")) @@ -32,7 +31,7 @@ func LogsRegs(w http.ResponseWriter, r *http.Request, user common.User) common.R } pageList := common.Paginate(logCount, perPage, 5) - pi := common.PanelRegLogsPage{&common.BasePanelPage{header, stats, "logs", common.ReportForumID}, llist, common.Paginator{pageList, page, lastPage}} + pi := common.PanelRegLogsPage{basePage, llist, common.Paginator{pageList, page, lastPage}} return panelRenderTemplate("panel_reglogs", w, r, user, &pi) } @@ -102,11 +101,10 @@ func modlogsElementType(action string, elementType string, elementID int, actor } func LogsMod(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "mod_logs", "logs") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_mod_logs") logCount := common.ModLogs.GlobalCount() page, _ := strconv.Atoi(r.FormValue("page")) @@ -125,16 +123,15 @@ func LogsMod(w http.ResponseWriter, r *http.Request, user common.User) common.Ro } pageList := common.Paginate(logCount, perPage, 5) - pi := common.PanelLogsPage{&common.BasePanelPage{header, stats, "logs", common.ReportForumID}, llist, common.Paginator{pageList, page, lastPage}} + pi := common.PanelLogsPage{basePage, llist, common.Paginator{pageList, page, lastPage}} return panelRenderTemplate("panel_modlogs", w, r, user, &pi) } func LogsAdmin(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "admin_logs", "logs") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_admin_logs") logCount := common.ModLogs.GlobalCount() page, _ := strconv.Atoi(r.FormValue("page")) @@ -153,6 +150,6 @@ func LogsAdmin(w http.ResponseWriter, r *http.Request, user common.User) common. } pageList := common.Paginate(logCount, perPage, 5) - pi := common.PanelLogsPage{&common.BasePanelPage{header, stats, "logs", common.ReportForumID}, llist, common.Paginator{pageList, page, lastPage}} + pi := common.PanelLogsPage{basePage, llist, common.Paginator{pageList, page, lastPage}} return panelRenderTemplate("panel_adminlogs", w, r, user, &pi) } diff --git a/routes/panel/pages.go b/routes/panel/pages.go index 67bd0225..705dd919 100644 --- a/routes/panel/pages.go +++ b/routes/panel/pages.go @@ -9,16 +9,15 @@ import ( ) func Pages(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "pages", "pages") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_pages") if r.FormValue("created") == "1" { - header.AddNotice("panel_page_created") + basePage.AddNotice("panel_page_created") } else if r.FormValue("deleted") == "1" { - header.AddNotice("panel_page_deleted") + basePage.AddNotice("panel_page_deleted") } pageCount := common.Pages.GlobalCount() @@ -32,7 +31,7 @@ func Pages(w http.ResponseWriter, r *http.Request, user common.User) common.Rout } pageList := common.Paginate(pageCount, perPage, 5) - pi := common.PanelCustomPagesPage{&common.BasePanelPage{header, stats, "pages", common.ReportForumID}, cPages, common.Paginator{pageList, page, lastPage}} + pi := common.PanelCustomPagesPage{basePage, cPages, common.Paginator{pageList, page, lastPage}} return panelRenderTemplate("panel_pages", w, r, user, &pi) } @@ -69,14 +68,12 @@ func PagesCreateSubmit(w http.ResponseWriter, r *http.Request, user common.User) } func PagesEdit(w http.ResponseWriter, r *http.Request, user common.User, spid string) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "pages_edit", "pages") if ferr != nil { return ferr } - header.Title = common.GetTitlePhrase("panel_pages_edit") - if r.FormValue("updated") == "1" { - header.AddNotice("panel_page_updated") + basePage.AddNotice("panel_page_updated") } pid, err := strconv.Atoi(spid) @@ -86,12 +83,12 @@ func PagesEdit(w http.ResponseWriter, r *http.Request, user common.User, spid st page, err := common.Pages.Get(pid) if err == sql.ErrNoRows { - return common.NotFound(w, r, header) + return common.NotFound(w, r, basePage.Header) } else if err != nil { return common.InternalError(err, w, r) } - pi := common.PanelCustomPageEditPage{&common.BasePanelPage{header, stats, "pages", common.ReportForumID}, page} + pi := common.PanelCustomPageEditPage{basePage, page} return panelRenderTemplate("panel_pages_edit", w, r, user, &pi) } diff --git a/routes/panel/settings.go b/routes/panel/settings.go index a3eeb73e..88092f52 100644 --- a/routes/panel/settings.go +++ b/routes/panel/settings.go @@ -11,16 +11,15 @@ import ( ) func Settings(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "settings", "settings") if ferr != nil { return ferr } if !user.Perms.EditSettings { return common.NoPermissions(w, r, user) } - header.Title = common.GetTitlePhrase("panel_settings") - settings, err := header.Settings.BypassGetAll() + settings, err := basePage.Settings.BypassGetAll() if err != nil { return common.InternalError(err, w, r) } @@ -49,21 +48,20 @@ func Settings(w http.ResponseWriter, r *http.Request, user common.User) common.R settingList = append(settingList, &common.PanelSetting{setting, common.GetSettingPhrase(setting.Name)}) } - pi := common.PanelPage{&common.BasePanelPage{header, stats, "settings", common.ReportForumID}, tList, settingList} + pi := common.PanelPage{basePage, tList, settingList} return panelRenderTemplate("panel_settings", w, r, user, &pi) } func SettingEdit(w http.ResponseWriter, r *http.Request, user common.User, sname string) common.RouteError { - header, stats, ferr := common.PanelUserCheck(w, r, &user) + basePage, ferr := buildBasePage(w, r, &user, "edit_setting", "settings") if ferr != nil { return ferr } if !user.Perms.EditSettings { return common.NoPermissions(w, r, user) } - header.Title = common.GetTitlePhrase("panel_edit_setting") - setting, err := header.Settings.BypassGet(sname) + setting, err := basePage.Settings.BypassGet(sname) if err == sql.ErrNoRows { return common.LocalError("The setting you want to edit doesn't exist.", w, r, user) } else if err != nil { @@ -91,7 +89,7 @@ func SettingEdit(w http.ResponseWriter, r *http.Request, user common.User, sname } pSetting := &common.PanelSetting{setting, common.GetSettingPhrase(setting.Name)} - pi := common.PanelSettingPage{&common.BasePanelPage{header, stats, "settings", common.ReportForumID}, itemList, pSetting} + pi := common.PanelSettingPage{basePage, itemList, pSetting} return panelRenderTemplate("panel_setting", w, r, user, &pi) } diff --git a/routes/panel/users.go b/routes/panel/users.go new file mode 100644 index 00000000..7ef8ec10 --- /dev/null +++ b/routes/panel/users.go @@ -0,0 +1,171 @@ +package panel + +import ( + "database/sql" + "net/http" + "strconv" + + "../../common" +) + +func Users(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + basePage, ferr := buildBasePage(w, r, &user, "users", "users") + if ferr != nil { + return ferr + } + + page, _ := strconv.Atoi(r.FormValue("page")) + perPage := 10 + offset, page, lastPage := common.PageOffset(basePage.Stats.Users, page, perPage) + + users, err := common.Users.GetOffset(offset, perPage) + if err != nil { + return common.InternalError(err, w, r) + } + + pageList := common.Paginate(basePage.Stats.Users, perPage, 5) + pi := common.PanelUserPage{basePage, users, common.Paginator{pageList, page, lastPage}} + return panelRenderTemplate("panel_users", w, r, user, &pi) +} + +func UsersEdit(w http.ResponseWriter, r *http.Request, user common.User, suid string) common.RouteError { + basePage, ferr := buildBasePage(w, r, &user, "edit_user", "users") + if ferr != nil { + return ferr + } + if !user.Perms.EditUser { + return common.NoPermissions(w, r, user) + } + + uid, err := strconv.Atoi(suid) + if err != nil { + return common.LocalError("The provided UserID is not a valid number.", w, r, user) + } + + targetUser, err := common.Users.Get(uid) + if err == sql.ErrNoRows { + return common.LocalError("The user you're trying to edit doesn't exist.", w, r, user) + } else if err != nil { + return common.InternalError(err, w, r) + } + + if targetUser.IsAdmin && !user.IsAdmin { + return common.LocalError("Only administrators can edit the account of an administrator.", w, r, user) + } + + // ? - Should we stop admins from deleting all the groups? Maybe, protect the group they're currently using? + groups, err := common.Groups.GetRange(1, 0) // ? - 0 = Go to the end + if err != nil { + return common.InternalError(err, w, r) + } + + var groupList []interface{} + for _, group := range groups { + if !user.Perms.EditUserGroupAdmin && group.IsAdmin { + continue + } + if !user.Perms.EditUserGroupSuperMod && group.IsMod { + continue + } + groupList = append(groupList, group) + } + + if r.FormValue("updated") == "1" { + basePage.AddNotice("panel_user_updated") + } + + pi := common.PanelPage{basePage, groupList, targetUser} + if common.RunPreRenderHook("pre_render_panel_edit_user", w, r, &user, &pi) { + return nil + } + err = common.Templates.ExecuteTemplate(w, "panel_user_edit.html", pi) + if err != nil { + return common.InternalError(err, w, r) + } + return nil +} + +func UsersEditSubmit(w http.ResponseWriter, r *http.Request, user common.User, suid string) common.RouteError { + _, ferr := common.SimplePanelUserCheck(w, r, &user) + if ferr != nil { + return ferr + } + if !user.Perms.EditUser { + return common.NoPermissions(w, r, user) + } + + uid, err := strconv.Atoi(suid) + if err != nil { + return common.LocalError("The provided UserID is not a valid number.", w, r, user) + } + + targetUser, err := common.Users.Get(uid) + if err == sql.ErrNoRows { + return common.LocalError("The user you're trying to edit doesn't exist.", w, r, user) + } else if err != nil { + return common.InternalError(err, w, r) + } + + if targetUser.IsAdmin && !user.IsAdmin { + return common.LocalError("Only administrators can edit the account of other administrators.", w, r, user) + } + + newname := common.SanitiseSingleLine(r.PostFormValue("user-name")) + if newname == "" { + return common.LocalError("You didn't put in a username.", w, r, user) + } + + // TODO: How should activation factor into admin set emails? + // TODO: How should we handle secondary emails? Do we even have secondary emails implemented? + newemail := common.SanitiseSingleLine(r.PostFormValue("user-email")) + if newemail == "" { + return common.LocalError("You didn't put in an email address.", w, r, user) + } + if (newemail != targetUser.Email) && !user.Perms.EditUserEmail { + return common.LocalError("You need the EditUserEmail permission to edit the email address of a user.", w, r, user) + } + + newpassword := r.PostFormValue("user-password") + if newpassword != "" && !user.Perms.EditUserPassword { + return common.LocalError("You need the EditUserPassword permission to edit the password of a user.", w, r, user) + } + + newgroup, err := strconv.Atoi(r.PostFormValue("user-group")) + if err != nil { + return common.LocalError("You need to provide a whole number for the group ID", w, r, user) + } + + group, err := common.Groups.Get(newgroup) + if err == sql.ErrNoRows { + return common.LocalError("The group you're trying to place this user in doesn't exist.", w, r, user) + } else if err != nil { + return common.InternalError(err, w, r) + } + + if !user.Perms.EditUserGroupAdmin && group.IsAdmin { + return common.LocalError("You need the EditUserGroupAdmin permission to assign someone to an administrator group.", w, r, user) + } + if !user.Perms.EditUserGroupSuperMod && group.IsMod { + return common.LocalError("You need the EditUserGroupSuperMod permission to assign someone to a super mod group.", w, r, user) + } + + err = targetUser.Update(newname, newemail, newgroup) + if err != nil { + return common.InternalError(err, w, r) + } + + if newpassword != "" { + common.SetPassword(targetUser.ID, newpassword) + // Log the user out as a safety precaution + common.Auth.ForceLogout(targetUser.ID) + } + targetUser.CacheRemove() + + // If we're changing our own password, redirect to the index rather than to a noperms error due to the force logout + if targetUser.ID == user.ID { + http.Redirect(w, r, "/", http.StatusSeeOther) + } else { + http.Redirect(w, r, "/panel/users/edit/"+strconv.Itoa(targetUser.ID)+"?updated=1", http.StatusSeeOther) + } + return nil +} diff --git a/schema/mssql/inserts.sql b/schema/mssql/inserts.sql index 97678750..770be7d2 100644 --- a/schema/mssql/inserts.sql +++ b/schema/mssql/inserts.sql @@ -31,7 +31,7 @@ INSERT INTO [menus] () VALUES (); INSERT INTO [menu_items] ([mid],[name],[htmlID],[position],[path],[aria],[tooltip],[order]) VALUES (1,'{lang.menu_forums}','menu_forums','left','/forums/','{lang.menu_forums_aria}','{lang.menu_forums_tooltip}',0); INSERT INTO [menu_items] ([mid],[name],[htmlID],[cssClass],[position],[path],[aria],[tooltip],[order]) VALUES (1,'{lang.menu_topics}','menu_topics','menu_topics','left','/topics/','{lang.menu_topics_aria}','{lang.menu_topics_tooltip}',1); INSERT INTO [menu_items] ([mid],[htmlID],[cssClass],[position],[tmplName],[order]) VALUES (1,'general_alerts','menu_alerts','right','menu_alerts',2); -INSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[memberOnly],[order]) VALUES (1,'{lang.menu_account}','menu_account','left','/user/edit/critical/','{lang.menu_account_aria}','{lang.menu_account_tooltip}',1,3); +INSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[memberOnly],[order]) VALUES (1,'{lang.menu_account}','menu_account','left','/user/edit/','{lang.menu_account_aria}','{lang.menu_account_tooltip}',1,3); INSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[memberOnly],[order]) VALUES (1,'{lang.menu_profile}','menu_profile','left','{me.Link}','{lang.menu_profile_aria}','{lang.menu_profile_tooltip}',1,4); INSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[memberOnly],[staffOnly],[order]) VALUES (1,'{lang.menu_panel}','menu_panel menu_account','left','/panel/','{lang.menu_panel_aria}','{lang.menu_panel_tooltip}',1,1,5); INSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[memberOnly],[order]) VALUES (1,'{lang.menu_logout}','menu_logout','left','/accounts/logout/?session={me.Session}','{lang.menu_logout_aria}','{lang.menu_logout_tooltip}',1,6); diff --git a/schema/mssql/query_users_2fa_keys.sql b/schema/mssql/query_users_2fa_keys.sql new file mode 100644 index 00000000..6f44a306 --- /dev/null +++ b/schema/mssql/query_users_2fa_keys.sql @@ -0,0 +1,14 @@ +CREATE TABLE [users_2fa_keys] ( + [uid] int not null, + [secret] nvarchar (100) not null, + [scratch1] nvarchar (50) not null, + [scratch2] nvarchar (50) not null, + [scratch3] nvarchar (50) not null, + [scratch4] nvarchar (50) not null, + [scratch5] nvarchar (50) not null, + [scratch6] nvarchar (50) not null, + [scratch7] nvarchar (50) not null, + [scratch8] nvarchar (50) not null, + [createdAt] datetime not null, + primary key([uid]) +); \ No newline at end of file diff --git a/schema/mysql/inserts.sql b/schema/mysql/inserts.sql index a8c2c13d..e099d716 100644 --- a/schema/mysql/inserts.sql +++ b/schema/mysql/inserts.sql @@ -31,7 +31,7 @@ INSERT INTO `menus`() VALUES (); INSERT INTO `menu_items`(`mid`,`name`,`htmlID`,`position`,`path`,`aria`,`tooltip`,`order`) VALUES (1,'{lang.menu_forums}','menu_forums','left','/forums/','{lang.menu_forums_aria}','{lang.menu_forums_tooltip}',0); INSERT INTO `menu_items`(`mid`,`name`,`htmlID`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`order`) VALUES (1,'{lang.menu_topics}','menu_topics','menu_topics','left','/topics/','{lang.menu_topics_aria}','{lang.menu_topics_tooltip}',1); INSERT INTO `menu_items`(`mid`,`htmlID`,`cssClass`,`position`,`tmplName`,`order`) VALUES (1,'general_alerts','menu_alerts','right','menu_alerts',2); -INSERT INTO `menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`memberOnly`,`order`) VALUES (1,'{lang.menu_account}','menu_account','left','/user/edit/critical/','{lang.menu_account_aria}','{lang.menu_account_tooltip}',1,3); +INSERT INTO `menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`memberOnly`,`order`) VALUES (1,'{lang.menu_account}','menu_account','left','/user/edit/','{lang.menu_account_aria}','{lang.menu_account_tooltip}',1,3); INSERT INTO `menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`memberOnly`,`order`) VALUES (1,'{lang.menu_profile}','menu_profile','left','{me.Link}','{lang.menu_profile_aria}','{lang.menu_profile_tooltip}',1,4); INSERT INTO `menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`memberOnly`,`staffOnly`,`order`) VALUES (1,'{lang.menu_panel}','menu_panel menu_account','left','/panel/','{lang.menu_panel_aria}','{lang.menu_panel_tooltip}',1,1,5); INSERT INTO `menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`memberOnly`,`order`) VALUES (1,'{lang.menu_logout}','menu_logout','left','/accounts/logout/?session={me.Session}','{lang.menu_logout_aria}','{lang.menu_logout_tooltip}',1,6); diff --git a/schema/mysql/query_users_2fa_keys.sql b/schema/mysql/query_users_2fa_keys.sql new file mode 100644 index 00000000..1cfd8a6f --- /dev/null +++ b/schema/mysql/query_users_2fa_keys.sql @@ -0,0 +1,14 @@ +CREATE TABLE `users_2fa_keys` ( + `uid` int not null, + `secret` varchar(100) not null, + `scratch1` varchar(50) not null, + `scratch2` varchar(50) not null, + `scratch3` varchar(50) not null, + `scratch4` varchar(50) not null, + `scratch5` varchar(50) not null, + `scratch6` varchar(50) not null, + `scratch7` varchar(50) not null, + `scratch8` varchar(50) not null, + `createdAt` datetime not null, + primary key(`uid`) +) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci; \ No newline at end of file diff --git a/schema/pgsql/inserts.sql b/schema/pgsql/inserts.sql index 6c3ebd96..73395d01 100644 --- a/schema/pgsql/inserts.sql +++ b/schema/pgsql/inserts.sql @@ -31,7 +31,7 @@ INSERT INTO "menus"() VALUES (); INSERT INTO "menu_items"("mid","name","htmlID","position","path","aria","tooltip","order") VALUES (1,'{lang.menu_forums}','menu_forums','left','/forums/','{lang.menu_forums_aria}','{lang.menu_forums_tooltip}',0); INSERT INTO "menu_items"("mid","name","htmlID","cssClass","position","path","aria","tooltip","order") VALUES (1,'{lang.menu_topics}','menu_topics','menu_topics','left','/topics/','{lang.menu_topics_aria}','{lang.menu_topics_tooltip}',1); INSERT INTO "menu_items"("mid","htmlID","cssClass","position","tmplName","order") VALUES (1,'general_alerts','menu_alerts','right','menu_alerts',2); -INSERT INTO "menu_items"("mid","name","cssClass","position","path","aria","tooltip","memberOnly","order") VALUES (1,'{lang.menu_account}','menu_account','left','/user/edit/critical/','{lang.menu_account_aria}','{lang.menu_account_tooltip}',1,3); +INSERT INTO "menu_items"("mid","name","cssClass","position","path","aria","tooltip","memberOnly","order") VALUES (1,'{lang.menu_account}','menu_account','left','/user/edit/','{lang.menu_account_aria}','{lang.menu_account_tooltip}',1,3); INSERT INTO "menu_items"("mid","name","cssClass","position","path","aria","tooltip","memberOnly","order") VALUES (1,'{lang.menu_profile}','menu_profile','left','{me.Link}','{lang.menu_profile_aria}','{lang.menu_profile_tooltip}',1,4); INSERT INTO "menu_items"("mid","name","cssClass","position","path","aria","tooltip","memberOnly","staffOnly","order") VALUES (1,'{lang.menu_panel}','menu_panel menu_account','left','/panel/','{lang.menu_panel_aria}','{lang.menu_panel_tooltip}',1,1,5); INSERT INTO "menu_items"("mid","name","cssClass","position","path","aria","tooltip","memberOnly","order") VALUES (1,'{lang.menu_logout}','menu_logout','left','/accounts/logout/?session={me.Session}','{lang.menu_logout_aria}','{lang.menu_logout_tooltip}',1,6); diff --git a/schema/pgsql/query_users_2fa_keys.sql b/schema/pgsql/query_users_2fa_keys.sql new file mode 100644 index 00000000..d9e48a22 --- /dev/null +++ b/schema/pgsql/query_users_2fa_keys.sql @@ -0,0 +1,14 @@ +CREATE TABLE `users_2fa_keys` ( + `uid` int not null, + `secret` varchar (100) not null, + `scratch1` varchar (50) not null, + `scratch2` varchar (50) not null, + `scratch3` varchar (50) not null, + `scratch4` varchar (50) not null, + `scratch5` varchar (50) not null, + `scratch6` varchar (50) not null, + `scratch7` varchar (50) not null, + `scratch8` varchar (50) not null, + `createdAt` timestamp not null, + primary key(`uid`) +); \ No newline at end of file diff --git a/schema/schema.json b/schema/schema.json index 0e619035..6a00a98c 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -1,5 +1,5 @@ { - "DBVersion":"5", + "DBVersion":"6", "DynamicFileVersion":"0", "MinGoVersion":"1.10", "MinVersion":"" diff --git a/templates/account_menu.html b/templates/account_menu.html index 9b40cc8a..a4830dd1 100644 --- a/templates/account_menu.html +++ b/templates/account_menu.html @@ -1,15 +1,13 @@ diff --git a/templates/account_own_edit.html b/templates/account_own_edit.html new file mode 100644 index 00000000..48226698 --- /dev/null +++ b/templates/account_own_edit.html @@ -0,0 +1,31 @@ +{{template "header.html" . }} +
+ {{template "account_menu.html" . }} +
+
+
+
+
+ Saved + + +
+ + +
+ + + + + + +
+
+
+
{{if not .MFASetup}}{{lang "account_dash_2fa_setup"}}{{else}}{{lang "account_dash_2fa_manage"}}{{end}} {{lang "account_dash_security_notice"}}
+
{{lang "account_dash_next_level"}}
+
+
+
+
+{{template "footer.html" . }} diff --git a/templates/account_own_edit_avatar.html b/templates/account_own_edit_avatar.html deleted file mode 100644 index c56fbfc6..00000000 --- a/templates/account_own_edit_avatar.html +++ /dev/null @@ -1,24 +0,0 @@ -{{template "header.html" . }} -
- {{template "account_menu.html" . }} -
-
-

{{lang "account_avatar_head"}}

-
-
-
-
-
-
- -
-
-
-
-
-
-
-{{template "footer.html" . }} diff --git a/templates/account_own_edit_mfa.html b/templates/account_own_edit_mfa.html new file mode 100644 index 00000000..103be606 --- /dev/null +++ b/templates/account_own_edit_mfa.html @@ -0,0 +1,29 @@ +{{template "header.html" . }} +
+ {{template "account_menu.html" . }} +
+
+

{{lang "account_mfa_head"}}

+
+
+
+
+ +
+
+
+
+
+

{{lang "account_mfa_scratch_head"}}

+
+
{{/** TODO: Don't inline this, figure a way of implementing it properly in the template system **/}} +
{{lang "account_mfa_scratch_explanation"}}
+
+
+ {{range .Something}} +
{{.}}
+ {{end}} +
+
+
+{{template "footer.html" . }} diff --git a/templates/account_own_edit_mfa_setup.html b/templates/account_own_edit_mfa_setup.html new file mode 100644 index 00000000..d797fa77 --- /dev/null +++ b/templates/account_own_edit_mfa_setup.html @@ -0,0 +1,26 @@ +{{template "header.html" . }} +
+ {{template "account_menu.html" . }} +
+
+

{{lang "account_mfa_setup_head"}}

+
+
+
+ + +
+ +
{{/** TODO: Make this a password? **/}} +
+
+
+
+
+
+
+
+{{template "footer.html" . }} diff --git a/templates/account_own_edit_password.html b/templates/account_own_edit_password.html index 4ded54ea..9116683f 100644 --- a/templates/account_own_edit_password.html +++ b/templates/account_own_edit_password.html @@ -6,7 +6,7 @@

{{lang "account_password_head"}}

-
+
diff --git a/templates/account_own_edit_username.html b/templates/account_own_edit_username.html deleted file mode 100644 index 1844aaa4..00000000 --- a/templates/account_own_edit_username.html +++ /dev/null @@ -1,25 +0,0 @@ -{{template "header.html" . }} - -{{template "footer.html" . }} diff --git a/templates/header.html b/templates/header.html index e569caa9..78c55dd6 100644 --- a/templates/header.html +++ b/templates/header.html @@ -14,10 +14,13 @@ {{if .Header.MetaDesc}}{{end}} + + {{if not .CurrentUser.IsSuperMod}}{{end}} @@ -53,6 +56,6 @@
-
{{range .Header.NoticeList}} +
{{range .Header.NoticeList}} {{template "notice.html" . }}{{end}}
\ No newline at end of file diff --git a/templates/login_mfa_verify.html b/templates/login_mfa_verify.html new file mode 100644 index 00000000..5a586f14 --- /dev/null +++ b/templates/login_mfa_verify.html @@ -0,0 +1,21 @@ +{{template "header.html" . }} +
+
+

{{lang "login_mfa_verify_head"}}

+
+
+
+ + + +
+
+
+{{template "footer.html" . }} diff --git a/templates/panel-forum-edit.html b/templates/panel_forum_edit.html similarity index 100% rename from templates/panel-forum-edit.html rename to templates/panel_forum_edit.html diff --git a/templates/panel-forum-edit-perms.html b/templates/panel_forum_edit_perms.html similarity index 100% rename from templates/panel-forum-edit-perms.html rename to templates/panel_forum_edit_perms.html diff --git a/templates/panel-group-edit.html b/templates/panel_group_edit.html similarity index 100% rename from templates/panel-group-edit.html rename to templates/panel_group_edit.html diff --git a/templates/panel-group-edit-perms.html b/templates/panel_group_edit_perms.html similarity index 100% rename from templates/panel-group-edit-perms.html rename to templates/panel_group_edit_perms.html diff --git a/templates/panel-user-edit.html b/templates/panel_user_edit.html similarity index 100% rename from templates/panel-user-edit.html rename to templates/panel_user_edit.html diff --git a/themes/cosora/public/account.css b/themes/cosora/public/account.css new file mode 100644 index 00000000..b4b4148f --- /dev/null +++ b/themes/cosora/public/account.css @@ -0,0 +1,88 @@ +.sidebar, .footer .widget { + display: none; +} +#account_dashboard .colstack_right .coldyn_block { + display: flex; +} +#account_dashboard .coldyn_item { + margin-left: 16px; +} +#dash_left { + border: 1px solid var(--element-border-color); + border-bottom: 2px solid var(--element-border-color); + background-color: var(--element-background-color); + padding: 18px; + height: 184px; + position: relative; +} +#dash_saved { + text-transform: uppercase; + font-size: 11px; + color: green; + position: absolute; + right: 8px; + top: 8px; + display: none; +} +.dash_security, .account_soon { + text-transform: uppercase; + font-size: 11px; + color: maroon; +} +#dash_username { + display: flex; + font-size: 18px; + text-align: center; + margin-bottom: 6px; +} +#dash_username input { + font-size: 16px; + width: 130px; + width: 80px; + padding-left: 8px; + margin-top: -4px; + margin-bottom: 6px; + margin-left: auto; + margin-right: auto; + color: hsl(0,0%,45%); /* TODO: Use this colour elsewhere? */ + text-align: center; +} +#dash_username button { + display: none; + margin-left: 4px; + padding: 6px; + margin-top: 0px; + margin-bottom: 6px; + padding-top: 4px; + padding-bottom: 4px; +} +#dash_left img { + display: block; + border-radius: 48px; + height: 72px; + width: 72px; + margin-left: auto; + margin-right: auto; +} +#dash_left label { + display: inline-block; + margin-right: 8px; +} +#dash_avatar_buttons { + display: flex; + margin-bottom: 3px; +} +#dash_right { + width: 100%; + background: none !important; + border: none !important; +} +#dash_right .rowitem { + border: 1px solid var(--element-border-color); + border-bottom: 2px solid var(--element-border-color); + background-color: var(--element-background-color); + padding: 16px; +} +#dash_right .rowitem:not(:last-child) { + margin-bottom: 8px; +} \ No newline at end of file diff --git a/themes/cosora/public/main.css b/themes/cosora/public/main.css index 30afef91..0fe0ed52 100644 --- a/themes/cosora/public/main.css +++ b/themes/cosora/public/main.css @@ -1245,7 +1245,7 @@ textarea { border-top: 1px solid var(--element-border-color) !important; } -.colstack_item .formrow { +/*.colstack_item .formrow { display: flex; } .colstack_right .formrow { @@ -1267,7 +1267,7 @@ textarea { width: 40%; margin-right: 12px; white-space: nowrap; -} +}*/ .formitem:only-child { width: 100%; display: flex; @@ -1295,25 +1295,28 @@ textarea { #create_topic_page .close_form, #create_topic_page .formlabel, #login_page .formlabel { display: none; } -#login_page .formrow:not(:first-child):not(:last-child), #register_page .formrow:not(:first-child):not(:last-child) { +.formrow:not(:first-child):not(:last-child) { margin-top: 4px; } -#login_page .formrow:not(:first-child), #register_page .formrow:not(:first-child) { +.formrow:not(:first-child) { padding-top: 3px; } +.formrow { + padding: 16px; +} +.formrow:not(:last-child) { + padding-bottom: 4px; +} #login_page .formrow:not(:last-child) { padding-bottom: 0px; } -#login_page .formrow, #register_page .formrow { - padding: 16px; -} -#register_page .formrow:not(:last-child) { - padding-bottom: 4px; -} -#register_page .formlabel { +.formlabel { display: block; font-size: 15px; } +.quick_create_form .formrow { + padding: 0px; +} #register_page .register_button_row { padding: 12px !important; padding-top: 0px !important; diff --git a/themes/cosora/public/misc.js b/themes/cosora/public/misc.js index 37b4ee77..b4088362 100644 --- a/themes/cosora/public/misc.js +++ b/themes/cosora/public/misc.js @@ -90,8 +90,14 @@ $(document).ready(function(){ // Move the alerts under the first header let colSel = $(".colstack_right .colstack_head:first"); + let colSelAlt = $(".colstack_right .colstack_item:first"); + let colSelAltAlt = $(".colstack_right .coldyn_block:first"); if(colSel.length > 0) { $('.alert').insertAfter(colSel); + } else if (colSelAlt.length > 0) { + $('.alert').insertBefore(colSelAlt); + } else if (colSelAltAlt.length > 0) { + $('.alert').insertBefore(colSelAltAlt); } else { $('.alert').insertAfter(".rowhead:first"); } diff --git a/themes/nox/public/account.css b/themes/nox/public/account.css new file mode 100644 index 00000000..e4cb8c52 --- /dev/null +++ b/themes/nox/public/account.css @@ -0,0 +1,69 @@ +.sidebar, .footer .widget { + display: none; +} + +#account_dashboard .colstack_right .coldyn_block { + display: flex; +} +#dash_left { + border-radius: 3px; + background-color: #444444; + padding: 12px; + height: 180px; + width: 240px; + position: relative; +} +#dash_saved { + display: none; +} +#dash_username { + display: flex; +} +#dash_username input { + display: block; + margin-left: auto; + margin-right: auto; + margin-bottom: 8px; + /*font-size: 16px;*/ + width: 100px; + display: relative; + padding-left: 16px; + background-position: right 8px bottom 8px; +} +#dash_username button { + margin-bottom: 8px; + padding-top: 2px; + padding-bottom: 2px; +} + +#dash_left img { + display: block; + border-radius: 48px; + height: 72px; + width: 72px; + margin-left: auto; + margin-right: auto; + margin-bottom: 12px; +} +#dash_avatar_buttons { + display: flex; +} +#dash_avatar_buttons label { + margin-left: auto; + margin-right: 8px; +} +#dash_avatar_buttons button { + margin-right: auto; +} +#dash_right { + width: 100%; + margin-left: 12px; +} +#dash_right .rowitem { + border-radius: 3px; + background-color: #444444; + padding: 16px; +} +#dash_right .rowitem:not(:last-child) { + margin-bottom: 8px; +} \ No newline at end of file diff --git a/themes/nox/public/fa-svg/LICENSE.txt b/themes/nox/public/fa-svg/LICENSE.txt new file mode 100644 index 00000000..28c1c4bc --- /dev/null +++ b/themes/nox/public/fa-svg/LICENSE.txt @@ -0,0 +1,34 @@ +Font Awesome Free License +------------------------- + +Font Awesome Free is free, open source, and GPL friendly. You can use it for +commercial projects, open source projects, or really almost whatever you want. +Full Font Awesome Free license: https://fontawesome.com/license. + +# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/) +In the Font Awesome Free download, the CC BY 4.0 license applies to all icons +packaged as SVG and JS file types. + +# Fonts: SIL OFL 1.1 License (https://scripts.sil.org/OFL) +In the Font Awesome Free download, the SIL OLF license applies to all icons +packaged as web and desktop font files. + +# Code: MIT License (https://opensource.org/licenses/MIT) +In the Font Awesome Free download, the MIT license applies to all non-font and +non-icon files. + +# Attribution +Attribution is required by MIT, SIL OLF, and CC BY licenses. Downloaded Font +Awesome Free files already contain embedded comments with sufficient +attribution, so you shouldn't need to do anything additional when using these +files normally. + +We've kept attribution comments terse, so we ask that you do not actively work +to remove them from files, especially code. They're a great way for folks to +learn about Font Awesome. + +# Brand Icons +All brand icons are trademarks of their respective owners. The use of these +trademarks does not indicate endorsement of the trademark holder by Font +Awesome, nor vice versa. **Please do not use brand logos for any purpose except +to represent the company, product, or service to which they refer.** diff --git a/themes/nox/public/fa-svg/README.md b/themes/nox/public/fa-svg/README.md new file mode 100644 index 00000000..6e421418 --- /dev/null +++ b/themes/nox/public/fa-svg/README.md @@ -0,0 +1,7 @@ +# Font Awesome 5.0.13 + +Thanks for downloading Font Awesome! We're so excited you're here. + +Our documentation is available online. Just head here: + +https://fontawesome.com diff --git a/themes/nox/public/fa-svg/pencil-alt-light.svg b/themes/nox/public/fa-svg/pencil-alt-light.svg new file mode 100644 index 00000000..5e7a474e --- /dev/null +++ b/themes/nox/public/fa-svg/pencil-alt-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/themes/nox/public/fa-svg/pencil-alt.svg b/themes/nox/public/fa-svg/pencil-alt.svg new file mode 100644 index 00000000..02872eb7 --- /dev/null +++ b/themes/nox/public/fa-svg/pencil-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/themes/nox/public/main.css b/themes/nox/public/main.css index cc422259..357459ec 100644 --- a/themes/nox/public/main.css +++ b/themes/nox/public/main.css @@ -1,5 +1,23 @@ :root { --darkest-background: #222222; + --second-dark-background: #292929; + --third-dark-background: #333333; +} + +@font-face { + font-family: 'Font Awesome 5 Free'; + font-style: normal; + font-weight: 400; + src: url("../fontawesome-5.0.13/webfonts/fa-regular-400.eot"); + src: url("../fontawesome-5.0.13/webfonts/fa-regular-400.eot?#iefix") format("embedded-opentype"), url("../fontawesome-5.0.13/webfonts/fa-regular-400.woff2") format("woff2"), url("../fontawesome-5.0.13/webfonts/fa-regular-400.woff") format("woff"), url("../fontawesome-5.0.13/webfonts/fa-regular-400.ttf") format("truetype"), url("../fontawesome-5.0.13/webfonts/fa-regular-400.svg#fontawesome") format("svg"); +} + +@font-face { + font-family: 'Font Awesome 5 Free'; + font-style: normal; + font-weight: 900; + src: url("../fontawesome-5.0.13/webfonts/fa-solid-900.eot"); + src: url("../fontawesome-5.0.13/webfonts/fa-solid-900.eot?#iefix") format("embedded-opentype"), url("../fontawesome-5.0.13/webfonts/fa-solid-900.woff2") format("woff2"), url("../fontawesome-5.0.13/webfonts/fa-solid-900.woff") format("woff"), url("../fontawesome-5.0.13/webfonts/fa-solid-900.ttf") format("truetype"), url("../fontawesome-5.0.13/webfonts/fa-solid-900.svg#fontawesome") format("svg"); } * { @@ -67,7 +85,8 @@ li a { .user_box { display: flex; flex-direction: row; - background-color: #333333; + border-radius: 3px; + background-color: var(--third-dark-background); padding-top: 11px; padding-bottom: 11px; padding-left: 12px; @@ -93,7 +112,7 @@ li a { clear: both; } #back { - background: #333333; + background: var(--third-dark-background); padding: 24px; padding-top: 12px; clear: both; @@ -107,11 +126,31 @@ li a { width: 320px; } .rowblock:not(.topic_list):not(.rowhead):not(.opthead) .rowitem { + border-radius: 3px; background-color: #444444; display: flex; padding: 12px; margin-left: 12px; } +.colstack_right .colstack_item.the_form { + border-radius: 3px; + background-color: #444444; + padding: 16px; +} +.colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem { + border-radius: 3px; + background-color: #444444; + padding: 16px; +} +.colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem:not(:last-child) { + margin-bottom: 8px; +} +.colstack_right .colstack_head:not(:first-child) { + margin-top: 16px; +} +.rowmsg { + margin-bottom: 8px; +} h1, h3 { -webkit-margin-before: 0; @@ -146,6 +185,7 @@ h1, h3 { margin-bottom: 8px; } .topic_row { + border-radius: 3px; background-color: #444444; display: flex; } @@ -212,6 +252,35 @@ h1, h3 { display: none; } +input, select, button, .formbutton, textarea { + border-radius: 3px; + background: rgb(90,90,90); + color: rgb(200,200,200); + border: none; + padding: 4px; +} +input:focus, select:focus, textarea:focus { + outline: 1px solid rgb(120,120,120); +} +input { + background-image: url(./fa-svg/pencil-alt.svg); + background-size: 12px; + background-repeat: no-repeat; + background-position: right 10px bottom 9px; + background-position-x: right 10px; + padding: 5px; + padding-bottom: 3px; + font-size: 16px; +} +button, .formbutton { + background: rgb(110,110,210); + color: rgb(250,250,250); + font-family: "Segoe UI"; + font-size: 15px; + text-align: center; + padding: 6px; +} + .pageset { display: flex; margin-top: 8px; @@ -219,9 +288,10 @@ h1, h3 { .pageitem { font-size: 17px; + border-radius: 3px; background-color: #444444; - padding: 7px; - margin-right: 6px; + padding: 7px; + margin-right: 6px; } #prevFloat, #nextFloat { @@ -280,7 +350,7 @@ h1, h3 { @media(min-width: 1010px) { .container { - background-color: #292929; + background-color: var(--second-dark-background); } #back { width: 1000px; diff --git a/themes/nox/public/panel.css b/themes/nox/public/panel.css index 4c4a84ed..19f903c3 100644 --- a/themes/nox/public/panel.css +++ b/themes/nox/public/panel.css @@ -25,10 +25,16 @@ .colstack_left .colstack_head:not(:first-child) { margin-top: 8px; } +.colstack_left .colstack_head a { + color: rgb(231, 231, 231); +} .rowmenu { margin-bottom: 2px; font-size: 17px; } +.rowmenu a { + color: rgb(170, 170, 170); +} .colstack_right { background-color: #444444; @@ -37,4 +43,58 @@ padding-right: 24px; padding-bottom: 24px; padding-left: 24px; +} +.colstack_right .colstack_item.the_form { + background-color: #555555; +} +.colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem { + background-color: #555555; +} + +.colstack_grid { + display: grid; + grid-gap: 8px; + grid-template-columns: repeat(3, 1fr); +} +.grid_item { + border-radius: 3px; + color: rgb(190,190,190); + background-color: #555555; + padding: 12px; +} + +.rowlist.bgavatars .rowitem { + background-image: none !important; +} +.rowlist.bgavatars .bgsub { + width: 48px; + height: 48px; +} + +input, select, button, .formbutton, textarea { + background: rgb(107,107,107); + color: rgb(217,217,217); +} +input:focus, select:focus, textarea:focus { + outline: 1px solid rgb(137,137,137); +} +/* ? - The background properties need to be redeclared for the new image or it won't work properly */ +input { + background-image: url(./fa-svg/pencil-alt-light.svg); + background-size: 12px; + background-repeat: no-repeat; + background-position: right 10px bottom 9px; + background-position-x: right 10px; +} +input::placeholder, textarea::placeholder { + color: rgb(167,167,167); + opacity: 1; /* Firefox fix */ +} +button, .formbutton { + /*background: rgb(110,110,210); + color: rgb(250,250,250);*/ +} +#themeSelector select { + background: rgb(90,90,90); + color: rgb(200,200,200); } \ No newline at end of file diff --git a/themes/nox/theme.json b/themes/nox/theme.json index c719f40c..05a7dcd5 100644 --- a/themes/nox/theme.json +++ b/themes/nox/theme.json @@ -14,10 +14,6 @@ } ], "Resources": [ - { - "Name":"EQCSS.js", - "Location":"global" - }, { "Name":"trumbowyg/trumbowyg.min.js", "Location":"global", diff --git a/themes/shadow/public/account.css b/themes/shadow/public/account.css new file mode 100644 index 00000000..7a9c0157 --- /dev/null +++ b/themes/shadow/public/account.css @@ -0,0 +1,51 @@ +#account_dashboard .colstack_right .coldyn_block { + display: flex; +} +#dash_saved { + display: none; +} +#dash_left { + padding: 18px; + padding-right: 0px; + padding-top: 11px; + padding-left: 0px; + width: 260px; + position: relative; +} +#dash_left .rowitem { + margin-top: 0px; +} +#dash_username { + display: flex; +} +#dash_username button { + margin-left: 6px; +} +#dash_left .rowitem img { + width: 100%; + margin-top: 8px; + margin-bottom: 4px; + margin-left: 0px; + margin-right: 12px; +} +#dash_avatar_buttons { + display: flex; +} +#dash_avatar_buttons button { + margin-left: 8px; +} +#dash_right { + width: 100%; + padding: 16px; + padding-top: 3px; + padding-left: 8px; + padding-right: 0px; +} + +.account_soon, .dash_security { + font-size: 13px; + color: rgba(255, 80, 80, 1); +} +.rowmenu .account_soon, .rowmenu .dash_security { + font-size: 11px; +} \ No newline at end of file diff --git a/themes/shadow/public/main.css b/themes/shadow/public/main.css index e93565c3..a5bed5e1 100644 --- a/themes/shadow/public/main.css +++ b/themes/shadow/public/main.css @@ -488,7 +488,7 @@ textarea.large { margin-top: 0px; } -.formitem input { +input { background-color: var(--input-background-color); border: 1px solid var(--input-border-color); color: var(--input-text-color); diff --git a/themes/tempra-conflux/public/account.css b/themes/tempra-conflux/public/account.css new file mode 100644 index 00000000..50e5f1e9 --- /dev/null +++ b/themes/tempra-conflux/public/account.css @@ -0,0 +1,30 @@ +.sidebar, #dash_saved { + display: none; +} +#account_dashboard .colstack_right .coldyn_block { + display: flex; +} +#dash_left .rowitem { + border: 1px solid hsl(0,0%,85%); +} +#dash_username { + display: flex; +} +#dash_avatar_buttons { + display: flex; +} +#dash_right { + width: 100%; + margin-right: 6px; +} +#dash_right .rowitem { + margin-left: 10px; + border: 1px solid hsl(0,0%,85%); +} +#dash_right .rowitem:not(last-child) { + margin-bottom: 8px; +} +.account_soon, .dash_security { + font-size: 14px; + color: maroon; +} \ No newline at end of file diff --git a/themes/tempra-simple/public/account.css b/themes/tempra-simple/public/account.css new file mode 100644 index 00000000..5d1ff949 --- /dev/null +++ b/themes/tempra-simple/public/account.css @@ -0,0 +1,30 @@ +.sidebar, #dash_saved { + display: none; +} + +#account_dashboard .colstack_right .coldyn_block { + display: flex; +} +#dash_left .rowitem { + border: 1px solid hsl(0,0%,85%); +} +#dash_username { + display: flex; +} +#dash_avatar_buttons { + display: flex; +} +#dash_right { + width: 100%; +} +#dash_right .rowitem { + border: 1px solid hsl(0,0%,85%); + margin-left: 8px; +} +#dash_right .rowitem:not(:last-child) { + margin-bottom: 8px; +} +.account_soon, .dash_security { + font-size: 14px; + color: maroon; +} \ No newline at end of file