Merge branch 'develop' into T319-user-delete-acct

This commit is contained in:
Matt Baer 2020-02-09 11:53:24 -05:00
commit f406f894c5
83 changed files with 3868 additions and 253 deletions

View file

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

View file

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

View file

@ -47,15 +47,15 @@ It's designed to be flexible and share your writing widely, so it's built around
## Hosting ## Hosting
We offer two kinds of hosting services that make WriteFreely deployment painless: [Write.as](https://write.as) for individuals, and [WriteFreely.host](https://writefreely.host) for communities. Besides saving you time, as a customer you directly help fund WriteFreely development. We offer two kinds of hosting services that make WriteFreely deployment painless: [Write.as Pro](https://write.as/pro) for individuals, and [Write.as for Teams](https://write.as/for/teams) for businesses. Besides saving you time and effort, both services directly fund WriteFreely development and ensure the long-term sustainability of our open source work.
### [![Write.as](https://write.as/img/writeas-wf-readme.png)](https://write.as/) ### [![Write.as Pro](https://writefreely.org/img/writeas-pro-readme.png)](https://write.as/pro)
Start a personal blog on [Write.as](https://write.as), our flagship instance. Built to eliminate setup friction and preserve your privacy, Write.as helps you start a blog in seconds. It supports custom domains (with SSL) and multiple blogs / pen names per account. [Read more here](https://write.as/pricing). Start a personal blog on [Write.as](https://write.as), our flagship instance. Built to eliminate setup friction and preserve your privacy, Write.as helps you start a blog in seconds. It supports custom domains (with SSL) and multiple blogs / pen names per account. [Read more here](https://write.as/pro).
### [![WriteFreely.host](https://writefreely.host/img/wfhost-wf-readme.png)](https://writefreely.host) ### [![Write.as for Teams](https://writefreely.org/img/writeas-for-teams-readme.png)](https://write.as/for/teams)
[WriteFreely.host](https://writefreely.host) makes it easy to start a close-knit community — to share knowledge, complement your Mastodon instance, or publish updates in your organization. We take care of the hosting, upgrades, backups, and maintenance so you can focus on writing. [Write.as for Teams](https://write.as/for/teams) gives your organization, business, or [open source project](https://write.as/for/open-source) a clutter-free space to share updates or proposals and build your collective knowledge. We take care of hosting, upgrades, backups, and maintenance so your team can focus on writing.
## Quick start ## Quick start

View file

@ -85,7 +85,7 @@ func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error {
} }
func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
// Get params // Get params
var ur userRegistration var ur userRegistration
@ -120,7 +120,7 @@ func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error)
} }
func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
// Validate required params (alias) // Validate required params (alias)
if signup.Alias == "" { if signup.Alias == "" {
@ -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 != "" {
@ -377,7 +373,7 @@ func webLogin(app *App, w http.ResponseWriter, r *http.Request) error {
var loginAttemptUsers = sync.Map{} var loginAttemptUsers = sync.Map{}
func login(app *App, w http.ResponseWriter, r *http.Request) error { func login(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
oneTimeToken := r.FormValue("with") oneTimeToken := r.FormValue("with")
verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "") verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "")
@ -580,7 +576,7 @@ func viewExportOptions(app *App, u *User, w http.ResponseWriter, r *http.Request
func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) { func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
var filename string var filename string
var u = &User{} var u = &User{}
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
if reqJSON { if reqJSON {
// Use given Authorization header // Use given Authorization header
accessToken := r.Header.Get("Authorization") accessToken := r.Header.Get("Authorization")
@ -625,7 +621,7 @@ func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte,
// Export as CSV // Export as CSV
if strings.HasSuffix(r.URL.Path, ".csv") { if strings.HasSuffix(r.URL.Path, ".csv") {
data = exportPostsCSV(u, posts) data = exportPostsCSV(app.cfg.App.Host, u, posts)
return data, filename, err return data, filename, err
} }
if strings.HasSuffix(r.URL.Path, ".zip") { if strings.HasSuffix(r.URL.Path, ".zip") {
@ -662,7 +658,7 @@ func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, s
} }
func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error { func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
uObj := struct { uObj := struct {
ID int64 `json:"id,omitempty"` ID int64 `json:"id,omitempty"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
@ -686,7 +682,7 @@ func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error {
} }
func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
if !reqJSON { if !reqJSON {
return ErrBadRequestedType return ErrBadRequestedType
} }
@ -717,7 +713,7 @@ func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) e
} }
func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
if !reqJSON { if !reqJSON {
return ErrBadRequestedType return ErrBadRequestedType
} }
@ -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)
} }
suspended, err := app.db.IsUserSuspended(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
Suspended 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,
Suspended: suspended,
} }
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
suspended, err := app.db.IsUserSuspended(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
Suspended 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),
Suspended: suspended,
} }
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
} }
suspended, err := app.db.IsUserSuspended(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
Suspended bool
}{ }{
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
Collection: c, Collection: c,
Suspended: suspended,
} }
showUserPage(w, "collection", obj) showUserPage(w, "collection", obj)
@ -822,7 +838,7 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
} }
func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error { func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
var s userSettings var s userSettings
var u *User var u *User
@ -976,17 +992,24 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
titleStats = c.DisplayTitle() + " " titleStats = c.DisplayTitle() + " "
} }
suspended, err := app.db.IsUserSuspended(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
Suspended 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,
Suspended: suspended,
} }
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
Suspended 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",
Suspended: fullUser.IsSilenced(),
} }
showUserPage(w, "settings", obj) showUserPage(w, "settings", obj)
@ -1085,3 +1110,16 @@ func handleUserDelete(app *App, u *User, w http.ResponseWriter, r *http.Request)
_ = addSessionFlash(app, w, r, "Account deleted successfully, sorry to see you go.", nil) _ = addSessionFlash(app, w, r, "Account deleted successfully, sorry to see you go.", nil)
return impart.HTTPError{http.StatusFound, "/me/logout"} return impart.HTTPError{http.StatusFound, "/me/logout"}
} }
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
}

195
account_import.go Normal file
View file

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

View file

@ -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 {
@ -80,10 +83,19 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
if err != nil { if err != nil {
return err return err
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID)
if err != nil {
log.Error("fetch collection activities: %v", err)
return ErrInternalGeneral
}
if suspended {
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 +117,14 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
if err != nil { if err != nil {
return err return err
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID)
if err != nil {
log.Error("fetch collection outbox: %v", err)
return ErrInternalGeneral
}
if suspended {
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 +152,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 +179,14 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req
if err != nil { if err != nil {
return err return err
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID)
if err != nil {
log.Error("fetch collection followers: %v", err)
return ErrInternalGeneral
}
if suspended {
return ErrCollectionNotFound
}
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
accountRoot := c.FederatedAccount() accountRoot := c.FederatedAccount()
@ -183,6 +212,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 +234,14 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req
if err != nil { if err != nil {
return err return err
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID)
if err != nil {
log.Error("fetch collection following: %v", err)
return ErrInternalGeneral
}
if suspended {
return ErrCollectionNotFound
}
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
accountRoot := c.FederatedAccount() accountRoot := c.FederatedAccount()
@ -219,6 +257,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 +277,14 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
// TODO: return Reject? // TODO: return Reject?
return err return err
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID)
if err != nil {
log.Error("fetch collection inbox: %v", err)
return ErrInternalGeneral
}
if suspended {
return ErrCollectionNotFound
}
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
if debugging { if debugging {
@ -375,12 +422,12 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
// Add follower locally, since it wasn't found before // Add follower locally, since it wasn't found before
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox) res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox)
if err != nil { if err != nil {
if !app.db.isDuplicateKeyErr(err) { // if duplicate key, res will be nil and panic on
// res.LastInsertId below
t.Rollback() t.Rollback()
log.Error("Couldn't add new remoteuser in DB: %v\n", err) log.Error("Couldn't add new remoteuser in DB: %v\n", err)
return return
} }
}
followerID, err = res.LastInsertId() followerID, err = res.LastInsertId()
if err != nil { if err != nil {
@ -524,7 +571,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 +617,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 +635,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 +661,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 +708,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 +797,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()))
}

View file

@ -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"
) )
@ -173,8 +175,9 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
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"),
@ -184,7 +187,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)
@ -230,6 +245,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 suspended: %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

12
app.go
View file

@ -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
timeline *localTimeline timeline *localTimeline
@ -101,6 +101,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").
// //

View file

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

View file

@ -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 Suspended bool
} }
SubmittedCollection struct { SubmittedCollection struct {
// Data used for updating a given collection // Data used for updating a given collection
@ -338,7 +339,7 @@ func (c *Collection) RenderMathJax() bool {
} }
func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
alias := r.FormValue("alias") alias := r.FormValue("alias")
title := r.FormValue("title") title := r.FormValue("title")
@ -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
} }
suspended, err := app.db.IsUserSuspended(userID)
if err != nil {
log.Error("new collection: %v", err)
return ErrInternalGeneral
}
if suspended {
return ErrUserSuspended
}
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."}
@ -454,7 +464,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
// Redirect users who aren't requesting JSON // Redirect users who aren't requesting JSON
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
if !reqJSON { if !reqJSON {
return impart.HTTPError{http.StatusFound, c.CanonicalURL()} return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
} }
@ -477,6 +487,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
res.Owner = u res.Owner = u
} }
} }
// TODO: check suspended
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.IsUserSuspended(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
suspended, err := app.db.IsUserSuspended(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 && suspended {
return ErrCollectionNotFound
}
displayPage.Suspended = isOwner && suspended
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.Suspended = owner != nil && owner.IsSilenced()
displayPage.Owner = owner displayPage.Owner = owner
coll.Owner = displayPage.Owner coll.Owner = displayPage.Owner
// Add more data // Add more data
@ -919,16 +973,15 @@ func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Reque
} }
func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error { func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
vars := mux.Vars(r) vars := mux.Vars(r)
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
} }
} }
suspended, err := app.db.IsUserSuspended(u.ID)
if err != nil {
log.Error("existing collection: %v", err)
return ErrInternalGeneral
}
if suspended {
return ErrUserSuspended
}
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

View file

@ -42,6 +42,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:"-"`
} }
@ -56,6 +58,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"`
@ -101,6 +121,8 @@ type (
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"`
} }
) )

View file

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

View file

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

View file

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

View file

@ -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"
@ -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
} }
// IsUserSuspended returns true if the user account associated with id is
// currently suspended.
func (db *datastore) IsUserSuspended(id int64) (bool, error) {
u := &User{ID: id}
err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status)
switch {
case err == sql.ErrNoRows:
return false, fmt.Errorf("is user suspended: %v", ErrUserNotFound)
case err != nil:
log.Error("Couldn't SELECT user password: %v", err)
return false, fmt.Errorf("is user suspended: %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)
} }
@ -2149,16 +2185,6 @@ func (db *datastore) DeleteAccount(userID int64) error {
rs, _ = res.RowsAffected() rs, _ = res.RowsAffected()
log.Info("Deleted %d for %s from collectionkeys", rs, c.Alias) log.Info("Deleted %d for %s from collectionkeys", rs, c.Alias)
// Float all collection's posts
res, err = t.Exec("UPDATE posts SET collection_id = NULL WHERE collection_id = ? AND owner_id = ?", c.ID, userID)
if err != nil {
t.Rollback()
log.Error("Unable to update collection %s for posts: %v", c.Alias, err)
return err
}
rs, _ = res.RowsAffected()
log.Info("Removed %d posts from collection %s", rs, c.Alias)
// TODO: federate delete collection // TODO: federate delete collection
// Remove remote follows // Remove remote follows
@ -2192,6 +2218,16 @@ func (db *datastore) DeleteAccount(userID int64) error {
rs, _ = res.RowsAffected() rs, _ = res.RowsAffected()
log.Info("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 // TODO: should maybe get each row so we can federate a delete
// if so needs to be outside of transaction like collections // if so needs to be outside of transaction like collections
@ -2293,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)
@ -2405,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
@ -2452,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)
@ -2465,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.
@ -2495,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
}

50
database_test.go Normal file
View file

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

52
db/alter.go Normal file
View file

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

56
db/alter_test.go Normal file
View file

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

244
db/create.go Normal file
View file

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

146
db/create_test.go Normal file
View file

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

76
db/dialect.go Normal file
View file

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

53
db/index.go Normal file
View file

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

9
db/raw.go Normal file
View file

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

26
db/tx.go Normal file
View file

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

View file

@ -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
@ -45,7 +46,10 @@ var (
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."}
ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."}
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
ErrUserSuspended = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
) )
// Post operation errors // Post operation errors

View file

@ -20,7 +20,7 @@ import (
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
) )
func exportPostsCSV(u *User, posts *[]PublicPost) []byte { func exportPostsCSV(hostName string, u *User, posts *[]PublicPost) []byte {
var b bytes.Buffer var b bytes.Buffer
r := [][]string{ r := [][]string{
@ -30,8 +30,9 @@ func exportPostsCSV(u *User, posts *[]PublicPost) []byte {
var blog string var blog string
if p.Collection != nil { if p.Collection != nil {
blog = p.Collection.Alias blog = p.Collection.Alias
p.Collection.hostName = hostName
} }
f := []string{p.ID, p.Slug.String, blog, p.CanonicalURL(), p.Created8601(), p.Title.String, strings.Replace(p.Content, "\n", "\\n", -1)} f := []string{p.ID, p.Slug.String, blog, p.CanonicalURL(hostName), p.Created8601(), p.Title.String, strings.Replace(p.Content, "\n", "\\n", -1)}
r = append(r, f) r = append(r, f)
} }

14
feed.go
View file

@ -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
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID)
if err != nil {
log.Error("view feed: get user: %v", err)
return ErrInternalGeneral
}
if suspended {
return ErrCollectionNotFound
}
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
if c.IsPrivate() || c.IsProtected() { if c.IsPrivate() || c.IsProtected() {

20
go.mod
View file

@ -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,21 +35,21 @@ 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-20190208162236-193df9c0f06f
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
@ -55,6 +59,8 @@ require (
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

41
go.sum
View file

@ -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,30 +126,46 @@ 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-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg= golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg=
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
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=
@ -170,3 +197,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=

View file

@ -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 {
@ -772,13 +803,32 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error)
return return
} }
if IsJSON(r.Header.Get("Content-Type")) { if IsJSON(r) {
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."}) impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
return return
} }
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 == "" {

View file

@ -57,11 +57,18 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req
p := struct { p := struct {
*UserPage *UserPage
Invites *[]Invite Invites *[]Invite
Suspended bool
}{ }{
UserPage: NewUserPage(app, r, u, "Invite People", f), UserPage: NewUserPage(app, r, u, "Invite People", f),
} }
var err error var err error
p.Suspended, err = app.db.IsUserSuspended(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 ErrUserSuspended
}
var err error var err error
var maxUses int var maxUses int
if muVal != "0" { if muVal != "0" {

View file

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

View file

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

153
main_test.go Normal file
View file

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

View file

@ -13,6 +13,7 @@ package migrations
import ( import (
"database/sql" "database/sql"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
) )
@ -57,6 +58,10 @@ 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

29
migrations/v3.go Normal file
View file

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

46
migrations/v4.go Normal file
View file

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

67
migrations/v5.go Normal file
View file

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

29
migrations/v6.go Normal file
View file

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

291
oauth.go Normal file
View file

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

10
oauth/state.go Normal file
View file

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

218
oauth_signup.go Normal file
View file

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

180
oauth_slack.go Normal file
View file

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

253
oauth_test.go Normal file
View file

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

114
oauth_writeas.go Normal file
View file

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

15
pad.go
View file

@ -38,6 +38,7 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
Post *RawPost Post *RawPost
User *User User *User
Blogs *[]Collection Blogs *[]Collection
Suspended 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.Suspended, err = app.db.IsUserSuspended(appData.User.ID)
if err != nil {
log.Error("Unable to get users suspension status for Pad: %v", err)
}
} }
padTmpl := app.cfg.App.Editor padTmpl := app.cfg.App.Editor
if templates[padTmpl] == nil { if templates[padTmpl] == nil {
if padTmpl != "" {
log.Info("No template '%s' found. Falling back to default 'pad' template.", 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
Suspended 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.Suspended, err = app.db.IsUserSuspended(appData.User.ID)
if err != nil {
log.Error("view meta: get user suspended 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)

View file

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

174
pages/signup-oauth.tmpl Normal file
View file

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

View file

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

149
posts.go
View file

@ -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 suspended bool
if found {
suspended, err = app.db.IsUserSuspended(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
Suspended 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 && suspended {
return ErrPostNotFound
}
page.Suspended = suspended
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)
@ -472,7 +488,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
// /posts?collection={alias} // /posts?collection={alias}
// ? /collections/{alias}/posts // ? /collections/{alias}/posts
func newPost(app *App, w http.ResponseWriter, r *http.Request) error { func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
vars := mux.Vars(r) vars := mux.Vars(r)
collAlias := vars["alias"] collAlias := vars["alias"]
if collAlias == "" { if collAlias == "" {
@ -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)
} }
suspended, err := app.db.IsUserSuspended(userID)
if err != nil {
log.Error("new post: %v", err)
}
if suspended {
return ErrUserSuspended
}
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 {
@ -598,7 +621,7 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
} }
func existingPost(app *App, w http.ResponseWriter, r *http.Request) error { func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
vars := mux.Vars(r) vars := mux.Vars(r)
postID := vars["post"] postID := vars["post"]
@ -663,6 +686,14 @@ func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
} }
} }
suspended, err := app.db.IsUserSuspended(userID)
if err != nil {
log.Error("existing post: %v", err)
}
if suspended {
return ErrUserSuspended
}
// 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
} }
suspended, err := app.db.IsUserSuspended(ownerID)
if err != nil {
log.Error("add post: %v", err)
}
if suspended {
return ErrUserSuspended
}
// 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
} }
suspended, err := app.db.IsUserSuspended(userID)
if err != nil {
log.Error("pin post: %v", err)
}
if suspended {
return ErrUserSuspended
}
// 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,11 +1049,6 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return err return err
} }
coll.hostName = app.cfg.App.Host
_, err = apiCheckCollectionPermissions(app, r, coll)
if err != nil {
return err
}
collID = coll.ID collID = coll.ID
} }
@ -1014,18 +1056,33 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return err return err
} }
p.extractData()
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/activity+json") {
// Fetch information about the collection this belongs to
if coll == nil && p.CollectionID.Valid { 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) coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
if err != nil { if err != nil {
return err return err
} }
} }
if coll != nil {
coll.hostName = app.cfg.App.Host
_, err = apiCheckCollectionPermissions(app, r, coll)
if err != nil {
return err
}
}
suspended, err := app.db.IsUserSuspended(p.OwnerID.Int64)
if err != nil {
log.Error("fetch post: %v", err)
}
if suspended {
return ErrPostNotFound
}
p.extractData()
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/activity+json") {
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)
} }
@ -1061,18 +1119,19 @@ func (p *Post) processPost() PublicPost {
return *res return *res
} }
func (p *PublicPost) CanonicalURL() string { func (p *PublicPost) CanonicalURL(hostName string) string {
if p.Collection == nil || p.Collection.Alias == "" { if p.Collection == nil || p.Collection.Alias == "" {
return p.Collection.hostName + "/" + p.ID return hostName + "/" + p.ID
} }
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
o.URL = p.CanonicalURL() o.URL = p.CanonicalURL(cfg.App.Host)
o.AttributedTo = p.Collection.FederatedAccount() o.AttributedTo = p.Collection.FederatedAccount()
o.CC = []string{ o.CC = []string{
p.Collection.FederatedAccount() + "/followers", p.Collection.FederatedAccount() + "/followers",
@ -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,13 +1355,22 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
} }
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
suspended, err := app.db.IsUserSuspended(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) {
if suspended {
return ErrPostNotFound
} else if !isAuthorizedForCollection(app, c.Alias, r) {
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug} 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 && suspended {
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
Suspended 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,
Suspended: suspended,
} }
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)

16
read.go
View file

@ -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)
@ -293,7 +295,7 @@ func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) e
} }
title = p.PlainDisplayTitle() title = p.PlainDisplayTitle()
permalink = p.CanonicalURL() permalink = p.CanonicalURL(app.cfg.App.Host)
if p.Collection != nil { if p.Collection != nil {
author = p.Collection.Title author = p.Collection.Title
} else { } else {

View file

@ -10,9 +10,13 @@
package writefreely package writefreely
import "mime" import (
"mime"
"net/http"
)
func IsJSON(h string) bool { func IsJSON(r *http.Request) bool {
ct, _, _ := mime.ParseMediaType(h) ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
return ct == "application/json" accept := r.Header.Get("Accept")
return ct == "application/json" || accept == "application/json"
} }

View file

@ -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()
@ -95,6 +101,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")
@ -107,10 +114,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()
@ -145,6 +155,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
} }

