Merge branch 'develop' into T572-check-updates

pull/175/head
Matt Baer 5 years ago
commit 8364dce398
  1. 2
      .travis.yml
  2. 30
      Makefile
  3. 56
      account.go
  4. 195
      account_import.go
  5. 129
      activitypub.go
  6. 83
      admin.go
  7. 58
      app.go
  8. 3
      author/author.go
  9. 13
      cmd/writefreely/main.go
  10. 78
      collections.go
  11. 28
      config/config.go
  12. 15
      config/funcs.go
  13. 6
      database-lib.go
  14. 14
      database-no-sqlite.go
  15. 12
      database-sqlite.go
  16. 303
      database.go
  17. 50
      database_test.go
  18. 52
      db/alter.go
  19. 56
      db/alter_test.go
  20. 244
      db/create.go
  21. 146
      db/create_test.go
  22. 76
      db/dialect.go
  23. 53
      db/index.go
  24. 9
      db/raw.go
  25. 26
      db/tx.go
  26. 12
      errors.go
  27. 14
      feed.go
  28. 26
      go.mod
  29. 56
      go.sum
  30. 54
      handle.go
  31. 13
      invites.go
  32. 32
      less/core.less
  33. 10
      less/post-temp.less
  34. 153
      main_test.go
  35. 9
      migrations/migrations.go
  36. 29
      migrations/v3.go
  37. 46
      migrations/v4.go
  38. 67
      migrations/v5.go
  39. 29
      migrations/v6.go
  40. 291
      oauth.go
  41. 10
      oauth/state.go
  42. 218
      oauth_signup.go
  43. 180
      oauth_slack.go
  44. 253
      oauth_test.go
  45. 114
      oauth_writeas.go
  46. 23
      pad.go
  47. 49
      pages/login.tmpl
  48. 174
      pages/signup-oauth.tmpl
  49. 35
      postrender.go
  50. 139
      posts.go
  51. 14
      read.go
  52. 23
      routes.go
  53. 22
      scripts/upgrade-server.sh
  54. BIN
      static/img/sign_in_with_slack.png
  55. BIN
      static/img/sign_in_with_slack@2x.png
  56. 16
      static/js/localdate.js
  57. 12
      templates.go
  58. 6
      templates/base.tmpl
  59. 18
      templates/chorus-collection-post.tmpl
  60. 6
      templates/chorus-collection.tmpl
  61. 8
      templates/collection-post.tmpl
  62. 6
      templates/collection-tags.tmpl
  63. 6
      templates/collection.tmpl
  64. 4
      templates/edit-meta.tmpl
  65. 4
      templates/include/posts.tmpl
  66. 6
      templates/pad.tmpl
  67. 5
      templates/post.tmpl
  68. 6
      templates/read.tmpl
  69. 2
      templates/user/admin/users.tmpl
  70. 75
      templates/user/admin/view-user.tmpl
  71. 15
      templates/user/articles.tmpl
  72. 3
      templates/user/collection.tmpl
  73. 3
      templates/user/collections.tmpl
  74. 64
      templates/user/import.tmpl
  75. 8
      templates/user/include/header.tmpl
  76. 5
      templates/user/include/silenced.tmpl
  77. 24
      templates/user/invite.tmpl
  78. 3
      templates/user/settings.tmpl
  79. 3
      templates/user/stats.tmpl
  80. 12
      users.go
  81. 62
      webfinger.go

@ -1,7 +1,7 @@
language: go language: go
go: go:
- "1.11.x" - "1.13.x"
env: env:
- GO111MODULE=on - GO111MODULE=on

@ -25,28 +25,40 @@ build-no-sqlite: assets-no-sqlite deps-no-sqlite
build-linux: deps build-linux: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOGET) -u github.com/karalabe/xgo; \ $(GOGET) -u src.techknowlogick.com/xgo; \
fi fi
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
build-windows: deps build-windows: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOGET) -u github.com/karalabe/xgo; \ $(GOGET) -u src.techknowlogick.com/xgo; \
fi fi
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
build-darwin: deps build-darwin: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOGET) -u github.com/karalabe/xgo; \ $(GOGET) -u src.techknowlogick.com/xgo; \
fi fi
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely 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 build-arm7: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOGET) -u github.com/karalabe/xgo; \ $(GOGET) -u src.techknowlogick.com/xgo; \
fi fi
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely 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 src.techknowlogick.com/xgo; \
fi
xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
build-docker : build-docker :
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) . $(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
@ -79,10 +91,18 @@ release : clean ui assets
mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME) mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
rm $(BUILDPATH)/$(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 $(MAKE) build-arm7
mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME) mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME)
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME)
rm $(BUILDPATH)/$(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-arm64
mv build/$(BINARY_NAME)-linux-arm64 $(BUILDPATH)/$(BINARY_NAME)
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm64.tar.gz -C build $(BINARY_NAME)
rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-darwin $(MAKE) build-darwin
mv build/$(BINARY_NAME)-darwin-10.6-amd64 $(BUILDPATH)/$(BINARY_NAME) mv build/$(BINARY_NAME)-darwin-10.6-amd64 $(BUILDPATH)/$(BINARY_NAME)
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME)
@ -135,7 +155,7 @@ $(TMPBIN)/go-bindata: deps $(TMPBIN)
$(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata $(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata
$(TMPBIN)/xgo: deps $(TMPBIN) $(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 ci-assets : $(TMPBIN)/go-bindata
$(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql $(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql

@ -156,17 +156,9 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
Username: signup.Alias, Username: signup.Alias,
HashedPass: hashedPass, HashedPass: hashedPass,
HasPass: createdWithPass, HasPass: createdWithPass,
Email: zero.NewString("", signup.Email != ""), Email: prepareUserEmail(signup.Email, app.keys.EmailKey),
Created: time.Now().Truncate(time.Second).UTC(), Created: time.Now().Truncate(time.Second).UTC(),
} }
if signup.Email != "" {
encEmail, err := data.Encrypt(app.keys.EmailKey, signup.Email)
if err != nil {
log.Error("Unable to encrypt email: %s\n", err)
} else {
u.Email.String = string(encEmail)
}
}
// Create actual user // Create actual user
if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil { if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
@ -314,12 +306,16 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
Message template.HTML Message template.HTML
Flashes []template.HTML Flashes []template.HTML
LoginUsername string LoginUsername string
OauthSlack bool
OauthWriteAs bool
}{ }{
pageForReq(app, r), pageForReq(app, r),
r.FormValue("to"), r.FormValue("to"),
template.HTML(""), template.HTML(""),
[]template.HTML{}, []template.HTML{},
getTempInfo(app, "login-user", r, w), getTempInfo(app, "login-user", r, w),
app.Config().SlackOauth.ClientID != "",
app.Config().WriteAsOauth.ClientID != "",
} }
if earlyError != "" { if earlyError != "" {
@ -750,14 +746,20 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
log.Error("unable to fetch collections: %v", err) log.Error("unable to fetch collections: %v", err)
} }
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
log.Error("view articles: %v", err)
}
d := struct { d := struct {
*UserPage *UserPage
AnonymousPosts *[]PublicPost AnonymousPosts *[]PublicPost
Collections *[]Collection Collections *[]Collection
Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f), UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f),
AnonymousPosts: p, AnonymousPosts: p,
Collections: c, Collections: c,
Silenced: silenced,
} }
d.UserPage.SetMessaging(u) d.UserPage.SetMessaging(u)
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
@ -779,6 +781,11 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request)
uc, _ := app.db.GetUserCollectionCount(u.ID) uc, _ := app.db.GetUserCollectionCount(u.ID)
// TODO: handle any errors // TODO: handle any errors
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
log.Error("view collections %v", err)
return fmt.Errorf("view collections: %v", err)
}
d := struct { d := struct {
*UserPage *UserPage
Collections *[]Collection Collections *[]Collection
@ -786,11 +793,13 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request)
UsedCollections, TotalCollections int UsedCollections, TotalCollections int
NewBlogsDisabled bool NewBlogsDisabled bool
Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f), UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f),
Collections: c, Collections: c,
UsedCollections: int(uc), UsedCollections: int(uc),
NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc), NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
Silenced: silenced,
} }
d.UserPage.SetMessaging(u) d.UserPage.SetMessaging(u)
showUserPage(w, "collections", d) showUserPage(w, "collections", d)
@ -808,13 +817,20 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
return ErrCollectionNotFound return ErrCollectionNotFound
} }
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
log.Error("view edit collection %v", err)
return fmt.Errorf("view edit collection: %v", err)
}
flashes, _ := getSessionFlashes(app, w, r, nil) flashes, _ := getSessionFlashes(app, w, r, nil)
obj := struct { obj := struct {
*UserPage *UserPage
*Collection *Collection
Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
Collection: c, Collection: c,
Silenced: silenced,
} }
showUserPage(w, "collection", obj) showUserPage(w, "collection", obj)
@ -976,17 +992,24 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
titleStats = c.DisplayTitle() + " " titleStats = c.DisplayTitle() + " "
} }
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
log.Error("view stats: %v", err)
return err
}
obj := struct { obj := struct {
*UserPage *UserPage
VisitsBlog string VisitsBlog string
Collection *Collection Collection *Collection
TopPosts *[]PublicPost TopPosts *[]PublicPost
APFollowers int APFollowers int
Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
VisitsBlog: alias, VisitsBlog: alias,
Collection: c, Collection: c,
TopPosts: topPosts, TopPosts: topPosts,
Silenced: silenced,
} }
if app.cfg.App.Federation { if app.cfg.App.Federation {
folls, err := app.db.GetAPFollowers(c) folls, err := app.db.GetAPFollowers(c)
@ -1020,11 +1043,13 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
Email string Email string
HasPass bool HasPass bool
IsLogOut bool IsLogOut bool
Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, "Account Settings", flashes), UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
Email: fullUser.EmailClear(app.keys), Email: fullUser.EmailClear(app.keys),
HasPass: passIsSet, HasPass: passIsSet,
IsLogOut: r.FormValue("logout") == "1", IsLogOut: r.FormValue("logout") == "1",
Silenced: fullUser.IsSilenced(),
} }
showUserPage(w, "settings", obj) showUserPage(w, "settings", obj)
@ -1068,3 +1093,16 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s
// Return value // Return value
return s return s
} }
func prepareUserEmail(input string, emailKey []byte) zero.String {
email := zero.NewString("", input != "")
if len(input) > 0 {
encEmail, err := data.Encrypt(emailKey, input)
if err != nil {
log.Error("Unable to encrypt email: %s\n", err)
} else {
email.String = string(encEmail)
}
}
return email
}

@ -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"}
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -37,6 +37,8 @@ import (
const ( const (
// TODO: delete. don't use this! // TODO: delete. don't use this!
apCustomHandleDefault = "blog" apCustomHandleDefault = "blog"
apCacheTime = time.Minute
) )
type RemoteUser struct { type RemoteUser struct {
@ -44,6 +46,7 @@ type RemoteUser struct {
ActorID string ActorID string
Inbox string Inbox string
SharedInbox string SharedInbox string
Handle string
} }
func (ru *RemoteUser) AsPerson() *activitystreams.Person { 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 { func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Server", serverSoftware) w.Header().Set("Server", serverSoftware)
@ -80,10 +89,19 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
if err != nil { if err != nil {
return err return err
} }
silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("fetch collection activities: %v", err)
return ErrInternalGeneral
}
if silenced {
return ErrCollectionNotFound
}
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
p := c.PersonObject() p := c.PersonObject()
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, p, http.StatusOK) return impart.RenderActivityJSON(w, p, http.StatusOK)
} }
@ -105,6 +123,14 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
if err != nil { if err != nil {
return err 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 c.hostName = app.cfg.App.Host
if app.cfg.App.SingleUser { if app.cfg.App.SingleUser {
@ -132,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) posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false)
for _, pp := range *posts { for _, pp := range *posts {
pp.Collection = res pp.Collection = res
o := pp.ActivityObject(app.cfg) o := pp.ActivityObject(app)
a := activitystreams.NewCreateActivity(o) a := activitystreams.NewCreateActivity(o)
ocp.OrderedItems = append(ocp.OrderedItems, *a) ocp.OrderedItems = append(ocp.OrderedItems, *a)
} }
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, ocp, http.StatusOK) return impart.RenderActivityJSON(w, ocp, http.StatusOK)
} }
@ -158,6 +185,14 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req
if err != nil { if err != nil {
return err 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 c.hostName = app.cfg.App.Host
accountRoot := c.FederatedAccount() accountRoot := c.FederatedAccount()
@ -183,6 +218,7 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req
ocp.OrderedItems = append(ocp.OrderedItems, f.ActorID) ocp.OrderedItems = append(ocp.OrderedItems, f.ActorID)
} }
*/ */
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, ocp, http.StatusOK) return impart.RenderActivityJSON(w, ocp, http.StatusOK)
} }
@ -204,6 +240,14 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req
if err != nil { if err != nil {
return err 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 c.hostName = app.cfg.App.Host
accountRoot := c.FederatedAccount() accountRoot := c.FederatedAccount()
@ -219,6 +263,7 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req
// Return outbox page // Return outbox page
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "following", 0, p) ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "following", 0, p)
ocp.OrderedItems = []interface{}{} ocp.OrderedItems = []interface{}{}
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, ocp, http.StatusOK) return impart.RenderActivityJSON(w, ocp, http.StatusOK)
} }
@ -238,6 +283,14 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
// TODO: return Reject? // TODO: return Reject?
return err 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 c.hostName = app.cfg.App.Host
if debugging { if debugging {
@ -342,6 +395,11 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
} }
go func() { go func() {
if to == nil {
log.Error("No to! %v", err)
return
}
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
am, err := a.Serialize() am, err := a.Serialize()
if err != nil { if err != nil {
@ -350,10 +408,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
} }
am["@context"] = []string{activitystreams.Namespace} 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) err = makeActivityPost(app.cfg.App.Host, p, fullActor.Inbox, am)
if err != nil { if err != nil {
log.Error("Unable to make activity POST: %v", err) log.Error("Unable to make activity POST: %v", err)
@ -462,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 { if err != nil {
return err return err
} }
@ -498,7 +552,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
} }
} }
resp, err := http.DefaultClient.Do(r) resp, err := activityPubClient().Do(r)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -524,7 +578,7 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
} }
p.Collection.hostName = app.cfg.App.Host p.Collection.hostName = app.cfg.App.Host
actor := p.Collection.PersonObject(collID) actor := p.Collection.PersonObject(collID)
na := p.ActivityObject(app.cfg) na := p.ActivityObject(app)
// Add followers // Add followers
p.Collection.ID = collID p.Collection.ID = collID
@ -570,7 +624,7 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
} }
} }
actor := p.Collection.PersonObject(collID) actor := p.Collection.PersonObject(collID)
na := p.ActivityObject(app.cfg) na := p.ActivityObject(app)
// Add followers // Add followers
p.Collection.ID = collID p.Collection.ID = collID
@ -588,18 +642,25 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
inbox = f.Inbox inbox = f.Inbox
} }
if _, ok := inboxes[inbox]; ok { if _, ok := inboxes[inbox]; ok {
// check if we're already sending to this shared inbox
inboxes[inbox] = append(inboxes[inbox], f.ActorID) inboxes[inbox] = append(inboxes[inbox], f.ActorID)
} else { } else {
// add the new shared inbox to the list
inboxes[inbox] = []string{f.ActorID} inboxes[inbox] = []string{f.ActorID}
} }
} }
var activity *activitystreams.Activity
// for each one of the shared inboxes
for si, instFolls := range inboxes { for si, instFolls := range inboxes {
// add all followers from that instance
// to the CC field
na.CC = []string{} na.CC = []string{}
for _, f := range instFolls { for _, f := range instFolls {
na.CC = append(na.CC, f) na.CC = append(na.CC, f)
} }
var activity *activitystreams.Activity // create a new "Create" activity
// with our article as object
if isUpdate { if isUpdate {
activity = activitystreams.NewUpdateActivity(na) activity = activitystreams.NewUpdateActivity(na)
} else { } else {
@ -607,17 +668,42 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
activity.To = na.To activity.To = na.To
activity.CC = na.CC activity.CC = na.CC
} }
// and post it to that sharedInbox
err = makeActivityPost(app.cfg.App.Host, actor, si, activity) err = makeActivityPost(app.cfg.App.Host, actor, si, activity)
if err != nil { if err != nil {
log.Error("Couldn't post! %v", err) 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 return nil
} }
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
u := RemoteUser{ActorID: actorID} 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."} return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."}
@ -629,6 +715,21 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
return &u, nil 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) { func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser, error) {
log.Info("Fetching actor %s locally", actorIRI) log.Info("Fetching actor %s locally", actorIRI)
actor := &activitystreams.Person{} actor := &activitystreams.Person{}
@ -703,3 +804,7 @@ func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error {
return nil return nil
} }
func setCacheControl(w http.ResponseWriter, ttl time.Duration) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
}

