diff --git a/app.go b/app.go index 669c751..82548e9 100644 --- a/app.go +++ b/app.go @@ -1,6 +1,7 @@ package writefreely import ( + "database/sql" "flag" "fmt" _ "github.com/go-sql-driver/mysql" @@ -21,6 +22,7 @@ const ( type app struct { router *mux.Router + db *datastore cfg *config.Config keys *keychain sessionStore *sessions.CookieStore @@ -62,11 +64,33 @@ func Serve() { // Initialize modules app.sessionStore = initSession(app) + // Check database configuration + if app.cfg.Database.User == "" || app.cfg.Database.Password == "" { + log.Error("Database user or password not set.") + os.Exit(1) + } + if app.cfg.Database.Host == "" { + app.cfg.Database.Host = "localhost" + } + if app.cfg.Database.Database == "" { + app.cfg.Database.Database = "writeas" + } + + log.Info("Connecting to database...") + db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database)) + if err != nil { + log.Error("\n%s\n", err) + os.Exit(1) + } + app.db = &datastore{db} + defer shutdown(app) + app.db.SetMaxOpenConns(50) + r := mux.NewRouter() handler := NewHandler(app.sessionStore) // Handle app routes - initRoutes(handler, r, app.cfg) + initRoutes(handler, r, app.cfg, app.db) // Handle static files fs := http.FileServer(http.Dir(staticDir)) @@ -92,4 +116,6 @@ func Serve() { } func shutdown(app *app) { + log.Info("Closing database connection...") + app.db.Close() } diff --git a/collections.go b/collections.go new file mode 100644 index 0000000..0ba4089 --- /dev/null +++ b/collections.go @@ -0,0 +1,69 @@ +package writefreely + +import ( + "database/sql" +) + +type ( + Collection struct { + ID int64 `datastore:"id" json:"-"` + Alias string `datastore:"alias" schema:"alias" json:"alias"` + Title string `datastore:"title" schema:"title" json:"title"` + Description string `datastore:"description" schema:"description" json:"description"` + Direction string `schema:"dir" json:"dir,omitempty"` + Language string `schema:"lang" json:"lang,omitempty"` + StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"` + Script string `datastore:"script" schema:"script" json:"script,omitempty"` + Public bool `datastore:"public" json:"public"` + Visibility collVisibility `datastore:"private" json:"-"` + Format string `datastore:"format" json:"format,omitempty"` + Views int64 `json:"views"` + OwnerID int64 `datastore:"owner_id" json:"-"` + PublicOwner bool `datastore:"public_owner" json:"-"` + PreferSubdomain bool `datastore:"prefer_subdomain" json:"-"` + Domain string `datastore:"domain" json:"domain,omitempty"` + IsDomainActive bool `datastore:"is_active" json:"-"` + IsSecure bool `datastore:"is_secure" json:"-"` + CustomHandle string `datastore:"handle" json:"-"` + Email string `json:"email,omitempty"` + URL string `json:"url,omitempty"` + + app *app + } + CollectionObj struct { + Collection + TotalPosts int `json:"total_posts"` + Owner *User `json:"owner,omitempty"` + Posts *[]PublicPost `json:"posts,omitempty"` + } + SubmittedCollection struct { + // Data used for updating a given collection + ID int64 + OwnerID uint64 + + // Form helpers + PreferURL string `schema:"prefer_url" json:"prefer_url"` + Privacy int `schema:"privacy" json:"privacy"` + Pass string `schema:"password" json:"password"` + Federate bool `schema:"federate" json:"federate"` + MathJax bool `schema:"mathjax" json:"mathjax"` + Handle string `schema:"handle" json:"handle"` + + // Actual collection values updated in the DB + Alias *string `schema:"alias" json:"alias"` + Title *string `schema:"title" json:"title"` + Description *string `schema:"description" json:"description"` + StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` + Script *sql.NullString `schema:"script" json:"script"` + Visibility *int `schema:"visibility" json:"public"` + Format *sql.NullString `schema:"format" json:"format"` + PreferSubdomain *bool `schema:"prefer_subdomain" json:"prefer_subdomain"` + Domain *sql.NullString `schema:"domain" json:"domain"` + } + CollectionFormat struct { + Format string + } +) + +// collVisibility represents the visibility level for the collection. +type collVisibility int diff --git a/config/config.go b/config/config.go index 9defa9e..630dfe3 100644 --- a/config/config.go +++ b/config/config.go @@ -15,11 +15,12 @@ type ( } DatabaseCfg struct { - Type string `ini:"type"` - User string `ini:"username"` - Pass string `ini:"password"` - Host string `ini:"host"` - Port int `ini:"port"` + Type string `ini:"type"` + User string `ini:"username"` + Password string `ini:"password"` + Database string `ini:"database"` + Host string `ini:"host"` + Port int `ini:"port"` } AppCfg struct { diff --git a/database.go b/database.go new file mode 100644 index 0000000..e18b52e --- /dev/null +++ b/database.go @@ -0,0 +1,2180 @@ +package writefreely + +import ( + "database/sql" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/guregu/null" + "github.com/guregu/null/zero" + uuid "github.com/nu7hatch/gouuid" + "github.com/writeas/impart" + "github.com/writeas/nerds/store" + "github.com/writeas/web-core/activitypub" + "github.com/writeas/web-core/auth" + "github.com/writeas/web-core/data" + "github.com/writeas/web-core/id" + "github.com/writeas/web-core/log" + "github.com/writeas/web-core/query" + "github.com/writeas/writefreely/author" +) + +const ( + mySQLErrDuplicateKey = 1062 +) + +type writestore interface { + CreateUser(*User, string) error + UpdateUserEmail(keys *keychain, userID int64, email string) error + UpdateEncryptedUserEmail(int64, []byte) error + GetUserByID(int64) (*User, error) + GetUserForAuth(string) (*User, error) + GetUserForAuthByID(int64) (*User, error) + GetUserNameFromToken(string) (string, error) + GetUserDataFromToken(string) (int64, string, error) + GetAPIUser(header string) (*User, error) + GetUserID(accessToken string) int64 + GetUserIDPrivilege(accessToken string) (userID int64, sudo bool) + DeleteToken(accessToken []byte) error + FetchLastAccessToken(userID int64) string + GetAccessToken(userID int64) (string, error) + GetTemporaryAccessToken(userID int64, validSecs int) (string, error) + GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error) + DeleteAccount(userID int64) (l *string, err error) + ChangeSettings(app *app, u *User, s *userSettings) error + ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error + + GetCollections(u *User) (*[]Collection, error) + GetPublishableCollections(u *User) (*[]Collection, error) + GetMeStats(u *User) userMeStats + GetTopPosts(u *User, alias string) (*[]PublicPost, error) + GetAnonymousPosts(u *User) (*[]PublicPost, error) + GetUserPosts(u *User) (*[]PublicPost, error) + + CreateOwnedPost(post *SubmittedPost, accessToken, collAlias string) (*PublicPost, error) + CreatePost(userID, collID int64, post *SubmittedPost) (*Post, error) + UpdateOwnedPost(post *AuthenticatedPost, userID int64) error + GetEditablePost(id, editToken string) (*PublicPost, error) + PostIDExists(id string) bool + GetPost(id string, collectionID int64) (*PublicPost, error) + GetOwnedPost(id string, ownerID int64) (*PublicPost, error) + GetPostProperty(id string, collectionID int64, property string) (interface{}, error) + + 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) + UpdateCollection(c *SubmittedCollection, alias string) error + DeleteCollection(alias string, userID int64) error + + UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error + GetLastPinnedPostPos(collID int64) int64 + GetPinnedPosts(coll *CollectionObj) (*[]PublicPost, error) + RemoveCollectionRedirect(t *sql.Tx, alias string) error + GetCollectionRedirect(alias string) (new string) + IsCollectionAttributeOn(id int64, attr string) bool + CollectionHasAttribute(id int64, attr string) bool + + CanCollect(cpr *ClaimPostRequest, userID int64) bool + AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error) + DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error) + ClaimPosts(userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) + + GetPostsCount(c *CollectionObj, includeFuture bool) + GetPosts(c *Collection, page int, includeFuture bool) (*[]PublicPost, error) + GetPostsTagged(c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) + + GetAPFollowers(c *Collection) (*[]RemoteUser, error) + GetAPActorKeys(collectionID int64) ([]byte, []byte) +} + +type datastore struct { + *sql.DB +} + +func (db *datastore) CreateUser(u *User, collectionTitle string) error { + // New users get a `users` and `collections` row. + t, err := db.Begin() + if err != nil { + return err + } + + if db.PostIDExists(u.Username) { + return impart.HTTPError{http.StatusConflict, "Invalid collection name."} + } + + // 1. Add to `users` table + // NOTE: Assumes User's Password is already hashed! + res, err := t.Exec("INSERT INTO users (username, password, email, created) VALUES (?, ?, ?, NOW())", u.Username, u.HashedPass, u.Email) + if err != nil { + t.Rollback() + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + if mysqlErr.Number == mySQLErrDuplicateKey { + return impart.HTTPError{http.StatusConflict, "Username is already taken."} + } + } + + log.Error("Rolling back users INSERT: %v\n", err) + return err + } + u.ID, err = res.LastInsertId() + if err != nil { + t.Rollback() + log.Error("Rolling back after LastInsertId: %v\n", err) + return err + } + + // 2. Create user's Collection + if collectionTitle == "" { + collectionTitle = u.Username + } + res, err = t.Exec("INSERT INTO collections (alias, title, owner_id) VALUES (?, ?, ?)", u.Username, collectionTitle, u.ID) + if err != nil { + t.Rollback() + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + if mysqlErr.Number == mySQLErrDuplicateKey { + return impart.HTTPError{http.StatusConflict, "Username is already taken."} + } + } + log.Error("Rolling back collections INSERT: %v\n", err) + return err + } + + db.RemoveCollectionRedirect(t, u.Username) + + err = t.Commit() + if err != nil { + t.Rollback() + log.Error("Rolling back after Commit(): %v\n", err) + return err + } + + return nil +} + +// FIXME: We're returning errors inconsistently in this file. Do we use Errorf +// for returned value, or impart? +func (db *datastore) UpdateUserEmail(keys *keychain, userID int64, email string) error { + encEmail, err := data.Encrypt(keys.emailKey, email) + if err != nil { + return fmt.Errorf("Couldn't encrypt email %s: %s\n", email, err) + } + + return db.UpdateEncryptedUserEmail(userID, encEmail) +} + +func (db *datastore) UpdateEncryptedUserEmail(userID int64, encEmail []byte) error { + _, err := db.Exec("UPDATE users SET email = ? WHERE id = ?", encEmail, userID) + if err != nil { + return fmt.Errorf("Unable to update user email: %s", err) + } + + return nil +} + +func (db *datastore) CreateCollectionFromToken(alias, title, accessToken string) (*Collection, error) { + userID := db.GetUserID(accessToken) + if userID == -1 { + return nil, ErrBadAccessToken + } + + return db.CreateCollection(alias, title, userID) +} + +func (db *datastore) GetUserCollectionCount(userID int64) (uint64, error) { + var collCount uint64 + err := db.QueryRow("SELECT COUNT(*) FROM collections WHERE owner_id = ?", userID).Scan(&collCount) + switch { + case err == sql.ErrNoRows: + return 0, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user from database."} + case err != nil: + log.Error("Couldn't get collections count for user %d: %v", userID, err) + return 0, err + } + + return collCount, nil +} + +func (db *datastore) CreateCollection(alias, title string, userID int64) (*Collection, error) { + if db.PostIDExists(alias) { + return nil, impart.HTTPError{http.StatusConflict, "Invalid collection name."} + } + + // All good, so create new collection + res, err := db.Exec("INSERT INTO collections (alias, title, owner_id) VALUES (?, ?, ?)", alias, title, userID) + if err != nil { + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + if mysqlErr.Number == mySQLErrDuplicateKey { + return nil, impart.HTTPError{http.StatusConflict, "Collection already exists."} + } + } + log.Error("Couldn't add to collections: %v\n", err) + return nil, err + } + + c := &Collection{ + Alias: alias, + Title: title, + OwnerID: userID, + PublicOwner: false, + } + + c.ID, err = res.LastInsertId() + if err != nil { + log.Error("Couldn't get collection LastInsertId: %v\n", err) + } + + return c, nil +} + +func (db *datastore) GetUserByID(id int64) (*User, error) { + u := &User{ID: id} + + err := db.QueryRow("SELECT username, password, email, created FROM users WHERE id = ?", id).Scan(&u.Username, &u.HashedPass, &u.Email, &u.Created) + switch { + case err == sql.ErrNoRows: + return nil, ErrUserNotFound + case err != nil: + log.Error("Couldn't SELECT user password: %v", err) + return nil, err + } + + return u, nil +} + +// DoesUserNeedAuth returns true if the user hasn't provided any methods for +// authenticating with the account, such a passphrase or email address. +// Any errors are reported to admin and silently quashed, returning false as the +// result. +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) + switch { + case err == sql.ErrNoRows: + // ERROR. Don't give false positives on needing auth methods + return false + case err != nil: + // ERROR. Don't give false positives on needing auth methods + log.Error("Couldn't SELECT user %d from users: %v", id, err) + return false + } + // User doesn't need auth if there's an email + return len(email) == 0 && len(pass) == 0 +} + +func (db *datastore) IsUserPassSet(id int64) (bool, error) { + var pass []byte + err := db.QueryRow("SELECT pass FROM users WHERE id = ?", id).Scan(&pass) + switch { + case err == sql.ErrNoRows: + return false, nil + case err != nil: + log.Error("Couldn't SELECT user %d from users: %v", id, err) + return false, err + } + + return len(pass) > 0, nil +} + +func (db *datastore) GetUserForAuth(username string) (*User, error) { + u := &User{Username: username} + + err := db.QueryRow("SELECT id, password, email, created FROM users WHERE username = ?", username).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created) + switch { + case err == sql.ErrNoRows: + return nil, ErrUserNotFound + case err != nil: + log.Error("Couldn't SELECT user password: %v", err) + return nil, err + } + + return u, nil +} + +func (db *datastore) GetUserForAuthByID(userID int64) (*User, error) { + u := &User{ID: userID} + + err := db.QueryRow("SELECT id, password, email, created FROM users WHERE id = ?", u.ID).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created) + switch { + case err == sql.ErrNoRows: + return nil, ErrUserNotFound + case err != nil: + log.Error("Couldn't SELECT userForAuthByID: %v", err) + return nil, err + } + + return u, nil +} + +func (db *datastore) GetUserNameFromToken(accessToken string) (string, error) { + t := auth.GetToken(accessToken) + if len(t) == 0 { + return "", ErrNoAccessToken + } + + var oneTime bool + var username string + err := db.QueryRow("SELECT username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token = ? AND (expires IS NULL OR expires > NOW())", t).Scan(&username, &oneTime) + switch { + case err == sql.ErrNoRows: + return "", ErrBadAccessToken + case err != nil: + return "", ErrInternalGeneral + } + + // Delete token if it was one-time + if oneTime { + db.DeleteToken(t[:]) + } + + return username, nil +} + +func (db *datastore) GetUserDataFromToken(accessToken string) (int64, string, error) { + t := auth.GetToken(accessToken) + if len(t) == 0 { + return 0, "", ErrNoAccessToken + } + + var userID int64 + var oneTime bool + var username string + err := db.QueryRow("SELECT user_id, username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token = ? AND (expires IS NULL OR expires > NOW())", t).Scan(&userID, &username, &oneTime) + switch { + case err == sql.ErrNoRows: + return 0, "", ErrBadAccessToken + case err != nil: + return 0, "", ErrInternalGeneral + } + + // Delete token if it was one-time + if oneTime { + db.DeleteToken(t[:]) + } + + return userID, username, nil +} + +func (db *datastore) GetAPIUser(header string) (*User, error) { + uID := db.GetUserID(header) + if uID == -1 { + return nil, fmt.Errorf(ErrUserNotFound.Error()) + } + return db.GetUserByID(uID) +} + +// GetUserID takes a hexadecimal accessToken, parses it into its binary +// representation, and gets any user ID associated with the token. If no user +// is associated, -1 is returned. +func (db *datastore) GetUserID(accessToken string) int64 { + i, _ := db.GetUserIDPrivilege(accessToken) + return i +} + +func (db *datastore) GetUserIDPrivilege(accessToken string) (userID int64, sudo bool) { + t := auth.GetToken(accessToken) + if len(t) == 0 { + return -1, false + } + + var oneTime bool + err := db.QueryRow("SELECT user_id, sudo, one_time FROM accesstokens WHERE token = ? AND (expires IS NULL OR expires > NOW())", t).Scan(&userID, &sudo, &oneTime) + switch { + case err == sql.ErrNoRows: + return -1, false + case err != nil: + return -1, false + } + + // Delete token if it was one-time + if oneTime { + db.DeleteToken(t[:]) + } + + return +} + +func (db *datastore) DeleteToken(accessToken []byte) error { + res, err := db.Exec("DELETE FROM accesstokens WHERE token = ?", accessToken) + if err != nil { + return err + } + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + return impart.HTTPError{http.StatusNotFound, "Token is invalid or doesn't exist"} + } + return nil +} + +// FetchLastAccessToken creates a new non-expiring, valid access token for the given +// userID. +func (db *datastore) FetchLastAccessToken(userID int64) string { + var t []byte + err := db.QueryRow("SELECT token FROM accesstokens WHERE user_id = ? AND (expires IS NULL OR expires > NOW()) ORDER BY created DESC LIMIT 1", userID).Scan(&t) + switch { + case err == sql.ErrNoRows: + return "" + case err != nil: + log.Error("Failed selecting from accesstoken: %v", err) + return "" + } + + u, err := uuid.Parse(t) + if err != nil { + return "" + } + return u.String() +} + +// GetAccessToken creates a new non-expiring, valid access token for the given +// userID. +func (db *datastore) GetAccessToken(userID int64) (string, error) { + return db.GetTemporaryOneTimeAccessToken(userID, 0, false) +} + +// GetTemporaryAccessToken creates a new valid access token for the given +// userID that remains valid for the given time in seconds. If validSecs is 0, +// the access token doesn't automatically expire. +func (db *datastore) GetTemporaryAccessToken(userID int64, validSecs int) (string, error) { + return db.GetTemporaryOneTimeAccessToken(userID, validSecs, false) +} + +// GetTemporaryOneTimeAccessToken creates a new valid access token for the given +// userID that remains valid for the given time in seconds and can only be used +// once if oneTime is true. If validSecs is 0, the access token doesn't +// automatically expire. +func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error) { + u, err := uuid.NewV4() + if err != nil { + log.Error("Unable to generate token: %v", err) + return "", err + } + + // Insert UUID to `accesstokens` + binTok := u[:] + + expirationVal := "NULL" + if validSecs > 0 { + expirationVal = fmt.Sprintf("DATE_ADD(NOW(), INTERVAL %d SECOND)", validSecs) + } + + _, err = db.Exec("INSERT INTO accesstokens (token, user_id, created, one_time, expires) VALUES (?, ?, NOW(), ?, "+expirationVal+")", string(binTok), userID, oneTime) + if err != nil { + log.Error("Couldn't INSERT accesstoken: %v", err) + return "", err + } + + return u.String(), nil +} + +func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias string) (*PublicPost, error) { + var userID, collID int64 = -1, -1 + var coll *Collection + var err error + if accessToken != "" { + userID = db.GetUserID(accessToken) + if userID == -1 { + return nil, ErrBadAccessToken + } + if collAlias != "" { + coll, err = db.GetCollection(collAlias) + if err != nil { + return nil, err + } + if coll.OwnerID != userID { + return nil, ErrForbiddenCollection + } + collID = coll.ID + } + } + + rp := &PublicPost{} + rp.Post, err = db.CreatePost(userID, collID, post) + if err != nil { + return rp, err + } + if coll != nil { + coll.ForPublic() + rp.Collection = &CollectionObj{Collection: *coll} + } + return rp, nil +} + +func (db *datastore) CreatePost(userID, collID int64, post *SubmittedPost) (*Post, error) { + idLen := postIDLen + friendlyID := store.GenerateFriendlyRandomString(idLen) + + // Handle appearance / font face + appearance := post.Font + if !post.isFontValid() { + appearance = "norm" + } + + var err error + ownerID := sql.NullInt64{ + Valid: false, + } + ownerCollID := sql.NullInt64{ + Valid: false, + } + slug := sql.NullString{"", false} + + // If an alias was supplied, we'll add this to the collection as well. + if userID > 0 { + ownerID.Int64 = userID + ownerID.Valid = true + if collID > 0 { + ownerCollID.Int64 = collID + ownerCollID.Valid = true + var slugVal string + if post.Title != nil && *post.Title != "" { + slugVal = getSlug(*post.Title, post.Language.String) + if slugVal == "" { + slugVal = getSlug(*post.Content, post.Language.String) + } + } else { + slugVal = getSlug(*post.Content, post.Language.String) + } + if slugVal == "" { + slugVal = friendlyID + } + slug = sql.NullString{slugVal, true} + } + } + + _, err = db.Exec("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, owner_id, collection_id, updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())", friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, ownerID, ownerCollID) + if err != nil { + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + if mysqlErr.Number == mySQLErrDuplicateKey { + // Duplicate entry error; try a new slug + // TODO: make this a little more robust + // TODO: reuse exact same db.Exec statement as above + slug = sql.NullString{id.GenSafeUniqueSlug(slug.String), true} + _, err = db.Exec("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, owner_id, collection_id, updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())", friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, ownerID, ownerCollID) + if err != nil { + return nil, handleFailedPostInsert(fmt.Errorf("Retried slug generation, still failed: %v", err)) + } + } else { + return nil, handleFailedPostInsert(err) + } + } else { + return nil, handleFailedPostInsert(err) + } + } + + // TODO: return Created field in proper format + return &Post{ + ID: friendlyID, + Slug: null.NewString(slug.String, slug.Valid), + Font: appearance, + Language: zero.NewString(post.Language.String, post.Language.Valid), + RTL: zero.NewBool(post.IsRTL.Bool, post.IsRTL.Valid), + OwnerID: null.NewInt(userID, true), + CollectionID: null.NewInt(userID, true), + Created: time.Now().Truncate(time.Second).UTC(), + Updated: time.Now().Truncate(time.Second).UTC(), + Title: zero.NewString(*(post.Title), true), + Content: *(post.Content), + }, nil +} + +// UpdateOwnedPost updates an existing post with only the given fields in the +// supplied AuthenticatedPost. +func (db *datastore) UpdateOwnedPost(post *AuthenticatedPost, userID int64) error { + params := []interface{}{} + var queryUpdates, sep, authCondition string + if post.Slug != nil && *post.Slug != "" { + queryUpdates += sep + "slug = ?" + sep = ", " + params = append(params, getSlug(*post.Slug, "")) + } + if post.Content != nil { + queryUpdates += sep + "content = ?" + sep = ", " + params = append(params, post.Content) + } + if post.Title != nil { + queryUpdates += sep + "title = ?" + sep = ", " + params = append(params, post.Title) + } + if post.Language.Valid { + queryUpdates += sep + "language = ?" + sep = ", " + params = append(params, post.Language.String) + } + if post.IsRTL.Valid { + queryUpdates += sep + "rtl = ?" + sep = ", " + params = append(params, post.IsRTL.Bool) + } + if post.Font != "" { + queryUpdates += sep + "text_appearance = ?" + sep = ", " + params = append(params, post.Font) + } + if post.Created != nil { + createTime, err := time.Parse(postMetaDateFormat, *post.Created) + if err != nil { + log.Error("Unable to parse Created date: %v", err) + return fmt.Errorf("That's the incorrect format for Created date.") + } + queryUpdates += sep + "created = ?" + sep = ", " + params = append(params, createTime) + } + + // WHERE parameters... + // id = ? + params = append(params, post.ID) + // AND owner_id = ? + authCondition = "(owner_id = ?)" + params = append(params, userID) + + if queryUpdates == "" { + return ErrPostNoUpdatableVals + } + + queryUpdates += sep + "updated = NOW()" + + res, err := db.Exec("UPDATE posts SET "+queryUpdates+" WHERE id = ? AND "+authCondition, params...) + if err != nil { + log.Error("Unable to update owned post: %v", err) + return err + } + + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + // Show the correct error message if nothing was updated + var dummy int + err := db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND "+authCondition, post.ID, params[len(params)-1]).Scan(&dummy) + switch { + case err == sql.ErrNoRows: + return ErrUnauthorizedEditPost + case err != nil: + log.Error("Failed selecting from posts: %v", err) + } + return nil + } + + return nil +} + +func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Collection, error) { + 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) + + err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &format, &c.OwnerID, &c.Visibility, &customHandle, &c.Views) + switch { + case err == sql.ErrNoRows: + return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} + case err != nil: + 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 + + return c, nil +} + +func (db *datastore) GetCollection(alias string) (*Collection, error) { + return db.GetCollectionBy("alias = ?", alias) +} + +func (db *datastore) GetCollectionForPad(alias string) (*Collection, error) { + c := &Collection{Alias: alias} + + row := db.QueryRow("SELECT id, alias, title, description, privacy FROM collections WHERE alias = ?", alias) + + err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility) + switch { + case err == sql.ErrNoRows: + return c, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} + case err != nil: + log.Error("Failed selecting from collections: %v", err) + return c, ErrInternalGeneral + } + c.Public = c.IsPublic() + + return c, nil +} + +func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) { + return db.GetCollectionBy("host = ?", host) +} + +func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) error { + 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") + + if c.Format != nil { + cf := &CollectionFormat{Format: c.Format.String} + if cf.Valid() { + q.SetNullString(c.Format, "format") + } + } + + var updatePass bool + if c.Visibility != nil && (collVisibility(*c.Visibility)&CollProtected == 0 || c.Pass != "") { + q.SetIntPtr(c.Visibility, "privacy") + if c.Pass != "" { + updatePass = true + } + } + + // WHERE values + q.Where("alias = ? AND owner_id = ?", alias, c.OwnerID) + + if q.Updates == "" { + return ErrPostNoUpdatableVals + } + + // 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."} + } + + // Update MathJax value + if c.MathJax { + _, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = ?", collID, "render_mathjax", "1", "1") + if err != nil { + log.Error("Unable to insert render_mathjax value: %v", err) + return err + } + } else { + _, err = db.Exec("DELETE FROM collectionattributes WHERE collection_id = ? AND attribute = ?", collID, "render_mathjax") + if err != nil { + log.Error("Unable to delete render_mathjax value: %v", err) + return err + } + } + + 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 { + log.Error("Unable to update collection: %v", err) + return err + } + + rowsAffected, _ = res.RowsAffected() + if !changed || rowsAffected == 0 { + // Show the correct error message if nothing was updated + var dummy int + err := db.QueryRow("SELECT 1 FROM collections WHERE alias = ? AND owner_id = ?", alias, c.OwnerID).Scan(&dummy) + switch { + case err == sql.ErrNoRows: + return ErrUnauthorizedEditPost + case err != nil: + log.Error("Failed selecting from collections: %v", err) + } + if !updatePass { + return nil + } + } + + if updatePass { + hashedPass, err := auth.HashPass([]byte(c.Pass)) + if err != nil { + log.Error("Unable to create hash: %s", err) + return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} + } + _, err = db.Exec("INSERT INTO collectionpasswords (collection_id, password) VALUES ((SELECT id FROM collections WHERE alias = ?), ?) ON DUPLICATE KEY UPDATE password = ?", alias, hashedPass, hashedPass) + if err != nil { + return err + } + } + + 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 +// edit token is valid for the post. +func (db *datastore) GetEditablePost(id, editToken string) (*PublicPost, error) { + // FIXME: code duplicated from getPost() + // TODO: add slight logic difference to getPost / one func + var ownerName sql.NullString + p := &Post{} + + row := db.QueryRow("SELECT "+postCols+", (SELECT username FROM users WHERE users.id = posts.owner_id) AS username FROM posts WHERE id = ? LIMIT 1", id) + err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName) + switch { + case err == sql.ErrNoRows: + return nil, ErrPostNotFound + case err != nil: + log.Error("Failed selecting from collections: %v", err) + return nil, err + } + + if p.Content == "" { + return nil, ErrPostUnpublished + } + + res := p.processPost() + if ownerName.Valid { + res.Owner = &PublicUser{Username: ownerName.String} + } + + return &res, nil +} + +func (db *datastore) PostIDExists(id string) bool { + var dummy bool + err := db.QueryRow("SELECT 1 FROM posts WHERE id = ?", id).Scan(&dummy) + return err == nil && dummy +} + +// GetPost gets a public-facing post object from the database. If collectionID +// is > 0, the post will be retrieved by slug and collection ID, rather than +// post ID. +// TODO: break this into two functions: +// - GetPost(id string) +// - GetCollectionPost(slug string, collectionID int64) +func (db *datastore) GetPost(id string, collectionID int64) (*PublicPost, error) { + var ownerName sql.NullString + p := &Post{} + + var row *sql.Row + var where string + params := []interface{}{id} + if collectionID > 0 { + where = "slug = ? AND collection_id = ?" + params = append(params, collectionID) + } else { + where = "id = ?" + } + row = db.QueryRow("SELECT "+postCols+", (SELECT username FROM users WHERE users.id = posts.owner_id) AS username FROM posts WHERE "+where+" LIMIT 1", params...) + err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName) + switch { + case err == sql.ErrNoRows: + if collectionID > 0 { + return nil, ErrCollectionPageNotFound + } + return nil, ErrPostNotFound + case err != nil: + log.Error("Failed selecting from collections: %v", err) + return nil, err + } + + if p.Content == "" { + return nil, ErrPostUnpublished + } + + res := p.processPost() + if ownerName.Valid { + res.Owner = &PublicUser{Username: ownerName.String} + } + + return &res, nil +} + +// TODO: don't duplicate getPost() functionality +func (db *datastore) GetOwnedPost(id string, ownerID int64) (*PublicPost, error) { + p := &Post{} + + var row *sql.Row + where := "id = ? AND owner_id = ?" + params := []interface{}{id, ownerID} + row = db.QueryRow("SELECT "+postCols+" FROM posts WHERE "+where+" LIMIT 1", params...) + err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content) + switch { + case err == sql.ErrNoRows: + return nil, ErrPostNotFound + case err != nil: + log.Error("Failed selecting from collections: %v", err) + return nil, err + } + + if p.Content == "" { + return nil, ErrPostUnpublished + } + + res := p.processPost() + + return &res, nil +} + +func (db *datastore) GetPostProperty(id string, collectionID int64, property string) (interface{}, error) { + propSelects := map[string]string{ + "views": "view_count AS views", + } + selectQuery, ok := propSelects[property] + if !ok { + return nil, impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid property: %s.", property)} + } + + var res interface{} + var row *sql.Row + if collectionID != 0 { + row = db.QueryRow("SELECT "+selectQuery+" FROM posts WHERE slug = ? AND collection_id = ? LIMIT 1", id, collectionID) + } else { + row = db.QueryRow("SELECT "+selectQuery+" FROM posts WHERE id = ? LIMIT 1", id) + } + err := row.Scan(&res) + switch { + case err == sql.ErrNoRows: + return nil, impart.HTTPError{http.StatusNotFound, "Post not found."} + case err != nil: + log.Error("Failed selecting post: %v", err) + return nil, err + } + + return res, nil +} + +// GetPostsCount modifies the CollectionObj to include the correct number of +// standard (non-pinned) posts. It will return future posts if `includeFuture` +// is true. +func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) { + var count int64 + timeCondition := "" + if !includeFuture { + timeCondition = "AND created <= NOW()" + } + err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE collection_id = ? AND pinned_position IS NULL "+timeCondition, c.ID).Scan(&count) + switch { + case err == sql.ErrNoRows: + c.TotalPosts = 0 + case err != nil: + log.Error("Failed selecting from collections: %v", err) + c.TotalPosts = 0 + } + + c.TotalPosts = int(count) +} + +// GetPosts retrieves all standard (non-pinned) posts for the given Collection. +// It will return future posts if `includeFuture` is true. +// TODO: change includeFuture to isOwner, since that's how it's used +func (db *datastore) GetPosts(c *Collection, page int, includeFuture bool) (*[]PublicPost, error) { + collID := c.ID + + cf := c.NewFormat() + order := "DESC" + if cf.Ascending() { + order = "ASC" + } + + pagePosts := cf.PostsPerPage() + start := page*pagePosts - pagePosts + if page == 0 { + start = 0 + pagePosts = 1000 + } + + limitStr := "" + if page > 0 { + limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) + } + timeCondition := "" + if !includeFuture { + timeCondition = "AND created <= NOW()" + } + rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND pinned_position IS NULL "+timeCondition+" ORDER BY created "+order+limitStr, collID) + if err != nil { + log.Error("Failed selecting from posts: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts."} + } + defer rows.Close() + + // TODO: extract this common row scanning logic for queries using `postCols` + posts := []PublicPost{} + for rows.Next() { + p := &Post{} + err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content) + if err != nil { + log.Error("Failed scanning row: %v", err) + break + } + p.extractData() + p.formatContent(c, includeFuture) + + posts = append(posts, p.processPost()) + } + err = rows.Err() + if err != nil { + log.Error("Error after Next() on rows: %v", err) + } + + return &posts, nil +} + +// GetPostsTagged retrieves all posts on the given Collection that contain the +// given tag. +// It will return future posts if `includeFuture` is true. +// TODO: change includeFuture to isOwner, since that's how it's used +func (db *datastore) GetPostsTagged(c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) { + collID := c.ID + + cf := c.NewFormat() + order := "DESC" + if cf.Ascending() { + order = "ASC" + } + + pagePosts := cf.PostsPerPage() + start := page*pagePosts - pagePosts + if page == 0 { + start = 0 + pagePosts = 1000 + } + + limitStr := "" + if page > 0 { + limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) + } + timeCondition := "" + if !includeFuture { + timeCondition = "AND created <= NOW()" + } + rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND LOWER(content) RLIKE ? "+timeCondition+" ORDER BY created "+order+limitStr, collID, "#"+strings.ToLower(tag)+"[[:>:]]") + if err != nil { + log.Error("Failed selecting from posts: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts."} + } + defer rows.Close() + + // TODO: extract this common row scanning logic for queries using `postCols` + posts := []PublicPost{} + for rows.Next() { + p := &Post{} + err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content) + if err != nil { + log.Error("Failed scanning row: %v", err) + break + } + p.extractData() + p.formatContent(c, includeFuture) + + posts = append(posts, p.processPost()) + } + err = rows.Err() + if err != nil { + log.Error("Error after Next() on rows: %v", err) + } + + return &posts, nil +} + +func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) { + rows, err := db.Query("SELECT actor_id, inbox, shared_inbox FROM remotefollows f INNER JOIN remoteusers u ON f.remote_user_id = u.id WHERE collection_id = ?", c.ID) + if err != nil { + log.Error("Failed selecting from followers: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve followers."} + } + defer rows.Close() + + followers := []RemoteUser{} + for rows.Next() { + f := RemoteUser{} + err = rows.Scan(&f.ActorID, &f.Inbox, &f.SharedInbox) + followers = append(followers, f) + } + return &followers, nil +} + +// CanCollect returns whether or not the given user can add the given post to a +// collection. This is true when a post is already owned by the user. +// NOTE: this is currently only used to potentially add owned posts to a +// collection. This has the SIDE EFFECT of also generating a slug for the post. +// FIXME: make this side effect more explicit (or extract it) +func (db *datastore) CanCollect(cpr *ClaimPostRequest, userID int64) bool { + var title, content string + var lang sql.NullString + err := db.QueryRow("SELECT title, content, language FROM posts WHERE id = ? AND owner_id = ?", cpr.ID, userID).Scan(&title, &content, &lang) + switch { + case err == sql.ErrNoRows: + return false + case err != nil: + log.Error("Failed on post CanCollect(%s, %d): %v", cpr.ID, userID, err) + return false + } + + // Since we have the post content and the post is collectable, generate the + // post's slug now. + cpr.Slug = getSlugFromPost(title, content, lang.String) + + return true +} + +func (db *datastore) AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error) { + qRes, err := db.Exec(query, params...) + if err != nil { + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + if mysqlErr.Number == mySQLErrDuplicateKey && slugIdx > -1 { + s := id.GenSafeUniqueSlug(p.Slug) + if s == p.Slug { + // Sanity check to prevent infinite recursion + return qRes, fmt.Errorf("GenSafeUniqueSlug generated nothing unique: %s", s) + } + p.Slug = s + params[slugIdx] = p.Slug + return db.AttemptClaim(p, query, params, slugIdx) + } + } + return qRes, fmt.Errorf("attemptClaim: %s", err) + } + return qRes, nil +} + +func (db *datastore) DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error) { + postClaimReqs := map[string]bool{} + res := []ClaimPostResult{} + for i := range postIDs { + postID := postIDs[i] + + r := ClaimPostResult{Code: 0, ErrorMessage: ""} + + // Perform post validation + if postID == "" { + r.ErrorMessage = "Missing post ID. " + } + if _, ok := postClaimReqs[postID]; ok { + r.Code = 429 + r.ErrorMessage = "You've already tried anonymizing this post." + r.ID = postID + res = append(res, r) + continue + } + postClaimReqs[postID] = true + + var err error + // Get full post information to return + var fullPost *PublicPost + fullPost, err = db.GetPost(postID, 0) + if err != nil { + if err, ok := err.(impart.HTTPError); ok { + r.Code = err.Status + r.ErrorMessage = err.Message + r.ID = postID + res = append(res, r) + continue + } else { + log.Error("Error getting post in dispersePosts: %v", err) + } + } + if fullPost.OwnerID.Int64 != userID { + r.Code = http.StatusConflict + r.ErrorMessage = "Post is already owned by someone else." + r.ID = postID + res = append(res, r) + continue + } + + var qRes sql.Result + var query string + var params []interface{} + // Do AND owner_id = ? for sanity. + // This should've been caught and returned with a good error message + // just above. + query = "UPDATE posts SET collection_id = NULL WHERE id = ? AND owner_id = ?" + params = []interface{}{postID, userID} + qRes, err = db.Exec(query, params...) + if err != nil { + r.Code = http.StatusInternalServerError + r.ErrorMessage = "A glitch happened on our end." + r.ID = postID + res = append(res, r) + log.Error("dispersePosts (post %s): %v", postID, err) + continue + } + + // Post was successfully dispersed + r.Code = http.StatusOK + r.Post = fullPost + + rowsAffected, _ := qRes.RowsAffected() + if rowsAffected == 0 { + // This was already claimed, but return 200 + r.Code = http.StatusOK + } + res = append(res, r) + } + + return &res, nil +} + +func (db *datastore) ClaimPosts(userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) { + postClaimReqs := map[string]bool{} + res := []ClaimPostResult{} + postCollAlias := collAlias + for i := range *posts { + p := (*posts)[i] + if &p == nil { + continue + } + + r := ClaimPostResult{Code: 0, ErrorMessage: ""} + + // Perform post validation + if p.ID == "" { + r.ErrorMessage = "Missing post ID `id`. " + } + if _, ok := postClaimReqs[p.ID]; ok { + r.Code = 429 + r.ErrorMessage = "You've already tried claiming this post." + r.ID = p.ID + res = append(res, r) + continue + } + postClaimReqs[p.ID] = true + + canCollect := db.CanCollect(&p, userID) + if !canCollect && p.Token == "" { + // TODO: ensure post isn't owned by anyone else when a valid modify + // token is given. + r.ErrorMessage += "Missing post Edit Token `token`." + } + if r.ErrorMessage != "" { + // Post validate failed + r.Code = http.StatusBadRequest + r.ID = p.ID + res = append(res, r) + continue + } + + var err error + var qRes sql.Result + var query string + var params []interface{} + var slugIdx int = -1 + if collAlias == "" { + // Posts are being claimed at /posts/claim, not + // /collections/{alias}/collect, so use given individual collection + // to associate post with. + postCollAlias = p.CollectionAlias + } + if postCollAlias != "" { + // Associate this post with a collection + var coll *Collection + if p.CreateCollection { + // This is a new collection + // TODO: consider removing this. This seriously complicates this + // method and adds another (unnecessary?) logic path. + coll, err = db.CreateCollection(postCollAlias, "", userID) + if err != nil { + if err, ok := err.(impart.HTTPError); ok { + r.Code = err.Status + r.ErrorMessage = err.Message + } else { + r.Code = http.StatusInternalServerError + r.ErrorMessage = "Unknown error occurred creating collection" + } + r.ID = p.ID + res = append(res, r) + continue + } + } else { + // Attempt to add to existing collection + coll, err = db.GetCollection(postCollAlias) + if err != nil { + if err, ok := err.(impart.HTTPError); ok { + if err.Status == http.StatusNotFound { + // Show obfuscated "forbidden" response, as if attempting to add to an + // unowned blog. + r.Code = ErrForbiddenCollection.Status + r.ErrorMessage = ErrForbiddenCollection.Message + } else { + r.Code = err.Status + r.ErrorMessage = err.Message + } + } else { + r.Code = http.StatusInternalServerError + r.ErrorMessage = "Unknown error occurred claiming post with collection" + } + r.ID = p.ID + res = append(res, r) + continue + } + if coll.OwnerID != userID { + r.Code = ErrForbiddenCollection.Status + r.ErrorMessage = ErrForbiddenCollection.Message + r.ID = p.ID + res = append(res, r) + continue + } + } + if p.Slug == "" { + p.Slug = p.ID + } + if canCollect { + // User already owns this post, so just add it to the given + // collection. + query = "UPDATE posts SET collection_id = ?, slug = ? WHERE id = ? AND owner_id = ?" + params = []interface{}{coll.ID, p.Slug, p.ID, userID} + slugIdx = 1 + } else { + query = "UPDATE posts SET owner_id = ?, collection_id = ?, slug = ? WHERE id = ? AND modify_token = ? AND owner_id IS NULL" + params = []interface{}{userID, coll.ID, p.Slug, p.ID, p.Token} + slugIdx = 2 + } + } else { + query = "UPDATE posts SET owner_id = ? WHERE id = ? AND modify_token = ? AND owner_id IS NULL" + params = []interface{}{userID, p.ID, p.Token} + } + 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.ID = p.ID + res = append(res, r) + log.Error("claimPosts (post %s): %v", p.ID, err) + continue + } + + // Get full post information to return + var fullPost *PublicPost + if p.Token != "" { + fullPost, err = db.GetEditablePost(p.ID, p.Token) + } else { + fullPost, err = db.GetPost(p.ID, 0) + } + if err != nil { + if err, ok := err.(impart.HTTPError); ok { + r.Code = err.Status + r.ErrorMessage = err.Message + r.ID = p.ID + res = append(res, r) + continue + } + } + if fullPost.OwnerID.Int64 != userID { + r.Code = http.StatusConflict + r.ErrorMessage = "Post is already owned by someone else." + r.ID = p.ID + res = append(res, r) + continue + } + + // Post was successfully claimed + r.Code = http.StatusOK + r.Post = fullPost + + rowsAffected, _ := qRes.RowsAffected() + if rowsAffected == 0 { + // This was already claimed, but return 200 + r.Code = http.StatusOK + } + res = append(res, r) + } + + return &res, nil +} + +func (db *datastore) UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error { + if pos <= 0 || pos > 20 { + pos = db.GetLastPinnedPostPos(collID) + 1 + if pos == -1 { + pos = 1 + } + } + var err error + if pinned { + _, err = db.Exec("UPDATE posts SET pinned_position = ? WHERE id = ?", pos, postID) + } else { + _, err = db.Exec("UPDATE posts SET pinned_position = NULL WHERE id = ?", postID) + } + if err != nil { + log.Error("Unable to update pinned post: %v", err) + return err + } + return nil +} + +func (db *datastore) GetLastPinnedPostPos(collID int64) int64 { + var lastPos sql.NullInt64 + err := db.QueryRow("SELECT MAX(pinned_position) FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL", collID).Scan(&lastPos) + switch { + case err == sql.ErrNoRows: + return -1 + case err != nil: + log.Error("Failed selecting from posts: %v", err) + return -1 + } + if !lastPos.Valid { + return -1 + } + return lastPos.Int64 +} + +func (db *datastore) GetPinnedPosts(coll *CollectionObj) (*[]PublicPost, error) { + rows, err := db.Query("SELECT id, slug, title, LEFT(content, 80), pinned_position FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL ORDER BY pinned_position ASC", coll.ID) + if err != nil { + log.Error("Failed selecting pinned posts: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve pinned posts."} + } + defer rows.Close() + + posts := []PublicPost{} + for rows.Next() { + p := &Post{} + err = rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Content, &p.PinnedPosition) + if err != nil { + log.Error("Failed scanning row: %v", err) + break + } + p.extractData() + + pp := p.processPost() + pp.Collection = coll + posts = append(posts, pp) + } + return &posts, nil +} + +func (db *datastore) GetCollections(u *User) (*[]Collection, error) { + rows, err := db.Query("SELECT id, alias, title, privacy, view_count FROM collections LEFT JOIN domains ON id = collection_id WHERE owner_id = ? ORDER BY id ASC", u.ID) + if err != nil { + log.Error("Failed selecting from collections: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user collections."} + } + 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.Visibility, &c.Views) + if err != nil { + 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() + + colls = append(colls, c) + } + err = rows.Err() + if err != nil { + log.Error("Error after Next() on rows: %v", err) + } + + return &colls, nil +} + +func (db *datastore) GetPublishableCollections(u *User) (*[]Collection, error) { + c, err := db.GetCollections(u) + if err != nil { + return nil, err + } + + if len(*c) == 0 { + return nil, impart.HTTPError{http.StatusInternalServerError, "You don't seem to have any blogs; they might've moved to another account. Try logging out and logging into your other account."} + } + return c, nil +} + +func (db *datastore) GetMeStats(u *User) userMeStats { + s := userMeStats{} + + // User counts + colls, _ := db.GetUserCollectionCount(u.ID) + s.TotalCollections = colls + + var articles, collPosts uint64 + err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ? AND collection_id IS NULL", u.ID).Scan(&articles) + if err != nil && err != sql.ErrNoRows { + log.Error("Couldn't get articles count for user %d: %v", u.ID, err) + } + s.TotalArticles = articles + + err = db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ? AND collection_id IS NOT NULL", u.ID).Scan(&collPosts) + if err != nil && err != sql.ErrNoRows { + log.Error("Couldn't get coll posts count for user %d: %v", u.ID, err) + } + s.CollectionPosts = collPosts + + return s +} + +func (db *datastore) GetTopPosts(u *User, alias string) (*[]PublicPost, error) { + params := []interface{}{u.ID} + where := "" + if alias != "" { + where = " AND alias = ?" + params = append(params, alias) + } + rows, err := db.Query("SELECT p.id, p.slug, p.view_count, p.title, c.alias, c.title, c.description, c.view_count FROM posts p LEFT JOIN collections c ON p.collection_id = c.id WHERE p.owner_id = ?"+where+" ORDER BY p.view_count DESC, created DESC LIMIT 25", params...) + if err != nil { + log.Error("Failed selecting from posts: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user top posts."} + } + defer rows.Close() + + posts := []PublicPost{} + var gotErr bool + for rows.Next() { + p := Post{} + c := Collection{} + var alias, title, description sql.NullString + var views sql.NullInt64 + err = rows.Scan(&p.ID, &p.Slug, &p.ViewCount, &p.Title, &alias, &title, &description, &views) + if err != nil { + log.Error("Failed scanning User.getPosts() row: %v", err) + gotErr = true + break + } + p.extractData() + pubPost := p.processPost() + + if alias.Valid && alias.String != "" { + c.Alias = alias.String + c.Title = title.String + c.Description = description.String + c.Views = views.Int64 + pubPost.Collection = &CollectionObj{Collection: c} + } + + posts = append(posts, pubPost) + } + err = rows.Err() + if err != nil { + log.Error("Error after Next() on rows: %v", err) + } + + if gotErr && len(posts) == 0 { + // There were a lot of errors + return nil, impart.HTTPError{http.StatusInternalServerError, "Unable to get data."} + } + + return &posts, nil +} + +func (db *datastore) GetAnonymousPosts(u *User) (*[]PublicPost, error) { + rows, err := db.Query("SELECT id, view_count, title, created, updated, content FROM posts WHERE owner_id = ? AND collection_id IS NULL ORDER BY created DESC", u.ID) + if err != nil { + log.Error("Failed selecting from posts: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user anonymous posts."} + } + defer rows.Close() + + posts := []PublicPost{} + for rows.Next() { + p := Post{} + err = rows.Scan(&p.ID, &p.ViewCount, &p.Title, &p.Created, &p.Updated, &p.Content) + if err != nil { + log.Error("Failed scanning row: %v", err) + break + } + p.extractData() + + posts = append(posts, p.processPost()) + } + err = rows.Err() + if err != nil { + log.Error("Error after Next() on rows: %v", err) + } + + return &posts, nil +} + +func (db *datastore) GetUserPosts(u *User) (*[]PublicPost, error) { + rows, err := db.Query("SELECT p.id, p.slug, p.view_count, p.title, p.created, p.updated, p.content, p.text_appearance, p.language, p.rtl, c.alias, c.title, c.description, c.view_count FROM posts p LEFT JOIN collections c ON collection_id = c.id WHERE p.owner_id = ? ORDER BY created ASC", u.ID) + if err != nil { + log.Error("Failed selecting from posts: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user posts."} + } + defer rows.Close() + + posts := []PublicPost{} + var gotErr bool + for rows.Next() { + p := Post{} + c := Collection{} + var alias, title, description sql.NullString + var views sql.NullInt64 + err = rows.Scan(&p.ID, &p.Slug, &p.ViewCount, &p.Title, &p.Created, &p.Updated, &p.Content, &p.Font, &p.Language, &p.RTL, &alias, &title, &description, &views) + if err != nil { + log.Error("Failed scanning User.getPosts() row: %v", err) + gotErr = true + break + } + p.extractData() + pubPost := p.processPost() + + if alias.Valid && alias.String != "" { + c.Alias = alias.String + c.Title = title.String + c.Description = description.String + c.Views = views.Int64 + pubPost.Collection = &CollectionObj{Collection: c} + } + + posts = append(posts, pubPost) + } + err = rows.Err() + if err != nil { + log.Error("Error after Next() on rows: %v", err) + } + + if gotErr && len(posts) == 0 { + // There were a lot of errors + return nil, impart.HTTPError{http.StatusInternalServerError, "Unable to get data."} + } + + return &posts, nil +} + +// ChangeSettings takes a User and applies the changes in the given +// userSettings, MODIFYING THE USER with successful changes. +func (db *datastore) ChangeSettings(app *app, u *User, s *userSettings) error { + var errPass error + q := query.NewUpdate() + + // Update email if given + if s.Email != "" { + encEmail, err := data.Encrypt(app.keys.emailKey, s.Email) + if err != nil { + log.Error("Couldn't encrypt email %s: %s\n", s.Email, err) + return impart.HTTPError{http.StatusInternalServerError, "Unable to encrypt email address."} + } + q.SetBytes(encEmail, "email") + + // Update the email if something goes awry updating the password + defer func() { + if errPass != nil { + db.UpdateEncryptedUserEmail(u.ID, encEmail) + } + }() + u.Email = zero.StringFrom(s.Email) + } + + // Update username if given + var newUsername string + if s.Username != "" { + var ie *impart.HTTPError + newUsername, ie = getValidUsername(app, s.Username, u.Username) + if ie != nil { + // Username is invalid + return *ie + } + if !author.IsValidUsername(app.cfg, newUsername) { + // Ensure the username is syntactically correct. + return impart.HTTPError{http.StatusPreconditionFailed, "Username isn't valid."} + } + + t, err := db.Begin() + if err != nil { + log.Error("Couldn't start username change transaction: %v", err) + return err + } + + _, err = t.Exec("UPDATE users SET username = ? WHERE id = ?", newUsername, u.ID) + if err != nil { + t.Rollback() + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + if mysqlErr.Number == mySQLErrDuplicateKey { + return impart.HTTPError{http.StatusConflict, "Username is already taken."} + } + } + log.Error("Unable to update users table: %v", err) + return ErrInternalGeneral + } + + _, err = t.Exec("UPDATE collections SET alias = ? WHERE alias = ? AND owner_id = ?", newUsername, u.Username, u.ID) + if err != nil { + t.Rollback() + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + if mysqlErr.Number == mySQLErrDuplicateKey { + return impart.HTTPError{http.StatusConflict, "Username is already taken."} + } + } + log.Error("Unable to update collection: %v", err) + return ErrInternalGeneral + } + + // Keep track of name changes for redirection + db.RemoveCollectionRedirect(t, newUsername) + _, err = t.Exec("UPDATE collectionredirects SET new_alias = ? WHERE new_alias = ?", newUsername, u.Username) + if err != nil { + log.Error("Unable to update collectionredirects: %v", err) + } + _, err = t.Exec("INSERT INTO collectionredirects (prev_alias, new_alias) VALUES (?, ?)", u.Username, newUsername) + if err != nil { + log.Error("Unable to add new collectionredirect: %v", err) + } + + err = t.Commit() + if err != nil { + t.Rollback() + log.Error("Rolling back after Commit(): %v\n", err) + return err + } + + u.Username = newUsername + } + + // Update passphrase if given + if s.NewPass != "" { + // Check if user has already set a password + var err error + u.HasPass, err = db.IsUserPassSet(u.ID) + if err != nil { + errPass = impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data."} + return errPass + } + + if u.HasPass { + // Check if currently-set password is correct + hashedPass := u.HashedPass + if len(hashedPass) == 0 { + authUser, err := db.GetUserForAuthByID(u.ID) + if err != nil { + errPass = err + return errPass + } + hashedPass = authUser.HashedPass + } + if !auth.Authenticated(hashedPass, []byte(s.OldPass)) { + errPass = impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} + return errPass + } + } + hashedPass, err := auth.HashPass([]byte(s.NewPass)) + if err != nil { + errPass = impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} + return errPass + } + q.SetBytes(hashedPass, "password") + } + + // WHERE values + q.Append(u.ID) + + if q.Updates == "" { + if s.Username == "" { + return ErrPostNoUpdatableVals + } + + // Nothing to update except username. That was successful, so return now. + return nil + } + + res, err := db.Exec("UPDATE users SET "+q.Updates+" WHERE id = ?", q.Params...) + if err != nil { + log.Error("Unable to update collection: %v", err) + return err + } + + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + // Show the correct error message if nothing was updated + var dummy int + err := db.QueryRow("SELECT 1 FROM users WHERE id = ?", u.ID).Scan(&dummy) + switch { + case err == sql.ErrNoRows: + return ErrUnauthorizedGeneral + case err != nil: + log.Error("Failed selecting from users: %v", err) + } + return nil + } + + if s.NewPass != "" && !u.HasPass { + u.HasPass = true + } + + return nil +} + +func (db *datastore) ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error { + var dbPass []byte + err := db.QueryRow("SELECT password FROM users WHERE id = ?", userID).Scan(&dbPass) + switch { + case err == sql.ErrNoRows: + return ErrUserNotFound + case err != nil: + log.Error("Couldn't SELECT user password for change: %v", err) + return err + } + + if !sudo && !auth.Authenticated(dbPass, []byte(curPass)) { + return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} + } + + _, err = db.Exec("UPDATE users SET password = ? WHERE id = ?", hashedPass, userID) + if err != nil { + log.Error("Could not update passphrase: %v", err) + return err + } + + return nil +} + +func (db *datastore) RemoveCollectionRedirect(t *sql.Tx, alias string) error { + _, err := t.Exec("DELETE FROM collectionredirects WHERE prev_alias = ?", alias) + if err != nil { + log.Error("Unable to delete from collectionredirects: %v", err) + return err + } + return nil +} + +func (db *datastore) GetCollectionRedirect(alias string) (new string) { + row := db.QueryRow("SELECT new_alias FROM collectionredirects WHERE prev_alias = ?", alias) + err := row.Scan(&new) + if err != nil && err != sql.ErrNoRows { + log.Error("Failed selecting from collectionredirects: %v", err) + } + return +} + +func (db *datastore) DeleteCollection(alias string, userID int64) error { + c := &Collection{Alias: alias} + var username string + + row := db.QueryRow("SELECT username FROM users WHERE id = ?", userID) + err := row.Scan(&username) + if err != nil { + return err + } + + // Ensure user isn't deleting their main blog + if alias == username { + return impart.HTTPError{http.StatusForbidden, "You cannot currently delete your primary blog."} + } + + row = db.QueryRow("SELECT id FROM collections WHERE alias = ? AND owner_id = ?", alias, userID) + err = row.Scan(&c.ID) + switch { + case err == sql.ErrNoRows: + return impart.HTTPError{http.StatusNotFound, "Collection doesn't exist or you're not allowed to delete it."} + case err != nil: + log.Error("Failed selecting from collections: %v", err) + return ErrInternalGeneral + } + + t, err := db.Begin() + if err != nil { + return err + } + + // Float all collection's posts + _, err = t.Exec("UPDATE posts SET collection_id = NULL WHERE collection_id = ? AND owner_id = ?", c.ID, userID) + if err != nil { + t.Rollback() + return err + } + + // Remove redirects to or from this collection + _, err = t.Exec("DELETE FROM collectionredirects WHERE prev_alias = ? OR new_alias = ?", alias, alias) + if err != nil { + t.Rollback() + return err + } + + // Remove any optional collection password + _, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID) + if err != nil { + t.Rollback() + return err + } + + // Finally, delete collection itself + _, err = t.Exec("DELETE FROM collections WHERE id = ?", c.ID) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} + +func (db *datastore) IsCollectionAttributeOn(id int64, attr string) bool { + var v string + err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&v) + switch { + case err == sql.ErrNoRows: + return false + case err != nil: + log.Error("Couldn't SELECT value in isCollectionAttributeOn for attribute '%s': %v", attr, err) + return false + } + return v == "1" +} + +func (db *datastore) CollectionHasAttribute(id int64, attr string) bool { + var dummy string + err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&dummy) + switch { + case err == sql.ErrNoRows: + return false + case err != nil: + log.Error("Couldn't SELECT value in collectionHasAttribute for attribute '%s': %v", attr, err) + return false + } + return true +} + +func (db *datastore) DeleteAccount(userID int64) (l *string, err error) { + debug := "" + l = &debug + + t, err := db.Begin() + if err != nil { + stringLogln(l, "Unable to begin: %v", err) + return + } + + // Get all collections + rows, err := db.Query("SELECT id, alias FROM collections WHERE owner_id = ?", userID) + if err != nil { + t.Rollback() + stringLogln(l, "Unable to get collections: %v", err) + return + } + defer rows.Close() + colls := []Collection{} + var c Collection + for rows.Next() { + err = rows.Scan(&c.ID, &c.Alias) + if err != nil { + t.Rollback() + stringLogln(l, "Unable to scan collection cols: %v", err) + return + } + colls = append(colls, c) + } + + var res sql.Result + for _, c := range colls { + // TODO: user deleteCollection() func + // Delete tokens + res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID) + if err != nil { + t.Rollback() + stringLogln(l, "Unable to delete attributes on %s: %v", c.Alias, err) + return + } + 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 { + t.Rollback() + stringLogln(l, "Unable to delete passwords on %s: %v", c.Alias, err) + return + } + rs, _ = res.RowsAffected() + stringLogln(l, "Deleted %d for %s from collectionpasswords", rs, c.Alias) + + // Remove redirects to this collection + res, err = t.Exec("DELETE FROM collectionredirects WHERE new_alias = ?", c.Alias) + if err != nil { + t.Rollback() + stringLogln(l, "Unable to delete redirects on %s: %v", c.Alias, err) + return + } + 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 + res, err = t.Exec("DELETE FROM collections WHERE owner_id = ?", userID) + if err != nil { + t.Rollback() + stringLogln(l, "Unable to delete collections: %v", err) + return + } + rs, _ := res.RowsAffected() + stringLogln(l, "Deleted %d from collections", rs) + + // Delete tokens + res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID) + if err != nil { + t.Rollback() + stringLogln(l, "Unable to delete access tokens: %v", err) + return + } + rs, _ = res.RowsAffected() + stringLogln(l, "Deleted %d from accesstokens", rs) + + // Delete posts + res, err = t.Exec("DELETE FROM posts WHERE owner_id = ?", userID) + if err != nil { + t.Rollback() + stringLogln(l, "Unable to delete posts: %v", err) + return + } + rs, _ = res.RowsAffected() + stringLogln(l, "Deleted %d from posts", rs) + + res, err = t.Exec("DELETE FROM userattributes WHERE user_id = ?", userID) + if err != nil { + t.Rollback() + stringLogln(l, "Unable to delete attributes: %v", err) + return + } + rs, _ = res.RowsAffected() + stringLogln(l, "Deleted %d from userattributes", rs) + + res, err = t.Exec("DELETE FROM users WHERE id = ?", userID) + if err != nil { + t.Rollback() + stringLogln(l, "Unable to delete user: %v", err) + return + } + rs, _ = res.RowsAffected() + stringLogln(l, "Deleted %d from users", rs) + + err = t.Commit() + if err != nil { + t.Rollback() + stringLogln(l, "Unable to commit: %v", err) + return + } + + return +} + +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) + 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) + 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) + return nil, nil + } + + return pub, priv +} + +func stringLogln(log *string, s string, v ...interface{}) { + *log += fmt.Sprintf(s+"\n", v...) +} + +func handleFailedPostInsert(err error) error { + log.Error("Couldn't insert into posts: %v", err) + return err +} diff --git a/errors.go b/errors.go index 28819fb..1cc6cc2 100644 --- a/errors.go +++ b/errors.go @@ -7,5 +7,24 @@ import ( // Commonly returned HTTP errors var ( - ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."} + ErrBadAccessToken = impart.HTTPError{http.StatusUnauthorized, "Invalid access token."} + ErrNoAccessToken = impart.HTTPError{http.StatusBadRequest, "Authorization token required."} + + 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."} + + ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."} + + ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."} + ErrPostNotFound = impart.HTTPError{Status: http.StatusNotFound, Message: "Post not found."} + 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."} +) + +// Post operation errors +var ( + ErrPostNoUpdatableVals = impart.HTTPError{http.StatusBadRequest, "Supply some properties to update."} ) diff --git a/postrender.go b/postrender.go new file mode 100644 index 0000000..71fd1e4 --- /dev/null +++ b/postrender.go @@ -0,0 +1,135 @@ +package writefreely + +import ( + "bytes" + "github.com/microcosm-cc/bluemonday" + stripmd "github.com/writeas/go-strip-markdown" + "github.com/writeas/saturday" + "html" + "html/template" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +var ( + blockReg = regexp.MustCompile("<(ul|ol|blockquote)>\n") + endBlockReg = regexp.MustCompile("\n") + youtubeReg = regexp.MustCompile("(https?://www.youtube.com/embed/[a-zA-Z0-9\\-_]+)(\\?[^\t\n\f\r \"']+)?") + titleElementReg = regexp.MustCompile("") + hashtagReg = regexp.MustCompile(`#([\p{L}\p{M}\d]+)`) + markeddownReg = regexp.MustCompile("

(.+)

") +) + +func (p *Post) formatContent(c *Collection, isOwner bool) { + baseURL := c.CanonicalURL() + if isOwner { + baseURL = "/" + c.Alias + "/" + } + newCon := hashtagReg.ReplaceAllFunc([]byte(p.Content), func(b []byte) []byte { + // Ensure we only replace "hashtags" that have already been extracted. + // `hashtagReg` catches everything, including any hash on the end of a + // URL, so we rely on p.Tags as the final word on whether or not to link + // a tag. + for _, t := range p.Tags { + if string(b) == "#"+t { + return bytes.Replace(b, []byte("#"+t), []byte("#"+t+""), -1) + } + } + return b + }) + p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String))) + p.HTMLContent = template.HTML(applyMarkdown([]byte(newCon))) + if exc := strings.Index(string(newCon), ""); exc > -1 { + p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(newCon[:exc]))) + } +} + +func (p *PublicPost) formatContent(isOwner bool) { + p.Post.formatContent(&p.Collection.Collection, isOwner) +} + +func applyMarkdown(data []byte) string { + return applyMarkdownSpecial(data, false) +} + +func applyMarkdownSpecial(data []byte, skipNoFollow bool) string { + mdExtensions := 0 | + blackfriday.EXTENSION_TABLES | + blackfriday.EXTENSION_FENCED_CODE | + blackfriday.EXTENSION_AUTOLINK | + blackfriday.EXTENSION_STRIKETHROUGH | + blackfriday.EXTENSION_SPACE_HEADERS | + blackfriday.EXTENSION_AUTO_HEADER_IDS + htmlFlags := 0 | + blackfriday.HTML_USE_SMARTYPANTS | + blackfriday.HTML_SMARTYPANTS_DASHES + + // Generate Markdown + md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions) + // Strip out bad HTML + policy := getSanitizationPolicy() + policy.RequireNoFollowOnLinks(!skipNoFollow) + outHTML := string(policy.SanitizeBytes(md)) + // Strip newlines on certain block elements that render with them + outHTML = blockReg.ReplaceAllString(outHTML, "<$1>") + outHTML = endBlockReg.ReplaceAllString(outHTML, "") + // Remove all query parameters on YouTube embed links + // TODO: make this more specific. Taking the nuclear approach here to strip ?autoplay=1 + outHTML = youtubeReg.ReplaceAllString(outHTML, "$1") + + return outHTML +} + +func applyBasicMarkdown(data []byte) string { + mdExtensions := 0 | + blackfriday.EXTENSION_STRIKETHROUGH | + blackfriday.EXTENSION_SPACE_HEADERS | + blackfriday.EXTENSION_HEADER_IDS + htmlFlags := 0 | + blackfriday.HTML_SKIP_HTML | + blackfriday.HTML_USE_SMARTYPANTS | + blackfriday.HTML_SMARTYPANTS_DASHES + + // Generate Markdown + md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions) + // Strip out bad HTML + policy := bluemonday.UGCPolicy() + policy.AllowAttrs("class", "id").Globally() + outHTML := string(policy.SanitizeBytes(md)) + outHTML = markeddownReg.ReplaceAllString(outHTML, "$1") + outHTML = strings.TrimRightFunc(outHTML, unicode.IsSpace) + + return outHTML +} + +func postTitle(content, friendlyId string) string { + const maxTitleLen = 80 + + // Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML + // entities added in by sanitizing the content. + content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content)) + + content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace) + eol := strings.IndexRune(content, '\n') + blankLine := strings.Index(content, "\n\n") + if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen { + return strings.TrimSpace(content[:blankLine]) + } else if utf8.RuneCountInString(content) <= maxTitleLen { + return content + } + return friendlyId +} + +func getSanitizationPolicy() *bluemonday.Policy { + policy := bluemonday.UGCPolicy() + policy.AllowAttrs("src", "style").OnElements("iframe", "video") + policy.AllowAttrs("frameborder", "width", "height").Matching(bluemonday.Integer).OnElements("iframe") + policy.AllowAttrs("allowfullscreen").OnElements("iframe") + policy.AllowAttrs("controls", "loop", "muted", "autoplay").OnElements("video") + policy.AllowAttrs("target").OnElements("a") + policy.AllowAttrs("style", "class", "id").Globally() + policy.AllowURLSchemes("http", "https", "mailto", "xmpp") + return policy +} diff --git a/posts.go b/posts.go new file mode 100644 index 0000000..7f12da3 --- /dev/null +++ b/posts.go @@ -0,0 +1,178 @@ +package writefreely + +import ( + "github.com/guregu/null" + "github.com/guregu/null/zero" + "github.com/kylemcc/twitter-text-go/extract" + "github.com/writeas/monday" + "github.com/writeas/slug" + "github.com/writeas/web-core/converter" + "github.com/writeas/web-core/parse" + "github.com/writeas/web-core/tags" + "html/template" + "regexp" + "time" +) + +const ( + // Post ID length bounds + minIDLen = 10 + maxIDLen = 10 + userPostIDLen = 10 + postIDLen = 10 + + postMetaDateFormat = "2006-01-02 15:04:05" +) + +type ( + AuthenticatedPost struct { + ID string `json:"id" schema:"id"` + *SubmittedPost + } + + // SubmittedPost represents a post supplied by a client for publishing or + // updating. Since Title and Content can be updated to "", they are + // pointers that can be easily tested to detect changes. + SubmittedPost struct { + Slug *string `json:"slug" schema:"slug"` + Title *string `json:"title" schema:"title"` + Content *string `json:"body" schema:"body"` + Font string `json:"font" schema:"font"` + IsRTL converter.NullJSONBool `json:"rtl" schema:"rtl"` + Language converter.NullJSONString `json:"lang" schema:"lang"` + Created *string `json:"created" schema:"created"` + + // [{ "medium": "ev" }, { "twitter": "ilikebeans" }] + Crosspost []map[string]string `json:"crosspost" schema:"crosspost"` + } + + // Post represents a post as found in the database. + Post struct { + ID string `db:"id" json:"id"` + Slug null.String `db:"slug" json:"slug,omitempty"` + Font string `db:"text_appearance" json:"appearance"` + Language zero.String `db:"language" json:"language"` + RTL zero.Bool `db:"rtl" json:"rtl"` + Privacy int64 `db:"privacy" json:"-"` + OwnerID null.Int `db:"owner_id" json:"-"` + CollectionID null.Int `db:"collection_id" json:"-"` + PinnedPosition null.Int `db:"pinned_position" json:"-"` + Created time.Time `db:"created" json:"created"` + Updated time.Time `db:"updated" json:"updated"` + ViewCount int64 `db:"view_count" json:"-"` + EmbedViewCount int64 `db:"embed_view_count" json:"-"` + Title zero.String `db:"title" json:"title"` + HTMLTitle template.HTML `db:"title" json:"-"` + Content string `db:"content" json:"body"` + HTMLContent template.HTML `db:"content" json:"-"` + HTMLExcerpt template.HTML `db:"content" json:"-"` + Tags []string `json:"tags"` + Images []string `json:"images,omitempty"` + + OwnerName string `json:"owner,omitempty"` + } + + // PublicPost holds properties for a publicly returned post, i.e. a post in + // a context where the viewer may not be the owner. As such, sensitive + // metadata for the post is hidden and properties supporting the display of + // the post are added. + PublicPost struct { + *Post + IsSubdomain bool `json:"-"` + IsTopLevel bool `json:"-"` + Domain string `json:"-"` + DisplayDate string `json:"-"` + Views int64 `json:"views"` + Owner *PublicUser `json:"-"` + IsOwner bool `json:"-"` + Collection *CollectionObj `json:"collection,omitempty"` + } + + AnonymousAuthPost struct { + ID string `json:"id"` + Token string `json:"token"` + } + ClaimPostRequest struct { + *AnonymousAuthPost + CollectionAlias string `json:"collection"` + CreateCollection bool `json:"create_collection"` + + // Generated properties + Slug string `json:"-"` + } + ClaimPostResult struct { + ID string `json:"id,omitempty"` + Code int `json:"code,omitempty"` + ErrorMessage string `json:"error_msg,omitempty"` + Post *PublicPost `json:"post,omitempty"` + } +) + +func (p *Post) processPost() PublicPost { + res := &PublicPost{Post: p, Views: 0} + res.Views = p.ViewCount + // TODO: move to own function + loc := monday.FuzzyLocale(p.Language.String) + res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc) + + return *res +} + +// TODO: merge this into getSlugFromPost or phase it out +func getSlug(title, lang string) string { + return getSlugFromPost("", title, lang) +} + +func getSlugFromPost(title, body, lang string) string { + if title == "" { + title = postTitle(body, body) + } + title = parse.PostLede(title, false) + // Truncate lede if needed + title, _ = parse.TruncToWord(title, 80) + if lang != "" && len(lang) == 2 { + return slug.MakeLang(title, lang) + } + return slug.Make(title) +} + +// isFontValid returns whether or not the submitted post's appearance is valid. +func (p *SubmittedPost) isFontValid() bool { + validFonts := map[string]bool{ + "norm": true, + "sans": true, + "mono": true, + "wrap": true, + "code": true, + } + + if _, valid := validFonts[p.Font]; valid { + return true + } + return false +} + +func (p *Post) extractData() { + p.Tags = tags.Extract(p.Content) + p.extractImages() +} + +var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg)$`) + +func (p *Post) extractImages() { + matches := extract.ExtractUrls(p.Content) + urls := map[string]bool{} + for i := range matches { + u := matches[i].Text + if !imageURLRegex.MatchString(u) { + continue + } + urls[u] = true + } + + resURLs := make([]string, 0) + for k := range urls { + resURLs = append(resURLs, k) + } + p.Images = resURLs +} diff --git a/routes.go b/routes.go index 17ad73b..545fc78 100644 --- a/routes.go +++ b/routes.go @@ -7,7 +7,7 @@ import ( "strings" ) -func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config) { +func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datastore) { isSingleUser := !cfg.App.MultiUser // Write.as router