From 77f7b4a522cf2dd522a64a86b32dee245f1cfd6f Mon Sep 17 00:00:00 2001 From: Rob Loranger Date: Wed, 28 Aug 2019 12:37:45 -0700 Subject: [PATCH 01/12] Add account suspension features This renders all requests for that user's posts, collections and related ActivityPub endpoints with 404 responses. While suspended, users may not create or edit posts or collections. User status is listed in the admin user page Admin view of user details shows status and now has a button to activate or suspend a user. --- account.go | 29 ++++++++------- activitypub.go | 40 ++++++++++++++++++++ admin.go | 34 +++++++++++++++-- collections.go | 34 ++++++++++++++++- database.go | 51 +++++++++++++++++++++----- errors.go | 5 ++- feed.go | 14 ++++++- invites.go | 13 +++++-- migrations/migrations.go | 2 + migrations/v3.go | 29 +++++++++++++++ pad.go | 22 ++++++++--- posts.go | 73 +++++++++++++++++++++++++++++++++++-- read.go | 14 ++++--- routes.go | 8 ++-- schema.sql | 1 + sqlite.sql | 3 +- templates/edit-meta.tmpl | 4 ++ templates/pad.tmpl | 6 ++- templates/user/admin/users.tmpl | 5 +++ templates/user/admin/view-user.tmpl | 32 ++++++++++++++++ templates/user/settings.tmpl | 6 +++ users.go | 1 + webfinger.go | 11 +++++- 23 files changed, 381 insertions(+), 56 deletions(-) create mode 100644 migrations/v3.go diff --git a/account.go b/account.go index 1cf259b..49700b4 100644 --- a/account.go +++ b/account.go @@ -13,6 +13,13 @@ package writefreely import ( "encoding/json" "fmt" + "html/template" + "net/http" + "regexp" + "strings" + "sync" + "time" + "github.com/gorilla/mux" "github.com/gorilla/sessions" "github.com/guregu/null/zero" @@ -22,12 +29,6 @@ import ( "github.com/writeas/web-core/log" "github.com/writeas/writefreely/author" "github.com/writeas/writefreely/page" - "html/template" - "net/http" - "regexp" - "strings" - "sync" - "time" ) type ( @@ -1011,14 +1012,16 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err obj := struct { *UserPage - Email string - HasPass bool - IsLogOut bool + Email string + HasPass bool + IsLogOut bool + Suspended bool }{ - UserPage: NewUserPage(app, r, u, "Account Settings", flashes), - Email: fullUser.EmailClear(app.keys), - HasPass: passIsSet, - IsLogOut: r.FormValue("logout") == "1", + UserPage: NewUserPage(app, r, u, "Account Settings", flashes), + Email: fullUser.EmailClear(app.keys), + HasPass: passIsSet, + IsLogOut: r.FormValue("logout") == "1", + Suspended: fullUser.Suspended, } showUserPage(w, "settings", obj) diff --git a/activitypub.go b/activitypub.go index 997609d..c10838d 100644 --- a/activitypub.go +++ b/activitypub.go @@ -80,6 +80,14 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re if err != nil { return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("fetch collection inbox: get owner: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host p := c.PersonObject() @@ -105,6 +113,14 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques if err != nil { return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("fetch collection inbox: get owner: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host if app.cfg.App.SingleUser { @@ -158,6 +174,14 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req if err != nil { return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("fetch collection inbox: get owner: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host accountRoot := c.FederatedAccount() @@ -204,6 +228,14 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req if err != nil { return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("fetch collection inbox: get owner: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host accountRoot := c.FederatedAccount() @@ -238,6 +270,14 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request // TODO: return Reject? return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("fetch collection inbox: get owner: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host if debugging { diff --git a/admin.go b/admin.go index fe19ad5..dc8580d 100644 --- a/admin.go +++ b/admin.go @@ -13,16 +13,17 @@ package writefreely import ( "database/sql" "fmt" + "net/http" + "runtime" + "strconv" + "time" + "github.com/gogits/gogs/pkg/tool" "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" - "net/http" - "runtime" - "strconv" - "time" ) var ( @@ -229,6 +230,31 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque return nil } +func handleAdminToggleUserSuspended(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + username := vars["username"] + if username == "" { + return impart.HTTPError{http.StatusFound, "/admin/users"} + } + + userToToggle, err := app.db.GetUserForAuth(username) + if err != nil { + log.Error("failed to get user: %v", err) + return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)} + } + if userToToggle.Suspended { + err = app.db.SetUserSuspended(userToToggle.ID, false) + } else { + err = app.db.SetUserSuspended(userToToggle.ID, true) + } + if err != nil { + log.Error("toggle user suspended: %v", err) + return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user suspended: %v")} + } + // TODO: invalidate sessions + return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)} +} + func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p := struct { *UserPage diff --git a/collections.go b/collections.go index aee74f7..95dd808 100644 --- a/collections.go +++ b/collections.go @@ -379,6 +379,7 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { } var userID int64 + var err error if reqJSON && !c.Web { accessToken = r.Header.Get("Authorization") if accessToken == "" { @@ -395,6 +396,14 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { } userID = u.ID } + suspended, err := app.db.IsUserSuspended(userID) + if err != nil { + log.Error("new collection: get user: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } if !author.IsValidUsername(app.cfg, c.Alias) { return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."} @@ -724,6 +733,15 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("view collection: get owner: %v", err) + return ErrInternalGeneral + } + + if suspended { + return ErrCollectionNotFound + } // Serve ActivityStreams data now, if requested if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { ac := c.PersonObject() @@ -824,6 +842,10 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e return err } + if u.Suspended { + return ErrCollectionNotFound + } + page := getCollectionPage(vars) c, err := processCollectionPermissions(app, cr, u, w, r) @@ -916,7 +938,6 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error if reqJSON && !isWeb { // Ensure an access token was given accessToken := r.Header.Get("Authorization") - u = &User{} u.ID = app.db.GetUserID(accessToken) if u.ID == -1 { return ErrBadAccessToken @@ -928,6 +949,16 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error } } + suspended, err := app.db.IsUserSuspended(u.ID) + if err != nil { + log.Error("existing collection: get user suspended status: %v", err) + return ErrInternalGeneral + } + + if suspended { + return ErrUserSuspended + } + if r.Method == "DELETE" { err := app.db.DeleteCollection(collAlias, u.ID) if err != nil { @@ -940,7 +971,6 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error } c := SubmittedCollection{OwnerID: uint64(u.ID)} - var err error if reqJSON { // Decode JSON request diff --git a/database.go b/database.go index 34c5234..150a74f 100644 --- a/database.go +++ b/database.go @@ -296,7 +296,7 @@ func (db *datastore) CreateCollection(cfg *config.Config, alias, title string, u func (db *datastore) GetUserByID(id int64) (*User, error) { u := &User{ID: id} - err := db.QueryRow("SELECT username, password, email, created FROM users WHERE id = ?", id).Scan(&u.Username, &u.HashedPass, &u.Email, &u.Created) + err := db.QueryRow("SELECT username, password, email, created, suspended FROM users WHERE id = ?", id).Scan(&u.Username, &u.HashedPass, &u.Email, &u.Created, &u.Suspended) switch { case err == sql.ErrNoRows: return nil, ErrUserNotFound @@ -308,6 +308,23 @@ func (db *datastore) GetUserByID(id int64) (*User, error) { return u, nil } +// IsUserSuspended returns true if the user account associated with id is +// currently suspended. +func (db *datastore) IsUserSuspended(id int64) (bool, error) { + u := &User{ID: id} + + err := db.QueryRow("SELECT suspended FROM users WHERE id = ?", id).Scan(&u.Suspended) + switch { + case err == sql.ErrNoRows: + return false, ErrUserNotFound + case err != nil: + log.Error("Couldn't SELECT user password: %v", err) + return false, err + } + + return u.Suspended, nil +} + // DoesUserNeedAuth returns true if the user hasn't provided any methods for // authenticating with the account, such a passphrase or email address. // Any errors are reported to admin and silently quashed, returning false as the @@ -347,7 +364,7 @@ func (db *datastore) IsUserPassSet(id int64) (bool, error) { func (db *datastore) GetUserForAuth(username string) (*User, error) { u := &User{Username: username} - err := db.QueryRow("SELECT id, password, email, created FROM users WHERE username = ?", username).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created) + err := db.QueryRow("SELECT id, password, email, created, suspended FROM users WHERE username = ?", username).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created, &u.Suspended) switch { case err == sql.ErrNoRows: // Check if they've entered the wrong, unnormalized username @@ -370,7 +387,7 @@ func (db *datastore) GetUserForAuth(username string) (*User, error) { func (db *datastore) GetUserForAuthByID(userID int64) (*User, error) { u := &User{ID: userID} - err := db.QueryRow("SELECT id, password, email, created FROM users WHERE id = ?", u.ID).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created) + err := db.QueryRow("SELECT id, password, email, created, suspended FROM users WHERE id = ?", u.ID).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created, &u.Suspended) switch { case err == sql.ErrNoRows: return nil, ErrUserNotFound @@ -1624,7 +1641,11 @@ func (db *datastore) GetMeStats(u *User) userMeStats { } func (db *datastore) GetTotalCollections() (collCount int64, err error) { - err = db.QueryRow(`SELECT COUNT(*) FROM collections`).Scan(&collCount) + err = db.QueryRow(` + SELECT COUNT(*) + FROM collections c + LEFT JOIN users u ON u.id = c.owner_id + WHERE u.suspended = 0`).Scan(&collCount) if err != nil { log.Error("Unable to fetch collections count: %v", err) } @@ -1632,7 +1653,11 @@ func (db *datastore) GetTotalCollections() (collCount int64, err error) { } func (db *datastore) GetTotalPosts() (postCount int64, err error) { - err = db.QueryRow(`SELECT COUNT(*) FROM posts`).Scan(&postCount) + err = db.QueryRow(` + SELECT COUNT(*) + FROM posts p + LEFT JOIN users u ON u.id = p.owner_id + WHERE u.Suspended = 0`).Scan(&postCount) if err != nil { log.Error("Unable to fetch posts count: %v", err) } @@ -2341,17 +2366,17 @@ func (db *datastore) GetAllUsers(page uint) (*[]User, error) { limitStr = fmt.Sprintf("%d, %d", (page-1)*adminUsersPerPage, adminUsersPerPage) } - rows, err := db.Query("SELECT id, username, created FROM users ORDER BY created DESC LIMIT " + limitStr) + rows, err := db.Query("SELECT id, username, created, suspended FROM users ORDER BY created DESC LIMIT " + limitStr) if err != nil { - log.Error("Failed selecting from posts: %v", err) - return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user posts."} + log.Error("Failed selecting from users: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve all users."} } defer rows.Close() users := []User{} for rows.Next() { u := User{} - err = rows.Scan(&u.ID, &u.Username, &u.Created) + err = rows.Scan(&u.ID, &u.Username, &u.Created, &u.Suspended) if err != nil { log.Error("Failed scanning GetAllUsers() row: %v", err) break @@ -2388,6 +2413,14 @@ func (db *datastore) GetUserLastPostTime(id int64) (*time.Time, error) { return &t, nil } +func (db *datastore) SetUserSuspended(id int64, suspend bool) error { + _, err := db.Exec("UPDATE users SET suspended = ? WHERE id = ?", suspend, id) + if err != nil { + return fmt.Errorf("failed to update user suspended status: %v", err) + } + return nil +} + func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) { var t time.Time err := db.QueryRow("SELECT created FROM posts WHERE collection_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t) diff --git a/errors.go b/errors.go index 0092b7f..fa7304f 100644 --- a/errors.go +++ b/errors.go @@ -11,8 +11,9 @@ package writefreely import ( - "github.com/writeas/impart" "net/http" + + "github.com/writeas/impart" ) // Commonly returned HTTP errors @@ -46,6 +47,8 @@ var ( ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} + + ErrUserSuspended = impart.HTTPError{http.StatusForbidden, "Account is suspended, contact the administrator."} ) // Post operation errors diff --git a/feed.go b/feed.go index dd82c33..353b1b9 100644 --- a/feed.go +++ b/feed.go @@ -12,12 +12,13 @@ package writefreely import ( "fmt" + "net/http" + "time" + . "github.com/gorilla/feeds" "github.com/gorilla/mux" stripmd "github.com/writeas/go-strip-markdown" "github.com/writeas/web-core/log" - "net/http" - "time" ) func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error { @@ -34,6 +35,15 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error { if err != nil { return nil } + + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("view feed: get user: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host if c.IsPrivate() || c.IsProtected() { diff --git a/invites.go b/invites.go index 561255f..93b82b4 100644 --- a/invites.go +++ b/invites.go @@ -12,15 +12,16 @@ package writefreely import ( "database/sql" + "html/template" + "net/http" + "strconv" + "time" + "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/nerds/store" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/page" - "html/template" - "net/http" - "strconv" - "time" ) type Invite struct { @@ -77,6 +78,10 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re muVal := r.FormValue("uses") expVal := r.FormValue("expires") + if u.Suspended { + return ErrUserSuspended + } + var err error var maxUses int if muVal != "0" { diff --git a/migrations/migrations.go b/migrations/migrations.go index 70e4b7b..de3f487 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -13,6 +13,7 @@ package migrations import ( "database/sql" + "github.com/writeas/web-core/log" ) @@ -57,6 +58,7 @@ func (m *migration) Migrate(db *datastore) error { var migrations = []Migration{ New("support user invites", supportUserInvites), // -> V1 (v0.8.0) New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0) + New("support users suspension", supportUserSuspension), // V2 -> V3 () } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v3.go b/migrations/v3.go new file mode 100644 index 0000000..c7c00a9 --- /dev/null +++ b/migrations/v3.go @@ -0,0 +1,29 @@ +/* + * Copyright © 2019 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportUserSuspension(db *datastore) error { + t, err := db.Begin() + + _, err = t.Exec(`ALTER TABLE users ADD COLUMN suspended ` + db.typeBool() + ` DEFAULT '0' NOT NULL`) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/pad.go b/pad.go index 1545b4f..8a54b76 100644 --- a/pad.go +++ b/pad.go @@ -11,12 +11,13 @@ package writefreely import ( + "net/http" + "strings" + "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/page" - "net/http" - "strings" ) func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error { @@ -34,9 +35,10 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error { } appData := &struct { page.StaticPage - Post *RawPost - User *User - Blogs *[]Collection + Post *RawPost + User *User + Blogs *[]Collection + Suspended bool Editing bool // True if we're modifying an existing post EditCollection *Collection // Collection of the post we're editing, if any @@ -51,6 +53,10 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error { if err != nil { log.Error("Unable to get user's blogs for Pad: %v", err) } + appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID) + if err != nil { + log.Error("Unable to get users suspension status for Pad: %v", err) + } } padTmpl := app.cfg.App.Editor @@ -121,12 +127,18 @@ func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error { EditCollection *Collection // Collection of the post we're editing, if any Flashes []string NeedsToken bool + Suspended bool }{ StaticPage: pageForReq(app, r), Post: &RawPost{Font: "norm"}, User: getUserSession(app, r), } var err error + appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID) + if err != nil { + log.Error("view meta: get user suspended status: %v", err) + return ErrInternalGeneral + } if action == "" && slug == "" { return ErrPostNotFound diff --git a/posts.go b/posts.go index 2f3606f..2d64808 100644 --- a/posts.go +++ b/posts.go @@ -380,6 +380,16 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { } } + suspended, err := app.db.IsUserSuspended(ownerID.Int64) + if err != nil { + log.Error("view post: get collection owner: %v", err) + return ErrInternalGeneral + } + + if suspended { + return ErrPostNotFound + } + // Check if post has been unpublished if content == "" { gone = true @@ -496,6 +506,15 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error { } else { userID = app.db.GetUserID(accessToken) } + suspended, err := app.db.IsUserSuspended(userID) + if err != nil { + log.Error("new post: get user: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } + if userID == -1 { return ErrNotLoggedIn } @@ -508,7 +527,7 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error { var p *SubmittedPost if reqJSON { decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&p) + err = decoder.Decode(&p) if err != nil { log.Error("Couldn't parse new post JSON request: %v\n", err) return ErrBadJSON @@ -554,7 +573,6 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error { var newPost *PublicPost = &PublicPost{} var coll *Collection - var err error if accessToken != "" { newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host) } else { @@ -662,6 +680,15 @@ func existingPost(app *App, w http.ResponseWriter, r *http.Request) error { } } + suspended, err := app.db.IsUserSuspended(userID) + if err != nil { + log.Error("existing post: get user: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } + // Modify post struct p.ID = postID @@ -856,11 +883,20 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error { ownerID = u.ID } + suspended, err := app.db.IsUserSuspended(ownerID) + if err != nil { + log.Error("add post: get user: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } + // Parse claimed posts in format: // [{"id": "...", "token": "..."}] var claims *[]ClaimPostRequest decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&claims) + err = decoder.Decode(&claims) if err != nil { return ErrBadJSONArray } @@ -950,13 +986,22 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error { userID = u.ID } + suspended, err := app.db.IsUserSuspended(userID) + if err != nil { + log.Error("pin post: get user: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } + // Parse request var posts []struct { ID string `json:"id"` Position int64 `json:"position"` } decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&posts) + err = decoder.Decode(&posts) if err != nil { return ErrBadJSONArray } @@ -992,6 +1037,7 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error { func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error { var collID int64 + var ownerID int64 var coll *Collection var err error vars := mux.Vars(r) @@ -1007,12 +1053,22 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error { return err } collID = coll.ID + ownerID = coll.OwnerID } p, err := app.db.GetPost(vars["post"], collID) if err != nil { return err } + suspended, err := app.db.IsUserSuspended(ownerID) + if err != nil { + log.Error("fetch post: get owner: %v", err) + return ErrInternalGeneral + } + + if suspended { + return ErrPostNotFound + } p.extractData() @@ -1270,6 +1326,15 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error } c.hostName = app.cfg.App.Host + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("view collection post: get owner: %v", err) + return ErrInternalGeneral + } + + if suspended { + return ErrPostNotFound + } // Check collection permissions if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) { return ErrPostNotFound diff --git a/read.go b/read.go index 3bc91c7..e7d1e55 100644 --- a/read.go +++ b/read.go @@ -13,6 +13,12 @@ package writefreely import ( "database/sql" "fmt" + "html/template" + "math" + "net/http" + "strconv" + "time" + . "github.com/gorilla/feeds" "github.com/gorilla/mux" stripmd "github.com/writeas/go-strip-markdown" @@ -20,11 +26,6 @@ import ( "github.com/writeas/web-core/log" "github.com/writeas/web-core/memo" "github.com/writeas/writefreely/page" - "html/template" - "math" - "net/http" - "strconv" - "time" ) const ( @@ -62,7 +63,8 @@ func (app *App) FetchPublicPosts() (interface{}, error) { rows, err := app.db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated FROM collections c LEFT JOIN posts p ON p.collection_id = c.id - WHERE c.privacy = 1 AND (p.created >= ` + app.db.dateSub(3, "month") + ` AND p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) + LEFT JOIN users u ON u.id = p.owner_id + WHERE c.privacy = 1 AND (p.created >= ` + app.db.dateSub(3, "month") + ` AND p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.suspended = 0 ORDER BY p.created DESC`) if err != nil { log.Error("Failed selecting from posts: %v", err) diff --git a/routes.go b/routes.go index 724c532..e7014cd 100644 --- a/routes.go +++ b/routes.go @@ -11,13 +11,14 @@ package writefreely import ( + "net/http" + "path/filepath" + "strings" + "github.com/gorilla/mux" "github.com/writeas/go-webfinger" "github.com/writeas/web-core/log" "github.com/writefreely/go-nodeinfo" - "net/http" - "path/filepath" - "strings" ) // InitStaticRoutes adds routes for serving static files. @@ -143,6 +144,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET") write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET") + write.HandleFunc("/admin/user/{username}", handler.Admin(handleAdminToggleUserSuspended)).Methods("POST") write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET") write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET") write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST") diff --git a/schema.sql b/schema.sql index b3fae97..3a79736 100644 --- a/schema.sql +++ b/schema.sql @@ -225,6 +225,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `password` char(60) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, `email` varbinary(255) DEFAULT NULL, `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `suspended` tinyint(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1; diff --git a/sqlite.sql b/sqlite.sql index 90989ed..920ffed 100644 --- a/sqlite.sql +++ b/sqlite.sql @@ -214,7 +214,8 @@ CREATE TABLE IF NOT EXISTS `users` ( username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, email TEXT DEFAULT NULL, - created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + suspended INTEGER NOT NULL DEFAULT 0 ); -- -------------------------------------------------------- diff --git a/templates/edit-meta.tmpl b/templates/edit-meta.tmpl index 8d96b15..6707e68 100644 --- a/templates/edit-meta.tmpl +++ b/templates/edit-meta.tmpl @@ -269,6 +269,10 @@ + {{template "footer" .}} {{end}} From 619b10c3e5f4f4e28752dbe302a632ea92c0d84a Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Thu, 7 Nov 2019 17:09:43 +0900 Subject: [PATCH 07/12] Fix "suspended" message location on Drafts Previously it was above the header. Ref T661 --- templates/post.tmpl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/post.tmpl b/templates/post.tmpl index 74135a3..52d53a9 100644 --- a/templates/post.tmpl +++ b/templates/post.tmpl @@ -35,10 +35,6 @@ {{template "highlighting" .}} - - {{if .Suspended}} - {{template "user-suspended"}} - {{end}}

{{.SiteName}}

+ + {{if .Suspended}} + {{template "user-suspended"}} + {{end}}
{{if .Title}}

{{.Title}}

{{end}}{{ if .IsPlainText }}

{{.Content}}

{{ else }}
{{.HTMLContent}}
{{ end }}
From 2c2ee0c00cd80e199678ac53adac25d6ff5803a3 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Mon, 11 Nov 2019 15:16:04 +0900 Subject: [PATCH 08/12] Tweak "suspended" notification copy --- templates/user/include/suspended.tmpl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/user/include/suspended.tmpl b/templates/user/include/suspended.tmpl index b1e42c8..e5d9be8 100644 --- a/templates/user/include/suspended.tmpl +++ b/templates/user/include/suspended.tmpl @@ -1,6 +1,5 @@ {{define "user-suspended"}}
-

This account is currently suspended.

-

Please contact the instance administrator to discuss reactivation.

+

Your account is suspended. You can still access all of your posts and blogs, but no one else can currently see them.

{{end}} From f7550a0da8f6782dd12a683844394aca3b8e117b Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 12 Nov 2019 00:04:36 +0900 Subject: [PATCH 09/12] Change more suspension check logic From u.Status == UserSuspended to u.IsSuspended() Ref T661 --- account.go | 2 +- admin.go | 2 +- collections.go | 4 ++-- database.go | 2 +- invites.go | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/account.go b/account.go index 0faa7bb..25f1e0d 100644 --- a/account.go +++ b/account.go @@ -1053,7 +1053,7 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err Email: fullUser.EmailClear(app.keys), HasPass: passIsSet, IsLogOut: r.FormValue("logout") == "1", - Suspended: fullUser.Status == UserSuspended, + Suspended: fullUser.IsSuspended(), } showUserPage(w, "settings", obj) diff --git a/admin.go b/admin.go index 65afd5f..2e5b8a5 100644 --- a/admin.go +++ b/admin.go @@ -242,7 +242,7 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht log.Error("failed to get user: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)} } - if user.Status == UserSuspended { + if user.IsSuspended() { err = app.db.SetUserStatus(user.ID, UserActive) } else { err = app.db.SetUserStatus(user.ID, UserSuspended) diff --git a/collections.go b/collections.go index 38ec6f1..fe9d89f 100644 --- a/collections.go +++ b/collections.go @@ -906,10 +906,10 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e log.Error("Error getting user for collection: %v", err) } } - if !isOwner && u.Status == UserSuspended { + if !isOwner && u.IsSuspended() { return ErrCollectionNotFound } - displayPage.Suspended = u.Status == UserSuspended + displayPage.Suspended = u.IsSuspended() displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data diff --git a/database.go b/database.go index 4b0c702..4b4f4dc 100644 --- a/database.go +++ b/database.go @@ -322,7 +322,7 @@ func (db *datastore) IsUserSuspended(id int64) (bool, error) { return false, fmt.Errorf("is user suspended: %v", err) } - return u.Status == UserSuspended, nil + return u.IsSuspended(), nil } // DoesUserNeedAuth returns true if the user hasn't provided any methods for diff --git a/invites.go b/invites.go index 8f341ec..5f04c69 100644 --- a/invites.go +++ b/invites.go @@ -78,7 +78,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re muVal := r.FormValue("uses") expVal := r.FormValue("expires") - if u.Status == UserSuspended { + if u.IsSuspended() { return ErrUserSuspended } From c3f76a3ab84026dd4431e1b081c089fae8fd484f Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 12 Nov 2019 00:16:44 +0900 Subject: [PATCH 10/12] Change "suspend" to "silence" where user-facing This puts the verbiage more in line with what the feature does, and leaves room for other moderation controls in the future. NOTE: this includes no backend refactoring, which may be confusing. We should rename things to fit ASAP. Ref T661 --- errors.go | 2 +- templates/pad.tmpl | 2 +- templates/user/admin/users.tmpl | 2 +- templates/user/admin/view-user.tmpl | 12 ++++++------ templates/user/include/suspended.tmpl | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/errors.go b/errors.go index fa7304f..c0d435c 100644 --- a/errors.go +++ b/errors.go @@ -48,7 +48,7 @@ var ( ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} - ErrUserSuspended = impart.HTTPError{http.StatusForbidden, "Account is suspended, contact the administrator."} + ErrUserSuspended = impart.HTTPError{http.StatusForbidden, "Account is silenced."} ) // Post operation errors diff --git a/templates/pad.tmpl b/templates/pad.tmpl index 4be311e..0b73b94 100644 --- a/templates/pad.tmpl +++ b/templates/pad.tmpl @@ -134,7 +134,7 @@ var suspended = {{.Suspended}}; var publish = function(content, font) { if (suspended === true) { - alert("Your account is currently suspended, posting is disabled."); + alert("Your account is silenced, so you can't publish or update posts."); return; } {{if and (and .Post.Id (not .Post.Slug)) (not .User)}} diff --git a/templates/user/admin/users.tmpl b/templates/user/admin/users.tmpl index df840b2..fb69d3a 100644 --- a/templates/user/admin/users.tmpl +++ b/templates/user/admin/users.tmpl @@ -18,7 +18,7 @@ {{.Username}} {{.CreatedFriendly}} {{if .IsAdmin}}Admin{{else}}User{{end}} - {{if .IsSuspended}}Suspended{{else}}Active{{end}} + {{if .IsSilenced}}Silenced{{else}}Active{{end}} {{end}} diff --git a/templates/user/admin/view-user.tmpl b/templates/user/admin/view-user.tmpl index 3b13c33..dc7b2ef 100644 --- a/templates/user/admin/view-user.tmpl +++ b/templates/user/admin/view-user.tmpl @@ -57,16 +57,16 @@ td.active-suspend > input[type="submit"] { {{if .LastPost}}{{.LastPost}}{{else}}Never{{end}} -
+ Status {{if .User.IsSuspended}} -

Suspended

- +

Silenced

+ {{else}}

Active

- + {{end}} @@ -117,8 +117,8 @@ td.active-suspend > input[type="submit"] { diff --git a/templates/user/include/suspended.tmpl b/templates/user/include/suspended.tmpl index e5d9be8..76906de 100644 --- a/templates/user/include/suspended.tmpl +++ b/templates/user/include/suspended.tmpl @@ -1,5 +1,5 @@ {{define "user-suspended"}}
-

Your account is suspended. You can still access all of your posts and blogs, but no one else can currently see them.

+

Your account has been silenced. You can still access all of your posts and blogs, but no one else can currently see them.

{{end}} From 7f96e8c38421689b2e1d39056fae4bf90a0e2bc7 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 12 Nov 2019 00:40:16 +0900 Subject: [PATCH 11/12] Rename UserSuspended to UserSilenced Some of the work needed to have the backend match user-facing wording. Ref T661 --- account.go | 2 +- admin.go | 4 ++-- collections.go | 4 ++-- database.go | 2 +- invites.go | 2 +- templates/user/admin/view-user.tmpl | 2 +- users.go | 6 +++--- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/account.go b/account.go index 25f1e0d..5fb87f0 100644 --- a/account.go +++ b/account.go @@ -1053,7 +1053,7 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err Email: fullUser.EmailClear(app.keys), HasPass: passIsSet, IsLogOut: r.FormValue("logout") == "1", - Suspended: fullUser.IsSuspended(), + Suspended: fullUser.IsSilenced(), } showUserPage(w, "settings", obj) diff --git a/admin.go b/admin.go index 2e5b8a5..e624bfb 100644 --- a/admin.go +++ b/admin.go @@ -242,10 +242,10 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht log.Error("failed to get user: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)} } - if user.IsSuspended() { + if user.IsSilenced() { err = app.db.SetUserStatus(user.ID, UserActive) } else { - err = app.db.SetUserStatus(user.ID, UserSuspended) + err = app.db.SetUserStatus(user.ID, UserSilenced) } if err != nil { log.Error("toggle user suspended: %v", err) diff --git a/collections.go b/collections.go index fe9d89f..3e86f30 100644 --- a/collections.go +++ b/collections.go @@ -906,10 +906,10 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e log.Error("Error getting user for collection: %v", err) } } - if !isOwner && u.IsSuspended() { + if !isOwner && u.IsSilenced() { return ErrCollectionNotFound } - displayPage.Suspended = u.IsSuspended() + displayPage.Suspended = u.IsSilenced() displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data diff --git a/database.go b/database.go index 4b4f4dc..d78d888 100644 --- a/database.go +++ b/database.go @@ -322,7 +322,7 @@ func (db *datastore) IsUserSuspended(id int64) (bool, error) { return false, fmt.Errorf("is user suspended: %v", err) } - return u.IsSuspended(), nil + return u.IsSilenced(), nil } // DoesUserNeedAuth returns true if the user hasn't provided any methods for diff --git a/invites.go b/invites.go index 5f04c69..1dba7bd 100644 --- a/invites.go +++ b/invites.go @@ -78,7 +78,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re muVal := r.FormValue("uses") expVal := r.FormValue("expires") - if u.IsSuspended() { + if u.IsSilenced() { return ErrUserSuspended } diff --git a/templates/user/admin/view-user.tmpl b/templates/user/admin/view-user.tmpl index dc7b2ef..be50b12 100644 --- a/templates/user/admin/view-user.tmpl +++ b/templates/user/admin/view-user.tmpl @@ -61,7 +61,7 @@ td.active-suspend > input[type="submit"] {
Status - {{if .User.IsSuspended}} + {{if .User.IsSilenced}}

Silenced

{{else}} diff --git a/users.go b/users.go index 5eb2e61..9b5c99c 100644 --- a/users.go +++ b/users.go @@ -23,7 +23,7 @@ type UserStatus int const ( UserActive = iota - UserSuspended + UserSilenced ) type ( @@ -127,6 +127,6 @@ func (u *User) IsAdmin() bool { return u.ID == 1 } -func (u *User) IsSuspended() bool { - return u.Status&UserSuspended != 0 +func (u *User) IsSilenced() bool { + return u.Status&UserSilenced != 0 } From 5644e8d2516fd92df3158d3d875279293b130ff0 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 12 Nov 2019 00:41:45 +0900 Subject: [PATCH 12/12] Fix "silenced" alert styles on more pages - Tagged posts - Collection index Ref T661 --- less/core.less | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/less/core.less b/less/core.less index f4332a9..8844c84 100644 --- a/less/core.less +++ b/less/core.less @@ -516,10 +516,17 @@ abbr { body#collection article p, body#subpage article p { .article-p; } -pre, body#post article, body#collection article, body#subpage article, body#subpage #wrapper h1 { +pre, body#post article, #post .alert, #subpage .alert, body#collection article, body#subpage article, body#subpage #wrapper h1 { max-width: 40rem; margin: 0 auto; } +#collection header .alert, #post .alert, #subpage .alert { + margin-bottom: 1em; + p { + text-align: left; + line-height: 1.4; + } +} textarea, pre, body#post article, body#collection article p { &.norm, &.sans, &.wrap { line-height: 1.4em;