@ -16,12 +16,14 @@ import (
"net/http" "net/http"
"runtime" "runtime"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/auth" "github.com/writeas/web-core/auth"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/passgen"
"github.com/writeas/writefreely/appstats" "github.com/writeas/writefreely/appstats"
"github.com/writeas/writefreely/config" "github.com/writeas/writefreely/config"
) )
@ -169,11 +171,12 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
Config config.AppCfg Config config.AppCfg
Message string Message string
User *User User *User
Colls []inspectedCollection Colls []inspectedCollection
LastPost string LastPost string
NewPassword string
TotalPosts int64 TotalPosts int64
ClearEmail string
}{ }{
Config: app.cfg.App, Config: app.cfg.App,
Message: r.FormValue("m"), Message: r.FormValue("m"),
@ -183,7 +186,19 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
var err error var err error
p.User, err = app.db.GetUserForAuth(username) p.User, err = app.db.GetUserForAuth(username)
if err != nil { 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)
for _, flash := range flashes {
if strings.HasPrefix(flash, "SUCCESS: ") {
p.NewPassword = strings.TrimPrefix(flash, "SUCCESS: ")
p.ClearEmail = p.User.EmailClear(app.keys)
}
} }
p.UserPage = NewUserPage(app, r, u, p.User.Username, nil) p.UserPage = NewUserPage(app, r, u, p.User.Username, nil)
p.TotalPosts = app.db.GetUserPostsCount(p.User.ID) p.TotalPosts = app.db.GetUserPostsCount(p.User.ID)
@ -229,6 +244,62 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
return nil return nil
} }
func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
user, err := app.db.GetUserForAuth(username)
if err != nil {
log.Error("failed to get user: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)}
}
if user.IsSilenced() {
err = app.db.SetUserStatus(user.ID, UserActive)
} else {
err = app.db.SetUserStatus(user.ID, UserSilenced)
}
if err != nil {
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)}
}
func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
// Generate new random password since none supplied
pass := passgen.NewWordish()
hashedPass, err := auth.HashPass([]byte(pass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
}
userIDVal := r.FormValue("user")
log.Info("ADMIN: Changing user %s password", userIDVal)
id, err := strconv.Atoi(userIDVal)
if err != nil {
return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid user ID: %v", err)}
}
err = app.db.ChangePassphrase(int64(id), true, "", hashedPass)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
}
log.Info("ADMIN: Successfully changed.")
addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: %s", pass), nil)
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s", username)}
}
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error { func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct { p := struct {
*UserPage *UserPage

@ -56,7 +56,7 @@ var (
debugging bool debugging bool
// Software version can be set from git env using -ldflags // Software version can be set from git env using -ldflags
softwareVer = "0.10.0" softwareVer = "0.11.2"
// DEPRECATED VARS // DEPRECATED VARS
isSingleUser bool isSingleUser bool
@ -70,7 +70,7 @@ type App struct {
cfg *config.Config cfg *config.Config
cfgFile string cfgFile string
keys *key.Keychain keys *key.Keychain
sessionStore *sessions.CookieStore sessionStore sessions.Store
formDecoder *schema.Decoder formDecoder *schema.Decoder
updates *updatesCache updates *updatesCache
@ -102,6 +102,14 @@ func (app *App) SetKeys(k *key.Keychain) {
app.keys = k 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 // Apper is the interface for getting data into and out of a WriteFreely
// instance (or "App"). // instance (or "App").
// //
@ -684,6 +692,52 @@ func ResetPassword(apper Apper, username string) error {
return nil 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) { func connectToDatabase(app *App) {
log.Info("Connecting to %s database...", app.cfg.Database.Type) log.Info("Connecting to %s database...", app.cfg.Database.Type)

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -65,6 +65,7 @@ var reservedUsernames = map[string]bool{
"metadata": true, "metadata": true,
"new": true, "new": true,
"news": true, "news": true,
"oauth": true,
"post": true, "post": true,
"posts": true, "posts": true,
"privacy": true, "privacy": true,

@ -13,11 +13,12 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"os"
"strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely" "github.com/writeas/writefreely"
"os"
"strings"
) )
func main() { func main() {
@ -38,6 +39,7 @@ func main() {
// Admin actions // Admin actions
createAdmin := flag.String("create-admin", "", "Create an admin with the given username:password") 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") 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") resetPassUser := flag.String("reset-pass", "", "Reset the given user's password")
outputVersion := flag.Bool("v", false, "Output the current version") outputVersion := flag.Bool("v", false, "Output the current version")
flag.Parse() flag.Parse()
@ -102,6 +104,13 @@ func main() {
os.Exit(1) os.Exit(1)
} }
os.Exit(0) 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 { } else if *migrate {
err := writefreely.Migrate(app) err := writefreely.Migrate(app)
if err != nil { if err != nil {

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -63,6 +63,7 @@ type (
TotalPosts int `json:"total_posts"` TotalPosts int `json:"total_posts"`
Owner *User `json:"owner,omitempty"` Owner *User `json:"owner,omitempty"`
Posts *[]PublicPost `json:"posts,omitempty"` Posts *[]PublicPost `json:"posts,omitempty"`
Format *CollectionFormat
} }
DisplayCollection struct { DisplayCollection struct {
*CollectionObj *CollectionObj
@ -70,7 +71,7 @@ type (
IsTopLevel bool IsTopLevel bool
CurrentPage int CurrentPage int
TotalPages int TotalPages int
Format *CollectionFormat Silenced bool
} }
SubmittedCollection struct { SubmittedCollection struct {
// Data used for updating a given collection // Data used for updating a given collection
@ -379,6 +380,7 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
} }
var userID int64 var userID int64
var err error
if reqJSON && !c.Web { if reqJSON && !c.Web {
accessToken = r.Header.Get("Authorization") accessToken = r.Header.Get("Authorization")
if accessToken == "" { if accessToken == "" {
@ -395,6 +397,14 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
} }
userID = u.ID userID = u.ID
} }
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("new collection: %v", err)
return ErrInternalGeneral
}
if silenced {
return ErrUserSilenced
}
if !author.IsValidUsername(app.cfg, c.Alias) { if !author.IsValidUsername(app.cfg, c.Alias) {
return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."} return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."}
@ -477,6 +487,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
res.Owner = u res.Owner = u
} }
} }
// TODO: check status for silenced
app.db.GetPostsCount(res, isCollOwner) app.db.GetPostsCount(res, isCollOwner)
// Strip non-public information // Strip non-public information
res.Collection.ForPublic() res.Collection.ForPublic()
@ -545,6 +556,13 @@ type CollectionPage struct {
CanInvite bool CanInvite bool
} }
func NewCollectionObj(c *Collection) *CollectionObj {
return &CollectionObj{
Collection: *c,
Format: c.NewFormat(),
}
}
func (c *CollectionObj) ScriptDisplay() template.JS { func (c *CollectionObj) ScriptDisplay() template.JS {
return template.JS(c.Script) return template.JS(c.Script)
} }
@ -637,6 +655,16 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R
uname = u.Username uname = u.Username
} }
// TODO: move this to all permission checks?
suspended, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("process protected collection permissions: %v", err)
return nil, err
}
if suspended {
return nil, ErrCollectionNotFound
}
// See if we've authorized this collection // See if we've authorized this collection
authd := isAuthorizedForCollection(app, c.Alias, r) authd := isAuthorizedForCollection(app, c.Alias, r)
@ -684,11 +712,10 @@ func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPost
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection { func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
coll := &DisplayCollection{ coll := &DisplayCollection{
CollectionObj: &CollectionObj{Collection: *c}, CollectionObj: NewCollectionObj(c),
CurrentPage: page, CurrentPage: page,
Prefix: cr.prefix, Prefix: cr.prefix,
IsTopLevel: isSingleUser, IsTopLevel: isSingleUser,
Format: c.NewFormat(),
} }
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner) c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
return coll return coll
@ -725,13 +752,19 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
if c == nil || err != nil { if c == nil || err != nil {
return err return err
} }
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("view collection: %v", err)
return ErrInternalGeneral
}
// Serve ActivityStreams data now, if requested // Serve ActivityStreams data now, if requested
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
ac := c.PersonObject() ac := c.PersonObject()
ac.Context = []interface{}{activitystreams.Namespace} ac.Context = []interface{}{activitystreams.Namespace}
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, ac, http.StatusOK) return impart.RenderActivityJSON(w, ac, http.StatusOK)
} }
@ -784,6 +817,10 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
log.Error("Error getting user for collection: %v", err) log.Error("Error getting user for collection: %v", err)
} }
} }
if !isOwner && silenced {
return ErrCollectionNotFound
}
displayPage.Silenced = isOwner && silenced
displayPage.Owner = owner displayPage.Owner = owner
coll.Owner = displayPage.Owner coll.Owner = displayPage.Owner
@ -820,6 +857,19 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
return err 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 { func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r) vars := mux.Vars(r)
tag := vars["tag"] tag := vars["tag"]
@ -885,7 +935,11 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
// Log the error and just continue // Log the error and just continue
log.Error("Error getting user for collection: %v", err) log.Error("Error getting user for collection: %v", err)
} }
if owner.IsSilenced() {
return ErrCollectionNotFound
}
} }
displayPage.Silenced = owner != nil && owner.IsSilenced()
displayPage.Owner = owner displayPage.Owner = owner
coll.Owner = displayPage.Owner coll.Owner = displayPage.Owner
// Add more data // Add more data
@ -924,11 +978,10 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
collAlias := vars["alias"] collAlias := vars["alias"]
isWeb := r.FormValue("web") == "1" isWeb := r.FormValue("web") == "1"
var u *User u := &User{}
if reqJSON && !isWeb { if reqJSON && !isWeb {
// Ensure an access token was given // Ensure an access token was given
accessToken := r.Header.Get("Authorization") accessToken := r.Header.Get("Authorization")
u = &User{}
u.ID = app.db.GetUserID(accessToken) u.ID = app.db.GetUserID(accessToken)
if u.ID == -1 { if u.ID == -1 {
return ErrBadAccessToken return ErrBadAccessToken
@ -940,6 +993,16 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
} }
} }
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
log.Error("existing collection: %v", err)
return ErrInternalGeneral
}
if silenced {
return ErrUserSilenced
}
if r.Method == "DELETE" { if r.Method == "DELETE" {
err := app.db.DeleteCollection(collAlias, u.ID) err := app.db.DeleteCollection(collAlias, u.ID)
if err != nil { if err != nil {
@ -952,7 +1015,6 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
} }
c := SubmittedCollection{OwnerID: uint64(u.ID)} c := SubmittedCollection{OwnerID: uint64(u.ID)}
var err error
if reqJSON { if reqJSON {
// Decode JSON request // Decode JSON request

@ -43,6 +43,8 @@ type (
PagesParentDir string `ini:"pages_parent_dir"` PagesParentDir string `ini:"pages_parent_dir"`
KeysParentDir string `ini:"keys_parent_dir"` KeysParentDir string `ini:"keys_parent_dir"`
HashSeed string `ini:"hash_seed"`
Dev bool `ini:"-"` Dev bool `ini:"-"`
} }
@ -57,6 +59,24 @@ type (
Port int `ini:"port"` Port int `ini:"port"`
} }
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"`
}
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"`
}
// AppCfg holds values that affect how the application functions // AppCfg holds values that affect how the application functions
AppCfg struct { AppCfg struct {
SiteName string `ini:"site_name"` SiteName string `ini:"site_name"`
@ -102,9 +122,11 @@ type (
// Config holds the complete configuration for running a writefreely instance // Config holds the complete configuration for running a writefreely instance
Config struct { Config struct {
Server ServerCfg `ini:"server"` Server ServerCfg `ini:"server"`
Database DatabaseCfg `ini:"database"` Database DatabaseCfg `ini:"database"`
App AppCfg `ini:"app"` App AppCfg `ini:"app"`
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
} }
) )

@ -11,7 +11,9 @@
package config package config
import ( import (
"net/http"
"strings" "strings"
"time"
) )
// FriendlyHost returns the app's Host sans any schema // FriendlyHost returns the app's Host sans any schema
@ -25,3 +27,16 @@ func (ac AppCfg) CanCreateBlogs(currentlyUsed uint64) bool {
} }
return int(currentlyUsed) < ac.MaxBlogs return int(currentlyUsed) < ac.MaxBlogs
} }
// OrDefaultString returns input or a default value if input is empty.
func OrDefaultString(input, defaultValue string) string {
if len(input) == 0 {
return defaultValue
}
return input
}
// DefaultHTTPClient returns a sane default HTTP client.
func DefaultHTTPClient() *http.Client {
return &http.Client{Timeout: 10 * time.Second}
}