View file

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

16
static/js/localdate.js Normal file
View file

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

View file

@ -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", "suspended.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", "suspended.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", "suspended.tmpl"),
)) ))
} }

View file

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

View file

@ -8,7 +8,7 @@
<link rel="stylesheet" type="text/css" href="/css/write.css" /> <link rel="stylesheet" type="text/css" href="/css/write.css" />
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="canonical" href="{{.CanonicalURL}}" /> <link rel="canonical" href="{{.CanonicalURL .Host}}" />
<meta name="generator" content="WriteFreely"> <meta name="generator" content="WriteFreely">
<meta name="title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}"> <meta name="title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}">
<meta name="description" content="{{.Summary}}"> <meta name="description" content="{{.Summary}}">
@ -25,7 +25,7 @@
<meta property="og:description" content="{{.Summary}}" /> <meta property="og:description" content="{{.Summary}}" />
<meta property="og:site_name" content="{{.Collection.DisplayTitle}}" /> <meta property="og:site_name" content="{{.Collection.DisplayTitle}}" />
<meta property="og:type" content="article" /> <meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}" /> <meta property="og:url" content="{{.CanonicalURL .Host}}" />
<meta property="og:updated_time" content="{{.Created8601}}" /> <meta property="og:updated_time" content="{{.Created8601}}" />
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}} {{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}}
<meta property="article:published_time" content="{{.Created8601}}"> <meta property="article:published_time" content="{{.Created8601}}">
@ -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 .Suspended}}
{{template "user-suspended"}}
{{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}}{{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;

View file

@ -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 .Suspended}}
{{template "user-suspended"}}
{{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}}{{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;

View file

@ -9,7 +9,7 @@
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
{{ if .IsFound }} {{ if .IsFound }}
<link rel="canonical" href="{{.CanonicalURL}}" /> <link rel="canonical" href="{{.CanonicalURL .Host}}" />
<meta name="generator" content="WriteFreely"> <meta name="generator" content="WriteFreely">
<meta name="title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}"> <meta name="title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}">
<meta name="description" content="{{.Summary}}"> <meta name="description" content="{{.Summary}}">
@ -26,7 +26,7 @@
<meta property="og:description" content="{{.Summary}}" /> <meta property="og:description" content="{{.Summary}}" />
<meta property="og:site_name" content="{{.Collection.DisplayTitle}}" /> <meta property="og:site_name" content="{{.Collection.DisplayTitle}}" />
<meta property="og:type" content="article" /> <meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}" /> <meta property="og:url" content="{{.CanonicalURL .Host}}" />
<meta property="og:updated_time" content="{{.Created8601}}" /> <meta property="og:updated_time" content="{{.Created8601}}" />
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}} {{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}}
<meta property="article:published_time" content="{{.Created8601}}"> <meta property="article:published_time" content="{{.Created8601}}">
@ -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}}{{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 .Suspended}}
{{template "user-suspended"}}
{{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;

View file

@ -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}}{{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 .Suspended}}
{{template "user-suspended"}}
{{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>

View file

@ -62,13 +62,16 @@
</ul></nav>{{end}} </ul></nav>{{end}}
<header> <header>
{{if .Suspended}}
{{template "user-suspended"}}
{{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}}{{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) {

View file

@ -269,6 +269,10 @@
<script src="/js/h.js"></script> <script src="/js/h.js"></script>
<script> <script>
function updateMeta() { function updateMeta() {
if ({{.Suspended}}) {
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();

View file

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

View file

@ -25,10 +25,10 @@
{{else}}<li><a id="publish-to"><span id="target-name">Draft</span> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a> {{else}}<li><a id="publish-to"><span id="target-name">Draft</span> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a>
<ul> <ul>
<li class="menu-heading">Publish to...</li> <li class="menu-heading">Publish to...</li>
<li class="target selected" id="anonymous"><a href="#anonymous"><i class="material-icons md-18">description</i> <em>Draft</em></a></li> {{if .Blogs}}{{range $idx, $el := .Blogs}}
{{if .Blogs}}{{range .Blogs}} <li class="target{{if eq $idx 0}} selected{{end}}" id="blog-{{$el.Alias}}"><a href="#{{$el.Alias}}"><i class="material-icons md-18">public</i> {{if $el.Title}}{{$el.Title}}{{else}}{{$el.Alias}}{{end}}</a></li>
<li class="target" id="blog-{{.Alias}}"><a href="#{{.Alias}}"><i class="material-icons md-18">public</i> {{if .Title}}{{.Title}}{{else}}{{.Alias}}{{end}}</a></li>
{{end}}{{end}} {{end}}{{end}}
<li class="target" id="blog-anonymous"><a href="#anonymous"><i class="material-icons md-18">description</i> <em>Draft</em></a></li>
<li id="user-separator" class="separator"><hr /></li> <li id="user-separator" class="separator"><hr /></li>
{{ if .SingleUser }} {{ if .SingleUser }}
<li><a href="/"><i class="material-icons md-18">launch</i> View Blog</a></li> <li><a href="/"><i class="material-icons md-18">launch</i> View Blog</a></li>
@ -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 suspended = {{.Suspended}};
var publish = function(content, font) { var publish = function(content, font) {
if (suspended === 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.");
@ -278,7 +282,7 @@
document.getElementById('target-name').innerText = newText.join(' '); document.getElementById('target-name').innerText = newText.join(' ');
}); });
} }
var postTarget = H.get('postTarget', 'anonymous'); var postTarget = H.get('postTarget', '{{if .Blogs}}{{$blog := index .Blogs 0}}{{$blog.Alias}}{{else}}anonymous{{end}}');
if (location.hash != '') { if (location.hash != '') {
postTarget = location.hash.substring(1); postTarget = location.hash.substring(1);
// TODO: pushState to /pad (or whatever the URL is) so we live on a clean URL // TODO: pushState to /pad (or whatever the URL is) so we live on a clean URL

View file

@ -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>
@ -50,6 +49,10 @@
</nav> </nav>
</header> </header>
{{if .Suspended}}
{{template "user-suspended"}}
{{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>
<footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language}}</p></nav></footer> <footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language}}</p></nav></footer>

View file

@ -87,17 +87,17 @@
{{ if gt (len .Posts) 0 }} {{ if gt (len .Posts) 0 }}
<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}}.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}}.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>
<a class="read-more" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div class="e-content preview" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{ if not .HTMLContent }}<p id="post-body" class="e-content preview">{{.Content}}</p>{{ else }}{{.HTMLContent}}{{ end }}<div class="over">&nbsp;</div></div> <a class="read-more" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div class="e-content preview" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{ if not .HTMLContent }}<p id="post-body" class="e-content preview">{{.Content}}</p>{{ else }}{{.HTMLContent}}{{ end }}<div class="over">&nbsp;</div></div>
<a class="read-more maybe" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{end}}</article> <a class="read-more maybe" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{end}}</article>
{{end}} {{end}}
</section> </section>
{{ else }} {{ else }}
@ -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');

