Maintenance Mode + Blockable Admin Scope

This commit is contained in:
Noah Petherbridge 2023-08-16 22:09:04 -07:00
parent 5f5fe37350
commit bf71ed421c
14 changed files with 401 additions and 11 deletions

View File

@ -23,8 +23,10 @@ const (
// Website administration // Website administration
// - Forum: ability to manage available forums // - Forum: ability to manage available forums
// - Scopes: ability to manage admin groups & scopes // - Scopes: ability to manage admin groups & scopes
// - Maintenance mode
ScopeForumAdmin = "admin.forum.manage" ScopeForumAdmin = "admin.forum.manage"
ScopeAdminScopeAdmin = "admin.scope.manage" ScopeAdminScopeAdmin = "admin.scope.manage"
ScopeMaintenance = "admin.maintenance"
// User account admin // User account admin
// - Impersonate: ability to log in as a user account // - Impersonate: ability to log in as a user account
@ -35,6 +37,9 @@ const (
ScopeUserPromote = "admin.user.promote" ScopeUserPromote = "admin.user.promote"
ScopeUserDelete = "admin.user.delete" 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 // Special scope to mark an admin automagically in the Inner Circle
ScopeIsInnerCircle = "admin.override.inner-circle" ScopeIsInnerCircle = "admin.override.inner-circle"
) )
@ -61,6 +66,7 @@ func ListAdminScopes() []string {
ScopeUserBan, ScopeUserBan,
ScopeUserDelete, ScopeUserDelete,
ScopeUserPromote, ScopeUserPromote,
ScopeUnblockable,
ScopeIsInnerCircle, ScopeIsInnerCircle,
} }
} }

View File

@ -11,11 +11,16 @@ import (
"github.com/google/uuid" "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 // Current loaded settings.json
var Current = DefaultVariable() var Current = DefaultVariable()
// Variable configuration attributes (loaded from settings.json). // Variable configuration attributes (loaded from settings.json).
type Variable struct { type Variable struct {
Version int
BaseURL string BaseURL string
AdminEmail string AdminEmail string
CronAPIKey string CronAPIKey string
@ -23,6 +28,7 @@ type Variable struct {
Redis Redis Redis Redis
Database Database Database Database
BareRTC BareRTC BareRTC BareRTC
Maintenance Maintenance
UseXForwardedFor bool UseXForwardedFor bool
} }
@ -65,15 +71,7 @@ func LoadSettings() {
Current = v Current = v
} else { } else {
var buf bytes.Buffer WriteSettings()
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)
log.Warn("NOTICE: Created default settings.json file - review it and configure mail servers and database!") 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!") log.Error("No database configured in settings.json. Choose SQLite or Postgres and update the DB connector string!")
os.Exit(1) 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. // Mail settings.
@ -114,3 +136,11 @@ type BareRTC struct {
JWTSecret string JWTSecret string
URL string URL string
} }
// Maintenance mode settings.
type Maintenance struct {
PauseSignup bool
PauseLogin bool
PauseChat bool
PauseInteraction bool
}

View File

@ -6,6 +6,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "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/models"
"code.nonshy.com/nonshy/website/pkg/ratelimit" "code.nonshy.com/nonshy/website/pkg/ratelimit"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
@ -70,6 +71,11 @@ func Login() http.HandlerFunc {
return return
} }
// Maintenance mode check.
if middleware.LoginMaintenance(user, w, r) {
return
}
// OK. Log in the user's session. // OK. Log in the user's session.
session.LoginUser(w, r, user) session.LoginUser(w, r, user)

View File