@ -1,7 +1,7 @@
// +build wflib // +build wflib
/* /*
* Copyright © 2019 A Bunch Tell LLC. * Copyright © 2019-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -18,3 +18,7 @@ package writefreely
func (db *datastore) isDuplicateKeyErr(err error) bool { func (db *datastore) isDuplicateKeyErr(err error) bool {
return false return false
} }
func (db *datastore) isIgnorableError(err error) bool {
return false
}

@ -1,7 +1,7 @@
// +build !sqlite,!wflib // +build !sqlite,!wflib
/* /*
* Copyright © 2019 A Bunch Tell LLC. * Copyright © 2019-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -28,3 +28,15 @@ func (db *datastore) isDuplicateKeyErr(err error) bool {
return false 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
}

@ -48,3 +48,15 @@ func (db *datastore) isDuplicateKeyErr(err error) bool {
return false 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
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -11,8 +11,10 @@
package writefreely package writefreely
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
wf_db "github.com/writeas/writefreely/db"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -20,6 +22,7 @@ import (
"github.com/guregu/null" "github.com/guregu/null"
"github.com/guregu/null/zero" "github.com/guregu/null/zero"
uuid "github.com/nu7hatch/gouuid" uuid "github.com/nu7hatch/gouuid"
"github.com/writeas/activityserve"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/nerds/store" "github.com/writeas/nerds/store"
"github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/activitypub"
@ -35,6 +38,7 @@ import (
const ( const (
mySQLErrDuplicateKey = 1062 mySQLErrDuplicateKey = 1062
mySQLErrCollationMix = 1267
driverMySQL = "mysql" driverMySQL = "mysql"
driverSQLite = "sqlite3" driverSQLite = "sqlite3"
@ -61,7 +65,7 @@ type writestore interface {
GetAccessToken(userID int64) (string, error) GetAccessToken(userID int64) (string, error)
GetTemporaryAccessToken(userID int64, validSecs int) (string, error) GetTemporaryAccessToken(userID int64, validSecs int) (string, error)
GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (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 ChangeSettings(app *App, u *User, s *userSettings) error
ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error
@ -124,6 +128,11 @@ type writestore interface {
GetUserLastPostTime(id int64) (*time.Time, error) GetUserLastPostTime(id int64) (*time.Time, error)
GetCollectionLastPostTime(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, error)
GenerateOAuthState(context.Context, string, string) (string, error)
DatabaseInitialized() bool DatabaseInitialized() bool
} }
@ -132,6 +141,8 @@ type datastore struct {
driverName string driverName string
} }
var _ writestore = &datastore{}
func (db *datastore) now() string { func (db *datastore) now() string {
if db.driverName == driverSQLite { if db.driverName == driverSQLite {
return "strftime('%Y-%m-%d %H:%M:%S','now')" return "strftime('%Y-%m-%d %H:%M:%S','now')"
@ -296,7 +307,7 @@ func (db *datastore) CreateCollection(cfg *config.Config, alias, title string, u
func (db *datastore) GetUserByID(id int64) (*User, error) { func (db *datastore) GetUserByID(id int64) (*User, error) {
u := &User{ID: id} u := &User{ID: id}
err := db.QueryRow("SELECT username, password, email, created FROM users WHERE id = ?", id).Scan(&u.Username, &u.HashedPass, &u.Email, &u.Created) 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, ErrUserNotFound return nil, ErrUserNotFound
@ -308,6 +319,23 @@ func (db *datastore) GetUserByID(id int64) (*User, error) {
return u, nil 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, fmt.Errorf("is user silenced: %v", 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 // DoesUserNeedAuth returns true if the user hasn't provided any methods for
// authenticating with the account, such a passphrase or email address. // authenticating with the account, such a passphrase or email address.
// Any errors are reported to admin and silently quashed, returning false as the // Any errors are reported to admin and silently quashed, returning false as the
@ -347,7 +375,7 @@ func (db *datastore) IsUserPassSet(id int64) (bool, error) {
func (db *datastore) GetUserForAuth(username string) (*User, error) { func (db *datastore) GetUserForAuth(username string) (*User, error) {
u := &User{Username: username} u := &User{Username: username}
err := db.QueryRow("SELECT id, password, email, created FROM users WHERE username = ?", username).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created) 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
// Check if they've entered the wrong, unnormalized username // Check if they've entered the wrong, unnormalized username
@ -370,7 +398,7 @@ func (db *datastore) GetUserForAuth(username string) (*User, error) {
func (db *datastore) GetUserForAuthByID(userID int64) (*User, error) { func (db *datastore) GetUserForAuthByID(userID int64) (*User, error) {
u := &User{ID: userID} u := &User{ID: userID}
err := db.QueryRow("SELECT id, password, email, created FROM users WHERE id = ?", u.ID).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created) 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, ErrUserNotFound return nil, ErrUserNotFound
@ -1629,7 +1657,11 @@ func (db *datastore) GetMeStats(u *User) userMeStats {
} }
func (db *datastore) GetTotalCollections() (collCount int64, err error) { func (db *datastore) GetTotalCollections() (collCount int64, err error) {
err = db.QueryRow(`SELECT COUNT(*) FROM collections`).Scan(&collCount) 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 { if err != nil {
log.Error("Unable to fetch collections count: %v", err) log.Error("Unable to fetch collections count: %v", err)
} }
@ -1637,7 +1669,11 @@ func (db *datastore) GetTotalCollections() (collCount int64, err error) {
} }
func (db *datastore) GetTotalPosts() (postCount int64, err error) { func (db *datastore) GetTotalPosts() (postCount int64, err error) {
err = db.QueryRow(`SELECT COUNT(*) FROM posts`).Scan(&postCount) 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 { if err != nil {
log.Error("Unable to fetch posts count: %v", err) log.Error("Unable to fetch posts count: %v", err)
} }
@ -2079,22 +2115,13 @@ func (db *datastore) CollectionHasAttribute(id int64, attr string) bool {
return true return true
} }
func (db *datastore) DeleteAccount(userID int64) (l *string, err error) { // DeleteAccount will delete the entire account for userID
debug := "" func (db *datastore) DeleteAccount(userID int64) error {
l = &debug
t, err := db.Begin()
if err != nil {
stringLogln(l, "Unable to begin: %v", err)
return
}
// Get all collections // Get all collections
rows, err := db.Query("SELECT id, alias FROM collections WHERE owner_id = ?", userID) rows, err := db.Query("SELECT id, alias FROM collections WHERE owner_id = ?", userID)
if err != nil { if err != nil {
t.Rollback() log.Error("Unable to get collections: %v", err)
stringLogln(l, "Unable to get collections: %v", err) return err
return
} }
defer rows.Close() defer rows.Close()
colls := []Collection{} colls := []Collection{}
@ -2102,103 +2129,158 @@ func (db *datastore) DeleteAccount(userID int64) (l *string, err error) {
for rows.Next() { for rows.Next() {
err = rows.Scan(&c.ID, &c.Alias) err = rows.Scan(&c.ID, &c.Alias)
if err != nil { if err != nil {
t.Rollback() log.Error("Unable to scan collection cols: %v", err)
stringLogln(l, "Unable to scan collection cols: %v", err) return err
return
} }
colls = append(colls, c) 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 var res sql.Result
for _, c := range colls { for _, c := range colls {
// TODO: user deleteCollection() func
// Delete tokens // Delete tokens
res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID) res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID)
if err != nil { if err != nil {
t.Rollback() t.Rollback()
stringLogln(l, "Unable to delete attributes on %s: %v", c.Alias, err) log.Error("Unable to delete attributes on %s: %v", c.Alias, err)
return return err
} }
rs, _ := res.RowsAffected() 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 // Remove any optional collection password
res, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID) res, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID)
if err != nil { if err != nil {
t.Rollback() t.Rollback()
stringLogln(l, "Unable to delete passwords on %s: %v", c.Alias, err) log.Error("Unable to delete passwords on %s: %v", c.Alias, err)
return return err
} }
rs, _ = res.RowsAffected() 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 // Remove redirects to this collection
res, err = t.Exec("DELETE FROM collectionredirects WHERE new_alias = ?", c.Alias) res, err = t.Exec("DELETE FROM collectionredirects WHERE new_alias = ?", c.Alias)
if err != nil { if err != nil {
t.Rollback() t.Rollback()
stringLogln(l, "Unable to delete redirects on %s: %v", c.Alias, err) log.Error("Unable to delete redirects on %s: %v", c.Alias, err)
return return err
} }
rs, _ = res.RowsAffected() 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 // Delete collections
res, err = t.Exec("DELETE FROM collections WHERE owner_id = ?", userID) res, err = t.Exec("DELETE FROM collections WHERE owner_id = ?", userID)
if err != nil { if err != nil {
t.Rollback() t.Rollback()
stringLogln(l, "Unable to delete collections: %v", err) log.Error("Unable to delete collections: %v", err)
return return err
} }
rs, _ := res.RowsAffected() rs, _ := res.RowsAffected()
stringLogln(l, "Deleted %d from collections", rs) log.Info("Deleted %d from collections", rs)
// Delete tokens // Delete tokens
res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID) res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID)
if err != nil { if err != nil {
t.Rollback() t.Rollback()
stringLogln(l, "Unable to delete access tokens: %v", err) log.Error("Unable to delete access tokens: %v", err)
return return err
} }
rs, _ = res.RowsAffected() 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 // 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("DELETE FROM posts WHERE owner_id = ?", userID)
if err != nil { if err != nil {
t.Rollback() t.Rollback()
stringLogln(l, "Unable to delete posts: %v", err) log.Error("Unable to delete posts: %v", err)
return return err
} }
rs, _ = res.RowsAffected() 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) res, err = t.Exec("DELETE FROM userattributes WHERE user_id = ?", userID)
if err != nil { if err != nil {
t.Rollback() t.Rollback()
stringLogln(l, "Unable to delete attributes: %v", err) log.Error("Unable to delete attributes: %v", err)
return 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)
if err != nil {
t.Rollback()
log.Error("Unable to delete invites: %v", err)
return err
} }
rs, _ = res.RowsAffected() rs, _ = res.RowsAffected()
stringLogln(l, "Deleted %d from userattributes", rs) log.Info("Deleted %d from userinvites", rs)
// Delete the user
res, err = t.Exec("DELETE FROM users WHERE id = ?", userID) res, err = t.Exec("DELETE FROM users WHERE id = ?", userID)
if err != nil { if err != nil {
t.Rollback() t.Rollback()
stringLogln(l, "Unable to delete user: %v", err) log.Error("Unable to delete user: %v", err)
return return err
} }
rs, _ = res.RowsAffected() 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() err = t.Commit()
if err != nil { if err != nil {
t.Rollback() t.Rollback()
stringLogln(l, "Unable to commit: %v", err) log.Error("Unable to commit: %v", err)
return return err
} }
return // TODO: federate delete actor
return nil
} }
func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) { func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) {
@ -2247,7 +2329,7 @@ func (db *datastore) GetUserInvite(id string) (*Invite, error) {
var i Invite 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) 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows, db.isIgnorableError(err):
return nil, impart.HTTPError{http.StatusNotFound, "Invite doesn't exist."} return nil, impart.HTTPError{http.StatusNotFound, "Invite doesn't exist."}
case err != nil: case err != nil:
log.Error("Failed selecting invite: %v", err) log.Error("Failed selecting invite: %v", err)
@ -2359,17 +2441,17 @@ func (db *datastore) GetAllUsers(page uint) (*[]User, error) {
limitStr = fmt.Sprintf("%d, %d", (page-1)*adminUsersPerPage, adminUsersPerPage) limitStr = fmt.Sprintf("%d, %d", (page-1)*adminUsersPerPage, adminUsersPerPage)
} }
rows, err := db.Query("SELECT id, username, created FROM users ORDER BY created DESC LIMIT " + limitStr) rows, err := db.Query("SELECT id, username, created, status FROM users ORDER BY created DESC LIMIT " + limitStr)
if err != nil { if err != nil {
log.Error("Failed selecting from posts: %v", err) log.Error("Failed selecting from users: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user posts."} return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve all users."}
} }
defer rows.Close() defer rows.Close()
users := []User{} users := []User{}
for rows.Next() { for rows.Next() {
u := User{} u := User{}
err = rows.Scan(&u.ID, &u.Username, &u.Created) err = rows.Scan(&u.ID, &u.Username, &u.Created, &u.Status)
if err != nil { if err != nil {
log.Error("Failed scanning GetAllUsers() row: %v", err) log.Error("Failed scanning GetAllUsers() row: %v", err)
break break
@ -2406,6 +2488,15 @@ func (db *datastore) GetUserLastPostTime(id int64) (*time.Time, error) {
return &t, nil 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) { func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
var t time.Time var t time.Time
err := db.QueryRow("SELECT created FROM posts WHERE collection_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t) err := db.QueryRow("SELECT created FROM posts WHERE collection_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t)
@ -2419,6 +2510,69 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
return &t, nil return &t, nil
} }
func (db *datastore) GenerateOAuthState(ctx context.Context, provider, clientID string) (string, error) {
state := store.Generate62RandomString(24)
_, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at) VALUES (?, ?, ?, FALSE, NOW())", state, provider, clientID)
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, error) {
var provider string
var clientID string
err := wf_db.RunTransactionWithOptions(ctx, db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
err := tx.QueryRow("SELECT provider, client_id FROM oauth_client_states WHERE state = ? AND used = FALSE", state).Scan(&provider, &clientID)
if err != nil {
return err
}
res, err := tx.ExecContext(ctx, "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 "", "", nil
}
return provider, clientID, 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)
} 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)
}
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).
Scan(&userID)
// Not finding a record is OK.
if err != nil && err != sql.ErrNoRows {
return -1, err
}
return userID, nil
}
// DatabaseInitialized returns whether or not the current datastore has been // DatabaseInitialized returns whether or not the current datastore has been
// initialized with the correct schema. // initialized with the correct schema.
// Currently, it checks to see if the `users` table exists. // Currently, it checks to see if the `users` table exists.
@ -2449,3 +2603,40 @@ func handleFailedPostInsert(err error) error {
log.Error("Couldn't insert into posts: %v", err) log.Error("Couldn't insert into posts: %v", err)
return 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
}

@ -0,0 +1,50 @@
package writefreely
import (
"context"
"database/sql"
"github.com/stretchr/testify/assert"
"testing"
)
func TestOAuthDatastore(t *testing.T) {
if !runMySQLTests() {
t.Skip("skipping mysql tests")
}
withTestDB(t, func(db *sql.DB) {
ctx := context.Background()
ds := &datastore{
DB: db,
driverName: "",
}
state, err := ds.GenerateOAuthState(ctx, "test", "development")
assert.NoError(t, err)
assert.Len(t, state, 24)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = false", state)
_, _, err = ds.ValidateOAuthState(ctx, state)
assert.NoError(t, err)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = true", state)
var localUserID int64 = 99
var remoteUserID = "100"
err = ds.RecordRemoteUserID(ctx, localUserID, remoteUserID, "test", "test", "access_token_a")
assert.NoError(t, err)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_users` WHERE `user_id` = ? AND `remote_user_id` = ? AND access_token = 'access_token_a'", localUserID, remoteUserID)
err = ds.RecordRemoteUserID(ctx, localUserID, remoteUserID, "test", "test", "access_token_b")
assert.NoError(t, err)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_users` WHERE `user_id` = ? AND `remote_user_id` = ? AND access_token = 'access_token_b'", localUserID, remoteUserID)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_users`")
foundUserID, err := ds.GetIDForRemoteUser(ctx, remoteUserID, "test", "test")
assert.NoError(t, err)
assert.Equal(t, localUserID, foundUserID)
})
}

@ -0,0 +1,52 @@
package db
import (
"fmt"
"strings"
)
type AlterTableSqlBuilder struct {
Dialect DialectType
Name string
Changes []string
}
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))
}
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))
}
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, ", ")))
return b
}
func (b *AlterTableSqlBuilder) ToSQL() (string, error) {
var str strings.Builder
str.WriteString("ALTER TABLE ")
str.WriteString(b.Name)
str.WriteString(" ")
if len(b.Changes) == 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(", ")
}
}
return str.String(), nil
}

@ -0,0 +1,56 @@
package db
import "testing"
func TestAlterTableSqlBuilder_ToSQL(t *testing.T) {
type fields struct {
Dialect DialectType
Name string
Changes []string
}
tests := []struct {
name string
builder *AlterTableSqlBuilder
want string
wantErr bool
}{
{
name: "MySQL add int",
builder: DialectMySQL.
AlterTable("the_table").
AddColumn(DialectMySQL.Column("the_col", ColumnTypeInteger, UnsetSize)),
want: "ALTER TABLE the_table ADD COLUMN the_col INT NOT NULL",
wantErr: false,
},
{
name: "MySQL add string",
builder: DialectMySQL.
AlterTable("the_table").
AddColumn(DialectMySQL.Column("the_col", ColumnTypeVarChar, OptionalInt{true, 128})),
want: "ALTER TABLE the_table ADD COLUMN the_col VARCHAR(128) NOT NULL",
wantErr: false,
},
{
name: "MySQL add int and string",
builder: DialectMySQL.
AlterTable("the_table").
AddColumn(DialectMySQL.Column("first_col", ColumnTypeInteger, UnsetSize)).
AddColumn(DialectMySQL.Column("second_col", ColumnTypeVarChar, OptionalInt{true, 128})),
want: "ALTER TABLE the_table ADD COLUMN first_col INT NOT NULL, ADD COLUMN second_col VARCHAR(128) NOT NULL",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.builder.ToSQL()
if (err != nil) != tt.wantErr {
t.Errorf("ToSQL() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ToSQL() got = %v, want %v", got, tt.want)
}
})
}
}

@ -0,0 +1,244 @@
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 {
return "", fmt.Errorf("unsupported column type %d for dialect %d and size %v", d, dialect, size)
}
switch d {
case ColumnTypeSmallInt:
{
if dialect == DialectSQLite {
return "INTEGER", nil
}
mod := ""
if size.Set {
mod = fmt.Sprintf("(%d)", size.Value)
}
return "SMALLINT" + mod, nil
}
case ColumnTypeInteger:
{
if dialect == DialectSQLite {
return "INTEGER", nil
}
mod := ""
if size.Set {
mod = fmt.Sprintf("(%d)", size.Value)
}
return "INT" + mod, nil
}
case ColumnTypeChar:
{
if dialect == DialectSQLite {
return "TEXT", nil
}
mod := ""
if size.Set {
mod = fmt.Sprintf("(%d)", size.Value)
}
return "CHAR" + mod, nil
}
case ColumnTypeVarChar:
{
if dialect == DialectSQLite {
return "TEXT", nil
}
mod := ""
if size.Set {
mod = fmt.Sprintf("(%d)", size.Value)
}
return "VARCHAR" + mod, nil
}
case ColumnTypeBool:
{
if dialect == DialectSQLite {
return "INTEGER", nil
}
return "TINYINT(1)", nil
}
case ColumnTypeDateTime:
return "DATETIME", nil
case ColumnTypeText:
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) 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 ")
str.WriteString(c.Default.Value)
}
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)
}
for _, constraint := range b.Constraints {
things = append(things, constraint)
}
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
}

@ -0,0 +1,146 @@
package db
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestDialect_Column(t *testing.T) {
c1 := DialectSQLite.Column("foo", ColumnTypeBool, UnsetSize)
assert.Equal(t, DialectSQLite, c1.Dialect)
c2 := DialectMySQL.Column("foo", ColumnTypeBool, UnsetSize)
assert.Equal(t, DialectMySQL, c2.Dialect)
}
func TestColumnType_Format(t *testing.T) {
type args struct {
dialect DialectType
size OptionalInt
}
tests := []struct {
name string
d ColumnType
args args
want string
wantErr bool
}{
{"Sqlite bool", ColumnTypeBool, args{dialect: DialectSQLite}, "INTEGER", false},
{"Sqlite small int", ColumnTypeSmallInt, args{dialect: DialectSQLite}, "INTEGER", false},
{"Sqlite int", ColumnTypeInteger, args{dialect: DialectSQLite}, "INTEGER", false},
{"Sqlite char", ColumnTypeChar, args{dialect: DialectSQLite}, "TEXT", false},
{"Sqlite varchar", ColumnTypeVarChar, args{dialect: DialectSQLite}, "TEXT", false},
{"Sqlite text", ColumnTypeText, args{dialect: DialectSQLite}, "TEXT", false},
{"Sqlite datetime", ColumnTypeDateTime, args{dialect: DialectSQLite}, "DATETIME", false},
{"MySQL bool", ColumnTypeBool, args{dialect: DialectMySQL}, "TINYINT(1)", false},
{"MySQL small int", ColumnTypeSmallInt, args{dialect: DialectMySQL}, "SMALLINT", false},
{"MySQL small int with param", ColumnTypeSmallInt, args{dialect: DialectMySQL, size: OptionalInt{true, 3}}, "SMALLINT(3)", false},
{"MySQL int", ColumnTypeInteger, args{dialect: DialectMySQL}, "INT", false},
{"MySQL int with param", ColumnTypeInteger, args{dialect: DialectMySQL, size: OptionalInt{true, 11}}, "INT(11)", false},
{"MySQL char", ColumnTypeChar, args{dialect: DialectMySQL}, "CHAR", false},
{"MySQL char with param", ColumnTypeChar, args{dialect: DialectMySQL, size: OptionalInt{true, 4}}, "CHAR(4)", false},
{"MySQL varchar", ColumnTypeVarChar, args{dialect: DialectMySQL}, "VARCHAR", false},
{"MySQL varchar with param", ColumnTypeVarChar, args{dialect: DialectMySQL, size: OptionalInt{true, 25}}, "VARCHAR(25)", false},
{"MySQL text", ColumnTypeText, args{dialect: DialectMySQL}, "TEXT", false},
{"MySQL datetime", ColumnTypeDateTime, args{dialect: DialectMySQL}, "DATETIME", false},
{"invalid column type", 10000, args{dialect: DialectMySQL}, "", true},
{"invalid dialect", ColumnTypeBool, args{dialect: 10000}, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.d.Format(tt.args.dialect, tt.args.size)
if (err != nil) != tt.wantErr {
t.Errorf("Format() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Format() got = %v, want %v", got, tt.want)
}
})
}
}
func TestColumn_Build(t *testing.T) {
type fields struct {
Dialect DialectType
Name string
Nullable bool
Default OptionalString
Type ColumnType
Size OptionalInt
PrimaryKey bool
}
tests := []struct {
name string
fields fields
want string
wantErr bool
}{
{"Sqlite bool", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo INTEGER NOT NULL", false},
{"Sqlite bool nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo INTEGER", false},
{"Sqlite small int", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeSmallInt, UnsetSize, true}, "foo INTEGER NOT NULL PRIMARY KEY", false},
{"Sqlite small int nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeSmallInt, UnsetSize, false}, "foo INTEGER", false},
{"Sqlite int", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INTEGER NOT NULL", false},
{"Sqlite int nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INTEGER", false},
{"Sqlite char", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo TEXT NOT NULL", false},
{"Sqlite char nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo TEXT", false},
{"Sqlite varchar", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo TEXT NOT NULL", false},
{"Sqlite varchar nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo TEXT", false},
{"Sqlite text", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT NOT NULL", false},
{"Sqlite text nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT", false},
{"Sqlite datetime", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME NOT NULL", false},
{"Sqlite datetime nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME", false},
{"MySQL bool", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo TINYINT(1) NOT NULL", false},
{"MySQL bool nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo TINYINT(1)", false},
{"MySQL small int", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeSmallInt, UnsetSize, true}, "foo SMALLINT NOT NULL PRIMARY KEY", false},
{"MySQL small int nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeSmallInt, UnsetSize, false}, "foo SMALLINT", false},
{"MySQL int", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INT NOT NULL", false},
{"MySQL int nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INT", false},
{"MySQL char", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo CHAR NOT NULL", false},
{"MySQL char nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo CHAR", false},
{"MySQL varchar", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo VARCHAR NOT NULL", false},
{"MySQL varchar nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo VARCHAR", false},
{"MySQL text", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT NOT NULL", false},
{"MySQL text nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT", false},
{"MySQL datetime", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME NOT NULL", false},
{"MySQL datetime nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Column{
Dialect: tt.fields.Dialect,
Name: tt.fields.Name,
Nullable: tt.fields.Nullable,
Default: tt.fields.Default,
Type: tt.fields.Type,
Size: tt.fields.Size,
PrimaryKey: tt.fields.PrimaryKey,
}
if got, err := c.String(); got != tt.want {
if (err != nil) != tt.wantErr {
t.Errorf("String() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("String() got = %v, want %v", got, tt.want)
}
}
})
}
}
func TestCreateTableSqlBuilder_ToSQL(t *testing.T) {
sql, err := DialectMySQL.
Table("foo").
SetIfNotExists(true).
Column(DialectMySQL.Column("bar", ColumnTypeInteger, UnsetSize).SetPrimaryKey(true)).
Column(DialectMySQL.Column("baz", ColumnTypeText, UnsetSize)).
Column(DialectMySQL.Column("qux", ColumnTypeDateTime, UnsetSize).SetDefault("NOW()")).
UniqueConstraint("bar").
UniqueConstraint("bar", "baz").
ToSQL()
assert.NoError(t, err)
assert.Equal(t, "CREATE TABLE IF NOT EXISTS foo ( bar INT NOT NULL PRIMARY KEY, baz TEXT NOT NULL, qux DATETIME NOT NULL DEFAULT NOW(), UNIQUE(bar), UNIQUE(bar,baz) )", sql)
}

@ -0,0 +1,76 @@
package db
import "fmt"
type DialectType int
const (
DialectSQLite DialectType = iota
DialectMySQL 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}
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}
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}
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}
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}
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}
default:
panic(fmt.Sprintf("unexpected dialect: %d", d))
}
}

@ -0,0 +1,53 @@
package db
import (
"fmt"
"strings"
)
type CreateIndexSqlBuilder struct {
Dialect DialectType
Name string
Table string
Unique bool
Columns []string
}
type DropIndexSqlBuilder struct {
Dialect DialectType
Name string
Table string
}
func (b *CreateIndexSqlBuilder) ToSQL() (string, error) {
var str strings.Builder
str.WriteString("CREATE ")
if b.Unique {
str.WriteString("UNIQUE ")
}
str.WriteString("INDEX ")
str.WriteString(b.Name)
str.WriteString(" on ")
str.WriteString(b.Table)
if len(b.Columns) == 0 {
return "", fmt.Errorf("columns provided for this index: %s", b.Name)
}
str.WriteString(" (")
columnCount := len(b.Columns)
for i, thing := range b.Columns {
str.WriteString(thing)
if i < columnCount-1 {
str.WriteString(", ")
}
}
str.WriteString(")")
return str.String(), nil
}
func (b *DropIndexSqlBuilder) ToSQL() (string, error) {
return fmt.Sprintf("DROP INDEX %s on %s", b.Name, b.Table), nil
}

@ -0,0 +1,9 @@
package db
type RawSqlBuilder struct {
Query string
}
func (b *RawSqlBuilder) ToSQL() (string, error) {
return b.Query, nil
}

@ -0,0 +1,26 @@
package db
import (
"context"
"database/sql"
)
// TransactionScopedWork describes code executed within a database transaction.
type TransactionScopedWork func(ctx context.Context, db *sql.Tx) error
// RunTransactionWithOptions executes a block of code within a database transaction.
func RunTransactionWithOptions(ctx context.Context, db *sql.DB, txOpts *sql.TxOptions, txWork TransactionScopedWork) error {
tx, err := db.BeginTx(ctx, txOpts)
if err != nil {
return err
}
if err = txWork(ctx, tx); err != nil {
if txErr := tx.Rollback(); txErr != nil {
return txErr
}
return err
}
return tx.Commit()
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -11,8 +11,9 @@
package writefreely package writefreely
import ( import (
"github.com/writeas/impart"
"net/http" "net/http"
"github.com/writeas/impart"
) )
// Commonly returned HTTP errors // Commonly returned HTTP errors
@ -44,8 +45,11 @@ var (
ErrPostUnpublished = impart.HTTPError{Status: http.StatusGone, Message: "Post unpublished by author."} 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."} ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."}
ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."}
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."}
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
) )
// Post operation errors // Post operation errors

@ -12,12 +12,13 @@ package writefreely
import ( import (
"fmt" "fmt"
"net/http"
"time"
. "github.com/gorilla/feeds" . "github.com/gorilla/feeds"
"github.com/gorilla/mux" "github.com/gorilla/mux"
stripmd "github.com/writeas/go-strip-markdown" stripmd "github.com/writeas/go-strip-markdown"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"net/http"
"time"
) )
func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error { func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
@ -34,6 +35,15 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
if err != nil { if err != nil {
return nil return nil
} }
silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("view feed: get user: %v", err)
return ErrInternalGeneral
}
if silenced {
return ErrCollectionNotFound
}
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
if c.IsPrivate() || c.IsProtected() { if c.IsPrivate() || c.IsProtected() {

@ -6,17 +6,21 @@ require (
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect
github.com/clbanning/mxj v1.8.4 // 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/dustin/go-humanize v1.0.0
github.com/fatih/color v1.7.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-sql-driver/mysql v1.4.1
github.com/go-test/deep v1.0.1 // indirect github.com/go-test/deep v1.0.1 // indirect
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // 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/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/feeds v1.1.0 github.com/gorilla/feeds v1.1.0
github.com/gorilla/mux v1.7.0 github.com/gorilla/mux v1.7.0
github.com/gorilla/schema v1.0.2 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/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/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/jtolds/gls v4.2.1+incompatible // indirect
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
@ -31,30 +35,30 @@ require (
github.com/pelletier/go-toml v1.2.0 // indirect github.com/pelletier/go-toml v1.2.0 // indirect
github.com/pkg/errors v0.8.1 // indirect github.com/pkg/errors v0.8.1 // indirect
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
github.com/stretchr/testify v1.3.0 // indirect github.com/stretchr/testify v1.3.0
github.com/writeas/activity v0.1.2 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-strip-markdown v2.0.1+incompatible
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2
github.com/writeas/httpsig v1.0.0 github.com/writeas/httpsig v1.0.0
github.com/writeas/impart v1.1.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/monday v0.0.0-20181024183321-54a7dd579219
github.com/writeas/nerds v1.0.0 github.com/writeas/nerds v1.0.0
github.com/writeas/openssl-go v1.0.0 // indirect
github.com/writeas/saturday v1.7.1 github.com/writeas/saturday v1.7.1
github.com/writeas/slug v1.2.0 github.com/writeas/slug v1.2.0
github.com/writeas/web-core v1.0.0 github.com/writeas/web-core v1.2.0
github.com/writefreely/go-nodeinfo v1.2.0 github.com/writefreely/go-nodeinfo v1.2.0
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f golang.org/x/crypto v0.0.0-20200109152110-61a87790db17
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 // indirect golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect
golang.org/x/sys v0.0.0-20190209173611-3b5209105503 // indirect
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67
google.golang.org/appengine v1.4.0 // indirect google.golang.org/appengine v1.4.0 // indirect
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
gopkg.in/ini.v1 v1.41.0 gopkg.in/ini.v1 v1.41.0
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect gopkg.in/yaml.v2 v2.2.2 // indirect
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // indirect
) )
go 1.13

@ -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 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 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= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 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 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 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 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 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.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 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 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= 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 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo=
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= 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/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 h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg=
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE= 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 h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 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 h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY=
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU= 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 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk=
github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= 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/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 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 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.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= 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 h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM=
github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= 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 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw= 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= github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
@ -115,44 +126,63 @@ 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/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 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0= 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 h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE= 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 h1:DUsp4OhdfI+e6iUqcPQlwx8QYXuUDsToTz/x82D3Zuo=
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc= 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 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A=
github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY= 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 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE=
github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= 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 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ= 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= github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo=
github.com/writeas/nerds v1.0.0/go.mod h1:Gn2bHy1EwRcpXeB7ZhVmuUwiweK0e+JllNf66gvNLdU= github.com/writeas/nerds v1.0.0/go.mod h1:Gn2bHy1EwRcpXeB7ZhVmuUwiweK0e+JllNf66gvNLdU=
github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o= 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/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
github.com/writeas/saturday v1.6.0/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE= github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE=
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g= 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/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
github.com/writeas/web-core v1.0.0 h1:5VKkCakQgdKZcbfVKJXtRpc5VHrkflusCl/KRCPzpQ0= github.com/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0=
github.com/writeas/web-core v1.0.0/go.mod h1:Si3chV7VWgY8CsV+3gRolMXSO2Vx1ZFAQ/mkrpvmyEE= github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI=
github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss= 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/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg= golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/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-20200109152110-61a87790db17 h1:nVJ3guKA9qdkEQ3TUdXI9QSINo2CUPM/cySEvw2w8I0=
golang.org/x/crypto v0.0.0-20200109152110-61a87790db17/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 h1:rJm0LuqUjoDhSk2zO9ISMSToQxGz7Os2jRiOL8AWu4c= golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 h1:rJm0LuqUjoDhSk2zO9ISMSToQxGz7Os2jRiOL8AWu4c=
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/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-20190206173232-65e2d4e15006 h1:bfLnR+k0tq5Lqt6dflRLcZiz6UaXCMt3vhYJ1l4FQ80= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/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-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190209173611-3b5209105503 h1:5SvYFrOM3W8Mexn9/oA44Ji7vhXAZQ9hiP+1Q/DMrWg= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 h1:bPP/rGuN1LUM0eaEwo6vnP6OfIWJzJBulzGUiKLjjSY= golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 h1:bPP/rGuN1LUM0eaEwo6vnP6OfIWJzJBulzGUiKLjjSY=
@ -170,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.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 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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=

@ -73,7 +73,7 @@ type (
type Handler struct { type Handler struct {
errors *ErrorPages errors *ErrorPages
sessionStore *sessions.CookieStore sessionStore sessions.Store
app Apper app Apper
} }
@ -96,7 +96,7 @@ func NewHandler(apper Apper) *Handler {
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")), InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")),
Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")), Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")),
}, },
sessionStore: apper.App().sessionStore, sessionStore: apper.App().SessionStore(),
app: apper, app: apper,
} }
@ -549,6 +549,37 @@ func (h *Handler) All(f handlerFunc) http.HandlerFunc {
} }
} }
func (h *Handler) OAuth(f handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleOAuthError(w, r, func() error {
// TODO: return correct "success" status
status := 200
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s:\n%s", e, debug.Stack())
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
status = 500
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
err := f(h.app.App(), w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
}
return err
}())
}
}
func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc { func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
h.handleError(w, r, func() error { h.handleError(w, r, func() error {
@ -779,6 +810,25 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error)
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r)) h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
} }
func (h *Handler) handleOAuthError(w http.ResponseWriter, r *http.Request, err error) {
if err == nil {
return
}
if err, ok := err.(impart.HTTPError); ok {
if err.Status >= 300 && err.Status < 400 {
sendRedirect(w, err.Status, err.Message)
return
}
impart.WriteOAuthError(w, err)
return
}
impart.WriteOAuthError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
return
}
func correctPageFromLoginAttempt(r *http.Request) string { func correctPageFromLoginAttempt(r *http.Request) string {
to := r.FormValue("to") to := r.FormValue("to")
if to == "" { if to == "" {

@ -56,12 +56,19 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req
p := struct { p := struct {
*UserPage *UserPage
Invites *[]Invite Invites *[]Invite
Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, "Invite People", f), UserPage: NewUserPage(app, r, u, "Invite People", f),
} }
var err error 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) p.Invites, err = app.db.GetUserInvites(u.ID)
if err != nil { if err != nil {
return err return err
@ -78,6 +85,10 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re
muVal := r.FormValue("uses") muVal := r.FormValue("uses")
expVal := r.FormValue("expires") expVal := r.FormValue("expires")
if u.IsSilenced() {
return ErrUserSilenced
}
var err error var err error
var maxUses int var maxUses int
if muVal != "0" { if muVal != "0" {

@ -516,10 +516,17 @@ abbr {
body#collection article p, body#subpage article p { body#collection article p, body#subpage article p {
.article-p; .article-p;
} }
pre, body#post article, body#collection article, body#subpage article, body#subpage #wrapper h1 { pre, body#post article, #post .alert, #subpage .alert, body#collection article, body#subpage article, body#subpage #wrapper h1 {
max-width: 40rem; max-width: 40rem;
margin: 0 auto; margin: 0 auto;
} }
#collection header .alert, #post .alert, #subpage .alert {
margin-bottom: 1em;
p {
text-align: left;
line-height: 1.4;
}
}
textarea, pre, body#post article, body#collection article p { textarea, pre, body#post article, body#collection article p {
&.norm, &.sans, &.wrap { &.norm, &.sans, &.wrap {
line-height: 1.4em; line-height: 1.4em;
@ -677,18 +684,19 @@ select.inputform, textarea.inputform {
border: 1px solid #999; border: 1px solid #999;
} }
input, button, select.inputform, textarea.inputform { input, button, select.inputform, textarea.inputform, a.btn {
padding: 0.5em; padding: 0.5em;
font-family: @serifFont; font-family: @serifFont;
font-size: 100%; font-size: 100%;
.rounded(.25em); .rounded(.25em);
&[type=submit], &.submit { &[type=submit], &.submit, &.cta {
border: 1px solid @primary; border: 1px solid @primary;
background: @primary; background: @primary;
color: white; color: white;
.transition(0.2s); .transition(0.2s);
&:hover { &:hover {
background-color: lighten(@primary, 3%); background-color: lighten(@primary, 3%);
text-decoration: none;
} }
&:disabled { &:disabled {
cursor: default; cursor: default;
@ -1310,6 +1318,24 @@ form {
font-size: 0.86em; font-size: 0.86em;
line-height: 2; 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 { div.row {
display: flex; display: flex;

@ -17,6 +17,16 @@ body {
font-size: 1.6em; font-size: 1.6em;
} }
} }
article {
h2#title.dated {
margin-bottom: 0.5em;
}
time.dt-published {
display: block;
color: #666;
margin-bottom: 1em;
}
}
} }
} }

@ -0,0 +1,153 @@
package writefreely
import (
"context"
"database/sql"
"encoding/gob"
"errors"
"fmt"
uuid "github.com/nu7hatch/gouuid"
"github.com/stretchr/testify/assert"
"math/rand"
"os"
"strings"
"testing"
"time"
)
var testDB *sql.DB
type ScopedTestBody func(*sql.DB)
// TestMain provides testing infrastructure within this package.
func TestMain(m *testing.M) {
rand.Seed(time.Now().UTC().UnixNano())
gob.Register(&User{})
if runMySQLTests() {
var err error
testDB, err = initMySQL(os.Getenv("WF_USER"), os.Getenv("WF_PASSWORD"), os.Getenv("WF_DB"), os.Getenv("WF_HOST"))
if err != nil {
fmt.Println(err)
return
}
}
code := m.Run()
if runMySQLTests() {
if closeErr := testDB.Close(); closeErr != nil {
fmt.Println(closeErr)
}
}
os.Exit(code)
}
func runMySQLTests() bool {
return len(os.Getenv("TEST_MYSQL")) > 0
}
func initMySQL(dbUser, dbPassword, dbName, dbHost string) (*sql.DB, error) {
if dbUser == "" || dbPassword == "" {
return nil, errors.New("database user or password not set")
}
if dbHost == "" {
dbHost = "localhost"
}
if dbName == "" {
dbName = "writefreely"
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?charset=utf8mb4&parseTime=true", dbUser, dbPassword, dbHost, dbName)
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := ensureMySQL(db); err != nil {
return nil, err
}
return db, nil
}
func ensureMySQL(db *sql.DB) error {
if err := db.Ping(); err != nil {
return err
}
db.SetMaxOpenConns(250)
return nil
}
// withTestDB provides a scoped database connection.
func withTestDB(t *testing.T, testBody ScopedTestBody) {
db, cleanup, err := newTestDatabase(testDB,
os.Getenv("WF_USER"),
os.Getenv("WF_PASSWORD"),
os.Getenv("WF_DB"),
os.Getenv("WF_HOST"),
)
assert.NoError(t, err)
defer func() {
assert.NoError(t, cleanup())
}()
testBody(db)
}
// newTestDatabase creates a new temporary test database. When a test
// database connection is returned, it will have created a new database and
// initialized it with tables from a reference database.
func newTestDatabase(base *sql.DB, dbUser, dbPassword, dbName, dbHost string) (*sql.DB, func() error, error) {
var err error
var baseName = dbName
if baseName == "" {
row := base.QueryRow("SELECT DATABASE()")
err := row.Scan(&baseName)
if err != nil {
return nil, nil, err
}
}
tUUID, _ := uuid.NewV4()
suffix := strings.Replace(tUUID.String(), "-", "_", -1)
newDBName := baseName + suffix
_, err = base.Exec("CREATE DATABASE " + newDBName)
if err != nil {
return nil, nil, err
}
newDB, err := initMySQL(dbUser, dbPassword, newDBName, dbHost)
if err != nil {
return nil, nil, err
}
rows, err := base.Query("SHOW TABLES IN " + baseName)
if err != nil {
return nil, nil, err
}
for rows.Next() {
var tableName string
if err := rows.Scan(&tableName); err != nil {
return nil, nil, err
}
query := fmt.Sprintf("CREATE TABLE %s LIKE %s.%s", tableName, baseName, tableName)
if _, err := newDB.Exec(query); err != nil {
return nil, nil, err
}
}
cleanup := func() error {
if closeErr := newDB.Close(); closeErr != nil {
fmt.Println(closeErr)
}
_, err = base.Exec("DROP DATABASE " + newDBName)
return err
}
return newDB, cleanup, nil
}
func countRows(t *testing.T, ctx context.Context, db *sql.DB, count int, query string, args ...interface{}) {
var returned int
err := db.QueryRowContext(ctx, query, args...).Scan(&returned)
assert.NoError(t, err, "error executing query %s and args %s", query, args)
assert.Equal(t, count, returned, "unexpected return count %d, expected %d from %s and args %s", returned, count, query, args)
}

@ -13,6 +13,7 @@ package migrations
import ( import (
"database/sql" "database/sql"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
) )
@ -55,8 +56,12 @@ func (m *migration) Migrate(db *datastore) error {
} }
var migrations = []Migration{ var migrations = []Migration{
New("support user invites", supportUserInvites), // -> V1 (v0.8.0) New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.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 // CurrentVer returns the current migration version the application is on

@ -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 supportUserStatus(db *datastore) error {
t, err := db.Begin()
_, err = t.Exec(`ALTER TABLE users ADD COLUMN status ` + db.typeInt() + ` DEFAULT '0' NOT NULL`)
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}

@ -0,0 +1,46 @@
package migrations
import (
"context"
"database/sql"
wf_db "github.com/writeas/writefreely/db"
)
func oauth(db *datastore) error {
dialect := wf_db.DialectMySQL
if db.driverName == driverSQLite {
dialect = wf_db.DialectSQLite
}
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(true).
Column(dialect.Column("user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
Column(dialect.Column("remote_user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
UniqueConstraint("user_id").
UniqueConstraint("remote_user_id").
ToSQL()
if err != nil {
return err
}
createTableOauthClientState, err := dialect.
Table("oauth_client_states").
SetIfNotExists(true).
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).SetDefault("NOW()")).
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
})
}

@ -0,0 +1,67 @@
package migrations
import (
"context"
"database/sql"
wf_db "github.com/writeas/writefreely/db"
)
func oauthSlack(db *datastore) error {
dialect := wf_db.DialectMySQL
if db.driverName == driverSQLite {
dialect = wf_db.DialectSQLite
}
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,})).
AddColumn(dialect.
Column(
"client_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128,})),
dialect.
AlterTable("oauth_users").
ChangeColumn("remote_user_id",
dialect.
Column(
"remote_user_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128,})).
AddColumn(dialect.
Column(
"provider",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 24,})).
AddColumn(dialect.
Column(
"client_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128,})).
AddColumn(dialect.
Column(
"access_token",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 512,})),
dialect.DropIndex("remote_user_id", "oauth_users"),
dialect.DropIndex("user_id", "oauth_users"),
dialect.CreateUniqueIndex("oauth_users", "oauth_users", "user_id", "provider", "client_id"),
}
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
})
}

@ -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
}

@ -0,0 +1,291 @@
package writefreely
import (
"context"
"encoding/json"
"fmt"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/writeas/impart"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config"
"io"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
)
// TokenResponse contains data returned when a token is created either
// through a code exchange or using a refresh token.
type TokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
Error string `json:"error"`
}
// InspectResponse contains data returned when an access token is inspected.
type InspectResponse struct {
ClientID string `json:"client_id"`
UserID string `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
Username string `json:"username"`
DisplayName string `json:"-"`
Email string `json:"email"`
Error string `json:"error"`
}
// tokenRequestMaxLen is the most bytes that we'll read from the /oauth/token
// endpoint. One megabyte is plenty.
const tokenRequestMaxLen = 1000000
// infoRequestMaxLen is the most bytes that we'll read from the
// /oauth/inspect endpoint.
const infoRequestMaxLen = 1000000
// OAuthDatastoreProvider provides a minimal interface of data store, config,
// and session store for use with the oauth handlers.
type OAuthDatastoreProvider interface {
DB() OAuthDatastore
Config() *config.Config
SessionStore() sessions.Store
}
// OAuthDatastore provides a minimal interface of data store methods used in
// oauth functionality.
type OAuthDatastore interface {
GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
RecordRemoteUserID(context.Context, int64, string, string, string, string) error
ValidateOAuthState(context.Context, string) (string, string, error)
GenerateOAuthState(context.Context, string, string) (string, error)
CreateUser(*config.Config, *User, string) error
GetUserByID(int64) (*User, error)
}
type HttpClient interface {
Do(req *http.Request) (*http.Response, error)
}
type oauthClient interface {
GetProvider() string
GetClientID() string
GetCallbackLocation() string
buildLoginURL(state string) (string, error)
exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error)
inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error)
}
type callbackProxyClient struct {
server string
callbackLocation string
httpClient HttpClient
}
type oauthHandler struct {
Config *config.Config
DB OAuthDatastore
Store sessions.Store
EmailKey []byte
oauthClient oauthClient
callbackProxy *callbackProxyClient
}
func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID())
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
}
if h.callbackProxy != nil {
if err := h.callbackProxy.register(ctx, state); err != nil {
return impart.HTTPError{http.StatusInternalServerError, "could not register state server"}
}
}
location, err := h.oauthClient.buildLoginURL(state)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
}
return impart.HTTPError{http.StatusTemporaryRedirect, location}
}
func configureSlackOauth(parentHandler *Handler, r *mux.Router, app *App) {
if app.Config().SlackOauth.ClientID != "" {
callbackLocation := app.Config().App.Host + "/oauth/callback/slack"
var stateRegisterClient *callbackProxyClient = nil
if app.Config().SlackOauth.CallbackProxyAPI != "" {
stateRegisterClient = &callbackProxyClient{
server: app.Config().SlackOauth.CallbackProxyAPI,
callbackLocation: app.Config().App.Host + "/oauth/callback/slack",
httpClient: config.DefaultHTTPClient(),
}
callbackLocation = app.Config().SlackOauth.CallbackProxy
}
oauthClient := slackOauthClient{
ClientID: app.Config().SlackOauth.ClientID,
ClientSecret: app.Config().SlackOauth.ClientSecret,
TeamID: app.Config().SlackOauth.TeamID,
HttpClient: config.DefaultHTTPClient(),
CallbackLocation: callbackLocation,
}
configureOauthRoutes(parentHandler, r, app, oauthClient, stateRegisterClient)
}
}
func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) {
if app.Config().WriteAsOauth.ClientID != "" {
callbackLocation := app.Config().App.Host + "/oauth/callback/write.as"
var callbackProxy *callbackProxyClient = nil
if app.Config().WriteAsOauth.CallbackProxy != "" {
callbackProxy = &callbackProxyClient{
server: app.Config().WriteAsOauth.CallbackProxyAPI,
callbackLocation: app.Config().App.Host + "/oauth/callback/write.as",
httpClient: config.DefaultHTTPClient(),
}
callbackLocation = app.Config().SlackOauth.CallbackProxy
}
oauthClient := writeAsOauthClient{
ClientID: app.Config().WriteAsOauth.ClientID,
ClientSecret: app.Config().WriteAsOauth.ClientSecret,
ExchangeLocation: config.OrDefaultString(app.Config().WriteAsOauth.TokenLocation, writeAsExchangeLocation),
InspectLocation: config.OrDefaultString(app.Config().WriteAsOauth.InspectLocation, writeAsIdentityLocation),
AuthLocation: config.OrDefaultString(app.Config().WriteAsOauth.AuthLocation, writeAsAuthLocation),
HttpClient: config.DefaultHTTPClient(),
CallbackLocation: callbackLocation,
}
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
}
}
func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) {
handler := &oauthHandler{
Config: app.Config(),
DB: app.DB(),
Store: app.SessionStore(),
oauthClient: oauthClient,
EmailKey: app.keys.EmailKey,
callbackProxy: callbackProxy,
}
r.HandleFunc("/oauth/"+oauthClient.GetProvider(), parentHandler.OAuth(handler.viewOauthInit)).Methods("GET")
r.HandleFunc("/oauth/callback/"+oauthClient.GetProvider(), parentHandler.OAuth(handler.viewOauthCallback)).Methods("GET")
r.HandleFunc("/oauth/signup", parentHandler.OAuth(handler.viewOauthSignup)).Methods("POST")
}
func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
code := r.FormValue("code")
state := r.FormValue("state")
provider, clientID, err := h.DB.ValidateOAuthState(ctx, state)
if err != nil {
log.Error("Unable to ValidateOAuthState: %s", err)
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
}
tokenResponse, err := h.oauthClient.exchangeOauthCode(ctx, code)
if err != nil {
log.Error("Unable to exchangeOauthCode: %s", err)
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
}
// Now that we have the access token, let's use it real quick to make sur
// it really really works.
tokenInfo, err := h.oauthClient.inspectOauthAccessToken(ctx, tokenResponse.AccessToken)
if err != nil {
log.Error("Unable to inspectOauthAccessToken: %s", err)
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
}
localUserID, err := h.DB.GetIDForRemoteUser(ctx, tokenInfo.UserID, provider, clientID)
if err != nil {
log.Error("Unable to GetIDForRemoteUser: %s", err)
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
}
if localUserID != -1 {
user, err := h.DB.GetUserByID(localUserID)
if err != nil {
log.Error("Unable to GetUserByID %d: %s", localUserID, err)
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
}
if err = loginOrFail(h.Store, w, r, user); err != nil {
log.Error("Unable to loginOrFail %d: %s", localUserID, err)
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
}
return nil
}
displayName := tokenInfo.DisplayName
if len(displayName) == 0 {
displayName = tokenInfo.Username
}
tp := &oauthSignupPageParams{
AccessToken: tokenResponse.AccessToken,
TokenUsername: tokenInfo.Username,
TokenAlias: tokenInfo.DisplayName,
TokenEmail: tokenInfo.Email,
TokenRemoteUser: tokenInfo.UserID,
Provider: provider,
ClientID: clientID,
}
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
return h.showOauthSignupPage(app, w, r, tp, nil)
}
func (r *callbackProxyClient) register(ctx context.Context, state string) error {
form := url.Values{}
form.Add("state", state)
form.Add("location", r.callbackLocation)
req, err := http.NewRequestWithContext(ctx, "POST", r.server, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("User-Agent", "writefreely")
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := r.httpClient.Do(req)
if err != nil {
return err
}
if resp.StatusCode != http.StatusCreated {
return fmt.Errorf("unable register state location: %d", resp.StatusCode)
}
return nil
}
func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error {
lr := io.LimitReader(body, int64(n+1))
data, err := ioutil.ReadAll(lr)
if err != nil {
return err
}
if len(data) == n+1 {
return fmt.Errorf("content larger than max read allowance: %d", n)
}
return json.Unmarshal(data, thing)
}
func loginOrFail(store sessions.Store, w http.ResponseWriter, r *http.Request, user *User) error {
// An error may be returned, but a valid session should always be returned.
session, _ := store.Get(r, cookieName)
session.Values[cookieUserVal] = user.Cookie()
if err := session.Save(r, w); err != nil {
fmt.Println("error saving session", err)
return err
}
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return nil
}

@ -0,0 +1,10 @@
package oauth
import "context"
// ClientStateStore provides state management used by the OAuth client.
type ClientStateStore interface {
Generate(ctx context.Context) (string, error)
Validate(ctx context.Context, state string) error
}

@ -0,0 +1,218 @@
/*
* 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 (
"crypto/sha256"
"encoding/hex"
"fmt"
"github.com/writeas/impart"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/page"
"html/template"
"net/http"
"strings"
"time"
)
type viewOauthSignupVars struct {
page.StaticPage
To string
Message template.HTML
Flashes []template.HTML
AccessToken string
TokenUsername 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
LoginUsername string
Alias string // TODO: rename this to match the data it represents: the collection title
Email string
}
const (
oauthParamAccessToken = "access_token"
oauthParamTokenUsername = "token_username"
oauthParamTokenAlias = "token_alias"
oauthParamTokenEmail = "token_email"
oauthParamTokenRemoteUserID = "token_remote_user"
oauthParamClientID = "client_id"
oauthParamProvider = "provider"
oauthParamHash = "signature"
oauthParamUsername = "username"
oauthParamAlias = "alias"
oauthParamEmail = "email"
oauthParamPassword = "password"
)
type oauthSignupPageParams struct {
AccessToken string
TokenUsername string
TokenAlias string // TODO: rename this to match the data it represents: the collection title
TokenEmail string
TokenRemoteUser string
ClientID string
Provider string
TokenHash string
}
func (p oauthSignupPageParams) HashTokenParams(key string) string {
hasher := sha256.New()
hasher.Write([]byte(key))
hasher.Write([]byte(p.AccessToken))
hasher.Write([]byte(p.TokenUsername))
hasher.Write([]byte(p.TokenAlias))
hasher.Write([]byte(p.TokenEmail))
hasher.Write([]byte(p.TokenRemoteUser))
hasher.Write([]byte(p.ClientID))
hasher.Write([]byte(p.Provider))
return hex.EncodeToString(hasher.Sum(nil))
}
func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.Request) error {
tp := &oauthSignupPageParams{
AccessToken: r.FormValue(oauthParamAccessToken),
TokenUsername: r.FormValue(oauthParamTokenUsername),
TokenAlias: r.FormValue(oauthParamTokenAlias),
TokenEmail: r.FormValue(oauthParamTokenEmail),
TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID),
ClientID: r.FormValue(oauthParamClientID),
Provider: r.FormValue(oauthParamProvider),
}
if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) {
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."}
}
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
if err := h.validateOauthSignup(r); err != nil {
return h.showOauthSignupPage(app, w, r, tp, err)
}
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: hasPass,
Email: prepareUserEmail(r.FormValue(oauthParamEmail), h.EmailKey),
Created: time.Now().Truncate(time.Second).UTC(),
}
displayName := r.FormValue(oauthParamAlias)
if len(displayName) == 0 {
displayName = r.FormValue(oauthParamUsername)
}
err = h.DB.CreateUser(h.Config, newUser, displayName)
if err != nil {
return h.showOauthSignupPage(app, w, r, tp, err)
}
err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken))
if err != nil {
return h.showOauthSignupPage(app, w, r, tp, err)
}
if err := loginOrFail(h.Store, w, r, newUser); err != nil {
return h.showOauthSignupPage(app, w, r, tp, err)
}
return nil
}
func (h oauthHandler) validateOauthSignup(r *http.Request) error {
username := r.FormValue(oauthParamUsername)
if len(username) < h.Config.App.MinUsernameLen {
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too short."}
}
if len(username) > 100 {
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too long."}
}
collTitle := r.FormValue(oauthParamAlias)
if len(collTitle) == 0 {
collTitle = username
}
email := r.FormValue(oauthParamEmail)
if len(email) > 0 {
parts := strings.Split(email, "@")
if len(parts) != 2 || (len(parts[0]) < 1 || len(parts[1]) < 1) {
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Invalid email address"}
}
}
return nil
}
func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *http.Request, tp *oauthSignupPageParams, errMsg error) error {
username := tp.TokenUsername
collTitle := tp.TokenAlias
email := tp.TokenEmail
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// Ignore this
log.Error("Unable to get session; ignoring: %v", err)
}
if tmpValue := r.FormValue(oauthParamUsername); len(tmpValue) > 0 {
username = tmpValue
}
if tmpValue := r.FormValue(oauthParamAlias); len(tmpValue) > 0 {
collTitle = tmpValue
}
if tmpValue := r.FormValue(oauthParamEmail); len(tmpValue) > 0 {
email = tmpValue
}
p := &viewOauthSignupVars{
StaticPage: pageForReq(app, r),
To: r.FormValue("to"),
Flashes: []template.HTML{},
AccessToken: tp.AccessToken,
TokenUsername: tp.TokenUsername,
TokenAlias: tp.TokenAlias,
TokenEmail: tp.TokenEmail,
TokenRemoteUser: tp.TokenRemoteUser,
Provider: tp.Provider,
ClientID: tp.ClientID,
TokenHash: tp.TokenHash,
LoginUsername: username,
Alias: collTitle,
Email: email,
}
// Display any error messages
flashes, _ := getSessionFlashes(app, w, r, session)
for _, flash := range flashes {
p.Flashes = append(p.Flashes, template.HTML(flash))
}
if errMsg != nil {
p.Flashes = append(p.Flashes, template.HTML(errMsg.Error()))
}
err = pages["signup-oauth.tmpl"].ExecuteTemplate(w, "base", p)
if err != nil {
log.Error("Unable to render signup-oauth: %v", err)
return err
}
return nil
}

@ -0,0 +1,180 @@
/*
* 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 (
"context"
"errors"
"fmt"
"github.com/writeas/nerds/store"
"github.com/writeas/slug"
"net/http"
"net/url"
"strings"
)
type slackOauthClient struct {
ClientID string
ClientSecret string
TeamID string
CallbackLocation string
HttpClient HttpClient
}
type slackExchangeResponse struct {
OK bool `json:"ok"`
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
TeamName string `json:"team_name"`
TeamID string `json:"team_id"`
Error string `json:"error"`
}
type slackIdentity struct {
Name string `json:"name"`
ID string `json:"id"`
Email string `json:"email"`
}
type slackTeam struct {
Name string `json:"name"`
ID string `json:"id"`
}
type slackUserIdentityResponse struct {
OK bool `json:"ok"`
User slackIdentity `json:"user"`
Team slackTeam `json:"team"`
Error string `json:"error"`
}
const (
slackAuthLocation = "https://slack.com/oauth/authorize"
slackExchangeLocation = "https://slack.com/api/oauth.access"
slackIdentityLocation = "https://slack.com/api/users.identity"
)
var _ oauthClient = slackOauthClient{}
func (c slackOauthClient) GetProvider() string {
return "slack"
}
func (c slackOauthClient) GetClientID() string {
return c.ClientID
}
func (c slackOauthClient) GetCallbackLocation() string {
return c.CallbackLocation
}
func (c slackOauthClient) buildLoginURL(state string) (string, error) {
u, err := url.Parse(slackAuthLocation)
if err != nil {
return "", err
}
q := u.Query()
q.Set("client_id", c.ClientID)
q.Set("scope", "identity.basic identity.email identity.team")
q.Set("redirect_uri", c.CallbackLocation)
q.Set("state", state)
// If this param is not set, the user can select which team they
// authenticate through and then we'd have to match the configured team
// against the profile get. That is extra work in the post-auth phase
// that we don't want to do.
q.Set("team", c.TeamID)
// The Slack OAuth docs don't explicitly list this one, but it is part of
// the spec, so we include it anyway.
q.Set("response_type", "code")
u.RawQuery = q.Encode()
return u.String(), nil
}
func (c slackOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
form := url.Values{}
// The oauth.access documentation doesn't explicitly mention this
// parameter, but it is part of the spec, so we include it anyway.
// https://api.slack.com/methods/oauth.access
form.Add("grant_type", "authorization_code")
form.Add("redirect_uri", c.CallbackLocation)
form.Add("code", code)
req, err := http.NewRequest("POST", slackExchangeLocation, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely")
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(c.ClientID, c.ClientSecret)
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, errors.New("unable to exchange code for access token")
}
var tokenResponse slackExchangeResponse
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
return nil, err
}
if !tokenResponse.OK {
return nil, errors.New(tokenResponse.Error)
}
return tokenResponse.TokenResponse(), nil
}
func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
req, err := http.NewRequest("GET", slackIdentityLocation, nil)
if err != nil {
return nil, err
}
req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, errors.New("unable to inspect access token")
}
var inspectResponse slackUserIdentityResponse
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil {
return nil, err
}
if !inspectResponse.OK {
return nil, errors.New(inspectResponse.Error)
}
return inspectResponse.InspectResponse(), nil
}
func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse {
return &InspectResponse{
UserID: resp.User.ID,
Username: fmt.Sprintf("%s-%s", slug.Make(resp.User.Name), store.GenerateRandomString("0123456789bcdfghjklmnpqrstvwxyz", 5)),
DisplayName: resp.User.Name,
Email: resp.User.Email,
}
}
func (resp slackExchangeResponse) TokenResponse() *TokenResponse {
return &TokenResponse{
AccessToken: resp.AccessToken,
}
}

@ -0,0 +1,253 @@
package writefreely
import (
"context"
"fmt"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/assert"
"github.com/writeas/impart"
"github.com/writeas/nerds/store"
"github.com/writeas/writefreely/config"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
type MockOAuthDatastoreProvider struct {
DoDB func() OAuthDatastore
DoConfig func() *config.Config
DoSessionStore func() sessions.Store
}
type MockOAuthDatastore struct {
DoGenerateOAuthState func(context.Context, string, string) (string, error)
DoValidateOAuthState func(context.Context, string) (string, string, error)
DoGetIDForRemoteUser func(context.Context, string, string, string) (int64, error)
DoCreateUser func(*config.Config, *User, string) error
DoRecordRemoteUserID func(context.Context, int64, string, string, string, string) error
DoGetUserByID func(int64) (*User, error)
}
var _ OAuthDatastore = &MockOAuthDatastore{}
type StringReadCloser struct {
*strings.Reader
}
func (src *StringReadCloser) Close() error {
return nil
}
type MockHTTPClient struct {
DoDo func(req *http.Request) (*http.Response, error)
}
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
if m.DoDo != nil {
return m.DoDo(req)
}
return &http.Response{}, nil
}
func (m *MockOAuthDatastoreProvider) SessionStore() sessions.Store {
if m.DoSessionStore != nil {
return m.DoSessionStore()
}
return sessions.NewCookieStore([]byte("secret-key"))
}
func (m *MockOAuthDatastoreProvider) DB() OAuthDatastore {
if m.DoDB != nil {
return m.DoDB()
}
return &MockOAuthDatastore{}
}
func (m *MockOAuthDatastoreProvider) Config() *config.Config {
if m.DoConfig != nil {
return m.DoConfig()
}
cfg := config.New()
cfg.UseSQLite(true)
cfg.WriteAsOauth = config.WriteAsOauthCfg{
ClientID: "development",
ClientSecret: "development",
AuthLocation: "https://write.as/oauth/login",
TokenLocation: "https://write.as/oauth/token",
InspectLocation: "https://write.as/oauth/inspect",
}
cfg.SlackOauth = config.SlackOauthCfg{
ClientID: "development",
ClientSecret: "development",
TeamID: "development",
}
return cfg
}
func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, error) {
if m.DoValidateOAuthState != nil {
return m.DoValidateOAuthState(ctx, state)
}
return "", "", nil
}
func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provider, clientID string) (int64, error) {
if m.DoGetIDForRemoteUser != nil {
return m.DoGetIDForRemoteUser(ctx, remoteUserID, provider, clientID)
}
return -1, nil
}
func (m *MockOAuthDatastore) CreateUser(cfg *config.Config, u *User, username string) error {
if m.DoCreateUser != nil {
return m.DoCreateUser(cfg, u, username)
}
u.ID = 1
return nil
}
func (m *MockOAuthDatastore) RecordRemoteUserID(ctx context.Context, localUserID int64, remoteUserID, provider, clientID, accessToken string) error {
if m.DoRecordRemoteUserID != nil {
return m.DoRecordRemoteUserID(ctx, localUserID, remoteUserID, provider, clientID, accessToken)
}
return nil
}
func (m *MockOAuthDatastore) GetUserByID(userID int64) (*User, error) {
if m.DoGetUserByID != nil {
return m.DoGetUserByID(userID)
}
user := &User{
}
return user, nil
}
func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string) (string, error) {
if m.DoGenerateOAuthState != nil {
return m.DoGenerateOAuthState(ctx, provider, clientID)
}
return store.Generate62RandomString(14), nil
}
func TestViewOauthInit(t *testing.T) {
t.Run("success", func(t *testing.T) {
app := &MockOAuthDatastoreProvider{}
h := oauthHandler{
Config: app.Config(),
DB: app.DB(),
Store: app.SessionStore(),
EmailKey: []byte{0xd, 0xe, 0xc, 0xa, 0xf, 0xf, 0xb, 0xa, 0xd},
oauthClient: writeAsOauthClient{
ClientID: app.Config().WriteAsOauth.ClientID,
ClientSecret: app.Config().WriteAsOauth.ClientSecret,
ExchangeLocation: app.Config().WriteAsOauth.TokenLocation,
InspectLocation: app.Config().WriteAsOauth.InspectLocation,
AuthLocation: app.Config().WriteAsOauth.AuthLocation,
CallbackLocation: "http://localhost/oauth/callback",
HttpClient: nil,
},
}
req, err := http.NewRequest("GET", "/oauth/client", nil)
assert.NoError(t, err)
rr := httptest.NewRecorder()
err = h.viewOauthInit(nil, rr, req)
assert.NotNil(t, err)
httpErr, ok := err.(impart.HTTPError)
assert.True(t, ok)
assert.Equal(t, http.StatusTemporaryRedirect, httpErr.Status)
assert.NotEmpty(t, httpErr.Message)
locURI, err := url.Parse(httpErr.Message)
assert.NoError(t, err)
assert.Equal(t, "/oauth/login", locURI.Path)
assert.Equal(t, "development", locURI.Query().Get("client_id"))
assert.Equal(t, "http://localhost/oauth/callback", locURI.Query().Get("redirect_uri"))
assert.Equal(t, "code", locURI.Query().Get("response_type"))
assert.NotEmpty(t, locURI.Query().Get("state"))
})
t.Run("state failure", func(t *testing.T) {
app := &MockOAuthDatastoreProvider{
DoDB: func() OAuthDatastore {
return &MockOAuthDatastore{
DoGenerateOAuthState: func(ctx context.Context, provider, clientID string) (string, error) {
return "", fmt.Errorf("pretend unable to write state error")
},
}
},
}
h := oauthHandler{
Config: app.Config(),
DB: app.DB(),
Store: app.SessionStore(),
EmailKey: []byte{0xd, 0xe, 0xc, 0xa, 0xf, 0xf, 0xb, 0xa, 0xd},
oauthClient: writeAsOauthClient{
ClientID: app.Config().WriteAsOauth.ClientID,
ClientSecret: app.Config().WriteAsOauth.ClientSecret,
ExchangeLocation: app.Config().WriteAsOauth.TokenLocation,
InspectLocation: app.Config().WriteAsOauth.InspectLocation,
AuthLocation: app.Config().WriteAsOauth.AuthLocation,
CallbackLocation: "http://localhost/oauth/callback",
HttpClient: nil,
},
}
req, err := http.NewRequest("GET", "/oauth/client", nil)
assert.NoError(t, err)
rr := httptest.NewRecorder()
err = h.viewOauthInit(nil, rr, req)
httpErr, ok := err.(impart.HTTPError)
assert.True(t, ok)
assert.NotEmpty(t, httpErr.Message)
assert.Equal(t, http.StatusInternalServerError, httpErr.Status)
assert.Equal(t, "could not prepare oauth redirect url", httpErr.Message)
})
}
func TestViewOauthCallback(t *testing.T) {
t.Run("success", func(t *testing.T) {
app := &MockOAuthDatastoreProvider{}
h := oauthHandler{
Config: app.Config(),
DB: app.DB(),
Store: app.SessionStore(),
EmailKey: []byte{0xd, 0xe, 0xc, 0xa, 0xf, 0xf, 0xb, 0xa, 0xd},
oauthClient: writeAsOauthClient{
ClientID: app.Config().WriteAsOauth.ClientID,
ClientSecret: app.Config().WriteAsOauth.ClientSecret,
ExchangeLocation: app.Config().WriteAsOauth.TokenLocation,
InspectLocation: app.Config().WriteAsOauth.InspectLocation,
AuthLocation: app.Config().WriteAsOauth.AuthLocation,
CallbackLocation: "http://localhost/oauth/callback",
HttpClient: &MockHTTPClient{
DoDo: func(req *http.Request) (*http.Response, error) {
switch req.URL.String() {
case "https://write.as/oauth/token":
return &http.Response{
StatusCode: 200,
Body: &StringReadCloser{strings.NewReader(`{"access_token": "access_token", "expires_in": 1000, "refresh_token": "refresh_token", "token_type": "access"}`)},
}, nil
case "https://write.as/oauth/inspect":
return &http.Response{
StatusCode: 200,
Body: &StringReadCloser{strings.NewReader(`{"client_id": "development", "user_id": "1", "expires_at": "2019-12-19T11:42:01Z", "username": "nick", "email": "nick@testing.write.as"}`)},
}, nil
}
return &http.Response{
StatusCode: http.StatusNotFound,
}, nil
},
},
},
}
req, err := http.NewRequest("GET", "/oauth/callback", nil)
assert.NoError(t, err)
rr := httptest.NewRecorder()
err = h.viewOauthCallback(nil, rr, req)
assert.NoError(t, err)
assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
})
}

@ -0,0 +1,114 @@
package writefreely
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
)
type writeAsOauthClient struct {
ClientID string
ClientSecret string
AuthLocation string
ExchangeLocation string
InspectLocation string
CallbackLocation string
HttpClient HttpClient
}
var _ oauthClient = writeAsOauthClient{}
const (
writeAsAuthLocation = "https://write.as/oauth/login"
writeAsExchangeLocation = "https://write.as/oauth/token"
writeAsIdentityLocation = "https://write.as/oauth/inspect"
)
func (c writeAsOauthClient) GetProvider() string {
return "write.as"
}
func (c writeAsOauthClient) GetClientID() string {
return c.ClientID
}
func (c writeAsOauthClient) GetCallbackLocation() string {
return c.CallbackLocation
}
func (c writeAsOauthClient) buildLoginURL(state string) (string, error) {
u, err := url.Parse(c.AuthLocation)
if err != nil {
return "", err
}
q := u.Query()
q.Set("client_id", c.ClientID)
q.Set("redirect_uri", c.CallbackLocation)
q.Set("response_type", "code")
q.Set("state", state)
u.RawQuery = q.Encode()
return u.String(), nil
}
func (c writeAsOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
form := url.Values{}
form.Add("grant_type", "authorization_code")
form.Add("redirect_uri", c.CallbackLocation)
form.Add("code", code)
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely")
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(c.ClientID, c.ClientSecret)
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, errors.New("unable to exchange code for access token")
}
var tokenResponse TokenResponse
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
return nil, err
}
if tokenResponse.Error != "" {
return nil, errors.New(tokenResponse.Error)
}
return &tokenResponse, nil
}
func (c writeAsOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
req, err := http.NewRequest("GET", c.InspectLocation, nil)
if err != nil {
return nil, err
}
req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, errors.New("unable to inspect access token")
}
var inspectResponse InspectResponse
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil {
return nil, err
}
if inspectResponse.Error != "" {
return nil, errors.New(inspectResponse.Error)
}
return &inspectResponse, nil
}

@ -35,9 +35,10 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
} }
appData := &struct { appData := &struct {
page.StaticPage page.StaticPage
Post *RawPost Post *RawPost
User *User User *User
Blogs *[]Collection Blogs *[]Collection
Silenced bool
Editing bool // True if we're modifying an existing post Editing bool // True if we're modifying an existing post
EditCollection *Collection // Collection of the post we're editing, if any EditCollection *Collection // Collection of the post we're editing, if any
@ -52,11 +53,17 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
log.Error("Unable to get user's blogs for Pad: %v", err) log.Error("Unable to get user's blogs for Pad: %v", err)
} }
appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
if err != nil {
log.Error("Unable to get user status for Pad: %v", err)
}
} }
padTmpl := app.cfg.App.Editor padTmpl := app.cfg.App.Editor
if templates[padTmpl] == nil { if templates[padTmpl] == nil {
log.Info("No template '%s' found. Falling back to default 'pad' template.", padTmpl) if padTmpl != "" {
log.Info("No template '%s' found. Falling back to default 'pad' template.", padTmpl)
}
padTmpl = "pad" padTmpl = "pad"
} }
@ -85,6 +92,7 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return err return err
} }
appData.EditCollection.hostName = app.cfg.App.Host
} else { } else {
// Editing a floating article // Editing a floating article
appData.Post = getRawPost(app, action) appData.Post = getRawPost(app, action)
@ -119,12 +127,18 @@ func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error {
EditCollection *Collection // Collection of the post we're editing, if any EditCollection *Collection // Collection of the post we're editing, if any
Flashes []string Flashes []string
NeedsToken bool NeedsToken bool
Silenced bool
}{ }{
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
Post: &RawPost{Font: "norm"}, Post: &RawPost{Font: "norm"},
User: getUserSession(app, r), User: getUserSession(app, r),
} }
var err error var err error
appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
if err != nil {
log.Error("view meta: get user status: %v", err)
return ErrInternalGeneral
}
if action == "" && slug == "" { if action == "" && slug == "" {
return ErrPostNotFound return ErrPostNotFound
@ -148,6 +162,7 @@ func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return err return err
} }
appData.EditCollection.hostName = app.cfg.App.Host
} else { } else {
// Editing a floating article // Editing a floating article
appData.Post = getRawPost(app, action) appData.Post = getRawPost(app, action)

@ -1,7 +1,38 @@
{{define "head"}}<title>Log in &mdash; {{.SiteName}}</title> {{define "head"}}<title>Log in &mdash; {{.SiteName}}</title>
<meta name="description" content="Log in to {{.SiteName}}."> <meta name="description" content="Log in to {{.SiteName}}.">
<meta itemprop="description" content="Log in to {{.SiteName}}."> <meta itemprop="description" content="Log in to {{.SiteName}}.">
<style>input{margin-bottom:0.5em;}</style> <style>
input{margin-bottom:0.5em;}
.or {
text-align: center;
margin-bottom: 3.5em;
}
.or p {
display: inline-block;
background-color: white;
padding: 0 1em;
}
.or hr {
margin-top: -1.6em;
margin-bottom: 0;
}
hr.short {
max-width: 30rem;
}
.row.signinbtns {
justify-content: space-evenly;
font-size: 1em;
margin-top: 3em;
margin-bottom: 2em;
}
.loginbtn {
height: 40px;
}
#writeas-login {
box-sizing: border-box;
font-size: 17px;
}
</style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<div class="tight content-container"> <div class="tight content-container">
@ -11,6 +42,22 @@
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}} </ul>{{end}}
{{ if or .OauthSlack .OauthWriteAs }}
<div class="row content-container signinbtns">
{{ if .OauthSlack }}
<a class="loginbtn" href="/oauth/slack"><img alt="Sign in with Slack" height="40" width="172" src="/img/sign_in_with_slack.png" srcset="/img/sign_in_with_slack.png 1x, /img/sign_in_with_slack@2x.png 2x" /></a>
{{ end }}
{{ if .OauthWriteAs }}
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as">Sign in with <strong>Write.as</strong></a>
{{ end }}
</div>
<div class="or">
<p>or</p>
<hr class="short" />
</div>
{{ end }}
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()"> <form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br /> <input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br /> <input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />

@ -0,0 +1,174 @@
{{define "head"}}<title>Log in &mdash; {{.SiteName}}</title>
<meta name="description" content="Log in to {{.SiteName}}.">
<meta itemprop="description" content="Log in to {{.SiteName}}.">
<style>input{margin-bottom:0.5em;}</style>
<style type="text/css">
h2 {
font-weight: normal;
}
#pricing.content-container div.form-container #payment-form {
display: block !important;
}
#pricing #signup-form table {
max-width: inherit !important;
width: 100%;
}
#pricing #payment-form table {
margin-top: 0 !important;
max-width: inherit !important;
width: 100%;
}
tr.subscription {
border-spacing: 0;
}
#pricing.content-container tr.subscription button {
margin-top: 0 !important;
margin-bottom: 0 !important;
width: 100%;
}
#pricing tr.subscription td {
padding: 0 0.5em;
}
#pricing table.billing > tbody > tr > td:first-child {
vertical-align: middle !important;
}
.billing-section {
display: none;
}
.billing-section.bill-me {
display: table-row;
}
#btn-create {
color: white !important;
}
#total-price {
padding-left: 0.5em;
}
#alias-site.demo {
color: #999;
}
#alias-site {
text-align: left;
margin: 0.5em 0;
}
form dd {
margin: 0;
}
</style>
{{end}}
{{define "content"}}
<div id="pricing" class="tight content-container">
<h1>Log in to {{.SiteName}}</h1>
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
<div id="billing">
<form action="/oauth/signup" method="post" style="text-align: center;margin-top:1em;" onsubmit="return disableSubmit()">
<input type="hidden" name="access_token" value="{{ .AccessToken }}" />
<input type="hidden" name="token_username" value="{{ .TokenUsername }}" />
<input type="hidden" name="token_alias" value="{{ .TokenAlias }}" />
<input type="hidden" name="token_email" value="{{ .TokenEmail }}" />
<input type="hidden" name="token_remote_user" value="{{ .TokenRemoteUser }}" />
<input type="hidden" name="provider" value="{{ .Provider }}" />
<input type="hidden" name="client_id" value="{{ .ClientID }}" />
<input type="hidden" name="signature" value="{{ .TokenHash }}" />
<dl class="billing">
<label>
<dt>Display Name</dt>
<dd>
<input type="text" style="width: 100%; box-sizing: border-box;" name="alias" placeholder="Name"{{ if .Alias }} value="{{.Alias}}"{{ end }} />
</dd>
</label>
<label>
<dt>Username</dt>
<dd>
<input type="text" id="username" name="username" style="width: 100%; box-sizing: border-box;" placeholder="Username" value="{{.LoginUsername}}" /><br />
{{if .Federation}}<p id="alias-site" class="demo">@<strong>your-username</strong>@{{.FriendlyHost}}</p>{{else}}<p id="alias-site" class="demo">{{.FriendlyHost}}/<strong>your-username</strong></p>{{end}}
</dd>
</label>
<label>
<dt>Email</dt>
<dd>
<input type="text" name="email" style="width: 100%; box-sizing: border-box;" placeholder="Email"{{ if .Email }} value="{{.Email}}"{{ end }} />
</dd>
</label>
<dt>
<input type="submit" id="btn-login" value="Login" />
</dt>
</dl>
</form>
</div>
<script type="text/javascript" src="/js/h.js"></script>
<script type="text/javascript">
// Copied from signup.tmpl
// NOTE: this element is named "alias" on signup.tmpl and "username" here
var $alias = H.getEl('username');
function disableSubmit() {
// Validate input
if (!aliasOK) {
var $a = $alias;
$a.el.className = 'error';
$a.el.focus();
$a.el.scrollIntoView();
return false;
}
var $btn = document.getElementById("btn-login");
$btn.value = "Logging in...";
$btn.disabled = true;
return true;
}
// Copied from signup.tmpl
var $aliasSite = document.getElementById('alias-site');
var aliasOK = true;
var typingTimer;
var doneTypingInterval = 750;
var doneTyping = function() {
// Check on username
var alias = $alias.el.value;
if (alias != "") {
var params = {
username: alias
};
var http = new XMLHttpRequest();
http.open("POST", '/api/alias', true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
data = JSON.parse(http.responseText);
if (http.status == 200) {
aliasOK = true;
$alias.removeClass('error');
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)demo(?!\S)/g, '');
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)error(?!\S)/g, '');
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>' + data.data + '</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>' + data.data + '</strong>/{{ end }}';
} else {
aliasOK = false;
$alias.setClass('error');
$aliasSite.className = 'error';
$aliasSite.textContent = data.error_msg;
}
}
}
http.send(JSON.stringify(params));
} else {
$aliasSite.className += ' demo';
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>your-username</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>your-username</strong>/{{ end }}';
}
};
$alias.on('keyup input', function() {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
});
doneTyping();
</script>
{{end}}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -11,9 +11,11 @@
package writefreely package writefreely
import ( import (
"encoding/json"
"fmt" "fmt"
"html" "html"
"html/template" "html/template"
"net/http"
"regexp" "regexp"
"strings" "strings"
"unicode" "unicode"
@ -21,7 +23,9 @@ import (
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
stripmd "github.com/writeas/go-strip-markdown" stripmd "github.com/writeas/go-strip-markdown"
"github.com/writeas/impart"
blackfriday "github.com/writeas/saturday" blackfriday "github.com/writeas/saturday"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/stringmanip" "github.com/writeas/web-core/stringmanip"
"github.com/writeas/writefreely/config" "github.com/writeas/writefreely/config"
"github.com/writeas/writefreely/parse" "github.com/writeas/writefreely/parse"
@ -34,6 +38,7 @@ var (
titleElementReg = regexp.MustCompile("</?h[1-6]>") titleElementReg = regexp.MustCompile("</?h[1-6]>")
hashtagReg = regexp.MustCompile(`{{\[\[\|\|([^|]+)\|\|\]\]}}`) hashtagReg = regexp.MustCompile(`{{\[\[\|\|([^|]+)\|\|\]\]}}`)
markeddownReg = regexp.MustCompile("<p>(.+)</p>") markeddownReg = regexp.MustCompile("<p>(.+)</p>")
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) { func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool) {
@ -82,6 +87,8 @@ func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *c
tagPrefix = "/read/t/" tagPrefix = "/read/t/"
} }
md = []byte(hashtagReg.ReplaceAll(md, []byte("<a href=\""+tagPrefix+"$1\" class=\"hashtag\"><span>#</span><span class=\"p-category\">$1</span></a>"))) md = []byte(hashtagReg.ReplaceAll(md, []byte("<a href=\""+tagPrefix+"$1\" class=\"hashtag\"><span>#</span><span class=\"p-category\">$1</span></a>")))
handlePrefix := cfg.App.Host + "/@/"
md = []byte(mentionReg.ReplaceAll(md, []byte("<a href=\""+handlePrefix+"$1$2\" class=\"u-url mention\">@<span>$1$2</span></a>")))
} }
// Strip out bad HTML // Strip out bad HTML
policy := getSanitizationPolicy() policy := getSanitizationPolicy()
@ -234,3 +241,29 @@ func shortPostDescription(content string) string {
} }
return strings.TrimSpace(fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1))) return strings.TrimSpace(fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1)))
} }
func handleRenderMarkdown(app *App, w http.ResponseWriter, r *http.Request) error {
if !IsJSON(r) {
return impart.HTTPError{Status: http.StatusUnsupportedMediaType, Message: "Markdown API only supports JSON requests"}
}
in := struct {
CollectionURL string `json:"collection_url"`
RawBody string `json:"raw_body"`
}{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&in)
if err != nil {
log.Error("Couldn't parse markdown JSON request: %v", err)
return ErrBadJSON
}
out := struct {
Body string `json:"body"`
}{
Body: applyMarkdown([]byte(in.RawBody), in.CollectionURL, app.cfg),
}
return impart.WriteSuccess(w, out, http.StatusOK)
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -35,7 +35,6 @@ import (
"github.com/writeas/web-core/i18n" "github.com/writeas/web-core/i18n"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/tags" "github.com/writeas/web-core/tags"
"github.com/writeas/writefreely/config"
"github.com/writeas/writefreely/page" "github.com/writeas/writefreely/page"
"github.com/writeas/writefreely/parse" "github.com/writeas/writefreely/parse"
) )
@ -229,6 +228,10 @@ func (p Post) Summary() string {
return shortPostDescription(p.Content) 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. // Excerpt shows any text that comes before a (more) tag.
// TODO: use HTMLExcerpt in templates instead of this method // TODO: use HTMLExcerpt in templates instead of this method
func (p *Post) Excerpt() template.HTML { func (p *Post) Excerpt() template.HTML {
@ -381,6 +384,14 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
} }
} }
var silenced bool
if found {
silenced, err = app.db.IsUserSilenced(ownerID.Int64)
if err != nil {
log.Error("view post: %v", err)
}
}
// Check if post has been unpublished // Check if post has been unpublished
if content == "" { if content == "" {
gone = true gone = true
@ -431,6 +442,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
Username string Username string
IsOwner bool IsOwner bool
SiteURL string SiteURL string
Silenced bool
}{ }{
AnonymousPost: post, AnonymousPost: post,
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
@ -441,6 +453,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
} }
if !page.IsOwner && silenced {
return ErrPostNotFound
}
page.Silenced = silenced
err = templates["post"].ExecuteTemplate(w, "post", page) err = templates["post"].ExecuteTemplate(w, "post", page)
if err != nil { if err != nil {
log.Error("Post template execute error: %v", err) log.Error("Post template execute error: %v", err)
@ -497,6 +513,14 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
} else { } else {
userID = app.db.GetUserID(accessToken) userID = app.db.GetUserID(accessToken)
} }
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("new post: %v", err)
}
if silenced {
return ErrUserSilenced
}
if userID == -1 { if userID == -1 {
return ErrNotLoggedIn return ErrNotLoggedIn
} }
@ -509,7 +533,7 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
var p *SubmittedPost var p *SubmittedPost
if reqJSON { if reqJSON {
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&p) err = decoder.Decode(&p)
if err != nil { if err != nil {
log.Error("Couldn't parse new post JSON request: %v\n", err) log.Error("Couldn't parse new post JSON request: %v\n", err)
return ErrBadJSON return ErrBadJSON
@ -555,7 +579,6 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
var newPost *PublicPost = &PublicPost{} var newPost *PublicPost = &PublicPost{}
var coll *Collection var coll *Collection
var err error
if accessToken != "" { if accessToken != "" {
newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host) newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host)
} else { } else {
@ -663,6 +686,14 @@ func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
} }
} }
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("existing post: %v", err)
}
if silenced {
return ErrUserSilenced
}
// Modify post struct // Modify post struct
p.ID = postID p.ID = postID
@ -857,11 +888,19 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
ownerID = u.ID ownerID = u.ID
} }
silenced, err := app.db.IsUserSilenced(ownerID)
if err != nil {
log.Error("add post: %v", err)
}
if silenced {
return ErrUserSilenced
}
// Parse claimed posts in format: // Parse claimed posts in format:
// [{"id": "...", "token": "..."}] // [{"id": "...", "token": "..."}]
var claims *[]ClaimPostRequest var claims *[]ClaimPostRequest
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&claims) err = decoder.Decode(&claims)
if err != nil { if err != nil {
return ErrBadJSONArray return ErrBadJSONArray
} }
@ -951,13 +990,21 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
userID = u.ID userID = u.ID
} }
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("pin post: %v", err)
}
if silenced {
return ErrUserSilenced
}
// Parse request // Parse request
var posts []struct { var posts []struct {
ID string `json:"id"` ID string `json:"id"`
Position int64 `json:"position"` Position int64 `json:"position"`
} }
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&posts) err = decoder.Decode(&posts)
if err != nil { if err != nil {
return ErrBadJSONArray return ErrBadJSONArray
} }
@ -1002,30 +1049,40 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return err return err
} }
collID = coll.ID
}
p, err := app.db.GetPost(vars["post"], collID)
if err != nil {
return err
}
if coll == nil && p.CollectionID.Valid {
// Collection post is getting fetched by post ID, not coll alias + post slug, so get coll info now.
coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
if err != nil {
return err
}
}
if coll != nil {
coll.hostName = app.cfg.App.Host coll.hostName = app.cfg.App.Host
_, err = apiCheckCollectionPermissions(app, r, coll) _, err = apiCheckCollectionPermissions(app, r, coll)
if err != nil { if err != nil {
return err return err
} }
collID = coll.ID
} }
p, err := app.db.GetPost(vars["post"], collID) silenced, err := app.db.IsUserSilenced(p.OwnerID.Int64)
if err != nil { if err != nil {
return err log.Error("fetch post: %v", err)
}
if silenced {
return ErrPostNotFound
} }
p.extractData() p.extractData()
accept := r.Header.Get("Accept") accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/activity+json") { if strings.Contains(accept, "application/activity+json") {
// Fetch information about the collection this belongs to
if coll == nil && p.CollectionID.Valid {
coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
if err != nil {
return err
}
}
if coll == nil { if coll == nil {
// This is a draft post; 404 for now // This is a draft post; 404 for now
// TODO: return ActivityObject // TODO: return ActivityObject
@ -1033,8 +1090,9 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
} }
p.Collection = &CollectionObj{Collection: *coll} p.Collection = &CollectionObj{Collection: *coll}
po := p.ActivityObject(app.cfg) po := p.ActivityObject(app)
po.Context = []interface{}{activitystreams.Namespace} po.Context = []interface{}{activitystreams.Namespace}
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, po, http.StatusOK) return impart.RenderActivityJSON(w, po, http.StatusOK)
} }
@ -1068,7 +1126,8 @@ func (p *PublicPost) CanonicalURL(hostName string) string {
return p.Collection.CanonicalURL() + p.Slug.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 := activitystreams.NewArticleObject()
o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
o.Published = p.Created o.Published = p.Created
@ -1108,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 return o
} }
@ -1275,12 +1355,21 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
} }
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("view collection post: %v", err)
}
// Check collection permissions // Check collection permissions
if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) { if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
return ErrPostNotFound return ErrPostNotFound
} }
if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) { if c.IsProtected() && (u == nil || u.ID != c.OwnerID) {
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug} if silenced {
return ErrPostNotFound
} else if !isAuthorizedForCollection(app, c.Alias, r) {
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
}
} }
cr.isCollOwner = u != nil && c.OwnerID == u.ID cr.isCollOwner = u != nil && c.OwnerID == u.ID
@ -1291,7 +1380,7 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
// Fetch extra data about the Collection // Fetch extra data about the Collection
// TODO: refactor out this logic, shared in collection.go:fetchCollection() // TODO: refactor out this logic, shared in collection.go:fetchCollection()
coll := &CollectionObj{Collection: *c} coll := NewCollectionObj(c)
owner, err := app.db.GetUserByID(coll.OwnerID) owner, err := app.db.GetUserByID(coll.OwnerID)
if err != nil { if err != nil {
// Log the error and just continue // Log the error and just continue
@ -1331,6 +1420,9 @@ Are you sure it was ever here?`,
p.Collection = coll p.Collection = coll
p.IsTopLevel = app.cfg.App.SingleUser p.IsTopLevel = app.cfg.App.SingleUser
if !p.IsOwner && silenced {
return ErrPostNotFound
}
// Check if post has been unpublished // Check if post has been unpublished
if p.Content == "" && p.Title.String == "" { if p.Content == "" && p.Title.String == "" {
return impart.HTTPError{http.StatusGone, "Post was unpublished."} return impart.HTTPError{http.StatusGone, "Post was unpublished."}
@ -1362,8 +1454,9 @@ Are you sure it was ever here?`,
return ErrCollectionPageNotFound return ErrCollectionPageNotFound
} }
p.extractData() p.extractData()
ap := p.ActivityObject(app.cfg) ap := p.ActivityObject(app)
ap.Context = []interface{}{activitystreams.Namespace} ap.Context = []interface{}{activitystreams.Namespace}
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, ap, http.StatusOK) return impart.RenderActivityJSON(w, ap, http.StatusOK)
} else { } else {
p.extractData() p.extractData()
@ -1380,12 +1473,14 @@ Are you sure it was ever here?`,
IsFound bool IsFound bool
IsAdmin bool IsAdmin bool
CanInvite bool CanInvite bool
Silenced bool
}{ }{
PublicPost: p, PublicPost: p,
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
IsOwner: cr.isCollOwner, IsOwner: cr.isCollOwner,
IsCustomDomain: cr.isCustomDomain, IsCustomDomain: cr.isCustomDomain,
IsFound: postFound, IsFound: postFound,
Silenced: silenced,
} }
tp.IsAdmin = u != nil && u.IsAdmin() tp.IsAdmin = u != nil && u.IsAdmin()
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)

@ -13,6 +13,12 @@ package writefreely
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"html/template"
"math"
"net/http"
"strconv"
"time"
. "github.com/gorilla/feeds" . "github.com/gorilla/feeds"
"github.com/gorilla/mux" "github.com/gorilla/mux"
stripmd "github.com/writeas/go-strip-markdown" stripmd "github.com/writeas/go-strip-markdown"
@ -20,11 +26,6 @@ import (
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/memo" "github.com/writeas/web-core/memo"
"github.com/writeas/writefreely/page" "github.com/writeas/writefreely/page"
"html/template"
"math"
"net/http"
"strconv"
"time"
) )
const ( const (
@ -69,7 +70,8 @@ func (app *App) FetchPublicPosts() (interface{}, error) {
rows, err := app.db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated rows, err := app.db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated
FROM collections c FROM collections c
LEFT JOIN posts p ON p.collection_id = c.id LEFT JOIN posts p ON p.collection_id = c.id
WHERE c.privacy = 1 AND (p.created >= ` + app.db.dateSub(3, "month") + ` AND p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) LEFT JOIN users u ON u.id = p.owner_id
WHERE c.privacy = 1 AND (p.created >= ` + app.db.dateSub(3, "month") + ` AND p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0
ORDER BY p.created DESC`) ORDER BY p.created DESC`)
if err != nil { if err != nil {
log.Error("Failed selecting from posts: %v", err) log.Error("Failed selecting from posts: %v", err)

@ -70,6 +70,12 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover))) write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover)))
write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo))) 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())
// Set up dyamic page handlers // Set up dyamic page handlers
// Handle auth // Handle auth
auth := write.PathPrefix("/api/auth/").Subrouter() auth := write.PathPrefix("/api/auth/").Subrouter()
@ -94,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("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET") me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).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("/settings", handler.User(viewSettings)).Methods("GET")
me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET") me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET")
me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET") me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
@ -106,10 +113,13 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST") apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST") apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST") apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST")
// Sign up validation // Sign up validation
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST")
// Handle collections // Handle collections
write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST") write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
apiColls := write.PathPrefix("/api/collections/").Subrouter() apiColls := write.PathPrefix("/api/collections/").Subrouter()
@ -144,6 +154,8 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET") write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET") write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST")
write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).Methods("POST")
write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET") write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET")
write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET") write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST") write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST")
@ -153,7 +165,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
// Handle special pages first // Handle special pages first
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
write.HandleFunc("/signup", handler.Web(handleViewLanding, 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 // TODO: show a reader-specific 404 page if the function is disabled
write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader)) write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader))
RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter()) RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter())
@ -161,14 +173,14 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
draftEditPrefix := "" draftEditPrefix := ""
if apper.App().cfg.App.SingleUser { if apper.App().cfg.App.SingleUser {
draftEditPrefix = "/d" draftEditPrefix = "/d"
write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
} else { } else {
write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
} }
// All the existing stuff // All the existing stuff
write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET") write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET")
// Collections // Collections
if apper.App().cfg.App.SingleUser { if apper.App().cfg.App.SingleUser {
RouteCollections(handler, write.PathPrefix("/").Subrouter()) RouteCollections(handler, write.PathPrefix("/").Subrouter())
@ -180,6 +192,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
} }
write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional)) write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional))
write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional)) write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional))
return r return r
} }

@ -11,7 +11,7 @@
## have not installed the binary `writefreely` in another location. ## ## 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. # This file is part of WriteFreely.
# #
@ -31,7 +31,7 @@ fi
# go ahead and check for the latest release on linux # go ahead and check for the latest release on linux
echo "Checking for updates..." 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 # check current version
@ -82,13 +82,25 @@ filename=${parts[-1]}
echo "Extracting files..." echo "Extracting files..."
tar -zxf $tempdir/$filename -C $tempdir 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 # copy files
echo "Copying 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 # restart service
echo "Restarting writefreely systemd service..." echo "Starting writefreely systemd service..."
if `systemctl restart writefreely`; then if `systemctl start writefreely`; then
echo "Success, version has been upgraded to $latest." echo "Success, version has been upgraded to $latest."
else else
echo "Upgrade complete, but failed to restart service." echo "Upgrade complete, but failed to restart service."

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@ -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'));
}

@ -11,10 +11,6 @@
package writefreely package writefreely
import ( import (
"github.com/dustin/go-humanize"
"github.com/writeas/web-core/l10n"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config"
"html/template" "html/template"
"io" "io"
"io/ioutil" "io/ioutil"
@ -22,6 +18,11 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/dustin/go-humanize"
"github.com/writeas/web-core/l10n"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config"
) )
var ( var (
@ -63,6 +64,7 @@ func initTemplate(parentDir, name string) {
filepath.Join(parentDir, templatesDir, name+".tmpl"), filepath.Join(parentDir, templatesDir, name+".tmpl"),
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "base.tmpl"), filepath.Join(parentDir, templatesDir, "base.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
} }
if name == "collection" || name == "collection-tags" || name == "chorus-collection" { if name == "collection" || name == "collection-tags" || name == "chorus-collection" {
// These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl" // These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl"
@ -86,6 +88,7 @@ func initPage(parentDir, path, key string) {
path, path,
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "base.tmpl"), filepath.Join(parentDir, templatesDir, "base.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
)) ))
} }
@ -98,6 +101,7 @@ func initUserPage(parentDir, path, key string) {
path, path,
filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"), filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
)) ))
} }

@ -22,7 +22,7 @@
{{ end }} {{ end }}
{{if not .SingleUser}} {{if not .SingleUser}}
<nav id="user-nav"> <nav id="user-nav">
{{if and .Chorus .Username}} {{if .Username}}
<nav class="dropdown-nav"> <nav class="dropdown-nav">
<ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul> <ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}} {{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
@ -39,10 +39,10 @@
{{ if and .SimpleNav (not .SingleUser) }} {{ if and .SimpleNav (not .SingleUser) }}
{{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>Home</a>{{end}} {{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>Home</a>{{end}}
{{ end }} {{ end }}
<a href="/about"{{if eq .Path "/about"}} class="selected"{{end}}>About</a> {{if or .Chorus (not .Username)}}<a href="/about"{{if eq .Path "/about"}} class="selected"{{end}}>About</a>{{end}}
{{ if not .SingleUser }} {{ if not .SingleUser }}
{{ if .Username }} {{ if .Username }}
{{if gt .MaxBlogs 1}}<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>{{end}} {{if or (not .Chorus) (gt .MaxBlogs 1)}}<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>{{end}}
{{if and (and .Chorus (eq .MaxBlogs 1)) .Username}}<a href="/{{.Username}}/"{{if eq .Path (printf "/%s/" .Username)}} class="selected"{{end}}>My Posts</a>{{end}} {{if and (and .Chorus (eq .MaxBlogs 1)) .Username}}<a href="/{{.Username}}/"{{if eq .Path (printf "/%s/" .Username)}} class="selected"{{end}}>My Posts</a>{{end}}
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}} {{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
{{ end }} {{ end }}

@ -37,16 +37,6 @@ body footer {
} }
body#post header { body#post header {
padding: 1em 1rem; padding: 1em 1rem;
}
article time.dt-published {
display: block;
color: #666;
}
body#post article h2#title{
margin-bottom: 0.5em;
}
article time.dt-published {
margin-bottom: 1em;
} }
</style> </style>
@ -65,7 +55,10 @@ article time.dt-published {
{{template "user-navigation" .}} {{template "user-navigation" .}}
<article id="post-body" class="{{.Font}} h-entry">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name">{{.FormattedDisplayTitle}}</h2>{{end}}{{/* TODO: check format: if .Collection.Format.ShowDates*/}}<time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time><div class="e-content">{{.HTMLContent}}</div></article> {{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<article id="post-body" class="{{.Font}} h-entry">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name{{if $.Collection.Format.ShowDates}} dated{{end}}">{{.FormattedDisplayTitle}}</h2>{{end}}{{if $.Collection.Format.ShowDates}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time>{{end}}<div class="e-content">{{.HTMLContent}}</div></article>
{{ if .Collection.ShowFooterBranding }} {{ if .Collection.ShowFooterBranding }}
<footer dir="ltr"> <footer dir="ltr">
@ -77,7 +70,7 @@ article time.dt-published {
</p> </p>
<nav> <nav>
{{if .PinnedPosts}} {{if .PinnedPosts}}
{{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}} {{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}
{{end}} {{end}}
</nav> </nav>
<hr> <hr>
@ -90,6 +83,7 @@ article time.dt-published {
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}} {{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}} {{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
{{end}} {{end}}
<script src="/js/localdate.js"></script>
<script type="text/javascript"> <script type="text/javascript">
var pinning = false; var pinning = false;

@ -61,6 +61,9 @@ body#collection header nav.tabs a:first-child {
<body id="collection" itemscope itemtype="http://schema.org/WebPage"> <body id="collection" itemscope itemtype="http://schema.org/WebPage">
{{template "user-navigation" .}} {{template "user-navigation" .}}
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<header> <header>
<h1 dir="{{.Direction}}" id="blog-title"><a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1> <h1 dir="{{.Direction}}" id="blog-title"><a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1>
{{if .Description}}<p class="description p-note">{{.Description}}</p>{{end}} {{if .Description}}<p class="description p-note">{{.Description}}</p>{{end}}
@ -68,7 +71,7 @@ body#collection header nav.tabs a:first-child {
<!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p--> <!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p-->
{{/*end*/}} {{/*end*/}}
{{if .PinnedPosts}}<nav class="pinned-posts"> {{if .PinnedPosts}}<nav class="pinned-posts">
{{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav> {{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav>
{{end}} {{end}}
</header> </header>
@ -112,6 +115,7 @@ body#collection header nav.tabs a:first-child {
{{if .Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}} {{if .Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
{{end}} {{end}}
<script src="/js/h.js"></script> <script src="/js/h.js"></script>
<script src="/js/localdate.js"></script>
<script src="/js/postactions.js"></script> <script src="/js/postactions.js"></script>
<script type="text/javascript"> <script type="text/javascript">
var deleting = false; var deleting = false;

@ -50,7 +50,7 @@
<h1 dir="{{.Direction}}" id="blog-title"><a rel="author" href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1> <h1 dir="{{.Direction}}" id="blog-title"><a rel="author" href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1>
<nav> <nav>
{{if .PinnedPosts}} {{if .PinnedPosts}}
{{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}} {{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}
{{end}} {{end}}
{{ if and .IsOwner .IsFound }}<span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span> {{ if and .IsOwner .IsFound }}<span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span>
<a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a> <a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a>
@ -59,7 +59,10 @@
</nav> </nav>
</header> </header>
<article id="post-body" class="{{.Font}} h-entry {{if not .IsFound}}error-page{{end}}">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name">{{.FormattedDisplayTitle}}</h2>{{end}}<div class="e-content">{{.HTMLContent}}</div></article> {{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<article id="post-body" class="{{.Font}} h-entry {{if not .IsFound}}error-page{{end}}">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name{{if $.Collection.Format.ShowDates}} dated{{end}}">{{.FormattedDisplayTitle}}</h2>{{end}}{{if $.Collection.Format.ShowDates}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time>{{end}}<div class="e-content">{{.HTMLContent}}</div></article>
{{ if .Collection.ShowFooterBranding }} {{ if .Collection.ShowFooterBranding }}
<footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav></footer> <footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav></footer>
@ -70,6 +73,7 @@
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}} {{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}} {{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
{{end}} {{end}}
<script src="/js/localdate.js"></script>
<script type="text/javascript"> <script type="text/javascript">
var pinning = false; var pinning = false;

@ -48,11 +48,14 @@
<h1 dir="{{.Direction}}" id="blog-title"><a href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1> <h1 dir="{{.Direction}}" id="blog-title"><a href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1>
<nav> <nav>
{{if .PinnedPosts}} {{if .PinnedPosts}}
{{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}{{end}}">{{.DisplayTitle}}</a>{{end}} {{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.DisplayTitle}}</a>{{end}}
{{end}} {{end}}
</nav> </nav>
</header> </header>
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
{{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}} {{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}}
<h1>{{.Tag}}</h1> <h1>{{.Tag}}</h1>
{{template "posts" .}} {{template "posts" .}}
@ -72,6 +75,7 @@
{{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}} {{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Collection.Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}} {{if .Collection.Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
{{end}} {{end}}
<script src="/js/localdate.js"></script>
{{if .IsOwner}} {{if .IsOwner}}
<script src="/js/h.js"></script> <script src="/js/h.js"></script>
<script src="/js/postactions.js"></script> <script src="/js/postactions.js"></script>

@ -62,13 +62,16 @@
</ul></nav>{{end}} </ul></nav>{{end}}
<header> <header>
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<h1 dir="{{.Direction}}" id="blog-title">{{if .Posts}}{{else}}<span class="writeas-prefix"><a href="/">write.as</a></span> {{end}}<a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1> <h1 dir="{{.Direction}}" id="blog-title">{{if .Posts}}{{else}}<span class="writeas-prefix"><a href="/">write.as</a></span> {{end}}<a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1>
{{if .Description}}<p class="description p-note">{{.Description}}</p>{{end}} {{if .Description}}<p class="description p-note">{{.Description}}</p>{{end}}
{{/*if not .Public/*}} {{/*if not .Public/*}}
<!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p--> <!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p-->
{{/*end*/}} {{/*end*/}}
{{if .PinnedPosts}}<nav> {{if .PinnedPosts}}<nav>
{{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav> {{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav>
{{end}} {{end}}
</header> </header>
@ -113,6 +116,7 @@
{{end}} {{end}}
<script src="/js/h.js"></script> <script src="/js/h.js"></script>
<script src="/js/postactions.js"></script> <script src="/js/postactions.js"></script>
<script src="/js/localdate.js"></script>
<script type="text/javascript"> <script type="text/javascript">
var deleting = false; var deleting = false;
function delPost(e, id, owned) { function delPost(e, id, owned) {

@ -269,6 +269,10 @@
<script src="/js/h.js"></script> <script src="/js/h.js"></script>
<script> <script>
function updateMeta() { function updateMeta() {
if ({{.Silenced}}) {
alert("Your account is silenced, so you can't edit posts.");
return
}
document.getElementById('create-error').style.display = 'none'; document.getElementById('create-error').style.display = 'none';
var $created = document.getElementById('created'); var $created = document.getElementById('created');
var dateStr = $created.value.trim(); var dateStr = $created.value.trim();

@ -21,10 +21,10 @@
{{end}} {{end}}
{{end}} {{end}}
</h2> </h2>
{{if $.Format.ShowDates}}<time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{$.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time>{{end}} {{if $.Format.ShowDates}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{$.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time>{{end}}
{{else}} {{else}}
<h2 class="post-title" itemprop="name"> <h2 class="post-title" itemprop="name">
{{if $.Format.ShowDates}}<time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time>{{end}} {{if $.Format.ShowDates}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time>{{end}}
{{if $.IsOwner}} {{if $.IsOwner}}
{{if not $.Format.ShowDates}}<a class="user hidden action" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}} {{if not $.Format.ShowDates}}<a class="user hidden action" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}}
<a class="user hidden action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/edit">edit</a> <a class="user hidden action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/edit">edit</a>

@ -131,8 +131,12 @@
{{else}}var canPublish = true;{{end}} {{else}}var canPublish = true;{{end}}
var publishing = false; var publishing = false;
var justPublished = false; var justPublished = false;
var silenced = {{.Silenced}};
var publish = function(content, font) { var publish = function(content, font) {
if (silenced === true) {
alert("Your account is silenced, so you can't publish or update posts.");
return;
}
{{if and (and .Post.Id (not .Post.Slug)) (not .User)}} {{if and (and .Post.Id (not .Post.Slug)) (not .User)}}
if (!token) { if (!token) {
alert("You don't have permission to update this post."); alert("You don't have permission to update this post.");

@ -35,7 +35,6 @@
{{template "highlighting" .}} {{template "highlighting" .}}
</head> </head>
<body id="post"> <body id="post">
<header> <header>
<h1 dir="{{.Direction}}"><a href="/">{{.SiteName}}</a></h1> <h1 dir="{{.Direction}}"><a href="/">{{.SiteName}}</a></h1>
<nav> <nav>
@ -49,6 +48,10 @@
{{ end }} {{ end }}
</nav> </nav>
</header> </header>
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<article class="{{.Font}} h-entry">{{if .Title}}<h2 id="title" class="p-name">{{.Title}}</h2>{{end}}{{ if .IsPlainText }}<p id="post-body" class="e-content">{{.Content}}</p>{{ else }}<div id="post-body" class="e-content">{{.HTMLContent}}</div>{{ end }}</article> <article class="{{.Font}} h-entry">{{if .Title}}<h2 id="title" class="p-name">{{.Title}}</h2>{{end}}{{ if .IsPlainText }}<p id="post-body" class="e-content">{{.Content}}</p>{{ else }}<div id="post-body" class="e-content">{{.HTMLContent}}</div>{{ end }}</article>

@ -88,9 +88,9 @@
<section itemscope itemtype="http://schema.org/Blog"> <section itemscope itemtype="http://schema.org/Blog">
{{range .Posts}}<article class="{{.Font}} h-entry" itemscope itemtype="http://schema.org/BlogPosting"> {{range .Posts}}<article class="{{.Font}} h-entry" itemscope itemtype="http://schema.org/BlogPosting">
{{if .Title.String}}<h2 class="post-title" itemprop="name" class="p-name"><a href="{{if .Slug.String}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}" itemprop="url" class="u-url">{{.PlainDisplayTitle}}</a></h2> {{if .Title.String}}<h2 class="post-title" itemprop="name" class="p-name"><a href="{{if .Slug.String}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}" itemprop="url" class="u-url">{{.PlainDisplayTitle}}</a></h2>
<time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{.Collection.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time> <time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{.Collection.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time>
{{else}} {{else}}
<h2 class="post-title" itemprop="name"><time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time></h2> <h2 class="post-title" itemprop="name"><time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time></h2>
{{end}} {{end}}
<p class="source">{{if .Collection}}from <a href="{{.Collection.CanonicalURL}}">{{.Collection.DisplayTitle}}</a>{{else}}<em>Anonymous</em>{{end}}</p> <p class="source">{{if .Collection}}from <a href="{{.Collection.CanonicalURL}}">{{.Collection.DisplayTitle}}</a>{{else}}<em>Anonymous</em>{{end}}</p>
{{if .Excerpt}}<div class="p-summary" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{.Excerpt}}</div> {{if .Excerpt}}<div class="p-summary" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{.Excerpt}}</div>
@ -112,7 +112,7 @@
</nav>{{end}} </nav>{{end}}
</div> </div>
<script src="/js/localdate.js">
<script type="text/javascript"> <script type="text/javascript">
(function() { (function() {
var $articles = document.querySelectorAll('article'); var $articles = document.querySelectorAll('article');

@ -11,12 +11,14 @@
<th>User</th> <th>User</th>
<th>Joined</th> <th>Joined</th>
<th>Type</th> <th>Type</th>
<th>Status</th>
</tr> </tr>
{{range .Users}} {{range .Users}}
<tr> <tr>
<td><a href="/admin/user/{{.Username}}">{{.Username}}</a></td> <td><a href="/admin/user/{{.Username}}">{{.Username}}</a></td>
<td>{{.CreatedFriendly}}</td> <td>{{.CreatedFriendly}}</td>
<td style="text-align:center">{{if .IsAdmin}}Admin{{else}}User{{end}}</td> <td style="text-align:center">{{if .IsAdmin}}Admin{{else}}User{{end}}</td>
<td style="text-align:center">{{if .IsSilenced}}Silenced{{else}}Active{{end}}</td>
</tr> </tr>
{{end}} {{end}}
</table> </table>

@ -7,12 +7,43 @@ table.classy th {
h3 { h3 {
font-weight: normal; font-weight: normal;
} }
td.active-silence {
display: flex;
align-items: center;
}
td.active-silence > input[type="submit"] {
margin-left: auto;
margin-right: 5%;
}
@media only screen and (max-width: 500px) {
td.active-silence {
flex-wrap: wrap;
}
td.active-silence > input[type="submit"] {
margin: auto;
}
}
input.copy-text {
text-align: center;
font-size: 1.2em;
color: #555;
width: 100%;
box-sizing: border-box;
}
</style> </style>
<div class="snug content-container"> <div class="snug content-container">
{{template "admin-header" .}} {{template "admin-header" .}}
<h2 id="posts-header">{{.User.Username}}</h2> <h2 id="posts-header">{{.User.Username}}</h2>
{{if .NewPassword}}<div class="alert success">
<p>This user's password has been reset to:</p>
<p><input type="text" class="copy-text" value="{{.NewPassword}}" onfocus="if (this.select) this.select(); else this.setSelectionRange(0, this.value.length);" readonly /></p>
<p>They can use this new password to log in to their account. <strong>This will only be shown once</strong>, so be sure to copy it and send it to them now.</p>
{{if .ClearEmail}}<p>Their email address is: <a href="mailto:{{.ClearEmail}}">{{.ClearEmail}}</a></p>{{end}}
</div>
{{end}}
<table class="classy export"> <table class="classy export">
<tr> <tr>
<th>No.</th> <th>No.</th>
@ -38,6 +69,34 @@ h3 {
<th>Last Post</th> <th>Last Post</th>
<td>{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}</td> <td>{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}</td>
</tr> </tr>
<tr>
<form action="/admin/user/{{.User.Username}}/status" method="POST" {{if not .User.IsSilenced}}onsubmit="return confirmSilence()"{{end}}>
<a id="status"/>
<th>Status</th>
<td class="active-silence">
{{if .User.IsSilenced}}
<p>Silenced</p>
<input type="submit" value="Unsilence"/>
{{else}}
<p>Active</p>
<input class="danger" type="submit" value="Silence" {{if .User.IsAdmin}}disabled{{end}}/>
{{end}}
</td>
</form>
</tr>
<tr>
<th>Password</th>
<td>
{{if ne .Username .User.Username}}
<form id="reset-form" action="/admin/user/{{.User.Username}}/passphrase" method="post" autocomplete="false">
<input type="hidden" name="user" value="{{.User.ID}}"/>
<button type="submit">Reset</button>
</form>
{{else}}
<a href="/me/settings" title="Go to reset password page">Change your password</a>
{{end}}
</td>
</tr>
</table> </table>
<h2>Blogs</h2> <h2>Blogs</h2>
@ -83,5 +142,19 @@ h3 {
{{end}} {{end}}
</div> </div>
<script type="text/javascript">
function confirmSilence() {
return confirm("Silence this user? They'll still be able to log in and access their posts, but no one else will be able to see them anymore. You can reverse this decision at any time.");
}
form = document.getElementById("reset-form");
form.addEventListener('submit', function(e) {
e.preventDefault();
agreed = confirm("Reset this user's password? This will generate a new temporary password that you'll need to share with them, and invalidate their old one.");
if (agreed === true) {
form.submit();
}
});
</script>
{{template "footer" .}} {{template "footer" .}}
{{end}} {{end}}

@ -6,10 +6,16 @@
{{if .Flashes}}<ul class="errors"> {{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}} </ul>{{end}}
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<h2 id="posts-header">drafts</h2> <h2 id="posts-header">drafts</h2>
{{ if .AnonymousPosts }}<div class="atoms posts"> {{ if .AnonymousPosts }}
<p>These are your draft posts. You can share them individually (without a blog) or move them to your blog when you're ready.</p>
<div class="atoms posts">
{{ range $el := .AnonymousPosts }}<div id="post-{{.ID}}" class="post"> {{ range $el := .AnonymousPosts }}<div id="post-{{.ID}}" class="post">
<h3><a href="/{{if $.SingleUser}}d/{{end}}{{.ID}}" itemprop="url">{{.DisplayTitle}}</a></h3> <h3><a href="/{{if $.SingleUser}}d/{{end}}{{.ID}}" itemprop="url">{{.DisplayTitle}}</a></h3>
<h4> <h4>
@ -31,10 +37,11 @@
{{end}} {{end}}
{{ end }} {{ end }}
</h4> </h4>
{{if .Summary}}<p>{{.Summary}}</p>{{end}} {{if .Summary}}<p>{{.SummaryHTML}}</p>{{end}}
</div>{{end}} </div>{{end}}
</div>{{ else }}<div id="no-posts-published"><p>You haven't saved any drafts yet.</p> </div>{{ else }}<div id="no-posts-published">
<p>They'll show up here once you do. {{if not .SingleUser}}Find your blog posts from the <a href="/me/c/">Blogs</a> page.{{end}}</p> <p>Your anonymous and draft posts will show up here once you've published some. You'll be able to share them individually (without a blog) or move them to a blog when you're ready.</p>
{{if not .SingleUser}}<p>Alternatively, see your blogs and their posts on your <a href="/me/c/">Blogs</a> page.</p>{{end}}
<p class="text-cta"><a href="{{if .SingleUser}}/me/new{{else}}/{{end}}">Start writing</a></p></div>{{ end }} <p class="text-cta"><a href="{{if .SingleUser}}/me/new{{else}}/{{end}}">Start writing</a></p></div>{{ end }}
<div id="moving"></div> <div id="moving"></div>

@ -8,6 +8,9 @@
<div class="content-container snug"> <div class="content-container snug">
<div id="overlay"></div> <div id="overlay"></div>
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<h2>Customize {{.DisplayTitle}} <a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">view blog</a></h2> <h2>Customize {{.DisplayTitle}} <a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">view blog</a></h2>
{{if .Flashes}}<ul class="errors"> {{if .Flashes}}<ul class="errors">

@ -7,6 +7,9 @@
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}} </ul>{{end}}
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<h2>blogs</h2> <h2>blogs</h2>
<ul class="atoms collections"> <ul class="atoms collections">
{{range $i, $el := .Collections}}<li class="collection"><h3> {{range $i, $el := .Collections}}<li class="collection"><h3>

@ -0,0 +1,64 @@
{{define "import"}}
{{template "header" .}}
<style>
input[type=file] {
padding: 0;
font-size: 0.86em;
display: block;
margin: 0.5rem 0;
}
label {
display: block;
margin: 1em 0;
}
</style>
<div class="snug content-container">
<h1 id="import-header">Import posts</h1>
{{if .Message}}
<div class="alert {{if .InfoMsg}}info{{else}}success{{end}}">
<p>{{.Message}}</p>
</div>
{{end}}
{{if .Flashes}}
<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>
{{end}}
<p>Publish plain text or Markdown files to your account by uploading them below.</p>
<div class="formContainer">
<form id="importPosts" class="prominent" enctype="multipart/form-data" action="/api/me/import" method="POST">
<label>Select some files to import:
<input id="fileInput" class="fileInput" name="files" type="file" multiple accept="text/markdown, text/plain"/>
</label>
<input id="fileDates" name="fileDates" hidden/>
<label>
Import these posts to:
<select name="collection">
{{range $i, $el := .Collections}}
<option value="{{.Alias}}" {{if eq $i 0}}selected{{end}}>{{.DisplayTitle}}</option>
{{end}}
<option value="">Drafts</option>
</select>
</label>
<script>
// timezone offset in seconds
const tzOffsetSec = new Date().getTimezoneOffset() * 60;
const fileInput = document.getElementById('fileInput');
const fileDates = document.getElementById('fileDates');
fileInput.addEventListener('change', (e) => {
const files = e.target.files;
let dateMap = {};
for (let file of files) {
// convert from milliseconds to seconds and adjust for tz
dateMap[file.name] = Math.round(file.lastModified / 1000) + tzOffsetSec;
}
fileDates.value = JSON.stringify(dateMap);
})
</script>
<input type="submit" value="Import" />
</form>
</div>
</div>
{{template "footer" .}}
{{end}}

@ -10,6 +10,7 @@
<li class="separator"><hr /></li> <li class="separator"><hr /></li>
{{if .IsAdmin}}<li><a href="/admin">Admin</a></li>{{end}} {{if .IsAdmin}}<li><a href="/admin">Admin</a></li>{{end}}
<li><a href="/me/settings">Settings</a></li> <li><a href="/me/settings">Settings</a></li>
<li><a href="/me/import">Import posts</a></li>
<li><a href="/me/export">Export</a></li> <li><a href="/me/export">Export</a></li>
<li class="separator"><hr /></li> <li class="separator"><hr /></li>
<li><a href="/me/logout">Log out</a></li> <li><a href="/me/logout">Log out</a></li>
@ -22,19 +23,17 @@
</nav> </nav>
</nav> </nav>
{{else}} {{else}}
{{ if .Chorus }}<nav id="full-nav"> <nav id="full-nav">
<div class="left-side"> <div class="left-side">
<h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1> <h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1>
</div> </div>
{{ else }}
<h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1>
{{ end }}
<nav id="user-nav"> <nav id="user-nav">
{{if .Username}} {{if .Username}}
<nav class="dropdown-nav"> <nav class="dropdown-nav">
<ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul> <ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}} {{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
<li><a href="/me/settings">Account settings</a></li> <li><a href="/me/settings">Account settings</a></li>
<li><a href="/me/import">Import posts</a></li>
<li><a href="/me/export">Export</a></li> <li><a href="/me/export">Export</a></li>
{{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}} {{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
<li class="separator"><hr /></li> <li class="separator"><hr /></li>
@ -62,6 +61,7 @@
{{else}} {{else}}
<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a> <a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}} {{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
{{end}} {{end}}
</nav> </nav>
</nav> </nav>

@ -0,0 +1,5 @@
{{define "user-silenced"}}
<div class="alert info">
<p><strong>Your account has been silenced.</strong> You can still access all of your posts and blogs, but no one else can currently see them.</p>
</div>
{{end}}

@ -8,18 +8,7 @@
margin-left: 0.5em; margin-left: 0.5em;
margin-right: 0; margin-right: 0;
} }
label { table.classy {
font-weight: bold;
}
select {
font-size: 1em;
width: 100%;
padding: 0.5rem;
display: block;
border-radius: 0.25rem;
margin: 0.5rem 0;
}
input, table.classy {
width: 100%; width: 100%;
} }
table.classy.export a { table.classy.export a {
@ -31,14 +20,17 @@ table td {
</style> </style>
<div class="snug content-container"> <div class="snug content-container">
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<h1>Invite people</h1> <h1>Invite people</h1>
<p>Invite others to join <em>{{.SiteName}}</em> by generating and sharing invite links below.</p> <p>Invite others to join <em>{{.SiteName}}</em> by generating and sharing invite links below.</p>
<form style="margin: 2em 0" action="/api/me/invites" method="post"> <form style="margin: 2em 0" class="prominent" action="/api/me/invites" method="post">
<div class="row"> <div class="row">
<div class="half"> <div class="half">
<label for="uses">Maximum number of uses:</label> <label for="uses">Maximum number of uses:</label>
<select id="uses" name="uses"> <select id="uses" name="uses" {{if .Silenced}}disabled{{end}}>
<option value="0">No limit</option> <option value="0">No limit</option>
<option value="1">1 use</option> <option value="1">1 use</option>
<option value="5">5 uses</option> <option value="5">5 uses</option>
@ -50,7 +42,7 @@ table td {
</div> </div>
<div class="half"> <div class="half">
<label for="expires">Expire after:</label> <label for="expires">Expire after:</label>
<select id="expires" name="expires"> <select id="expires" name="expires" {{if .Silenced}}disabled{{end}}>
<option value="0">Never</option> <option value="0">Never</option>
<option value="30">30 minutes</option> <option value="30">30 minutes</option>
<option value="60">1 hour</option> <option value="60">1 hour</option>
@ -63,7 +55,7 @@ table td {
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<input type="submit" value="Generate" /> <input type="submit" value="Generate" {{if .Silenced}}disabled title="You cannot generate invites while your account is silenced."{{end}} />
</div> </div>
</form> </form>

@ -7,6 +7,9 @@ h3 { font-weight: normal; }
.section > *:not(input) { font-size: 0.86em; } .section > *:not(input) { font-size: 0.86em; }
</style> </style>
<div class="content-container snug regular"> <div class="content-container snug regular">
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<h2>{{if .IsLogOut}}Before you go...{{else}}Account Settings {{if .IsAdmin}}<a href="/admin">admin settings</a>{{end}}{{end}}</h2> <h2>{{if .IsLogOut}}Before you go...{{else}}Account Settings {{if .IsAdmin}}<a href="/admin">admin settings</a>{{end}}{{end}}</h2>
{{if .Flashes}}<ul class="errors"> {{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}

@ -17,6 +17,9 @@ td.none {
</style> </style>
<div class="content-container snug"> <div class="content-container snug">
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
<h2 id="posts-header">{{if .Collection}}{{.Collection.DisplayTitle}} {{end}}Stats</h2> <h2 id="posts-header">{{if .Collection}}{{.Collection.DisplayTitle}} {{end}}Stats</h2>
<p>Stats for all time.</p> <p>Stats for all time.</p>

@ -19,6 +19,13 @@ import (
"github.com/writeas/writefreely/key" "github.com/writeas/writefreely/key"
) )
type UserStatus int
const (
UserActive = iota
UserSilenced
)
type ( type (
userCredentials struct { userCredentials struct {
Alias string `json:"alias" schema:"alias"` Alias string `json:"alias" schema:"alias"`
@ -59,6 +66,7 @@ type (
HasPass bool `json:"has_pass"` HasPass bool `json:"has_pass"`
Email zero.String `json:"email"` Email zero.String `json:"email"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
Status UserStatus `json:"status"`
clearEmail string `json:"email"` clearEmail string `json:"email"`
} }
@ -118,3 +126,7 @@ func (u *User) IsAdmin() bool {
// TODO: get this from database // TODO: get this from database
return u.ID == 1 return u.ID == 1
} }
func (u *User) IsSilenced() bool {
return u.Status&UserSilenced != 0
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -11,11 +11,15 @@
package writefreely package writefreely
import ( import (
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"github.com/writeas/go-webfinger" "github.com/writeas/go-webfinger"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config" "github.com/writeas/writefreely/config"
"net/http"
) )
type wfResolver struct { type wfResolver struct {
@ -37,6 +41,14 @@ func (wfr wfResolver) FindUser(username string, host, requestHost string, r []we
log.Error("Unable to get blog: %v", err) log.Error("Unable to get blog: %v", err)
return nil, err return nil, err
} }
silenced, err := wfr.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("webfinger find user: check is silenced: %v", err)
return nil, err
}
if silenced {
return nil, wfUserNotFoundErr
}
c.hostName = wfr.cfg.App.Host c.hostName = wfr.cfg.App.Host
if wfr.cfg.App.SingleUser { if wfr.cfg.App.SingleUser {
// Ensure handle matches user-chosen one on single-user blogs // Ensure handle matches user-chosen one on single-user blogs
@ -80,3 +92,49 @@ func (wfr wfResolver) DummyUser(username string, hostname string, r []webfinger.
func (wfr wfResolver) IsNotFoundError(err error) bool { func (wfr wfResolver) IsNotFoundError(err error) bool {
return err == wfUserNotFoundErr return err == wfUserNotFoundErr
} }
// RemoteLookup looks up a user by handle at a remote server
// and returns the actor URL
func RemoteLookup(handle string) string {
handle = strings.TrimLeft(handle, "@")
// let's take the server part of the handle
parts := strings.Split(handle, "@")
resp, err := http.Get("https://" + parts[1] + "/.well-known/webfinger?resource=acct:" + handle)
if err != nil {
log.Error("Error performing webfinger request", err)
return ""
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Error("Error reading webfinger response", err)
return ""
}
var result webfinger.Resource
err = json.Unmarshal(body, &result)
if err != nil {
log.Error("Unsupported webfinger response received: %v", err)
return ""
}
var href string
// iterate over webfinger links and find the one with
// a self "rel"
for _, link := range result.Links {
if link.Rel == "self" {
href = link.HRef
}
}
// if we didn't find it with the above then
// try using aliases
if href == "" {
// take the last alias because mastodon has the
// https://instance.tld/@user first which
// doesn't work as an href
href = result.Aliases[len(result.Aliases)-1]
}
return href
}

Loading…
Cancel
Save