View file

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

View file

@ -7,12 +7,43 @@ table.classy th {
h3 { h3 {
font-weight: normal; font-weight: normal;
} }
td.active-suspend {
display: flex;
align-items: center;
}
td.active-suspend > input[type="submit"] {
margin-left: auto;
margin-right: 5%;
}
@media only screen and (max-width: 500px) {
td.active-suspend {
flex-wrap: wrap;
}
td.active-suspend > 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-suspend">
{{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}}

View file

@ -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 .Suspended}}
{{template "user-suspended"}}
{{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>

View file

@ -8,6 +8,9 @@
<div class="content-container snug"> <div class="content-container snug">
<div id="overlay"></div> <div id="overlay"></div>
{{if .Suspended}}
{{template "user-suspended"}}
{{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">

View file

@ -7,6 +7,9 @@
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}} </ul>{{end}}
{{if .Suspended}}
{{template "user-suspended"}}
{{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>

View file

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

View file

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

View file

@ -0,0 +1,5 @@
{{define "user-suspended"}}
<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}}

View file

@ -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 .Suspended}}
{{template "user-suspended"}}
{{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 .Suspended}}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 .Suspended}}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 .Suspended}}disabled title="You cannot generate invites while your account is silenced."{{end}} />
</div> </div>
</form> </form>

View file

@ -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 .Suspended}}
{{template "user-suspended"}}
{{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}}

View file

@ -17,6 +17,9 @@ td.none {
</style> </style>
<div class="content-container snug"> <div class="content-container snug">
{{if .Suspended}}
{{template "user-suspended"}}
{{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>

View file

@ -13,13 +13,14 @@ package writefreely
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"net/http"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"net/http"
) )
func handleWebSignup(app *App, w http.ResponseWriter, r *http.Request) error { func handleWebSignup(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
// Get params // Get params
var ur userRegistration var ur userRegistration
@ -71,7 +72,7 @@ func handleWebSignup(app *App, w http.ResponseWriter, r *http.Request) error {
// { "username": "asdf" } // { "username": "asdf" }
// result: { code: 204 } // result: { code: 204 }
func handleUsernameCheck(app *App, w http.ResponseWriter, r *http.Request) error { func handleUsernameCheck(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type")) reqJSON := IsJSON(r)
// Get params // Get params
var d struct { var d struct {

View file

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

View file

@ -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
} }
suspended, err := wfr.db.IsUserSuspended(c.OwnerID)
if err != nil {
log.Error("webfinger find user: check is suspended: %v", err)
return nil, err
}
if suspended {
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
}