@ -5,6 +5,7 @@ import (
"net/url" "net/url"
"regexp" "regexp"
"code.nonshy.com/nonshy/website/pkg/middleware"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
@ -51,6 +52,11 @@ func Profile() http.HandlerFunc {
return 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) // Forcing an external view? (preview of logged-out profile view for visibility=external accounts)
// You must be logged-in actually to see this. // You must be logged-in actually to see this.
if r.FormValue("view") == "external" { if r.FormValue("view") == "external" {

View File

@ -10,6 +10,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/mail" "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/models"
"code.nonshy.com/nonshy/website/pkg/redis" "code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
@ -35,6 +36,11 @@ func Signup() http.HandlerFunc {
tmpl := templates.Must("account/signup.html") tmpl := templates.Must("account/signup.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Maintenance mode?
if middleware.SignupMaintenance(w, r) {
return
}
// Template vars. // Template vars.
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"SignupToken": "", // non-empty if user has clicked verification link "SignupToken": "", // non-empty if user has clicked verification link

View File

@ -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
}
})
}

View File

@ -108,8 +108,8 @@ func BlockUser() http.HandlerFunc {
return return
} }
// Can't block admins. // Can't block admins who have the unblockable scope.
if user.IsAdmin { if user.IsAdmin && user.HasAdminScope(config.ScopeUnblockable) {
// For curiosity's sake, log a report. // For curiosity's sake, log a report.
fb := &models.Feedback{ fb := &models.Feedback{
Intent: "report", Intent: "report",

View File

@ -12,6 +12,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/geoip" "code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log" "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/models"
"code.nonshy.com/nonshy/website/pkg/photo" "code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
@ -67,6 +68,11 @@ func Landing() http.HandlerFunc {
isShy = currentUser.IsShy() isShy = currentUser.IsShy()
) )
if intent == "join" { if intent == "join" {
// Maintenance mode?
if middleware.ChatMaintenance(currentUser, w, r) {
return
}
// If we are shy, block chat for now. // If we are shy, block chat for now.
if isShy { if isShy {
session.FlashError(w, r, session.FlashError(w, r,

View File

@ -40,6 +40,11 @@ func LoginRequired(handler http.Handler) http.Handler {
return 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. // Ping LastLoginAt for long lived sessions, but not if impersonated.
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) { if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
user.LastLoginAt = time.Now() user.LastLoginAt = time.Now()
@ -123,6 +128,11 @@ func CertRequired(handler http.Handler) http.Handler {
return return
} }
// Is the site under a Maintenance Mode restriction?
if MaintenanceMode(currentUser, w, r) {
return
}
// User must be certified. // User must be certified.
if !currentUser.Certified || currentUser.ProfilePhoto.ID == 0 { if !currentUser.Certified || currentUser.ProfilePhoto.ID == 0 {
log.Error("CertRequired: user is not certified") log.Error("CertRequired: user is not certified")

View File

@ -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
}

View File

@ -87,6 +87,7 @@ func New() http.Handler {
mux.Handle("/admin/photo/certification", middleware.AdminRequired("", photo.AdminCertification())) mux.Handle("/admin/photo/certification", middleware.AdminRequired("", photo.AdminCertification()))
mux.Handle("/admin/feedback", middleware.AdminRequired("", admin.Feedback())) mux.Handle("/admin/feedback", middleware.AdminRequired("", admin.Feedback()))
mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions())) 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", middleware.AdminRequired(config.ScopeForumAdmin, forum.Manage()))
mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit())) mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit()))
mux.Handle("/inner-circle/remove", middleware.AdminRequired(config.ScopeCircleModerator, account.RemoveCircle())) mux.Handle("/inner-circle/remove", middleware.AdminRequired(config.ScopeCircleModerator, account.RemoveCircle()))

View File

@ -148,6 +148,12 @@
Admin Permissions Management Admin Permissions Management
</a> </a>
</li> </li>
<li>
<a href="/admin/maintenance">
<i class="fa fa-wrench mr-2"></i>
Maintenance Mode
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -0,0 +1,108 @@
{{define "title"}}Admin - Maintenance Mode{{end}}
{{define "content"}}
{{$Root := .}}
<div class="container">
<section class="hero is-danger is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Maintenance Mode
</h1>
</div>
</div>
</section>
<form method="POST" action="{{.Request.URL.Path}}">
{{InputCSRF}}
<div class="p-4">
<p class="block">
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.
</p>
<p class="block">
<button type="submit" class="button is-danger mr-2"
name="intent" value="everything"
onclick="return confirm('Do you want to lock down EVERYTHING?')">
<i class="fa fa-exclamation-triangle mr-2"></i> Lock Down Everything
</button>
<button type="submit" class="button is-success"
name="intent" value="nothing"
onclick="return confirm('Do you want to RESTORE ALL site functionality?')">
<i class="fa fa-exclamation-triangle mr-2"></i> Restore Everything
</button>
</p>
<hr>
<label class="label">Maintenance Mode Settings</label>
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="signup"
value="true"
{{if .Maint.PauseSignup}}checked{{end}}>
Pause new account signups
</label>
<p class="help">
New account signups are paused and a maintenance page is shown in its place.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="login"
value="true"
{{if .Maint.PauseLogin}}checked{{end}}>
Pause new logins
</label>
<p class="help">
The login page is disabled (except for admin user login). Already logged-in
users can remain logged in.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="chat"
value="true"
{{if .Maint.PauseChat}}checked{{end}}>
Pause chat room entry
</label>
<p class="help">
No new entries into the chat room are allowed.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="interaction"
value="true"
{{if .Maint.PauseInteraction}}checked{{end}}>
Pause <strong>all</strong> interactions
</label>
<p class="help">
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.
</p>
</div>
<div class="block">
<button type="submit" class="button is-primary"
name="intent" value="save">
Save Settings
</button>
</div>
</div>
</div>
{{end}}

View File

@ -0,0 +1,61 @@
{{define "title"}}Not Available{{end}}
{{define "content"}}
<div class="container">
<section class="hero block is-warning is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">Not Available</h1>
<h2 class="subtitle">The website is currently unavailable</h2>
</div>
</div>
</section>
<div class="block content p-4 mb-0">
<h1>{{PrettyTitle}} is currently not available</h1>
<p>
We regret to inform you that {{PrettyTitle}} is currently not available.
</p>
<p>
The website is currently in "maintenance mode" and the feature you requested
is currently on pause. Please check back again later.
</p>
<!-- More information? -->
{{if .Reason}}
<h2>More Information</h2>
{{end}}
{{if eq .Reason "interaction"}}
<p>
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.
</p>
<p>
If you'd like, you may <a href="/logout">log out</a> or just come back later
and see if the maintenance mode of the website has been lifted.
</p>
{{else if eq .Reason "signup"}}
<p>
All new account signups are currently on pause. We are not accepting any new
members at this time. Please check back again later.
</p>
{{else if eq .Reason "login"}}
<p>
All new account logins are currently on pause. Please try again later.
</p>
{{else if eq .Reason "chat"}}
<p>
The chat room is currently offline for maintenance. Please try again some other time.
</p>
{{else}}
<p>
No further information is available at this time.
</p>
{{end}}
</div>
</div>
{{end}}