Merge branch 'develop' into import-text

This commit is contained in:
Matt Baer 2019-11-26 17:41:29 -05:00
commit af6e5dea3a
62 changed files with 1847 additions and 363 deletions

View file

@ -7,6 +7,7 @@ GOBUILD=$(GOCMD) build $(LDFLAGS)
GOTEST=$(GOCMD) test $(LDFLAGS) GOTEST=$(GOCMD) test $(LDFLAGS)
GOGET=$(GOCMD) get GOGET=$(GOCMD) get
BINARY_NAME=writefreely BINARY_NAME=writefreely
BUILDPATH=build/$(BINARY_NAME)
DOCKERCMD=docker DOCKERCMD=docker
IMAGE_NAME=writeas/writefreely IMAGE_NAME=writeas/writefreely
TMPBIN=./tmp TMPBIN=./tmp
@ -69,39 +70,40 @@ install : build
cd less/; $(MAKE) install $(MFLAGS) cd less/; $(MAKE) install $(MFLAGS)
release : clean ui assets release : clean ui assets
mkdir build mkdir -p $(BUILDPATH)
cp -r templates build cp -r templates $(BUILDPATH)
cp -r pages build cp -r pages $(BUILDPATH)
cp -r static build cp -r static $(BUILDPATH)
mkdir build/keys mkdir $(BUILDPATH)/keys
$(MAKE) build-linux $(MAKE) build-linux
mv build/$(BINARY_NAME)-linux-amd64 build/$(BINARY_NAME) mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz * tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
rm build/$(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-arm7 $(MAKE) build-arm7
mv build/$(BINARY_NAME)-linux-arm-7 build/$(BINARY_NAME) mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME)
cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz * tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME)
rm build/$(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-darwin $(MAKE) build-darwin
mv build/$(BINARY_NAME)-darwin-10.6-amd64 build/$(BINARY_NAME) mv build/$(BINARY_NAME)-darwin-10.6-amd64 $(BUILDPATH)/$(BINARY_NAME)
cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz * tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME)
rm build/$(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-windows $(MAKE) build-windows
mv build/$(BINARY_NAME)-windows-4.0-amd64.exe build/$(BINARY_NAME).exe mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe
cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./* cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME)
rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-docker $(MAKE) build-docker
$(MAKE) release-docker $(MAKE) release-docker
# This assumes you're on linux/amd64 # This assumes you're on linux/amd64
release-linux : clean ui release-linux : clean ui
mkdir build mkdir -p $(BUILDPATH)
cp -r templates build cp -r templates $(BUILDPATH)
cp -r pages build cp -r pages $(BUILDPATH)
cp -r static build cp -r static $(BUILDPATH)
mkdir build/keys mkdir $(BUILDPATH)/keys
$(MAKE) build-no-sqlite $(MAKE) build-no-sqlite
mv cmd/writefreely/$(BINARY_NAME) build/$(BINARY_NAME) mv cmd/writefreely/$(BINARY_NAME) $(BUILDPATH)/$(BINARY_NAME)
cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz * tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
release-docker : release-docker :
$(DOCKERCMD) push $(IMAGE_NAME) $(DOCKERCMD) push $(IMAGE_NAME)

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

@ -28,6 +28,7 @@ import (
"github.com/writeas/web-core/data" "github.com/writeas/web-core/data"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/author" "github.com/writeas/writefreely/author"
"github.com/writeas/writefreely/config"
"github.com/writeas/writefreely/page" "github.com/writeas/writefreely/page"
) )
@ -59,11 +60,15 @@ func NewUserPage(app *App, r *http.Request, u *User, title string, flashes []str
up.Flashes = flashes up.Flashes = flashes
up.Path = r.URL.Path up.Path = r.URL.Path
up.IsAdmin = u.IsAdmin() up.IsAdmin = u.IsAdmin()
up.CanInvite = app.cfg.App.UserInvites != "" && up.CanInvite = canUserInvite(app.cfg, up.IsAdmin)
(up.IsAdmin || app.cfg.App.UserInvites != "admin")
return up return up
} }
func canUserInvite(cfg *config.Config, isAdmin bool) bool {
return cfg.App.UserInvites != "" &&
(isAdmin || cfg.App.UserInvites != "admin")
}
func (up *UserPage) SetMessaging(u *User) { func (up *UserPage) SetMessaging(u *User) {
//up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID) //up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
} }
@ -80,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
@ -115,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 == "" {
@ -305,10 +310,10 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
p := &struct { p := &struct {
page.StaticPage page.StaticPage
To string To string
Message template.HTML Message template.HTML
Flashes []template.HTML Flashes []template.HTML
Username string LoginUsername string
}{ }{
pageForReq(app, r), pageForReq(app, r),
r.FormValue("to"), r.FormValue("to"),
@ -372,7 +377,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 != "")
@ -547,7 +552,7 @@ func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser
if err != nil { if err != nil {
log.Error("Login: Unable to get user posts: %v", err) log.Error("Login: Unable to get user posts: %v", err)
} }
colls, err := app.db.GetCollections(u) colls, err := app.db.GetCollections(u, app.cfg.App.Host)
if err != nil { if err != nil {
log.Error("Login: Unable to get user collections: %v", err) log.Error("Login: Unable to get user collections: %v", err)
} }
@ -575,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) { 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")
@ -620,7 +625,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") {
@ -657,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 { 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"`
@ -681,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 { 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
} }
@ -712,12 +717,12 @@ 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
} }
p, err := app.db.GetCollections(u) p, err := app.db.GetCollections(u, app.cfg.App.Host)
if err != nil { if err != nil {
return err return err
} }
@ -740,19 +745,25 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
log.Error("unable to fetch flashes: %v", err) log.Error("unable to fetch flashes: %v", err)
} }
c, err := app.db.GetPublishableCollections(u) c, err := app.db.GetPublishableCollections(u, app.cfg.App.Host)
if err != nil { if err != nil {
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")
@ -763,7 +774,7 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
} }
func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) error { func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
c, err := app.db.GetCollections(u) c, err := app.db.GetCollections(u, app.cfg.App.Host)
if err != nil { if err != nil {
log.Error("unable to fetch collections: %v", err) log.Error("unable to fetch collections: %v", err)
return fmt.Errorf("No collections") return fmt.Errorf("No collections")
@ -774,6 +785,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
@ -781,11 +797,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)
@ -803,13 +821,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)
@ -817,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 { 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
@ -971,17 +996,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)
@ -1012,14 +1044,16 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
obj := struct { obj := struct {
*UserPage *UserPage
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)

View file

@ -80,6 +80,14 @@ 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()
@ -105,6 +113,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 {
@ -129,10 +145,10 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p) ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p)
ocp.OrderedItems = []interface{}{} ocp.OrderedItems = []interface{}{}
posts, err := app.db.GetPosts(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() o := pp.ActivityObject(app.cfg)
a := activitystreams.NewCreateActivity(o) a := activitystreams.NewCreateActivity(o)
ocp.OrderedItems = append(ocp.OrderedItems, *a) ocp.OrderedItems = append(ocp.OrderedItems, *a)
} }
@ -158,6 +174,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()
@ -204,6 +228,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()
@ -238,6 +270,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,11 +415,11 @@ 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
t.Rollback() // res.LastInsertId below
log.Error("Couldn't add new remoteuser in DB: %v\n", err) t.Rollback()
return log.Error("Couldn't add new remoteuser in DB: %v\n", err)
} return
} }
followerID, err = res.LastInsertId() followerID, err = res.LastInsertId()
@ -524,7 +564,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() na := p.ActivityObject(app.cfg)
// Add followers // Add followers
p.Collection.ID = collID p.Collection.ID = collID
@ -570,7 +610,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() na := p.ActivityObject(app.cfg)
// Add followers // Add followers
p.Collection.ID = collID p.Collection.ID = collID

135
admin.go
View file

@ -13,16 +13,19 @@ package writefreely
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"github.com/gogits/gogs/pkg/tool" "net/http"
"runtime"
"strconv"
"strings"
"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/config" "github.com/writeas/writefreely/config"
"net/http"
"runtime"
"strconv"
"time"
) )
var ( var (
@ -169,11 +172,12 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
Config config.AppCfg Config config.AppCfg
Message string Message string
User *User User *User
Colls []inspectedCollection Colls []inspectedCollection
LastPost string LastPost string
NewPassword string
TotalPosts int64 TotalPosts int64
ClearEmail string
}{ }{
Config: app.cfg.App, Config: app.cfg.App,
Message: r.FormValue("m"), Message: r.FormValue("m"),
@ -185,6 +189,14 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
if err != nil { if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)} 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.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)
lp, err := app.db.GetUserLastPostTime(p.User.ID) lp, err := app.db.GetUserLastPostTime(p.User.ID)
@ -195,7 +207,7 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
p.LastPost = lp.Format("January 2, 2006, 3:04 PM") p.LastPost = lp.Format("January 2, 2006, 3:04 PM")
} }
colls, err := app.db.GetCollections(p.User) colls, err := app.db.GetCollections(p.User, app.cfg.App.Host)
if err != nil { if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)} return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)}
} }
@ -229,6 +241,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")}
}
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
@ -319,6 +387,8 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
} }
p.Content, err = getLandingBody(app) p.Content, err = getLandingBody(app)
p.Content.ID = "landing" p.Content.ID = "landing"
} else if slug == "reader" {
p.Content, err = getReaderSection(app)
} else { } else {
p.Content, err = app.db.GetDynamicContent(slug) p.Content, err = app.db.GetDynamicContent(slug)
} }
@ -342,7 +412,7 @@ func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Req
id := vars["page"] id := vars["page"]
// Validate // Validate
if id != "about" && id != "privacy" && id != "landing" { if id != "about" && id != "privacy" && id != "landing" && id != "reader" {
return impart.HTTPError{http.StatusNotFound, "No such page."} return impart.HTTPError{http.StatusNotFound, "No such page."}
} }
@ -356,6 +426,9 @@ func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Req
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m} return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
} }
err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section") err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section")
} else if id == "reader" {
// Update sections with titles
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "section")
} else { } else {
// Update page // Update page
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page") err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page")
@ -402,37 +475,37 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
} }
func updateAppStats() { func updateAppStats() {
sysStatus.Uptime = tool.TimeSincePro(appStartTime) sysStatus.Uptime = appstats.TimeSincePro(appStartTime)
m := new(runtime.MemStats) m := new(runtime.MemStats)
runtime.ReadMemStats(m) runtime.ReadMemStats(m)
sysStatus.NumGoroutine = runtime.NumGoroutine() sysStatus.NumGoroutine = runtime.NumGoroutine()
sysStatus.MemAllocated = tool.FileSize(int64(m.Alloc)) sysStatus.MemAllocated = appstats.FileSize(int64(m.Alloc))
sysStatus.MemTotal = tool.FileSize(int64(m.TotalAlloc)) sysStatus.MemTotal = appstats.FileSize(int64(m.TotalAlloc))
sysStatus.MemSys = tool.FileSize(int64(m.Sys)) sysStatus.MemSys = appstats.FileSize(int64(m.Sys))
sysStatus.Lookups = m.Lookups sysStatus.Lookups = m.Lookups
sysStatus.MemMallocs = m.Mallocs sysStatus.MemMallocs = m.Mallocs
sysStatus.MemFrees = m.Frees sysStatus.MemFrees = m.Frees
sysStatus.HeapAlloc = tool.FileSize(int64(m.HeapAlloc)) sysStatus.HeapAlloc = appstats.FileSize(int64(m.HeapAlloc))
sysStatus.HeapSys = tool.FileSize(int64(m.HeapSys)) sysStatus.HeapSys = appstats.FileSize(int64(m.HeapSys))
sysStatus.HeapIdle = tool.FileSize(int64(m.HeapIdle)) sysStatus.HeapIdle = appstats.FileSize(int64(m.HeapIdle))
sysStatus.HeapInuse = tool.FileSize(int64(m.HeapInuse)) sysStatus.HeapInuse = appstats.FileSize(int64(m.HeapInuse))
sysStatus.HeapReleased = tool.FileSize(int64(m.HeapReleased)) sysStatus.HeapReleased = appstats.FileSize(int64(m.HeapReleased))
sysStatus.HeapObjects = m.HeapObjects sysStatus.HeapObjects = m.HeapObjects
sysStatus.StackInuse = tool.FileSize(int64(m.StackInuse)) sysStatus.StackInuse = appstats.FileSize(int64(m.StackInuse))
sysStatus.StackSys = tool.FileSize(int64(m.StackSys)) sysStatus.StackSys = appstats.FileSize(int64(m.StackSys))
sysStatus.MSpanInuse = tool.FileSize(int64(m.MSpanInuse)) sysStatus.MSpanInuse = appstats.FileSize(int64(m.MSpanInuse))
sysStatus.MSpanSys = tool.FileSize(int64(m.MSpanSys)) sysStatus.MSpanSys = appstats.FileSize(int64(m.MSpanSys))
sysStatus.MCacheInuse = tool.FileSize(int64(m.MCacheInuse)) sysStatus.MCacheInuse = appstats.FileSize(int64(m.MCacheInuse))
sysStatus.MCacheSys = tool.FileSize(int64(m.MCacheSys)) sysStatus.MCacheSys = appstats.FileSize(int64(m.MCacheSys))
sysStatus.BuckHashSys = tool.FileSize(int64(m.BuckHashSys)) sysStatus.BuckHashSys = appstats.FileSize(int64(m.BuckHashSys))
sysStatus.GCSys = tool.FileSize(int64(m.GCSys)) sysStatus.GCSys = appstats.FileSize(int64(m.GCSys))
sysStatus.OtherSys = tool.FileSize(int64(m.OtherSys)) sysStatus.OtherSys = appstats.FileSize(int64(m.OtherSys))
sysStatus.NextGC = tool.FileSize(int64(m.NextGC)) sysStatus.NextGC = appstats.FileSize(int64(m.NextGC))
sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000) sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000)
sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000) sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000) sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)

36
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.1"
// DEPRECATED VARS // DEPRECATED VARS
isSingleUser bool isSingleUser bool
@ -185,8 +185,8 @@ func (app *App) ReqLog(r *http.Request, status int, timeSince time.Duration) str
return fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, timeSince, r.UserAgent()) return fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, timeSince, r.UserAgent())
} }
// handleViewHome shows page at root path. Will be the Pad if logged in and the // handleViewHome shows page at root path. It checks the configuration and
// catch-all landing page otherwise. // authentication state to show the correct page.
func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error { func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
if app.cfg.App.SingleUser { if app.cfg.App.SingleUser {
// Render blog index // Render blog index
@ -198,6 +198,15 @@ func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
if !forceLanding { if !forceLanding {
// Show correct page based on user auth status and configured landing path // Show correct page based on user auth status and configured landing path
u := getUserSession(app, r) u := getUserSession(app, r)
if app.cfg.App.Chorus {
// This instance is focused on reading, so show Reader on home route if not
// private or a private-instance user is logged in.
if !app.cfg.App.Private || u != nil {
return viewLocalTimeline(app, w, r)
}
}
if u != nil { if u != nil {
// User is logged in, so show the Pad // User is logged in, so show the Pad
return handleViewPad(app, w, r) return handleViewPad(app, w, r)
@ -208,6 +217,12 @@ func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
} }
} }
return handleViewLanding(app, w, r)
}
func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
forceLanding := r.FormValue("landing") == "1"
p := struct { p := struct {
page.StaticPage page.StaticPage
Flashes []template.HTML Flashes []template.HTML
@ -225,14 +240,14 @@ func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
log.Error("unable to get landing banner: %v", err) log.Error("unable to get landing banner: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)} return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
} }
p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), "")) p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), "", app.cfg))
content, err := getLandingBody(app) content, err := getLandingBody(app)
if err != nil { if err != nil {
log.Error("unable to get landing content: %v", err) log.Error("unable to get landing content: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)} return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)}
} }
p.Content = template.HTML(applyMarkdown([]byte(content.Content), "")) p.Content = template.HTML(applyMarkdown([]byte(content.Content), "", app.cfg))
// Get error messages // Get error messages
session, err := app.sessionStore.Get(r, cookieName) session, err := app.sessionStore.Get(r, cookieName)
@ -280,7 +295,7 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te
return err return err
} }
p.ContentTitle = c.Title.String p.ContentTitle = c.Title.String
p.Content = template.HTML(applyMarkdown([]byte(c.Content), "")) p.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg))
p.PlainContent = shortPostDescription(stripmd.Strip(c.Content)) p.PlainContent = shortPostDescription(stripmd.Strip(c.Content))
if !c.Updated.IsZero() { if !c.Updated.IsZero() {
p.Updated = c.Updated.Format("January 2, 2006") p.Updated = c.Updated.Format("January 2, 2006")
@ -318,6 +333,8 @@ func pageForReq(app *App, r *http.Request) page.StaticPage {
u = getUserSession(app, r) u = getUserSession(app, r)
if u != nil { if u != nil {
p.Username = u.Username p.Username = u.Username
p.IsAdmin = u != nil && u.IsAdmin()
p.CanInvite = canUserInvite(app.cfg, p.IsAdmin)
} }
} }
p.CanViewReader = !app.cfg.App.Private || u != nil p.CanViewReader = !app.cfg.App.Private || u != nil
@ -486,9 +503,14 @@ func ConnectToDatabase(app *App) error {
return nil return nil
} }
// FormatVersion constructs the version string for the application
func FormatVersion() string {
return serverSoftware + " " + softwareVer
}
// OutputVersion prints out the version of the application. // OutputVersion prints out the version of the application.
func OutputVersion() { func OutputVersion() {
fmt.Println(serverSoftware + " " + softwareVer) fmt.Println(FormatVersion())
} }
// NewApp creates a new app instance. // NewApp creates a new app instance.

128
appstats/appstats.go Normal file
View file

@ -0,0 +1,128 @@
// Copyright 2014-2018 The Gogs Authors. All rights reserved.
// Use of this source code is governed by a MIT-style license that can be
// found in the LICENSE file of the Gogs project (github.com/gogs/gogs).
package appstats
import (
"fmt"
"math"
"strings"
"time"
)
// Borrowed from github.com/gogs/gogs/pkg/tool
// Seconds-based time units
const (
Minute = 60
Hour = 60 * Minute
Day = 24 * Hour
Week = 7 * Day
Month = 30 * Day
Year = 12 * Month
)
func computeTimeDiff(diff int64) (int64, string) {
diffStr := ""
switch {
case diff <= 0:
diff = 0
diffStr = "now"
case diff < 2:
diff = 0
diffStr = "1 second"
case diff < 1*Minute:
diffStr = fmt.Sprintf("%d seconds", diff)
diff = 0
case diff < 2*Minute:
diff -= 1 * Minute
diffStr = "1 minute"
case diff < 1*Hour:
diffStr = fmt.Sprintf("%d minutes", diff/Minute)
diff -= diff / Minute * Minute
case diff < 2*Hour:
diff -= 1 * Hour
diffStr = "1 hour"
case diff < 1*Day:
diffStr = fmt.Sprintf("%d hours", diff/Hour)
diff -= diff / Hour * Hour
case diff < 2*Day:
diff -= 1 * Day
diffStr = "1 day"
case diff < 1*Week:
diffStr = fmt.Sprintf("%d days", diff/Day)
diff -= diff / Day * Day
case diff < 2*Week:
diff -= 1 * Week
diffStr = "1 week"
case diff < 1*Month:
diffStr = fmt.Sprintf("%d weeks", diff/Week)
diff -= diff / Week * Week
case diff < 2*Month:
diff -= 1 * Month
diffStr = "1 month"
case diff < 1*Year:
diffStr = fmt.Sprintf("%d months", diff/Month)
diff -= diff / Month * Month
case diff < 2*Year:
diff -= 1 * Year
diffStr = "1 year"
default:
diffStr = fmt.Sprintf("%d years", diff/Year)
diff = 0
}
return diff, diffStr
}
// TimeSincePro calculates the time interval and generate full user-friendly string.
func TimeSincePro(then time.Time) string {
now := time.Now()
diff := now.Unix() - then.Unix()
if then.After(now) {
return "future"
}
var timeStr, diffStr string
for {
if diff == 0 {
break
}
diff, diffStr = computeTimeDiff(diff)
timeStr += ", " + diffStr
}
return strings.TrimPrefix(timeStr, ", ")
}
func logn(n, b float64) float64 {
return math.Log(n) / math.Log(b)
}
func humanateBytes(s uint64, base float64, sizes []string) string {
if s < 10 {
return fmt.Sprintf("%d B", s)
}
e := math.Floor(logn(float64(s), base))
suffix := sizes[int(e)]
val := float64(s) / math.Pow(base, math.Floor(e))
f := "%.0f"
if val < 10 {
f = "%.1f"
}
return fmt.Sprintf(f+" %s", val, suffix)
}
// FileSize calculates the file size and generate user-friendly string.
func FileSize(s int64) string {
sizes := []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"}
return humanateBytes(uint64(s), 1024, sizes)
}

View file

@ -113,6 +113,7 @@ func main() {
// Initialize the application // Initialize the application
var err error var err error
log.Info("Starting %s...", writefreely.FormatVersion())
app, err = writefreely.Initialize(app, *debugPtr) app, err = writefreely.Initialize(app, *debugPtr)
if err != nil { if err != nil {
log.Error("%s", err) log.Error("%s", err)

View file

@ -71,6 +71,7 @@ type (
CurrentPage int CurrentPage int
TotalPages int TotalPages int
Format *CollectionFormat 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()
@ -512,7 +523,7 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
} }
} }
posts, err := app.db.GetPosts(c, page, isCollOwner, false, false) posts, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false)
if err != nil { if err != nil {
return err return err
} }
@ -541,6 +552,8 @@ type CollectionPage struct {
Username string Username string
Collections *[]Collection Collections *[]Collection
PinnedPosts *[]PublicPost PinnedPosts *[]PublicPost
IsAdmin bool
CanInvite bool
} }
func (c *CollectionObj) ScriptDisplay() template.JS { func (c *CollectionObj) ScriptDisplay() template.JS {
@ -723,6 +736,13 @@ 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
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") {
@ -744,7 +764,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
return impart.HTTPError{http.StatusFound, redirURL} return impart.HTTPError{http.StatusFound, redirURL}
} }
coll.Posts, _ = app.db.GetPosts(c, page, cr.isCollOwner, false, false) coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false)
// Serve collection // Serve collection
displayPage := CollectionPage{ displayPage := CollectionPage{
@ -753,6 +773,8 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
IsCustomDomain: cr.isCustomDomain, IsCustomDomain: cr.isCustomDomain,
IsWelcome: r.FormValue("greeting") != "", IsWelcome: r.FormValue("greeting") != "",
} }
displayPage.IsAdmin = u != nil && u.IsAdmin()
displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
var owner *User var owner *User
if u != nil { if u != nil {
displayPage.Username = u.Username displayPage.Username = u.Username
@ -762,14 +784,15 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
owner = u owner = u
displayPage.CanPin = true displayPage.CanPin = true
pubColls, err := app.db.GetPublishableCollections(owner) pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
if err != nil { if err != nil {
log.Error("unable to fetch collections: %v", err) log.Error("unable to fetch collections: %v", err)
} }
displayPage.Collections = pubColls displayPage.Collections = pubColls
} }
} }
if owner == nil { isOwner := owner != nil
if !isOwner {
// Current user doesn't own collection; retrieve owner information // Current user doesn't own collection; retrieve owner information
owner, err = app.db.GetUserByID(coll.OwnerID) owner, err = app.db.GetUserByID(coll.OwnerID)
if err != nil { if err != nil {
@ -777,14 +800,22 @@ 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
// Add more data // Add more data
// TODO: fix this mess of collections inside collections // TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj) displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
err = templates["collection"].ExecuteTemplate(w, "collection", displayPage) collTmpl := "collection"
if app.cfg.App.Chorus {
collTmpl = "chorus-collection"
}
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
if err != nil { if err != nil {
log.Error("Unable to render collection index: %v", err) log.Error("Unable to render collection index: %v", err)
} }
@ -833,7 +864,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
coll := newDisplayCollection(c, cr, page) coll := newDisplayCollection(c, cr, page)
coll.Posts, _ = app.db.GetPostsTagged(c, tag, page, cr.isCollOwner) coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
if coll.Posts != nil && len(*coll.Posts) == 0 { if coll.Posts != nil && len(*coll.Posts) == 0 {
return ErrCollectionPageNotFound return ErrCollectionPageNotFound
} }
@ -859,26 +890,31 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
owner = u owner = u
displayPage.CanPin = true displayPage.CanPin = true
pubColls, err := app.db.GetPublishableCollections(owner) pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
if err != nil { if err != nil {
log.Error("unable to fetch collections: %v", err) log.Error("unable to fetch collections: %v", err)
} }
displayPage.Collections = pubColls displayPage.Collections = pubColls
} }
} }
if owner == nil { isOwner := owner != nil
if !isOwner {
// Current user doesn't own collection; retrieve owner information // Current user doesn't own collection; retrieve owner information
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
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
// TODO: fix this mess of collections inside collections // TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj) displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage) err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
if err != nil { if err != nil {
@ -907,16 +943,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
@ -928,6 +963,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 {
@ -940,7 +985,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

@ -68,8 +68,13 @@ type (
JSDisabled bool `ini:"disable_js"` JSDisabled bool `ini:"disable_js"`
WebFonts bool `ini:"webfonts"` WebFonts bool `ini:"webfonts"`
Landing string `ini:"landing"` Landing string `ini:"landing"`
SimpleNav bool `ini:"simple_nav"`
WFModesty bool `ini:"wf_modesty"` WFModesty bool `ini:"wf_modesty"`
// Site functionality
Chorus bool `ini:"chorus"`
DisableDrafts bool `ini:"disable_drafts"`
// Users // Users
SingleUser bool `ini:"single_user"` SingleUser bool `ini:"single_user"`
OpenRegistration bool `ini:"open_registration"` OpenRegistration bool `ini:"open_registration"`

View file

@ -65,8 +65,8 @@ type writestore interface {
ChangeSettings(app *App, u *User, s *userSettings) error ChangeSettings(app *App, u *User, s *userSettings) error
ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error
GetCollections(u *User) (*[]Collection, error) GetCollections(u *User, hostName string) (*[]Collection, error)
GetPublishableCollections(u *User) (*[]Collection, error) GetPublishableCollections(u *User, hostName string) (*[]Collection, error)
GetMeStats(u *User) userMeStats GetMeStats(u *User) userMeStats
GetTotalCollections() (int64, error) GetTotalCollections() (int64, error)
GetTotalPosts() (int64, error) GetTotalPosts() (int64, error)
@ -94,7 +94,7 @@ type writestore interface {
UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error
GetLastPinnedPostPos(collID int64) int64 GetLastPinnedPostPos(collID int64) int64
GetPinnedPosts(coll *CollectionObj) (*[]PublicPost, error) GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[]PublicPost, error)
RemoveCollectionRedirect(t *sql.Tx, alias string) error RemoveCollectionRedirect(t *sql.Tx, alias string) error
GetCollectionRedirect(alias string) (new string) GetCollectionRedirect(alias string) (new string)
IsCollectionAttributeOn(id int64, attr string) bool IsCollectionAttributeOn(id int64, attr string) bool
@ -106,8 +106,8 @@ type writestore interface {
ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
GetPostsCount(c *CollectionObj, includeFuture bool) GetPostsCount(c *CollectionObj, includeFuture bool)
GetPosts(c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error)
GetPostsTagged(c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error)
GetAPFollowers(c *Collection) (*[]RemoteUser, error) GetAPFollowers(c *Collection) (*[]RemoteUser, error)
GetAPActorKeys(collectionID int64) ([]byte, []byte) GetAPActorKeys(collectionID int64) ([]byte, []byte)
@ -296,7 +296,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 +308,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 +364,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 +387,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
@ -1070,7 +1087,7 @@ func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
// It will return future posts if `includeFuture` is true. // It will return future posts if `includeFuture` is true.
// It will include only standard (non-pinned) posts unless `includePinned` is true. // It will include only standard (non-pinned) posts unless `includePinned` is true.
// TODO: change includeFuture to isOwner, since that's how it's used // TODO: change includeFuture to isOwner, since that's how it's used
func (db *datastore) GetPosts(c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) { func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) {
collID := c.ID collID := c.ID
cf := c.NewFormat() cf := c.NewFormat()
@ -1115,7 +1132,7 @@ func (db *datastore) GetPosts(c *Collection, page int, includeFuture, forceRecen
break break
} }
p.extractData() p.extractData()
p.formatContent(c, includeFuture) p.formatContent(cfg, c, includeFuture)
posts = append(posts, p.processPost()) posts = append(posts, p.processPost())
} }
@ -1131,7 +1148,7 @@ func (db *datastore) GetPosts(c *Collection, page int, includeFuture, forceRecen
// given tag. // given tag.
// It will return future posts if `includeFuture` is true. // It will return future posts if `includeFuture` is true.
// TODO: change includeFuture to isOwner, since that's how it's used // TODO: change includeFuture to isOwner, since that's how it's used
func (db *datastore) GetPostsTagged(c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) { func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) {
collID := c.ID collID := c.ID
cf := c.NewFormat() cf := c.NewFormat()
@ -1179,7 +1196,7 @@ func (db *datastore) GetPostsTagged(c *Collection, tag string, page int, include
break break
} }
p.extractData() p.extractData()
p.formatContent(c, includeFuture) p.formatContent(cfg, c, includeFuture)
posts = append(posts, p.processPost()) posts = append(posts, p.processPost())
} }
@ -1533,9 +1550,13 @@ func (db *datastore) GetLastPinnedPostPos(collID int64) int64 {
return lastPos.Int64 return lastPos.Int64
} }
func (db *datastore) GetPinnedPosts(coll *CollectionObj) (*[]PublicPost, error) { func (db *datastore) GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[]PublicPost, error) {
// FIXME: sqlite-backed instances don't include ellipsis on truncated titles // FIXME: sqlite-backed instances don't include ellipsis on truncated titles
rows, err := db.Query("SELECT id, slug, title, "+db.clip("content", 80)+", pinned_position FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL ORDER BY pinned_position ASC", coll.ID) timeCondition := ""
if !includeFuture {
timeCondition = "AND created <= " + db.now()
}
rows, err := db.Query("SELECT id, slug, title, "+db.clip("content", 80)+", pinned_position FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL "+timeCondition+" ORDER BY pinned_position ASC", coll.ID)
if err != nil { if err != nil {
log.Error("Failed selecting pinned posts: %v", err) log.Error("Failed selecting pinned posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve pinned posts."} return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve pinned posts."}
@ -1559,7 +1580,7 @@ func (db *datastore) GetPinnedPosts(coll *CollectionObj) (*[]PublicPost, error)
return &posts, nil return &posts, nil
} }
func (db *datastore) GetCollections(u *User) (*[]Collection, error) { func (db *datastore) GetCollections(u *User, hostName string) (*[]Collection, error) {
rows, err := db.Query("SELECT id, alias, title, description, privacy, view_count FROM collections WHERE owner_id = ? ORDER BY id ASC", u.ID) rows, err := db.Query("SELECT id, alias, title, description, privacy, view_count FROM collections WHERE owner_id = ? ORDER BY id ASC", u.ID)
if err != nil { if err != nil {
log.Error("Failed selecting from collections: %v", err) log.Error("Failed selecting from collections: %v", err)
@ -1575,6 +1596,7 @@ func (db *datastore) GetCollections(u *User) (*[]Collection, error) {
log.Error("Failed scanning row: %v", err) log.Error("Failed scanning row: %v", err)
break break
} }
c.hostName = hostName
c.URL = c.CanonicalURL() c.URL = c.CanonicalURL()
c.Public = c.IsPublic() c.Public = c.IsPublic()
@ -1588,8 +1610,8 @@ func (db *datastore) GetCollections(u *User) (*[]Collection, error) {
return &colls, nil return &colls, nil
} }
func (db *datastore) GetPublishableCollections(u *User) (*[]Collection, error) { func (db *datastore) GetPublishableCollections(u *User, hostName string) (*[]Collection, error) {
c, err := db.GetCollections(u) c, err := db.GetCollections(u, hostName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -1624,7 +1646,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)
} }
@ -1632,7 +1658,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)
} }
@ -2252,6 +2282,19 @@ func (db *datastore) GetUserInvite(id string) (*Invite, error) {
return &i, nil return &i, nil
} }
// IsUsersInvite returns true if the user with ID created the invite with code
// and an error other than sql no rows, if any. Will return false in the event
// of an error.
func (db *datastore) IsUsersInvite(code string, userID int64) (bool, error) {
var id string
err := db.QueryRow("SELECT id FROM userinvites WHERE id = ? AND owner_id = ?", code, userID).Scan(&id)
if err != nil && err != sql.ErrNoRows {
log.Error("Failed selecting invite: %v", err)
return false, err
}
return id != "", nil
}
func (db *datastore) GetUsersInvitedCount(id string) int64 { func (db *datastore) GetUsersInvitedCount(id string) int64 {
var count int64 var count int64
err := db.QueryRow("SELECT COUNT(*) FROM usersinvited WHERE invite_id = ?", id).Scan(&count) err := db.QueryRow("SELECT COUNT(*) FROM usersinvited WHERE invite_id = ?", id).Scan(&count)
@ -2341,17 +2384,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
@ -2388,6 +2431,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)

View file

@ -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
@ -46,6 +47,8 @@ var (
ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."}
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} 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)
} }
@ -104,7 +105,7 @@ func compileFullExport(app *App, u *User) *ExportUser {
User: u, User: u,
} }
colls, err := app.db.GetCollections(u) colls, err := app.db.GetCollections(u, app.cfg.App.Host)
if err != nil { if err != nil {
log.Error("unable to fetch collections: %v", err) log.Error("unable to fetch collections: %v", err)
} }
@ -118,7 +119,7 @@ func compileFullExport(app *App, u *User) *ExportUser {
var collObjs []CollectionObj var collObjs []CollectionObj
for _, c := range *colls { for _, c := range *colls {
co := &CollectionObj{Collection: c} co := &CollectionObj{Collection: c}
co.Posts, err = app.db.GetPosts(&c, 0, true, false, true) co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true)
if err != nil { if err != nil {
log.Error("unable to get collection posts: %v", err) log.Error("unable to get collection posts: %v", err)
} }

20
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() {
@ -55,9 +65,9 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
tag := mux.Vars(req)["tag"] tag := mux.Vars(req)["tag"]
if tag != "" { if tag != "" {
coll.Posts, _ = app.db.GetPostsTagged(c, tag, 1, false) coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, 1, false)
} else { } else {
coll.Posts, _ = app.db.GetPosts(c, 1, false, true, false) coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false)
} }
author := "" author := ""
@ -94,7 +104,7 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
Title: title, Title: title,
Link: &Link{Href: permalink}, Link: &Link{Href: permalink},
Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>", Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>",
Content: applyMarkdown([]byte(p.Content), ""), Content: applyMarkdown([]byte(p.Content), "", app.cfg),
Author: &Author{author, ""}, Author: &Author{author, ""},
Created: p.Created, Created: p.Created,
Updated: p.Updated, Updated: p.Updated,

21
go.mod
View file

@ -2,25 +2,14 @@ module github.com/writeas/writefreely
require ( require (
github.com/BurntSushi/toml v0.3.1 // indirect github.com/BurntSushi/toml v0.3.1 // indirect
github.com/Unknwon/com v0.0.0-20181010210213-41959bdd855f // indirect
github.com/Unknwon/i18n v0.0.0-20171114194641-b64d33658966 // indirect
github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737 // 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/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-macaron/cache v0.0.0-20151013081102-561735312776 // indirect
github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 // indirect
github.com/go-macaron/session v0.0.0-20190131233854-0a0a789bf193 // 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/gogits/gogs v0.11.86
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 // indirect
github.com/gogs/go-libravatar v0.0.0-20161120025154-cd1abbd55d09 // indirect
github.com/gogs/gogs v0.11.86 // indirect
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a // indirect
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // 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
@ -31,15 +20,12 @@ require (
github.com/hashicorp/go-multierror v1.0.0 github.com/hashicorp/go-multierror v1.0.0
github.com/ikeikeikeike/go-sitemap-generator v1.0.1 github.com/ikeikeikeike/go-sitemap-generator v1.0.1
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
github.com/imdario/mergo v0.3.7 // indirect
github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect
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
github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect
github.com/manifoldco/promptui v0.3.2 github.com/manifoldco/promptui v0.3.2
github.com/mattn/go-colorable v0.1.0 // indirect github.com/mattn/go-colorable v0.1.0 // indirect
github.com/mattn/go-sqlite3 v1.10.0 github.com/mattn/go-sqlite3 v1.10.0
github.com/mcuadros/go-version v0.0.0-20180611085657-6d5863ca60fa // indirect
github.com/microcosm-cc/bluemonday v1.0.2 github.com/microcosm-cc/bluemonday v1.0.2
github.com/mitchellh/go-wordwrap v1.0.0 github.com/mitchellh/go-wordwrap v1.0.0
github.com/nicksnyder/go-i18n v1.10.0 // indirect github.com/nicksnyder/go-i18n v1.10.0 // indirect
@ -47,7 +33,6 @@ 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/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // 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
@ -63,7 +48,7 @@ require (
github.com/writeas/openssl-go v1.0.0 // indirect 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
@ -72,11 +57,7 @@ require (
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect
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/bufio.v1 v1.0.0-20140618132640-567b2bfa514e // indirect
gopkg.in/clog.v1 v1.2.0 // indirect
gopkg.in/ini.v1 v1.41.0 gopkg.in/ini.v1 v1.41.0
gopkg.in/macaron.v1 v1.3.2 // indirect
gopkg.in/redis.v2 v2.3.2 // indirect
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect 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
) )

42
go.sum
View file

@ -2,10 +2,6 @@ code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY= 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/Unknwon/com v0.0.0-20181010210213-41959bdd855f h1:m1tYqjD/N0vF/S8s/ZKz/eccUr8RAAcrOK2MhXeTegA=
github.com/Unknwon/com v0.0.0-20181010210213-41959bdd855f/go.mod h1:KYCjqMOeHpNuTOiFQU6WEcTG7poCJrUs0YgyHNtn1no=
github.com/Unknwon/i18n v0.0.0-20171114194641-b64d33658966 h1:Mp8GNJ/tdTZIEdLdZfykEJaL3mTyEYrSzYNcdoQKpJk=
github.com/Unknwon/i18n v0.0.0-20171114194641-b64d33658966/go.mod h1:SFtfq0zFPsENI7DpE87QM2hcYu5QQ0fRdCgP+P1Hrqo=
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=
github.com/alecthomas/gometalinter v3.0.0+incompatible h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU= github.com/alecthomas/gometalinter v3.0.0+incompatible h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU=
github.com/alecthomas/gometalinter v3.0.0+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk= github.com/alecthomas/gometalinter v3.0.0+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
@ -13,8 +9,6 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZq
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737 h1:rRISKWyXfVxvoa702s91Zl5oREZTrR3yv+tXrrX7G/g=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 h1:jWNY1NDg6a/c8RSXkai7IX6UOhir0LD39I4Dukg+4Ks= github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 h1:jWNY1NDg6a/c8RSXkai7IX6UOhir0LD39I4Dukg+4Ks=
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ= github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
@ -38,26 +32,10 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
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/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
github.com/go-macaron/cache v0.0.0-20151013081102-561735312776 h1:UYIHS1r0WotqB5cIa0PAiV0m6GzD9rDBcn4alp5JgCw=
github.com/go-macaron/cache v0.0.0-20151013081102-561735312776/go.mod h1:hHAsZm/oBZVcY+S7qdQL6Vbg5VrXF6RuKGuqsszt3Ok=
github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 h1:NjHlg70DuOkcAMqgt0+XA+NHwtu66MkTVVgR4fFWbcI=
github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191/go.mod h1:VFI2o2q9kYsC4o7VP1HrEVosiZZTd+MVT3YZx4gqvJw=
github.com/go-macaron/session v0.0.0-20190131233854-0a0a789bf193 h1:z/nqwd+ql/r6Q3QGnwNd6B89UjPytM0be5pDQV9TuWw=
github.com/go-macaron/session v0.0.0-20190131233854-0a0a789bf193/go.mod h1:ScEJm9Gk+ez5JJTml5WlBIqavAfuE5nF8e4Gvyz/X+A=
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=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gogits/gogs v0.11.86 h1:IujCpA+F/mYDXTcqdy593rl2donWakAWoL2HYZn7spw=
github.com/gogits/gogs v0.11.86/go.mod h1:H8FMbPPb+o/TgI6YnmQmT8nmEIHypXDau+f2CChYoCk=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/gogs/go-libravatar v0.0.0-20161120025154-cd1abbd55d09 h1:UdOSIHZpkYcajRbfebBYzFDsL3SuqObH3bvKYBqgKmI=
github.com/gogs/go-libravatar v0.0.0-20161120025154-cd1abbd55d09/go.mod h1:Zas3BtO88pk1cwUfEYlvnl/CRwh0ybDxRWSwRjG8I3w=
github.com/gogs/gogs v0.11.86 h1:D+dXuY/6XjJ2t74W/dxo7ogx5+xW05Va8sJiQSS4WXA=
github.com/gogs/gogs v0.11.86/go.mod h1:qlbvdn16XTC6q7eR+thjW+OLdN+mi2PBZ8KqVT39T88=
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a h1:8DZwxETOVWIinYxDK+i6L+rMb7eGATGaakD6ZucfHVk=
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a/go.mod h1:TUIZ+29jodWQ8Gk6Pvtg4E09aMsc3C/VLZiVYfUhWQU=
github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
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=
@ -90,10 +68,6 @@ github.com/ikeikeikeike/go-sitemap-generator v1.0.1 h1:49Fn8gro/B12vCY8pf5/+/Jpr
github.com/ikeikeikeike/go-sitemap-generator v1.0.1/go.mod h1:QI+zWsz6yQyxkG9LWNcnu0f7aiAE5tPdsZOsICgmd1c= github.com/ikeikeikeike/go-sitemap-generator v1.0.1/go.mod h1:QI+zWsz6yQyxkG9LWNcnu0f7aiAE5tPdsZOsICgmd1c=
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/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI=
github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/jteeuwen/go-bindata v3.0.7+incompatible h1:91Uy4d9SYVr1kyTJ15wJsog+esAZZl7JmEfTkwmhJts=
github.com/jteeuwen/go-bindata v3.0.7+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs=
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
@ -119,8 +93,6 @@ github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mcuadros/go-version v0.0.0-20180611085657-6d5863ca60fa h1:XvNrttGMJfVrUqblGju4IkjYXwx6l5OAAyjaIsydzsk=
github.com/mcuadros/go-version v0.0.0-20180611085657-6d5863ca60fa/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4=
@ -137,8 +109,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY=
@ -185,8 +155,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/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 h1:5VKkCakQgdKZcbfVKJXtRpc5VHrkflusCl/KRCPzpQ0=
github.com/writeas/web-core v1.0.0/go.mod h1:Si3chV7VWgY8CsV+3gRolMXSO2Vx1ZFAQ/mkrpvmyEE= github.com/writeas/web-core v1.0.0/go.mod h1:Si3chV7VWgY8CsV+3gRolMXSO2Vx1ZFAQ/mkrpvmyEE=
github.com/writefreely/go-nodeinfo v1.1.0 h1:dp/ieEu0/gTeNKFvJTYhzBBouyFn7aiWtWzkb8J1JLg= github.com/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0=
github.com/writefreely/go-nodeinfo v1.1.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= 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=
@ -213,19 +183,11 @@ google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO50
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c h1:vTxShRUnK60yd8DZU+f95p1zSLj814+5CuEh7NjF2/Y= gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c h1:vTxShRUnK60yd8DZU+f95p1zSLj814+5CuEh7NjF2/Y=
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA= gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA=
gopkg.in/bufio.v1 v1.0.0-20140618132640-567b2bfa514e h1:wGA78yza6bu/mWcc4QfBuIEHEtc06xdiU0X8sY36yUU=
gopkg.in/bufio.v1 v1.0.0-20140618132640-567b2bfa514e/go.mod h1:xsQCaysVCudhrYTfzYWe577fCe7Ceci+6qjO2Rdc0Z4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/clog.v1 v1.2.0 h1:BHfwHRNQy497iBNsRBassPixSAxRbn2z5KVkdBFbwxc=
gopkg.in/clog.v1 v1.2.0/go.mod h1:L6fgdpdhFgKX4eGuDvt+N6X2GwZE160NRrIHzvaF8ZM=
gopkg.in/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE= gopkg.in/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE=
gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/macaron.v1 v1.3.2 h1:AvWIaPmwBUA87/OWzePkoxeaw6YJWDfBt1pDFPBnLf8=
gopkg.in/macaron.v1 v1.3.2/go.mod h1:PrsiawTWAGZs6wFbT5hlr7SQ2Ns9h7cUVtcUu4lQOVo=
gopkg.in/redis.v2 v2.3.2 h1:GPVIIB/JnL1wvfULefy3qXmPu1nfNu2d0yA09FHgwfs=
gopkg.in/redis.v2 v2.3.2/go.mod h1:4wl9PJ/CqzeHk3LVq1hNLHH8krm3+AXEgut4jVc++LU=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.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=

View file

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

View file

@ -12,15 +12,16 @@ package writefreely
import ( import (
"database/sql" "database/sql"
"html/template"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/nerds/store" "github.com/writeas/nerds/store"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/page" "github.com/writeas/writefreely/page"
"html/template"
"net/http"
"strconv"
"time"
) )
type Invite struct { type Invite struct {
@ -77,6 +78,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" {
@ -114,6 +119,36 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
return err return err
} }
expired := i.Expired()
if !expired && i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
// Invite has a max-use number, so check if we're past that limit
i.uses = app.db.GetUsersInvitedCount(inviteCode)
expired = i.uses >= i.MaxUses.Int64
}
if u := getUserSession(app, r); u != nil {
// check if invite belongs to another user
// error can be ignored as not important in this case
if ownInvite, _ := app.db.IsUsersInvite(inviteCode, u.ID); !ownInvite {
addSessionFlash(app, w, r, "You're already registered and logged in.", nil)
// show homepage
return impart.HTTPError{http.StatusFound, "/me/settings"}
}
// show invite instructions
p := struct {
*UserPage
Invite *Invite
Expired bool
}{
UserPage: NewUserPage(app, r, u, "Invite to "+app.cfg.App.SiteName, nil),
Invite: i,
Expired: expired,
}
showUserPage(w, "invite-help", p)
return nil
}
p := struct { p := struct {
page.StaticPage page.StaticPage
Error string Error string
@ -124,16 +159,10 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
Invite: inviteCode, Invite: inviteCode,
} }
if i.Expired() { if expired {
p.Error = "This invite link has expired." p.Error = "This invite link has expired."
} }
if i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
if c := app.db.GetUsersInvitedCount(inviteCode); c >= i.MaxUses.Int64 {
p.Error = "This invite link has expired."
}
}
// Get error messages // Get error messages
session, err := app.sessionStore.Get(r, cookieName) session, err := app.sessionStore.Get(r, cookieName)
if err != nil { if err != nil {

View file

@ -1,20 +1,10 @@
ifeq ($(shell which lessc),/usr/bin/lessc)
LESSC=/usr/bin/lessc
else ifeq ($(shell which lessc),/usr/local/bin/lessc)
LESSC=/usr/local/bin/lessc
else ifeq ($(shell which lessc),/bin/lessc)
LESSC=/bin/lessc
else
LESSC=node_modules/.bin/lessc
endif
export LESSC
CSSDIR=../static/css/ CSSDIR=../static/css/
all : all :
$(LESSC) app.less --clean-css="--s1 --advanced" $(CSSDIR)write.css @command -v lessc >/dev/null 2>&1 || { echo >&2 "lessc is not installed, please run: make install or: less/install-less.sh"; exit 1; }
$(LESSC) fonts.less --clean-css="--s1 --advanced" $(CSSDIR)fonts.css lessc app.less --clean-css="--s1 --advanced" $(CSSDIR)write.css
$(LESSC) icons.less --clean-css="--s1 --advanced" $(CSSDIR)icons.css lessc fonts.less --clean-css="--s1 --advanced" $(CSSDIR)fonts.css
lessc icons.less --clean-css="--s1 --advanced" $(CSSDIR)icons.css
install : install :
./install-less.sh ./install-less.sh

View file

@ -405,6 +405,31 @@ body {
} }
} }
nav#full-nav {
margin: 0;
.left-side {
display: inline-block;
a:first-child {
margin-left: 0;
}
}
.right-side {
float: right;
}
}
nav#full-nav a.simple-btn, .tool button {
font-family: @sansFont;
border: 1px solid #ccc !important;
padding: .5rem 1rem;
margin: 0;
.rounded(.25em);
text-decoration: none;
}
.post-title { .post-title {
a { a {
&:link { &:link {
@ -491,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;

View file

@ -63,7 +63,7 @@ body#pad, body#pad-sub {
} }
} }
#belt { #belt {
a { a, button {
color: #000; color: #000;
} }
} }
@ -100,7 +100,7 @@ body#pad, body#pad-sub {
} }
} }
#belt { #belt {
a { a, button {
color: white; color: white;
} }
} }

View file

@ -222,6 +222,13 @@ body#pad, body#pad-sub {
font-style: italic; font-style: italic;
} }
} }
button {
font-family: @sansFont;
background-color: transparent;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
border: 0;
}
} }
} }
} }

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

33
pad.go
View file

@ -11,12 +11,13 @@
package writefreely package writefreely
import ( import (
"net/http"
"strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/page" "github.com/writeas/writefreely/page"
"net/http"
"strings"
) )
func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error { func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
@ -34,9 +35,10 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
} }
appData := &struct { appData := &struct {
page.StaticPage page.StaticPage
Post *RawPost Post *RawPost
User *User User *User
Blogs *[]Collection Blogs *[]Collection
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
@ -47,23 +49,26 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
} }
var err error var err error
if appData.User != nil { if appData.User != nil {
appData.Blogs, err = app.db.GetPublishableCollections(appData.User) appData.Blogs, err = app.db.GetPublishableCollections(appData.User, app.cfg.App.Host)
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 padTmpl == "" { if templates[padTmpl] == nil {
if padTmpl != "" {
log.Info("No template '%s' found. Falling back to default 'pad' template.", padTmpl)
}
padTmpl = "pad" padTmpl = "pad"
} }
if action == "" && slug == "" { if action == "" && slug == "" {
// Not editing any post; simply render the Pad // Not editing any post; simply render the Pad
if templates[padTmpl] == nil {
log.Info("No template '%s' found. Falling back to default 'pad' template.", padTmpl)
padTmpl = "pad"
}
if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil { if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil {
log.Error("Unable to execute template: %v", err) log.Error("Unable to execute template: %v", err)
} }
@ -121,12 +126,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

View file

@ -28,6 +28,8 @@ type StaticPage struct {
Values map[string]string Values map[string]string
Flashes []string Flashes []string
CanViewReader bool CanViewReader bool
IsAdmin bool
CanInvite bool
} }
// SanitizeHost alters the StaticPage to contain a real hostname. This is // SanitizeHost alters the StaticPage to contain a real hostname. This is

View file

@ -135,3 +135,30 @@ WriteFreely can communicate with other federated platforms like Mastodon, so peo
} }
return "" return ""
} }
func getReaderSection(app *App) (*instanceContent, error) {
c, err := app.db.GetDynamicContent("reader")
if err != nil {
return nil, err
}
if c == nil {
c = &instanceContent{
ID: "reader",
Type: "section",
Content: defaultReaderBanner(app.cfg),
Updated: defaultPageUpdatedTime,
}
}
if !c.Title.Valid {
c.Title = defaultReaderTitle(app.cfg)
}
return c, nil
}
func defaultReaderTitle(cfg *config.Config) sql.NullString {
return sql.NullString{String: "Reader", Valid: true}
}
func defaultReaderBanner(cfg *config.Config) string {
return "Read the latest posts from " + cfg.App.SiteName + "."
}

View file

@ -12,8 +12,8 @@
</ul>{{end}} </ul>{{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="{{.Username}}" {{if not .Username}}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 .Username}}autofocus{{end}} /><br /> <input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
{{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}} {{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}}
<input type="submit" id="btn-login" value="Login" /> <input type="submit" id="btn-login" value="Login" />
</form> </form>

View file

@ -12,17 +12,19 @@ package writefreely
import ( import (
"fmt" "fmt"
"github.com/microcosm-cc/bluemonday"
stripmd "github.com/writeas/go-strip-markdown"
"github.com/writeas/saturday"
"github.com/writeas/web-core/stringmanip"
"github.com/writeas/writefreely/parse"
"html" "html"
"html/template" "html/template"
"regexp" "regexp"
"strings" "strings"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"github.com/microcosm-cc/bluemonday"
stripmd "github.com/writeas/go-strip-markdown"
blackfriday "github.com/writeas/saturday"
"github.com/writeas/web-core/stringmanip"
"github.com/writeas/writefreely/config"
"github.com/writeas/writefreely/parse"
) )
var ( var (
@ -34,27 +36,28 @@ var (
markeddownReg = regexp.MustCompile("<p>(.+)</p>") markeddownReg = regexp.MustCompile("<p>(.+)</p>")
) )
func (p *Post) formatContent(c *Collection, isOwner bool) { func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool) {
baseURL := c.CanonicalURL() baseURL := c.CanonicalURL()
// TODO: redundant
if !isSingleUser { if !isSingleUser {
baseURL = "/" + c.Alias + "/" baseURL = "/" + c.Alias + "/"
} }
p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String))) p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String)))
p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL)) p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL, cfg))
if exc := strings.Index(string(p.Content), "<!--more-->"); exc > -1 { if exc := strings.Index(string(p.Content), "<!--more-->"); exc > -1 {
p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:exc]), baseURL)) p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:exc]), baseURL, cfg))
} }
} }
func (p *PublicPost) formatContent(isOwner bool) { func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool) {
p.Post.formatContent(&p.Collection.Collection, isOwner) p.Post.formatContent(cfg, &p.Collection.Collection, isOwner)
} }
func applyMarkdown(data []byte, baseURL string) string { func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
return applyMarkdownSpecial(data, false, baseURL) return applyMarkdownSpecial(data, false, baseURL, cfg)
} }
func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string) string { func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string {
mdExtensions := 0 | mdExtensions := 0 |
blackfriday.EXTENSION_TABLES | blackfriday.EXTENSION_TABLES |
blackfriday.EXTENSION_FENCED_CODE | blackfriday.EXTENSION_FENCED_CODE |
@ -74,7 +77,11 @@ func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string) string
md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions) md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
if baseURL != "" { if baseURL != "" {
// Replace special text generated by Markdown parser // Replace special text generated by Markdown parser
md = []byte(hashtagReg.ReplaceAll(md, []byte("<a href=\""+baseURL+"tag:$1\" class=\"hashtag\"><span>#</span><span class=\"p-category\">$1</span></a>"))) tagPrefix := baseURL + "tag:"
if cfg.App.Chorus {
tagPrefix = "/read/t/"
}
md = []byte(hashtagReg.ReplaceAll(md, []byte("<a href=\""+tagPrefix+"$1\" class=\"hashtag\"><span>#</span><span class=\"p-category\">$1</span></a>")))
} }
// Strip out bad HTML // Strip out bad HTML
policy := getSanitizationPolicy() policy := getSanitizationPolicy()
@ -163,6 +170,7 @@ func getSanitizationPolicy() *bluemonday.Policy {
policy.AllowAttrs("controls", "loop", "muted", "autoplay").OnElements("video") policy.AllowAttrs("controls", "loop", "muted", "autoplay").OnElements("video")
policy.AllowAttrs("controls", "loop", "muted", "autoplay", "preload").OnElements("audio") policy.AllowAttrs("controls", "loop", "muted", "autoplay", "preload").OnElements("audio")
policy.AllowAttrs("target").OnElements("a") policy.AllowAttrs("target").OnElements("a")
policy.AllowAttrs("title").OnElements("abbr")
policy.AllowAttrs("style", "class", "id").Globally() policy.AllowAttrs("style", "class", "id").Globally()
policy.AllowURLSchemes("http", "https", "mailto", "xmpp") policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
return policy return policy

123
posts.go
View file

@ -35,6 +35,7 @@ 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"
) )
@ -376,10 +377,16 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
Direction: d, Direction: d,
} }
if !isRaw { if !isRaw {
post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "")) post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg))
} }
} }
suspended, err := app.db.IsUserSuspended(ownerID.Int64)
if err != nil {
log.Error("view post: %v", err)
return ErrInternalGeneral
}
// Check if post has been unpublished // Check if post has been unpublished
if content == "" { if content == "" {
gone = true gone = true
@ -427,9 +434,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
page := struct { page := struct {
*AnonymousPost *AnonymousPost
page.StaticPage page.StaticPage
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),
@ -440,6 +448,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)
@ -471,7 +483,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 == "" {
@ -496,6 +508,15 @@ 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)
return ErrInternalGeneral
}
if suspended {
return ErrUserSuspended
}
if userID == -1 { if userID == -1 {
return ErrNotLoggedIn return ErrNotLoggedIn
} }
@ -508,7 +529,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
@ -554,7 +575,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 {
@ -597,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 { 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"]
@ -662,6 +682,15 @@ 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)
return ErrInternalGeneral
}
if suspended {
return ErrUserSuspended
}
// Modify post struct // Modify post struct
p.ID = postID p.ID = postID
@ -856,11 +885,20 @@ 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)
return ErrInternalGeneral
}
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
} }
@ -950,13 +988,22 @@ 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)
return ErrInternalGeneral
}
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
} }
@ -992,6 +1039,7 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error { func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
var collID int64 var collID int64
var ownerID int64
var coll *Collection var coll *Collection
var err error var err error
vars := mux.Vars(r) vars := mux.Vars(r)
@ -1007,12 +1055,22 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
return err return err
} }
collID = coll.ID collID = coll.ID
ownerID = coll.OwnerID
} }
p, err := app.db.GetPost(vars["post"], collID) p, err := app.db.GetPost(vars["post"], collID)
if err != nil { if err != nil {
return err return err
} }
suspended, err := app.db.IsUserSuspended(ownerID)
if err != nil {
log.Error("fetch post: %v", err)
return ErrInternalGeneral
}
if suspended {
return ErrPostNotFound
}
p.extractData() p.extractData()
@ -1032,7 +1090,7 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
} }
p.Collection = &CollectionObj{Collection: *coll} p.Collection = &CollectionObj{Collection: *coll}
po := p.ActivityObject() po := p.ActivityObject(app.cfg)
po.Context = []interface{}{activitystreams.Namespace} po.Context = []interface{}{activitystreams.Namespace}
return impart.RenderActivityJSON(w, po, http.StatusOK) return impart.RenderActivityJSON(w, po, http.StatusOK)
} }
@ -1060,25 +1118,25 @@ 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() *activitystreams.Object { func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object {
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",
} }
o.Name = p.DisplayTitle() o.Name = p.DisplayTitle()
if p.HTMLContent == template.HTML("") { if p.HTMLContent == template.HTML("") {
p.formatContent(false) p.formatContent(cfg, false)
} }
o.Content = string(p.HTMLContent) o.Content = string(p.HTMLContent)
if p.Language.Valid { if p.Language.Valid {
@ -1093,7 +1151,11 @@ func (p *PublicPost) ActivityObject() *activitystreams.Object {
if isSingleUser { if isSingleUser {
tagBaseURL = p.Collection.CanonicalURL() + "tag:" tagBaseURL = p.Collection.CanonicalURL() + "tag:"
} else { } else {
tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias) if cfg.App.Chorus {
tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName)
} else {
tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
}
} }
for _, t := range p.Tags { for _, t := range p.Tags {
o.Tag = append(o.Tag, activitystreams.Tag{ o.Tag = append(o.Tag, activitystreams.Tag{
@ -1270,6 +1332,12 @@ 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)
return ErrInternalGeneral
}
// 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
@ -1326,6 +1394,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."}
@ -1357,14 +1428,14 @@ Are you sure it was ever here?`,
return ErrCollectionPageNotFound return ErrCollectionPageNotFound
} }
p.extractData() p.extractData()
ap := p.ActivityObject() ap := p.ActivityObject(app.cfg)
ap.Context = []interface{}{activitystreams.Namespace} ap.Context = []interface{}{activitystreams.Namespace}
return impart.RenderActivityJSON(w, ap, http.StatusOK) return impart.RenderActivityJSON(w, ap, http.StatusOK)
} else { } else {
p.extractData() p.extractData()
p.Content = strings.Replace(p.Content, "<!--more-->", "", 1) p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
// TODO: move this to function // TODO: move this to function
p.formatContent(cr.isCollOwner) p.formatContent(app.cfg, cr.isCollOwner)
tp := struct { tp := struct {
*PublicPost *PublicPost
page.StaticPage page.StaticPage
@ -1373,20 +1444,30 @@ Are you sure it was ever here?`,
IsCustomDomain bool IsCustomDomain bool
PinnedPosts *[]PublicPost PinnedPosts *[]PublicPost
IsFound bool IsFound bool
IsAdmin 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.PinnedPosts, _ = app.db.GetPinnedPosts(coll) tp.IsAdmin = u != nil && u.IsAdmin()
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p) tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
if !postFound { if !postFound {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
if err := templates["collection-post"].ExecuteTemplate(w, "post", tp); err != nil { postTmpl := "collection-post"
if app.cfg.App.Chorus {
postTmpl = "chorus-collection-post"
}
if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil {
log.Error("Error in collection-post template: %v", err) log.Error("Error in collection-post template: %v", err)
} }
} }

49
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 (
@ -47,6 +48,13 @@ type readPublication struct {
Posts *[]PublicPost Posts *[]PublicPost
CurrentPage int CurrentPage int
TotalPages int TotalPages int
SelTopic string
IsAdmin bool
CanInvite bool
// Customizable page content
ContentTitle string
Content template.HTML
} }
func initLocalTimeline(app *App) { func initLocalTimeline(app *App) {
@ -62,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)
@ -97,7 +106,7 @@ func (app *App) FetchPublicPosts() (interface{}, error) {
} }
p.extractData() p.extractData()
p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), "")) p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), "", app.cfg))
fp := p.processPost() fp := p.processPost()
if isCollectionPost { if isCollectionPost {
fp.Collection = &CollectionObj{Collection: *c} fp.Collection = &CollectionObj{Collection: *c}
@ -197,13 +206,25 @@ func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page in
} }
d := &readPublication{ d := &readPublication{
pageForReq(app, r), StaticPage: pageForReq(app, r),
&posts, Posts: &posts,
page, CurrentPage: page,
ttlPages, TotalPages: ttlPages,
SelTopic: tag,
} }
if app.cfg.App.Chorus {
u := getUserSession(app, r)
d.IsAdmin = u != nil && u.IsAdmin()
d.CanInvite = canUserInvite(app.cfg, d.IsAdmin)
}
c, err := getReaderSection(app)
if err != nil {
return err
}
d.ContentTitle = c.Title.String
d.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg))
err := templates["read"].ExecuteTemplate(w, "base", d) err = templates["read"].ExecuteTemplate(w, "base", d)
if err != nil { if err != nil {
log.Error("Unable to render reader: %v", err) log.Error("Unable to render reader: %v", err)
fmt.Fprintf(w, ":(") fmt.Fprintf(w, ":(")
@ -274,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 {
@ -286,7 +307,7 @@ func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) e
Title: title, Title: title,
Link: &Link{Href: permalink}, Link: &Link{Href: permalink},
Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>", Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>",
Content: applyMarkdown([]byte(p.Content), ""), Content: applyMarkdown([]byte(p.Content), "", app.cfg),
Author: &Author{author, ""}, Author: &Author{author, ""},
Created: p.Created, Created: p.Created,
Updated: p.Updated, Updated: p.Updated,

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

@ -146,6 +146,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 +155,8 @@ 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("/invite/{code}", handler.Web(handleViewInvite, UserLevelNoneRequired)).Methods("GET") write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
write.HandleFunc("/invite/{code}", 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())

View file

@ -66,7 +66,7 @@ func handleViewSitemap(app *App, w http.ResponseWriter, r *http.Request) error {
host = c.CanonicalURL() host = c.CanonicalURL()
sm := buildSitemap(host, pre) sm := buildSitemap(host, pre)
posts, err := app.db.GetPosts(c, 0, false, false, false) posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false)
if err != nil { if err != nil {
log.Error("Error getting posts: %v", err) log.Error("Error getting posts: %v", err)
return err return err

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,12 +64,16 @@ 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" { 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"
files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl")) files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl"))
} }
if name == "collection" || name == "collection-tags" || name == "collection-post" || name == "post" { if name == "chorus-collection" || name == "chorus-collection-post" {
files = append(files, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"))
}
if name == "collection" || name == "collection-tags" || name == "collection-post" || name == "post" || name == "chorus-collection" || name == "chorus-collection-post" {
files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl")) files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl"))
} }
templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...)) templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
@ -83,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"),
)) ))
} }
@ -95,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"),
)) ))
} }

235
templates/bare.tmpl Normal file
View file

@ -0,0 +1,235 @@
{{define "pad"}}<!DOCTYPE HTML>
<html>
<head>
<title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} &mdash; {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="google" value="notranslate">
</head>
<body id="pad" class="light">
<div id="overlay"></div>
<textarea id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
{{end}}{{.Post.Content}}</textarea>
<header id="tools">
<div id="clip">
{{if not .SingleUser}}<h1>{{if .Chorus}}<a href="/" title="Home">{{else}}<a href="/me/c/" title="View blogs">{{end}}{{.SiteName}}</a></h1>{{end}}
<nav id="target" {{if .SingleUser}}style="margin-left:0"{{end}}><ul>
<li>{{if .Blogs}}<a href="{{$c := index .Blogs 0}}{{$c.CanonicalURL}}">My Posts</a>{{else}}<a>Draft</a>{{end}}</li>
</ul></nav>
<span id="wc" class="hidden if-room room-4">0 words</span>
</div>
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
<div id="belt">
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
<div class="tool"><button title="Publish your writing" id="publish" style="font-weight: bold">Post</button></div>
</div>
</header>
<script src="/js/h.js"></script>
<script>
var $writer = H.getEl('writer');
var $btnPublish = H.getEl('publish');
var $wc = H.getEl("wc");
var updateWordCount = function() {
var words = 0;
var val = $writer.el.value.trim();
if (val != '') {
words = $writer.el.value.trim().replace(/\s+/gi, ' ').split(' ').length;
}
$wc.el.innerText = words + " word" + (words != 1 ? "s" : "");
};
var setButtonStates = function() {
if (!canPublish) {
$btnPublish.el.className = 'disabled';
return;
}
if ($writer.el.value.length === 0 || (draftDoc != 'lastDoc' && $writer.el.value == origDoc)) {
$btnPublish.el.className = 'disabled';
} else {
$btnPublish.el.className = '';
}
};
{{if .Post.Id}}var draftDoc = 'draft{{.Post.Id}}';
var origDoc = '{{.Post.Content}}';{{else}}var draftDoc = 'lastDoc';{{end}}
H.load($writer, draftDoc, true);
updateWordCount();
var typingTimer;
var doneTypingInterval = 200;
var posts;
{{if and .Post.Id (not .Post.Slug)}}
var token = null;
var curPostIdx;
posts = JSON.parse(H.get('posts', '[]'));
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.Post.Id}}") {
token = posts[i].token;
break;
}
}
var canPublish = token != null;
{{else}}var canPublish = true;{{end}}
var publishing = false;
var justPublished = false;
var publish = function(content, font) {
{{if and (and .Post.Id (not .Post.Slug)) (not .User)}}
if (!token) {
alert("You don't have permission to update this post.");
return;
}
{{end}}
publishing = true;
$btnPublish.el.textContent = 'Posting...';
$btnPublish.el.disabled = true;
var http = new XMLHttpRequest();
var lang = navigator.languages ? navigator.languages[0] : (navigator.language || navigator.userLanguage);
lang = lang.substring(0, 2);
var post = H.getTitleStrict(content);
var params = {
body: post.content,
title: post.title,
font: font,
lang: lang
};
{{ if .Post.Slug }}
var url = "/api/collections/{{.EditCollection.Alias}}/posts/{{.Post.Id}}";
{{ else if .Post.Id }}
var url = "/api/posts/{{.Post.Id}}";
if (typeof token === 'undefined' || !token) {
token = "";
}
params.token = token;
{{ else }}
var url = "/api/posts";
var postTarget = '{{if .Blogs}}{{$c := index .Blogs 0}}{{$c.Alias}}{{else}}anonymous{{end}}';
if (postTarget != 'anonymous') {
url = "/api/collections/" + postTarget + "/posts";
}
{{ end }}
http.open("POST", url, true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
publishing = false;
if (http.status == 200 || http.status == 201) {
data = JSON.parse(http.responseText);
id = data.data.id;
nextURL = '{{if .SingleUser}}/d{{end}}/'+id;
{{ if not .Post.Id }}
// Post created
if (postTarget != 'anonymous') {
nextURL = {{if not .SingleUser}}'/'+postTarget+{{end}}'/'+data.data.slug;
}
editToken = data.data.token;
{{ if not .User }}if (postTarget == 'anonymous') {
// Save the data
var posts = JSON.parse(H.get('posts', '[]'));
{{if .Post.Id}}var newPost = H.createPost("{{.Post.Id}}", token, content);
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.Post.Id}}") {
posts[i].title = newPost.title;
posts[i].summary = newPost.summary;
break;
}
}
nextURL = "/pad/posts";{{else}}posts.push(H.createPost(id, editToken, content));{{end}}
H.set('posts', JSON.stringify(posts));
}
{{ end }}
{{ end }}
justPublished = true;
if (draftDoc != 'lastDoc') {
H.remove(draftDoc);
{{if .Editing}}H.remove('draft{{.Post.Id}}font');{{end}}
} else {
H.set(draftDoc, '');
}
{{if .EditCollection}}
window.location = '{{.EditCollection.CanonicalURL}}{{.Post.Slug}}';
{{else}}
window.location = nextURL;
{{end}}
} else {
$btnPublish.el.textContent = 'Post';
alert("Failed to post. Please try again.");
}
}
}
http.send(JSON.stringify(params));
};
setButtonStates();
$writer.on('keyup input', function() {
setButtonStates();
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
}, false);
$writer.on('keydown', function(e) {
clearTimeout(typingTimer);
if (e.keyCode == 13 && (e.metaKey || e.ctrlKey)) {
$btnPublish.el.click();
}
});
$btnPublish.on('click', function(e) {
e.preventDefault();
if (!publishing && $writer.el.value) {
var content = $writer.el.value;
publish(content, selectedFont);
}
});
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
var selectedFont = H.get('{{if .Editing}}draft{{.Post.Id}}font{{else}}padFont{{end}}', '{{.Post.Font}}');
var doneTyping = function() {
if (draftDoc == 'lastDoc' || $writer.el.value != origDoc) {
H.save($writer, draftDoc);
updateWordCount();
}
};
window.addEventListener('beforeunload', function(e) {
if (draftDoc != 'lastDoc' && $writer.el.value == origDoc) {
H.remove(draftDoc);
} else if (!justPublished) {
doneTyping();
}
});
try {
(function() {
var wf=document.createElement('script');
wf.src = '/js/webfont.js';
wf.type='text/javascript';
wf.async='true';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {
// whatevs
}
</script>
</body>
</html>{{end}}

View file

@ -13,14 +13,49 @@
<body {{template "body-attrs" .}}> <body {{template "body-attrs" .}}>
<div id="overlay"></div> <div id="overlay"></div>
<header> <header>
<h2><a href="/">{{.SiteName}}</a></h2> {{ if .Chorus }}<nav id="full-nav">
<div class="left-side">
<h2><a href="/">{{.SiteName}}</a></h2>
</div>
{{ else }}
<h2><a href="/">{{.SiteName}}</a></h2>
{{ end }}
{{if not .SingleUser}} {{if not .SingleUser}}
<nav id="user-nav"> <nav id="user-nav">
<nav class="tabs"> {{if .Username}}
<a href="/about"{{if eq .Path "/about"}} class="selected"{{end}}>About</a> <nav class="dropdown-nav">
{{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>Reader</a>{{end}} <ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
{{if and (not .SingleUser) (not .Username)}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>Log in</a>{{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/export">Export</a></li>
{{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
<li class="separator"><hr /></li>
<li><a href="/me/logout">Log out</a></li>
</ul></li>
</ul>
</nav> </nav>
{{end}}
<nav class="tabs">
{{ if and .SimpleNav (not .SingleUser) }}
{{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>Home</a>{{end}}
{{ end }}
{{if or .Chorus (not .Username)}}<a href="/about"{{if eq .Path "/about"}} class="selected"{{end}}>About</a>{{end}}
{{ if not .SingleUser }}
{{ if .Username }}
{{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 not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
{{ end }}
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>Reader</a>{{end}}
{{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
{{if not .Username}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>Log in</a>{{else if .SimpleNav}}<a href="/me/logout">Log out</a>{{end}}
{{ end }}
</nav>
{{if .Chorus}}{{if .Username}}<div class="right-side" style="font-size: 0.86em;">
<a class="simple-btn" href="/new">New Post</a>
</div>{{end}}
</nav>
{{end}}
</nav> </nav>
{{end}} {{end}}
</header> </header>

View file

@ -0,0 +1,153 @@
{{define "post"}}<!DOCTYPE HTML>
<html {{if .Language.Valid}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">
<head prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article#">
<meta charset="utf-8">
<title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="canonical" href="{{.CanonicalURL .Host}}" />
<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="description" content="{{.Summary}}">
{{if gt .Views 1}}<meta name="twitter:label1" value="Views">
<meta name="twitter:data1" value="{{largeNumFmt .Views}}">{{end}}
<meta name="author" content="{{.Collection.Title}}" />
<meta itemprop="description" content="{{.Summary}}">
<meta itemprop="datePublished" content="{{.CreatedDate}}" />
<meta name="twitter:card" content="summary">
<meta name="twitter:description" content="{{.Summary}}">
<meta name="twitter:title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}">
{{if gt (len .Images) 0}}<meta name="twitter:image" content="{{index .Images 0}}">{{else}}<meta name="twitter:image" content="{{.Collection.AvatarURL}}">{{end}}
<meta property="og:title" content="{{.PlainDisplayTitle}}" />
<meta property="og:description" content="{{.Summary}}" />
<meta property="og:site_name" content="{{.Collection.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL .Host}}" />
<meta property="og:updated_time" content="{{.Created8601}}" />
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}}
<meta property="article:published_time" content="{{.Created8601}}">
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}}
<style type="text/css">
body footer {
max-width: 40rem;
margin: 0 auto;
}
body#post header {
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>
{{if .Collection.RenderMathJax}}
<!-- Add mathjax logic -->
{{template "mathjax" . }}
{{end}}
<!-- Add highlighting logic -->
{{template "highlighting" .}}
</head>
<body id="post">
<div id="overlay"></div>
{{template "user-navigation" .}}
{{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">{{.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 .Collection.ShowFooterBranding }}
<footer dir="ltr">
<p style="text-align: left">Published by <a rel="author" href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a>
{{ if .IsOwner }} &middot; <span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span>
&middot; <a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a>
{{if .IsPinned}} &middot; <a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">Unpin</a>{{end}}
{{ end }}
</p>
<nav>
{{if .PinnedPosts}}
{{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}
{{end}}
</nav>
<hr>
<nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav>
</footer>
{{ end }}
</body>
{{if .Collection.CanShowScript}}
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
{{end}}
<script type="text/javascript">
var pinning = false;
function unpinPost(e, postID) {
e.preventDefault();
if (pinning) {
return;
}
pinning = true;
var $footer = document.getElementsByTagName('footer')[0];
var callback = function() {
// Hide current page
var $pinnedNavLink = $footer.getElementsByTagName('nav')[0].querySelector('.pinned.selected');
$pinnedNavLink.style.display = 'none';
};
var $pinBtn = $footer.getElementsByClassName('unpin')[0];
$pinBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/collections/{{.Collection.Alias}}/unpin";
var params = [ { "id": postID } ];
http.open("POST", url, true);
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
pinning = false;
if (http.status == 200) {
callback();
$pinBtn.style.display = 'none';
$pinBtn.innerHTML = 'Pin';
} else if (http.status == 409) {
$pinBtn.innerHTML = 'Unpin';
} else {
$pinBtn.innerHTML = 'Unpin';
alert("Failed to unpin." + (http.status>=500?" Please try again.":""));
}
}
}
http.send(JSON.stringify(params));
};
try { // Fonts
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
</script>
</html>{{end}}

View file

@ -0,0 +1,233 @@
{{define "collection"}}<!DOCTYPE HTML>
<html {{if .Language}}lang="{{.Language}}"{{end}} dir="{{.Direction}}">
<head>
<meta charset="utf-8">
<title>{{.DisplayTitle}}{{if not .SingleUser}} &mdash; {{.SiteName}}{{end}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="canonical" href="{{.CanonicalURL}}">
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}}
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}}
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} &raquo; Feed" href="{{.CanonicalURL}}feed/" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content="WriteFreely">
<meta name="description" content="{{.Description}}">
<meta itemprop="name" content="{{.DisplayTitle}}">
<meta itemprop="description" content="{{.Description}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.DisplayTitle}}">
<meta name="twitter:image" content="{{.AvatarURL}}">
<meta name="twitter:description" content="{{.Description}}">
<meta property="og:title" content="{{.DisplayTitle}}" />
<meta property="og:site_name" content="{{.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}" />
<meta property="og:description" content="{{.Description}}" />
<meta property="og:image" content="{{.AvatarURL}}">
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
<style type="text/css">
body#collection header {
max-width: 40em;
margin: 1em auto;
text-align: left;
padding: 0;
}
body#collection header.multiuser {
max-width: 100%;
margin: 1em;
}
body#collection header nav:not(.pinned-posts) {
display: inline;
}
body#collection header nav.dropdown-nav,
body#collection header nav.tabs,
body#collection header nav.tabs a:first-child {
margin: 0 0 0 1em;
}
</style>
{{if .RenderMathJax}}
<!-- Add mathjax logic -->
{{template "mathjax" .}}
{{end}}
<!-- Add highlighting logic -->
{{template "highlighting" . }}
</head>
<body id="collection" itemscope itemtype="http://schema.org/WebPage">
{{template "user-navigation" .}}
{{if .Suspended}}
{{template "user-suspended"}}
{{end}}
<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>
{{if .Description}}<p class="description p-note">{{.Description}}</p>{{end}}
{{/*if not .Public/*}}
<!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p-->
{{/*end*/}}
{{if .PinnedPosts}}<nav class="pinned-posts">
{{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav>
{{end}}
</header>
{{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}}
{{if .IsWelcome}}
<div id="welcome">
<h2>Welcome, <strong>{{.Username}}</strong>!</h2>
<p>This is your new blog.</p>
<p><a class="simple-cta" href="/#{{.Alias}}">Start writing</a>, or <a class="simple-cta" href="/me/c/{{.Alias}}">customize</a> your blog.</p>
<p>Check out our <a class="simple-cta" href="https://guides.write.as/writing/?pk_campaign=welcome">writing guide</a> to see what else you can do, and <a class="simple-cta" href="/contact">get in touch</a> anytime with questions or feedback.</p>
</div>
{{end}}
{{template "posts" .}}
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
{{if or (and .Format.Ascending (lt .CurrentPage .TotalPages)) (isRTL .Direction)}}
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; {{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} &#8674;</a>{{end}}
{{else}}
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; Older</a>{{end}}
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer &#8674;</a>{{end}}
{{end}}
</nav>{{end}}
{{if .Posts}}</section>{{else}}</div>{{end}}
{{if .ShowFooterBranding }}
<footer>
<hr />
<nav dir="ltr">
{{if not .SingleUser}}<a class="home pubd" href="/">{{.SiteName}}</a> &middot; {{end}}powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a>
</nav>
</footer>
{{ end }}
</body>
{{if .CanShowScript}}
{{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
{{end}}
<script src="/js/h.js"></script>
<script src="/js/postactions.js"></script>
<script type="text/javascript">
var deleting = false;
function delPost(e, id, owned) {
e.preventDefault();
if (deleting) {
return;
}
// TODO: UNDO!
if (window.confirm('Are you sure you want to delete this post?')) {
// AJAX
deletePost(id, "", function() {
// Remove post from list
var $postEl = document.getElementById('post-' + id);
$postEl.parentNode.removeChild($postEl);
// TODO: add next post from this collection at the bottom
});
}
}
var deletePost = function(postID, token, callback) {
deleting = true;
var $delBtn = document.getElementById('post-' + postID).getElementsByClassName('delete action')[0];
$delBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/posts/" + postID;
http.open("DELETE", url, true);
http.onreadystatechange = function() {
if (http.readyState == 4) {
deleting = false;
if (http.status == 204) {
callback();
} else if (http.status == 409) {
$delBtn.innerHTML = 'delete';
alert("Post is synced to another account. Delete the post from that account instead.");
// TODO: show "remove" button instead of "delete" now
// Persist that state.
// Have it remove the post locally only.
} else {
$delBtn.innerHTML = 'delete';
alert("Failed to delete." + (http.status>=500?" Please try again.":""));
}
}
}
http.send();
};
var pinning = false;
function pinPost(e, postID, slug, title) {
e.preventDefault();
if (pinning) {
return;
}
pinning = true;
var callback = function() {
// Visibly remove post from collection
var $postEl = document.getElementById('post-' + postID);
$postEl.parentNode.removeChild($postEl);
var $header = document.querySelector('header:not(.multiuser)');
var $pinnedNavs = $header.getElementsByTagName('nav');
// Add link to nav
var link = '<a class="pinned" href="/{{.Alias}}/'+slug+'">'+title+'</a>';
if ($pinnedNavs.length == 0) {
$header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>');
} else {
$pinnedNavs[0].insertAdjacentHTML("beforeend", link);
}
};
var $pinBtn = document.getElementById('post-' + postID).getElementsByClassName('pin action')[0];
$pinBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/collections/{{.Alias}}/pin";
var params = [ { "id": postID } ];
http.open("POST", url, true);
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
pinning = false;
if (http.status == 200) {
callback();
} else if (http.status == 409) {
$pinBtn.innerHTML = 'pin';
alert("Post is synced to another account. Delete the post from that account instead.");
// TODO: show "remove" button instead of "delete" now
// Persist that state.
// Have it remove the post locally only.
} else {
$pinBtn.innerHTML = 'pin';
alert("Failed to pin." + (http.status>=500?" Please try again.":""));
}
}
}
http.send(JSON.stringify(params));
};
try {
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {}
</script>
</html>{{end}}

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,6 +59,9 @@
</nav> </nav>
</header> </header>
{{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">{{.FormattedDisplayTitle}}</h2>{{end}}<div class="e-content">{{.HTMLContent}}</div></article> <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 .Collection.ShowFooterBranding }} {{ if .Collection.ShowFooterBranding }}

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

View file

@ -48,6 +48,7 @@
{{else}} {{else}}
<li><a href="/#{{.Alias}}" class="write">{{.SiteName}}</a></li> <li><a href="/#{{.Alias}}" class="write">{{.SiteName}}</a></li>
{{end}} {{end}}
{{if .SimpleNav}}<li><a href="/new#{{.Alias}}">New Post</a></li>{{end}}
<li><a href="/me/c/{{.Alias}}">Customize</a></li> <li><a href="/me/c/{{.Alias}}">Customize</a></li>
<li><a href="/me/c/{{.Alias}}/stats">Stats</a></li> <li><a href="/me/c/{{.Alias}}/stats">Stats</a></li>
<li class="separator"><hr /></li> <li class="separator"><hr /></li>
@ -61,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>

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 currently supsended, editing posts is disabled.');
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

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

@ -25,6 +25,9 @@
</head> </head>
<body id="collection" itemscope itemtype="http://schema.org/WebPage"> <body id="collection" itemscope itemtype="http://schema.org/WebPage">
{{if .Suspended}}
{{template "user-supsended"}}
{{end}}
<header> <header>
<h1 dir="{{.Direction}}" id="blog-title"><a href="/{{.Alias}}/" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1> <h1 dir="{{.Direction}}" id="blog-title"><a href="/{{.Alias}}/" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1>
</header> </header>

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

@ -65,34 +65,39 @@
} }
body#collection header nav { body#collection header nav {
display: inline !important; display: inline !important;
}
body#collection header nav:not(#full-nav):not(#user-nav) {
margin: 0 0 0 1em !important; margin: 0 0 0 1em !important;
} }
header nav#user-nav { header nav#user-nav {
margin-left: 0 !important; margin-left: 0 !important;
} }
body#collection header nav.tabs a:first-child {
margin-left: 1em;
}
</style> </style>
{{end}} {{end}}
{{define "body-attrs"}}id="collection"{{end}} {{define "body-attrs"}}id="collection"{{end}}
{{define "content"}} {{define "content"}}
<div class="content-container snug" style="max-width: 40rem;"> <div class="content-container snug" style="max-width: 40rem;">
<h1 style="text-align:center">Reader</h1> <h1>{{.ContentTitle}}</h1>
<p>Read the latest posts from {{.SiteName}}. {{if .Username}}To showcase your writing here, go to your <a href="/me/c/">blog</a> settings and select the <em>Public</em> option.{{end}}</p> <p{{if .SelTopic}} style="text-align:center"{{end}}>{{if .SelTopic}}#{{.SelTopic}} posts{{else}}{{.Content}}{{end}}</p>
</div> </div>
<div id="wrapper"> <div id="wrapper">
{{ 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="{{.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>
{{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="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time></h2>
{{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 }}

View file

@ -20,6 +20,9 @@ table.classy.export .disabled, table.classy.export a {
<tr> <tr>
<td colspan="2"><a href="/admin/page/landing">Home</a></td> <td colspan="2"><a href="/admin/page/landing">Home</a></td>
</tr> </tr>
{{if .LocalTimeline}}<tr>
<td colspan="2"><a href="/admin/page/reader">Reader</a></td>
</tr>{{end}}
{{range .Pages}} {{range .Pages}}
<tr> <tr>
<td><a href="/admin/page/{{.ID}}">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.ID}}{{end}}</a></td> <td><a href="/admin/page/{{.ID}}">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.ID}}{{end}}</a></td>

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

@ -31,6 +31,8 @@ input[type=text] {
<p class="page-desc content-desc">Describe what your instance is <a href="/about" target="page">about</a>.</p> <p class="page-desc content-desc">Describe what your instance is <a href="/about" target="page">about</a>.</p>
{{else if eq .Content.ID "privacy"}} {{else if eq .Content.ID "privacy"}}
<p class="page-desc content-desc">Outline your <a href="/privacy" target="page">privacy policy</a>.</p> <p class="page-desc content-desc">Outline your <a href="/privacy" target="page">privacy policy</a>.</p>
{{else if eq .Content.ID "reader"}}
<p class="page-desc content-desc">Customize your <a href="/read" target="page">Reader</a> page.</p>
{{else if eq .Content.ID "landing"}} {{else if eq .Content.ID "landing"}}
<p class="page-desc content-desc">Customize your <a href="/?landing=1" target="page">home page</a>.</p> <p class="page-desc content-desc">Customize your <a href="/?landing=1" target="page">home page</a>.</p>
{{end}} {{end}}
@ -38,7 +40,7 @@ input[type=text] {
{{if .Message}}<p>{{.Message}}</p>{{end}} {{if .Message}}<p>{{.Message}}</p>{{end}}
<form method="post" action="/admin/update/{{.Content.ID}}" onsubmit="savePage(this)"> <form method="post" action="/admin/update/{{.Content.ID}}" onsubmit="savePage(this)">
{{if eq .Content.Type "section"}} {{if .Banner}}
<label for="banner"> <label for="banner">
Banner Banner
</label> </label>

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,6 +6,9 @@
{{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>

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,13 +7,16 @@
{{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>
<a class="title" href="/{{.Alias}}/">{{if .Title}}{{.Title}}{{else}}{{.Alias}}{{end}}</a> <a class="title" href="/{{.Alias}}/">{{if .Title}}{{.Title}}{{else}}{{.Alias}}{{end}}</a>
</h3> </h3>
<h4> <h4>
<a class="action new-post" href="/#{{.Alias}}">new post</a> <a class="action new-post" href="{{if $.Chorus}}/new{{else}}/{{end}}#{{.Alias}}">new post</a>
<a class="action" href="/me/c/{{.Alias}}">customize</a> <a class="action" href="/me/c/{{.Alias}}">customize</a>
<a class="action" href="/me/c/{{.Alias}}/stats">stats</a> <a class="action" href="/me/c/{{.Alias}}/stats">stats</a>
</h4> </h4>

View file

@ -1,21 +1,5 @@
{{define "header"}}<!DOCTYPE HTML> {{define "user-navigation"}}
<html> <header class="{{if .SingleUser}}singleuser{{else}}multiuser{{end}}">
<head>
<meta charset="utf-8">
<title>{{.PageTitle}} {{if .Separator}}{{.Separator}}{{else}}&mdash;{{end}} {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#888888" />
<meta name="apple-mobile-web-app-title" content="{{.SiteName}}">
<link rel="apple-touch-icon" sizes="152x152" href="/img/touch-icon-152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/img/touch-icon-167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/img/touch-icon-180.png">
</head>
<body id="me">
<header{{if .SingleUser}} class="singleuser"{{end}}>
{{if .SingleUser}} {{if .SingleUser}}
<nav id="user-nav"> <nav id="user-nav">
<nav class="dropdown-nav"> <nav class="dropdown-nav">
@ -39,8 +23,12 @@
</nav> </nav>
</nav> </nav>
{{else}} {{else}}
<h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1> <nav id="full-nav">
<div class="left-side">
<h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1>
</div>
<nav id="user-nav"> <nav id="user-nav">
{{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}}
@ -53,13 +41,56 @@
</ul></li> </ul></li>
</ul> </ul>
</nav> </nav>
{{end}}
<nav class="tabs"> <nav class="tabs">
<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a> {{if .SimpleNav}}
<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a> {{ if not .SingleUser }}
{{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>Home</a>{{end}}
{{ end }}
<a href="/about">About</a>
{{ if not .SingleUser }}
{{ if .Username }}
{{if gt .MaxBlogs 1}}<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>{{end}}
{{if and .Chorus (eq .MaxBlogs 1)}}<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}}
{{ end }}
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
{{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
{{if .Username}}<a href="/me/logout">Log out</a>{{else}}<a href="/login">Log in</a>{{end}}
{{ end }}
{{else}}
<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 and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
{{end}}
</nav> </nav>
</nav> </nav>
{{if .Chorus}}{{if .Username}}<div class="right-side">
<a class="simple-btn" href="/new">New Post</a>
</div>{{end}}
</nav>
{{end}}
{{end}} {{end}}
</header> </header>
{{end}}
{{define "header"}}<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>{{.PageTitle}} {{if .Separator}}{{.Separator}}{{else}}&mdash;{{end}} {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#888888" />
<meta name="apple-mobile-web-app-title" content="{{.SiteName}}">
<link rel="apple-touch-icon" sizes="152x152" href="/img/touch-icon-152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/img/touch-icon-167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/img/touch-icon-180.png">
</head>
<body id="me">
{{template "user-navigation" .}}
<div id="official-writing"> <div id="official-writing">
{{end}} {{end}}

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

@ -0,0 +1,32 @@
{{define "invite-help"}}
{{template "header" .}}
<style>
.copy-link {
width: 100%;
margin: 1rem 0;
text-align: center;
font-size: 1.2em;
color: #555;
}
</style>
<div class="snug content-container">
<h1>Invite to {{.SiteName}}</h1>
{{ if .Expired }}
<p style="font-style: italic">This invite link is expired.</p>
{{ else }}
<p>Copy the link below and send it to anyone that you want to join <em>{{ .SiteName }}</em>. You could paste it into an email, instant message, text message, or write it down on paper. Anyone who navigates to this special page will be able to create an account.</p>
<input class="copy-link" type="text" name="invite-url" value="{{$.Host}}/invite/{{.Invite.ID}}" onfocus="if (this.select) this.select(); else this.setSelectionRange(0, this.value.length);" readonly />
<p>
{{ if gt .Invite.MaxUses.Int64 0 }}
{{if eq .Invite.MaxUses.Int64 1}}Only <strong>one</strong> user{{else}}Up to <strong>{{.Invite.MaxUses.Int64}}</strong> users{{end}} can sign up with this link.
{{if gt .Invite.Uses 0}}So far, <strong>{{.Invite.Uses}}</strong> {{pluralize "person has" "people have" .Invite.Uses}} used it.{{end}}
{{if .Invite.Expires}}It expires on <strong>{{.Invite.ExpiresFriendly}}</strong>.{{end}}
{{ else }}
It can be used as many times as you like{{if .Invite.Expires}} before <strong>{{.Invite.ExpiresFriendly}}</strong>, when it expires{{end}}.
{{ end }}
</p>
{{ end }}
</div>
{{template "footer" .}}
{{end}}

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
@ -47,6 +48,9 @@ func handleWebSignup(app *App, w http.ResponseWriter, r *http.Request) error {
ur.Normalize = true ur.Normalize = true
to := "/" to := "/"
if app.cfg.App.SimpleNav {
to = "/new"
}
if ur.InviteCode != "" { if ur.InviteCode != "" {
to = "/invite/" + ur.InviteCode to = "/invite/" + ur.InviteCode
} }
@ -68,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

@ -11,11 +11,12 @@
package writefreely package writefreely
import ( import (
"net/http"
"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 +38,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