From 55ada671701cd4a951da38caf78f46a25250ba40 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Thu, 8 Nov 2018 01:31:01 -0500 Subject: [PATCH] Fill in remaining missing pieces - Database schema changes, removing obsolete custom domain-related code - Missing user structs - Setup verbiage changes - Missing routes - Missing error messages --- app.go | 22 +++++++++- auth.go | 18 ++++++++ config/config.go | 3 +- config/setup.go | 5 ++- database.go | 126 ++++++++----------------------------------------------- errors.go | 24 ++++++++--- routes.go | 18 ++++++++ users.go | 43 +++++++++++++++++++ 8 files changed, 142 insertions(+), 117 deletions(-) create mode 100644 auth.go diff --git a/app.go b/app.go index 349ec1d..7d7b51c 100644 --- a/app.go +++ b/app.go @@ -12,14 +12,18 @@ import ( "syscall" "github.com/gorilla/mux" + "github.com/gorilla/schema" "github.com/gorilla/sessions" + "github.com/writeas/web-core/converter" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" ) const ( - staticDir = "static/" + staticDir = "static/" + assumedTitleLen = 80 + postsPerPage = 10 serverSoftware = "Write Freely" softwareURL = "https://writefreely.org" @@ -28,6 +32,12 @@ const ( var ( debugging bool + + // DEPRECATED VARS + // TODO: pass app.cfg into GetCollection* calls so we can get these values + // from Collection methods and we no longer need these. + hostName string + isSingleUser bool ) type app struct { @@ -36,6 +46,7 @@ type app struct { cfg *config.Config keys *keychain sessionStore *sessions.CookieStore + formDecoder *schema.Decoder } // handleViewHome shows page at root path. Will be the Pad if logged in and the @@ -128,6 +139,8 @@ func Serve() { cfg: cfg, } + hostName = cfg.App.Host + isSingleUser = cfg.App.SingleUser app.cfg.Server.Dev = *debugPtr initTemplates() @@ -141,6 +154,13 @@ func Serve() { // Initialize modules app.sessionStore = initSession(app) + app.formDecoder = schema.NewDecoder() + app.formDecoder.RegisterConverter(converter.NullJSONString{}, converter.ConvertJSONNullString) + app.formDecoder.RegisterConverter(converter.NullJSONBool{}, converter.ConvertJSONNullBool) + app.formDecoder.RegisterConverter(sql.NullString{}, converter.ConvertSQLNullString) + app.formDecoder.RegisterConverter(sql.NullBool{}, converter.ConvertSQLNullBool) + app.formDecoder.RegisterConverter(sql.NullInt64{}, converter.ConvertSQLNullInt64) + app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64) // Check database configuration if app.cfg.Database.User == "" || app.cfg.Database.Password == "" { diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..074e1c0 --- /dev/null +++ b/auth.go @@ -0,0 +1,18 @@ +package writefreely + +// AuthenticateUser ensures a user with the given accessToken is valid. Call +// it before any operations that require authentication or optionally associate +// data with a user account. +// Returns an error if the given accessToken is invalid. Otherwise the +// associated user ID is returned. +func AuthenticateUser(db writestore, accessToken string) (int64, error) { + if accessToken == "" { + return 0, ErrNoAccessToken + } + userID := db.GetUserID(accessToken) + if userID == -1 { + return 0, ErrBadAccessToken + } + + return userID, nil +} diff --git a/config/config.go b/config/config.go index 511356c..c3c0628 100644 --- a/config/config.go +++ b/config/config.go @@ -10,7 +10,8 @@ const ( type ( ServerCfg struct { - Port int `ini:"port"` + HiddenHost string `ini:"hidden_host"` + Port int `ini:"port"` Dev bool `ini:"-"` } diff --git a/config/setup.go b/config/setup.go index a89a148..37d83cf 100644 --- a/config/setup.go +++ b/config/setup.go @@ -10,11 +10,14 @@ import ( func Configure() error { c, err := Load() + var action string if err != nil { fmt.Println("No configuration yet. Creating new.") c = New() + action = "generate" } else { fmt.Println("Configuration loaded.") + action = "update" } title := color.New(color.Bold, color.BgGreen).PrintlnFunc() @@ -22,7 +25,7 @@ func Configure() error { fmt.Println() intro(" ✍ Write Freely Configuration ✍") fmt.Println() - fmt.Println(wordwrap.WrapString(" This quick configuration process will generate the application's config file, "+FileName+".\n\n It validates your input along the way, so you can be sure any future errors aren't caused by a bad configuration. If you'd rather configure your server manually, instead run: writefreely --create-config and edit that file.", 75)) + fmt.Println(wordwrap.WrapString(" This quick configuration process will "+action+" the application's config file, "+FileName+".\n\n It validates your input along the way, so you can be sure any future errors aren't caused by a bad configuration. If you'd rather configure your server manually, instead run: writefreely --create-config and edit that file.", 75)) fmt.Println() title(" Server setup ") diff --git a/database.go b/database.go index d901717..590c5b0 100644 --- a/database.go +++ b/database.go @@ -65,11 +65,10 @@ type writestore interface { CreateCollectionFromToken(string, string, string) (*Collection, error) CreateCollection(string, string, int64) (*Collection, error) - GetFuzzyDomain(host string) string GetCollectionBy(condition string, value interface{}) (*Collection, error) GetCollection(alias string) (*Collection, error) GetCollectionForPad(alias string) (*Collection, error) - GetCollectionFromDomain(host string) (*Collection, error) + GetCollectionByID(id int64) (*Collection, error) UpdateCollection(c *SubmittedCollection, alias string) error DeleteCollection(alias string, userID int64) error @@ -256,7 +255,7 @@ func (db *datastore) DoesUserNeedAuth(id int64) bool { var pass, email []byte // Find out if user has an email set first - err := db.QueryRow("SELECT pass, email FROM users WHERE id = ?", id).Scan(&pass, &email) + err := db.QueryRow("SELECT password, email FROM users WHERE id = ?", id).Scan(&pass, &email) switch { case err == sql.ErrNoRows: // ERROR. Don't give false positives on needing auth methods @@ -272,7 +271,7 @@ func (db *datastore) DoesUserNeedAuth(id int64) bool { func (db *datastore) IsUserPassSet(id int64) (bool, error) { var pass []byte - err := db.QueryRow("SELECT pass FROM users WHERE id = ?", id).Scan(&pass) + err := db.QueryRow("SELECT password FROM users WHERE id = ?", id).Scan(&pass) switch { case err == sql.ErrNoRows: return false, nil @@ -672,10 +671,10 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll c := &Collection{} // FIXME: change Collection to reflect database values. Add helper functions to get actual values - var styleSheet, script, format, customHandle zero.String - row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, format, owner_id, privacy, handle, view_count FROM collections WHERE "+condition, value) + var styleSheet, script, format zero.String + row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value) - err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &format, &c.OwnerID, &c.Visibility, &customHandle, &c.Views) + err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &format, &c.OwnerID, &c.Visibility, &c.Views) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} @@ -683,12 +682,12 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll log.Error("Failed selecting from collections: %v", err) return nil, err } - c.CustomHandle = customHandle.String c.StyleSheet = styleSheet.String c.Script = script.String c.Format = format.String c.Public = c.IsPublic() - // TODO: set app to c + + c.db = db return c, nil } @@ -715,6 +714,10 @@ func (db *datastore) GetCollectionForPad(alias string) (*Collection, error) { return c, nil } +func (db *datastore) GetCollectionByID(id int64) (*Collection, error) { + return db.GetCollectionBy("id = ?", id) +} + func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) { return db.GetCollectionBy("host = ?", host) } @@ -723,7 +726,6 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro q := query.NewUpdate(). SetStringPtr(c.Title, "title"). SetStringPtr(c.Description, "description"). - SetBoolPtr(c.PreferSubdomain, "prefer_subdomain"). SetNullString(c.StyleSheet, "style_sheet"). SetNullString(c.Script, "script") @@ -751,15 +753,10 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro // Find any current domain var collID int64 - var currentDomain sql.NullString var rowsAffected int64 var changed bool var res sql.Result - err := db.QueryRow("SELECT id, host FROM collections LEFT JOIN domains ON id = collection_id WHERE alias = ?", alias).Scan(&collID, ¤tDomain) - if err != nil { - log.Error("Failed selecting from domains: %v", err) - return impart.HTTPError{http.StatusInternalServerError, "Couldn't update custom domain."} - } + var err error // Update MathJax value if c.MathJax { @@ -776,42 +773,6 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro } } - if currentDomain.String != c.Domain.String { - if c.Domain.String == "" { - _, err := db.Exec("DELETE FROM domains WHERE collection_id = ?", collID) - if err != nil { - log.Error("Unable to delete domain %s from domains: %s", currentDomain.String, err) - } - } else if !currentDomain.Valid { - c.Domain.String = strings.ToLower(c.Domain.String) - // There is no current domain; add it - res, err = db.Exec("INSERT INTO domains (host, collection_id, handle) VALUES (?, ?, ?)", c.Domain, collID, c.FediverseHandle()) - if err != nil { - log.Error("Unable to insert domain: %v", err) - return err - } - changed = true - } else { - c.Domain.String = strings.ToLower(c.Domain.String) - // Update the current domain - res, err = db.Exec("UPDATE domains SET host = ?, handle = ?, last_checked = NULL WHERE collection_id = ?", c.Domain, c.FediverseHandle(), collID) - if err != nil { - log.Error("Unable to update domain: %v", err) - } else { - rowsAffected, _ = res.RowsAffected() - if rowsAffected > 0 { - changed = true - } - } - } - } else if c.Handle != "" { - _, err = db.Exec("UPDATE domains SET handle = ? WHERE collection_id = ?", c.FediverseHandle(), collID) - if err != nil { - log.Error("Unable to update domain handle (only): %v", err) - return err - } - } - // Update rest of the collection data res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...) if err != nil { @@ -850,34 +811,6 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro return nil } -// GetFuzzyDomain takes an attempted host and finds any potential authoritative -// domains where the user should be redirected -func (db *datastore) GetFuzzyDomain(host string) string { - if strings.HasPrefix(host, "www.") { - host = host[strings.Index(host, ".")+1:] - } else { - return "" - } - var curHost string - var active, secure bool - err := db.QueryRow("SELECT host, is_active, is_secure FROM domains WHERE host = ?", host).Scan(&curHost, &active, &secure) - if err != nil { - if err != sql.ErrNoRows { - log.Error("Failed fuzzy domain check for %s: %v", host, err) - } - return "" - } - if !active { - return "" - } - if secure { - curHost = "https://" + curHost - } else { - curHost = "http://" + curHost - } - return curHost -} - const postCols = "id, slug, text_appearance, language, rtl, privacy, owner_id, collection_id, pinned_position, created, updated, view_count, title, content" // getEditablePost returns a PublicPost with the given ID only if the given @@ -1407,7 +1340,7 @@ func (db *datastore) ClaimPosts(userID int64, collAlias string, posts *[]ClaimPo qRes, err = db.AttemptClaim(&p, query, params, slugIdx) if err != nil { r.Code = http.StatusInternalServerError - r.ErrorMessage = "A Write.as error occurred. The humans have been alerted." + r.ErrorMessage = "An unknown error occurred." r.ID = p.ID res = append(res, r) log.Error("claimPosts (post %s): %v", p.ID, err) @@ -1523,8 +1456,6 @@ func (db *datastore) GetCollections(u *User) (*[]Collection, error) { defer rows.Close() colls := []Collection{} - var domain zero.String - var isActive, isSecure null.Bool for rows.Next() { c := Collection{} err = rows.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility, &c.Views) @@ -1532,9 +1463,6 @@ func (db *datastore) GetCollections(u *User) (*[]Collection, error) { log.Error("Failed scanning row: %v", err) break } - c.Domain = domain.String - c.IsDomainActive = isActive.Bool - c.IsSecure = isSecure.Bool c.URL = c.CanonicalURL() c.Public = c.IsPublic() @@ -2051,16 +1979,6 @@ func (db *datastore) DeleteAccount(userID int64) (l *string, err error) { rs, _ := res.RowsAffected() stringLogln(l, "Deleted %d for %s from collectionattributes", rs, c.Alias) - // Delete collection email address - res, err = t.Exec("DELETE FROM collectionemails WHERE collection_id = ?", c.ID) - if err != nil { - t.Rollback() - stringLogln(l, "Unable to delete emails on %s: %v", c.Alias, err) - return - } - rs, _ = res.RowsAffected() - stringLogln(l, "Deleted %d for %s from collectionemails", rs, c.Alias) - // Remove any optional collection password res, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID) if err != nil { @@ -2080,16 +1998,6 @@ func (db *datastore) DeleteAccount(userID int64) (l *string, err error) { } rs, _ = res.RowsAffected() stringLogln(l, "Deleted %d for %s from collectionredirects", rs, c.Alias) - - // Remove any associated custom domains - res, err = t.Exec("DELETE FROM domains WHERE collection_id = ?", c.ID) - if err != nil { - t.Rollback() - stringLogln(l, "Unable to delete domains on %s: %v", c.Alias, err) - return - } - rs, _ = res.RowsAffected() - stringLogln(l, "Deleted %d for %s from domains", rs, c.Alias) } // Delete collections @@ -2152,18 +2060,18 @@ func (db *datastore) DeleteAccount(userID int64) (l *string, err error) { func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) { var pub, priv []byte - err := db.QueryRow("SELECT public_key, private_key FROM activitypubkeys WHERE collection_id = ?", collectionID).Scan(&pub, &priv) + err := db.QueryRow("SELECT public_key, private_key FROM collectionkeys WHERE collection_id = ?", collectionID).Scan(&pub, &priv) switch { case err == sql.ErrNoRows: // Generate keys pub, priv = activitypub.GenerateKeys() - _, err = db.Exec("INSERT INTO activitypubkeys (collection_id, public_key, private_key) VALUES (?, ?, ?)", collectionID, pub, priv) + _, err = db.Exec("INSERT INTO collectionkeys (collection_id, public_key, private_key) VALUES (?, ?, ?)", collectionID, pub, priv) if err != nil { log.Error("Unable to INSERT new activitypub keypair: %v", err) return nil, nil } case err != nil: - log.Error("Couldn't SELECT activitypubkeys: %v", err) + log.Error("Couldn't SELECT collectionkeys: %v", err) return nil, nil } diff --git a/errors.go b/errors.go index 1cc6cc2..d307a68 100644 --- a/errors.go +++ b/errors.go @@ -7,21 +7,35 @@ import ( // Commonly returned HTTP errors var ( + ErrBadFormData = impart.HTTPError{http.StatusBadRequest, "Expected valid form data."} + ErrBadJSON = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON object."} + ErrBadJSONArray = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON array."} ErrBadAccessToken = impart.HTTPError{http.StatusUnauthorized, "Invalid access token."} ErrNoAccessToken = impart.HTTPError{http.StatusBadRequest, "Authorization token required."} + ErrNotLoggedIn = impart.HTTPError{http.StatusUnauthorized, "Not logged in."} - ErrForbiddenCollection = impart.HTTPError{http.StatusForbidden, "You don't have permission to add to this collection."} - ErrUnauthorizedEditPost = impart.HTTPError{http.StatusUnauthorized, "Invalid editing credentials."} - ErrUnauthorizedGeneral = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to do that."} + ErrForbiddenCollection = impart.HTTPError{http.StatusForbidden, "You don't have permission to add to this collection."} + ErrForbiddenEditPost = impart.HTTPError{http.StatusForbidden, "You don't have permission to update this post."} + ErrUnauthorizedEditPost = impart.HTTPError{http.StatusUnauthorized, "Invalid editing credentials."} + ErrUnauthorizedGeneral = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to do that."} + ErrBadRequestedType = impart.HTTPError{http.StatusNotAcceptable, "Bad requested Content-Type."} + ErrCollectionUnauthorizedRead = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to access this collection."} - ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."} + ErrNoPublishableContent = impart.HTTPError{http.StatusBadRequest, "Supply something to publish."} + ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."} + ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."} + + ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} + ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."} ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."} ErrPostNotFound = impart.HTTPError{Status: http.StatusNotFound, Message: "Post not found."} + ErrPostBanned = impart.HTTPError{Status: http.StatusGone, Message: "Post removed."} ErrPostUnpublished = impart.HTTPError{Status: http.StatusGone, Message: "Post unpublished by author."} ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."} - ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} + ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} + ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} ) // Post operation errors diff --git a/routes.go b/routes.go index 0cdbae0..b79243f 100644 --- a/routes.go +++ b/routes.go @@ -44,6 +44,16 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto 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 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)) @@ -100,6 +110,14 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST") posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST") + if cfg.App.OpenRegistration { + write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST") + } + write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") + + // Handle special pages first + write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) + if cfg.App.SingleUser { write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") } else { diff --git a/users.go b/users.go index e9e63a5..ec7af21 100644 --- a/users.go +++ b/users.go @@ -9,6 +9,35 @@ import ( ) type ( + userCredentials struct { + Alias string `json:"alias" schema:"alias"` + Pass string `json:"pass" schema:"pass"` + Email string `json:"email" schema:"email"` + Web bool `json:"web" schema:"-"` + To string `json:"-" schema:"to"` + + EmailLogin bool `json:"via_email" schema:"via_email"` + } + + userRegistration struct { + userCredentials + Honeypot string `json:"fullname" schema:"fullname"` + Normalize bool `json:"normalize" schema:"normalize"` + Signup bool `json:"signup" schema:"signup"` + } + + // AuthUser contains information for a newly authenticated user (either + // from signing up or logging in). + AuthUser struct { + AccessToken string `json:"access_token,omitempty"` + Password string `json:"password,omitempty"` + User *User `json:"user"` + + // Verbose user data + Posts *[]PublicPost `json:"posts,omitempty"` + Collections *[]Collection `json:"collections,omitempty"` + } + // User is a consistent user object in the database and all contexts (auth // and non-auth) in the API. User struct { @@ -21,6 +50,20 @@ type ( clearEmail string `json:"email"` } + + userMeStats struct { + TotalCollections, TotalArticles, CollectionPosts uint64 + } + + ExportUser struct { + *User + Collections *[]CollectionObj `json:"collections"` + AnonymousPosts []PublicPost `json:"posts"` + } + + PublicUser struct { + Username string `json:"username"` + } ) // EmailClear decrypts and returns the user's email, caching it in the user