diff --git a/README.md b/README.md index 4f0b6bb..68da89b 100644 --- a/README.md +++ b/README.md @@ -47,15 +47,15 @@ It's designed to be flexible and share your writing widely, so it's built around ## 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 diff --git a/account.go b/account.go index 5fb87f0..c41f24d 100644 --- a/account.go +++ b/account.go @@ -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) { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) // Get params 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) { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) // Validate required params (alias) if signup.Alias == "" { @@ -377,7 +377,7 @@ func webLogin(app *App, w http.ResponseWriter, r *http.Request) error { var loginAttemptUsers = sync.Map{} 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") verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "") @@ -580,7 +580,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) { var filename string var u = &User{} - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) if reqJSON { // Use given Authorization header accessToken := r.Header.Get("Authorization") @@ -625,7 +625,7 @@ func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, // Export as CSV if strings.HasSuffix(r.URL.Path, ".csv") { - data = exportPostsCSV(u, posts) + data = exportPostsCSV(app.cfg.App.Host, u, posts) return data, filename, err } if strings.HasSuffix(r.URL.Path, ".zip") { @@ -662,7 +662,7 @@ func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, s } func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) uObj := struct { ID int64 `json:"id,omitempty"` Username string `json:"username,omitempty"` @@ -686,7 +686,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 { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) if !reqJSON { return ErrBadRequestedType } @@ -717,7 +717,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 { - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) if !reqJSON { return ErrBadRequestedType } @@ -842,7 +842,7 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques } 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 u *User diff --git a/activitypub.go b/activitypub.go index eeaa1fa..a18a636 100644 --- a/activitypub.go +++ b/activitypub.go @@ -415,11 +415,11 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request // 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) if err != nil { - if !app.db.isDuplicateKeyErr(err) { - t.Rollback() - log.Error("Couldn't add new remoteuser in DB: %v\n", err) - return - } + // if duplicate key, res will be nil and panic on + // res.LastInsertId below + t.Rollback() + log.Error("Couldn't add new remoteuser in DB: %v\n", err) + return } followerID, err = res.LastInsertId() diff --git a/admin.go b/admin.go index e624bfb..ebb4225 100644 --- a/admin.go +++ b/admin.go @@ -16,12 +16,14 @@ import ( "net/http" "runtime" "strconv" + "strings" "time" "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/log" + "github.com/writeas/web-core/passgen" "github.com/writeas/writefreely/appstats" "github.com/writeas/writefreely/config" ) @@ -170,11 +172,12 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque Config config.AppCfg Message string - User *User - Colls []inspectedCollection - LastPost string - - TotalPosts int64 + User *User + Colls []inspectedCollection + LastPost string + NewPassword string + TotalPosts int64 + ClearEmail string }{ Config: app.cfg.App, Message: r.FormValue("m"), @@ -186,6 +189,14 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)} } + + 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.TotalPosts = app.db.GetUserPostsCount(p.User.ID) lp, err := app.db.GetUserLastPostTime(p.User.ID) @@ -254,6 +265,38 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht 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 { p := struct { *UserPage diff --git a/app.go b/app.go index 514c7c8..9b01bc6 100644 --- a/app.go +++ b/app.go @@ -56,7 +56,7 @@ var ( debugging bool // Software version can be set from git env using -ldflags - softwareVer = "0.10.0" + softwareVer = "0.11.0" // DEPRECATED VARS isSingleUser bool diff --git a/collections.go b/collections.go index 3e86f30..54ddc8a 100644 --- a/collections.go +++ b/collections.go @@ -339,7 +339,7 @@ func (c *Collection) RenderMathJax() bool { } 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") title := r.FormValue("title") @@ -464,7 +464,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error { c.hostName = app.cfg.App.Host // Redirect users who aren't requesting JSON - reqJSON := IsJSON(r.Header.Get("Content-Type")) + reqJSON := IsJSON(r) if !reqJSON { return impart.HTTPError{http.StatusFound, c.CanonicalURL()} } @@ -943,7 +943,7 @@ func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Reque } 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) collAlias := vars["alias"] isWeb := r.FormValue("web") == "1" diff --git a/export.go b/export.go index 3b5ac49..592bc0c 100644 --- a/export.go +++ b/export.go @@ -20,7 +20,7 @@ import ( "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 r := [][]string{ @@ -30,8 +30,9 @@ func exportPostsCSV(u *User, posts *[]PublicPost) []byte { var blog string if p.Collection != nil { 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) } diff --git a/go.mod b/go.mod index 9c67aeb..88af8c9 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/writeas/openssl-go v1.0.0 // indirect github.com/writeas/saturday v1.7.1 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 golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect diff --git a/go.sum b/go.sum index ec1e19d..b256223 100644 --- a/go.sum +++ b/go.sum @@ -135,6 +135,8 @@ github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g= github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ= github.com/writeas/web-core v1.0.0 h1:5VKkCakQgdKZcbfVKJXtRpc5VHrkflusCl/KRCPzpQ0= github.com/writeas/web-core v1.0.0/go.mod h1:Si3chV7VWgY8CsV+3gRolMXSO2Vx1ZFAQ/mkrpvmyEE= +github.com/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0= +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/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo= diff --git a/handle.go b/handle.go index 99c23ae..7e410f5 100644 --- a/handle.go +++ b/handle.go @@ -772,7 +772,7 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error) 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."}) return } diff --git a/posts.go b/posts.go index 6974a4f..2a6fe74 100644 --- a/posts.go +++ b/posts.go @@ -483,7 +483,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { // /posts?collection={alias} // ? /collections/{alias}/posts 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) collAlias := vars["alias"] if collAlias == "" { @@ -617,7 +617,7 @@ func newPost(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) postID := vars["post"] @@ -1118,9 +1118,9 @@ func (p *Post) processPost() PublicPost { return *res } -func (p *PublicPost) CanonicalURL() string { +func (p *PublicPost) CanonicalURL(hostName string) string { if p.Collection == nil || p.Collection.Alias == "" { - return p.Collection.hostName + "/" + p.ID + return hostName + "/" + p.ID } return p.Collection.CanonicalURL() + p.Slug.String } @@ -1129,7 +1129,7 @@ func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object o := activitystreams.NewArticleObject() o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID o.Published = p.Created - o.URL = p.CanonicalURL() + o.URL = p.CanonicalURL(cfg.App.Host) o.AttributedTo = p.Collection.FederatedAccount() o.CC = []string{ p.Collection.FederatedAccount() + "/followers", diff --git a/read.go b/read.go index 6d0c8a7..d708121 100644 --- a/read.go +++ b/read.go @@ -295,7 +295,7 @@ func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) e } title = p.PlainDisplayTitle() - permalink = p.CanonicalURL() + permalink = p.CanonicalURL(app.cfg.App.Host) if p.Collection != nil { author = p.Collection.Title } else { diff --git a/request.go b/request.go index 4939f9c..2eb29f5 100644 --- a/request.go +++ b/request.go @@ -10,9 +10,13 @@ package writefreely -import "mime" +import ( + "mime" + "net/http" +) -func IsJSON(h string) bool { - ct, _, _ := mime.ParseMediaType(h) - return ct == "application/json" +func IsJSON(r *http.Request) bool { + ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + accept := r.Header.Get("Accept") + return ct == "application/json" || accept == "application/json" } diff --git a/routes.go b/routes.go index 1ff250f..64c6c0f 100644 --- a/routes.go +++ b/routes.go @@ -145,6 +145,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).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/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET") write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST") diff --git a/templates/chorus-collection-post.tmpl b/templates/chorus-collection-post.tmpl index 58e514f..a6b6102 100644 --- a/templates/chorus-collection-post.tmpl +++ b/templates/chorus-collection-post.tmpl @@ -8,7 +8,7 @@ - + @@ -25,7 +25,7 @@ - + {{range .Images}}{{else}}{{end}} @@ -80,7 +80,7 @@ article time.dt-published {


diff --git a/templates/chorus-collection.tmpl b/templates/chorus-collection.tmpl index 5f4d418..504e54f 100644 --- a/templates/chorus-collection.tmpl +++ b/templates/chorus-collection.tmpl @@ -71,7 +71,7 @@ body#collection header nav.tabs a:first-child { {{/*end*/}} {{if .PinnedPosts}} + {{range .PinnedPosts}}{{.PlainDisplayTitle}}{{end}} {{end}} diff --git a/templates/collection-post.tmpl b/templates/collection-post.tmpl index 4398804..e24c6dd 100644 --- a/templates/collection-post.tmpl +++ b/templates/collection-post.tmpl @@ -9,7 +9,7 @@ {{ if .IsFound }} - + @@ -26,7 +26,7 @@ - + {{range .Images}}{{else}}{{end}} @@ -50,7 +50,7 @@

{{end}} diff --git a/templates/pad.tmpl b/templates/pad.tmpl index 0b73b94..a8fca98 100644 --- a/templates/pad.tmpl +++ b/templates/pad.tmpl @@ -25,10 +25,10 @@ {{else}}
  • Draft