1246 lines
33 KiB
Go
1246 lines
33 KiB
Go
/*
|
|
* Copyright © 2018-2021 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"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/csrf"
|
|
"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/writefreely/writefreely/author"
|
|
"github.com/writefreely/writefreely/config"
|
|
"github.com/writefreely/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
|
|
CollAlias string
|
|
}
|
|
)
|
|
|
|
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) {
|
|
if app.cfg.App.DisablePasswordAuth {
|
|
err := ErrDisabledPasswordAuth
|
|
return nil, err
|
|
}
|
|
|
|
reqJSON := IsJSON(r)
|
|
|
|
// 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)
|
|
|
|
// 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
|
|
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: true,
|
|
Email: prepareUserEmail(signup.Email, app.keys.EmailKey),
|
|
Created: time.Now().Truncate(time.Second).UTC(),
|
|
}
|
|
|
|
// Create actual user
|
|
if err := app.db.CreateUser(app.cfg, u, desiredUsername, signup.Description); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Log invite if needed
|
|
if signup.InviteCode != "" {
|
|
err = app.db.CreateInvitedUser(signup.InviteCode, u.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,
|
|
}
|
|
title := signup.Alias
|
|
if signup.Normalize {
|
|
title = desiredUsername
|
|
}
|
|
resUser.Collections = &[]Collection{
|
|
{
|
|
Alias: signup.Alias,
|
|
Title: title,
|
|
Description: signup.Description,
|
|
},
|
|
}
|
|
|
|
var coll *Collection
|
|
if signup.Monetization != "" {
|
|
if coll == nil {
|
|
coll, err = app.db.GetCollection(signup.Alias)
|
|
if err != nil {
|
|
log.Error("Unable to get new collection '%s' for monetization on signup: %v", signup.Alias, err)
|
|
return nil, err
|
|
}
|
|
}
|
|
err = app.db.SetCollectionAttribute(coll.ID, "monetization_pointer", signup.Monetization)
|
|
if err != nil {
|
|
log.Error("Unable to add monetization on signup: %v", err)
|
|
return nil, err
|
|
}
|
|
coll.Monetization = signup.Monetization
|
|
}
|
|
|
|
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
|
|
*OAuthButtons
|
|
To string
|
|
Message template.HTML
|
|
Flashes []template.HTML
|
|
LoginUsername string
|
|
}{
|
|
StaticPage: pageForReq(app, r),
|
|
OAuthButtons: NewOAuthButtons(app.Config()),
|
|
To: r.FormValue("to"),
|
|
Message: template.HTML(""),
|
|
Flashes: []template.HTML{},
|
|
LoginUsername: 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)
|
|
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
|
|
|
|
if app.cfg.App.DisablePasswordAuth {
|
|
err := ErrDisabledPasswordAuth
|
|
return err
|
|
}
|
|
|
|
// 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 len(u.HashedPass) == 0 {
|
|
return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"}
|
|
}
|
|
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)
|
|
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(app.cfg.App.Host, 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)
|
|
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)
|
|
if !reqJSON {
|
|
return ErrBadRequestedType
|
|
}
|
|
|
|
isAnonPosts := r.FormValue("anonymous") == "1"
|
|
if isAnonPosts {
|
|
pageStr := r.FormValue("page")
|
|
pg, err := strconv.Atoi(pageStr)
|
|
if err != nil {
|
|
log.Error("Error parsing page parameter '%s': %s", pageStr, err)
|
|
pg = 1
|
|
}
|
|
|
|
p, err := app.db.GetAnonymousPosts(u, pg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return impart.WriteSuccess(w, p, http.StatusOK)
|
|
}
|
|
|
|
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)
|
|
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, 1)
|
|
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)
|
|
}
|
|
|
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
|
if err != nil {
|
|
log.Error("view articles: %v", err)
|
|
}
|
|
d := struct {
|
|
*UserPage
|
|
AnonymousPosts *[]PublicPost
|
|
Collections *[]Collection
|
|
Silenced bool
|
|
}{
|
|
UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f),
|
|
AnonymousPosts: p,
|
|
Collections: c,
|
|
Silenced: silenced,
|
|
}
|
|
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
|
|
|
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
|
if err != nil {
|
|
log.Error("view collections %v", err)
|
|
return fmt.Errorf("view collections: %v", err)
|
|
}
|
|
d := struct {
|
|
*UserPage
|
|
Collections *[]Collection
|
|
|
|
UsedCollections, TotalCollections int
|
|
|
|
NewBlogsDisabled bool
|
|
Silenced bool
|
|
}{
|
|
UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f),
|
|
Collections: c,
|
|
UsedCollections: int(uc),
|
|
NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
|
|
Silenced: silenced,
|
|
}
|
|
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
|
|
}
|
|
|
|
// Add collection properties
|
|
c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer")
|
|
|
|
silenced, err := app.db.IsUserSilenced(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
|
|
Silenced bool
|
|
}{
|
|
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
|
Collection: c,
|
|
Silenced: silenced,
|
|
}
|
|
obj.UserPage.CollAlias = c.Alias
|
|
|
|
showUserPage(w, "collection", obj)
|
|
return nil
|
|
}
|
|
|
|
func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error {
|
|
reqJSON := IsJSON(r)
|
|
|
|
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
|
|
}
|
|
c.hostName = app.cfg.App.Host
|
|
}
|
|
|
|
topPosts, err := app.db.GetTopPosts(u, alias, c.hostName)
|
|
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() + " "
|
|
}
|
|
|
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
|
if err != nil {
|
|
log.Error("view stats: %v", err)
|
|
return err
|
|
}
|
|
obj := struct {
|
|
*UserPage
|
|
VisitsBlog string
|
|
Collection *Collection
|
|
TopPosts *[]PublicPost
|
|
APFollowers int
|
|
Silenced bool
|
|
}{
|
|
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
|
VisitsBlog: alias,
|
|
Collection: c,
|
|
TopPosts: topPosts,
|
|
Silenced: silenced,
|
|
}
|
|
obj.UserPage.CollAlias = c.Alias
|
|
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)
|
|
|
|
enableOauthSlack := app.Config().SlackOauth.ClientID != ""
|
|
enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != ""
|
|
enableOauthGitLab := app.Config().GitlabOauth.ClientID != ""
|
|
enableOauthGeneric := app.Config().GenericOauth.ClientID != ""
|
|
enableOauthGitea := app.Config().GiteaOauth.ClientID != ""
|
|
|
|
oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID)
|
|
if err != nil {
|
|
log.Error("Unable to get oauth accounts for settings: %s", err)
|
|
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
|
}
|
|
for idx, oauthAccount := range oauthAccounts {
|
|
switch oauthAccount.Provider {
|
|
case "slack":
|
|
enableOauthSlack = false
|
|
case "write.as":
|
|
enableOauthWriteAs = false
|
|
case "gitlab":
|
|
enableOauthGitLab = false
|
|
case "generic":
|
|
oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName
|
|
oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect
|
|
enableOauthGeneric = false
|
|
case "gitea":
|
|
enableOauthGitea = false
|
|
}
|
|
}
|
|
|
|
displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0
|
|
|
|
obj := struct {
|
|
*UserPage
|
|
Email string
|
|
HasPass bool
|
|
IsLogOut bool
|
|
Silenced bool
|
|
CSRFField template.HTML
|
|
OauthSection bool
|
|
OauthAccounts []oauthAccountInfo
|
|
OauthSlack bool
|
|
OauthWriteAs bool
|
|
OauthGitLab bool
|
|
GitLabDisplayName string
|
|
OauthGeneric bool
|
|
OauthGenericDisplayName string
|
|
OauthGitea bool
|
|
GiteaDisplayName string
|
|
}{
|
|
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
|
Email: fullUser.EmailClear(app.keys),
|
|
HasPass: passIsSet,
|
|
IsLogOut: r.FormValue("logout") == "1",
|
|
Silenced: fullUser.IsSilenced(),
|
|
CSRFField: csrf.TemplateField(r),
|
|
OauthSection: displayOauthSection,
|
|
OauthAccounts: oauthAccounts,
|
|
OauthSlack: enableOauthSlack,
|
|
OauthWriteAs: enableOauthWriteAs,
|
|
OauthGitLab: enableOauthGitLab,
|
|
GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName),
|
|
OauthGeneric: enableOauthGeneric,
|
|
OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName),
|
|
OauthGitea: enableOauthGitea,
|
|
GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName),
|
|
}
|
|
|
|
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 {
|
|
if !app.cfg.App.OpenDeletion {
|
|
return impart.HTTPError{http.StatusForbidden, "Open account deletion is disabled on this instance."}
|
|
}
|
|
|
|
confirmUsername := r.PostFormValue("confirm-username")
|
|
if u.Username != confirmUsername {
|
|
return impart.HTTPError{http.StatusBadRequest, "Confirmation username must match your username exactly."}
|
|
}
|
|
|
|
// Check for account deletion safeguards in place
|
|
if u.IsAdmin() {
|
|
return impart.HTTPError{http.StatusForbidden, "Cannot delete admin."}
|
|
}
|
|
|
|
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)}
|
|
}
|
|
|
|
// FIXME: This doesn't ever appear to the user, as (I believe) the value is erased when the session cookie is reset
|
|
_ = addSessionFlash(app, w, r, "Thanks for writing with us! You account was deleted successfully.", nil)
|
|
return impart.HTTPError{http.StatusFound, "/me/logout"}
|
|
}
|
|
|
|
func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
|
provider := r.FormValue("provider")
|
|
clientID := r.FormValue("client_id")
|
|
remoteUserID := r.FormValue("remote_user_id")
|
|
|
|
err := app.db.RemoveOauth(r.Context(), u.ID, provider, clientID, remoteUserID)
|
|
if err != nil {
|
|
return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()}
|
|
}
|
|
|
|
return impart.HTTPError{Status: http.StatusFound, Message: "/me/settings"}
|
|
}
|
|
|
|
func prepareUserEmail(input string, emailKey []byte) zero.String {
|
|
email := zero.NewString("", input != "")
|
|
if len(input) > 0 {
|
|
encEmail, err := data.Encrypt(emailKey, input)
|
|
if err != nil {
|
|
log.Error("Unable to encrypt email: %s\n", err)
|
|
} else {
|
|
email.String = string(encEmail)
|
|
|
|
}
|
|
}
|
|
return email
|
|
}
|