diff --git a/README.md b/README.md index 4f0b6bb..68da89b 100644 --- a/README.md +++ b/README.md @@ -47,15 +47,15 @@ It's designed to be flexible and share your writing widely, so it's built around ## Hosting -We offer two kinds of hosting services that make WriteFreely deployment painless: [Write.as](https://write.as) for individuals, and [WriteFreely.host](https://writefreely.host) for communities. Besides saving you time, as a customer you directly help fund WriteFreely development. +We offer two kinds of hosting services that make WriteFreely deployment painless: [Write.as Pro](https://write.as/pro) for individuals, and [Write.as for Teams](https://write.as/for/teams) for businesses. Besides saving you time and effort, both services directly fund WriteFreely development and ensure the long-term sustainability of our open source work. -### [![Write.as](https://write.as/img/writeas-wf-readme.png)](https://write.as/) +### [![Write.as Pro](https://writefreely.org/img/writeas-pro-readme.png)](https://write.as/pro) -Start a personal blog on [Write.as](https://write.as), our flagship instance. Built to eliminate setup friction and preserve your privacy, Write.as helps you start a blog in seconds. It supports custom domains (with SSL) and multiple blogs / pen names per account. [Read more here](https://write.as/pricing). +Start a personal blog on [Write.as](https://write.as), our flagship instance. Built to eliminate setup friction and preserve your privacy, Write.as helps you start a blog in seconds. It supports custom domains (with SSL) and multiple blogs / pen names per account. [Read more here](https://write.as/pro). -### [![WriteFreely.host](https://writefreely.host/img/wfhost-wf-readme.png)](https://writefreely.host) +### [![Write.as for Teams](https://writefreely.org/img/writeas-for-teams-readme.png)](https://write.as/for/teams) -[WriteFreely.host](https://writefreely.host) makes it easy to start a close-knit community — to share knowledge, complement your Mastodon instance, or publish updates in your organization. We take care of the hosting, upgrades, backups, and maintenance so you can focus on writing. +[Write.as for Teams](https://write.as/for/teams) gives your organization, business, or [open source project](https://write.as/for/open-source) a clutter-free space to share updates or proposals and build your collective knowledge. We take care of hosting, upgrades, backups, and maintenance so your team can focus on writing. ## Quick start diff --git a/account.go b/account.go index 2a66ecf..c41f24d 100644 --- a/account.go +++ b/account.go @@ -85,7 +85,7 @@ func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error { } func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) // Get params var ur userRegistration @@ -120,7 +120,7 @@ func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) } func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) // Validate required params (alias) if signup.Alias == "" { @@ -377,7 +377,7 @@ func webLogin(app *App, w http.ResponseWriter, r *http.Request) error { var loginAttemptUsers = sync.Map{} func login(app *App, w http.ResponseWriter, r *http.Request) error { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) oneTimeToken := r.FormValue("with") verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "") @@ -580,7 +580,7 @@ func viewExportOptions(app *App, u *User, w http.ResponseWriter, r *http.Request func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) { var filename string var u = &User{} - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) if reqJSON { // Use given Authorization header accessToken := r.Header.Get("Authorization") @@ -625,7 +625,7 @@ func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, // Export as CSV if strings.HasSuffix(r.URL.Path, ".csv") { - data = exportPostsCSV(u, posts) + data = exportPostsCSV(app.cfg.App.Host, u, posts) return data, filename, err } if strings.HasSuffix(r.URL.Path, ".zip") { @@ -662,7 +662,7 @@ func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, s } func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) uObj := struct { ID int64 `json:"id,omitempty"` Username string `json:"username,omitempty"` @@ -686,7 +686,7 @@ func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error { } func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) if !reqJSON { return ErrBadRequestedType } @@ -717,7 +717,7 @@ func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) e } func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) if !reqJSON { return ErrBadRequestedType } @@ -750,14 +750,20 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err log.Error("unable to fetch collections: %v", err) } + suspended, err := app.db.IsUserSuspended(u.ID) + if err != nil { + log.Error("view articles: %v", err) + } d := struct { *UserPage AnonymousPosts *[]PublicPost Collections *[]Collection + Suspended bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f), AnonymousPosts: p, Collections: c, + Suspended: suspended, } d.UserPage.SetMessaging(u) w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") @@ -779,6 +785,11 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) uc, _ := app.db.GetUserCollectionCount(u.ID) // TODO: handle any errors + suspended, err := app.db.IsUserSuspended(u.ID) + if err != nil { + log.Error("view collections %v", err) + return fmt.Errorf("view collections: %v", err) + } d := struct { *UserPage Collections *[]Collection @@ -786,11 +797,13 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) UsedCollections, TotalCollections int NewBlogsDisabled bool + Suspended bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f), Collections: c, UsedCollections: int(uc), NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc), + Suspended: suspended, } d.UserPage.SetMessaging(u) showUserPage(w, "collections", d) @@ -808,13 +821,20 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques return ErrCollectionNotFound } + suspended, err := app.db.IsUserSuspended(u.ID) + if err != nil { + log.Error("view edit collection %v", err) + return fmt.Errorf("view edit collection: %v", err) + } flashes, _ := getSessionFlashes(app, w, r, nil) obj := struct { *UserPage *Collection + Suspended bool }{ UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), Collection: c, + Suspended: suspended, } showUserPage(w, "collection", obj) @@ -822,7 +842,7 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques } func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) var s userSettings var u *User @@ -976,17 +996,24 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error titleStats = c.DisplayTitle() + " " } + suspended, err := app.db.IsUserSuspended(u.ID) + if err != nil { + log.Error("view stats: %v", err) + return err + } obj := struct { *UserPage VisitsBlog string Collection *Collection TopPosts *[]PublicPost APFollowers int + Suspended bool }{ UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), VisitsBlog: alias, Collection: c, TopPosts: topPosts, + Suspended: suspended, } if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(c) @@ -1017,14 +1044,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.IsSilenced(), } showUserPage(w, "settings", obj) diff --git a/activitypub.go b/activitypub.go index a6ef1da..3e7f557 100644 --- a/activitypub.go +++ b/activitypub.go @@ -81,6 +81,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 activities: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host p := c.PersonObject() @@ -106,6 +114,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 outbox: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host if app.cfg.App.SingleUser { @@ -159,6 +175,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 followers: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host accountRoot := c.FederatedAccount() @@ -205,6 +229,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 following: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host accountRoot := c.FederatedAccount() @@ -239,6 +271,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: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host if debugging { @@ -376,11 +416,11 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request // Add follower locally, since it wasn't found before res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox) if err != nil { - if !app.db.isDuplicateKeyErr(err) { - t.Rollback() - log.Error("Couldn't add new remoteuser in DB: %v\n", err) - return - } + // if duplicate key, res will be nil and panic on + // res.LastInsertId below + t.Rollback() + log.Error("Couldn't add new remoteuser in DB: %v\n", err) + return } followerID, err = res.LastInsertId() diff --git a/admin.go b/admin.go index fdbb82f..ebb4225 100644 --- a/admin.go +++ b/admin.go @@ -16,12 +16,14 @@ import ( "net/http" "runtime" "strconv" + "strings" "time" "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/log" + "github.com/writeas/web-core/passgen" "github.com/writeas/writefreely/appstats" "github.com/writeas/writefreely/config" ) @@ -170,11 +172,12 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque Config config.AppCfg Message string - User *User - Colls []inspectedCollection - LastPost string - - TotalPosts int64 + User *User + Colls []inspectedCollection + LastPost string + NewPassword string + TotalPosts int64 + ClearEmail string }{ Config: app.cfg.App, Message: r.FormValue("m"), @@ -186,6 +189,14 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)} } + + flashes, _ := getSessionFlashes(app, w, r, nil) + for _, flash := range flashes { + if strings.HasPrefix(flash, "SUCCESS: ") { + p.NewPassword = strings.TrimPrefix(flash, "SUCCESS: ") + p.ClearEmail = p.User.EmailClear(app.keys) + } + } p.UserPage = NewUserPage(app, r, u, p.User.Username, nil) p.TotalPosts = app.db.GetUserPostsCount(p.User.ID) lp, err := app.db.GetUserLastPostTime(p.User.ID) @@ -230,6 +241,62 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque return nil } +func handleAdminToggleUserStatus(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"} + } + + user, 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 user.IsSilenced() { + err = app.db.SetUserStatus(user.ID, UserActive) + } else { + err = app.db.SetUserStatus(user.ID, UserSilenced) + } + if err != nil { + log.Error("toggle user suspended: %v", err) + return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v")} + } + return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)} +} + +func handleAdminResetUserPass(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"} + } + + // Generate new random password since none supplied + pass := passgen.NewWordish() + hashedPass, err := auth.HashPass([]byte(pass)) + if err != nil { + return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)} + } + + userIDVal := r.FormValue("user") + log.Info("ADMIN: Changing user %s password", userIDVal) + id, err := strconv.Atoi(userIDVal) + if err != nil { + return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid user ID: %v", err)} + } + + err = app.db.ChangePassphrase(int64(id), true, "", hashedPass) + if err != nil { + return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)} + } + log.Info("ADMIN: Successfully changed.") + + addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: %s", pass), nil) + + return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s", username)} +} + func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p := struct { *UserPage diff --git a/app.go b/app.go index 514c7c8..018ce37 100644 --- a/app.go +++ b/app.go @@ -56,7 +56,7 @@ var ( debugging bool // Software version can be set from git env using -ldflags - softwareVer = "0.10.0" + softwareVer = "0.11.1" // DEPRECATED VARS isSingleUser bool diff --git a/collections.go b/collections.go index f0450b5..24393c1 100644 --- a/collections.go +++ b/collections.go @@ -71,6 +71,7 @@ type ( CurrentPage int TotalPages int Format *CollectionFormat + Suspended bool } SubmittedCollection struct { // Data used for updating a given collection @@ -338,7 +339,7 @@ func (c *Collection) RenderMathJax() bool { } func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) alias := r.FormValue("alias") title := r.FormValue("title") @@ -379,6 +380,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 +397,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: %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."} @@ -454,7 +464,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error { c.hostName = app.cfg.App.Host // Redirect users who aren't requesting JSON - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) if !reqJSON { return impart.HTTPError{http.StatusFound, c.CanonicalURL()} } @@ -477,6 +487,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error { res.Owner = u } } + // TODO: check suspended app.db.GetPostsCount(res, isCollOwner) // Strip non-public information res.Collection.ForPublic() @@ -725,9 +736,14 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro if c == nil || err != nil { return err } - c.hostName = app.cfg.App.Host + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("view collection: %v", err) + return ErrInternalGeneral + } + // Serve ActivityStreams data now, if requested if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { ac := c.PersonObject() @@ -784,6 +800,10 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro log.Error("Error getting user for collection: %v", err) } } + if !isOwner && suspended { + return ErrCollectionNotFound + } + displayPage.Suspended = isOwner && suspended displayPage.Owner = owner coll.Owner = displayPage.Owner @@ -898,7 +918,11 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e // Log the error and just continue log.Error("Error getting user for collection: %v", err) } + if owner.IsSilenced() { + return ErrCollectionNotFound + } } + displayPage.Suspended = owner != nil && owner.IsSilenced() displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data @@ -932,16 +956,15 @@ func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Reque } func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) vars := mux.Vars(r) collAlias := vars["alias"] isWeb := r.FormValue("web") == "1" - var u *User + u := &User{} 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 @@ -953,6 +976,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: %v", err) + return ErrInternalGeneral + } + + if suspended { + return ErrUserSuspended + } + if r.Method == "DELETE" { err := app.db.DeleteCollection(collAlias, u.ID) if err != nil { @@ -965,7 +998,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 a95bfca..8572132 100644 --- a/database.go +++ b/database.go @@ -297,7 +297,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, status FROM users WHERE id = ?", id).Scan(&u.Username, &u.HashedPass, &u.Email, &u.Created, &u.Status) switch { case err == sql.ErrNoRows: return nil, ErrUserNotFound @@ -309,6 +309,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 status FROM users WHERE id = ?", id).Scan(&u.Status) + switch { + case err == sql.ErrNoRows: + return false, fmt.Errorf("is user suspended: %v", ErrUserNotFound) + case err != nil: + log.Error("Couldn't SELECT user password: %v", err) + return false, fmt.Errorf("is user suspended: %v", err) + } + + return u.IsSilenced(), 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 @@ -348,7 +365,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, status FROM users WHERE username = ?", username).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created, &u.Status) switch { case err == sql.ErrNoRows: // Check if they've entered the wrong, unnormalized username @@ -371,7 +388,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, status FROM users WHERE id = ?", u.ID).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created, &u.Status) switch { case err == sql.ErrNoRows: return nil, ErrUserNotFound @@ -1630,7 +1647,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.status = 0`).Scan(&collCount) if err != nil { log.Error("Unable to fetch collections count: %v", err) } @@ -1638,7 +1659,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.status = 0`).Scan(&postCount) if err != nil { log.Error("Unable to fetch posts count: %v", err) } @@ -2360,17 +2385,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, status 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.Status) if err != nil { log.Error("Failed scanning GetAllUsers() row: %v", err) break @@ -2407,6 +2432,15 @@ func (db *datastore) GetUserLastPostTime(id int64) (*time.Time, error) { return &t, nil } +// SetUserStatus changes a user's status in the database. see Users.UserStatus +func (db *datastore) SetUserStatus(id int64, status UserStatus) error { + _, err := db.Exec("UPDATE users SET status = ? WHERE id = ?", status, id) + if err != nil { + return fmt.Errorf("failed to update user 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..c0d435c 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 silenced."} ) // Post operation errors diff --git a/export.go b/export.go index 3b5ac49..592bc0c 100644 --- a/export.go +++ b/export.go @@ -20,7 +20,7 @@ import ( "github.com/writeas/web-core/log" ) -func exportPostsCSV(u *User, posts *[]PublicPost) []byte { +func exportPostsCSV(hostName string, u *User, posts *[]PublicPost) []byte { var b bytes.Buffer r := [][]string{ @@ -30,8 +30,9 @@ func exportPostsCSV(u *User, posts *[]PublicPost) []byte { var blog string if p.Collection != nil { blog = p.Collection.Alias + p.Collection.hostName = hostName } - f := []string{p.ID, p.Slug.String, blog, p.CanonicalURL(), p.Created8601(), p.Title.String, strings.Replace(p.Content, "\n", "\\n", -1)} + f := []string{p.ID, p.Slug.String, blog, p.CanonicalURL(hostName), p.Created8601(), p.Title.String, strings.Replace(p.Content, "\n", "\\n", -1)} r = append(r, f) } diff --git a/feed.go b/feed.go index 32feb82..44bb331 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/go.mod b/go.mod index 2f66a2d..5ac4a8b 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( github.com/writeas/openssl-go v1.0.0 // indirect github.com/writeas/saturday v1.7.1 github.com/writeas/slug v1.2.0 - github.com/writeas/web-core v1.0.0 + github.com/writeas/web-core v1.2.0 github.com/writefreely/go-nodeinfo v1.2.0 golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect diff --git a/go.sum b/go.sum index 75626d2..c8b46ff 100644 --- a/go.sum +++ b/go.sum @@ -144,6 +144,8 @@ github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g= github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ= github.com/writeas/web-core v1.0.0 h1:5VKkCakQgdKZcbfVKJXtRpc5VHrkflusCl/KRCPzpQ0= github.com/writeas/web-core v1.0.0/go.mod h1:Si3chV7VWgY8CsV+3gRolMXSO2Vx1ZFAQ/mkrpvmyEE= +github.com/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0= +github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI= github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss= github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo= diff --git a/handle.go b/handle.go index 99c23ae..7e410f5 100644 --- a/handle.go +++ b/handle.go @@ -772,7 +772,7 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error) return } - if IsJSON(r.Header.Get("Content-Type")) { + if IsJSON(r) { impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."}) return } diff --git a/invites.go b/invites.go index 4e1f5fa..1dba7bd 100644 --- a/invites.go +++ b/invites.go @@ -78,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.IsSilenced() { + return ErrUserSuspended + } + var err error var maxUses int if muVal != "0" { 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; diff --git a/migrations/migrations.go b/migrations/migrations.go index 145c6df..0610054 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -13,6 +13,7 @@ package migrations import ( "database/sql" + "github.com/writeas/web-core/log" ) @@ -55,9 +56,10 @@ 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 activityPub mentions", supportActivityPubMentions), // V2 -> V3 (v0.1x.0) + New("support user invites", supportUserInvites), // -> V1 (v0.8.0) + New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0) + New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0) + New("support ActivityPub mentions", supportActivityPubMentions), // V3 -> V4 (v0.12.0) } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v3.go b/migrations/v3.go index c6f5012..b5351da 100644 --- a/migrations/v3.go +++ b/migrations/v3.go @@ -10,10 +10,10 @@ package migrations -func supportActivityPubMentions(db *datastore) error { +func supportUserStatus(db *datastore) error { t, err := db.Begin() - _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` DEFAULT '' NOT NULL`) + _, err = t.Exec(`ALTER TABLE users ADD COLUMN status ` + db.typeInt() + ` DEFAULT '0' NOT NULL`) if err != nil { t.Rollback() return err diff --git a/migrations/v4.go b/migrations/v4.go new file mode 100644 index 0000000..c6f5012 --- /dev/null +++ b/migrations/v4.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 supportActivityPubMentions(db *datastore) error { + t, err := db.Begin() + + _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` DEFAULT '' 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 3cb7f37..37d1c9b 100644 --- a/pad.go +++ b/pad.go @@ -35,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 @@ -52,11 +53,17 @@ 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 if templates[padTmpl] == nil { - log.Info("No template '%s' found. Falling back to default 'pad' template.", padTmpl) + if padTmpl != "" { + log.Info("No template '%s' found. Falling back to default 'pad' template.", padTmpl) + } padTmpl = "pad" } @@ -119,12 +126,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 df8be93..5900142 100644 --- a/posts.go +++ b/posts.go @@ -380,6 +380,12 @@ 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: %v", err) + return ErrInternalGeneral + } + // Check if post has been unpublished if content == "" { gone = true @@ -427,9 +433,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { page := struct { *AnonymousPost page.StaticPage - Username string - IsOwner bool - SiteURL string + Username string + IsOwner bool + SiteURL string + Suspended bool }{ AnonymousPost: post, StaticPage: pageForReq(app, r), @@ -440,6 +447,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID } + if !page.IsOwner && suspended { + return ErrPostNotFound + } + page.Suspended = suspended err = templates["post"].ExecuteTemplate(w, "post", page) if err != nil { log.Error("Post template execute error: %v", err) @@ -471,7 +482,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { // /posts?collection={alias} // ? /collections/{alias}/posts func newPost(app *App, w http.ResponseWriter, r *http.Request) error { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) vars := mux.Vars(r) collAlias := vars["alias"] if collAlias == "" { @@ -496,6 +507,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: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } + if userID == -1 { return ErrNotLoggedIn } @@ -508,7 +528,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 +574,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 { @@ -597,7 +616,7 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error { } func existingPost(app *App, w http.ResponseWriter, r *http.Request) error { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) vars := mux.Vars(r) postID := vars["post"] @@ -662,6 +681,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: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } + // Modify post struct p.ID = postID @@ -856,11 +884,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: %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 +987,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: %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 +1038,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 +1054,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: %v", err) + return ErrInternalGeneral + } + + if suspended { + return ErrPostNotFound + } p.extractData() @@ -1060,9 +1117,9 @@ func (p *Post) processPost() PublicPost { return *res } -func (p *PublicPost) CanonicalURL() string { +func (p *PublicPost) CanonicalURL(hostName string) string { if p.Collection == nil || p.Collection.Alias == "" { - return p.Collection.hostName + "/" + p.ID + return hostName + "/" + p.ID } return p.Collection.CanonicalURL() + p.Slug.String } @@ -1072,7 +1129,7 @@ func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object { o := activitystreams.NewArticleObject() o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID o.Published = p.Created - o.URL = p.CanonicalURL() + o.URL = p.CanonicalURL(cfg.App.Host) o.AttributedTo = p.Collection.FederatedAccount() o.CC = []string{ p.Collection.FederatedAccount() + "/followers", @@ -1296,6 +1353,12 @@ 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: %v", err) + return ErrInternalGeneral + } + // Check collection permissions if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) { return ErrPostNotFound @@ -1352,6 +1415,9 @@ Are you sure it was ever here?`, p.Collection = coll p.IsTopLevel = app.cfg.App.SingleUser + if !p.IsOwner && suspended { + return ErrPostNotFound + } // Check if post has been unpublished if p.Content == "" && p.Title.String == "" { return impart.HTTPError{http.StatusGone, "Post was unpublished."} @@ -1401,12 +1467,14 @@ Are you sure it was ever here?`, IsFound bool IsAdmin bool CanInvite bool + Suspended bool }{ PublicPost: p, StaticPage: pageForReq(app, r), IsOwner: cr.isCollOwner, IsCustomDomain: cr.isCustomDomain, IsFound: postFound, + Suspended: suspended, } tp.IsAdmin = u != nil && u.IsAdmin() tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) diff --git a/read.go b/read.go index ec0305a..d708121 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 ( @@ -69,7 +70,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.status = 0 ORDER BY p.created DESC`) if err != nil { log.Error("Failed selecting from posts: %v", err) @@ -293,7 +295,7 @@ func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) e } title = p.PlainDisplayTitle() - permalink = p.CanonicalURL() + permalink = p.CanonicalURL(app.cfg.App.Host) if p.Collection != nil { author = p.Collection.Title } else { diff --git a/request.go b/request.go index 4939f9c..2eb29f5 100644 --- a/request.go +++ b/request.go @@ -10,9 +10,13 @@ package writefreely -import "mime" +import ( + "mime" + "net/http" +) -func IsJSON(h string) bool { - ct, _, _ := mime.ParseMediaType(h) - return ct == "application/json" +func IsJSON(r *http.Request) bool { + ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + accept := r.Header.Get("Accept") + return ct == "application/json" || accept == "application/json" } diff --git a/routes.go b/routes.go index 6f6bd58..85b858b 100644 --- a/routes.go +++ b/routes.go @@ -147,6 +147,8 @@ 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}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST") + write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).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/templates.go b/templates.go index 6e9a008..968845d 100644 --- a/templates.go +++ b/templates.go @@ -11,10 +11,6 @@ package writefreely import ( - "github.com/dustin/go-humanize" - "github.com/writeas/web-core/l10n" - "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/config" "html/template" "io" "io/ioutil" @@ -22,6 +18,11 @@ import ( "os" "path/filepath" "strings" + + "github.com/dustin/go-humanize" + "github.com/writeas/web-core/l10n" + "github.com/writeas/web-core/log" + "github.com/writeas/writefreely/config" ) var ( @@ -63,6 +64,7 @@ func initTemplate(parentDir, name string) { filepath.Join(parentDir, templatesDir, name+".tmpl"), filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "base.tmpl"), + filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"), } if name == "collection" || name == "collection-tags" || name == "chorus-collection" { // These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl" @@ -86,6 +88,7 @@ func initPage(parentDir, path, key string) { path, filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "base.tmpl"), + filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"), )) } @@ -98,6 +101,7 @@ func initUserPage(parentDir, path, key string) { path, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"), filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"), + filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"), )) } diff --git a/templates/base.tmpl b/templates/base.tmpl index aae7850..3826917 100644 --- a/templates/base.tmpl +++ b/templates/base.tmpl @@ -22,7 +22,7 @@ {{ end }} {{if not .SingleUser}} {{end}}
+ {{if .Suspended}} + {{template "user-suspended"}} + {{end}}

{{if .Posts}}{{else}}write.as {{end}}{{.DisplayTitle}}

{{if .Description}}

{{.Description}}

{{end}} {{/*if not .Public/*}} {{/*end*/}} {{if .PinnedPosts}} + {{range .PinnedPosts}}{{.PlainDisplayTitle}}{{end}} {{end}}
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}} diff --git a/templates/user/articles.tmpl b/templates/user/articles.tmpl index 67d3e0b..3edb89c 100644 --- a/templates/user/articles.tmpl +++ b/templates/user/articles.tmpl @@ -6,6 +6,9 @@ {{if .Flashes}}{{end}} +{{if .Suspended}} + {{template "user-suspended"}} +{{end}}

drafts

diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl index 8af3bda..edd06c1 100644 --- a/templates/user/collection.tmpl +++ b/templates/user/collection.tmpl @@ -8,6 +8,9 @@
+ {{if .Suspended}} + {{template "user-suspended"}} + {{end}}

Customize {{.DisplayTitle}} view blog

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

blogs