Scheduled
{{end}}{{if .Title.String}}diff --git a/Makefile b/Makefile index 782e680..85f02d3 100644 --- a/Makefile +++ b/Makefile @@ -25,31 +25,37 @@ build-no-sqlite: assets-no-sqlite deps-no-sqlite build-linux: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u github.com/karalabe/xgo; \ + $(GOGET) -u src.techknowlogick.com/xgo; \ fi xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely build-windows: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u github.com/karalabe/xgo; \ + $(GOGET) -u src.techknowlogick.com/xgo; \ fi xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely build-darwin: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u github.com/karalabe/xgo; \ + $(GOGET) -u src.techknowlogick.com/xgo; \ fi xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely +build-arm6: deps + @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + $(GOGET) -u src.techknowlogick.com/xgo; \ + fi + xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely + build-arm7: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u github.com/karalabe/xgo; \ + $(GOGET) -u src.techknowlogick.com/xgo; \ fi xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely build-arm64: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GOGET) -u github.com/karalabe/xgo; \ + $(GOGET) -u src.techknowlogick.com/xgo; \ fi xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely @@ -85,6 +91,10 @@ release : clean ui assets mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) + $(MAKE) build-arm6 + mv build/$(BINARY_NAME)-linux-arm-6 $(BUILDPATH)/$(BINARY_NAME) + tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm6.tar.gz -C build $(BINARY_NAME) + rm $(BUILDPATH)/$(BINARY_NAME) $(MAKE) build-arm7 mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME) @@ -145,7 +155,7 @@ $(TMPBIN)/go-bindata: deps $(TMPBIN) $(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata $(TMPBIN)/xgo: deps $(TMPBIN) - $(GOBUILD) -o $(TMPBIN)/xgo github.com/karalabe/xgo + $(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo ci-assets : $(TMPBIN)/go-bindata $(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql diff --git a/account.go b/account.go index 2dcfd27..5dba924 100644 --- a/account.go +++ b/account.go @@ -746,7 +746,7 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err log.Error("unable to fetch collections: %v", err) } - suspended, err := app.db.IsUserSuspended(u.ID) + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("view articles: %v", err) } @@ -754,12 +754,12 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err *UserPage AnonymousPosts *[]PublicPost Collections *[]Collection - Suspended bool + Silenced bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f), AnonymousPosts: p, Collections: c, - Suspended: suspended, + Silenced: silenced, } d.UserPage.SetMessaging(u) w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") @@ -781,7 +781,7 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) uc, _ := app.db.GetUserCollectionCount(u.ID) // TODO: handle any errors - suspended, err := app.db.IsUserSuspended(u.ID) + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("view collections %v", err) return fmt.Errorf("view collections: %v", err) @@ -793,13 +793,13 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) UsedCollections, TotalCollections int NewBlogsDisabled bool - Suspended bool + Silenced bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f), Collections: c, UsedCollections: int(uc), NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc), - Suspended: suspended, + Silenced: silenced, } d.UserPage.SetMessaging(u) showUserPage(w, "collections", d) @@ -817,7 +817,7 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques return ErrCollectionNotFound } - suspended, err := app.db.IsUserSuspended(u.ID) + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("view edit collection %v", err) return fmt.Errorf("view edit collection: %v", err) @@ -826,11 +826,11 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques obj := struct { *UserPage *Collection - Suspended bool + Silenced bool }{ UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), Collection: c, - Suspended: suspended, + Silenced: silenced, } showUserPage(w, "collection", obj) @@ -992,7 +992,7 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error titleStats = c.DisplayTitle() + " " } - suspended, err := app.db.IsUserSuspended(u.ID) + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("view stats: %v", err) return err @@ -1003,13 +1003,13 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error Collection *Collection TopPosts *[]PublicPost APFollowers int - Suspended bool + Silenced bool }{ UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), VisitsBlog: alias, Collection: c, TopPosts: topPosts, - Suspended: suspended, + Silenced: silenced, } if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(c) @@ -1040,16 +1040,16 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err obj := struct { *UserPage - Email string - HasPass bool - IsLogOut bool - Suspended bool + Email string + HasPass bool + IsLogOut bool + Silenced bool }{ - UserPage: NewUserPage(app, r, u, "Account Settings", flashes), - Email: fullUser.EmailClear(app.keys), - HasPass: passIsSet, - IsLogOut: r.FormValue("logout") == "1", - Suspended: fullUser.IsSilenced(), + UserPage: NewUserPage(app, r, u, "Account Settings", flashes), + Email: fullUser.EmailClear(app.keys), + HasPass: passIsSet, + IsLogOut: r.FormValue("logout") == "1", + Silenced: fullUser.IsSilenced(), } showUserPage(w, "settings", obj) diff --git a/account_import.go b/account_import.go new file mode 100644 index 0000000..b34f3a7 --- /dev/null +++ b/account_import.go @@ -0,0 +1,195 @@ +package writefreely + +import ( + "encoding/json" + "fmt" + "html/template" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/writeas/impart" + wfimport "github.com/writeas/import" + "github.com/writeas/web-core/log" +) + +func viewImport(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + // Fetch extra user data + p := NewUserPage(app, r, u, "Import Posts", nil) + + c, err := app.db.GetCollections(u, app.Config().App.Host) + if err != nil { + return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("unable to fetch collections: %v", err)} + } + + d := struct { + *UserPage + Collections *[]Collection + Flashes []template.HTML + Message string + InfoMsg bool + }{ + UserPage: p, + Collections: c, + Flashes: []template.HTML{}, + } + + flashes, _ := getSessionFlashes(app, w, r, nil) + for _, flash := range flashes { + if strings.HasPrefix(flash, "SUCCESS: ") { + d.Message = strings.TrimPrefix(flash, "SUCCESS: ") + } else if strings.HasPrefix(flash, "INFO: ") { + d.Message = strings.TrimPrefix(flash, "INFO: ") + d.InfoMsg = true + } else { + d.Flashes = append(d.Flashes, template.HTML(flash)) + } + } + + showUserPage(w, "import", d) + return nil +} + +func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + // limit 10MB per submission + r.ParseMultipartForm(10 << 20) + + collAlias := r.PostFormValue("collection") + coll := &Collection{ + ID: 0, + } + var err error + if collAlias != "" { + coll, err = app.db.GetCollection(collAlias) + if err != nil { + log.Error("Unable to get collection for import: %s", err) + return err + } + // Only allow uploading to collection if current user is owner + if coll.OwnerID != u.ID { + err := ErrUnauthorizedGeneral + _ = addSessionFlash(app, w, r, err.Message, nil) + return err + } + coll.hostName = app.cfg.App.Host + } + + fileDates := make(map[string]int64) + err = json.Unmarshal([]byte(r.FormValue("fileDates")), &fileDates) + if err != nil { + log.Error("invalid form data for file dates: %v", err) + return impart.HTTPError{http.StatusBadRequest, "form data for file dates was invalid"} + } + files := r.MultipartForm.File["files"] + var fileErrs []error + filesSubmitted := len(files) + var filesImported int + for _, formFile := range files { + fname := "" + ok := func() bool { + file, err := formFile.Open() + if err != nil { + fileErrs = append(fileErrs, fmt.Errorf("Unable to read file %s", formFile.Filename)) + log.Error("import file: open from form: %v", err) + return false + } + defer file.Close() + + tempFile, err := ioutil.TempFile("", "post-upload-*.txt") + if err != nil { + fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename)) + log.Error("import file: create temp file %s: %v", formFile.Filename, err) + return false + } + defer tempFile.Close() + + _, err = io.Copy(tempFile, file) + if err != nil { + fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename)) + log.Error("import file: copy to temp location %s: %v", formFile.Filename, err) + return false + } + + info, err := tempFile.Stat() + if err != nil { + fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename)) + log.Error("import file: stat temp file %s: %v", formFile.Filename, err) + return false + } + fname = info.Name() + return true + }() + if !ok { + continue + } + + post, err := wfimport.FromFile(filepath.Join(os.TempDir(), fname)) + if err == wfimport.ErrEmptyFile { + // not a real error so don't log + _ = addSessionFlash(app, w, r, fmt.Sprintf("%s was empty, import skipped", formFile.Filename), nil) + continue + } else if err == wfimport.ErrInvalidContentType { + // same as above + _ = addSessionFlash(app, w, r, fmt.Sprintf("%s is not a supported post file", formFile.Filename), nil) + continue + } else if err != nil { + fileErrs = append(fileErrs, fmt.Errorf("failed to read copy of %s", formFile.Filename)) + log.Error("import textfile: file to post: %v", err) + continue + } + + if collAlias != "" { + post.Collection = collAlias + } + dateTime := time.Unix(fileDates[formFile.Filename], 0) + post.Created = &dateTime + created := post.Created.Format("2006-01-02T15:04:05Z") + submittedPost := SubmittedPost{ + Title: &post.Title, + Content: &post.Content, + Font: "norm", + Created: &created, + } + rp, err := app.db.CreatePost(u.ID, coll.ID, &submittedPost) + if err != nil { + fileErrs = append(fileErrs, fmt.Errorf("failed to create post from %s", formFile.Filename)) + log.Error("import textfile: create db post: %v", err) + continue + } + + // Federate post, if necessary + if app.cfg.App.Federation && coll.ID > 0 { + go federatePost( + app, + &PublicPost{ + Post: rp, + Collection: &CollectionObj{ + Collection: *coll, + }, + }, + coll.ID, + false, + ) + } + filesImported++ + } + if len(fileErrs) != 0 { + _ = addSessionFlash(app, w, r, multierror.ListFormatFunc(fileErrs), nil) + } + + if filesImported == filesSubmitted { + verb := "posts" + if filesSubmitted == 1 { + verb = "post" + } + _ = addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: Import complete, %d %s imported.", filesImported, verb), nil) + } else if filesImported > 0 { + _ = addSessionFlash(app, w, r, fmt.Sprintf("INFO: %d of %d posts imported, see details below.", filesImported, filesSubmitted), nil) + } + return impart.HTTPError{http.StatusFound, "/me/import"} +} diff --git a/activitypub.go b/activitypub.go index a18a636..f15773f 100644 --- a/activitypub.go +++ b/activitypub.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -37,6 +37,8 @@ import ( const ( // TODO: delete. don't use this! apCustomHandleDefault = "blog" + + apCacheTime = time.Minute ) type RemoteUser struct { @@ -44,6 +46,7 @@ type RemoteUser struct { ActorID string Inbox string SharedInbox string + Handle string } func (ru *RemoteUser) AsPerson() *activitystreams.Person { @@ -62,6 +65,12 @@ func (ru *RemoteUser) AsPerson() *activitystreams.Person { } } +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) @@ -80,18 +89,19 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re if err != nil { return err } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection activities: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host p := c.PersonObject() + setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, p, http.StatusOK) } @@ -113,12 +123,12 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques if err != nil { return err } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection outbox: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host @@ -148,11 +158,12 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false) for _, pp := range *posts { pp.Collection = res - o := pp.ActivityObject(app.cfg) + o := pp.ActivityObject(app) a := activitystreams.NewCreateActivity(o) ocp.OrderedItems = append(ocp.OrderedItems, *a) } + setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ocp, http.StatusOK) } @@ -174,12 +185,12 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req if err != nil { return err } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection followers: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host @@ -207,6 +218,7 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req ocp.OrderedItems = append(ocp.OrderedItems, f.ActorID) } */ + setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ocp, http.StatusOK) } @@ -228,12 +240,12 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req if err != nil { return err } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection following: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host @@ -251,6 +263,7 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req // Return outbox page ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "following", 0, p) ocp.OrderedItems = []interface{}{} + setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ocp, http.StatusOK) } @@ -270,12 +283,12 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request // TODO: return Reject? return err } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection inbox: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host @@ -382,6 +395,11 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request } go func() { + if to == nil { + log.Error("No to! %v", err) + return + } + time.Sleep(2 * time.Second) am, err := a.Serialize() if err != nil { @@ -390,10 +408,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request } am["@context"] = []string{activitystreams.Namespace} - if to == nil { - log.Error("No to! %v", err) - return - } err = makeActivityPost(app.cfg.App.Host, p, fullActor.Inbox, am) if err != nil { log.Error("Unable to make activity POST: %v", err) @@ -502,7 +516,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m } } - resp, err := http.DefaultClient.Do(r) + resp, err := activityPubClient().Do(r) if err != nil { return err } @@ -538,7 +552,7 @@ func resolveIRI(hostName, url string) ([]byte, error) { } } - resp, err := http.DefaultClient.Do(r) + resp, err := activityPubClient().Do(r) if err != nil { return nil, err } @@ -564,7 +578,7 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error { } p.Collection.hostName = app.cfg.App.Host actor := p.Collection.PersonObject(collID) - na := p.ActivityObject(app.cfg) + na := p.ActivityObject(app) // Add followers p.Collection.ID = collID @@ -610,7 +624,7 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { } } actor := p.Collection.PersonObject(collID) - na := p.ActivityObject(app.cfg) + na := p.ActivityObject(app) // Add followers p.Collection.ID = collID @@ -628,18 +642,25 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { 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{} for _, f := range instFolls { na.CC = append(na.CC, f) } - var activity *activitystreams.Activity + // create a new "Create" activity + // with our article as object if isUpdate { activity = activitystreams.NewUpdateActivity(na) } else { @@ -647,17 +668,42 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { 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) + 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} - err := app.db.QueryRow("SELECT id, inbox, shared_inbox FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox) + err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &u.Handle) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."} @@ -669,6 +715,21 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { 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} + err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox) + 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 + } + return &u, nil +} + func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser, error) { log.Info("Fetching actor %s locally", actorIRI) actor := &activitystreams.Person{} @@ -743,3 +804,7 @@ func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error { return 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/admin.go b/admin.go index 0a73a11..5f7d244 100644 --- a/admin.go +++ b/admin.go @@ -187,7 +187,11 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque var err error p.User, err = app.db.GetUserForAuth(username) if err != nil { - return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)} + if err == ErrUserNotFound { + return err + } + log.Error("Could not get user: %v", err) + return impart.HTTPError{http.StatusInternalServerError, err.Error()} } flashes, _ := getSessionFlashes(app, w, r, nil) @@ -259,7 +263,7 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht err = app.db.SetUserStatus(user.ID, UserSilenced) } if err != nil { - log.Error("toggle user suspended: %v", err) + log.Error("toggle user silenced: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)} } return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)} diff --git a/app.go b/app.go index d465a3e..170c321 100644 --- a/app.go +++ b/app.go @@ -30,7 +30,7 @@ import ( "github.com/gorilla/schema" "github.com/gorilla/sessions" "github.com/manifoldco/promptui" - "github.com/writeas/go-strip-markdown" + stripmd "github.com/writeas/go-strip-markdown" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/converter" @@ -689,6 +689,52 @@ func ResetPassword(apper Apper, username string) error { 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) diff --git a/author/author.go b/author/author.go index bf3bfe1..0114905 100644 --- a/author/author.go +++ b/author/author.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018 A Bunch Tell LLC. + * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -65,6 +65,7 @@ var reservedUsernames = map[string]bool{ "metadata": true, "new": true, "news": true, + "oauth": true, "post": true, "posts": true, "privacy": true, diff --git a/cmd/writefreely/main.go b/cmd/writefreely/main.go index 48993c7..7fc2342 100644 --- a/cmd/writefreely/main.go +++ b/cmd/writefreely/main.go @@ -13,11 +13,12 @@ package main import ( "flag" "fmt" + "os" + "strings" + "github.com/gorilla/mux" "github.com/writeas/web-core/log" "github.com/writeas/writefreely" - "os" - "strings" ) func main() { @@ -38,6 +39,7 @@ func main() { // Admin actions createAdmin := flag.String("create-admin", "", "Create an admin with the given username:password") createUser := flag.String("create-user", "", "Create a regular user with the given username:password") + deleteUsername := flag.String("delete-user", "", "Delete a user with the given username") resetPassUser := flag.String("reset-pass", "", "Reset the given user's password") outputVersion := flag.Bool("v", false, "Output the current version") flag.Parse() @@ -102,6 +104,13 @@ func main() { os.Exit(1) } os.Exit(0) + } else if *deleteUsername != "" { + err := writefreely.DoDeleteAccount(app, *deleteUsername) + if err != nil { + log.Error(err.Error()) + os.Exit(1) + } + os.Exit(0) } else if *migrate { err := writefreely.Migrate(app) if err != nil { diff --git a/collections.go b/collections.go index 5c9a1b7..9688ad9 100644 --- a/collections.go +++ b/collections.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018 A Bunch Tell LLC. + * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -71,7 +71,7 @@ type ( IsTopLevel bool CurrentPage int TotalPages int - Suspended bool + Silenced bool } SubmittedCollection struct { // Data used for updating a given collection @@ -397,13 +397,13 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { } userID = u.ID } - suspended, err := app.db.IsUserSuspended(userID) + silenced, err := app.db.IsUserSilenced(userID) if err != nil { log.Error("new collection: %v", err) return ErrInternalGeneral } - if suspended { - return ErrUserSuspended + if silenced { + return ErrUserSilenced } if !author.IsValidUsername(app.cfg, c.Alias) { @@ -487,7 +487,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error { res.Owner = u } } - // TODO: check suspended + // TODO: check status for silenced app.db.GetPostsCount(res, isCollOwner) // Strip non-public information res.Collection.ForPublic() @@ -656,7 +656,7 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R } // TODO: move this to all permission checks? - suspended, err := app.db.IsUserSuspended(c.OwnerID) + suspended, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("process protected collection permissions: %v", err) return nil, err @@ -754,7 +754,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro } c.hostName = app.cfg.App.Host - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("view collection: %v", err) return ErrInternalGeneral @@ -764,6 +764,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { ac := c.PersonObject() ac.Context = []interface{}{activitystreams.Namespace} + setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ac, http.StatusOK) } @@ -816,10 +817,10 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro log.Error("Error getting user for collection: %v", err) } } - if !isOwner && suspended { + if !isOwner && silenced { return ErrCollectionNotFound } - displayPage.Suspended = isOwner && suspended + displayPage.Silenced = isOwner && silenced displayPage.Owner = owner coll.Owner = displayPage.Owner @@ -856,6 +857,19 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro return err } +func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + handle := vars["handle"] + + remoteUser, err := app.db.GetProfilePageFromHandle(app, handle) + if err != nil || remoteUser == "" { + log.Error("Couldn't find user %s: %v", handle, err) + return ErrRemoteUserNotFound + } + + return impart.HTTPError{Status: http.StatusFound, Message: remoteUser} +} + func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) tag := vars["tag"] @@ -925,7 +939,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e return ErrCollectionNotFound } } - displayPage.Suspended = owner != nil && owner.IsSilenced() + displayPage.Silenced = owner != nil && owner.IsSilenced() displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data @@ -979,14 +993,14 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error } } - suspended, err := app.db.IsUserSuspended(u.ID) + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("existing collection: %v", err) return ErrInternalGeneral } - if suspended { - return ErrUserSuspended + if silenced { + return ErrUserSilenced } if r.Method == "DELETE" { diff --git a/database-no-sqlite.go b/database-no-sqlite.go index a3d50fc..03d1a32 100644 --- a/database-no-sqlite.go +++ b/database-no-sqlite.go @@ -1,7 +1,7 @@ // +build !sqlite,!wflib /* - * Copyright © 2019 A Bunch Tell LLC. + * Copyright © 2019-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -28,3 +28,15 @@ func (db *datastore) isDuplicateKeyErr(err error) bool { 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 +} diff --git a/database-sqlite.go b/database-sqlite.go index 3741169..bd77e6a 100644 --- a/database-sqlite.go +++ b/database-sqlite.go @@ -48,3 +48,15 @@ func (db *datastore) isDuplicateKeyErr(err error) bool { 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 +} diff --git a/database.go b/database.go index ef52d84..cea7a97 100644 --- a/database.go +++ b/database.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018 A Bunch Tell LLC. + * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -22,6 +22,7 @@ import ( "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/nerds/store" "github.com/writeas/web-core/activitypub" @@ -37,6 +38,7 @@ import ( const ( mySQLErrDuplicateKey = 1062 + mySQLErrCollationMix = 1267 driverMySQL = "mysql" driverSQLite = "sqlite3" @@ -63,7 +65,7 @@ type writestore interface { GetAccessToken(userID int64) (string, error) GetTemporaryAccessToken(userID int64, validSecs int) (string, error) GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error) - DeleteAccount(userID int64) (l *string, err error) + DeleteAccount(userID int64) error ChangeSettings(app *App, u *User, s *userSettings) error ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error @@ -317,18 +319,18 @@ func (db *datastore) GetUserByID(id int64) (*User, error) { return u, nil } -// IsUserSuspended returns true if the user account associated with id is -// currently suspended. -func (db *datastore) IsUserSuspended(id int64) (bool, error) { +// 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, fmt.Errorf("is user suspended: %v", ErrUserNotFound) + return false, fmt.Errorf("is user silenced: %v", ErrUserNotFound) case err != nil: - log.Error("Couldn't SELECT user password: %v", err) - return false, fmt.Errorf("is user suspended: %v", err) + log.Error("Couldn't SELECT user status: %v", err) + return false, fmt.Errorf("is user silenced: %v", err) } return u.IsSilenced(), nil @@ -2113,22 +2115,13 @@ func (db *datastore) CollectionHasAttribute(id int64, attr string) bool { return true } -func (db *datastore) DeleteAccount(userID int64) (l *string, err error) { - debug := "" - l = &debug - - t, err := db.Begin() - if err != nil { - stringLogln(l, "Unable to begin: %v", err) - return - } - +// 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 { - t.Rollback() - stringLogln(l, "Unable to get collections: %v", err) - return + log.Error("Unable to get collections: %v", err) + return err } defer rows.Close() colls := []Collection{} @@ -2136,103 +2129,158 @@ func (db *datastore) DeleteAccount(userID int64) (l *string, err error) { for rows.Next() { err = rows.Scan(&c.ID, &c.Alias) if err != nil { - t.Rollback() - stringLogln(l, "Unable to scan collection cols: %v", err) - return + 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 { - // TODO: user deleteCollection() func // Delete tokens res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID) if err != nil { t.Rollback() - stringLogln(l, "Unable to delete attributes on %s: %v", c.Alias, err) - return + log.Error("Unable to delete attributes on %s: %v", c.Alias, err) + return err } rs, _ := res.RowsAffected() - stringLogln(l, "Deleted %d for %s from collectionattributes", rs, c.Alias) + 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) if err != nil { t.Rollback() - stringLogln(l, "Unable to delete passwords on %s: %v", c.Alias, err) - return + log.Error("Unable to delete passwords on %s: %v", c.Alias, err) + return err } rs, _ = res.RowsAffected() - stringLogln(l, "Deleted %d for %s from collectionpasswords", rs, c.Alias) + 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) if err != nil { t.Rollback() - stringLogln(l, "Unable to delete redirects on %s: %v", c.Alias, err) - return + log.Error("Unable to delete redirects on %s: %v", c.Alias, err) + return err } rs, _ = res.RowsAffected() - stringLogln(l, "Deleted %d for %s from collectionredirects", rs, c.Alias) + 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) + 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) + 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) if err != nil { t.Rollback() - stringLogln(l, "Unable to delete collections: %v", err) - return + log.Error("Unable to delete collections: %v", err) + return err } rs, _ := res.RowsAffected() - stringLogln(l, "Deleted %d from collections", rs) + log.Info("Deleted %d from collections", rs) // Delete tokens res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID) if err != nil { t.Rollback() - stringLogln(l, "Unable to delete access tokens: %v", err) - return + log.Error("Unable to delete access tokens: %v", err) + return err } rs, _ = res.RowsAffected() - stringLogln(l, "Deleted %d from accesstokens", rs) + log.Info("Deleted %d from accesstokens", rs) + + // Delete user attributes + res, err = t.Exec("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) if err != nil { t.Rollback() - stringLogln(l, "Unable to delete posts: %v", err) - return + log.Error("Unable to delete posts: %v", err) + return err } rs, _ = res.RowsAffected() - stringLogln(l, "Deleted %d from posts", rs) + log.Info("Deleted %d from posts", rs) + // Delete user attributes res, err = t.Exec("DELETE FROM userattributes WHERE user_id = ?", userID) if err != nil { t.Rollback() - stringLogln(l, "Unable to delete attributes: %v", err) - return + log.Error("Unable to delete attributes: %v", err) + return err } rs, _ = res.RowsAffected() - stringLogln(l, "Deleted %d from userattributes", rs) + log.Info("Deleted %d from userattributes", rs) + // Delete user invites + res, err = t.Exec("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) if err != nil { t.Rollback() - stringLogln(l, "Unable to delete user: %v", err) - return + log.Error("Unable to delete user: %v", err) + return err } rs, _ = res.RowsAffected() - stringLogln(l, "Deleted %d from users", rs) + log.Info("Deleted %d from users", rs) + // Commit all changes to the database err = t.Commit() if err != nil { t.Rollback() - stringLogln(l, "Unable to commit: %v", err) - return + log.Error("Unable to commit: %v", err) + return err } - return + // TODO: federate delete actor + + return nil } func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) { @@ -2281,7 +2329,7 @@ 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: + 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) @@ -2555,3 +2603,40 @@ func handleFailedPostInsert(err error) error { log.Error("Couldn't insert into posts: %v", err) return err } + +func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) { + actorIRI := "" + 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("Can't update handle (" + handle + ") in database for user " + 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", 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("Can't insert remote user in database", err) + return "", err + } + } + } else { + actorIRI = remoteUser.ActorID + } + return actorIRI, nil +} diff --git a/errors.go b/errors.go index c0d435c..b62fc9e 100644 --- a/errors.go +++ b/errors.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018 A Bunch Tell LLC. + * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -45,10 +45,11 @@ var ( ErrPostUnpublished = impart.HTTPError{Status: http.StatusGone, Message: "Post unpublished by author."} ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."} - ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} - ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} + ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} + ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."} + ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} - ErrUserSuspended = impart.HTTPError{http.StatusForbidden, "Account is silenced."} + ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."} ) // Post operation errors diff --git a/feed.go b/feed.go index 44bb331..4e1f612 100644 --- a/feed.go +++ b/feed.go @@ -36,12 +36,12 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error { return nil } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("view feed: get user: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host diff --git a/go.mod b/go.mod index 697fb68..5da3da4 100644 --- a/go.mod +++ b/go.mod @@ -6,17 +6,21 @@ require ( github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect github.com/clbanning/mxj v1.8.4 // indirect + github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect github.com/dustin/go-humanize v1.0.0 github.com/fatih/color v1.7.0 + github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d // indirect github.com/go-sql-driver/mysql v1.4.1 github.com/go-test/deep v1.0.1 // indirect github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect + github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 // indirect github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect github.com/gorilla/feeds v1.1.0 github.com/gorilla/mux v1.7.0 github.com/gorilla/schema v1.0.2 - github.com/gorilla/sessions v1.1.3 + github.com/gorilla/sessions v1.2.0 github.com/guregu/null v3.4.0+incompatible + github.com/hashicorp/go-multierror v1.0.0 github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec @@ -35,10 +39,12 @@ require ( github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect github.com/stretchr/testify v1.3.0 github.com/writeas/activity v0.1.2 + github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89 github.com/writeas/go-strip-markdown v2.0.1+incompatible github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 github.com/writeas/httpsig v1.0.0 github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d + github.com/writeas/import v0.2.0 github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 github.com/writeas/nerds v1.0.0 github.com/writeas/saturday v1.7.1 @@ -52,6 +58,7 @@ require ( gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect gopkg.in/ini.v1 v1.41.0 gopkg.in/yaml.v2 v2.2.2 // indirect + src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // indirect ) go 1.13 diff --git a/go.sum b/go.sum index 25ced67..2d433ec 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs= +code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/alecthomas/gometalinter v2.0.11+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk= @@ -23,13 +25,18 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk 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-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU= +github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/go-fed/httpsig v0.1.0 h1:6F2OxRVnNTN4OPN+Mc2jxs2WEay9/qiHT/jphlvAwIY= github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= +github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d h1:+uoOvOnNDgsYbWtAij4xP6Rgir3eJGjocFPxBJETU/U= +github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg= @@ -38,14 +45,14 @@ github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200j github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo= github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 h1:WD8iJ37bRNwvETMfVTusVSAi0WdXTpfNVGY2aHycNKY= +github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U= github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg= github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE= 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/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY= github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU= -github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk= github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= @@ -54,10 +61,14 @@ github.com/gorilla/schema v1.0.2 h1:sAgNfOcNYvdDSrzGHVy9nzCQahG+qmsg+nE8dK85QRA= github.com/gorilla/schema v1.0.2/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= -github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= +github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM= github.com/guregu/null v3.4.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 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 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/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= @@ -115,16 +126,28 @@ github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTG github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk= 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-20191008122325-5fc3b48e70c5 h1:nG84xWpxBM8YU/FJchezJqg7yZH8ImSRow6NoYtbSII= +github.com/writeas/activityserve v0.0.0-20191008122325-5fc3b48e70c5/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A= +github.com/writeas/activityserve v0.0.0-20191011072627-3a81f7784d5b h1:rd2wX/bTqD55hxtBjAhwLcUgaQE36c70KX3NzpDAwVI= +github.com/writeas/activityserve v0.0.0-20191011072627-3a81f7784d5b/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A= +github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89 h1:NJhzq9aTccL3SSSZMrcnYhkD6sObdY9otNZ1X6/ZKNE= +github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A= github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw= github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE= github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 h1:DUsp4OhdfI+e6iUqcPQlwx8QYXuUDsToTz/x82D3Zuo= github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc= +github.com/writeas/go-writeas v1.1.0 h1:WHGm6wriBkxYAOGbvriXH8DlMUGOi6jhSZLUZKQ+4mQ= +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 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE= github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d h1:PK7DOj3JE6MGf647esPrKzXEHFjGWX2hl22uX79ixaE= github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= +github.com/writeas/import v0.2.0 h1:Ov23JW9Rnjxk06rki1Spar45bNX647HhwhAZj3flJiY= +github.com/writeas/import v0.2.0/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM= github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c= github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ= github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo= @@ -177,3 +200,5 @@ gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+p gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b h1:rPAdjgXks4ToezTjygsnKZroxKVnA1L35DSpsJXPtfc= +src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU= diff --git a/invites.go b/invites.go index 1dba7bd..d5d024a 100644 --- a/invites.go +++ b/invites.go @@ -56,12 +56,19 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req p := struct { *UserPage - Invites *[]Invite + Invites *[]Invite + Silenced bool }{ UserPage: NewUserPage(app, r, u, "Invite People", f), } var err error + + p.Silenced, err = app.db.IsUserSilenced(u.ID) + if err != nil { + log.Error("view invites: %v", err) + } + p.Invites, err = app.db.GetUserInvites(u.ID) if err != nil { return err @@ -79,7 +86,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re expVal := r.FormValue("expires") if u.IsSilenced() { - return ErrUserSuspended + return ErrUserSilenced } var err error diff --git a/less/core.less b/less/core.less index 3669d76..fe8a28d 100644 --- a/less/core.less +++ b/less/core.less @@ -1318,6 +1318,24 @@ form { font-size: 0.86em; line-height: 2; } + + &.prominent { + margin: 1em 0; + + label { + font-weight: bold; + } + input, select { + width: 100%; + } + select { + font-size: 1em; + padding: 0.5rem; + display: block; + border-radius: 0.25rem; + margin: 0.5rem 0; + } + } } div.row { display: flex; diff --git a/migrations/migrations.go b/migrations/migrations.go index 917d912..41f036f 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -56,11 +56,12 @@ func (m *migration) Migrate(db *datastore) error { } 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 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 (v0.12.0) } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v6.go b/migrations/v6.go new file mode 100644 index 0000000..c6f5012 --- /dev/null +++ b/migrations/v6.go @@ -0,0 +1,29 @@ +/* + * Copyright © 2019 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportActivityPubMentions(db *datastore) error { + t, err := db.Begin() + + _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` DEFAULT '' NOT NULL`) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/oauth.go b/oauth.go index 2c7e2e8..caf8189 100644 --- a/oauth.go +++ b/oauth.go @@ -224,6 +224,11 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http return nil } + displayName := tokenInfo.DisplayName + if len(displayName) == 0 { + displayName = tokenInfo.Username + } + tp := &oauthSignupPageParams{ AccessToken: tokenResponse.AccessToken, TokenUsername: tokenInfo.Username, diff --git a/oauth_signup.go b/oauth_signup.go index cf90af6..220afbd 100644 --- a/oauth_signup.go +++ b/oauth_signup.go @@ -1,3 +1,13 @@ +/* + * Copyright © 2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + package writefreely import ( @@ -22,16 +32,16 @@ type viewOauthSignupVars struct { AccessToken string TokenUsername string - TokenAlias string + TokenAlias string // TODO: rename this to match the data it represents: the collection title TokenEmail string TokenRemoteUser string Provider string ClientID string TokenHash string - Username string - Alias string - Email string + LoginUsername string + Alias string // TODO: rename this to match the data it represents: the collection title + Email string } const ( @@ -52,7 +62,7 @@ const ( type oauthSignupPageParams struct { AccessToken string TokenUsername string - TokenAlias string + TokenAlias string // TODO: rename this to match the data it represents: the collection title TokenEmail string TokenRemoteUser string ClientID string @@ -91,14 +101,20 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R return h.showOauthSignupPage(app, w, r, tp, err) } - hashedPass, err := auth.HashPass([]byte(r.FormValue(oauthParamPassword))) - if err != nil { - return h.showOauthSignupPage(app, w, r, tp, fmt.Errorf("unable to hash password")) + var err error + hashedPass := []byte{} + clearPass := r.FormValue(oauthParamPassword) + hasPass := clearPass != "" + if hasPass { + hashedPass, err = auth.HashPass([]byte(clearPass)) + if err != nil { + return h.showOauthSignupPage(app, w, r, tp, fmt.Errorf("unable to hash password")) + } } newUser := &User{ Username: r.FormValue(oauthParamUsername), HashedPass: hashedPass, - HasPass: true, + HasPass: hasPass, Email: prepareUserEmail(r.FormValue(oauthParamEmail), h.EmailKey), Created: time.Now().Truncate(time.Second).UTC(), } @@ -131,13 +147,9 @@ func (h oauthHandler) validateOauthSignup(r *http.Request) error { if len(username) > 100 { return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too long."} } - alias := r.FormValue(oauthParamAlias) - if len(alias) == 0 { - return impart.HTTPError{Status: http.StatusBadRequest, Message: "Alias is too short."} - } - password := r.FormValue("password") - if len(password) == 0 { - return impart.HTTPError{Status: http.StatusBadRequest, Message: "Password is too short."} + collTitle := r.FormValue(oauthParamAlias) + if len(collTitle) == 0 { + collTitle = username } email := r.FormValue(oauthParamEmail) if len(email) > 0 { @@ -151,7 +163,7 @@ func (h oauthHandler) validateOauthSignup(r *http.Request) error { func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *http.Request, tp *oauthSignupPageParams, errMsg error) error { username := tp.TokenUsername - alias := tp.TokenAlias + collTitle := tp.TokenAlias email := tp.TokenEmail session, err := app.sessionStore.Get(r, cookieName) @@ -164,7 +176,7 @@ func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *ht username = tmpValue } if tmpValue := r.FormValue(oauthParamAlias); len(tmpValue) > 0 { - alias = tmpValue + collTitle = tmpValue } if tmpValue := r.FormValue(oauthParamEmail); len(tmpValue) > 0 { email = tmpValue @@ -184,9 +196,9 @@ func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *ht ClientID: tp.ClientID, TokenHash: tp.TokenHash, - Username: username, - Alias: alias, - Email: email, + LoginUsername: username, + Alias: collTitle, + Email: email, } // Display any error messages diff --git a/oauth_slack.go b/oauth_slack.go index 8cf4992..35db156 100644 --- a/oauth_slack.go +++ b/oauth_slack.go @@ -1,3 +1,13 @@ +/* + * Copyright © 2019-2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + package writefreely import ( @@ -157,7 +167,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse { return &InspectResponse{ UserID: resp.User.ID, - Username: fmt.Sprintf("%s-%s", slug.Make(resp.User.Name), store.Generate62RandomString(5)), + Username: fmt.Sprintf("%s-%s", slug.Make(resp.User.Name), store.GenerateRandomString("0123456789bcdfghjklmnpqrstvwxyz", 5)), DisplayName: resp.User.Name, Email: resp.User.Email, } diff --git a/pad.go b/pad.go index 3b0f1c2..0354cd3 100644 --- a/pad.go +++ b/pad.go @@ -35,10 +35,10 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error { } appData := &struct { page.StaticPage - Post *RawPost - User *User - Blogs *[]Collection - Suspended bool + Post *RawPost + User *User + Blogs *[]Collection + Silenced bool Editing bool // True if we're modifying an existing post EditCollection *Collection // Collection of the post we're editing, if any @@ -53,9 +53,9 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error { if err != nil { log.Error("Unable to get user's blogs for Pad: %v", err) } - appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID) + appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID) if err != nil { - log.Error("Unable to get users suspension status for Pad: %v", err) + log.Error("Unable to get user status for Pad: %v", err) } } @@ -127,16 +127,16 @@ func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error { EditCollection *Collection // Collection of the post we're editing, if any Flashes []string NeedsToken bool - Suspended bool + Silenced bool }{ StaticPage: pageForReq(app, r), Post: &RawPost{Font: "norm"}, User: getUserSession(app, r), } var err error - appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID) + appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID) if err != nil { - log.Error("view meta: get user suspended status: %v", err) + log.Error("view meta: get user status: %v", err) return ErrInternalGeneral } diff --git a/pages/signup-oauth.tmpl b/pages/signup-oauth.tmpl index 34081cf..ecf5db0 100644 --- a/pages/signup-oauth.tmpl +++ b/pages/signup-oauth.tmpl @@ -65,7 +65,7 @@ form dd { {{end}}
(.+)
") + mentionReg = regexp.MustCompile(`@([A-Za-z0-9._%+-]+)(@[A-Za-z0-9.-]+\.[A-Za-z]+)\b`) ) func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool) { @@ -86,6 +87,8 @@ func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *c tagPrefix = "/read/t/" } md = []byte(hashtagReg.ReplaceAll(md, []byte("#$1"))) + handlePrefix := cfg.App.Host + "/@/" + md = []byte(mentionReg.ReplaceAll(md, []byte("@$1$2"))) } // Strip out bad HTML policy := getSanitizationPolicy() diff --git a/posts.go b/posts.go index d2fbcca..a0e4588 100644 --- a/posts.go +++ b/posts.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -35,7 +35,6 @@ import ( "github.com/writeas/web-core/i18n" "github.com/writeas/web-core/log" "github.com/writeas/web-core/tags" - "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" "github.com/writeas/writefreely/parse" ) @@ -229,6 +228,10 @@ func (p Post) Summary() string { return shortPostDescription(p.Content) } +func (p Post) SummaryHTML() template.HTML { + return template.HTML(p.Summary()) +} + // Excerpt shows any text that comes before a (more) tag. // TODO: use HTMLExcerpt in templates instead of this method func (p *Post) Excerpt() template.HTML { @@ -381,9 +384,9 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { } } - var suspended bool + var silenced bool if found { - suspended, err = app.db.IsUserSuspended(ownerID.Int64) + silenced, err = app.db.IsUserSilenced(ownerID.Int64) if err != nil { log.Error("view post: %v", err) } @@ -436,10 +439,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { page := struct { *AnonymousPost page.StaticPage - Username string - IsOwner bool - SiteURL string - Suspended bool + Username string + IsOwner bool + SiteURL string + Silenced bool }{ AnonymousPost: post, StaticPage: pageForReq(app, r), @@ -450,10 +453,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID } - if !page.IsOwner && suspended { + if !page.IsOwner && silenced { return ErrPostNotFound } - page.Suspended = suspended + page.Silenced = silenced err = templates["post"].ExecuteTemplate(w, "post", page) if err != nil { log.Error("Post template execute error: %v", err) @@ -510,12 +513,12 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error { } else { userID = app.db.GetUserID(accessToken) } - suspended, err := app.db.IsUserSuspended(userID) + silenced, err := app.db.IsUserSilenced(userID) if err != nil { log.Error("new post: %v", err) } - if suspended { - return ErrUserSuspended + if silenced { + return ErrUserSilenced } if userID == -1 { @@ -683,12 +686,12 @@ func existingPost(app *App, w http.ResponseWriter, r *http.Request) error { } } - suspended, err := app.db.IsUserSuspended(userID) + silenced, err := app.db.IsUserSilenced(userID) if err != nil { log.Error("existing post: %v", err) } - if suspended { - return ErrUserSuspended + if silenced { + return ErrUserSilenced } // Modify post struct @@ -885,12 +888,12 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error { ownerID = u.ID } - suspended, err := app.db.IsUserSuspended(ownerID) + silenced, err := app.db.IsUserSilenced(ownerID) if err != nil { log.Error("add post: %v", err) } - if suspended { - return ErrUserSuspended + if silenced { + return ErrUserSilenced } // Parse claimed posts in format: @@ -987,12 +990,12 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error { userID = u.ID } - suspended, err := app.db.IsUserSuspended(userID) + silenced, err := app.db.IsUserSilenced(userID) if err != nil { log.Error("pin post: %v", err) } - if suspended { - return ErrUserSuspended + if silenced { + return ErrUserSilenced } // Parse request @@ -1068,11 +1071,11 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error { } } - suspended, err := app.db.IsUserSuspended(p.OwnerID.Int64) + silenced, err := app.db.IsUserSilenced(p.OwnerID.Int64) if err != nil { log.Error("fetch post: %v", err) } - if suspended { + if silenced { return ErrPostNotFound } @@ -1087,8 +1090,9 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error { } p.Collection = &CollectionObj{Collection: *coll} - po := p.ActivityObject(app.cfg) + po := p.ActivityObject(app) po.Context = []interface{}{activitystreams.Namespace} + setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, po, http.StatusOK) } @@ -1122,7 +1126,8 @@ func (p *PublicPost) CanonicalURL(hostName string) string { return p.Collection.CanonicalURL() + p.Slug.String } -func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object { +func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object { + cfg := app.cfg o := activitystreams.NewArticleObject() o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID o.Published = p.Created @@ -1162,6 +1167,27 @@ func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object }) } } + // Find mentioned users + mentionedUsers := make(map[string]string) + + stripper := bluemonday.StrictPolicy() + content := stripper.Sanitize(p.Content) + mentionRegex := regexp.MustCompile(`@[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]+\b`) + mentions := mentionRegex.FindAllString(content, -1) + + for _, handle := range mentions { + actorIRI, err := app.db.GetProfilePageFromHandle(app, handle) + if err != nil { + log.Info("Can't find this user either in the database nor in the remote instance") + return nil + } + mentionedUsers[handle] = actorIRI + } + + for handle, iri := range mentionedUsers { + o.CC = append(o.CC, iri) + o.Tag = append(o.Tag, activitystreams.Tag{Type: "Mention", HRef: iri, Name: handle}) + } return o } @@ -1329,7 +1355,7 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error } c.hostName = app.cfg.App.Host - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("view collection post: %v", err) } @@ -1339,7 +1365,7 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error return ErrPostNotFound } if c.IsProtected() && (u == nil || u.ID != c.OwnerID) { - if suspended { + if silenced { return ErrPostNotFound } else if !isAuthorizedForCollection(app, c.Alias, r) { return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug} @@ -1394,7 +1420,7 @@ Are you sure it was ever here?`, p.Collection = coll p.IsTopLevel = app.cfg.App.SingleUser - if !p.IsOwner && suspended { + if !p.IsOwner && silenced { return ErrPostNotFound } // Check if post has been unpublished @@ -1428,8 +1454,9 @@ Are you sure it was ever here?`, return ErrCollectionPageNotFound } p.extractData() - ap := p.ActivityObject(app.cfg) + ap := p.ActivityObject(app) ap.Context = []interface{}{activitystreams.Namespace} + setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ap, http.StatusOK) } else { p.extractData() @@ -1446,14 +1473,14 @@ Are you sure it was ever here?`, IsFound bool IsAdmin bool CanInvite bool - Suspended bool + Silenced bool }{ PublicPost: p, StaticPage: pageForReq(app, r), IsOwner: cr.isCollOwner, IsCustomDomain: cr.isCustomDomain, IsFound: postFound, - Suspended: suspended, + Silenced: silenced, } tp.IsAdmin = u != nil && u.IsAdmin() tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) diff --git a/routes.go b/routes.go index 18f8477..fcd00ec 100644 --- a/routes.go +++ b/routes.go @@ -70,6 +70,9 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover))) write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo))) + // handle mentions + write.HandleFunc("/@/{handle}", handler.Web(handleViewMention, UserLevelReader)) + configureSlackOauth(handler, write, apper.App()) configureWriteAsOauth(handler, write, apper.App()) @@ -97,6 +100,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET") me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET") + me.HandleFunc("/import", handler.User(viewImport)).Methods("GET") me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET") me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET") me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET") @@ -109,6 +113,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST") apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST") apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST") + apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST") // Sign up validation write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") @@ -159,7 +164,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { // Handle special pages first write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) - write.HandleFunc("/invite/{code}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") + write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") // TODO: show a reader-specific 404 page if the function is disabled write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader)) RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter()) @@ -167,14 +172,14 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { draftEditPrefix := "" if apper.App().cfg.App.SingleUser { draftEditPrefix = "/d" - write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") + write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") } else { - write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") + write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") } // All the existing stuff - write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") - write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET") + write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") + write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET") // Collections if apper.App().cfg.App.SingleUser { RouteCollections(handler, write.PathPrefix("/").Subrouter()) diff --git a/scripts/upgrade-server.sh b/scripts/upgrade-server.sh index c8e004a..581085d 100755 --- a/scripts/upgrade-server.sh +++ b/scripts/upgrade-server.sh @@ -11,7 +11,7 @@ ## have not installed the binary `writefreely` in another location. ## ############################################################################### # -# Copyright © 2019 A Bunch Tell LLC. +# Copyright © 2019-2020 A Bunch Tell LLC. # # This file is part of WriteFreely. # @@ -31,7 +31,7 @@ fi # go ahead and check for the latest release on linux echo "Checking for updates..." -url=`curl -s https://api.github.com/repos/writeas/writefreely/releases/latest | grep 'browser_' | grep linux | cut -d\" -f4` +url=`curl -s https://api.github.com/repos/writeas/writefreely/releases/latest | grep 'browser_' | grep 'linux' | grep 'amd64' | cut -d\" -f4` # check current version @@ -82,13 +82,25 @@ filename=${parts[-1]} echo "Extracting files..." tar -zxf $tempdir/$filename -C $tempdir +# stop service +echo "Stopping writefreely systemd service..." +if `systemctl start writefreely`; then + echo "Success, service stopped." +else + echo "Upgrade failed to stop the systemd service, exiting early." + exit 1 +fi + # copy files echo "Copying files..." -cp -r $tempdir/{pages,static,templates,writefreely} . +cp -r $tempdir/writefreely/{pages,static,templates,writefreely} . + +# migrate db +./writefreely -migrate # restart service -echo "Restarting writefreely systemd service..." -if `systemctl restart writefreely`; then +echo "Starting writefreely systemd service..." +if `systemctl start writefreely`; then echo "Success, version has been upgraded to $latest." else echo "Upgrade complete, but failed to restart service." diff --git a/static/js/localdate.js b/static/js/localdate.js new file mode 100644 index 0000000..879ebe4 --- /dev/null +++ b/static/js/localdate.js @@ -0,0 +1,16 @@ +function toLocalDate(dateEl, displayEl) { + var d = new Date(dateEl.getAttribute("datetime")); + displayEl.textContent = d.toLocaleDateString(navigator.language || "en-US", { year: 'numeric', month: 'long', day: 'numeric' }); +} + +// Adjust dates on individual post pages, and on posts in a list *with* an explicit title +var $dates = document.querySelectorAll("article > time"); +for (var i=0; i < $dates.length; i++) { + toLocalDate($dates[i], $dates[i]); +} + +// Adjust dates on posts in a list without an explicit title, where they act as the header +$dates = document.querySelectorAll("h2.post-title > time"); +for (i=0; i < $dates.length; i++) { + toLocalDate($dates[i], $dates[i].querySelector('a')); +} \ No newline at end of file diff --git a/templates.go b/templates.go index 968845d..c15c79b 100644 --- a/templates.go +++ b/templates.go @@ -64,7 +64,7 @@ func initTemplate(parentDir, name string) { filepath.Join(parentDir, templatesDir, name+".tmpl"), filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "base.tmpl"), - filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"), + filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"), } if name == "collection" || name == "collection-tags" || name == "chorus-collection" { // These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl" @@ -88,7 +88,7 @@ func initPage(parentDir, path, key string) { path, filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "base.tmpl"), - filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"), + filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"), )) } @@ -101,7 +101,7 @@ func initUserPage(parentDir, path, key string) { path, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"), filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"), - filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"), + filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"), )) } diff --git a/templates/chorus-collection-post.tmpl b/templates/chorus-collection-post.tmpl index 0e1164b..0418767 100644 --- a/templates/chorus-collection-post.tmpl +++ b/templates/chorus-collection-post.tmpl @@ -55,10 +55,10 @@ body#post header { {{template "user-navigation" .}} - {{if .Suspended}} - {{template "user-suspended"}} + {{if .Silenced}} + {{template "user-silenced"}} {{end}} -Scheduled
{{end}}{{if .Title.String}}Scheduled
{{end}}{{if .Title.String}}