diff --git a/pkg/config/admin_scopes.go b/pkg/config/admin_scopes.go index c561b57..d71d430 100644 --- a/pkg/config/admin_scopes.go +++ b/pkg/config/admin_scopes.go @@ -23,8 +23,10 @@ const ( // Website administration // - Forum: ability to manage available forums // - Scopes: ability to manage admin groups & scopes + // - Maintenance mode ScopeForumAdmin = "admin.forum.manage" ScopeAdminScopeAdmin = "admin.scope.manage" + ScopeMaintenance = "admin.maintenance" // User account admin // - Impersonate: ability to log in as a user account @@ -35,6 +37,9 @@ const ( ScopeUserPromote = "admin.user.promote" ScopeUserDelete = "admin.user.delete" + // Admins with this scope can not be blocked by users. + ScopeUnblockable = "admin.unblockable" + // Special scope to mark an admin automagically in the Inner Circle ScopeIsInnerCircle = "admin.override.inner-circle" ) @@ -61,6 +66,7 @@ func ListAdminScopes() []string { ScopeUserBan, ScopeUserDelete, ScopeUserPromote, + ScopeUnblockable, ScopeIsInnerCircle, } } diff --git a/pkg/config/variable.go b/pkg/config/variable.go index 1325d99..17bd5ab 100644 --- a/pkg/config/variable.go +++ b/pkg/config/variable.go @@ -11,11 +11,16 @@ import ( "github.com/google/uuid" ) +// Version of the config format - when new fields are added, it will attempt +// to write the settings.toml to disk so new defaults populate. +var currentVersion = 1 + // Current loaded settings.json var Current = DefaultVariable() // Variable configuration attributes (loaded from settings.json). type Variable struct { + Version int BaseURL string AdminEmail string CronAPIKey string @@ -23,6 +28,7 @@ type Variable struct { Redis Redis Database Database BareRTC BareRTC + Maintenance Maintenance UseXForwardedFor bool } @@ -65,15 +71,7 @@ func LoadSettings() { Current = v } else { - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetIndent("", " ") - err := enc.Encode(DefaultVariable()) - if err != nil { - panic(fmt.Sprintf("LoadSettings: couldn't marshal default settings: %s", err)) - } - - ioutil.WriteFile(SettingsPath, buf.Bytes(), 0600) + WriteSettings() log.Warn("NOTICE: Created default settings.json file - review it and configure mail servers and database!") } @@ -82,6 +80,30 @@ func LoadSettings() { log.Error("No database configured in settings.json. Choose SQLite or Postgres and update the DB connector string!") os.Exit(1) } + + // Have we added new config fields? Save the settings.json. + if Current.Version != currentVersion { + log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.") + Current.Version = currentVersion + if err := WriteSettings(); err != nil { + log.Error("Couldn't write your settings.json file: %s", err) + } + } +} + +// WriteSettings will commit the settings.json to disk. +func WriteSettings() error { + log.Error("Note: initial settings.json was written to disk.") + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + err := enc.Encode(Current) + if err != nil { + panic(fmt.Sprintf("WriteSettings: couldn't marshal settings: %s", err)) + } + + return ioutil.WriteFile(SettingsPath, buf.Bytes(), 0600) } // Mail settings. @@ -114,3 +136,11 @@ type BareRTC struct { JWTSecret string URL string } + +// Maintenance mode settings. +type Maintenance struct { + PauseSignup bool + PauseLogin bool + PauseChat bool + PauseInteraction bool +} diff --git a/pkg/controller/account/login.go b/pkg/controller/account/login.go index 216a4de..7f2e2f6 100644 --- a/pkg/controller/account/login.go +++ b/pkg/controller/account/login.go @@ -6,6 +6,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/middleware" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/ratelimit" "code.nonshy.com/nonshy/website/pkg/session" @@ -70,6 +71,11 @@ func Login() http.HandlerFunc { return } + // Maintenance mode check. + if middleware.LoginMaintenance(user, w, r) { + return + } + // OK. Log in the user's session. session.LoginUser(w, r, user) diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index efcc69a..ffda661 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -5,6 +5,7 @@ import ( "net/url" "regexp" + "code.nonshy.com/nonshy/website/pkg/middleware" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/templates" @@ -51,6 +52,11 @@ func Profile() http.HandlerFunc { return } + // Is the site under a Maintenance Mode restriction? + if middleware.MaintenanceMode(currentUser, w, r) { + return + } + // Forcing an external view? (preview of logged-out profile view for visibility=external accounts) // You must be logged-in actually to see this. if r.FormValue("view") == "external" { diff --git a/pkg/controller/account/signup.go b/pkg/controller/account/signup.go index b3d9e4d..56c05fa 100644 --- a/pkg/controller/account/signup.go +++ b/pkg/controller/account/signup.go @@ -10,6 +10,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/mail" + "code.nonshy.com/nonshy/website/pkg/middleware" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/redis" "code.nonshy.com/nonshy/website/pkg/session" @@ -35,6 +36,11 @@ func Signup() http.HandlerFunc { tmpl := templates.Must("account/signup.html") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Maintenance mode? + if middleware.SignupMaintenance(w, r) { + return + } + // Template vars. var vars = map[string]interface{}{ "SignupToken": "", // non-empty if user has clicked verification link diff --git a/pkg/controller/admin/maintenance.go b/pkg/controller/admin/maintenance.go new file mode 100644 index 0000000..c14c880 --- /dev/null +++ b/pkg/controller/admin/maintenance.go @@ -0,0 +1,73 @@ +package admin + +import ( + "net/http" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// Maintenance controller (/admin/maintenance) +func Maintenance() http.HandlerFunc { + tmpl := templates.Must("admin/maintenance.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Query parameters. + var ( + intent = r.FormValue("intent") + ) + + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get your current user: %s", err) + } + + _ = currentUser + + // POST event handlers. + if r.Method == http.MethodPost { + // Collect the form parameters. + var ( + pauseSignup = r.PostFormValue("signup") == "true" + pauseLogin = r.PostFormValue("login") == "true" + pauseChat = r.PostFormValue("chat") == "true" + pauseInteraction = r.PostFormValue("interaction") == "true" + ) + + switch intent { + case "everything", "nothing": + pauseSignup = intent == "everything" + pauseLogin = pauseSignup + pauseChat = pauseSignup + pauseInteraction = pauseSignup + intent = "save" + fallthrough + case "save": + // Update and save the site settings. + config.Current.Maintenance.PauseSignup = pauseSignup + config.Current.Maintenance.PauseLogin = pauseLogin + config.Current.Maintenance.PauseChat = pauseChat + config.Current.Maintenance.PauseInteraction = pauseInteraction + if err := config.WriteSettings(); err != nil { + session.FlashError(w, r, "Couldn't write settings.json: %s", err) + } else { + session.Flash(w, r, "Maintenance settings updated!") + } + default: + session.FlashError(w, r, "Unsupported intent: %s", intent) + } + + templates.Redirect(w, r.URL.Path) + return + } + + var vars = map[string]interface{}{ + "Intent": intent, + "Maint": config.Current.Maintenance, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/block/block.go b/pkg/controller/block/block.go index 3763c41..b83b12c 100644 --- a/pkg/controller/block/block.go +++ b/pkg/controller/block/block.go @@ -108,8 +108,8 @@ func BlockUser() http.HandlerFunc { return } - // Can't block admins. - if user.IsAdmin { + // Can't block admins who have the unblockable scope. + if user.IsAdmin && user.HasAdminScope(config.ScopeUnblockable) { // For curiosity's sake, log a report. fb := &models.Feedback{ Intent: "report", diff --git a/pkg/controller/chat/chat.go b/pkg/controller/chat/chat.go index 15fb3d9..a9aa0f7 100644 --- a/pkg/controller/chat/chat.go +++ b/pkg/controller/chat/chat.go @@ -12,6 +12,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/geoip" "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/middleware" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/photo" "code.nonshy.com/nonshy/website/pkg/session" @@ -67,6 +68,11 @@ func Landing() http.HandlerFunc { isShy = currentUser.IsShy() ) if intent == "join" { + // Maintenance mode? + if middleware.ChatMaintenance(currentUser, w, r) { + return + } + // If we are shy, block chat for now. if isShy { session.FlashError(w, r, diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go index db3fc35..5e68444 100644 --- a/pkg/middleware/authentication.go +++ b/pkg/middleware/authentication.go @@ -40,6 +40,11 @@ func LoginRequired(handler http.Handler) http.Handler { return } + // Is the site under a Maintenance Mode restriction? + if MaintenanceMode(user, w, r) { + return + } + // Ping LastLoginAt for long lived sessions, but not if impersonated. if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) { user.LastLoginAt = time.Now() @@ -123,6 +128,11 @@ func CertRequired(handler http.Handler) http.Handler { return } + // Is the site under a Maintenance Mode restriction? + if MaintenanceMode(currentUser, w, r) { + return + } + // User must be certified. if !currentUser.Certified || currentUser.ProfilePhoto.ID == 0 { log.Error("CertRequired: user is not certified") diff --git a/pkg/middleware/maintenance.go b/pkg/middleware/maintenance.go new file mode 100644 index 0000000..ae575a5 --- /dev/null +++ b/pkg/middleware/maintenance.go @@ -0,0 +1,71 @@ +package middleware + +import ( + "net/http" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +var tmplMaint = templates.Must("errors/maintenance.html") + +// MaintenanceMode check at the middleware level, e.g. to block +// LoginRequired and CertificationRequired if site-wide interaction +// is currently on hold. Returns true if handled. +func MaintenanceMode(currentUser *models.User, w http.ResponseWriter, r *http.Request) bool { + // Is the site under a Maintenance Mode restriction? + if config.Current.Maintenance.PauseInteraction && !currentUser.IsAdmin { + var vars = map[string]interface{}{ + "Reason": "interaction", + } + if err := tmplMaint.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return true + } + + return false +} + +// SignupMaintenance may handle maintenance mode requests for signup gating. +func SignupMaintenance(w http.ResponseWriter, r *http.Request) bool { + if config.Current.Maintenance.PauseSignup { + var vars = map[string]interface{}{ + "Reason": "signup", + } + if err := tmplMaint.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return true + } + return false +} + +// LoginMaintenance may handle maintenance mode requests for login gating. +func LoginMaintenance(currentUser *models.User, w http.ResponseWriter, r *http.Request) bool { + if config.Current.Maintenance.PauseLogin && !currentUser.IsAdmin { + var vars = map[string]interface{}{ + "Reason": "login", + } + if err := tmplMaint.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return true + } + return false +} + +// ChatMaintenance may handle maintenance mode requests for chat room gating. +func ChatMaintenance(currentUser *models.User, w http.ResponseWriter, r *http.Request) bool { + if config.Current.Maintenance.PauseChat && !currentUser.IsAdmin { + var vars = map[string]interface{}{ + "Reason": "chat", + } + if err := tmplMaint.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return true + } + return false +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 4fe56f5..50b770a 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -87,6 +87,7 @@ func New() http.Handler { mux.Handle("/admin/photo/certification", middleware.AdminRequired("", photo.AdminCertification())) mux.Handle("/admin/feedback", middleware.AdminRequired("", admin.Feedback())) mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions())) + mux.Handle("/admin/maintenance", middleware.AdminRequired(config.ScopeMaintenance, admin.Maintenance())) mux.Handle("/forum/admin", middleware.AdminRequired(config.ScopeForumAdmin, forum.Manage())) mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit())) mux.Handle("/inner-circle/remove", middleware.AdminRequired(config.ScopeCircleModerator, account.RemoveCircle())) diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index 822929e..78f7e6e 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -148,6 +148,12 @@ Admin Permissions Management +
  • + + + Maintenance Mode + +
  • diff --git a/web/templates/admin/maintenance.html b/web/templates/admin/maintenance.html new file mode 100644 index 0000000..20eaa5c --- /dev/null +++ b/web/templates/admin/maintenance.html @@ -0,0 +1,108 @@ +{{define "title"}}Admin - Maintenance Mode{{end}} +{{define "content"}} +{{$Root := .}} +
    +
    +
    +
    +

    + Maintenance Mode +

    +
    +
    +
    + +
    + {{InputCSRF}} +
    + +

    + This page allows you to set various Maintenance Mode flags that can pause or + disable website features if needed. + In an emergency, click on the Lock Down Everything button that will enable ALL maintenance + mode flags and basically disable the whole website for everybody except admin user accounts. +

    + +

    + + + +

    + +
    + + + +
    + +

    + New account signups are paused and a maintenance page is shown in its place. +

    +
    + +
    + +

    + The login page is disabled (except for admin user login). Already logged-in + users can remain logged in. +

    +
    + +
    + +

    + No new entries into the chat room are allowed. +

    +
    + +
    + +

    + Every site feature becomes basically 'admin required' and users are given an error + page where their only option is to log out or come back later. +

    +
    + +
    + +
    + +
    + +
    +{{end}} diff --git a/web/templates/errors/maintenance.html b/web/templates/errors/maintenance.html new file mode 100644 index 0000000..88ab2d7 --- /dev/null +++ b/web/templates/errors/maintenance.html @@ -0,0 +1,61 @@ +{{define "title"}}Not Available{{end}} +{{define "content"}} +
    +
    +
    +
    +

    Not Available

    +

    The website is currently unavailable

    +
    +
    +
    + +
    +

    {{PrettyTitle}} is currently not available

    +

    + We regret to inform you that {{PrettyTitle}} is currently not available. +

    + +

    + The website is currently in "maintenance mode" and the feature you requested + is currently on pause. Please check back again later. +

    + + + {{if .Reason}} +

    More Information

    + {{end}} + + {{if eq .Reason "interaction"}} +

    + All user interaction on the website is currently on pause. You are currently + logged in to an account (username: {{.CurrentUser.Username}}) and you may + remain logged-in if you want, but all actions that require a logged-in account + are currently disabled. +

    + +

    + If you'd like, you may log out or just come back later + and see if the maintenance mode of the website has been lifted. +

    + {{else if eq .Reason "signup"}} +

    + All new account signups are currently on pause. We are not accepting any new + members at this time. Please check back again later. +

    + {{else if eq .Reason "login"}} +

    + All new account logins are currently on pause. Please try again later. +

    + {{else if eq .Reason "chat"}} +

    + The chat room is currently offline for maintenance. Please try again some other time. +

    + {{else}} +

    + No further information is available at this time. +

    + {{end}} +
    +
    +{{end}}