diff --git a/account.go b/account.go index 2a66ecf..a8057d4 100644 --- a/account.go +++ b/account.go @@ -1,1070 +1,1087 @@ /* * Copyright © 2018-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 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" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/data" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/author" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" ) type ( userSettings struct { Username string `schema:"username" json:"username"` Email string `schema:"email" json:"email"` NewPass string `schema:"new-pass" json:"new_pass"` OldPass string `schema:"current-pass" json:"current_pass"` IsLogOut bool `schema:"logout" json:"logout"` } UserPage struct { page.StaticPage PageTitle string Separator template.HTML IsAdmin bool CanInvite bool } ) func NewUserPage(app *App, r *http.Request, u *User, title string, flashes []string) *UserPage { up := &UserPage{ StaticPage: pageForReq(app, r), PageTitle: title, } up.Username = u.Username up.Flashes = flashes up.Path = r.URL.Path up.IsAdmin = u.IsAdmin() up.CanInvite = canUserInvite(app.cfg, up.IsAdmin) return up } func canUserInvite(cfg *config.Config, isAdmin bool) bool { return cfg.App.UserInvites != "" && (isAdmin || cfg.App.UserInvites != "admin") } func (up *UserPage) SetMessaging(u *User) { //up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID) } const ( loginAttemptExpiration = 3 * time.Second ) var actuallyUsernameReg = regexp.MustCompile("username is actually ([a-z0-9\\-]+)\\. Please try that, instead") func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error { _, err := signup(app, w, r) return err } func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { reqJSON := IsJSON(r.Header.Get("Content-Type")) // Get params var ur userRegistration if reqJSON { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&ur) if err != nil { log.Error("Couldn't parse signup JSON request: %v\n", err) return nil, ErrBadJSON } } else { // Check if user is already logged in u := getUserSession(app, r) if u != nil { return &AuthUser{User: u}, nil } err := r.ParseForm() if err != nil { log.Error("Couldn't parse signup form request: %v\n", err) return nil, ErrBadFormData } err = app.formDecoder.Decode(&ur, r.PostForm) if err != nil { log.Error("Couldn't decode signup form request: %v\n", err) return nil, ErrBadFormData } } return signupWithRegistration(app, ur, w, r) } func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { reqJSON := IsJSON(r.Header.Get("Content-Type")) // Validate required params (alias) if signup.Alias == "" { return nil, impart.HTTPError{http.StatusBadRequest, "A username is required."} } if signup.Pass == "" { return nil, impart.HTTPError{http.StatusBadRequest, "A password is required."} } var desiredUsername string if signup.Normalize { // With this option we simply conform the username to what we expect // without complaining. Since they might've done something funny, like // enter: write.as/Way Out There, we'll use their raw input for the new // collection name and sanitize for the slug / username. desiredUsername = signup.Alias signup.Alias = getSlug(signup.Alias, "") } if !author.IsValidUsername(app.cfg, signup.Alias) { // Ensure the username is syntactically correct. return nil, impart.HTTPError{http.StatusPreconditionFailed, "Username is reserved or isn't valid. It must be at least 3 characters long, and can only include letters, numbers, and hyphens."} } // Handle empty optional params // TODO: remove this var createdWithPass := true hashedPass, err := auth.HashPass([]byte(signup.Pass)) if err != nil { return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} } // Create struct to insert u := &User{ Username: signup.Alias, HashedPass: hashedPass, HasPass: createdWithPass, Email: zero.NewString("", signup.Email != ""), Created: time.Now().Truncate(time.Second).UTC(), } if signup.Email != "" { encEmail, err := data.Encrypt(app.keys.EmailKey, signup.Email) if err != nil { log.Error("Unable to encrypt email: %s\n", err) } else { u.Email.String = string(encEmail) } } // Create actual user if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil { return nil, err } // Log invite if needed if signup.InviteCode != "" { cu, err := app.db.GetUserForAuth(signup.Alias) if err != nil { return nil, err } err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID) if err != nil { return nil, err } } // Add back unencrypted data for response if signup.Email != "" { u.Email.String = signup.Email } resUser := &AuthUser{ User: u, } if !createdWithPass { resUser.Password = signup.Pass } title := signup.Alias if signup.Normalize { title = desiredUsername } resUser.Collections = &[]Collection{ { Alias: signup.Alias, Title: title, }, } var token string if reqJSON && !signup.Web { token, err = app.db.GetAccessToken(u.ID) if err != nil { return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."} } resUser.AccessToken = token } else { session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. // Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144 log.Error("Session: %v; ignoring", err) } session.Values[cookieUserVal] = resUser.User.Cookie() err = session.Save(r, w) if err != nil { log.Error("Couldn't save session: %v", err) return nil, err } } if reqJSON { return resUser, impart.WriteSuccess(w, resUser, http.StatusCreated) } return resUser, nil } func viewLogout(app *App, w http.ResponseWriter, r *http.Request) error { session, err := app.sessionStore.Get(r, cookieName) if err != nil { return ErrInternalCookieSession } // Ensure user has an email or password set before they go, so they don't // lose access to their account. val := session.Values[cookieUserVal] var u = &User{} var ok bool if u, ok = val.(*User); !ok { log.Error("Error casting user object on logout. Vals: %+v Resetting cookie.", session.Values) err = session.Save(r, w) if err != nil { log.Error("Couldn't save session on logout: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."} } return impart.HTTPError{http.StatusFound, "/"} } u, err = app.db.GetUserByID(u.ID) if err != nil && err != ErrUserNotFound { return impart.HTTPError{http.StatusInternalServerError, "Unable to fetch user information."} } session.Options.MaxAge = -1 err = session.Save(r, w) if err != nil { log.Error("Couldn't save session on logout: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."} } return impart.HTTPError{http.StatusFound, "/"} } func handleAPILogout(app *App, w http.ResponseWriter, r *http.Request) error { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } t := auth.GetToken(accessToken) if len(t) == 0 { return ErrNoAccessToken } err := app.db.DeleteToken(t) if err != nil { return err } return impart.HTTPError{Status: http.StatusNoContent} } func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { var earlyError string oneTimeToken := r.FormValue("with") if oneTimeToken != "" { log.Info("Calling login with one-time token.") err := login(app, w, r) if err != nil { log.Info("Received error: %v", err) earlyError = fmt.Sprintf("%s", err) } } session, err := app.sessionStore.Get(r, cookieName) if err != nil { // Ignore this log.Error("Unable to get session; ignoring: %v", err) } p := &struct { page.StaticPage To string Message template.HTML Flashes []template.HTML LoginUsername string }{ pageForReq(app, r), r.FormValue("to"), template.HTML(""), []template.HTML{}, getTempInfo(app, "login-user", r, w), } if earlyError != "" { p.Flashes = append(p.Flashes, template.HTML(earlyError)) } // Display any error messages flashes, _ := getSessionFlashes(app, w, r, session) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } err = pages["login.tmpl"].ExecuteTemplate(w, "base", p) if err != nil { log.Error("Unable to render login: %v", err) return err } return nil } func webLogin(app *App, w http.ResponseWriter, r *http.Request) error { err := login(app, w, r) if err != nil { username := r.FormValue("alias") // Login request was unsuccessful; save the error in the session and redirect them if err, ok := err.(impart.HTTPError); ok { session, _ := app.sessionStore.Get(r, cookieName) if session != nil { session.AddFlash(err.Message) session.Save(r, w) } if m := actuallyUsernameReg.FindStringSubmatch(err.Message); len(m) > 0 { // Retain fixed username recommendation for the login form username = m[1] } } // Pass along certain information saveTempInfo(app, "login-user", username, r, w) // Retain post-login URL if one was given redirectTo := "/login" postLoginRedirect := r.FormValue("to") if postLoginRedirect != "" { redirectTo += "?to=" + postLoginRedirect } log.Error("Unable to login: %v", err) return impart.HTTPError{http.StatusTemporaryRedirect, redirectTo} } return nil } var loginAttemptUsers = sync.Map{} func login(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r.Header.Get("Content-Type")) oneTimeToken := r.FormValue("with") verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "") redirectTo := r.FormValue("to") if redirectTo == "" { if app.cfg.App.SingleUser { redirectTo = "/me/new" } else { redirectTo = "/" } } var u *User var err error var signin userCredentials // Log in with one-time token if one is given if oneTimeToken != "" { log.Info("Login: Logging user in via token.") userID := app.db.GetUserID(oneTimeToken) if userID == -1 { log.Error("Login: Got user -1 from token") err := ErrBadAccessToken err.Message = "Expired or invalid login code." return err } log.Info("Login: Found user %d.", userID) u, err = app.db.GetUserByID(userID) if err != nil { log.Error("Unable to fetch user on one-time token login: %v", err) return impart.HTTPError{http.StatusInternalServerError, "There was an error retrieving the user you want."} } log.Info("Login: Got user via token") } else { // Get params if reqJSON { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&signin) if err != nil { log.Error("Couldn't parse signin JSON request: %v\n", err) return ErrBadJSON } } else { err := r.ParseForm() if err != nil { log.Error("Couldn't parse signin form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&signin, r.PostForm) if err != nil { log.Error("Couldn't decode signin form request: %v\n", err) return ErrBadFormData } } log.Info("Login: Attempting login for '%s'", signin.Alias) // Validate required params (all) if signin.Alias == "" { msg := "Parameter `alias` required." if signin.Web { msg = "A username is required." } return impart.HTTPError{http.StatusBadRequest, msg} } if !signin.EmailLogin && signin.Pass == "" { msg := "Parameter `pass` required." if signin.Web { msg = "A password is required." } return impart.HTTPError{http.StatusBadRequest, msg} } // Prevent excessive login attempts on the same account // Skip this check in dev environment if !app.cfg.Server.Dev { now := time.Now() attemptExp, att := loginAttemptUsers.LoadOrStore(signin.Alias, now.Add(loginAttemptExpiration)) if att { if attemptExpTime, ok := attemptExp.(time.Time); ok { if attemptExpTime.After(now) { // This user attempted previously, and the period hasn't expired yet return impart.HTTPError{http.StatusTooManyRequests, "You're doing that too much."} } else { // This user attempted previously, but the time expired; free up space loginAttemptUsers.Delete(signin.Alias) } } else { log.Error("Unable to cast expiration to time") } } } // Retrieve password u, err = app.db.GetUserForAuth(signin.Alias) if err != nil { log.Info("Unable to getUserForAuth on %s: %v", signin.Alias, err) if strings.IndexAny(signin.Alias, "@") > 0 { log.Info("Suggesting: %s", ErrUserNotFoundEmail.Message) return ErrUserNotFoundEmail } return err } // Authenticate if u.Email.String == "" { // User has no email set, so check if they haven't added a password, either, // so we can return a more helpful error message. if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass { log.Info("Tried logging in to %s, but no password or email.", signin.Alias) return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."} } } if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) { return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} } } if reqJSON && !signin.Web { var token string if r.Header.Get("User-Agent") == "" { // Get last created token when User-Agent is empty token = app.db.FetchLastAccessToken(u.ID) if token == "" { token, err = app.db.GetAccessToken(u.ID) } } else { token, err = app.db.GetAccessToken(u.ID) } if err != nil { log.Error("Login: Unable to create access token: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."} } resUser := getVerboseAuthUser(app, token, u, verbose) return impart.WriteSuccess(w, resUser, http.StatusOK) } session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. log.Error("Login: Session: %v; ignoring", err) } // Remove unwanted data session.Values[cookieUserVal] = u.Cookie() err = session.Save(r, w) if err != nil { log.Error("Login: Couldn't save session: %v", err) // TODO: return error } // Send success if reqJSON { return impart.WriteSuccess(w, &AuthUser{User: u}, http.StatusOK) } log.Info("Login: Redirecting to %s", redirectTo) w.Header().Set("Location", redirectTo) w.WriteHeader(http.StatusFound) return nil } func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser { resUser := &AuthUser{ AccessToken: token, User: u, } // Fetch verbose user data if requested if verbose { posts, err := app.db.GetUserPosts(u) if err != nil { log.Error("Login: Unable to get user posts: %v", err) } colls, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { log.Error("Login: Unable to get user collections: %v", err) } passIsSet, err := app.db.IsUserPassSet(u.ID) if err != nil { // TODO: correct error meesage log.Error("Login: Unable to get user collections: %v", err) } resUser.Posts = posts resUser.Collections = colls resUser.User.HasPass = passIsSet } return resUser } func viewExportOptions(app *App, u *User, w http.ResponseWriter, r *http.Request) error { // Fetch extra user data p := NewUserPage(app, r, u, "Export", nil) showUserPage(w, "export", p) return nil } 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")) if reqJSON { // Use given Authorization header accessToken := r.Header.Get("Authorization") if accessToken == "" { return nil, filename, ErrNoAccessToken } userID := app.db.GetUserID(accessToken) if userID == -1 { return nil, filename, ErrBadAccessToken } var err error u, err = app.db.GetUserByID(userID) if err != nil { return nil, filename, impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve requested user."} } } else { // Use user cookie session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. log.Error("Session: %v; ignoring", err) } val := session.Values[cookieUserVal] var ok bool if u, ok = val.(*User); !ok { return nil, filename, ErrNotLoggedIn } } filename = u.Username + "-posts-" + time.Now().Truncate(time.Second).UTC().Format("200601021504") // Fetch data we're exporting var err error var data []byte posts, err := app.db.GetUserPosts(u) if err != nil { return data, filename, err } // Export as CSV if strings.HasSuffix(r.URL.Path, ".csv") { data = exportPostsCSV(u, posts) return data, filename, err } if strings.HasSuffix(r.URL.Path, ".zip") { data = exportPostsZip(u, posts) return data, filename, err } if r.FormValue("pretty") == "1" { data, err = json.MarshalIndent(posts, "", "\t") } else { data, err = json.Marshal(posts) } return data, filename, err } func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) { var err error filename := "" u := getUserSession(app, r) if u == nil { return nil, filename, ErrNotLoggedIn } filename = u.Username + "-" + time.Now().Truncate(time.Second).UTC().Format("200601021504") exportUser := compileFullExport(app, u) var data []byte if r.FormValue("pretty") == "1" { data, err = json.MarshalIndent(exportUser, "", "\t") } else { data, err = json.Marshal(exportUser) } return data, filename, err } func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r.Header.Get("Content-Type")) uObj := struct { ID int64 `json:"id,omitempty"` Username string `json:"username,omitempty"` }{} var err error if reqJSON { _, uObj.Username, err = app.db.GetUserDataFromToken(r.Header.Get("Authorization")) if err != nil { return err } } else { u := getUserSession(app, r) if u == nil { return impart.WriteSuccess(w, uObj, http.StatusOK) } uObj.Username = u.Username } return impart.WriteSuccess(w, uObj, http.StatusOK) } func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r.Header.Get("Content-Type")) if !reqJSON { return ErrBadRequestedType } var err error p := GetPostsCache(u.ID) if p == nil { userPostsCache.Lock() if userPostsCache.users[u.ID].ready == nil { userPostsCache.users[u.ID] = postsCacheItem{ready: make(chan struct{})} userPostsCache.Unlock() p, err = app.db.GetUserPosts(u) if err != nil { return err } CachePosts(u.ID, p) } else { userPostsCache.Unlock() <-userPostsCache.users[u.ID].ready p = GetPostsCache(u.ID) } } return impart.WriteSuccess(w, p, http.StatusOK) } func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r.Header.Get("Content-Type")) if !reqJSON { return ErrBadRequestedType } p, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { return err } return impart.WriteSuccess(w, p, http.StatusOK) } func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p, err := app.db.GetAnonymousPosts(u) if err != nil { log.Error("unable to fetch anon posts: %v", err) } // nil-out AnonymousPosts slice for easy detection in the template if p != nil && len(*p) == 0 { p = nil } f, err := getSessionFlashes(app, w, r, nil) if err != nil { log.Error("unable to fetch flashes: %v", err) } c, err := app.db.GetPublishableCollections(u, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } d := struct { *UserPage AnonymousPosts *[]PublicPost Collections *[]Collection }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f), AnonymousPosts: p, Collections: c, } d.UserPage.SetMessaging(u) w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT") showUserPage(w, "articles", d) return nil } func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) error { c, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) return fmt.Errorf("No collections") } f, _ := getSessionFlashes(app, w, r, nil) uc, _ := app.db.GetUserCollectionCount(u.ID) // TODO: handle any errors d := struct { *UserPage Collections *[]Collection UsedCollections, TotalCollections int NewBlogsDisabled bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f), Collections: c, UsedCollections: int(uc), NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc), } d.UserPage.SetMessaging(u) showUserPage(w, "collections", d) return nil } func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) c, err := app.db.GetCollection(vars["collection"]) if err != nil { return err } if c.OwnerID != u.ID { return ErrCollectionNotFound } flashes, _ := getSessionFlashes(app, w, r, nil) obj := struct { *UserPage *Collection }{ UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), Collection: c, } showUserPage(w, "collection", obj) return nil } func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r.Header.Get("Content-Type")) var s userSettings var u *User var sess *sessions.Session var err error if reqJSON { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } u, err = app.db.GetAPIUser(accessToken) if err != nil { return ErrBadAccessToken } decoder := json.NewDecoder(r.Body) err := decoder.Decode(&s) if err != nil { log.Error("Couldn't parse settings JSON request: %v\n", err) return ErrBadJSON } // Prevent all username updates // TODO: support changing username via JSON API request s.Username = "" } else { u, sess = getUserAndSession(app, r) if u == nil { return ErrNotLoggedIn } err := r.ParseForm() if err != nil { log.Error("Couldn't parse settings form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&s, r.PostForm) if err != nil { log.Error("Couldn't decode settings form request: %v\n", err) return ErrBadFormData } } // Do update postUpdateReturn := r.FormValue("return") redirectTo := "/me/settings" if s.IsLogOut { redirectTo += "?logout=1" } else if postUpdateReturn != "" { redirectTo = postUpdateReturn } // Only do updates on values we need if s.Username != "" && s.Username == u.Username { // Username hasn't actually changed; blank it out s.Username = "" } err = app.db.ChangeSettings(app, u, &s) if err != nil { if reqJSON { return err } if err, ok := err.(impart.HTTPError); ok { addSessionFlash(app, w, r, err.Message, nil) } } else { // Successful update. if reqJSON { return impart.WriteSuccess(w, u, http.StatusOK) } if s.IsLogOut { redirectTo = "/me/logout" } else { sess.Values[cookieUserVal] = u.Cookie() addSessionFlash(app, w, r, "Account updated.", nil) } } w.Header().Set("Location", redirectTo) w.WriteHeader(http.StatusFound) return nil } func updatePassphrase(app *App, w http.ResponseWriter, r *http.Request) error { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } curPass := r.FormValue("current") newPass := r.FormValue("new") // Ensure a new password is given (always required) if newPass == "" { return impart.HTTPError{http.StatusBadRequest, "Provide a new password."} } userID, sudo := app.db.GetUserIDPrivilege(accessToken) if userID == -1 { return ErrBadAccessToken } // Ensure a current password is given if the access token doesn't have sudo // privileges. if !sudo && curPass == "" { return impart.HTTPError{http.StatusBadRequest, "Provide current password."} } // Hash the new password hashedPass, err := auth.HashPass([]byte(newPass)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} } // Do update err = app.db.ChangePassphrase(userID, sudo, curPass, hashedPass) if err != nil { return err } return impart.WriteSuccess(w, struct{}{}, http.StatusOK) } func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error { var c *Collection var err error vars := mux.Vars(r) alias := vars["collection"] if alias != "" { c, err = app.db.GetCollection(alias) if err != nil { return err } if c.OwnerID != u.ID { return ErrCollectionNotFound } } topPosts, err := app.db.GetTopPosts(u, alias) if err != nil { log.Error("Unable to get top posts: %v", err) return err } flashes, _ := getSessionFlashes(app, w, r, nil) titleStats := "" if c != nil { titleStats = c.DisplayTitle() + " " } obj := struct { *UserPage VisitsBlog string Collection *Collection TopPosts *[]PublicPost APFollowers int }{ UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), VisitsBlog: alias, Collection: c, TopPosts: topPosts, } if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(c) if err != nil { return err } obj.APFollowers = len(*folls) } showUserPage(w, "stats", obj) return nil } func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error { fullUser, err := app.db.GetUserByID(u.ID) if err != nil { log.Error("Unable to get user for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } passIsSet, err := app.db.IsUserPassSet(u.ID) if err != nil { log.Error("Unable to get isUserPassSet for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } flashes, _ := getSessionFlashes(app, w, r, nil) obj := struct { *UserPage Email string HasPass bool IsLogOut bool }{ UserPage: NewUserPage(app, r, u, "Account Settings", flashes), Email: fullUser.EmailClear(app.keys), HasPass: passIsSet, IsLogOut: r.FormValue("logout") == "1", } showUserPage(w, "settings", obj) return nil } func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error { session, err := app.sessionStore.Get(r, "t") if err != nil { return ErrInternalCookieSession } session.Values[key] = val err = session.Save(r, w) if err != nil { log.Error("Couldn't saveTempInfo for key-val (%s:%s): %v", key, val, err) } return err } func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) string { session, err := app.sessionStore.Get(r, "t") if err != nil { return "" } // Get the information var s = "" var ok bool if s, ok = session.Values[key].(string); !ok { return "" } // Delete cookie session.Options.MaxAge = -1 err = session.Save(r, w) if err != nil { log.Error("Couldn't erase temp data for key %s: %v", key, err) } // Return value return s } + +func handleUserDelete(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + confirmUsername := r.PostFormValue("confirm-username") + if u.Username != confirmUsername { + return impart.HTTPError{http.StatusBadRequest, "Confirmation username must match your username exactly."} + } + + // TODO: prevent admin delete themselves? + err := app.db.DeleteAccount(u.ID) + if err != nil { + log.Error("user delete account: %v", err) + return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete account: %v", err)} + } + + _ = addSessionFlash(app, w, r, "Account deleted successfully, sorry to see you go.", nil) + return impart.HTTPError{http.StatusFound, "/me/logout"} +} diff --git a/routes.go b/routes.go index 0113e93..ab0a854 100644 --- a/routes.go +++ b/routes.go @@ -1,206 +1,207 @@ /* * Copyright © 2018-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 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" ) // InitStaticRoutes adds routes for serving static files. // TODO: this should just be a func, not method func (app *App) InitStaticRoutes(r *mux.Router) { // Handle static files fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir))) app.shttp = http.NewServeMux() app.shttp.Handle("/", fs) r.PathPrefix("/").Handler(fs) } // InitRoutes adds dynamic routes for the given mux.Router. func InitRoutes(apper Apper, r *mux.Router) *mux.Router { // Create handler handler := NewWFHandler(apper) // Set up routes hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:] if apper.App().cfg.App.SingleUser { hostSubroute = "{domain}" } else { if strings.HasPrefix(hostSubroute, "localhost") { hostSubroute = "localhost" } } if apper.App().cfg.App.SingleUser { log.Info("Adding %s routes (single user)...", hostSubroute) } else { log.Info("Adding %s routes (multi-user)...", hostSubroute) } // Primary app routes write := r.PathPrefix("/").Subrouter() // Federation endpoint configurations wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg}) wf.NoTLSHandler = nil // Federation endpoints // host-meta write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader)) // webfinger write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger))) // nodeinfo niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg) ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db}) write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover))) write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo))) // Set up dyamic page handlers // Handle auth auth := write.PathPrefix("/api/auth/").Subrouter() if apper.App().cfg.App.OpenRegistration { auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST") } auth.HandleFunc("/login", handler.All(login)).Methods("POST") auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST") auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE") // Handle logged in user sections me := write.PathPrefix("/me").Subrouter() me.HandleFunc("/", handler.Redirect("/me", UserLevelUser)) me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET") me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET") me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET") me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET") + me.HandleFunc("/delete", handler.User(handleUserDelete)).Methods("POST") me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET") me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET") me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET") me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET") me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET") me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET") me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET") write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET") apiMe := write.PathPrefix("/api/me/").Subrouter() apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET") apiMe.HandleFunc("/posts", handler.UserAPI(viewMyPostsAPI)).Methods("GET") apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET") apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST") apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST") apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST") // Sign up validation write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") // Handle collections write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST") apiColls := write.PathPrefix("/api/collections/").Subrouter() apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE") apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET") apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET") apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST") apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST") apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET") apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET") apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET") // Handle posts write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST") posts := write.PathPrefix("/api/posts/").Subrouter() posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.AllReader(fetchPost)).Methods("GET") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST") posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST") write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") 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/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") write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST") // Handle special pages first write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) write.HandleFunc("/invite/{code}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") // TODO: show a reader-specific 404 page if the function is disabled write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader)) RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter()) draftEditPrefix := "" if apper.App().cfg.App.SingleUser { draftEditPrefix = "/d" write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") } else { write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") } // All the existing stuff write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET") // Collections if apper.App().cfg.App.SingleUser { RouteCollections(handler, write.PathPrefix("/").Subrouter()) } else { write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader)) write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader)) RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter()) // Posts } write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional)) write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional)) return r } func RouteCollections(handler *Handler, r *mux.Router) { r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader)) r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) r.HandleFunc("/{slug}", handler.CollectionPostOrStatic) r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser)) r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser)) r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET") } func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) { r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm)) r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm)) r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm)) r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm)) } diff --git a/templates/user/settings.tmpl b/templates/user/settings.tmpl index fd204a5..cd45179 100644 --- a/templates/user/settings.tmpl +++ b/templates/user/settings.tmpl @@ -1,83 +1,95 @@ {{define "settings"}} {{template "header" .}}

{{if .IsLogOut}}Before you go...{{else}}Account Settings {{if .IsAdmin}}admin settings{{end}}{{end}}

{{if .Flashes}}{{end}} {{ if .IsLogOut }}

Please add an email address and/or passphrase so you can log in again later.

{{ else }}

Change your account settings here.

Username

{{ end }}

Passphrase

{{if and (not .HasPass) (not .IsLogOut)}}

Add a passphrase to easily log in to your account.

{{end}} {{if .HasPass}}

Current passphrase

New passphrase

{{end}} {{if .IsLogOut}}{{end}}

Email

{{if and (not .Email) (not .IsLogOut)}}

Add your email to get:

  • No-passphrase login
  • Account recovery if you forget your passphrase
{{end}}
+ + {{ if not .IsAdmin }} +
+

Delete Account

+

Danger Zone - This cannot be undone

+

This will delete your account and all your blogs and posts. Before continuing make sure to export your data.

+
+

Type your username to confirm deletion.

+ + +

+ {{end}}
{{template "footer" .}} {{end}}