diff --git a/activitypub.go b/activitypub.go index e8b67d2..6106cd4 100644 --- a/activitypub.go +++ b/activitypub.go @@ -1,1168 +1,1168 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "bytes" "crypto/sha256" "database/sql" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/http/httputil" "net/url" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/gorilla/mux" "github.com/writeas/activity/streams" "github.com/writeas/activityserve" "github.com/writeas/httpsig" "github.com/writeas/impart" "github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/id" "github.com/writeas/web-core/log" "github.com/writeas/web-core/silobridge" ) const ( // TODO: delete. don't use this! apCustomHandleDefault = "blog" apCacheTime = time.Minute ) var ( apCollectionPostIRIRegex = regexp.MustCompile("/api/collections/([a-z0-9\\-]+)/posts/([a-z0-9\\-]+)$") apDraftPostIRIRegex = regexp.MustCompile("/api/posts/([a-z0-9\\-]+)$") ) var instanceColl *Collection func initActivityPub(app *App) { ur, _ := url.Parse(app.cfg.App.Host) instanceColl = &Collection{ ID: 0, Alias: ur.Host, Title: ur.Host, db: app.db, hostName: app.cfg.App.Host, } } type RemoteUser struct { ID int64 ActorID string Inbox string SharedInbox string URL string Handle string Created time.Time } func (ru *RemoteUser) CreatedFriendly() string { return ru.Created.Format("January 2, 2006") } func (ru *RemoteUser) EstimatedHandle() string { if ru.Handle != "" { return ru.Handle } username := filepath.Base(ru.ActorID) host, _ := url.Parse(ru.ActorID) return username + "@" + host.Host } func (ru *RemoteUser) AsPerson() *activitystreams.Person { return &activitystreams.Person{ BaseObject: activitystreams.BaseObject{ Type: "Person", Context: []interface{}{ activitystreams.Namespace, }, ID: ru.ActorID, }, Inbox: ru.Inbox, Endpoints: activitystreams.Endpoints{ SharedInbox: ru.SharedInbox, }, } } func activityPubClient() *http.Client { return &http.Client{ Timeout: 15 * time.Second, } } func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] if alias == "" { alias = filepath.Base(r.RequestURI) } // TODO: enforce visibility // Get base Collection data var c *Collection var err error if alias == r.Host { c = instanceColl } else if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { return err } c.hostName = app.cfg.App.Host if !c.IsInstanceColl() { silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection activities: %v", err) return ErrInternalGeneral } if silenced { return ErrCollectionNotFound } } p := c.PersonObject() setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, p, http.StatusOK) } func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] // TODO: enforce visibility // Get base Collection data var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { return err } silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection outbox: %v", err) return ErrInternalGeneral } if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host if app.cfg.App.SingleUser { if alias != c.Alias { return ErrCollectionNotFound } } res := &CollectionObj{Collection: *c} app.db.GetPostsCount(res, false) accountRoot := c.FederatedAccount() page := r.FormValue("page") p, err := strconv.Atoi(page) if err != nil || p < 1 { // Return outbox oc := activitystreams.NewOrderedCollection(accountRoot, "outbox", res.TotalPosts) return impart.RenderActivityJSON(w, oc, http.StatusOK) } // Return outbox page ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p) ocp.OrderedItems = []interface{}{} posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false, "") for _, pp := range *posts { pp.Collection = res o := pp.ActivityObject(app) a := activitystreams.NewCreateActivity(o) a.Context = nil ocp.OrderedItems = append(ocp.OrderedItems, *a) } setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ocp, http.StatusOK) } func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] // TODO: enforce visibility // Get base Collection data var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { return err } silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection followers: %v", err) return ErrInternalGeneral } if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host accountRoot := c.FederatedAccount() folls, err := app.db.GetAPFollowers(c) if err != nil { return err } page := r.FormValue("page") p, err := strconv.Atoi(page) if err != nil || p < 1 { // Return outbox oc := activitystreams.NewOrderedCollection(accountRoot, "followers", len(*folls)) return impart.RenderActivityJSON(w, oc, http.StatusOK) } // Return outbox page ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "followers", len(*folls), p) ocp.OrderedItems = []interface{}{} /* for _, f := range *folls { ocp.OrderedItems = append(ocp.OrderedItems, f.ActorID) } */ setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ocp, http.StatusOK) } func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] // TODO: enforce visibility // Get base Collection data var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { return err } silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection following: %v", err) return ErrInternalGeneral } if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host accountRoot := c.FederatedAccount() page := r.FormValue("page") p, err := strconv.Atoi(page) if err != nil || p < 1 { // Return outbox oc := activitystreams.NewOrderedCollection(accountRoot, "following", 0) return impart.RenderActivityJSON(w, oc, http.StatusOK) } // Return outbox page ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "following", 0, p) ocp.OrderedItems = []interface{}{} setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ocp, http.StatusOK) } func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { // TODO: return Reject? return err } silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection inbox: %v", err) return ErrInternalGeneral } if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host if debugging { dump, err := httputil.DumpRequest(r, true) if err != nil { log.Error("Can't dump: %v", err) } else { log.Info("Rec'd! %q", dump) } } var m map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&m); err != nil { return err } a := streams.NewAccept() p := c.PersonObject() var to *url.URL var isFollow, isUnfollow, isLike, isUnlike bool var likePostID, unlikePostID string fullActor := &activitystreams.Person{} var remoteUser *RemoteUser res := &streams.Resolver{ LikeCallback: func(l *streams.Like) error { isLike = true // 1) Use the Like concrete type here // 2) Errors are propagated to res.Deserialize call below m["@context"] = []string{activitystreams.Namespace} b, _ := json.Marshal(m) if debugging { log.Info("Like: %s", b) } _, likeID := l.GetId() if likeID == nil { log.Error("Didn't resolve Like ID") } if p := l.HasObject(0); p == streams.NoPresence { return fmt.Errorf("no object for Like activity at index 0") } obj := l.Raw().GetObjectIRI(0) /* // TODO: handle this more robustly l.ResolveObject(&streams.Resolver{ LinkCallback: func(link *streams.Link) error { return nil }, }, 0) */ if obj == nil { return fmt.Errorf("didn't get ObjectIRI to Like") } likePostID, err = parsePostIDFromURL(app, obj) if err != nil { return err } // Finally, get actor information _, from := l.GetActor(0) if from == nil { return fmt.Errorf("No valid actor string") } fullActor, remoteUser, err = getActor(app, from.String()) if err != nil { return err } return nil }, FollowCallback: func(f *streams.Follow) error { isFollow = true // 1) Use the Follow concrete type here // 2) Errors are propagated to res.Deserialize call below m["@context"] = []string{activitystreams.Namespace} b, _ := json.Marshal(m) if debugging { log.Info("Follow: %s", b) } _, followID := f.GetId() if followID == nil { log.Error("Didn't resolve follow ID") } else { aID := c.FederatedAccount() + "#accept-" + id.GenerateFriendlyRandomString(20) acceptID, err := url.Parse(aID) if err != nil { log.Error("Couldn't parse generated Accept URL '%s': %v", aID, err) } a.SetId(acceptID) } a.AppendObject(f.Raw()) _, to = f.GetActor(0) obj := f.Raw().GetObjectIRI(0) if obj == nil { if debugging { log.Error("GetObjectIRI on Follow for actor is empty; trying object") } ao := f.Raw().GetObject(0) if ao == nil { log.Error("Fell back to GetObject and none parsed, so no actor ID! Follow request probably FAILED!") } else { obj = ao.GetId() } } a.AppendActor(obj) // First get actor information if to == nil { return fmt.Errorf("No valid `to` string") } fullActor, remoteUser, err = getActor(app, to.String()) if err != nil { return err } return impart.RenderActivityJSON(w, m, http.StatusOK) }, UndoCallback: func(u *streams.Undo) error { m["@context"] = []string{activitystreams.Namespace} b, _ := json.Marshal(m) if debugging { log.Info("Undo: %s", b) } a.AppendObject(u.Raw()) // Check type -- we handle Undo:Like and Undo:Follow _, err := u.ResolveObject(&streams.Resolver{ LikeCallback: func(like *streams.Like) error { isUnlike = true _, from := like.GetActor(0) obj := like.Raw().GetObjectIRI(0) if obj == nil { return fmt.Errorf("didn't get ObjectIRI for Undo Like") } unlikePostID, err = parsePostIDFromURL(app, obj) if err != nil { return err } fullActor, remoteUser, err = getActor(app, from.String()) if err != nil { return err } return nil }, // TODO: add FollowCallback for more robust handling }, 0) if err != nil { return err } if isUnlike { return nil } isUnfollow = true _, to = u.GetActor(0) // TODO: get actor from object.object, not object obj := u.Raw().GetObjectIRI(0) a.AppendActor(obj) if to != nil { // Populate fullActor from DB? remoteUser, err = getRemoteUser(app, to.String()) if err != nil { if iErr, ok := err.(*impart.HTTPError); ok { if iErr.Status == http.StatusNotFound { log.Error("No remoteuser info for Undo event!") } } return err } else { fullActor = remoteUser.AsPerson() } } else { log.Error("No to on Undo!") } return impart.RenderActivityJSON(w, m, http.StatusOK) }, } if err := res.Deserialize(m); err != nil { // 3) Any errors from #2 can be handled, or the payload is an unknown type. log.Error("Unable to resolve Follow: %v", err) if debugging { log.Error("Map: %s", m) } return err } // Handle synchronous activities if isLike { t, err := app.db.Begin() if err != nil { log.Error("Unable to start transaction: %v", err) return fmt.Errorf("unable to start transaction: %v", err) } var remoteUserID int64 if remoteUser != nil { remoteUserID = remoteUser.ID } else { remoteUserID, err = apAddRemoteUser(app, t, fullActor) } // Add like - _, err = t.Exec("INSERT INTO remote_likes (post_id, remote_user_id, created) VALUES (?, ?, "+app.db.now()+")", likePostID, remoteUserID) + _, err = t.Exec(app.db.QueryWrap("INSERT INTO remote_likes (post_id, remote_user_id, created) VALUES (?, ?, "+app.db.now()+")"), likePostID, remoteUserID) if err != nil { if !app.db.isDuplicateKeyErr(err) { t.Rollback() log.Error("Couldn't add like in DB: %v\n", err) return fmt.Errorf("Couldn't add like in DB: %v", err) } else { t.Rollback() log.Error("Couldn't add like in DB: %v\n", err) return fmt.Errorf("Couldn't add like in DB: %v", err) } } err = t.Commit() if err != nil { t.Rollback() log.Error("Rolling back after Commit(): %v\n", err) return fmt.Errorf("Rolling back after Commit(): %v\n", err) } if debugging { log.Info("Successfully liked post %s by remote user %s", likePostID, remoteUser.URL) } return impart.RenderActivityJSON(w, "", http.StatusOK) } else if isUnlike { t, err := app.db.Begin() if err != nil { log.Error("Unable to start transaction: %v", err) return fmt.Errorf("unable to start transaction: %v", err) } var remoteUserID int64 if remoteUser != nil { remoteUserID = remoteUser.ID } else { remoteUserID, err = apAddRemoteUser(app, t, fullActor) } // Remove like - _, err = t.Exec("DELETE FROM remote_likes WHERE post_id = ? AND remote_user_id = ?", unlikePostID, remoteUserID) + _, err = t.Exec(app.db.QueryWrap("DELETE FROM remote_likes WHERE post_id = ? AND remote_user_id = ?"), unlikePostID, remoteUserID) if err != nil { t.Rollback() log.Error("Couldn't delete Like from DB: %v\n", err) return fmt.Errorf("Couldn't delete Like from DB: %v", err) } err = t.Commit() if err != nil { t.Rollback() log.Error("Rolling back after Commit(): %v\n", err) return fmt.Errorf("Rolling back after Commit(): %v\n", err) } if debugging { log.Info("Successfully un-liked post %s by remote user %s", unlikePostID, remoteUser.URL) } return impart.RenderActivityJSON(w, "", http.StatusOK) } go func() { if to == nil { if debugging { log.Error("No `to` value!") } return } time.Sleep(2 * time.Second) am, err := a.Serialize() if err != nil { log.Error("Unable to serialize Accept: %v", err) return } am["@context"] = []string{activitystreams.Namespace} err = makeActivityPost(app.cfg.App.Host, p, fullActor.Inbox, am) if err != nil { log.Error("Unable to make activity POST: %v", err) return } if isFollow { t, err := app.db.Begin() if err != nil { log.Error("Unable to start transaction: %v", err) return } var followerID int64 if remoteUser != nil { followerID = remoteUser.ID } else { // TODO: use apAddRemoteUser() here, instead! // Add follower locally, since it wasn't found before - res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL) + res, err := t.Exec(app.db.QueryWrap("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)"), fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL) if err != nil { // if duplicate key, res will be nil and panic on // res.LastInsertId below t.Rollback() log.Error("Couldn't add new remoteuser in DB: %v\n", err) return } followerID, err = res.LastInsertId() if err != nil { t.Rollback() log.Error("no lastinsertid for followers, rolling back: %v", err) return } // Add in key - _, err = t.Exec("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)", fullActor.PublicKey.ID, followerID, fullActor.PublicKey.PublicKeyPEM) + _, err = t.Exec(app.db.QueryWrap("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)"), fullActor.PublicKey.ID, followerID, fullActor.PublicKey.PublicKeyPEM) if err != nil { if !app.db.isDuplicateKeyErr(err) { t.Rollback() log.Error("Couldn't add follower keys in DB: %v\n", err) return } } } // Add follow - _, err = t.Exec("INSERT INTO remotefollows (collection_id, remote_user_id, created) VALUES (?, ?, "+app.db.now()+")", c.ID, followerID) + _, err = t.Exec(app.db.QueryWrap("INSERT INTO remotefollows (collection_id, remote_user_id, created) VALUES (?, ?, "+app.db.now()+")"), c.ID, followerID) if err != nil { if !app.db.isDuplicateKeyErr(err) { t.Rollback() log.Error("Couldn't add follower in DB: %v\n", err) return } } err = t.Commit() if err != nil { t.Rollback() log.Error("Rolling back after Commit(): %v\n", err) return } } else if isUnfollow { // Remove follower locally _, err = app.db.Exec("DELETE FROM remotefollows WHERE collection_id = ? AND remote_user_id = (SELECT id FROM remoteusers WHERE actor_id = ?)", c.ID, to.String()) if err != nil { log.Error("Couldn't remove follower from DB: %v\n", err) } } }() return nil } func makeActivityPost(hostName string, p *activitystreams.Person, url string, m interface{}) error { log.Info("POST %s", url) b, err := json.Marshal(m) if err != nil { return err } r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b)) r.Header.Add("Content-Type", "application/activity+json") r.Header.Set("User-Agent", ServerUserAgent(hostName)) h := sha256.New() h.Write(b) r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil))) // Sign using the 'Signature' header privKey, err := activitypub.DecodePrivateKey(p.GetPrivKey()) if err != nil { return err } signer := httpsig.NewSigner(p.PublicKey.ID, privKey, httpsig.RSASHA256, []string{"(request-target)", "date", "host", "digest"}) err = signer.SignSigHeader(r) if err != nil { log.Error("Can't sign: %v", err) } if debugging { dump, err := httputil.DumpRequestOut(r, true) if err != nil { log.Error("Can't dump: %v", err) } else { log.Info("%s", dump) } } resp, err := activityPubClient().Do(r) if err != nil { return err } if resp != nil && resp.Body != nil { defer resp.Body.Close() } body, err := io.ReadAll(resp.Body) if err != nil { return err } if debugging { log.Info("Status : %s", resp.Status) log.Info("Response: %s", body) } return nil } func resolveIRI(hostName, url string) ([]byte, error) { log.Info("GET %s", url) r, _ := http.NewRequest("GET", url, nil) r.Header.Add("Accept", "application/activity+json") r.Header.Set("User-Agent", ServerUserAgent(hostName)) p := instanceColl.PersonObject() h := sha256.New() h.Write([]byte{}) r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil))) // Sign using the 'Signature' header privKey, err := activitypub.DecodePrivateKey(p.GetPrivKey()) if err != nil { return nil, err } signer := httpsig.NewSigner(p.PublicKey.ID, privKey, httpsig.RSASHA256, []string{"(request-target)", "date", "host", "digest"}) err = signer.SignSigHeader(r) if err != nil { log.Error("Can't sign: %v", err) } if debugging { dump, err := httputil.DumpRequestOut(r, true) if err != nil { log.Error("Can't dump: %v", err) } else { log.Info("%s", dump) } } resp, err := activityPubClient().Do(r) if err != nil { return nil, err } if resp != nil && resp.Body != nil { defer resp.Body.Close() } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if debugging { log.Info("Status : %s", resp.Status) log.Info("Response: %s", body) } return body, nil } func deleteFederatedPost(app *App, p *PublicPost, collID int64) error { if debugging { log.Info("Deleting federated post!") } p.Collection.hostName = app.cfg.App.Host actor := p.Collection.PersonObject(collID) na := p.ActivityObject(app) // Add followers p.Collection.ID = collID followers, err := app.db.GetAPFollowers(&p.Collection.Collection) if err != nil { log.Error("Couldn't delete post (get followers)! %v", err) return err } inboxes := map[string][]string{} for _, f := range *followers { inbox := f.SharedInbox if inbox == "" { inbox = f.Inbox } if _, ok := inboxes[inbox]; ok { inboxes[inbox] = append(inboxes[inbox], f.ActorID) } else { inboxes[inbox] = []string{f.ActorID} } } for si, instFolls := range inboxes { na.CC = []string{} na.CC = append(na.CC, instFolls...) da := activitystreams.NewDeleteActivity(na) // Make the ID unique to ensure it works in Pleroma // See: https://git.pleroma.social/pleroma/pleroma/issues/1481 da.ID += "#Delete" err = makeActivityPost(app.cfg.App.Host, actor, si, da) if err != nil { log.Error("Couldn't delete post! %v", err) } } return nil } func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { // If app is private, do not federate if app.cfg.App.Private { return nil } // Do not federate posts from private or protected blogs if p.Collection.Visibility == CollPrivate || p.Collection.Visibility == CollProtected { return nil } if debugging { if isUpdate { log.Info("Federating updated post!") } else { log.Info("Federating new post!") } } actor := p.Collection.PersonObject(collID) na := p.ActivityObject(app) // Add followers p.Collection.ID = collID followers, err := app.db.GetAPFollowers(&p.Collection.Collection) if err != nil { log.Error("Couldn't post! %v", err) return err } log.Info("Followers for %d: %+v", collID, followers) inboxes := map[string][]string{} for _, f := range *followers { inbox := f.SharedInbox if inbox == "" { inbox = f.Inbox } if _, ok := inboxes[inbox]; ok { // check if we're already sending to this shared inbox inboxes[inbox] = append(inboxes[inbox], f.ActorID) } else { // add the new shared inbox to the list inboxes[inbox] = []string{f.ActorID} } } var activity *activitystreams.Activity // for each one of the shared inboxes for si, instFolls := range inboxes { // add all followers from that instance // to the CC field na.CC = []string{} na.CC = append(na.CC, instFolls...) // create a new "Create" activity // with our article as object if isUpdate { na.Updated = &p.Updated activity = activitystreams.NewUpdateActivity(na) } else { activity = activitystreams.NewCreateActivity(na) activity.To = na.To activity.CC = na.CC } // and post it to that sharedInbox err = makeActivityPost(app.cfg.App.Host, actor, si, activity) if err != nil { log.Error("Couldn't post! %v", err) } } // re-create the object so that the CC list gets reset and has // the mentioned users. This might seem wasteful but the code is // cleaner than adding the mentioned users to CC here instead of // in p.ActivityObject() na = p.ActivityObject(app) for _, tag := range na.Tag { if tag.Type == "Mention" { activity = activitystreams.NewCreateActivity(na) activity.To = na.To activity.CC = na.CC // This here might be redundant in some cases as we might have already // sent this to the sharedInbox of this instance above, but we need too // much logic to catch this at the expense of the odd extra request. // I don't believe we'd ever have too many mentions in a single post that this // could become a burden. remoteUser, err := getRemoteUser(app, tag.HRef) if err != nil { log.Error("Unable to find remote user %s. Skipping: %v", tag.HRef, err) continue } err = makeActivityPost(app.cfg.App.Host, actor, remoteUser.Inbox, activity) if err != nil { log.Error("Couldn't post! %v", err) } } } return nil } func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { u := RemoteUser{ActorID: actorID} var urlVal, handle sql.NullString err := app.db.QueryRow("SELECT id, inbox, shared_inbox, url, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &urlVal, &handle) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."} case err != nil: log.Error("Couldn't get remote user %s: %v", actorID, err) return nil, err } u.URL = urlVal.String u.Handle = handle.String return &u, nil } // getRemoteUserFromHandle retrieves the profile page of a remote user // from the @user@server.tld handle func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) { u := RemoteUser{Handle: handle} var urlVal sql.NullString err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox, url FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox, &urlVal) switch { case err == sql.ErrNoRows: return nil, ErrRemoteUserNotFound case err != nil: log.Error("Couldn't get remote user %s: %v", handle, err) return nil, err } u.URL = urlVal.String return &u, nil } func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser, error) { log.Info("Fetching actor %s locally", actorIRI) actor := &activitystreams.Person{} remoteUser, err := getRemoteUser(app, actorIRI) if err != nil { if iErr, ok := err.(impart.HTTPError); ok { if iErr.Status == http.StatusNotFound { // Fetch remote actor log.Info("Not found; fetching actor %s remotely", actorIRI) actorResp, err := resolveIRI(app.cfg.App.Host, actorIRI) if err != nil { log.Error("Unable to get base actor! %v", err) return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actor."} } if err := unmarshalActor(actorResp, actor); err != nil { log.Error("Unable to unmarshal base actor! %v", err) return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actor."} } baseActor := &activitystreams.Person{} if err := unmarshalActor(actorResp, baseActor); err != nil { log.Error("Unable to unmarshal actual actor! %v", err) return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actual actor."} } // Fetch the actual actor using the owner field from the publicKey object actualActorResp, err := resolveIRI(app.cfg.App.Host, baseActor.PublicKey.Owner) if err != nil { log.Error("Unable to get actual actor! %v", err) return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actual actor."} } if err := unmarshalActor(actualActorResp, actor); err != nil { log.Error("Unable to unmarshal actual actor! %v", err) return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actual actor."} } } else { return nil, nil, err } } else { return nil, nil, err } } else { actor = remoteUser.AsPerson() } return actor, remoteUser, nil } func GetProfileURLFromHandle(app *App, handle string) (string, error) { handle = strings.TrimLeft(handle, "@") actorIRI := "" parts := strings.Split(handle, "@") if len(parts) != 2 { return "", fmt.Errorf("invalid handle format") } domain := parts[1] // Check non-AP instances if siloProfileURL := silobridge.Profile(parts[0], domain); siloProfileURL != "" { return siloProfileURL, nil } remoteUser, err := getRemoteUserFromHandle(app, handle) if err != nil { // can't find using handle in the table but the table may already have this user without // handle from a previous version // TODO: Make this determination. We should know whether a user exists without a handle, or doesn't exist at all actorIRI = RemoteLookup(handle) _, errRemoteUser := getRemoteUser(app, actorIRI) // if it exists then we need to update the handle if errRemoteUser == nil { _, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI) if err != nil { log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI) } } else { // this probably means we don't have the user in the table so let's try to insert it // here we need to ask the server for the inboxes remoteActor, err := activityserve.NewRemoteActor(actorIRI) if err != nil { log.Error("Couldn't fetch remote actor: %v", err) } if debugging { log.Info("Got remote actor: %s %s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle) } _, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url, handle) VALUES(?, ?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle) if err != nil { log.Error("Couldn't insert remote user: %v", err) return "", err } actorIRI = remoteActor.URL() } } else if remoteUser.URL == "" { log.Info("Remote user %s URL empty, fetching", remoteUser.ActorID) newRemoteActor, err := activityserve.NewRemoteActor(remoteUser.ActorID) if err != nil { log.Error("Couldn't fetch remote actor: %v", err) } else { _, err := app.db.Exec("UPDATE remoteusers SET url = ? WHERE actor_id = ?", newRemoteActor.URL(), remoteUser.ActorID) if err != nil { log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI) } else { actorIRI = newRemoteActor.URL() } } } else { actorIRI = remoteUser.URL } return actorIRI, nil } // unmarshal actor normalizes the actor response to conform to // the type Person from github.com/writeas/web-core/activitysteams // // some implementations return different context field types // this converts any non-slice contexts into a slice func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error { // FIXME: Hubzilla has an object for the Actor's url: cannot unmarshal object into Go struct field Person.url of type string // flexActor overrides the Context field to allow // all valid representations during unmarshal flexActor := struct { activitystreams.Person Context json.RawMessage `json:"@context,omitempty"` }{} if err := json.Unmarshal(actorResp, &flexActor); err != nil { return err } actor.Endpoints = flexActor.Endpoints actor.Followers = flexActor.Followers actor.Following = flexActor.Following actor.ID = flexActor.ID actor.Icon = flexActor.Icon actor.Inbox = flexActor.Inbox actor.Name = flexActor.Name actor.Outbox = flexActor.Outbox actor.PreferredUsername = flexActor.PreferredUsername actor.PublicKey = flexActor.PublicKey actor.Summary = flexActor.Summary actor.Type = flexActor.Type actor.URL = flexActor.URL func(val interface{}) { switch val.(type) { case []interface{}: // already a slice, do nothing actor.Context = val.([]interface{}) default: actor.Context = []interface{}{val} } }(flexActor.Context) return nil } func parsePostIDFromURL(app *App, u *url.URL) (string, error) { // Get post ID from URL var collAlias, slug, postID string if m := apCollectionPostIRIRegex.FindStringSubmatch(u.String()); len(m) == 3 { collAlias = m[1] slug = m[2] } else if m = apDraftPostIRIRegex.FindStringSubmatch(u.String()); len(m) == 2 { postID = m[1] } else { return "", fmt.Errorf("unable to match objectIRI: %s", u) } // Get postID if all we have is collection and slug if collAlias != "" && slug != "" { c, err := app.db.GetCollection(collAlias) if err != nil { return "", err } p, err := app.db.GetPost(slug, c.ID) if err != nil { return "", err } postID = p.ID } return postID, nil } func setCacheControl(w http.ResponseWriter, ttl time.Duration) { w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds())) } diff --git a/app.go b/app.go index 92ac432..f806e12 100644 --- a/app.go +++ b/app.go @@ -1,1000 +1,1006 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "crypto/tls" "database/sql" _ "embed" "fmt" "html/template" "net" "net/http" "net/url" "os" "os/signal" "path/filepath" "regexp" "strings" "syscall" "time" "github.com/gorilla/mux" "github.com/gorilla/schema" "github.com/gorilla/sessions" "github.com/manifoldco/promptui" stripmd "github.com/writeas/go-strip-markdown/v2" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/log" "golang.org/x/crypto/acme/autocert" "github.com/writefreely/writefreely/author" "github.com/writefreely/writefreely/config" "github.com/writefreely/writefreely/key" "github.com/writefreely/writefreely/migrations" "github.com/writefreely/writefreely/page" ) const ( staticDir = "static" assumedTitleLen = 80 postsPerPage = 10 postsPerArchPage = 40 serverSoftware = "WriteFreely" softwareURL = "https://writefreely.org" ) var ( debugging bool // Software version can be set from git env using -ldflags softwareVer = "0.15.1" // DEPRECATED VARS isSingleUser bool ) // App holds data and configuration for an individual WriteFreely instance. type App struct { router *mux.Router shttp *http.ServeMux db *datastore cfg *config.Config cfgFile string keys *key.Keychain sessionStore sessions.Store formDecoder *schema.Decoder updates *updatesCache timeline *localTimeline } // DB returns the App's datastore func (app *App) DB() *datastore { return app.db } // Router returns the App's router func (app *App) Router() *mux.Router { return app.router } // Config returns the App's current configuration. func (app *App) Config() *config.Config { return app.cfg } // SetConfig updates the App's Config to the given value. func (app *App) SetConfig(cfg *config.Config) { app.cfg = cfg } // SetKeys updates the App's Keychain to the given value. func (app *App) SetKeys(k *key.Keychain) { app.keys = k } func (app *App) SessionStore() sessions.Store { return app.sessionStore } func (app *App) SetSessionStore(s sessions.Store) { app.sessionStore = s } // Apper is the interface for getting data into and out of a WriteFreely // instance (or "App"). // // App returns the App for the current instance. // // LoadConfig reads an app configuration into the App, returning any error // encountered. // // SaveConfig persists the current App configuration. // // LoadKeys reads the App's encryption keys and loads them into its // key.Keychain. type Apper interface { App() *App LoadConfig() error SaveConfig(*config.Config) error LoadKeys() error ReqLog(r *http.Request, status int, timeSince time.Duration) string } // App returns the App func (app *App) App() *App { return app } // LoadConfig loads and parses a config file. func (app *App) LoadConfig() error { log.Info("Loading %s configuration...", app.cfgFile) cfg, err := config.Load(app.cfgFile) if err != nil { log.Error("Unable to load configuration: %v", err) os.Exit(1) return err } app.cfg = cfg return nil } // SaveConfig saves the given Config to disk -- namely, to the App's cfgFile. func (app *App) SaveConfig(c *config.Config) error { return config.Save(c, app.cfgFile) } // LoadKeys reads all needed keys from disk into the App. In order to use the // configured `Server.KeysParentDir`, you must call initKeyPaths(App) before // this. func (app *App) LoadKeys() error { var err error app.keys = &key.Keychain{} if debugging { log.Info(" %s", emailKeyPath) } executable, err := os.Executable() if err != nil { executable = "writefreely" } else { executable = filepath.Base(executable) } app.keys.EmailKey, err = os.ReadFile(emailKeyPath) if err != nil { return err } if debugging { log.Info(" %s", cookieAuthKeyPath) } app.keys.CookieAuthKey, err = os.ReadFile(cookieAuthKeyPath) if err != nil { return err } if debugging { log.Info(" %s", cookieKeyPath) } app.keys.CookieKey, err = os.ReadFile(cookieKeyPath) if err != nil { return err } if debugging { log.Info(" %s", csrfKeyPath) } app.keys.CSRFKey, err = os.ReadFile(csrfKeyPath) if err != nil { if os.IsNotExist(err) { log.Error(`Missing key: %s. Run this command to generate missing keys: %s keys generate `, csrfKeyPath, executable) } return err } return nil } func (app *App) ReqLog(r *http.Request, status int, timeSince time.Duration) string { return fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, timeSince, r.UserAgent()) } // handleViewHome shows page at root path. It checks the configuration and // authentication state to show the correct page. func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error { if app.cfg.App.SingleUser { // Render blog index return handleViewCollection(app, w, r) } // Multi-user instance forceLanding := r.FormValue("landing") == "1" if !forceLanding { // Show correct page based on user auth status and configured landing path u := getUserSession(app, r) if app.cfg.App.Chorus { // This instance is focused on reading, so show Reader on home route if not // private or a private-instance user is logged in. if !app.cfg.App.Private || u != nil { return viewLocalTimeline(app, w, r) } } if u != nil { // User is logged in, so show the Pad return handleViewPad(app, w, r) } if app.cfg.App.Private { return viewLogin(app, w, r) } if land := app.cfg.App.LandingPath(); land != "/" { return impart.HTTPError{http.StatusFound, land} } } return handleViewLanding(app, w, r) } func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error { forceLanding := r.FormValue("landing") == "1" p := struct { page.StaticPage *OAuthButtons Flashes []template.HTML Banner template.HTML Content template.HTML ForcedLanding bool }{ StaticPage: pageForReq(app, r), OAuthButtons: NewOAuthButtons(app.Config()), ForcedLanding: forceLanding, } banner, err := getLandingBanner(app) if err != nil { log.Error("unable to get landing banner: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)} } p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), "", app.cfg)) content, err := getLandingBody(app) if err != nil { log.Error("unable to get landing content: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)} } p.Content = template.HTML(applyMarkdown([]byte(content.Content), "", app.cfg)) // Get error messages session, err := app.sessionStore.Get(r, cookieName) if err != nil { // Ignore this log.Error("Unable to get session in handleViewHome; ignoring: %v", err) } flashes, _ := getSessionFlashes(app, w, r, session) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } // Show landing page return renderPage(w, "landing.tmpl", p) } func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *template.Template) error { p := struct { page.StaticPage ContentTitle string Content template.HTML PlainContent string Updated string AboutStats *InstanceStats }{ StaticPage: pageForReq(app, r), } if r.URL.Path == "/about" || r.URL.Path == "/contact" || r.URL.Path == "/privacy" { var c *instanceContent var err error if r.URL.Path == "/about" { c, err = getAboutPage(app) // Fetch stats p.AboutStats = &InstanceStats{} p.AboutStats.NumPosts, _ = app.db.GetTotalPosts() p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections() } else if r.URL.Path == "/contact" { c, err = getContactPage(app) if c.Updated.IsZero() { // Page was never set up, so return 404 return ErrPostNotFound } } else { c, err = getPrivacyPage(app) } if err != nil { return err } p.ContentTitle = c.Title.String p.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg)) p.PlainContent = shortPostDescription(stripmd.Strip(c.Content)) if !c.Updated.IsZero() { p.Updated = c.Updated.Format("January 2, 2006") } } // Serve templated page err := t.ExecuteTemplate(w, "base", p) if err != nil { log.Error("Unable to render page: %v", err) } return nil } func pageForReq(app *App, r *http.Request) page.StaticPage { p := page.StaticPage{ AppCfg: app.cfg.App, Path: r.URL.Path, Version: "v" + softwareVer, } // Use custom style, if file exists if _, err := os.Stat(filepath.Join(app.cfg.Server.StaticParentDir, staticDir, "local", "custom.css")); err == nil { p.CustomCSS = true } // Add user information, if given var u *User accessToken := r.FormValue("t") if accessToken != "" { userID := app.db.GetUserID(accessToken) if userID != -1 { var err error u, err = app.db.GetUserByID(userID) if err == nil { p.Username = u.Username } } } else { u = getUserSession(app, r) if u != nil { p.Username = u.Username p.IsAdmin = u != nil && u.IsAdmin() p.CanInvite = canUserInvite(app.cfg, p.IsAdmin) } } p.CanViewReader = !app.cfg.App.Private || u != nil return p } var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$") // Initialize loads the app configuration and initializes templates, keys, // session, route handlers, and the database connection. func Initialize(apper Apper, debug bool) (*App, error) { debugging = debug apper.LoadConfig() // Load templates err := InitTemplates(apper.App().Config()) if err != nil { return nil, fmt.Errorf("load templates: %s", err) } // Load keys and set up session initKeyPaths(apper.App()) // TODO: find a better way to do this, since it's unneeded in all Apper implementations err = InitKeys(apper) if err != nil { return nil, fmt.Errorf("init keys: %s", err) } apper.App().InitUpdates() apper.App().InitSession() apper.App().InitDecoder() err = ConnectToDatabase(apper.App()) if err != nil { return nil, fmt.Errorf("connect to DB: %s", err) } initActivityPub(apper.App()) if apper.App().cfg.Email.Enabled() { log.Info("Starting publish jobs queue...") go startPublishJobsQueue(apper.App()) } else { log.Error("[FAILED] Starting publish jobs queue: no email provider is configured.") } // Handle local timeline, if enabled if apper.App().cfg.App.LocalTimeline { log.Info("Initializing local timeline...") initLocalTimeline(apper.App()) } return apper.App(), nil } func Serve(app *App, r *mux.Router) { log.Info("Going to serve...") isSingleUser = app.cfg.App.SingleUser app.cfg.Server.Dev = debugging // Handle shutdown c := make(chan os.Signal, 2) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c log.Info("Shutting down...") shutdown(app) log.Info("Done.") os.Exit(0) }() // Start gopher server if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private { go initGopher(app) } // Start web application server var bindAddress = app.cfg.Server.Bind if bindAddress == "" { bindAddress = "localhost" } var err error if app.cfg.IsSecureStandalone() { if app.cfg.Server.Autocert { m := &autocert.Manager{ Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(app.cfg.Server.TLSCertPath), } host, err := url.Parse(app.cfg.App.Host) if err != nil { log.Error("[WARNING] Unable to parse configured host! %s", err) log.Error(`[WARNING] ALL hosts are allowed, which can open you to an attack where clients connect to a server by IP address and pretend to be asking for an incorrect host name, and cause you to reach the CA's rate limit for certificate requests. We recommend supplying a valid host name.`) log.Info("Using autocert on ANY host") } else { log.Info("Using autocert on host %s", host.Host) m.HostPolicy = autocert.HostWhitelist(host.Host) } s := &http.Server{ Addr: ":https", Handler: r, TLSConfig: &tls.Config{ GetCertificate: m.GetCertificate, }, } s.SetKeepAlivesEnabled(false) go func() { log.Info("Serving redirects on http://%s:80", bindAddress) err = http.ListenAndServe(":80", m.HTTPHandler(nil)) log.Error("Unable to start redirect server: %v", err) }() log.Info("Serving on https://%s:443", bindAddress) log.Info("---") err = s.ListenAndServeTLS("", "") } else { go func() { log.Info("Serving redirects on http://%s:80", bindAddress) err = http.ListenAndServe(fmt.Sprintf("%s:80", bindAddress), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, app.cfg.App.Host, http.StatusMovedPermanently) })) log.Error("Unable to start redirect server: %v", err) }() log.Info("Serving on https://%s:443", bindAddress) log.Info("Using manual certificates") log.Info("---") err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r) } } else { network := "tcp" protocol := "http" if strings.HasPrefix(bindAddress, "/") { network = "unix" protocol = "http+unix" // old sockets will remain after server closes; // we need to delete them in order to open new ones err = os.Remove(bindAddress) if err != nil && !os.IsNotExist(err) { log.Error("%s already exists but could not be removed: %v", bindAddress, err) os.Exit(1) } } else { bindAddress = fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port) } log.Info("Serving on %s://%s", protocol, bindAddress) log.Info("---") listener, err := net.Listen(network, bindAddress) if err != nil { log.Error("Could not bind to address: %v", err) os.Exit(1) } if network == "unix" { err = os.Chmod(bindAddress, 0o666) if err != nil { log.Error("Could not update socket permissions: %v", err) os.Exit(1) } } defer listener.Close() err = http.Serve(listener, r) } if err != nil { log.Error("Unable to start: %v", err) os.Exit(1) } } func (app *App) InitDecoder() { // TODO: do this at the package level, instead of the App level // Initialize modules 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) } // ConnectToDatabase validates and connects to the configured database, then // tests the connection. func ConnectToDatabase(app *App) error { // Check database configuration - if app.cfg.Database.Type == driverMySQL && app.cfg.Database.User == "" { + if (app.cfg.Database.Type == driverMySQL || app.cfg.Database.Type == driverPostgres) && app.cfg.Database.User == "" { return fmt.Errorf("Database user not set.") } if app.cfg.Database.Host == "" { app.cfg.Database.Host = "localhost" } if app.cfg.Database.Database == "" { app.cfg.Database.Database = "writefreely" } // TODO: check err connectToDatabase(app) // Test database connection err := app.db.Ping() if err != nil { return fmt.Errorf("Database ping failed: %s", err) } return nil } // FormatVersion constructs the version string for the application func FormatVersion() string { return serverSoftware + " " + softwareVer } // OutputVersion prints out the version of the application. func OutputVersion() { fmt.Println(FormatVersion()) } // NewApp creates a new app instance. func NewApp(cfgFile string) *App { return &App{ cfgFile: cfgFile, } } // CreateConfig creates a default configuration and saves it to the app's cfgFile. func CreateConfig(app *App) error { log.Info("Creating configuration...") c := config.New() log.Info("Saving configuration %s...", app.cfgFile) err := config.Save(c, app.cfgFile) if err != nil { return fmt.Errorf("Unable to save configuration: %v", err) } return nil } // DoConfig runs the interactive configuration process. func DoConfig(app *App, configSections string) { if configSections == "" { configSections = "server db app" } // let's check there aren't any garbage in the list configSectionsArray := strings.Split(configSections, " ") for _, element := range configSectionsArray { if element != "server" && element != "db" && element != "app" { log.Error("Invalid argument to --sections. Valid arguments are only \"server\", \"db\" and \"app\"") os.Exit(1) } } d, err := config.Configure(app.cfgFile, configSections) if err != nil { log.Error("Unable to configure: %v", err) os.Exit(1) } app.cfg = d.Config connectToDatabase(app) defer shutdown(app) if !app.db.DatabaseInitialized() { err = adminInitDatabase(app) if err != nil { log.Error(err.Error()) os.Exit(1) } } else { log.Info("Database already initialized.") } if d.User != nil { u := &User{ Username: d.User.Username, HashedPass: d.User.HashedPass, Created: time.Now().Truncate(time.Second).UTC(), } // Create blog log.Info("Creating user %s...\n", u.Username) err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName, "") if err != nil { log.Error("Unable to create user: %s", err) os.Exit(1) } log.Info("Done!") } os.Exit(0) } // GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir. func GenerateKeyFiles(app *App) error { // Read keys path from config app.LoadConfig() // Create keys dir if it doesn't exist yet fullKeysDir := filepath.Join(app.cfg.Server.KeysParentDir, keysDir) if _, err := os.Stat(fullKeysDir); os.IsNotExist(err) { err = os.Mkdir(fullKeysDir, 0700) if err != nil { return err } } // Generate keys initKeyPaths(app) // TODO: use something like https://github.com/hashicorp/go-multierror to return errors var keyErrs error err := generateKey(emailKeyPath) if err != nil { keyErrs = err } err = generateKey(cookieAuthKeyPath) if err != nil { keyErrs = err } err = generateKey(cookieKeyPath) if err != nil { keyErrs = err } err = generateKey(csrfKeyPath) if err != nil { keyErrs = err } return keyErrs } // CreateSchema creates all database tables needed for the application. func CreateSchema(apper Apper) error { apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) err := adminInitDatabase(apper.App()) if err != nil { return err } return nil } // Migrate runs all necessary database migrations. func Migrate(apper Apper) error { apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) err := migrations.Migrate(migrations.NewDatastore(apper.App().db.DB, apper.App().db.driverName)) if err != nil { return fmt.Errorf("migrate: %s", err) } return nil } // ResetPassword runs the interactive password reset process. func ResetPassword(apper Apper, username string) error { // Connect to the database apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) // Fetch user u, err := apper.App().db.GetUserForAuth(username) if err != nil { log.Error("Get user: %s", err) os.Exit(1) } // Prompt for new password prompt := promptui.Prompt{ Templates: &promptui.PromptTemplates{ Success: "{{ . | bold | faint }}: ", }, Label: "New password", Mask: '*', } newPass, err := prompt.Run() if err != nil { log.Error("%s", err) os.Exit(1) } // Do the update log.Info("Updating...") err = adminResetPassword(apper.App(), u, newPass) if err != nil { log.Error("%s", err) os.Exit(1) } log.Info("Success.") return nil } // DoDeleteAccount runs the confirmation and account delete process. func DoDeleteAccount(apper Apper, username string) error { // Connect to the database apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) // check user exists u, err := apper.App().db.GetUserForAuth(username) if err != nil { log.Error("%s", err) os.Exit(1) } userID := u.ID // do not delete the admin account // TODO: check for other admins and skip? if u.IsAdmin() { log.Error("Can not delete admin account") os.Exit(1) } // confirm deletion, w/ w/out posts prompt := promptui.Prompt{ Templates: &promptui.PromptTemplates{ Success: "{{ . | bold | faint }}: ", }, Label: fmt.Sprintf("Really delete user : %s", username), IsConfirm: true, } _, err = prompt.Run() if err != nil { log.Info("Aborted...") os.Exit(0) } log.Info("Deleting...") err = apper.App().db.DeleteAccount(userID) if err != nil { log.Error("%s", err) os.Exit(1) } log.Info("Success.") return nil } func connectToDatabase(app *App) { log.Info("Connecting to %s database...", app.cfg.Database.Type) var db *sql.DB var err error + var db_tls map[bool]string + db_tls = make(map[bool]string) + db_tls[true] = "enable" + db_tls[false] = "disable" if app.cfg.Database.Type == driverMySQL { db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&tls=%t", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()), app.cfg.Database.TLS)) db.SetMaxOpenConns(50) + } else if app.cfg.Database.Type == driverPostgres { + db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Database, db_tls[app.cfg.Database.TLS])) + db.SetMaxOpenConns(50) } else if app.cfg.Database.Type == driverSQLite { if !SQLiteEnabled { log.Error("Invalid database type '%s'. Binary wasn't compiled with SQLite3 support.", app.cfg.Database.Type) os.Exit(1) } if app.cfg.Database.FileName == "" { log.Error("SQLite database filename value in config.ini is empty.") os.Exit(1) } db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared") db.SetMaxOpenConns(2) } else { - log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type) + log.Error("Invalid database type '%s'. Only 'mysql', 'postgres' and 'sqlite3' are supported right now.", app.cfg.Database.Type) os.Exit(1) } if err != nil { log.Error("%s", err) os.Exit(1) } app.db = &datastore{db, app.cfg.Database.Type} } func shutdown(app *App) { log.Info("Closing database connection...") app.db.Close() if strings.HasPrefix(app.cfg.Server.Bind, "/") { // Clean up socket log.Info("Removing socket file...") err := os.Remove(app.cfg.Server.Bind) if err != nil { log.Error("Unable to remove socket: %s", err) os.Exit(1) } log.Info("Success.") } } // CreateUser creates a new admin or normal user from the given credentials. func CreateUser(apper Apper, username, password string, isAdmin bool) error { // Create an admin user with --create-admin apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) // Ensure an admin / first user doesn't already exist firstUser, _ := apper.App().db.GetUserByID(1) if isAdmin { // Abort if trying to create admin user, but one already exists if firstUser != nil { return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely user create [USER]:[PASSWORD]", firstUser.Username) } } else { // Abort if trying to create regular user, but no admin exists yet if firstUser == nil { return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely user create --admin [USER]:[PASSWORD]") } } // Create the user // Normalize and validate username desiredUsername := username username = getSlug(username, "") usernameDesc := username if username != desiredUsername { usernameDesc += " (originally: " + desiredUsername + ")" } if !author.IsValidUsername(apper.App().cfg, username) { return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, apper.App().cfg.App.MinUsernameLen) } // Hash the password hashedPass, err := auth.HashPass([]byte(password)) if err != nil { return fmt.Errorf("Unable to hash password: %v", err) } u := &User{ Username: username, HashedPass: hashedPass, Created: time.Now().Truncate(time.Second).UTC(), } userType := "user" if isAdmin { userType = "admin" } log.Info("Creating %s %s...", userType, usernameDesc) err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername, "") if err != nil { return fmt.Errorf("Unable to create user: %s", err) } log.Info("Done!") return nil } -//go:embed schema.sql -var schemaSql string +//go:embed mysql.sql +var mysqlSql string + +//go:embed postgres.sql +var postgresSql string //go:embed sqlite.sql var sqliteSql string func adminInitDatabase(app *App) error { var schema string if app.cfg.Database.Type == driverSQLite { schema = sqliteSql - } else { - schema = schemaSql } - - tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`") + if app.cfg.Database.Type == driverPostgres { + schema = postgresSql + } + if app.cfg.Database.Type == driverMySQL { + schema = mysqlSql + } queries := strings.Split(string(schema), ";\n") for _, q := range queries { - if strings.TrimSpace(q) == "" { + q = strings.TrimSpace(q) + if q == "" { continue } - parts := tblReg.FindStringSubmatch(q) - if len(parts) >= 3 { - log.Info("Creating table %s...", parts[2]) - } else { - log.Info("Creating table ??? (Weird query) No match in: %v", parts) - } - _, err := app.db.Exec(q) - if err != nil { - log.Error("%s", err) + if _, err := app.db.Exec(q); err != nil { + log.Error("Error %s executing:\n%s\n", err, q) } else { - log.Info("Created.") + log.Info("Successfully executing:\n%s\n", q) } } // Set up migrations table log.Info("Initializing appmigrations table...") err := migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName)) if err != nil { return fmt.Errorf("Unable to set initial migrations: %v", err) } log.Info("Running migrations...") err = migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName)) if err != nil { return fmt.Errorf("migrate: %s", err) } log.Info("Done.") return nil } // ServerUserAgent returns a User-Agent string to use in external requests. The // hostName parameter may be left empty. func ServerUserAgent(hostName string) string { hostUAStr := "" if hostName != "" { hostUAStr = "; +" + hostName } return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")" } diff --git a/config/config.go b/config/config.go index 7bee863..9d16393 100644 --- a/config/config.go +++ b/config/config.go @@ -1,315 +1,324 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ // Package config holds and assists in the configuration of a writefreely instance. package config import ( "net/url" "strings" "github.com/go-ini/ini" "github.com/writeas/web-core/log" "golang.org/x/net/idna" ) const ( // FileName is the default configuration file name FileName = "config.ini" UserNormal UserType = "user" UserAdmin = "admin" ) type ( UserType string // ServerCfg holds values that affect how the HTTP server runs ServerCfg struct { HiddenHost string `ini:"hidden_host"` Port int `ini:"port"` Bind string `ini:"bind"` TLSCertPath string `ini:"tls_cert_path"` TLSKeyPath string `ini:"tls_key_path"` Autocert bool `ini:"autocert"` TemplatesParentDir string `ini:"templates_parent_dir"` StaticParentDir string `ini:"static_parent_dir"` PagesParentDir string `ini:"pages_parent_dir"` KeysParentDir string `ini:"keys_parent_dir"` HashSeed string `ini:"hash_seed"` GopherPort int `ini:"gopher_port"` Dev bool `ini:"-"` } // DatabaseCfg holds values that determine how the application connects to a datastore DatabaseCfg struct { Type string `ini:"type"` FileName string `ini:"filename"` User string `ini:"username"` Password string `ini:"password"` Database string `ini:"database"` Host string `ini:"host"` Port int `ini:"port"` TLS bool `ini:"tls"` } WriteAsOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` AuthLocation string `ini:"auth_location"` TokenLocation string `ini:"token_location"` InspectLocation string `ini:"inspect_location"` CallbackProxy string `ini:"callback_proxy"` CallbackProxyAPI string `ini:"callback_proxy_api"` } GitlabOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` Host string `ini:"host"` DisplayName string `ini:"display_name"` CallbackProxy string `ini:"callback_proxy"` CallbackProxyAPI string `ini:"callback_proxy_api"` } GiteaOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` Host string `ini:"host"` DisplayName string `ini:"display_name"` CallbackProxy string `ini:"callback_proxy"` CallbackProxyAPI string `ini:"callback_proxy_api"` } SlackOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` TeamID string `ini:"team_id"` CallbackProxy string `ini:"callback_proxy"` CallbackProxyAPI string `ini:"callback_proxy_api"` } GenericOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` Host string `ini:"host"` DisplayName string `ini:"display_name"` CallbackProxy string `ini:"callback_proxy"` CallbackProxyAPI string `ini:"callback_proxy_api"` TokenEndpoint string `ini:"token_endpoint"` InspectEndpoint string `ini:"inspect_endpoint"` AuthEndpoint string `ini:"auth_endpoint"` Scope string `ini:"scope"` AllowDisconnect bool `ini:"allow_disconnect"` MapUserID string `ini:"map_user_id"` MapUsername string `ini:"map_username"` MapDisplayName string `ini:"map_display_name"` MapEmail string `ini:"map_email"` } // AppCfg holds values that affect how the application functions AppCfg struct { SiteName string `ini:"site_name"` SiteDesc string `ini:"site_description"` Host string `ini:"host"` // Site appearance Theme string `ini:"theme"` Editor string `ini:"editor"` JSDisabled bool `ini:"disable_js"` WebFonts bool `ini:"webfonts"` Landing string `ini:"landing"` SimpleNav bool `ini:"simple_nav"` WFModesty bool `ini:"wf_modesty"` // Site functionality Chorus bool `ini:"chorus"` Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info. DisableDrafts bool `ini:"disable_drafts"` // Users SingleUser bool `ini:"single_user"` OpenRegistration bool `ini:"open_registration"` OpenDeletion bool `ini:"open_deletion"` MinUsernameLen int `ini:"min_username_len"` MaxBlogs int `ini:"max_blogs"` // Options for public instances // Federation Federation bool `ini:"federation"` PublicStats bool `ini:"public_stats"` Monetization bool `ini:"monetization"` NotesOnly bool `ini:"notes_only"` // Access Private bool `ini:"private"` // Additional functions LocalTimeline bool `ini:"local_timeline"` UserInvites string `ini:"user_invites"` // Defaults DefaultVisibility string `ini:"default_visibility"` // Check for Updates UpdateChecks bool `ini:"update_checks"` // Disable password authentication if use only Oauth DisablePasswordAuth bool `ini:"disable_password_auth"` } EmailCfg struct { // SMTP configuration values Host string `ini:"smtp_host"` Port int `ini:"smtp_port"` Username string `ini:"smtp_username"` Password string `ini:"smtp_password"` EnableStartTLS bool `ini:"smtp_enable_start_tls"` // Mailgun configuration values Domain string `ini:"domain"` MailgunPrivate string `ini:"mailgun_private"` MailgunEurope bool `ini:"mailgun_europe"` } // Config holds the complete configuration for running a writefreely instance Config struct { Server ServerCfg `ini:"server"` Database DatabaseCfg `ini:"database"` App AppCfg `ini:"app"` Email EmailCfg `ini:"email"` SlackOauth SlackOauthCfg `ini:"oauth.slack"` WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"` GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"` GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"` GenericOauth GenericOauthCfg `ini:"oauth.generic"` } ) // New creates a new Config with sane defaults func New() *Config { c := &Config{ Server: ServerCfg{ Port: 8080, Bind: "localhost", /* IPV6 support when not using localhost? */ }, App: AppCfg{ Host: "http://localhost:8080", Theme: "write", WebFonts: true, SingleUser: true, MinUsernameLen: 3, MaxBlogs: 1, Federation: true, PublicStats: true, }, } c.UseMySQL(true) return c } // UseMySQL resets the Config's Database to use default values for a MySQL setup. func (cfg *Config) UseMySQL(fresh bool) { cfg.Database.Type = "mysql" if fresh { cfg.Database.Host = "localhost" cfg.Database.Port = 3306 } } +// UsePostgres resets the Config's Database to use default values for a Postgres setup. +func (cfg *Config) UsePostgres(fresh bool) { + cfg.Database.Type = "postgres" + if fresh { + cfg.Database.Host = "localhost" + cfg.Database.Port = 5432 + } +} + // UseSQLite resets the Config's Database to use default values for a SQLite setup. func (cfg *Config) UseSQLite(fresh bool) { cfg.Database.Type = "sqlite3" if fresh { cfg.Database.FileName = "writefreely.db" } } // IsSecureStandalone returns whether or not the application is running as a // standalone server with TLS enabled. func (cfg *Config) IsSecureStandalone() bool { return cfg.Server.Port == 443 && cfg.Server.TLSCertPath != "" && cfg.Server.TLSKeyPath != "" } func (ac *AppCfg) LandingPath() string { if !strings.HasPrefix(ac.Landing, "/") { return "/" + ac.Landing } return ac.Landing } func (lc EmailCfg) Enabled() bool { return (lc.Domain != "" && lc.MailgunPrivate != "") || lc.Username != "" && lc.Password != "" && lc.Host != "" && lc.Port > 0 } func (ac AppCfg) SignupPath() string { if !ac.OpenRegistration { return "" } if ac.Chorus || ac.Private || (ac.Landing != "" && ac.Landing != "/") { return "/signup" } return "/" } // Load reads the given configuration file, then parses and returns it as a Config. func Load(fname string) (*Config, error) { if fname == "" { fname = FileName } cfg, err := ini.Load(fname) if err != nil { return nil, err } // Parse INI file uc := &Config{} err = cfg.MapTo(uc) if err != nil { return nil, err } // Do any transformations u, err := url.Parse(uc.App.Host) if err != nil { return nil, err } d, err := idna.ToASCII(u.Hostname()) if err != nil { log.Error("idna.ToASCII for %s: %s", u.Hostname(), err) return nil, err } uc.App.Host = u.Scheme + "://" + d if u.Port() != "" { uc.App.Host += ":" + u.Port() } return uc, nil } // Save writes the given Config to the given file. func Save(uc *Config, fname string) error { cfg := ini.Empty() err := ini.ReflectFrom(cfg, uc) if err != nil { return err } if fname == "" { fname = FileName } return cfg.SaveTo(fname) } diff --git a/config/setup.go b/config/setup.go index 4beef13..66ce4b7 100644 --- a/config/setup.go +++ b/config/setup.go @@ -1,396 +1,456 @@ /* * Copyright © 2018 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package config import ( "fmt" "os" "strconv" "strings" "github.com/fatih/color" "github.com/manifoldco/promptui" "github.com/mitchellh/go-wordwrap" "github.com/writeas/web-core/auth" ) type SetupData struct { User *UserCreation Config *Config } func Configure(fname string, configSections string) (*SetupData, error) { data := &SetupData{} var err error if fname == "" { fname = FileName } data.Config, err = Load(fname) var action string isNewCfg := false if err != nil { fmt.Printf("No %s configuration yet. Creating new.\n", fname) data.Config = New() action = "generate" isNewCfg = true } else { fmt.Printf("Loaded configuration %s.\n", fname) action = "update" } title := color.New(color.Bold, color.BgGreen).PrintFunc() intro := color.New(color.Bold, color.FgWhite).PrintlnFunc() fmt.Println() intro(" ✍ WriteFreely Configuration ✍") fmt.Println() fmt.Println(wordwrap.WrapString(" This quick configuration process will "+action+" the application's config file, "+fname+".\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() tmpls := &promptui.PromptTemplates{ Success: "{{ . | bold | faint }}: ", } selTmpls := &promptui.SelectTemplates{ Selected: `{{.Label}} {{ . | faint }}`, } var selPrompt promptui.Select var prompt promptui.Prompt if strings.Contains(configSections, "server") { title(" Server setup ") fmt.Println() // Environment selection selPrompt = promptui.Select{ Templates: selTmpls, Label: "Environment", Items: []string{"Development", "Production, standalone", "Production, behind reverse proxy"}, } _, envType, err := selPrompt.Run() if err != nil { return data, err } isDevEnv := envType == "Development" isStandalone := envType == "Production, standalone" _, isDocker := os.LookupEnv("WRITEFREELY_DOCKER") data.Config.Server.Dev = isDevEnv if isDevEnv || !isStandalone { // Running in dev environment or behind reverse proxy; ask for port prompt = promptui.Prompt{ Templates: tmpls, Label: "Local port", Validate: validatePort, Default: fmt.Sprintf("%d", data.Config.Server.Port), } port, err := prompt.Run() if err != nil { return data, err } data.Config.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number } if isStandalone { selPrompt = promptui.Select{ Templates: selTmpls, Label: "Web server mode", Items: []string{"Insecure (port 80)", "Secure (port 443), manual certificate", "Secure (port 443), auto certificate"}, } sel, _, err := selPrompt.Run() if err != nil { return data, err } if sel == 0 { data.Config.Server.Autocert = false data.Config.Server.Port = 80 data.Config.Server.TLSCertPath = "" data.Config.Server.TLSKeyPath = "" } else if sel == 1 || sel == 2 { data.Config.Server.Port = 443 data.Config.Server.Autocert = sel == 2 if sel == 1 { // Manual certificate configuration prompt = promptui.Prompt{ Templates: tmpls, Label: "Certificate path", Validate: validateNonEmpty, Default: data.Config.Server.TLSCertPath, } data.Config.Server.TLSCertPath, err = prompt.Run() if err != nil { return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Key path", Validate: validateNonEmpty, Default: data.Config.Server.TLSKeyPath, } data.Config.Server.TLSKeyPath, err = prompt.Run() if err != nil { return data, err } } else { // Automatic certificate data.Config.Server.TLSCertPath = "certs" data.Config.Server.TLSKeyPath = "certs" } } } else { data.Config.Server.TLSCertPath = "" data.Config.Server.TLSKeyPath = "" } // If running in docker: // 1. always bind to 0.0.0.0 instead of localhost // 2. set paths of static files in UNIX manners if !isDevEnv && isDocker { data.Config.Server.TemplatesParentDir = "/usr/share/writefreely" data.Config.Server.StaticParentDir = "/usr/share/writefreely" data.Config.Server.PagesParentDir = "/usr/share/writefreely" data.Config.Server.Bind = "0.0.0.0" } fmt.Println() } if strings.Contains(configSections, "db") { title(" Database setup ") fmt.Println() selPrompt = promptui.Select{ Templates: selTmpls, Label: "Database driver", - Items: []string{"MySQL", "SQLite"}, + Items: []string{"MySQL", "Postgres", "SQLite"}, } sel, _, err := selPrompt.Run() if err != nil { return data, err } if sel == 0 { // Configure for MySQL data.Config.UseMySQL(isNewCfg) prompt = promptui.Prompt{ Templates: tmpls, Label: "Username", Validate: validateNonEmpty, Default: data.Config.Database.User, } data.Config.Database.User, err = prompt.Run() if err != nil { return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Password", Validate: validateNonEmpty, Default: data.Config.Database.Password, Mask: '*', } data.Config.Database.Password, err = prompt.Run() if err != nil { return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Database name", Validate: validateNonEmpty, Default: data.Config.Database.Database, } data.Config.Database.Database, err = prompt.Run() if err != nil { return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Host", Validate: validateNonEmpty, Default: data.Config.Database.Host, } data.Config.Database.Host, err = prompt.Run() if err != nil { return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Port", Validate: validatePort, Default: fmt.Sprintf("%d", data.Config.Database.Port), } dbPort, err := prompt.Run() if err != nil { return data, err } data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number } else if sel == 1 { + // Configure for Postgres + data.Config.UsePostgres(isNewCfg) + + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Username", + Validate: validateNonEmpty, + Default: data.Config.Database.User, + } + data.Config.Database.User, err = prompt.Run() + if err != nil { + return data, err + } + + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Password", + Validate: validateNonEmpty, + Default: data.Config.Database.Password, + Mask: '*', + } + data.Config.Database.Password, err = prompt.Run() + if err != nil { + return data, err + } + + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Database name", + Validate: validateNonEmpty, + Default: data.Config.Database.Database, + } + data.Config.Database.Database, err = prompt.Run() + if err != nil { + return data, err + } + + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Host", + Validate: validateNonEmpty, + Default: data.Config.Database.Host, + } + data.Config.Database.Host, err = prompt.Run() + if err != nil { + return data, err + } + + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Port", + Validate: validatePort, + Default: fmt.Sprintf("%d", data.Config.Database.Port), + } + dbPort, err := prompt.Run() + if err != nil { + return data, err + } + data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number + } else if sel == 2 { // Configure for SQLite data.Config.UseSQLite(isNewCfg) prompt = promptui.Prompt{ Templates: tmpls, Label: "Filename", Validate: validateNonEmpty, Default: data.Config.Database.FileName, } data.Config.Database.FileName, err = prompt.Run() if err != nil { return data, err } } fmt.Println() } if strings.Contains(configSections, "app") { title(" App setup ") fmt.Println() selPrompt = promptui.Select{ Templates: selTmpls, Label: "Site type", Items: []string{"Single user blog", "Multi-user instance"}, } _, usersType, err := selPrompt.Run() if err != nil { return data, err } data.Config.App.SingleUser = usersType == "Single user blog" if data.Config.App.SingleUser { data.User = &UserCreation{} // prompt for username prompt = promptui.Prompt{ Templates: tmpls, Label: "Admin username", Validate: validateNonEmpty, } data.User.Username, err = prompt.Run() if err != nil { return data, err } // prompt for password prompt = promptui.Prompt{ Templates: tmpls, Label: "Admin password", Validate: validateNonEmpty, } newUserPass, err := prompt.Run() if err != nil { return data, err } data.User.HashedPass, err = auth.HashPass([]byte(newUserPass)) if err != nil { return data, err } } siteNameLabel := "Instance name" if data.Config.App.SingleUser { siteNameLabel = "Blog name" } prompt = promptui.Prompt{ Templates: tmpls, Label: siteNameLabel, Validate: validateNonEmpty, Default: data.Config.App.SiteName, } data.Config.App.SiteName, err = prompt.Run() if err != nil { return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Public URL", Validate: validateDomain, Default: data.Config.App.Host, } data.Config.App.Host, err = prompt.Run() if err != nil { return data, err } if !data.Config.App.SingleUser { selPrompt = promptui.Select{ Templates: selTmpls, Label: "Registration", Items: []string{"Open", "Closed"}, } _, regType, err := selPrompt.Run() if err != nil { return data, err } data.Config.App.OpenRegistration = regType == "Open" prompt = promptui.Prompt{ Templates: tmpls, Label: "Max blogs per user", Default: fmt.Sprintf("%d", data.Config.App.MaxBlogs), } maxBlogs, err := prompt.Run() if err != nil { return data, err } data.Config.App.MaxBlogs, _ = strconv.Atoi(maxBlogs) // Ignore error, as we've already validated number } selPrompt = promptui.Select{ Templates: selTmpls, Label: "Federation", Items: []string{"Enabled", "Disabled"}, } _, fedType, err := selPrompt.Run() if err != nil { return data, err } data.Config.App.Federation = fedType == "Enabled" if data.Config.App.Federation { selPrompt = promptui.Select{ Templates: selTmpls, Label: "Usage stats (active users, posts)", Items: []string{"Public", "Private"}, } _, fedStatsType, err := selPrompt.Run() if err != nil { return data, err } data.Config.App.PublicStats = fedStatsType == "Public" selPrompt = promptui.Select{ Templates: selTmpls, Label: "Instance metadata privacy", Items: []string{"Public", "Private"}, } _, fedStatsType, err = selPrompt.Run() if err != nil { return data, err } data.Config.App.Private = fedStatsType == "Private" } } return data, Save(data.Config, fname) } diff --git a/database-no-sqlite.go b/database-no-sqlite.go index ef5104e..0add7ff 100644 --- a/database-no-sqlite.go +++ b/database-no-sqlite.go @@ -1,53 +1,64 @@ //go:build !sqlite && !wflib // +build !sqlite,!wflib /* * Copyright © 2019-2020 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "github.com/go-sql-driver/mysql" + "github.com/lib/pq" "github.com/writeas/web-core/log" ) func (db *datastore) isDuplicateKeyErr(err error) bool { if db.driverName == driverMySQL { if mysqlErr, ok := err.(*mysql.MySQLError); ok { return mysqlErr.Number == mySQLErrDuplicateKey } + } else if db.driverName == driverPostgres { + if postgresErr, ok := err.(*pq.Error); ok { + return postgresErr.Code == postgresErrDuplicateKey + } } else { log.Error("isDuplicateKeyErr: failed check for unrecognized driver '%s'", db.driverName) } return false } func (db *datastore) isIgnorableError(err error) bool { if db.driverName == driverMySQL { if mysqlErr, ok := err.(*mysql.MySQLError); ok { return mysqlErr.Number == mySQLErrCollationMix } } else { log.Error("isIgnorableError: failed check for unrecognized driver '%s'", db.driverName) } return false } func (db *datastore) isHighLoadError(err error) bool { if db.driverName == driverMySQL { if mysqlErr, ok := err.(*mysql.MySQLError); ok { return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns } } + if db.driverName == driverPostgres { + if postgresErr, ok := err.(*pq.Error); ok { + return postgresErr.Code == postgresErrMaxUserConns || postgresErr.Code == postgresErrTooManyConns + } + } + return false } diff --git a/database-sqlite.go b/database-sqlite.go index 7d33315..d862c61 100644 --- a/database-sqlite.go +++ b/database-sqlite.go @@ -1,73 +1,84 @@ //go:build sqlite && !wflib // +build sqlite,!wflib /* * Copyright © 2019-2020 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "github.com/go-sql-driver/mysql" + "github.com/lib/pq" "github.com/mattn/go-sqlite3" "github.com/writeas/web-core/log" "regexp" ) func init() { SQLiteEnabled = true regex := func(re, s string) (bool, error) { return regexp.MatchString(re, s) } sql.Register("sqlite3_with_regex", &sqlite3.SQLiteDriver{ ConnectHook: func(conn *sqlite3.SQLiteConn) error { return conn.RegisterFunc("regexp", regex, true) }, }) } func (db *datastore) isDuplicateKeyErr(err error) bool { if db.driverName == driverSQLite { if err, ok := err.(sqlite3.Error); ok { return err.Code == sqlite3.ErrConstraint } } else if db.driverName == driverMySQL { if mysqlErr, ok := err.(*mysql.MySQLError); ok { return mysqlErr.Number == mySQLErrDuplicateKey } + } else if db.driverName == driverPostgres { + if postgresErr, ok := err.(*pq.Error); ok { + return postgresErr.Code == postgresErrDuplicateKey + } } else { log.Error("isDuplicateKeyErr: failed check for unrecognized driver '%s'", db.driverName) } return false } func (db *datastore) isIgnorableError(err error) bool { if db.driverName == driverMySQL { if mysqlErr, ok := err.(*mysql.MySQLError); ok { return mysqlErr.Number == mySQLErrCollationMix } } else { log.Error("isIgnorableError: failed check for unrecognized driver '%s'", db.driverName) } return false } func (db *datastore) isHighLoadError(err error) bool { if db.driverName == driverMySQL { if mysqlErr, ok := err.(*mysql.MySQLError); ok { return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns } } + if db.driverName == driverPostgres { + if postgresErr, ok := err.(*pq.Error); ok { + return postgresErr.Code == postgresErrMaxUserConns || postgresErr.Code == postgresErrTooManyConns + } + } + return false } diff --git a/database.go b/database.go index 84898e0..b63a8bd 100644 --- a/database.go +++ b/database.go @@ -1,3330 +1,3472 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "context" "database/sql" "fmt" "net/http" "net/url" "strings" "time" "github.com/writeas/monday" "github.com/go-sql-driver/mysql" "github.com/writeas/web-core/silobridge" wf_db "github.com/writefreely/writefreely/db" "github.com/writefreely/writefreely/parse" "github.com/guregu/null" "github.com/guregu/null/zero" uuid "github.com/nu7hatch/gouuid" "github.com/writeas/activityserve" "github.com/writeas/impart" "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/writefreely/writefreely/author" "github.com/writefreely/writefreely/config" "github.com/writefreely/writefreely/key" ) const ( mySQLErrDuplicateKey = 1062 mySQLErrCollationMix = 1267 mySQLErrTooManyConns = 1040 mySQLErrMaxUserConns = 1203 - driverMySQL = "mysql" - driverSQLite = "sqlite3" + postgresErrDuplicateKey = "23505" + postgresErrTooManyConns = "53300" + postgresErrMaxUserConns = "53400" + + driverMySQL = "mysql" + driverPostgres = "postgres" + driverSQLite = "sqlite3" ) var ( SQLiteEnabled bool ) type writestore interface { CreateUser(*config.Config, *User, string, string) error UpdateUserEmail(keys *key.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) error ChangeSettings(app *App, u *User, s *userSettings) error ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error GetCollections(u *User, hostName string) (*[]Collection, error) GetPublishableCollections(u *User, hostName string) (*[]Collection, error) GetMeStats(u *User) userMeStats GetTotalCollections() (int64, error) GetTotalPosts() (int64, error) GetTopPosts(u *User, alias string, hostName string) (*[]PublicPost, error) GetAnonymousPosts(u *User, page int) (*[]PublicPost, error) GetUserPosts(u *User) (*[]PublicPost, error) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName 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(*config.Config, string, string, string) (*Collection, error) CreateCollection(*config.Config, string, string, int64) (*Collection, error) GetCollectionBy(condition string, value interface{}) (*Collection, error) GetCollection(alias string) (*Collection, error) GetCollectionForPad(alias string) (*Collection, error) GetCollectionByID(id int64) (*Collection, error) UpdateCollection(app *App, 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, includeFuture bool) (*[]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(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) GetPostLikeCounts(postID string) (int64, error) GetPostsCount(c *CollectionObj, includeFuture bool) error GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool, contentType PostType) (*[]PublicPost, error) GetAllPostsTaggedIDs(c *Collection, tag string, includeFuture bool) ([]string, error) GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) GetAPFollowers(c *Collection) (*[]RemoteUser, error) GetAPActorKeys(collectionID int64) ([]byte, []byte) CreateUserInvite(id string, userID int64, maxUses int, expires *time.Time) error GetUserInvites(userID int64) (*[]Invite, error) GetUserInvite(id string) (*Invite, error) GetUsersInvitedCount(id string) int64 CreateInvitedUser(inviteID string, userID int64) error GetDynamicContent(id string) (*instanceContent, error) UpdateDynamicContent(id, title, content, contentType string) error GetAllUsers(page uint) (*[]User, error) GetAllUsersCount() int64 GetUserLastPostTime(id int64) (*time.Time, error) GetCollectionLastPostTime(id int64) (*time.Time, error) GetIDForRemoteUser(context.Context, string, string, string) (int64, error) RecordRemoteUserID(context.Context, int64, string, string, string, string) error ValidateOAuthState(context.Context, string) (string, string, int64, string, error) GenerateOAuthState(context.Context, string, string, int64, string) (string, error) GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error DatabaseInitialized() bool } type datastore struct { *sql.DB driverName string } var _ writestore = &datastore{} func (db *datastore) now() string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return "strftime('%Y-%m-%d %H:%M:%S','now')" + case driverMySQL: + return "NOW()" + case driverPostgres: + return "NOW()" } - return "NOW()" + + return "" // placeholder } func (db *datastore) clip(field string, l int) string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return fmt.Sprintf("SUBSTR(%s, 0, %d)", field, l) + case driverMySQL: + return fmt.Sprintf("LEFT(%s, %d)", field, l) + case driverPostgres: + return fmt.Sprintf("LEFT(%s, %d)", field, l) } - return fmt.Sprintf("LEFT(%s, %d)", field, l) + + return "" // placeholder } func (db *datastore) upsert(indexedCols ...string) string { - if db.driverName == driverSQLite { + cc := strings.Join(indexedCols, ", ") + + switch db.driverName { + case driverSQLite: // NOTE: SQLite UPSERT syntax only works in v3.24.0 (2018-06-04) or later // Leaving this for whenever we can upgrade and include it in our binary - cc := strings.Join(indexedCols, ", ") + return "ON CONFLICT(" + cc + ") DO UPDATE SET" + case driverMySQL: + return "ON DUPLICATE KEY UPDATE" + case driverPostgres: return "ON CONFLICT(" + cc + ") DO UPDATE SET" } - return "ON DUPLICATE KEY UPDATE" + + return "" // placeholder } func (db *datastore) dateAdd(l int, unit string) string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: + // NOTE: SQLite UPSERT syntax only works in v3.24.0 (2018-06-04) or later + // Leaving this for whenever we can upgrade and include it in our binary return fmt.Sprintf("DATETIME('now', '%d %s')", l, unit) + case driverMySQL: + return fmt.Sprintf("DATE_ADD(NOW(), INTERVAL %d %s)", l, unit) + case driverPostgres: + return fmt.Sprintf("DATE_ADD(NOW(), %d %s)", l, unit) } - return fmt.Sprintf("DATE_ADD(NOW(), INTERVAL %d %s)", l, unit) + + return "" // placeholder } func (db *datastore) dateSub(l int, unit string) string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: + // NOTE: SQLite UPSERT syntax only works in v3.24.0 (2018-06-04) or later + // Leaving this for whenever we can upgrade and include it in our binary return fmt.Sprintf("DATETIME('now', '-%d %s')", l, unit) + case driverMySQL: + return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit) + case driverPostgres: + return fmt.Sprintf("DATE_SUBTRACT(NOW(), %d %s)", l, unit) } - return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit) + + return "" // placeholder } // CreateUser creates a new user in the database from the given User, UPDATING it in the process with the user's ID. func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string, collectionDesc string) error { + var res sql.Result if db.PostIDExists(u.Username) { return impart.HTTPError{http.StatusConflict, "Invalid collection name."} } // New users get a `users` and `collections` row. t, err := db.Begin() if err != nil { return err } // 1. Add to `users` table // NOTE: Assumes User's Password is already hashed! - res, err := t.Exec("INSERT INTO users (username, password, email) VALUES (?, ?, ?)", u.Username, u.HashedPass, u.Email) - if err != nil { - t.Rollback() - if db.isDuplicateKeyErr(err) { - return impart.HTTPError{http.StatusConflict, "Username is already taken."} + if db.driverName == driverPostgres { + err := t.QueryRow(db.QueryWrap("INSERT INTO users (username, password, email) VALUES (?, ?, ?) RETURNING id"), u.Username, u.HashedPass, u.Email).Scan(&u.ID) + if err != nil { + t.Rollback() + if db.isDuplicateKeyErr(err) { + return impart.HTTPError{http.StatusConflict, "Username is already taken."} + } + log.Error("Rolling back users INSERT: %v\n", err) + return err } + } else { + res, err = t.Exec(db.QueryWrap("INSERT INTO users (username, password, email) VALUES (?, ?, ?)"), u.Username, u.HashedPass, u.Email) + if err != nil { + t.Rollback() + if db.isDuplicateKeyErr(err) { + 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 + 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, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", u.Username, collectionTitle, collectionDesc, defaultVisibility(cfg), u.ID, 0) + res, err = t.Exec(db.QueryWrap("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)"), u.Username, collectionTitle, collectionDesc, defaultVisibility(cfg), u.ID, 0) if err != nil { t.Rollback() if db.isDuplicateKeyErr(err) { 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 *key.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(cfg *config.Config, alias, title, accessToken string) (*Collection, error) { userID := db.GetUserID(accessToken) if userID == -1 { return nil, ErrBadAccessToken } return db.CreateCollection(cfg, 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(cfg *config.Config, 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, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", alias, title, "", defaultVisibility(cfg), userID, 0) if err != nil { if db.isDuplicateKeyErr(err) { 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, Public: defaultVisibility(cfg) == CollPublic, } 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, status FROM users WHERE id = ?", id).Scan(&u.Username, &u.HashedPass, &u.Email, &u.Created, &u.Status) switch { case err == sql.ErrNoRows: return nil, ErrUserNotFound case err != nil: log.Error("Couldn't SELECT user password: %v", err) return nil, err } return u, nil } // IsUserSilenced returns true if the user account associated with id is // currently silenced. func (db *datastore) IsUserSilenced(id int64) (bool, error) { u := &User{ID: id} err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status) switch { case err == sql.ErrNoRows: return false, ErrUserNotFound case err != nil: log.Error("Couldn't SELECT user status: %v", err) return false, fmt.Errorf("is user silenced: %v", err) } return u.IsSilenced(), nil } // DoesUserNeedAuth returns true if the user hasn't provided any methods for // authenticating with the account, such a passphrase or email address. // Any errors are reported to admin and silently quashed, returning false as the // 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 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 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 password 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, status FROM users WHERE username = ?", username).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created, &u.Status) switch { case err == sql.ErrNoRows: // Check if they've entered the wrong, unnormalized username username = getSlug(username, "") if username != u.Username { err = db.QueryRow("SELECT id FROM users WHERE username = ? LIMIT 1", username).Scan(&u.ID) if err == nil { return db.GetUserForAuth(username) } } 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, status FROM users WHERE id = ?", u.ID).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created, &u.Status) switch { case err == sql.ErrNoRows: return nil, ErrUserNotFound 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 LIKE ? AND (expires IS NULL OR expires > "+db.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 LIKE ? AND (expires IS NULL OR expires > "+db.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 LIKE ? AND (expires IS NULL OR expires > "+db.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 LIKE ?", 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 > "+db.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 = db.dateAdd(validSecs, "SECOND") } _, err = db.Exec("INSERT INTO accesstokens (token, user_id, one_time, expires) VALUES (?, ?, ?, "+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) CreatePasswordResetToken(userID int64) (string, error) { t := id.Generate62RandomString(32) - _, err := db.Exec("INSERT INTO password_resets (user_id, token, used, created) VALUES (?, ?, 0, "+db.now()+")", userID, t) + _, err := db.Exec(fmt.Sprintf("INSERT INTO password_resets (user_id, token, used, created) VALUES (?, ?, %s, %s)", db.BoolFalse(), db.now()), userID, t) if err != nil { log.Error("Couldn't INSERT password_resets: %v", err) return "", err } return t, nil } func (db *datastore) GetUserFromPasswordReset(token string) int64 { var userID int64 - err := db.QueryRow("SELECT user_id FROM password_resets WHERE token = ? AND used = 0 AND created > "+db.dateSub(3, "HOUR"), token).Scan(&userID) + err := db.QueryRow("SELECT user_id FROM password_resets WHERE token = ? AND used = "+db.BoolFalse()+" AND created > "+db.dateSub(3, "HOUR"), token).Scan(&userID) if err != nil { return 0 } return userID } func (db *datastore) ConsumePasswordResetToken(t string) error { - _, err := db.Exec("UPDATE password_resets SET used = 1 WHERE token = ?", t) + _, err := db.Exec(fmt.Sprintf("UPDATE password_resets SET used = %s WHERE token = ?", db.BoolTrue()), t) if err != nil { log.Error("Couldn't UPDATE password_resets: %v", err) return err } return nil } func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName 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 } coll.hostName = hostName 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 := id.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.Slug != nil && *post.Slug != "" { slugVal = *post.Slug } else { 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} } } created := time.Now() if db.driverName == driverSQLite { // SQLite stores datetimes in UTC, so convert time.Now() to it here created = created.UTC() } if post.Created != nil && *post.Created != "" { created, err = time.Parse("2006-01-02T15:04:05Z", *post.Created) if err != nil { log.Error("Unable to parse Created time '%s': %v", *post.Created, err) created = time.Now() if db.driverName == driverSQLite { // SQLite stores datetimes in UTC, so convert time.Now() to it here created = created.UTC() } } } - stmt, err := db.Prepare("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, privacy, owner_id, collection_id, created, updated, view_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " + db.now() + ", ?)") + stmt, err := db.Prepare(db.QueryWrap("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, privacy, owner_id, collection_id, created, updated, view_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " + db.now() + ", ?)")) if err != nil { return nil, err } defer stmt.Close() - _, err = stmt.Exec(friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, 0, ownerID, ownerCollID, created, 0) + _, err = stmt.Exec(friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, db.BoolFalse(), ownerID, ownerCollID, created, 0) if err != nil { if db.isDuplicateKeyErr(err) { // Duplicate entry error; try a new slug // TODO: make this a little more robust slug = sql.NullString{id.GenSafeUniqueSlug(slug.String), true} - _, err = stmt.Exec(friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, 0, ownerID, ownerCollID, created, 0) + _, err = stmt.Exec(friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, db.BoolFalse(), ownerID, ownerCollID, created, 0) if err != nil { return nil, handleFailedPostInsert(fmt.Errorf("Retried slug generation, still failed: %v", 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: created.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 = " + db.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, signature, format zero.String row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, post_signature, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value) err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &signature, &format, &c.OwnerID, &c.Visibility, &c.Views) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} case db.isHighLoadError(err): return nil, ErrUnavailable case err != nil: log.Error("Failed selecting from collections: %v", err) return nil, err } c.StyleSheet = styleSheet.String c.Script = script.String c.Signature = signature.String c.Format = format.String c.Public = c.IsPublic() c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer") c.Verification = db.GetCollectionAttribute(c.ID, "verification_link") c.db = db 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) GetCollectionByID(id int64) (*Collection, error) { return db.GetCollectionBy("id = ?", id) } func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) { return db.GetCollectionBy("host = ?", host) } func (db *datastore) UpdateCollection(app *App, c *SubmittedCollection, alias string) error { // Truncate fields correctly, so we don't get "Data too long for column" errors in MySQL (writefreely#600) if c.Title != nil { *c.Title = parse.Truncate(*c.Title, collMaxLengthTitle) } if c.Description != nil { *c.Description = parse.Truncate(*c.Description, collMaxLengthDescription) } q := query.NewUpdate(). SetStringPtr(c.Title, "title"). SetStringPtr(c.Description, "description"). SetStringPtr(c.StyleSheet, "style_sheet"). SetStringPtr(c.Script, "script"). SetStringPtr(c.Signature, "post_signature") 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 == "" && c.Monetization == nil { return ErrPostNoUpdatableVals } // Find any current domain var collID int64 var rowsAffected int64 var changed bool var res sql.Result err := db.QueryRow("SELECT id FROM collections WHERE alias = ?", alias).Scan(&collID) if err != nil { log.Error("Failed selecting from collections: %v. Some things won't work.", err) } // Update MathJax value if c.MathJax { if db.driverName == driverSQLite { _, err = db.Exec("INSERT OR REPLACE INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", collID, "render_mathjax", "1") } else { _, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) "+db.upsert("collection_id", "attribute")+" 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 } } // Update Verification link value if c.Verification != nil { skipUpdate := false if *c.Verification != "" { // Strip away any excess spaces trimmed := strings.TrimSpace(*c.Verification) if strings.HasPrefix(trimmed, "@") && strings.Count(trimmed, "@") == 2 { // This looks like a fediverse handle, so resolve profile URL profileURL, err := GetProfileURLFromHandle(app, trimmed) if err != nil || profileURL == "" { log.Error("Couldn't find user %s: %v", trimmed, err) skipUpdate = true } else { c.Verification = &profileURL } } else { if !strings.HasPrefix(trimmed, "http") { trimmed = "https://" + trimmed } vu, err := url.Parse(trimmed) if err != nil { // Value appears invalid, so don't update skipUpdate = true } else { s := vu.String() c.Verification = &s } } } if !skipUpdate { err = db.SetCollectionAttribute(collID, "verification_link", *c.Verification) if err != nil { log.Error("Unable to insert verification_link value: %v", err) return err } } } // Update Monetization value if c.Monetization != nil { skipUpdate := false if *c.Monetization != "" { // Strip away any excess spaces trimmed := strings.TrimSpace(*c.Monetization) // Only update value when it starts with "$", per spec: https://paymentpointers.org if strings.HasPrefix(trimmed, "$") { c.Monetization = &trimmed } else { // Value appears invalid, so don't update skipUpdate = true } } if !skipUpdate { _, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) "+db.upsert("collection_id", "attribute")+" value = ?", collID, "monetization_pointer", *c.Monetization, *c.Monetization) if err != nil { log.Error("Unable to insert monetization_pointer value: %v", err) return err } } } // Update EmailSub value if c.EmailSubs { err = db.SetCollectionAttribute(collID, "email_subs", "1") if err != nil { log.Error("Unable to insert email_subs value: %v", err) return err } skipUpdate := false if c.LetterReply != nil { // Strip away any excess spaces trimmed := strings.TrimSpace(*c.LetterReply) // Only update value when it contains "@" if strings.IndexRune(trimmed, '@') > 0 { c.LetterReply = &trimmed } else { // Value appears invalid, so don't update skipUpdate = true } if !skipUpdate { err = db.SetCollectionAttribute(collID, collAttrLetterReplyTo, *c.LetterReply) if err != nil { log.Error("Unable to insert %s value: %v", collAttrLetterReplyTo, err) return err } } } } else { _, err = db.Exec("DELETE FROM collectionattributes WHERE collection_id = ? AND attribute = ?", collID, "email_subs") if err != nil { log.Error("Unable to delete email_subs value: %v", err) return err } } // Update rest of the collection data if q.Updates != "" { 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."} } if db.driverName == driverSQLite { _, err = db.Exec("INSERT OR REPLACE INTO collectionpasswords (collection_id, password) VALUES ((SELECT id FROM collections WHERE alias = ?), ?)", alias, hashedPass) } else { _, err = db.Exec("INSERT INTO collectionpasswords (collection_id, password) VALUES ((SELECT id FROM collections WHERE alias = ?), ?) "+db.upsert("collection_id")+" password = ?", alias, hashedPass, hashedPass) } if err != nil { return err } } return nil } 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 == "" && p.Title.String == "" { 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 == "" && p.Title.String == "" { return nil, ErrPostUnpublished } // Get additional information needed before processing post data p.LikeCount, err = db.GetPostLikeCounts(p.ID) if err != nil { return nil, err } 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 == "" && p.Title.String == "" { 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 } func (db *datastore) GetPostLikeCounts(postID string) (int64, error) { var count int64 err := db.QueryRow("SELECT COUNT(*) FROM remote_likes WHERE post_id = ?", postID).Scan(&count) switch { case err == sql.ErrNoRows: count = 0 case err != nil: return 0, err } return count, 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) error { var count int64 timeCondition := "" if !includeFuture { timeCondition = "AND created <= " + db.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 return err } c.TotalPosts = int(count) return nil } // GetPosts retrieves all posts for the given Collection. // It will return future posts if `includeFuture` is true. // It will include only standard (non-pinned) posts unless `includePinned` is true. // TODO: change includeFuture to isOwner, since that's how it's used func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool, contentType PostType) (*[]PublicPost, error) { collID := c.ID cf := c.NewFormat() order := "DESC" if cf.Ascending() && !forceRecentFirst { order = "ASC" } pagePosts := cf.PostsPerPage() start := page*pagePosts - pagePosts if page == 0 { start = 0 pagePosts = 1000 } else if contentType == postArch { pagePosts = postsPerArchPage start = page*pagePosts - pagePosts } limitStr := "" if page > 0 { - limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) + if db.driverName == driverPostgres { + limitStr = fmt.Sprintf(" LIMIT %d OFFSET %d", pagePosts, start) + } else { + limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) + } } timeCondition := "" if !includeFuture { timeCondition = "AND created <= " + db.now() } pinnedCondition := "" if !includePinned { pinnedCondition = "AND pinned_position IS NULL" } // FUTURE: handle different post contentType's here rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? "+pinnedCondition+" "+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.augmentContent(c) p.formatContent(cfg, c, includeFuture, false) pubPost := p.processPost() if contentType == postArch { // Overwrite DisplayDate with special Archive page version loc := monday.FuzzyLocale(pubPost.Language.String) pubPost.DisplayDate = monday.Format(pubPost.Created, monday.LongNoYrFormatsByLocale[loc], loc) } posts = append(posts, pubPost) } err = rows.Err() if err != nil { log.Error("Error after Next() on rows: %v", err) } return &posts, nil } func (db *datastore) GetAllPostsTaggedIDs(c *Collection, tag string, includeFuture bool) ([]string, error) { collID := c.ID cf := c.NewFormat() order := "DESC" if cf.Ascending() { order = "ASC" } timeCondition := "" if !includeFuture { timeCondition = "AND created <= " + db.now() } var rows *sql.Rows var err error if db.driverName == driverSQLite { rows, err = db.Query("SELECT id FROM posts WHERE collection_id = ? AND LOWER(content) regexp ? "+timeCondition+" ORDER BY created "+order, collID, `.*#`+strings.ToLower(tag)+`\b.*`) } else { rows, err = db.Query("SELECT id FROM posts WHERE collection_id = ? AND LOWER(content) RLIKE ? "+timeCondition+" ORDER BY created "+order, collID, "#"+strings.ToLower(tag)+"[[:>:]]") } if err != nil { log.Error("Failed selecting tagged posts: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve tagged collection posts."} } defer rows.Close() ids := []string{} for rows.Next() { var id string err = rows.Scan(&id) if err != nil { log.Error("Failed scanning row: %v", err) break } ids = append(ids, id) } err = rows.Err() if err != nil { log.Error("Error after Next() on rows: %v", err) } return ids, 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(cfg *config.Config, 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) + if db.driverName == driverPostgres { + limitStr = fmt.Sprintf(" LIMIT %d OFFSET %d", pagePosts, start) + } else { + limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) + } } timeCondition := "" if !includeFuture { timeCondition = "AND created <= " + db.now() } var rows *sql.Rows var err error if db.driverName == driverSQLite { rows, err = db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND LOWER(content) regexp ? "+timeCondition+" ORDER BY created "+order+limitStr, collID, `.*#`+strings.ToLower(tag)+`\b.*`) } else { 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.augmentContent(c) p.formatContent(cfg, c, includeFuture, false) 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) GetCollLangTotalPosts(collID int64, lang string) (uint64, error) { var articles uint64 err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE collection_id = ? AND language = ? AND created <= "+db.now(), collID, lang).Scan(&articles) if err != nil && err != sql.ErrNoRows { log.Error("Couldn't get total lang posts count for collection %d: %v", collID, err) return 0, err } return articles, nil } func (db *datastore) GetLangPosts(cfg *config.Config, c *Collection, lang 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) + if db.driverName == driverPostgres { + limitStr = fmt.Sprintf(" LIMIT %d OFFSET %d", pagePosts, start) + } else { + limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) + } } timeCondition := "" if !includeFuture { timeCondition = "AND created <= " + db.now() } rows, err := db.Query(`SELECT `+postCols+` FROM posts WHERE collection_id = ? AND language = ? `+timeCondition+` ORDER BY created `+order+limitStr, collID, lang) 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.augmentContent(c) p.formatContent(cfg, c, includeFuture, false) 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, f.created 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, &f.Created) 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 db.isDuplicateKeyErr(err) && 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(cfg *config.Config, 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 var coll *Collection 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 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(cfg, 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) + qRes, err = db.AttemptClaim(&p, db.QueryWrap(query), params, slugIdx) if err != nil { r.Code = http.StatusInternalServerError r.ErrorMessage = "An unknown error occurred." 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 if coll != nil { r.Post.Collection = &CollectionObj{Collection: *coll} } 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, includeFuture bool) (*[]PublicPost, error) { // FIXME: sqlite-backed instances don't include ellipsis on truncated titles timeCondition := "" if !includeFuture { timeCondition = "AND created <= " + db.now() } rows, err := db.Query("SELECT id, slug, title, "+db.clip("content", 80)+", pinned_position FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL "+timeCondition+" 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() p.augmentContent(&coll.Collection) pp := p.processPost() pp.Collection = coll posts = append(posts, pp) } return &posts, nil } func (db *datastore) GetCollections(u *User, hostName string) (*[]Collection, error) { rows, err := db.Query("SELECT id, alias, title, description, privacy, view_count FROM collections 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{} for rows.Next() { c := Collection{} err = rows.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility, &c.Views) if err != nil { log.Error("Failed scanning row: %v", err) break } c.hostName = hostName c.URL = c.CanonicalURL() c.Public = c.IsPublic() /* // NOTE: future functionality if visibility != nil { // TODO: && visibility == CollPublic { // Add Monetization info when retrieving all public collections c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer") } */ 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, hostName string) (*[]Collection, error) { c, err := db.GetCollections(u, hostName) 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) GetPublicCollections(hostName string) (*[]Collection, error) { - rows, err := db.Query(`SELECT c.id, alias, title, description, privacy, view_count - FROM collections c - LEFT JOIN users u ON u.id = c.owner_id - WHERE c.privacy = 1 AND u.status = 0 - ORDER BY title ASC`) + rows, err := db.Query(fmt.Sprintf(`SELECT c.id, alias, title, description, privacy, view_count + FROM collections c + LEFT JOIN users u ON u.id = c.owner_id + WHERE c.privacy = %s AND u.status = 0 + ORDER BY title ASC`, db.BoolTrue())) if err != nil { log.Error("Failed selecting public collections: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve public collections."} } defer rows.Close() colls := []Collection{} for rows.Next() { c := Collection{} err = rows.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility, &c.Views) if err != nil { log.Error("Failed scanning row: %v", err) break } c.hostName = hostName c.URL = c.CanonicalURL() c.Public = c.IsPublic() // Add Monetization information c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer") 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) 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) GetTotalCollections() (collCount int64, err error) { err = db.QueryRow(` SELECT COUNT(*) FROM collections c LEFT JOIN users u ON u.id = c.owner_id WHERE u.status = 0`).Scan(&collCount) if err != nil { log.Error("Unable to fetch collections count: %v", err) } return } func (db *datastore) GetTotalPosts() (postCount int64, err error) { err = db.QueryRow(` SELECT COUNT(*) FROM posts p LEFT JOIN users u ON u.id = p.owner_id WHERE u.status = 0`).Scan(&postCount) if err != nil { log.Error("Unable to fetch posts count: %v", err) } return } func (db *datastore) GetTopPosts(u *User, alias string, hostName 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, p.content, 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, &p.Content, &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 c.hostName = hostName 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, page int) (*[]PublicPost, error) { pagePosts := 10 start := page*pagePosts - pagePosts if page == 0 { start = 0 pagePosts = 1000 } limitStr := "" if page > 0 { - limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) + if db.driverName == driverPostgres { + limitStr = fmt.Sprintf(" LIMIT %d OFFSET %d", pagePosts, start) + } else { + limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) + } } rows, err := db.Query("SELECT id, view_count, title, language, created, updated, content FROM posts WHERE owner_id = ? AND collection_id IS NULL ORDER BY created DESC"+limitStr, 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.Language, &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 } func (db *datastore) GetUserPostsCount(userID int64) int64 { var count int64 err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ?", userID).Scan(&count) switch { case err == sql.ErrNoRows: return 0 case err != nil: log.Error("Failed selecting posts count for user %d: %v", userID, err) return 0 } return count } // 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) + _, err = t.Exec(db.QueryWrap("UPDATE users SET username = ? WHERE id = ?"), newUsername, u.ID) if err != nil { t.Rollback() if db.isDuplicateKeyErr(err) { 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) + _, err = t.Exec(db.QueryWrap("UPDATE collections SET alias = ? WHERE alias = ? AND owner_id = ?"), newUsername, u.Username, u.ID) if err != nil { t.Rollback() if db.isDuplicateKeyErr(err) { 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) + _, err = t.Exec(db.QueryWrap("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) + _, err = t.Exec(db.QueryWrap("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) + _, err := t.Exec(db.QueryWrap("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 && !db.isIgnorableError(err) { 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) + _, err = t.Exec(db.QueryWrap("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) + _, err = t.Exec(db.QueryWrap("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) + _, err = t.Exec(db.QueryWrap("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) + _, err = t.Exec(db.QueryWrap("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) GetCollectionAttribute(id int64, attr string) string { 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 "" case err != nil: log.Error("Couldn't SELECT value in getCollectionAttribute for attribute '%s': %v", attr, err) return "" } return v } func (db *datastore) SetCollectionAttribute(id int64, attr, v string) error { _, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) "+db.upsert("collection_id", "attribute")+" value = ?", id, attr, v, v) if err != nil { log.Error("Unable to INSERT into collectionattributes: %v", err) return err } return nil } // DeleteAccount will delete the entire account for userID func (db *datastore) DeleteAccount(userID int64) error { // Get all collections rows, err := db.Query("SELECT id, alias FROM collections WHERE owner_id = ?", userID) if err != nil { log.Error("Unable to get collections: %v", err) return err } defer rows.Close() colls := []Collection{} var c Collection for rows.Next() { err = rows.Scan(&c.ID, &c.Alias) if err != nil { log.Error("Unable to scan collection cols: %v", err) return err } colls = append(colls, c) } // Start transaction t, err := db.Begin() if err != nil { log.Error("Unable to begin: %v", err) return err } // Clean up all collection related information var res sql.Result for _, c := range colls { // Delete tokens - res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID) + res, err = t.Exec(db.QueryWrap("DELETE FROM collectionattributes WHERE collection_id = ?"), c.ID) if err != nil { t.Rollback() log.Error("Unable to delete attributes on %s: %v", c.Alias, err) return err } rs, _ := res.RowsAffected() log.Info("Deleted %d for %s from collectionattributes", rs, c.Alias) // Remove any optional collection password - res, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID) + res, err = t.Exec(db.QueryWrap("DELETE FROM collectionpasswords WHERE collection_id = ?"), c.ID) if err != nil { t.Rollback() log.Error("Unable to delete passwords on %s: %v", c.Alias, err) return err } rs, _ = res.RowsAffected() log.Info("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) + res, err = t.Exec(db.QueryWrap("DELETE FROM collectionredirects WHERE new_alias = ?"), c.Alias) if err != nil { t.Rollback() log.Error("Unable to delete redirects on %s: %v", c.Alias, err) return err } rs, _ = res.RowsAffected() log.Info("Deleted %d for %s from collectionredirects", rs, c.Alias) // Remove any collection keys - res, err = t.Exec("DELETE FROM collectionkeys WHERE collection_id = ?", c.ID) + res, err = t.Exec(db.QueryWrap("DELETE FROM collectionkeys WHERE collection_id = ?"), c.ID) if err != nil { t.Rollback() log.Error("Unable to delete keys on %s: %v", c.Alias, err) return err } rs, _ = res.RowsAffected() log.Info("Deleted %d for %s from collectionkeys", rs, c.Alias) // TODO: federate delete collection // Remove remote follows - res, err = t.Exec("DELETE FROM remotefollows WHERE collection_id = ?", c.ID) + res, err = t.Exec(db.QueryWrap("DELETE FROM remotefollows WHERE collection_id = ?"), c.ID) if err != nil { t.Rollback() log.Error("Unable to delete remote follows on %s: %v", c.Alias, err) return err } rs, _ = res.RowsAffected() log.Info("Deleted %d for %s from remotefollows", rs, c.Alias) } // Delete collections - res, err = t.Exec("DELETE FROM collections WHERE owner_id = ?", userID) + res, err = t.Exec(db.QueryWrap("DELETE FROM collections WHERE owner_id = ?"), userID) if err != nil { t.Rollback() log.Error("Unable to delete collections: %v", err) return err } rs, _ := res.RowsAffected() log.Info("Deleted %d from collections", rs) // Delete tokens - res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID) + res, err = t.Exec(db.QueryWrap("DELETE FROM accesstokens WHERE user_id = ?"), userID) if err != nil { t.Rollback() log.Error("Unable to delete access tokens: %v", err) return err } rs, _ = res.RowsAffected() log.Info("Deleted %d from accesstokens", rs) // Delete user attributes - res, err = t.Exec("DELETE FROM oauth_users WHERE user_id = ?", userID) + res, err = t.Exec(db.QueryWrap("DELETE FROM oauth_users WHERE user_id = ?"), userID) if err != nil { t.Rollback() log.Error("Unable to delete oauth_users: %v", err) return err } rs, _ = res.RowsAffected() log.Info("Deleted %d from oauth_users", rs) // Delete posts // TODO: should maybe get each row so we can federate a delete // if so needs to be outside of transaction like collections - res, err = t.Exec("DELETE FROM posts WHERE owner_id = ?", userID) + res, err = t.Exec(db.QueryWrap("DELETE FROM posts WHERE owner_id = ?"), userID) if err != nil { t.Rollback() log.Error("Unable to delete posts: %v", err) return err } rs, _ = res.RowsAffected() log.Info("Deleted %d from posts", rs) // Delete user attributes - res, err = t.Exec("DELETE FROM userattributes WHERE user_id = ?", userID) + res, err = t.Exec(db.QueryWrap("DELETE FROM userattributes WHERE user_id = ?"), userID) if err != nil { t.Rollback() log.Error("Unable to delete attributes: %v", err) return err } rs, _ = res.RowsAffected() log.Info("Deleted %d from userattributes", rs) // Delete user invites - res, err = t.Exec("DELETE FROM userinvites WHERE owner_id = ?", userID) + res, err = t.Exec(db.QueryWrap("DELETE FROM userinvites WHERE owner_id = ?"), userID) if err != nil { t.Rollback() log.Error("Unable to delete invites: %v", err) return err } rs, _ = res.RowsAffected() log.Info("Deleted %d from userinvites", rs) // Delete the user - res, err = t.Exec("DELETE FROM users WHERE id = ?", userID) + res, err = t.Exec(db.QueryWrap("DELETE FROM users WHERE id = ?"), userID) if err != nil { t.Rollback() log.Error("Unable to delete user: %v", err) return err } rs, _ = res.RowsAffected() log.Info("Deleted %d from users", rs) // Commit all changes to the database err = t.Commit() if err != nil { t.Rollback() log.Error("Unable to commit: %v", err) return err } // TODO: federate delete actor return nil } func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) { var pub, priv []byte 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 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 collectionkeys: %v", err) return nil, nil } return pub, priv } func (db *datastore) CreateUserInvite(id string, userID int64, maxUses int, expires *time.Time) error { - _, err := db.Exec("INSERT INTO userinvites (id, owner_id, max_uses, created, expires, inactive) VALUES (?, ?, ?, "+db.now()+", ?, 0)", id, userID, maxUses, expires) + _, err := db.Exec("INSERT INTO userinvites (id, owner_id, max_uses, created, expires, inactive) VALUES (?, ?, ?, "+db.now()+", ?, "+db.BoolFalse()+")", id, userID, maxUses, expires) return err } func (db *datastore) GetUserInvites(userID int64) (*[]Invite, error) { rows, err := db.Query("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE owner_id = ? ORDER BY created DESC", userID) if err != nil { log.Error("Failed selecting from userinvites: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user invites."} } defer rows.Close() is := []Invite{} for rows.Next() { i := Invite{} err = rows.Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive) is = append(is, i) } return &is, nil } func (db *datastore) GetUserInvite(id string) (*Invite, error) { var i Invite err := db.QueryRow("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE id = ?", id).Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive) switch { case err == sql.ErrNoRows, db.isIgnorableError(err): return nil, impart.HTTPError{http.StatusNotFound, "Invite doesn't exist."} case err != nil: log.Error("Failed selecting invite: %v", err) return nil, err } return &i, nil } // IsUsersInvite returns true if the user with ID created the invite with code // and an error other than sql no rows, if any. Will return false in the event // of an error. func (db *datastore) IsUsersInvite(code string, userID int64) (bool, error) { var id string err := db.QueryRow("SELECT id FROM userinvites WHERE id = ? AND owner_id = ?", code, userID).Scan(&id) if err != nil && err != sql.ErrNoRows { log.Error("Failed selecting invite: %v", err) return false, err } return id != "", nil } func (db *datastore) GetUsersInvitedCount(id string) int64 { var count int64 err := db.QueryRow("SELECT COUNT(*) FROM usersinvited WHERE invite_id = ?", id).Scan(&count) switch { case err == sql.ErrNoRows: return 0 case err != nil: log.Error("Failed selecting users invited count: %v", err) return 0 } return count } func (db *datastore) CreateInvitedUser(inviteID string, userID int64) error { _, err := db.Exec("INSERT INTO usersinvited (invite_id, user_id) VALUES (?, ?)", inviteID, userID) return err } func (db *datastore) GetInstancePages() ([]*instanceContent, error) { return db.GetAllDynamicContent("page") } func (db *datastore) GetAllDynamicContent(t string) ([]*instanceContent, error) { where := "" params := []interface{}{} if t != "" { where = " WHERE content_type = ?" params = append(params, t) } rows, err := db.Query("SELECT id, title, content, updated, content_type FROM appcontent"+where, params...) if err != nil { log.Error("Failed selecting from appcontent: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve instance pages."} } defer rows.Close() pages := []*instanceContent{} for rows.Next() { c := &instanceContent{} err = rows.Scan(&c.ID, &c.Title, &c.Content, &c.Updated, &c.Type) if err != nil { log.Error("Failed scanning row: %v", err) break } pages = append(pages, c) } err = rows.Err() if err != nil { log.Error("Error after Next() on rows: %v", err) } return pages, nil } func (db *datastore) GetDynamicContent(id string) (*instanceContent, error) { c := &instanceContent{ ID: id, } err := db.QueryRow("SELECT title, content, updated, content_type FROM appcontent WHERE id = ?", id).Scan(&c.Title, &c.Content, &c.Updated, &c.Type) switch { case err == sql.ErrNoRows: return nil, nil case err != nil: log.Error("Couldn't SELECT FROM appcontent for id '%s': %v", id, err) return nil, err } return c, nil } func (db *datastore) UpdateDynamicContent(id, title, content, contentType string) error { var err error if db.driverName == driverSQLite { _, err = db.Exec("INSERT OR REPLACE INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?)", id, title, content, contentType) } else { _, err = db.Exec("INSERT INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?) "+db.upsert("id")+" title = ?, content = ?, updated = "+db.now(), id, title, content, contentType, title, content) } if err != nil { log.Error("Unable to INSERT appcontent for '%s': %v", id, err) } return err } func (db *datastore) GetAllUsers(page uint) (*[]User, error) { - limitStr := fmt.Sprintf("0, %d", adminUsersPerPage) + var limitStr string if page > 1 { - limitStr = fmt.Sprintf("%d, %d", (page-1)*adminUsersPerPage, adminUsersPerPage) + if db.driverName == driverPostgres { + limitStr = fmt.Sprintf(" LIMIT %d OFFSET %d", adminUsersPerPage, (page-1)*adminUsersPerPage) + } else { + limitStr = fmt.Sprintf(" LIMIT %d, %d", (page-1)*adminUsersPerPage, adminUsersPerPage) + } + } else { + if db.driverName == driverPostgres { + limitStr = fmt.Sprintf(" LIMIT 0, %d", adminUsersPerPage) + } else { + limitStr = fmt.Sprintf(" LIMIT %d OFFSET 0", adminUsersPerPage) + } } - rows, err := db.Query("SELECT id, username, created, status FROM users ORDER BY created DESC LIMIT " + limitStr) + rows, err := db.Query("SELECT id, username, created, status FROM users ORDER BY created DESC" + limitStr) if err != nil { log.Error("Failed selecting from users: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve all users."} } defer rows.Close() users := []User{} for rows.Next() { u := User{} err = rows.Scan(&u.ID, &u.Username, &u.Created, &u.Status) if err != nil { log.Error("Failed scanning GetAllUsers() row: %v", err) break } users = append(users, u) } return &users, nil } func (db *datastore) GetAllUsersCount() int64 { var count int64 err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) switch { case err == sql.ErrNoRows: return 0 case err != nil: log.Error("Failed selecting all users count: %v", err) return 0 } return count } func (db *datastore) GetUserLastPostTime(id int64) (*time.Time, error) { var t time.Time err := db.QueryRow("SELECT created FROM posts WHERE owner_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t) switch { case err == sql.ErrNoRows: return nil, nil case err != nil: log.Error("Failed selecting last post time from posts: %v", err) return nil, err } return &t, nil } // SetUserStatus changes a user's status in the database. see Users.UserStatus func (db *datastore) SetUserStatus(id int64, status UserStatus) error { _, err := db.Exec("UPDATE users SET status = ? WHERE id = ?", status, id) if err != nil { return fmt.Errorf("failed to update user status: %v", err) } return nil } func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) { var t time.Time err := db.QueryRow("SELECT created FROM posts WHERE collection_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t) switch { case err == sql.ErrNoRows: return nil, nil case err != nil: log.Error("Failed selecting last post time from posts: %v", err) return nil, err } return &t, nil } func (db *datastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUser int64, inviteCode string) (string, error) { state := id.Generate62RandomString(24) attachUserVal := sql.NullInt64{Valid: attachUser > 0, Int64: attachUser} inviteCodeVal := sql.NullString{Valid: inviteCode != "", String: inviteCode} - _, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at, attach_user_id, invite_code) VALUES (?, ?, ?, FALSE, "+db.now()+", ?, ?)", state, provider, clientID, attachUserVal, inviteCodeVal) + _, err := db.ExecContext(ctx, db.QueryWrap("INSERT INTO oauth_client_states (state, provider, client_id, used, created_at, attach_user_id, invite_code) VALUES (?, ?, ?, FALSE, "+db.now()+", ?, ?)"), state, provider, clientID, attachUserVal, inviteCodeVal) if err != nil { return "", fmt.Errorf("unable to record oauth client state: %w", err) } return state, nil } func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, string, error) { var provider string var clientID string var attachUserID sql.NullInt64 var inviteCode sql.NullString err := wf_db.RunTransactionWithOptions(ctx, db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { err := tx. - QueryRowContext(ctx, "SELECT provider, client_id, attach_user_id, invite_code FROM oauth_client_states WHERE state = ? AND used = FALSE", state). + QueryRowContext(ctx, db.QueryWrap("SELECT provider, client_id, attach_user_id, invite_code FROM oauth_client_states WHERE state = ? AND used = FALSE"), state). Scan(&provider, &clientID, &attachUserID, &inviteCode) if err != nil { return err } - res, err := tx.ExecContext(ctx, "UPDATE oauth_client_states SET used = TRUE WHERE state = ?", state) + res, err := tx.ExecContext(ctx, db.QueryWrap("UPDATE oauth_client_states SET used = TRUE WHERE state = ?"), state) if err != nil { return err } rowsAffected, err := res.RowsAffected() if err != nil { return err } if rowsAffected != 1 { return fmt.Errorf("state not found") } return nil }) if err != nil { return "", "", 0, "", nil } return provider, clientID, attachUserID.Int64, inviteCode.String, nil } func (db *datastore) RecordRemoteUserID(ctx context.Context, localUserID int64, remoteUserID, provider, clientID, accessToken string) error { var err error if db.driverName == driverSQLite { - _, err = db.ExecContext(ctx, "INSERT OR REPLACE INTO oauth_users (user_id, remote_user_id, provider, client_id, access_token) VALUES (?, ?, ?, ?, ?)", localUserID, remoteUserID, provider, clientID, accessToken) + _, err = db.ExecContext(ctx, db.QueryWrap("INSERT OR REPLACE INTO oauth_users (user_id, remote_user_id, provider, client_id, access_token) VALUES (?, ?, ?, ?, ?)"), localUserID, remoteUserID, provider, clientID, accessToken) } else { - _, err = db.ExecContext(ctx, "INSERT INTO oauth_users (user_id, remote_user_id, provider, client_id, access_token) VALUES (?, ?, ?, ?, ?) "+db.upsert("user")+" access_token = ?", localUserID, remoteUserID, provider, clientID, accessToken, accessToken) + _, err = db.ExecContext(ctx, db.QueryWrap("INSERT INTO oauth_users (user_id, remote_user_id, provider, client_id, access_token) VALUES (?, ?, ?, ?, ?) "+db.upsert("user")+" access_token = ?"), localUserID, remoteUserID, provider, clientID, accessToken, accessToken) } if err != nil { log.Error("Unable to INSERT oauth_users for '%d': %v", localUserID, err) } return err } // GetIDForRemoteUser returns a user ID associated with a remote user ID. func (db *datastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provider, clientID string) (int64, error) { var userID int64 = -1 err := db. - QueryRowContext(ctx, "SELECT user_id FROM oauth_users WHERE remote_user_id = ? AND provider = ? AND client_id = ?", remoteUserID, provider, clientID). + QueryRowContext(ctx, db.QueryWrap("SELECT user_id FROM oauth_users WHERE remote_user_id = ? AND provider = ? AND client_id = ?"), remoteUserID, provider, clientID). Scan(&userID) // Not finding a record is OK. if err != nil && err != sql.ErrNoRows { return -1, err } return userID, nil } type oauthAccountInfo struct { Provider string ClientID string RemoteUserID string DisplayName string AllowDisconnect bool } func (db *datastore) GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) { - rows, err := db.QueryContext(ctx, "SELECT provider, client_id, remote_user_id FROM oauth_users WHERE user_id = ? ", userID) + rows, err := db.QueryContext(ctx, db.QueryWrap("SELECT provider, client_id, remote_user_id FROM oauth_users WHERE user_id = ? "), userID) if err != nil { log.Error("Failed selecting from oauth_users: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user oauth accounts."} } defer rows.Close() var records []oauthAccountInfo for rows.Next() { info := oauthAccountInfo{} err = rows.Scan(&info.Provider, &info.ClientID, &info.RemoteUserID) if err != nil { log.Error("Failed scanning GetAllUsers() row: %v", err) break } records = append(records, info) } return records, nil } // DatabaseInitialized returns whether or not the current datastore has been // initialized with the correct schema. // Currently, it checks to see if the `users` table exists. func (db *datastore) DatabaseInitialized() bool { var dummy string var err error if db.driverName == driverSQLite { err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'").Scan(&dummy) } else { err = db.QueryRow("SHOW TABLES LIKE 'users'").Scan(&dummy) } switch { case err == sql.ErrNoRows: return false case err != nil: log.Error("Couldn't SHOW TABLES: %v", err) return false } return true } func (db *datastore) RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error { - _, err := db.ExecContext(ctx, `DELETE FROM oauth_users WHERE user_id = ? AND provider = ? AND client_id = ? AND remote_user_id = ?`, userID, provider, clientID, remoteUserID) + _, err := db.ExecContext(ctx, db.QueryWrap(`DELETE FROM oauth_users WHERE user_id = ? AND provider = ? AND client_id = ? AND remote_user_id = ?`), userID, provider, clientID, remoteUserID) return err } 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 } // Deprecated: use GetProfileURLFromHandle() instead, which returns user-facing URL instead of actor_id func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) { handle = strings.TrimLeft(handle, "@") actorIRI := "" parts := strings.Split(handle, "@") if len(parts) != 2 { return "", fmt.Errorf("invalid handle format") } domain := parts[1] // Check non-AP instances if siloProfileURL := silobridge.Profile(parts[0], domain); siloProfileURL != "" { return siloProfileURL, nil } remoteUser, err := getRemoteUserFromHandle(app, handle) if err != nil { // can't find using handle in the table but the table may already have this user without // handle from a previous version // TODO: Make this determination. We should know whether a user exists without a handle, or doesn't exist at all actorIRI = RemoteLookup(handle) _, errRemoteUser := getRemoteUser(app, actorIRI) // if it exists then we need to update the handle if errRemoteUser == nil { _, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI) if err != nil { log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI) } } else { // this probably means we don't have the user in the table so let's try to insert it // here we need to ask the server for the inboxes remoteActor, err := activityserve.NewRemoteActor(actorIRI) if err != nil { log.Error("Couldn't fetch remote actor: %v", err) } if debugging { log.Info("%s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), handle) } _, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, handle) VALUES(?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), handle) if err != nil { log.Error("Couldn't insert remote user: %v", err) return "", err } } } else { actorIRI = remoteUser.ActorID } return actorIRI, nil } func (db *datastore) AddEmailSubscription(collID, userID int64, email string, confirmed bool) (*EmailSubscriber, error) { friendlyChars := "0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz" subID := id.GenerateRandomString(friendlyChars, 8) token := id.GenerateRandomString(friendlyChars, 16) emailVal := sql.NullString{ String: email, Valid: email != "", } userIDVal := sql.NullInt64{ Int64: userID, Valid: userID > 0, } _, err := db.Exec("INSERT INTO emailsubscribers (id, collection_id, user_id, email, subscribed, token, confirmed) VALUES (?, ?, ?, ?, "+db.now()+", ?, ?)", subID, collID, userIDVal, emailVal, token, confirmed) if err != nil { if mysqlErr, ok := err.(*mysql.MySQLError); ok { if mysqlErr.Number == mySQLErrDuplicateKey { // Duplicate, so just return existing subscriber information log.Info("Duplicate subscriber for email %s, user %d; returning existing subscriber", email, userID) return db.FetchEmailSubscriber(email, userID, collID) } } return nil, err } return &EmailSubscriber{ ID: subID, CollID: collID, UserID: userIDVal, Email: emailVal, Token: token, }, nil } func (db *datastore) IsEmailSubscriber(email string, userID, collID int64) bool { var dummy int var err error if email != "" { err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID).Scan(&dummy) } else { err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID).Scan(&dummy) } switch { case err == sql.ErrNoRows: return false case err != nil: return false } return true } func (db *datastore) GetEmailSubscribers(collID int64, reqConfirmed bool) ([]*EmailSubscriber, error) { cond := "" if reqConfirmed { - cond = " AND confirmed = 1" + cond = " AND confirmed = " + db.BoolTrue() } rows, err := db.Query(`SELECT s.id, collection_id, user_id, s.email, u.email, subscribed, token, confirmed, allow_export FROM emailsubscribers s LEFT JOIN users u ON u.id = user_id WHERE collection_id = ?`+cond+` ORDER BY subscribed DESC`, collID) if err != nil { log.Error("Failed selecting email subscribers for collection %d: %v", collID, err) return nil, err } defer rows.Close() var subs []*EmailSubscriber for rows.Next() { s := &EmailSubscriber{} err = rows.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.acctEmail, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport) if err != nil { log.Error("Failed scanning row from email subscribers: %v", err) continue } subs = append(subs, s) } return subs, nil } func (db *datastore) FetchEmailSubscriberEmail(subID, token string) (string, error) { var email sql.NullString // TODO: return user email if there's a user_id ? err := db.QueryRow("SELECT email FROM emailsubscribers WHERE id = ? AND token = ?", subID, token).Scan(&email) switch { case err == sql.ErrNoRows: return "", fmt.Errorf("Subscriber doesn't exist or token is invalid.") case err != nil: log.Error("Couldn't SELECT email from emailsubscribers: %v", err) return "", fmt.Errorf("Something went very wrong.") } return email.String, nil } func (db *datastore) FetchEmailSubscriber(email string, userID, collID int64) (*EmailSubscriber, error) { const emailSubCols = "id, collection_id, user_id, email, subscribed, token, confirmed, allow_export" s := &EmailSubscriber{} var row *sql.Row if email != "" { row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID) } else { row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID) } err := row.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport) switch { case err == sql.ErrNoRows: return nil, nil case err != nil: return nil, err } return s, nil } func (db *datastore) DeleteEmailSubscriber(subID, token string) error { res, err := db.Exec("DELETE FROM emailsubscribers WHERE id = ? AND token = ?", subID, token) if err != nil { return err } rowsAffected, _ := res.RowsAffected() if rowsAffected == 0 { return impart.HTTPError{http.StatusNotFound, "Invalid token, or subscriber doesn't exist"} } return nil } func (db *datastore) DeleteEmailSubscriberByUser(email string, userID, collID int64) error { var res sql.Result var err error if email != "" { res, err = db.Exec("DELETE FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID) } else { res, err = db.Exec("DELETE FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID) } if err != nil { return err } rowsAffected, _ := res.RowsAffected() if rowsAffected == 0 { return impart.HTTPError{http.StatusNotFound, "Subscriber doesn't exist"} } return nil } func (db *datastore) UpdateSubscriberConfirmed(subID, token string) error { email, err := db.FetchEmailSubscriberEmail(subID, token) if err != nil { log.Error("Didn't fetch email subscriber: %v", err) return err } // TODO: ensure all addresses with original name are also confirmed, e.g. matt+fake@write.as and matt@write.as are now confirmed - _, err = db.Exec("UPDATE emailsubscribers SET confirmed = 1 WHERE email = ?", email) + _, err = db.Exec(fmt.Sprint("UPDATE emailsubscribers SET confirmed = %s WHERE email = ?", db.BoolTrue()), email) if err != nil { log.Error("Could not update email subscriber confirmation status: %v", err) return err } return nil } func (db *datastore) IsSubscriberConfirmed(email string) bool { var dummy int64 - err := db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND confirmed = 1", email).Scan(&dummy) + err := db.QueryRow(fmt.Sprintf("SELECT 1 FROM emailsubscribers WHERE email = ? AND confirmed = %s", db.BoolTrue()), email).Scan(&dummy) switch { case err == sql.ErrNoRows: return false case err != nil: log.Error("Couldn't SELECT in isSubscriberConfirmed: %v", err) return false } return true } func (db *datastore) InsertJob(j *PostJob) error { res, err := db.Exec("INSERT INTO publishjobs (post_id, action, delay) VALUES (?, ?, ?)", j.PostID, j.Action, j.Delay) if err != nil { return err } jobID, err := res.LastInsertId() if err != nil { log.Error("[jobs] Couldn't get last insert ID! %s", err) } log.Info("[jobs] Queued %s job #%d for post %s, delayed %d minutes", j.Action, jobID, j.PostID, j.Delay) return nil } func (db *datastore) UpdateJobForPost(postID string, delay int64) error { _, err := db.Exec("UPDATE publishjobs SET delay = ? WHERE post_id = ?", delay, postID) if err != nil { return fmt.Errorf("Unable to update publish job: %s", err) } log.Info("Updated job for post %s: delay %d", postID, delay) return nil } func (db *datastore) DeleteJob(id int64) error { _, err := db.Exec("DELETE FROM publishjobs WHERE id = ?", id) if err != nil { return err } log.Info("[job #%d] Deleted.", id) return nil } func (db *datastore) DeleteJobByPost(postID string) error { _, err := db.Exec("DELETE FROM publishjobs WHERE post_id = ?", postID) if err != nil { return err } log.Info("[job] Deleted job for post %s", postID) return nil } func (db *datastore) GetJobsToRun(action string) ([]*PostJob, error) { timeWhere := "created < DATE_SUB(NOW(), INTERVAL delay MINUTE) AND created > DATE_SUB(NOW(), INTERVAL delay + 5 MINUTE)" if db.driverName == driverSQLite { timeWhere = "created < DATETIME('now', '-' || delay || ' MINUTE') AND created > DATETIME('now', '-' || (delay+5) || ' MINUTE')" } rows, err := db.Query(`SELECT pj.id, post_id, action, delay FROM publishjobs pj INNER JOIN posts p ON post_id = p.id WHERE action = ? AND `+timeWhere+` ORDER BY created ASC`, action) if err != nil { log.Error("Failed selecting from publishjobs: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve publish jobs."} } defer rows.Close() jobs := []*PostJob{} for rows.Next() { j := &PostJob{} err = rows.Scan(&j.ID, &j.PostID, &j.Action, &j.Delay) jobs = append(jobs, j) } return jobs, nil } + +func (db *datastore) BoolTrue() string { + switch db.driverName { + case driverSQLite: + return "1" + case driverMySQL: + return "1" + case driverPostgres: + return "TRUE" + } + + return "" // placeholder +} + +func (db *datastore) BoolFalse() string { + switch db.driverName { + case driverSQLite: + return "0" + case driverMySQL: + return "0" + case driverPostgres: + return "FALSE" + } + + return "" // placeholder +} + +func (db *datastore) Query(query string, args ...any) (*sql.Rows, error) { + return (*db.DB).Query(db.QueryWrap(query), args...) +} + +func (db *datastore) QueryRow(query string, args ...any) *sql.Row { + return (*db.DB).QueryRow(db.QueryWrap(query), args...) +} + +func (db *datastore) Exec(query string, args ...any) (sql.Result, error) { + return (*db.DB).Exec(db.QueryWrap(query), args...) +} + +func (db *datastore) QueryWrap(q string) string { + if db.driverName != driverPostgres { + return q + } + + output := "" + escape := false + ctr := 0 + + for i := range len(q) { + if q[i] == '\'' || q[i] == '`' { + escape = !escape + } + + if q[i] == '?' && !escape { + ctr += 1 + output += fmt.Sprintf("$%d", ctr) + } else { + output += string(q[i]) + } + } + + return output +} \ No newline at end of file diff --git a/db/alter.go b/db/alter.go index 0a4ffdd..14e92ac 100644 --- a/db/alter.go +++ b/db/alter.go @@ -1,52 +1,73 @@ package db import ( "fmt" "strings" ) +type alterTableQueryType int + +const ( + alter alterTableQueryType = iota + update alterTableQueryType = iota +) + +type change struct { + queryType alterTableQueryType + queryString string +} + type AlterTableSqlBuilder struct { Dialect DialectType Name string - Changes []string + Changes []change } func (b *AlterTableSqlBuilder) AddColumn(col *Column) *AlterTableSqlBuilder { if colVal, err := col.String(); err == nil { - b.Changes = append(b.Changes, fmt.Sprintf("ADD COLUMN %s", colVal)) + b.Changes = append(b.Changes, change{queryType: alter, queryString: fmt.Sprintf("ADD COLUMN %s", colVal)}) } return b } func (b *AlterTableSqlBuilder) ChangeColumn(name string, col *Column) *AlterTableSqlBuilder { if colVal, err := col.String(); err == nil { - b.Changes = append(b.Changes, fmt.Sprintf("CHANGE COLUMN %s %s", name, colVal)) + b.Changes = append(b.Changes, change{queryType: alter, queryString: fmt.Sprintf("RENAME COLUMN %s TO %s_old", name, name)}) + b.Changes = append(b.Changes, change{queryType: alter, queryString: fmt.Sprintf("ADD COLUMN %s", colVal)}) + b.Changes = append(b.Changes, change{queryType: update, queryString: fmt.Sprintf("SET %s = %s_old", col.Name, name)}) + b.Changes = append(b.Changes, change{queryType: alter, queryString: fmt.Sprintf("DROP COLUMN %s", name)}) } return b } func (b *AlterTableSqlBuilder) AddUniqueConstraint(name string, columns ...string) *AlterTableSqlBuilder { - b.Changes = append(b.Changes, fmt.Sprintf("ADD CONSTRAINT %s UNIQUE (%s)", name, strings.Join(columns, ", "))) + b.Changes = append(b.Changes, change{ + queryType: alter, + queryString: fmt.Sprintf("ADD CONSTRAINT %s UNIQUE (%s)", name, strings.Join(columns, ", ")), + }) return b } func (b *AlterTableSqlBuilder) ToSQL() (string, error) { - var str strings.Builder - - str.WriteString("ALTER TABLE ") - str.WriteString(b.Name) - str.WriteString(" ") + str := "" + changeCount := len(b.Changes) - if len(b.Changes) == 0 { + if changeCount == 0 { return "", fmt.Errorf("no changes provide for table: %s", b.Name) } - changeCount := len(b.Changes) - for i, thing := range b.Changes { - str.WriteString(thing) - if i < changeCount-1 { - str.WriteString(", ") + + for i, change := range b.Changes { + switch change.queryType { + case alter: + str += fmt.Sprintf("ALTER TABLE %s ", b.Name) + case update: + str += fmt.Sprintf("UPDATE %s ", b.Name) + } + str += change.queryString + if i < changeCount - 1 { + str += "; " } } - return str.String(), nil + return str, nil } diff --git a/db/create.go b/db/create.go index 1e9e679..870bfd7 100644 --- a/db/create.go +++ b/db/create.go @@ -1,263 +1,284 @@ /* * Copyright © 2019-2020 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package db import ( "fmt" "strings" ) type ColumnType int type OptionalInt struct { Set bool Value int } type OptionalString struct { Set bool Value string } type SQLBuilder interface { ToSQL() (string, error) } type Column struct { Dialect DialectType Name string Nullable bool Default OptionalString Type ColumnType Size OptionalInt PrimaryKey bool } type CreateTableSqlBuilder struct { Dialect DialectType Name string IfNotExists bool ColumnOrder []string Columns map[string]*Column Constraints []string } const ( ColumnTypeBool ColumnType = iota ColumnTypeSmallInt ColumnType = iota ColumnTypeInteger ColumnType = iota ColumnTypeChar ColumnType = iota ColumnTypeVarChar ColumnType = iota ColumnTypeText ColumnType = iota ColumnTypeDateTime ColumnType = iota ) var _ SQLBuilder = &CreateTableSqlBuilder{} var UnsetSize OptionalInt = OptionalInt{Set: false, Value: 0} var UnsetDefault OptionalString = OptionalString{Set: false, Value: ""} func (d ColumnType) Format(dialect DialectType, size OptionalInt) (string, error) { - if dialect != DialectMySQL && dialect != DialectSQLite { + if dialect != DialectMySQL && dialect != DialectPostgres && dialect != DialectSQLite { return "", fmt.Errorf("unsupported column type %d for dialect %d and size %v", d, dialect, size) } + + mod := "" + + if size.Set { + mod = fmt.Sprintf("(%d)", size.Value) + } + switch d { case ColumnTypeSmallInt: - { - if dialect == DialectSQLite { - return "INTEGER", nil - } - mod := "" - if size.Set { - mod = fmt.Sprintf("(%d)", size.Value) - } + switch dialect { + case DialectSQLite: + return "INTEGER", nil + case DialectMySQL: return "SMALLINT" + mod, nil + case DialectPostgres: + return "SMALLINT", nil } case ColumnTypeInteger: - { - if dialect == DialectSQLite { - return "INTEGER", nil - } - mod := "" - if size.Set { - mod = fmt.Sprintf("(%d)", size.Value) - } + switch dialect { + case DialectSQLite: + return "INTEGER", nil + case DialectMySQL: return "INT" + mod, nil + case DialectPostgres: + return "INT", nil } case ColumnTypeChar: - { - if dialect == DialectSQLite { - return "TEXT", nil - } - mod := "" - if size.Set { - mod = fmt.Sprintf("(%d)", size.Value) - } + switch dialect { + case DialectSQLite: + return "TEXT", nil + case DialectMySQL: + return "CHAR" + mod, nil + case DialectPostgres: return "CHAR" + mod, nil } case ColumnTypeVarChar: - { - if dialect == DialectSQLite { - return "TEXT", nil - } - mod := "" - if size.Set { - mod = fmt.Sprintf("(%d)", size.Value) - } + switch dialect { + case DialectSQLite: + return "TEXT", nil + case DialectMySQL: + return "VARCHAR" + mod, nil + case DialectPostgres: return "VARCHAR" + mod, nil } case ColumnTypeBool: - { - if dialect == DialectSQLite { - return "INTEGER", nil - } + switch dialect { + case DialectSQLite: + return "INTEGER", nil + case DialectMySQL: return "TINYINT(1)", nil + case DialectPostgres: + return "BOOLEAN", nil } case ColumnTypeDateTime: - return "DATETIME", nil + switch dialect { + case DialectSQLite: + return "DATETIME", nil + case DialectMySQL: + return "DATETIME", nil + case DialectPostgres: + return "TIMESTAMP", nil + } case ColumnTypeText: - return "TEXT", nil + switch dialect { + case DialectSQLite: + return "TEXT", nil + case DialectMySQL: + return "TEXT", nil + case DialectPostgres: + return "TEXT", nil + } } return "", fmt.Errorf("unsupported column type %d for dialect %d and size %v", d, dialect, size) } func (c *Column) SetName(name string) *Column { c.Name = name return c } func (c *Column) SetNullable(nullable bool) *Column { c.Nullable = nullable return c } func (c *Column) SetPrimaryKey(pk bool) *Column { c.PrimaryKey = pk return c } func (c *Column) SetDefault(value string) *Column { c.Default = OptionalString{Set: true, Value: value} return c } func (c *Column) SetDefaultCurrentTimestamp() *Column { - def := "NOW()" - if c.Dialect == DialectSQLite { + def := "" + + switch c.Dialect { + case DialectSQLite: def = "CURRENT_TIMESTAMP" + case DialectMySQL: + def = "NOW()" + case DialectPostgres: + def = "NOW()" } c.Default = OptionalString{Set: true, Value: def} return c } func (c *Column) SetType(t ColumnType) *Column { c.Type = t return c } func (c *Column) SetSize(size int) *Column { c.Size = OptionalInt{Set: true, Value: size} return c } func (c *Column) String() (string, error) { var str strings.Builder str.WriteString(c.Name) str.WriteString(" ") typeStr, err := c.Type.Format(c.Dialect, c.Size) if err != nil { return "", err } str.WriteString(typeStr) if !c.Nullable { str.WriteString(" NOT NULL") } if c.Default.Set { str.WriteString(" DEFAULT ") val := c.Default.Value if val == "" { val = "''" } str.WriteString(val) } if c.PrimaryKey { str.WriteString(" PRIMARY KEY") } return str.String(), nil } func (b *CreateTableSqlBuilder) Column(column *Column) *CreateTableSqlBuilder { if b.Columns == nil { b.Columns = make(map[string]*Column) } b.Columns[column.Name] = column b.ColumnOrder = append(b.ColumnOrder, column.Name) return b } func (b *CreateTableSqlBuilder) UniqueConstraint(columns ...string) *CreateTableSqlBuilder { for _, column := range columns { if _, ok := b.Columns[column]; !ok { // This fails silently. return b } } b.Constraints = append(b.Constraints, fmt.Sprintf("UNIQUE(%s)", strings.Join(columns, ","))) return b } func (b *CreateTableSqlBuilder) SetIfNotExists(ine bool) *CreateTableSqlBuilder { b.IfNotExists = ine return b } func (b *CreateTableSqlBuilder) ToSQL() (string, error) { var str strings.Builder str.WriteString("CREATE TABLE ") if b.IfNotExists { str.WriteString("IF NOT EXISTS ") } str.WriteString(b.Name) var things []string for _, columnName := range b.ColumnOrder { column, ok := b.Columns[columnName] if !ok { return "", fmt.Errorf("column not found: %s", columnName) } columnStr, err := column.String() if err != nil { return "", err } things = append(things, columnStr) } things = append(things, b.Constraints...) if thingLen := len(things); thingLen > 0 { str.WriteString(" ( ") for i, thing := range things { str.WriteString(thing) if i < thingLen-1 { str.WriteString(", ") } } str.WriteString(" )") } return str.String(), nil } diff --git a/db/dialect.go b/db/dialect.go index 4251465..5606ea0 100644 --- a/db/dialect.go +++ b/db/dialect.go @@ -1,76 +1,89 @@ package db import "fmt" type DialectType int const ( - DialectSQLite DialectType = iota - DialectMySQL DialectType = iota + DialectSQLite DialectType = iota + DialectMySQL DialectType = iota + DialectPostgres DialectType = iota ) func (d DialectType) Column(name string, t ColumnType, size OptionalInt) *Column { switch d { case DialectSQLite: return &Column{Dialect: DialectSQLite, Name: name, Type: t, Size: size} case DialectMySQL: return &Column{Dialect: DialectMySQL, Name: name, Type: t, Size: size} + case DialectPostgres: + return &Column{Dialect: DialectPostgres, Name: name, Type: t, Size: size} default: panic(fmt.Sprintf("unexpected dialect: %d", d)) } } func (d DialectType) Table(name string) *CreateTableSqlBuilder { switch d { case DialectSQLite: return &CreateTableSqlBuilder{Dialect: DialectSQLite, Name: name} case DialectMySQL: return &CreateTableSqlBuilder{Dialect: DialectMySQL, Name: name} + case DialectPostgres: + return &CreateTableSqlBuilder{Dialect: DialectPostgres, Name: name} default: panic(fmt.Sprintf("unexpected dialect: %d", d)) } } func (d DialectType) AlterTable(name string) *AlterTableSqlBuilder { switch d { case DialectSQLite: return &AlterTableSqlBuilder{Dialect: DialectSQLite, Name: name} case DialectMySQL: return &AlterTableSqlBuilder{Dialect: DialectMySQL, Name: name} + case DialectPostgres: + return &AlterTableSqlBuilder{Dialect: DialectPostgres, Name: name} default: panic(fmt.Sprintf("unexpected dialect: %d", d)) } } func (d DialectType) CreateUniqueIndex(name, table string, columns ...string) *CreateIndexSqlBuilder { switch d { case DialectSQLite: return &CreateIndexSqlBuilder{Dialect: DialectSQLite, Name: name, Table: table, Unique: true, Columns: columns} case DialectMySQL: return &CreateIndexSqlBuilder{Dialect: DialectMySQL, Name: name, Table: table, Unique: true, Columns: columns} + case DialectPostgres: + return &CreateIndexSqlBuilder{Dialect: DialectPostgres, Name: name, Table: table, Unique: true, Columns: columns} default: panic(fmt.Sprintf("unexpected dialect: %d", d)) } } func (d DialectType) CreateIndex(name, table string, columns ...string) *CreateIndexSqlBuilder { switch d { case DialectSQLite: return &CreateIndexSqlBuilder{Dialect: DialectSQLite, Name: name, Table: table, Unique: false, Columns: columns} case DialectMySQL: return &CreateIndexSqlBuilder{Dialect: DialectMySQL, Name: name, Table: table, Unique: false, Columns: columns} + case DialectPostgres: + return &CreateIndexSqlBuilder{Dialect: DialectPostgres, Name: name, Table: table, Unique: false, Columns: columns} default: panic(fmt.Sprintf("unexpected dialect: %d", d)) } } func (d DialectType) DropIndex(name, table string) *DropIndexSqlBuilder { switch d { case DialectSQLite: return &DropIndexSqlBuilder{Dialect: DialectSQLite, Name: name, Table: table} case DialectMySQL: return &DropIndexSqlBuilder{Dialect: DialectMySQL, Name: name, Table: table} + case DialectPostgres: + return &DropIndexSqlBuilder{Dialect: DialectPostgres, Name: name, Table: table} default: panic(fmt.Sprintf("unexpected dialect: %d", d)) } } diff --git a/docker-setup.sh b/docker-setup.sh index 46f41f3..ed845d0 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -1,4 +1,4 @@ #!/bin/bash -docker-compose exec db sh -c 'exec mysql -u root -pchangeme writefreely < /tmp/schema.sql' +docker-compose exec db sh -c 'exec mysql -u root -pchangeme writefreely < /tmp/mysql.sql' docker exec writefreely_web_1 writefreely --gen-keys docker exec -it writefreely_web_1 writefreely --config \ No newline at end of file diff --git a/go.mod b/go.mod index d3a75ad..7eabc9a 100644 --- a/go.mod +++ b/go.mod @@ -1,98 +1,100 @@ module github.com/writefreely/writefreely require ( github.com/PuerkitoBio/goquery v1.8.1 // indirect github.com/aymerick/douceur v0.2.0 github.com/clbanning/mxj v1.8.4 // indirect github.com/dustin/go-humanize v1.0.1 github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect github.com/fatih/color v1.17.0 github.com/go-ini/ini v1.67.0 github.com/go-sql-driver/mysql v1.8.1 github.com/go-test/deep v1.0.1 // indirect github.com/gobuffalo/envy v1.9.0 // indirect github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect github.com/gorilla/csrf v1.7.2 github.com/gorilla/feeds v1.1.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/schema v1.4.1 github.com/gorilla/sessions v1.3.0 github.com/gosimple/slug v1.14.0 github.com/guregu/null v4.0.0+incompatible github.com/hashicorp/go-multierror v1.1.1 github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec github.com/mailgun/mailgun-go v2.0.0+incompatible github.com/manifoldco/promptui v0.9.0 github.com/mattn/go-sqlite3 v1.14.21 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mitchellh/go-wordwrap v1.0.1 github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d github.com/onsi/ginkgo v1.16.4 // indirect github.com/onsi/gomega v1.13.0 // indirect github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect github.com/stretchr/testify v1.9.0 github.com/urfave/cli/v2 v2.27.4 github.com/writeas/activity v0.1.2 github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 github.com/writeas/go-strip-markdown/v2 v2.1.1 github.com/writeas/go-webfinger v1.1.0 github.com/writeas/httpsig v1.0.0 github.com/writeas/impart v1.1.1 github.com/writeas/import v0.2.1 github.com/writeas/monday v1.3.0 github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431 github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b github.com/writefreely/go-nodeinfo v1.2.0 golang.org/x/crypto v0.28.0 golang.org/x/net v0.30.0 ) require github.com/xhit/go-simple-mail/v2 v2.16.0 +require github.com/lib/pq v1.10.9 + require ( code.as/core/socks v1.0.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect github.com/beevik/etree v1.1.0 // indirect github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect github.com/gofrs/uuid v3.3.0+incompatible // indirect github.com/gologme/log v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/joho/godotenv v1.3.0 // indirect github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect github.com/writeas/go-writeas/v2 v2.0.2 // indirect github.com/writeas/openssl-go v1.0.0 // indirect github.com/writeas/slug v1.2.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.19.0 // indirect gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) -go 1.21 +go 1.22 diff --git a/go.sum b/go.sum index 6190de7..c3058df 100644 --- a/go.sum +++ b/go.sum @@ -1,333 +1,334 @@ code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs= code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8= github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clbanning/mxj v1.8.3/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs= github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0= github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk= github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8= github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg= github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gobuffalo/envy v1.9.0 h1:eZR0DuEgVLfeIb1zIKt3bT4YovIMf9O9LXQeCZLXpqE= github.com/gobuffalo/envy v1.9.0/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c= github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/feeds v1.1.2 h1:pxzZ5PD3RJdhFH2FsJJ4x6PqMqbgFk1+Vez4XWBW8Iw= github.com/gorilla/feeds v1.1.2/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg= github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= github.com/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es= github.com/gosimple/slug v1.14.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw= github.com/guregu/null v4.0.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM= github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI= github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o= github.com/mailgun/mailgun-go v2.0.0+incompatible/go.mod h1:NWTyU+O4aczg/nsGhQnvHL6v2n5Gy6Sv5tNDVvC6FbU= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.21 h1:IXocQLOykluc3xPE0Lvy8FtggMz1G+U3mEjg+0zGizc= github.com/mattn/go-sqlite3 v1.14.21/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak= github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY= github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0= github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 h1:bm/7gYo6y3GxtTa1qyUFyCk29CTnBAKt7z4D2MASYrw= github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o= github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28= github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA= github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q= github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc= github.com/writeas/go-writeas v1.1.0/go.mod h1:oh9U1rWaiE0p3kzdKwwvOpNXgp0P0IELI7OLOwV4fkA= github.com/writeas/go-writeas/v2 v2.0.2 h1:akvdMg89U5oBJiCkBwOXljVLTqP354uN6qnG2oOMrbk= github.com/writeas/go-writeas/v2 v2.0.2/go.mod h1:9sjczQJKmru925fLzg0usrU1R1tE4vBmQtGnItUMR0M= github.com/writeas/httpsig v1.0.0 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A= github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY= github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= github.com/writeas/impart v1.1.1 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o= github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= github.com/writeas/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg= github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM= github.com/writeas/monday v1.3.0 h1:h51wJ0DULXIDZ1w11zutLL7YCBRO5LznXISSzqVLZeA= github.com/writeas/monday v1.3.0/go.mod h1:9/CdGLDdIeAvzvf4oeihX++PE/qXUT2+tUlPQKCfRWY= github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o= github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA= github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt6ym/+FvIz+KvKEObSSc5ye+95zbTjVU= github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g= github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ= github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431 h1:ruqL2u87k504PXkR/fC4DcfZyyHmCindlpjOQKmyOsY= github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431/go.mod h1:7+idL4Y4woF7MnUfNX2mvkaQ8nLIJXths2y5iYPtA3k= github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ50NNi5k9yrFeyFszt3LyqyVK4+xUHFYY8B0= github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b/go.mod h1:T2UVVzt+R5KSSZe2xRSytnwc2M9AoDegi7foeIsik+M= github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss= github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA= github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/migrations/drivers.go b/migrations/drivers.go index 5c6958a..486ea4d 100644 --- a/migrations/drivers.go +++ b/migrations/drivers.go @@ -1,110 +1,242 @@ /* * Copyright © 2019 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package migrations import ( "fmt" ) // TODO: use now() from writefreely pkg func (db *datastore) now() string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return "strftime('%Y-%m-%d %H:%M:%S','now')" + case driverMySQL: + return "NOW()" + case driverPostgres: + return "NOW()" } - return "NOW()" + + return "" // placeholder } func (db *datastore) typeInt() string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return "INTEGER" + case driverMySQL: + return "INT" + case driverPostgres: + return "INT" } - return "INT" + + return "" // placeholder } func (db *datastore) typeSmallInt() string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return "INTEGER" + case driverMySQL: + return "SMALLINT" + case driverPostgres: + return "SMALLINT" } - return "SMALLINT" + + return "" // placeholder } func (db *datastore) typeTinyInt() string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return "INTEGER" + case driverMySQL: + return "TINYINT" + case driverPostgres: + return "SMALLINT" } - return "TINYINT" + + return "" // placeholder } func (db *datastore) typeText() string { return "TEXT" } func (db *datastore) typeChar(l int) string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return "TEXT" + case driverMySQL: + return fmt.Sprintf("CHAR(%d)", l) + case driverPostgres: + return fmt.Sprintf("CHAR(%d)", l) } - return fmt.Sprintf("CHAR(%d)", l) + + return "" // placeholder } func (db *datastore) typeVarChar(l int) string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return "TEXT" + case driverMySQL: + return fmt.Sprintf("VARCHAR(%d)", l) + case driverPostgres: + return fmt.Sprintf("VARCHAR(%d)", l) } - return fmt.Sprintf("VARCHAR(%d)", l) + + return "" // placeholder } func (db *datastore) typeVarBinary(l int) string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return "BLOB" + case driverMySQL: + return fmt.Sprintf("VARBINARY(%d)", l) + case driverPostgres: + return "BYTEA" } - return fmt.Sprintf("VARBINARY(%d)", l) + + return "" // placeholder } func (db *datastore) typeBool() string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return "INTEGER" + case driverMySQL: + return "TINYINT(1)" + case driverPostgres: + return "BOOLEAN" } - return "TINYINT(1)" + + return "" // placeholder } func (db *datastore) typeDateTime() string { - return "DATETIME" + switch db.driverName { + case driverSQLite: + return "DATETIME" + case driverMySQL: + return "DATETIME" + case driverPostgres: + return "TIMESTAMP" + } + + return "" // placeholder } func (db *datastore) typeIntPrimaryKey() string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: // From docs: "In SQLite, a column with type INTEGER PRIMARY KEY is an alias for the ROWID (except in WITHOUT // ROWID tables) which is always a 64-bit signed integer." return "INTEGER PRIMARY KEY" + case driverMySQL: + return "INT AUTO_INCREMENT PRIMARY KEY" + case driverPostgres: + return "SERIAL PRIMARY KEY" } - return "INT AUTO_INCREMENT PRIMARY KEY" + + return "" // placeholder } func (db *datastore) collateMultiByte() string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: + return "" + case driverMySQL: + return " COLLATE utf8_bin" + case driverPostgres: return "" } - return " COLLATE utf8_bin" + + return "" // placeholder } func (db *datastore) engine() string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: + return "" + case driverMySQL: + return " ENGINE = InnoDB" + case driverPostgres: return "" } - return " ENGINE = InnoDB" + + return "" // placeholder } func (db *datastore) after(colName string) string { - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: return "" + case driverMySQL: + return fmt.Sprintf(" AFTER %s", colName) + case driverPostgres: + return "" + } + + return "" // placeholder +} + +func (db *datastore) boolTrue() string { + switch db.driverName { + case driverSQLite: + return "1" + case driverMySQL: + return "1" + case driverPostgres: + return "TRUE" } - return " AFTER " + colName + + return "" // placeholder } + +func (db *datastore) boolFalse() string { + switch db.driverName { + case driverSQLite: + return "0" + case driverMySQL: + return "0" + case driverPostgres: + return "FALSE" + } + + return "" // placeholder +} + +func (db *datastore) QueryWrap(q string) string { + if db.driverName != driverPostgres { + return q + } + + output := "" + escape := false + ctr := 0 + + for i := range len(q) { + if q[i] == '\'' || q[i] == '`' { + escape = !escape + } + + if q[i] == '?' && !escape { + ctr += 1 + output += fmt.Sprintf("$%d", ctr) + } else { + output += string(q[i]) + } + } + + return output +} \ No newline at end of file diff --git a/migrations/migrations.go b/migrations/migrations.go index 6b5b094..0e99696 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -1,151 +1,160 @@ /* * Copyright © 2019 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ // Package migrations contains database migrations for WriteFreely package migrations import ( "database/sql" + "fmt" "github.com/writeas/web-core/log" ) // TODO: refactor to use the datastore struct from writefreely pkg type datastore struct { *sql.DB driverName string } func NewDatastore(db *sql.DB, dn string) *datastore { return &datastore{db, dn} } // TODO: use these consts from writefreely pkg const ( - driverMySQL = "mysql" - driverSQLite = "sqlite3" + driverMySQL = "mysql" + driverPostgres = "postgres" + driverSQLite = "sqlite3" ) type Migration interface { Description() string Migrate(db *datastore) error } type migration struct { description string migrate func(db *datastore) error } func New(d string, fn func(db *datastore) error) Migration { return &migration{d, fn} } func (m *migration) Description() string { return m.description } func (m *migration) Migrate(db *datastore) error { return m.migrate(db) } var migrations = []Migration{ New("support user invites", supportUserInvites), // -> V1 (v0.8.0) New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0) New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0) New("support oauth", oauth), // V3 -> V4 New("support slack oauth", oauthSlack), // V4 -> v5 New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6 New("support oauth attach", oauthAttach), // V6 -> V7 New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0) New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9 New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0) New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11 New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0) New("support newsletters", supportLetters), // V12 -> V13 New("support password resetting", supportPassReset), // V13 -> V14 New("speed up blog post retrieval", addPostRetrievalIndex), // V14 -> V15 New("support ActivityPub likes", supportRemoteLikes), // V15 -> V16 (v0.16.0) } // CurrentVer returns the current migration version the application is on func CurrentVer() int { return len(migrations) } func SetInitialMigrations(db *datastore) error { // Included schema files represent changes up to V1, so note that in the database - _, err := db.Exec("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)", 1, "") + _, err := db.Exec(db.QueryWrap("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)"), 1, "") if err != nil { return err } return nil } func Migrate(db *datastore) error { var version int var err error if db.tableExists("appmigrations") { err = db.QueryRow("SELECT MAX(version) FROM appmigrations").Scan(&version) if err != nil { return err } } else { log.Info("Initializing appmigrations table...") version = 0 _, err = db.Exec(`CREATE TABLE appmigrations ( version ` + db.typeInt() + ` NOT NULL, migrated ` + db.typeDateTime() + ` NOT NULL, result ` + db.typeText() + ` NOT NULL ) ` + db.engine() + `;`) if err != nil { return err } } if len(migrations[version:]) > 0 { for i, m := range migrations[version:] { curVer := version + i + 1 log.Info("Migrating to V%d: %s", curVer, m.Description()) err = m.Migrate(db) if err != nil { return err } // Update migrations table - _, err = db.Exec("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)", curVer, "") + _, err = db.Exec(db.QueryWrap("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)"), curVer, "") if err != nil { return err } } } else { log.Info("Database up-to-date. No migrations to run.") } return nil } func (db *datastore) tableExists(t string) bool { var dummy string var err error - if db.driverName == driverSQLite { - err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", t).Scan(&dummy) - } else { - err = db.QueryRow("SHOW TABLES LIKE '" + t + "'").Scan(&dummy) + var q string + switch db.driverName { + case driverSQLite: + q = fmt.Sprintf("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '%s'", t) + case driverMySQL: + q = fmt.Sprintf("SHOW TABLES LIKE '%s'", t) + case driverPostgres: + q = fmt.Sprintf("SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = '%s'", t) } + + err = db.QueryRow(q).Scan(&dummy) + switch { case err == sql.ErrNoRows: return false case err != nil: - log.Error("Couldn't SHOW TABLES: %v", err) + log.Error("Couldn't SHOW TABLES: %v", err.Error()) return false } return true } diff --git a/migrations/v13.go b/migrations/v13.go index 908ceac..f7c0a6d 100644 --- a/migrations/v13.go +++ b/migrations/v13.go @@ -1,58 +1,58 @@ /* * Copyright © 2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package migrations func supportLetters(db *datastore) error { t, err := db.Begin() if err != nil { t.Rollback() return err } _, err = t.Exec(`CREATE TABLE publishjobs ( id ` + db.typeIntPrimaryKey() + `, post_id ` + db.typeVarChar(16) + ` not null, action ` + db.typeVarChar(16) + ` not null, delay ` + db.typeTinyInt() + ` not null )`) if err != nil { t.Rollback() return err } _, err = t.Exec(`CREATE TABLE emailsubscribers ( id ` + db.typeChar(8) + ` not null, collection_id ` + db.typeInt() + ` not null, user_id ` + db.typeInt() + ` null, email ` + db.typeVarChar(255) + ` null, subscribed ` + db.typeDateTime() + ` not null, token ` + db.typeChar(16) + ` not null, - confirmed ` + db.typeBool() + ` default 0 not null, - allow_export ` + db.typeBool() + ` default 0 not null, + confirmed ` + db.typeBool() + ` default ` + db.boolFalse() + ` not null, + allow_export ` + db.typeBool() + ` default ` + db.boolFalse() + ` not null, constraint eu_coll_email unique (collection_id, email), constraint eu_coll_user unique (collection_id, user_id), PRIMARY KEY (id) )`) if err != nil { t.Rollback() return err } err = t.Commit() if err != nil { t.Rollback() return err } return nil } diff --git a/migrations/v14.go b/migrations/v14.go index 2883001..14a8140 100644 --- a/migrations/v14.go +++ b/migrations/v14.go @@ -1,37 +1,37 @@ /* * Copyright © 2023 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package migrations func supportPassReset(db *datastore) error { t, err := db.Begin() if err != nil { t.Rollback() return err } _, err = t.Exec(`CREATE TABLE password_resets ( user_id ` + db.typeInt() + ` not null, token ` + db.typeChar(32) + ` not null primary key, - used ` + db.typeBool() + ` default 0 not null, + used ` + db.typeBool() + ` default ` + db.boolFalse() + ` not null, created ` + db.typeDateTime() + ` not null )`) if err != nil { t.Rollback() return err } err = t.Commit() if err != nil { t.Rollback() return err } return nil } diff --git a/migrations/v15.go b/migrations/v15.go index 0875c5a..2a88481 100644 --- a/migrations/v15.go +++ b/migrations/v15.go @@ -1,33 +1,42 @@ /* * Copyright © 2023 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package migrations func addPostRetrievalIndex(db *datastore) error { t, err := db.Begin() if err != nil { t.Rollback() return err } - _, err = t.Exec("CREATE INDEX posts_get_collection_index ON posts (`collection_id`, `pinned_position`, `created`)") + switch db.driverName { + case driverSQLite: + _, err = t.Exec("CREATE INDEX posts_get_collection_index ON posts (`collection_id`, `pinned_position`, `created`)") + case driverMySQL: + _, err = t.Exec("CREATE INDEX posts_get_collection_index ON posts (`collection_id`, `pinned_position`, `created`)") + case driverPostgres: + _, err = t.Exec("CREATE INDEX posts_get_collection_idx ON posts (collection_id, pinned_position, created)") + } + + if err != nil { t.Rollback() return err } err = t.Commit() if err != nil { t.Rollback() return err } return nil } diff --git a/migrations/v4.go b/migrations/v4.go index 4ae267d..7b09e9d 100644 --- a/migrations/v4.go +++ b/migrations/v4.go @@ -1,54 +1,61 @@ /* * Copyright © 2019-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package migrations import ( "context" "database/sql" wf_db "github.com/writefreely/writefreely/db" ) func oauth(db *datastore) error { - dialect := wf_db.DialectMySQL - if db.driverName == driverSQLite { + var dialect wf_db.DialectType + + switch db.driverName { + case driverSQLite: dialect = wf_db.DialectSQLite + case driverMySQL: + dialect = wf_db.DialectMySQL + case driverPostgres: + dialect = wf_db.DialectPostgres } + return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { createTableUsersOauth, err := dialect. Table("oauth_users"). SetIfNotExists(false). Column(dialect.Column("user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)). Column(dialect.Column("remote_user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)). ToSQL() if err != nil { return err } createTableOauthClientState, err := dialect. Table("oauth_client_states"). SetIfNotExists(false). Column(dialect.Column("state", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 255})). Column(dialect.Column("used", wf_db.ColumnTypeBool, wf_db.UnsetSize)). Column(dialect.Column("created_at", wf_db.ColumnTypeDateTime, wf_db.UnsetSize).SetDefaultCurrentTimestamp()). UniqueConstraint("state"). ToSQL() if err != nil { return err } for _, table := range []string{createTableUsersOauth, createTableOauthClientState} { if _, err := tx.ExecContext(ctx, table); err != nil { return err } } return nil }) } diff --git a/migrations/v5.go b/migrations/v5.go index db18fa1..b10afbf 100644 --- a/migrations/v5.go +++ b/migrations/v5.go @@ -1,88 +1,95 @@ /* * Copyright © 2019-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package migrations import ( "context" "database/sql" wf_db "github.com/writefreely/writefreely/db" ) func oauthSlack(db *datastore) error { - dialect := wf_db.DialectMySQL - if db.driverName == driverSQLite { + var dialect wf_db.DialectType + + switch db.driverName { + case driverSQLite: dialect = wf_db.DialectSQLite + case driverMySQL: + dialect = wf_db.DialectMySQL + case driverPostgres: + dialect = wf_db.DialectPostgres } + return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { builders := []wf_db.SQLBuilder{ dialect. AlterTable("oauth_client_states"). AddColumn(dialect. Column( "provider", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")), dialect. AlterTable("oauth_client_states"). AddColumn(dialect. Column( "client_id", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")), dialect. AlterTable("oauth_users"). AddColumn(dialect. Column( "provider", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")), dialect. AlterTable("oauth_users"). AddColumn(dialect. Column( "client_id", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")), dialect. AlterTable("oauth_users"). AddColumn(dialect. Column( "access_token", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 512}).SetDefault("")), dialect.CreateUniqueIndex("oauth_users_uk", "oauth_users", "user_id", "provider", "client_id"), } if dialect != wf_db.DialectSQLite { // This updates the length of the `remote_user_id` column. It isn't needed for SQLite databases. builders = append(builders, dialect. AlterTable("oauth_users"). ChangeColumn("remote_user_id", dialect. Column( "remote_user_id", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 128}))) } for _, builder := range builders { query, err := builder.ToSQL() if err != nil { return err } if _, err := tx.ExecContext(ctx, query); err != nil { return err } } return nil }) } diff --git a/migrations/v7.go b/migrations/v7.go index 2056aa0..339a8d9 100644 --- a/migrations/v7.go +++ b/migrations/v7.go @@ -1,46 +1,53 @@ /* * Copyright © 2020-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package migrations import ( "context" "database/sql" wf_db "github.com/writefreely/writefreely/db" ) func oauthAttach(db *datastore) error { - dialect := wf_db.DialectMySQL - if db.driverName == driverSQLite { + var dialect wf_db.DialectType + + switch db.driverName { + case driverSQLite: dialect = wf_db.DialectSQLite + case driverMySQL: + dialect = wf_db.DialectMySQL + case driverPostgres: + dialect = wf_db.DialectPostgres } + return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { builders := []wf_db.SQLBuilder{ dialect. AlterTable("oauth_client_states"). AddColumn(dialect. Column( "attach_user_id", wf_db.ColumnTypeInteger, wf_db.OptionalInt{Set: true, Value: 24}).SetNullable(true)), } for _, builder := range builders { query, err := builder.ToSQL() if err != nil { return err } if _, err := tx.ExecContext(ctx, query); err != nil { return err } } return nil }) } diff --git a/migrations/v8.go b/migrations/v8.go index 36001af..5cb164e 100644 --- a/migrations/v8.go +++ b/migrations/v8.go @@ -1,45 +1,52 @@ /* * Copyright © 2020-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package migrations import ( "context" "database/sql" wf_db "github.com/writefreely/writefreely/db" ) func oauthInvites(db *datastore) error { - dialect := wf_db.DialectMySQL - if db.driverName == driverSQLite { + var dialect wf_db.DialectType + + switch db.driverName { + case driverSQLite: dialect = wf_db.DialectSQLite + case driverMySQL: + dialect = wf_db.DialectMySQL + case driverPostgres: + dialect = wf_db.DialectPostgres } + return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { builders := []wf_db.SQLBuilder{ dialect. AlterTable("oauth_client_states"). AddColumn(dialect.Column("invite_code", wf_db.ColumnTypeChar, wf_db.OptionalInt{ Set: true, Value: 6, }).SetNullable(true)), } for _, builder := range builders { query, err := builder.ToSQL() if err != nil { return err } if _, err := tx.ExecContext(ctx, query); err != nil { return err } } return nil }) } diff --git a/migrations/v9.go b/migrations/v9.go index f542189..a006956 100644 --- a/migrations/v9.go +++ b/migrations/v9.go @@ -1,37 +1,41 @@ /* * Copyright © 2020 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package migrations func optimizeDrafts(db *datastore) error { t, err := db.Begin() if err != nil { t.Rollback() return err } - if db.driverName == driverSQLite { + switch db.driverName { + case driverSQLite: _, err = t.Exec(`CREATE INDEX key_owner_post_id ON posts (owner_id, id)`) - } else { + case driverMySQL: _, err = t.Exec(`ALTER TABLE posts ADD INDEX(owner_id, id)`) + case driverPostgres: + _, err = t.Exec(`CREATE INDEX key_owner_post_idx ON posts (owner_id, id)`) } + if err != nil { t.Rollback() return err } err = t.Commit() if err != nil { t.Rollback() return err } return nil } diff --git a/schema.sql b/mysql.sql similarity index 100% rename from schema.sql rename to mysql.sql diff --git a/postgres.sql b/postgres.sql new file mode 100644 index 0000000..1da5c1a --- /dev/null +++ b/postgres.sql @@ -0,0 +1,148 @@ +CREATE TABLE IF NOT EXISTS "accesstokens" ( + "token" BYTEA PRIMARY KEY, + "user_id" INT NOT NULL, + "sudo" BOOLEAN NOT NULL DEFAULT FALSE, + "one_time" BOOLEAN NOT NULL DEFAULT FALSE, + "created" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires" TIMESTAMP DEFAULT NULL, + "user_agent" VARCHAR(255) DEFAULT NULL +); + + +CREATE TABLE IF NOT EXISTS "appcontent" ( + "id" VARCHAR(36) PRIMARY KEY, + "content" TEXT NOT NULL, + "updated" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + +CREATE TABLE "appmigrations" ( + "version" INT NOT NULL, + "migrated" TIMESTAMP NOT NULL, + "result" TEXT NOT NULL +); + + +CREATE TABLE IF NOT EXISTS "collectionattributes" ( + "collection_id" INT NOT NULL, + "attribute" VARCHAR(128) NOT NULL, + "value" VARCHAR(255) NOT NULL, + PRIMARY KEY ("collection_id", "attribute") +); + + +CREATE TABLE IF NOT EXISTS "collectionkeys" ( + "collection_id" INT PRIMARY KEY, + "public_key" BYTEA NOT NULL, + "private_key" BYTEA NOT NULL +); + + +CREATE TABLE IF NOT EXISTS "collectionpasswords" ( + "collection_id" INT PRIMARY KEY, + "password" CHAR(60) NOT NULL +); + + +CREATE TABLE IF NOT EXISTS "collectionredirects" ( + "prev_alias" VARCHAR(100) NOT NULL PRIMARY KEY, + "new_alias" VARCHAR(100) NOT NULL +); + + +CREATE TABLE IF NOT EXISTS "collections" ( + "id" SERIAL PRIMARY KEY, + "alias" VARCHAR(100) DEFAULT NULL, + "title" VARCHAR(255) NOT NULL, + "description" VARCHAR(160) NOT NULL, + "style_sheet" TEXT, + "script" TEXT DEFAULT NULL, + "format" VARCHAR(8) DEFAULT NULL, + "privacy" INT NOT NULL, + "owner_id" INT NOT NULL, + "view_count" INT NOT NULL +); + + +CREATE TABLE IF NOT EXISTS "posts" ( + "id" CHAR(16) PRIMARY KEY, + "slug" VARCHAR(100) DEFAULT NULL, + "modify_token" CHAR(32) DEFAULT NULL, + "text_appearance" CHAR(4) NOT NULL DEFAULT 'norm', + "language" CHAR(2) DEFAULT NULL, + "rtl" BOOLEAN DEFAULT NULL, + "privacy" INT NOT NULL, + "owner_id" INT DEFAULT NULL, + "collection_id" INT DEFAULT NULL, + "pinned_position" SMALLINT NULL, + "created" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "view_count" INT NOT NULL, + "title" VARCHAR(160) NOT NULL, + "content" TEXT NOT NULL +); + + +CREATE TABLE IF NOT EXISTS "remotefollows" ( + "collection_id" INT NOT NULL, + "remote_user_id" INT NOT NULL, + "created" TIMESTAMP NOT NULL, + PRIMARY KEY ("collection_id", "remote_user_id") +); + + +CREATE TABLE IF NOT EXISTS "remoteuserkeys" ( + "id" VARCHAR(255) PRIMARY KEY, + "remote_user_id" INT NOT NULL, + "public_key" BYTEA NOT NULL +); + + +CREATE TABLE IF NOT EXISTS "remoteusers" ( + "id" SERIAL PRIMARY KEY, + "actor_id" VARCHAR(255) NOT NULL, + "inbox" VARCHAR(255) NOT NULL, + "shared_inbox" VARCHAR(255) NOT NULL +); + + +CREATE TABLE IF NOT EXISTS "userattributes" ( + "user_id" INT NOT NULL, + "attribute" VARCHAR(64) NOT NULL, + "value" VARCHAR(255) NOT NULL, + PRIMARY KEY ("user_id", "attribute") +); + + +CREATE TABLE "userinvites" ( + "id" CHAR(6) NOT NULL, + "owner_id" INT NOT NULL, + "max_uses" SMALLINT DEFAULT NULL, + "created" TIMESTAMP NOT NULL, + "expires" TIMESTAMP DEFAULT NULL, + "inactive" BOOLEAN NOT NULL +); + + +CREATE TABLE "users" ( + "id" SERIAL PRIMARY KEY, + "username" VARCHAR(100) NOT NULL, + "password" CHAR(60) NOT NULL, + "email" BYTEA DEFAULT NULL, + "created" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + +CREATE TABLE "usersinvited" ( + "invite_id" CHAR(6) NOT NULL, + "user_id" INT NOT NULL +); + + +CREATE INDEX IF NOT EXISTS "alias" ON "collections" ("alias"); +CREATE UNIQUE INDEX IF NOT EXISTS "id_slug" ON "posts" ("collection_id", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "owner_id" ON "posts" ("owner_id", "id"); +CREATE INDEX IF NOT EXISTS "privacy_id" ON "posts" ("privacy", "id"); +CREATE UNIQUE INDEX IF NOT EXISTS "remote_user_id" ON "remoteuserkeys" ("remote_user_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "actor_id" ON "remoteusers" ("actor_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "username" ON "users" ("username"); diff --git a/read.go b/read.go index 46f07ee..23000b4 100644 --- a/read.go +++ b/read.go @@ -1,340 +1,340 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "fmt" "html/template" "math" "net/http" "strconv" "time" . "github.com/gorilla/feeds" "github.com/gorilla/mux" stripmd "github.com/writeas/go-strip-markdown/v2" "github.com/writeas/impart" "github.com/writeas/web-core/log" "github.com/writeas/web-core/memo" "github.com/writefreely/writefreely/page" ) const ( tlFeedLimit = 100 tlAPIPageLimit = 10 tlMaxAuthorPosts = 5 tlPostsPerPage = 16 tlMaxPostCache = 250 tlCacheDur = 10 * time.Minute ) type localTimeline struct { m *memo.Memo posts *[]PublicPost // Configuration values postsPerPage int } type readPublication struct { page.StaticPage Posts *[]PublicPost CurrentPage int TotalPages int SelTopic string IsAdmin bool CanInvite bool // Customizable page content ContentTitle string Content template.HTML } func initLocalTimeline(app *App) { app.timeline = &localTimeline{ postsPerPage: tlPostsPerPage, m: memo.New(app.FetchPublicPosts, tlCacheDur), } } // satisfies memo.Func func (app *App) FetchPublicPosts() (interface{}, error) { // Conditions limit := fmt.Sprintf("LIMIT %d", tlMaxPostCache) // This is better than the hard limit when limiting posts from individual authors // ageCond := `p.created >= ` + app.db.dateSub(3, "month") + ` AND ` // Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months rows, err := app.db.Query(`SELECT p.id, c.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated FROM collections c LEFT JOIN posts p ON p.collection_id = c.id LEFT JOIN users u ON u.id = p.owner_id - WHERE c.privacy = 1 AND (p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0 + WHERE c.privacy = `+app.db.BoolTrue()+` AND (p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0 ORDER BY p.created DESC ` + limit) if err != nil { log.Error("Failed selecting from posts: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()} } defer rows.Close() ap := map[string]uint{} posts := []PublicPost{} for rows.Next() { p := &Post{} c := &Collection{} var alias, title sql.NullString err = rows.Scan(&p.ID, &c.ID, &alias, &title, &p.Slug, &p.Title, &p.Content, &p.Font, &p.Language, &p.RTL, &p.Created, &p.Updated) if err != nil { log.Error("[READ] Unable to scan row, skipping: %v", err) continue } c.hostName = app.cfg.App.Host isCollectionPost := alias.Valid if isCollectionPost { c.Alias = alias.String if c.Alias != "" && ap[c.Alias] == tlMaxAuthorPosts { // Don't add post if we've hit the post-per-author limit continue } c.Public = true c.Title = title.String c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer") } p.extractData() p.handlePremiumContent(c, false, false, app.cfg) p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), "", app.cfg)) fp := p.processPost() if isCollectionPost { fp.Collection = &CollectionObj{Collection: *c} } posts = append(posts, fp) ap[c.Alias]++ } return posts, nil } func viewLocalTimelineAPI(app *App, w http.ResponseWriter, r *http.Request) error { updateTimelineCache(app.timeline, false) skip, _ := strconv.Atoi(r.FormValue("skip")) posts := []PublicPost{} for i := skip; i < skip+tlAPIPageLimit && i < len(*app.timeline.posts); i++ { posts = append(posts, (*app.timeline.posts)[i]) } return impart.WriteSuccess(w, posts, http.StatusOK) } func viewLocalTimeline(app *App, w http.ResponseWriter, r *http.Request) error { if !app.cfg.App.LocalTimeline { return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} } vars := mux.Vars(r) var p int page := 1 p, _ = strconv.Atoi(vars["page"]) if p > 0 { page = p } return showLocalTimeline(app, w, r, page, vars["author"], vars["tag"]) } // updateTimelineCache will reset and update the cache if it is stale or // the boolean passed in is true. func updateTimelineCache(tl *localTimeline, reset bool) { if reset { tl.m.Reset() } // Fetch posts if the cache is empty, has been reset or enough time has // passed since last cache. if tl.posts == nil || reset || tl.m.Invalidate() { log.Info("[READ] Updating post cache") postsInterfaces, err := tl.m.Get() if err != nil { log.Error("[READ] Unable to cache posts: %v", err) } else { castPosts := postsInterfaces.([]PublicPost) tl.posts = &castPosts } } } func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page int, author, tag string) error { updateTimelineCache(app.timeline, false) pl := len(*(app.timeline.posts)) ttlPages := int(math.Ceil(float64(pl) / float64(app.timeline.postsPerPage))) start := 0 if page > 1 { start = app.timeline.postsPerPage * (page - 1) if start > pl { return impart.HTTPError{http.StatusFound, fmt.Sprintf("/read/p/%d", ttlPages)} } } end := app.timeline.postsPerPage * page if end > pl { end = pl } var posts []PublicPost if author != "" { posts = []PublicPost{} for _, p := range *app.timeline.posts { if author == "anonymous" { if p.Collection == nil { posts = append(posts, p) } } else if p.Collection != nil && p.Collection.Alias == author { posts = append(posts, p) } } } else if tag != "" { posts = []PublicPost{} for _, p := range *app.timeline.posts { if p.HasTag(tag) { posts = append(posts, p) } } } else { posts = *app.timeline.posts posts = posts[start:end] } d := &readPublication{ StaticPage: pageForReq(app, r), Posts: &posts, CurrentPage: page, TotalPages: ttlPages, SelTopic: tag, } u := getUserSession(app, r) d.IsAdmin = u != nil && u.IsAdmin() d.CanInvite = canUserInvite(app.cfg, d.IsAdmin) c, err := getReaderSection(app) if err != nil { return err } d.ContentTitle = c.Title.String d.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg)) err = templates["read"].ExecuteTemplate(w, "base", d) if err != nil { log.Error("Unable to render reader: %v", err) fmt.Fprintf(w, ":(") } return nil } // NextPageURL provides a full URL for the next page of collection posts func (c *readPublication) NextPageURL(n int) string { return fmt.Sprintf("/read/p/%d", n+1) } // PrevPageURL provides a full URL for the previous page of collection posts, // returning a /page/N result for pages >1 func (c *readPublication) PrevPageURL(n int) string { if n == 2 { // Previous page is 1; no need for /p/ prefix return "/read" } return fmt.Sprintf("/read/p/%d", n-1) } // handlePostIDRedirect handles a route where a post ID is given and redirects // the user to the canonical post URL. func handlePostIDRedirect(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) postID := vars["post"] p, err := app.db.GetPost(postID, 0) if err != nil { return err } if !p.CollectionID.Valid { // No collection; send to normal URL // NOTE: not handling single user blogs here since this handler is only used for the Reader return impart.HTTPError{http.StatusFound, app.cfg.App.Host + "/" + postID + ".md"} } c, err := app.db.GetCollectionBy("id = ?", fmt.Sprintf("%d", p.CollectionID.Int64)) if err != nil { return err } c.hostName = app.cfg.App.Host // Retrieve collection information and send user to canonical URL return impart.HTTPError{http.StatusFound, c.CanonicalURL() + p.Slug.String} } func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) error { if !app.cfg.App.LocalTimeline { return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} } updateTimelineCache(app.timeline, false) feed := &Feed{ Title: app.cfg.App.SiteName + " Reader", Link: &Link{Href: app.cfg.App.Host}, Description: "Read the latest posts from " + app.cfg.App.SiteName + ".", Created: time.Now(), } c := 0 var title, permalink, author string for _, p := range *app.timeline.posts { if c == tlFeedLimit { break } title = p.PlainDisplayTitle() permalink = p.CanonicalURL(app.cfg.App.Host) if p.Collection != nil { author = p.Collection.Title } else { author = "Anonymous" } i := &Item{ Id: app.cfg.App.Host + "/read/a/" + p.ID, Title: title, Link: &Link{Href: permalink}, Description: "", Content: applyMarkdown([]byte(p.Content), "", app.cfg), Author: &Author{author, ""}, Created: p.Created, Updated: p.Updated, } feed.Items = append(feed.Items, i) c++ } rss, err := feed.ToRss() if err != nil { return err } fmt.Fprint(w, rss) return nil }