diff --git a/collections.go b/collections.go index e1ebe48..5fe5d24 100644 --- a/collections.go +++ b/collections.go @@ -1,1160 +1,1204 @@ /* * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "encoding/json" "fmt" "html/template" "math" "net/http" "net/url" "regexp" "strconv" "strings" "unicode" "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/bots" "github.com/writeas/web-core/log" waposts "github.com/writeas/web-core/posts" "github.com/writeas/writefreely/author" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" ) type ( // TODO: add Direction to db // TODO: add Language to db Collection struct { ID int64 `datastore:"id" json:"-"` Alias string `datastore:"alias" schema:"alias" json:"alias"` Title string `datastore:"title" schema:"title" json:"title"` Description string `datastore:"description" schema:"description" json:"description"` Direction string `schema:"dir" json:"dir,omitempty"` Language string `schema:"lang" json:"lang,omitempty"` StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"` Script string `datastore:"script" schema:"script" json:"script,omitempty"` Signature string `datastore:"post_signature" schema:"signature" json:"-"` Public bool `datastore:"public" json:"public"` Visibility collVisibility `datastore:"private" json:"-"` Format string `datastore:"format" json:"format,omitempty"` Views int64 `json:"views"` OwnerID int64 `datastore:"owner_id" json:"-"` PublicOwner bool `datastore:"public_owner" json:"-"` URL string `json:"url,omitempty"` MonetizationPointer string `json:"monetization_pointer,omitempty"` db *datastore hostName string } CollectionObj struct { Collection TotalPosts int `json:"total_posts"` Owner *User `json:"owner,omitempty"` Posts *[]PublicPost `json:"posts,omitempty"` Format *CollectionFormat } DisplayCollection struct { *CollectionObj Prefix string IsTopLevel bool CurrentPage int TotalPages int Silenced bool } SubmittedCollection struct { // Data used for updating a given collection ID int64 OwnerID uint64 // Form helpers PreferURL string `schema:"prefer_url" json:"prefer_url"` Privacy int `schema:"privacy" json:"privacy"` Pass string `schema:"password" json:"password"` MathJax bool `schema:"mathjax" json:"mathjax"` Handle string `schema:"handle" json:"handle"` // Actual collection values updated in the DB Alias *string `schema:"alias" json:"alias"` Title *string `schema:"title" json:"title"` Description *string `schema:"description" json:"description"` StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` Script *sql.NullString `schema:"script" json:"script"` Signature *sql.NullString `schema:"signature" json:"signature"` Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"` Visibility *int `schema:"visibility" json:"public"` Format *sql.NullString `schema:"format" json:"format"` } CollectionFormat struct { Format string } collectionReq struct { // Information about the collection request itself prefix, alias, domain string isCustomDomain bool // User-related fields isCollOwner bool + + isAuthorized bool } ) func (sc *SubmittedCollection) FediverseHandle() string { if sc.Handle == "" { return apCustomHandleDefault } return getSlug(sc.Handle, "") } // collVisibility represents the visibility level for the collection. type collVisibility int // Visibility levels. Values are bitmasks, stored in the database as // decimal numbers. If adding types, append them to this list. If removing, // replace the desired visibility with a new value. const CollUnlisted collVisibility = 0 const ( CollPublic collVisibility = 1 << iota CollPrivate CollProtected ) var collVisibilityStrings = map[string]collVisibility{ "unlisted": CollUnlisted, "public": CollPublic, "private": CollPrivate, "protected": CollProtected, } func defaultVisibility(cfg *config.Config) collVisibility { vis, ok := collVisibilityStrings[cfg.App.DefaultVisibility] if !ok { vis = CollUnlisted } return vis } func (cf *CollectionFormat) Ascending() bool { return cf.Format == "novel" } func (cf *CollectionFormat) ShowDates() bool { return cf.Format == "blog" } func (cf *CollectionFormat) PostsPerPage() int { if cf.Format == "novel" { return postsPerPage } return postsPerPage } // Valid returns whether or not a format value is valid. func (cf *CollectionFormat) Valid() bool { return cf.Format == "blog" || cf.Format == "novel" || cf.Format == "notebook" } // NewFormat creates a new CollectionFormat object from the Collection. func (c *Collection) NewFormat() *CollectionFormat { cf := &CollectionFormat{Format: c.Format} // Fill in default format if cf.Format == "" { cf.Format = "blog" } return cf } func (c *Collection) IsUnlisted() bool { return c.Visibility == 0 } func (c *Collection) IsPrivate() bool { return c.Visibility&CollPrivate != 0 } func (c *Collection) IsProtected() bool { return c.Visibility&CollProtected != 0 } func (c *Collection) IsPublic() bool { return c.Visibility&CollPublic != 0 } func (c *Collection) FriendlyVisibility() string { if c.IsPrivate() { return "Private" } if c.IsPublic() { return "Public" } if c.IsProtected() { return "Password-protected" } return "Unlisted" } func (c *Collection) ShowFooterBranding() bool { // TODO: implement this setting return true } // CanonicalURL returns a fully-qualified URL to the collection. func (c *Collection) CanonicalURL() string { return c.RedirectingCanonicalURL(false) } func (c *Collection) DisplayCanonicalURL() string { us := c.CanonicalURL() u, err := url.Parse(us) if err != nil { return us } p := u.Path if p == "/" { p = "" } return u.Hostname() + p } func (c *Collection) RedirectingCanonicalURL(isRedir bool) string { if c.hostName == "" { // If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writeas/writefreely/issues/new?template=bug_report.md") } if isSingleUser { return c.hostName + "/" } return fmt.Sprintf("%s/%s/", c.hostName, c.Alias) } // PrevPageURL provides a full URL for the previous page of collection posts, // returning a /page/N result for pages >1 func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string { u := "" if n == 2 { // Previous page is 1; no need for /page/ prefix if prefix == "" { u = "/" } // Else leave off trailing slash } else { u = fmt.Sprintf("/page/%d", n-1) } if tl { return u } return "/" + prefix + c.Alias + u } // NextPageURL provides a full URL for the next page of collection posts func (c *Collection) NextPageURL(prefix string, n int, tl bool) string { if tl { return fmt.Sprintf("/page/%d", n+1) } return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1) } func (c *Collection) DisplayTitle() string { if c.Title != "" { return c.Title } return c.Alias } func (c *Collection) StyleSheetDisplay() template.CSS { return template.CSS(c.StyleSheet) } // ForPublic modifies the Collection for public consumption, such as via // the API. func (c *Collection) ForPublic() { c.URL = c.CanonicalURL() } var isAvatarChar = regexp.MustCompile("[a-z0-9]").MatchString func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person { accountRoot := c.FederatedAccount() p := activitystreams.NewPerson(accountRoot) p.URL = c.CanonicalURL() uname := c.Alias p.PreferredUsername = uname p.Name = c.DisplayTitle() p.Summary = c.Description if p.Name != "" { if av := c.AvatarURL(); av != "" { p.Icon = activitystreams.Image{ Type: "Image", MediaType: "image/png", URL: av, } } } collID := c.ID if len(ids) > 0 { collID = ids[0] } pub, priv := c.db.GetAPActorKeys(collID) if pub != nil { p.AddPubKey(pub) p.SetPrivKey(priv) } return p } func (c *Collection) AvatarURL() string { fl := string(unicode.ToLower([]rune(c.DisplayTitle())[0])) if !isAvatarChar(fl) { return "" } return c.hostName + "/img/avatars/" + fl + ".png" } func (c *Collection) FederatedAPIBase() string { return c.hostName + "/" } func (c *Collection) FederatedAccount() string { accountUser := c.Alias return c.FederatedAPIBase() + "api/collections/" + accountUser } func (c *Collection) RenderMathJax() bool { return c.db.CollectionHasAttribute(c.ID, "render_mathjax") } func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) alias := r.FormValue("alias") title := r.FormValue("title") var missingParams, accessToken string var u *User c := struct { Alias string `json:"alias" schema:"alias"` Title string `json:"title" schema:"title"` Web bool `json:"web" schema:"web"` }{} if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err := decoder.Decode(&c) if err != nil { log.Error("Couldn't parse post update JSON request: %v\n", err) return ErrBadJSON } } else { // TODO: move form parsing to formDecoder c.Alias = alias c.Title = title } if c.Alias == "" { if c.Title != "" { // If only a title was given, just use it to generate the alias. c.Alias = getSlug(c.Title, "") } else { missingParams += "`alias` " } } if c.Title == "" { missingParams += "`title` " } if missingParams != "" { return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)} } var userID int64 var err error if reqJSON && !c.Web { accessToken = r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } userID = app.db.GetUserID(accessToken) if userID == -1 { return ErrBadAccessToken } } else { u = getUserSession(app, r) if u == nil { return ErrNotLoggedIn } userID = u.ID } silenced, err := app.db.IsUserSilenced(userID) if err != nil { log.Error("new collection: %v", err) return ErrInternalGeneral } if silenced { return ErrUserSilenced } if !author.IsValidUsername(app.cfg, c.Alias) { return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."} } coll, err := app.db.CreateCollection(app.cfg, c.Alias, c.Title, userID) if err != nil { // TODO: handle this return err } res := &CollectionObj{Collection: *coll} if reqJSON { return impart.WriteSuccess(w, res, http.StatusCreated) } redirectTo := "/me/c/" // TODO: redirect to pad when necessary return impart.HTTPError{http.StatusFound, redirectTo} } func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (int64, error) { accessToken := r.Header.Get("Authorization") var userID int64 = -1 if accessToken != "" { userID = app.db.GetUserID(accessToken) } isCollOwner := userID == c.OwnerID if c.IsPrivate() && !isCollOwner { // Collection is private, but user isn't authenticated return -1, ErrCollectionNotFound } if c.IsProtected() { // TODO: check access token return -1, ErrCollectionUnauthorizedRead } return userID, nil } // fetchCollection handles the API endpoint for retrieving collection data. func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error { accept := r.Header.Get("Accept") if strings.Contains(accept, "application/activity+json") { return handleFetchCollectionActivities(app, w, r) } vars := mux.Vars(r) alias := vars["alias"] // TODO: move this logic into a common getCollection function // Get base Collection data c, err := app.db.GetCollection(alias) if err != nil { return err } c.hostName = app.cfg.App.Host // Redirect users who aren't requesting JSON reqJSON := IsJSON(r) if !reqJSON { return impart.HTTPError{http.StatusFound, c.CanonicalURL()} } // Check permissions userID, err := apiCheckCollectionPermissions(app, r, c) if err != nil { return err } isCollOwner := userID == c.OwnerID // Fetch extra data about the Collection res := &CollectionObj{Collection: *c} if c.PublicOwner { u, err := app.db.GetUserByID(res.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } else { res.Owner = u } } // TODO: check status for silenced app.db.GetPostsCount(res, isCollOwner) // Strip non-public information res.Collection.ForPublic() return impart.WriteSuccess(w, res, http.StatusOK) } // fetchCollectionPosts handles an API endpoint for retrieving a collection's // posts. func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) alias := vars["alias"] c, err := app.db.GetCollection(alias) if err != nil { return err } c.hostName = app.cfg.App.Host // Check permissions userID, err := apiCheckCollectionPermissions(app, r, c) if err != nil { return err } isCollOwner := userID == c.OwnerID // Get page page := 1 if p := r.FormValue("page"); p != "" { pInt, _ := strconv.Atoi(p) if pInt > 0 { page = pInt } } posts, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false) if err != nil { return err } coll := &CollectionObj{Collection: *c, Posts: posts} app.db.GetPostsCount(coll, isCollOwner) // Strip non-public information coll.Collection.ForPublic() // Transform post bodies if needed if r.FormValue("body") == "html" { for _, p := range *coll.Posts { p.Content = waposts.ApplyMarkdown([]byte(p.Content)) } } return impart.WriteSuccess(w, coll, http.StatusOK) } type CollectionPage struct { page.StaticPage *DisplayCollection IsCustomDomain bool IsWelcome bool IsOwner bool + IsCollLoggedIn bool CanPin bool Username string Monetization string Collections *[]Collection PinnedPosts *[]PublicPost IsAdmin bool CanInvite bool } func NewCollectionObj(c *Collection) *CollectionObj { return &CollectionObj{ Collection: *c, Format: c.NewFormat(), } } func (c *CollectionObj) ScriptDisplay() template.JS { return template.JS(c.Script) } var jsSourceCommentReg = regexp.MustCompile("(?m)^// src:(.+)$") func (c *CollectionObj) ExternalScripts() []template.URL { scripts := []template.URL{} if c.Script == "" { return scripts } matches := jsSourceCommentReg.FindAllStringSubmatch(c.Script, -1) for _, m := range matches { scripts = append(scripts, template.URL(strings.TrimSpace(m[1]))) } return scripts } func (c *CollectionObj) CanShowScript() bool { return false } func processCollectionRequest(cr *collectionReq, vars map[string]string, w http.ResponseWriter, r *http.Request) error { cr.prefix = vars["prefix"] cr.alias = vars["collection"] // Normalize the URL, redirecting user to consistent post URL if cr.alias != strings.ToLower(cr.alias) { return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", strings.ToLower(cr.alias))} } return nil } // processCollectionPermissions checks the permissions for the given // collectionReq, returning a Collection if access is granted; otherwise this // renders any necessary collection pages, for example, if requesting a custom // domain that doesn't yet have a collection associated, or if a collection // requires a password. In either case, this will return nil, nil -- thus both // values should ALWAYS be checked to determine whether or not to continue. func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) { // Display collection if this is a collection var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(cr.alias) } // TODO: verify we don't reveal the existence of a private collection with redirection if err != nil { if err, ok := err.(impart.HTTPError); ok { if err.Status == http.StatusNotFound { if cr.isCustomDomain { // User is on the site from a custom domain //tErr := pages["404-domain.tmpl"].ExecuteTemplate(w, "base", pageForHost(page.StaticPage{}, r)) //if tErr != nil { //log.Error("Unable to render 404-domain page: %v", err) //} return nil, nil } if len(cr.alias) >= minIDLen && len(cr.alias) <= maxIDLen { // Alias is within post ID range, so just be sure this isn't a post if app.db.PostIDExists(cr.alias) { // TODO: use StatusFound for vanity post URLs when we implement them return nil, impart.HTTPError{http.StatusMovedPermanently, "/" + cr.alias} } } // Redirect if necessary newAlias := app.db.GetCollectionRedirect(cr.alias) if newAlias != "" { return nil, impart.HTTPError{http.StatusFound, "/" + newAlias + "/"} } } } return nil, err } c.hostName = app.cfg.App.Host // Update CollectionRequest to reflect owner status cr.isCollOwner = u != nil && u.ID == c.OwnerID // Check permissions if !cr.isCollOwner { if c.IsPrivate() { return nil, ErrCollectionNotFound } else if c.IsProtected() { uname := "" if u != nil { uname = u.Username } // TODO: move this to all permission checks? suspended, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("process protected collection permissions: %v", err) return nil, err } if suspended { return nil, ErrCollectionNotFound } // See if we've authorized this collection - authd := isAuthorizedForCollection(app, c.Alias, r) + cr.isAuthorized = isAuthorizedForCollection(app, c.Alias, r) - if !authd { + if !cr.isAuthorized { p := struct { page.StaticPage *CollectionObj Username string Next string Flashes []template.HTML }{ StaticPage: pageForReq(app, r), CollectionObj: &CollectionObj{Collection: *c}, Username: uname, Next: r.FormValue("g"), Flashes: []template.HTML{}, } // Get owner information p.CollectionObj.Owner, err = app.db.GetUserByID(c.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } flashes, _ := getSessionFlashes(app, w, r, nil) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } err = templates["password-collection"].ExecuteTemplate(w, "password-collection", p) if err != nil { log.Error("Unable to render password-collection: %v", err) return nil, err } return nil, nil } } } return c, nil } func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) { u := getUserSession(app, r) return u, nil } func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection { coll := &DisplayCollection{ CollectionObj: NewCollectionObj(c), CurrentPage: page, Prefix: cr.prefix, IsTopLevel: isSingleUser, } c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner) return coll } // getCollectionPage returns the collection page as an int. If the parsed page value is not // greater than 0 then the default value of 1 is returned. func getCollectionPage(vars map[string]string) int { if p, _ := strconv.Atoi(vars["page"]); p > 0 { return p } return 1 } // handleViewCollection displays the requested Collection func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } u, err := checkUserForCollection(app, cr, r, false) if err != nil { return err } page := getCollectionPage(vars) c, err := processCollectionPermissions(app, cr, u, w, r) if c == nil || err != nil { return err } c.hostName = app.cfg.App.Host silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("view collection: %v", err) return ErrInternalGeneral } // Serve ActivityStreams data now, if requested if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { ac := c.PersonObject() ac.Context = []interface{}{activitystreams.Namespace} setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ac, http.StatusOK) } // Fetch extra data about the Collection // TODO: refactor out this logic, shared in collection.go:fetchCollection() coll := newDisplayCollection(c, cr, page) coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage()))) if coll.TotalPages > 0 && page > coll.TotalPages { redirURL := fmt.Sprintf("/page/%d", coll.TotalPages) if !app.cfg.App.SingleUser { redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL) } return impart.HTTPError{http.StatusFound, redirURL} } coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false) // Serve collection displayPage := CollectionPage{ DisplayCollection: coll, + IsCollLoggedIn: cr.isAuthorized, StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, IsWelcome: r.FormValue("greeting") != "", } displayPage.IsAdmin = u != nil && u.IsAdmin() displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin) var owner *User if u != nil { displayPage.Username = u.Username displayPage.IsOwner = u.ID == coll.OwnerID if displayPage.IsOwner { // Add in needed information for users viewing their own collection owner = u displayPage.CanPin = true pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } displayPage.Collections = pubColls } } isOwner := owner != nil if !isOwner { // Current user doesn't own collection; retrieve owner information owner, err = app.db.GetUserByID(coll.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } } if !isOwner && silenced { return ErrCollectionNotFound } displayPage.Silenced = isOwner && silenced displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") collTmpl := "collection" if app.cfg.App.Chorus { collTmpl = "chorus-collection" } err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage) if err != nil { log.Error("Unable to render collection index: %v", err) } // Update collection view count go func() { // Don't update if owner is viewing the collection. if u != nil && u.ID == coll.OwnerID { return } // Only update for human views if r.Method == "HEAD" || bots.IsBot(r.UserAgent()) { return } _, err := app.db.Exec("UPDATE collections SET view_count = view_count + 1 WHERE id = ?", coll.ID) if err != nil { log.Error("Unable to update collections count: %v", err) } }() return err } func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) handle := vars["handle"] remoteUser, err := app.db.GetProfilePageFromHandle(app, handle) if err != nil || remoteUser == "" { log.Error("Couldn't find user %s: %v", handle, err) return ErrRemoteUserNotFound } return impart.HTTPError{Status: http.StatusFound, Message: remoteUser} } func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) tag := vars["tag"] cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } u, err := checkUserForCollection(app, cr, r, false) if err != nil { return err } page := getCollectionPage(vars) c, err := processCollectionPermissions(app, cr, u, w, r) if c == nil || err != nil { return err } coll := newDisplayCollection(c, cr, page) coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner) if coll.Posts != nil && len(*coll.Posts) == 0 { return ErrCollectionPageNotFound } // Serve collection displayPage := struct { CollectionPage Tag string }{ CollectionPage: CollectionPage{ DisplayCollection: coll, StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, }, Tag: tag, } var owner *User if u != nil { displayPage.Username = u.Username displayPage.IsOwner = u.ID == coll.OwnerID if displayPage.IsOwner { // Add in needed information for users viewing their own collection owner = u displayPage.CanPin = true pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } displayPage.Collections = pubColls } } isOwner := owner != nil if !isOwner { // Current user doesn't own collection; retrieve owner information owner, err = app.db.GetUserByID(coll.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } if owner.IsSilenced() { return ErrCollectionNotFound } } displayPage.Silenced = owner != nil && owner.IsSilenced() displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage) if err != nil { log.Error("Unable to render collection tag page: %v", err) } return nil } func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) slug := vars["slug"] cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } // Normalize the URL, redirecting user to consistent post URL loc := fmt.Sprintf("/%s", slug) if !app.cfg.App.SingleUser { loc = fmt.Sprintf("/%s/%s", cr.alias, slug) } return impart.HTTPError{http.StatusFound, loc} } func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) collAlias := vars["alias"] isWeb := r.FormValue("web") == "1" u := &User{} if reqJSON && !isWeb { // Ensure an access token was given accessToken := r.Header.Get("Authorization") u.ID = app.db.GetUserID(accessToken) if u.ID == -1 { return ErrBadAccessToken } } else { u = getUserSession(app, r) if u == nil { return ErrNotLoggedIn } } silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("existing collection: %v", err) return ErrInternalGeneral } if silenced { return ErrUserSilenced } if r.Method == "DELETE" { err := app.db.DeleteCollection(collAlias, u.ID) if err != nil { // TODO: if not HTTPError, report error to admin log.Error("Unable to delete collection: %s", err) return err } addSessionFlash(app, w, r, "Deleted your blog, "+collAlias+".", nil) return impart.HTTPError{Status: http.StatusNoContent} } c := SubmittedCollection{OwnerID: uint64(u.ID)} if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err = decoder.Decode(&c) if err != nil { log.Error("Couldn't parse collection update JSON request: %v\n", err) return ErrBadJSON } } else { err = r.ParseForm() if err != nil { log.Error("Couldn't parse collection update form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&c, r.PostForm) if err != nil { log.Error("Couldn't decode collection update form request: %v\n", err) return ErrBadFormData } } err = app.db.UpdateCollection(&c, collAlias) if err != nil { if err, ok := err.(impart.HTTPError); ok { if reqJSON { return err } addSessionFlash(app, w, r, err.Message, nil) return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias} } else { log.Error("Couldn't update collection: %v\n", err) return err } } if reqJSON { return impart.WriteSuccess(w, struct { }{}, http.StatusOK) } addSessionFlash(app, w, r, "Blog updated!", nil) return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias} } // collectionAliasFromReq takes a request and returns the collection alias // if it can be ascertained, as well as whether or not the collection uses a // custom domain. func collectionAliasFromReq(r *http.Request) string { vars := mux.Vars(r) alias := vars["subdomain"] isSubdomain := alias != "" if !isSubdomain { // Fall back to write.as/{collection} since this isn't a custom domain alias = vars["collection"] } return alias } func handleWebCollectionUnlock(app *App, w http.ResponseWriter, r *http.Request) error { var readReq struct { Alias string `schema:"alias" json:"alias"` Pass string `schema:"password" json:"password"` Next string `schema:"to" json:"to"` } // Get params if impart.ReqJSON(r) { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&readReq) if err != nil { log.Error("Couldn't parse readReq JSON request: %v\n", err) return ErrBadJSON } } else { err := r.ParseForm() if err != nil { log.Error("Couldn't parse readReq form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&readReq, r.PostForm) if err != nil { log.Error("Couldn't decode readReq form request: %v\n", err) return ErrBadFormData } } if readReq.Alias == "" { return impart.HTTPError{http.StatusBadRequest, "Need a collection `alias` to read."} } if readReq.Pass == "" { return impart.HTTPError{http.StatusBadRequest, "Please supply a password."} } var collHashedPass []byte err := app.db.QueryRow("SELECT password FROM collectionpasswords INNER JOIN collections ON id = collection_id WHERE alias = ?", readReq.Alias).Scan(&collHashedPass) if err != nil { if err == sql.ErrNoRows { log.Error("No collectionpassword found when trying to read collection %s", readReq.Alias) return impart.HTTPError{http.StatusInternalServerError, "Something went very wrong. The humans have been alerted."} } return err } if !auth.Authenticated(collHashedPass, []byte(readReq.Pass)) { return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} } // Success; set cookie session, err := app.sessionStore.Get(r, blogPassCookieName) if err == nil { session.Values[readReq.Alias] = true err = session.Save(r, w) if err != nil { log.Error("Didn't save unlocked blog '%s': %v", readReq.Alias, err) } } next := "/" + readReq.Next if !app.cfg.App.SingleUser { next = "/" + readReq.Alias + next } return impart.HTTPError{http.StatusFound, next} } func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool { authd := false session, err := app.sessionStore.Get(r, blogPassCookieName) if err == nil { _, authd = session.Values[alias] } return authd } + +func logOutCollection(app *App, alias string, w http.ResponseWriter, r *http.Request) error { + session, err := app.sessionStore.Get(r, blogPassCookieName) + if err != nil { + return err + } + + // Remove this from map of blogs logged into + delete(session.Values, alias) + + // If not auth'd with any blog, delete entire cookie + if len(session.Values) == 0 { + session.Options.MaxAge = -1 + } + return session.Save(r, w) +} + +func handleLogOutCollection(app *App, w http.ResponseWriter, r *http.Request) error { + alias := collectionAliasFromReq(r) + var c *Collection + var err error + if app.cfg.App.SingleUser { + c, err = app.db.GetCollectionByID(1) + } else { + c, err = app.db.GetCollection(alias) + } + if err != nil { + return err + } + if !c.IsProtected() { + // Invalid to log out of this collection + return ErrCollectionPageNotFound + } + + err = logOutCollection(app, c.Alias, w, r) + if err != nil { + addSessionFlash(app, w, r, "Logging out failed. Try clearing cookies for this site, instead.", nil) + } + return impart.HTTPError{http.StatusFound, c.CanonicalURL()} +} diff --git a/routes.go b/routes.go index bb1785f..5530552 100644 --- a/routes.go +++ b/routes.go @@ -1,226 +1,227 @@ /* * Copyright © 2018-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 writefreely import ( "net/http" "path/filepath" "strings" "github.com/gorilla/mux" "github.com/writeas/go-webfinger" "github.com/writeas/web-core/log" "github.com/writefreely/go-nodeinfo" ) // InitStaticRoutes adds routes for serving static files. // TODO: this should just be a func, not method func (app *App) InitStaticRoutes(r *mux.Router) { // Handle static files fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir))) fs = cacheControl(fs) app.shttp = http.NewServeMux() app.shttp.Handle("/", fs) r.PathPrefix("/").Handler(fs) } // InitRoutes adds dynamic routes for the given mux.Router. func InitRoutes(apper Apper, r *mux.Router) *mux.Router { // Create handler handler := NewWFHandler(apper) // Set up routes hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:] if apper.App().cfg.App.SingleUser { hostSubroute = "{domain}" } else { if strings.HasPrefix(hostSubroute, "localhost") { hostSubroute = "localhost" } } if apper.App().cfg.App.SingleUser { log.Info("Adding %s routes (single user)...", hostSubroute) } else { log.Info("Adding %s routes (multi-user)...", hostSubroute) } // Primary app routes write := r.PathPrefix("/").Subrouter() // Federation endpoint configurations wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg}) wf.NoTLSHandler = nil // Federation endpoints // host-meta write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader)) // webfinger write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger))) // nodeinfo niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg) ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db}) write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover))) write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo))) // handle mentions write.HandleFunc("/@/{handle}", handler.Web(handleViewMention, UserLevelReader)) configureSlackOauth(handler, write, apper.App()) configureWriteAsOauth(handler, write, apper.App()) configureGitlabOauth(handler, write, apper.App()) configureGenericOauth(handler, write, apper.App()) configureGiteaOauth(handler, write, apper.App()) // Set up dyamic page handlers // Handle auth auth := write.PathPrefix("/api/auth/").Subrouter() if apper.App().cfg.App.OpenRegistration { auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST") } auth.HandleFunc("/login", handler.All(login)).Methods("POST") auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST") auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE") // Handle logged in user sections me := write.PathPrefix("/me").Subrouter() me.HandleFunc("/", handler.Redirect("/me", UserLevelUser)) me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET") me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET") me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET") me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET") me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET") me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET") me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET") me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET") me.HandleFunc("/import", handler.User(viewImport)).Methods("GET") me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET") me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET") me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET") write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET") apiMe := write.PathPrefix("/api/me/").Subrouter() apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET") apiMe.HandleFunc("/posts", handler.UserAPI(viewMyPostsAPI)).Methods("GET") apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET") apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST") apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST") apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST") apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST") apiMe.HandleFunc("/oauth/remove", handler.User(removeOauth)).Methods("POST") // Sign up validation write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST") // Handle collections write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST") apiColls := write.PathPrefix("/api/collections/").Subrouter() apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE") apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET") apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET") apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST") apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST") apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET") apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET") apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET") // Handle posts write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST") posts := write.PathPrefix("/api/posts/").Subrouter() posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.AllReader(fetchPost)).Methods("GET") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST") posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST") write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") write.HandleFunc("/admin/monitor", handler.Admin(handleViewAdminMonitor)).Methods("GET") write.HandleFunc("/admin/settings", handler.Admin(handleViewAdminSettings)).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}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST") write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).Methods("POST") write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET") write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET") write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST") write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST") write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET") // Handle special pages first write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") // TODO: show a reader-specific 404 page if the function is disabled write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader)) RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter()) draftEditPrefix := "" if apper.App().cfg.App.SingleUser { draftEditPrefix = "/d" write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") } else { write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") } // All the existing stuff write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET") // Collections if apper.App().cfg.App.SingleUser { RouteCollections(handler, write.PathPrefix("/").Subrouter()) } else { write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader)) write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader)) RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter()) // Posts } write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional)) write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional)) return r } func RouteCollections(handler *Handler, r *mux.Router) { + r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional)) r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) r.HandleFunc("/{slug}", handler.CollectionPostOrStatic) r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser)) r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser)) r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET") } func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) { r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm)) r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm)) r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm)) r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm)) } diff --git a/templates/collection.tmpl b/templates/collection.tmpl index 42664e7..493e6b7 100644 --- a/templates/collection.tmpl +++ b/templates/collection.tmpl @@ -1,236 +1,251 @@ {{define "collection"}} {{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}} {{if gt .CurrentPage 1}}{{end}} {{if lt .CurrentPage .TotalPages}}{{end}} {{if not .IsPrivate}}{{end}} {{template "collection-meta" .}} {{if .StyleSheet}}{{end}} {{if .RenderMathJax}} {{template "mathjax" .}} {{end}} {{template "highlighting" . }} - {{if or .IsOwner .SingleUser}}{{end}} + {{if or .IsOwner .SingleUser}} + + {{else if .IsCollLoggedIn}} + + {{end}}
{{if .Silenced}} {{template "user-silenced"}} {{end}}

{{if .Posts}}{{else}}write.as {{end}}{{.DisplayTitle}}

{{if .Description}}

{{.Description}}

{{end}} {{/*if not .Public/*}} {{/*end*/}} {{if .PinnedPosts}} {{end}}
{{if .Posts}}
{{else}}
{{end}} {{if .IsWelcome}}

Welcome, {{.Username}}!

This is your new blog.

Start writing, or customize your blog.

Check out our writing guide to see what else you can do, and get in touch anytime with questions or feedback.

{{end}} {{template "posts" .}} {{if gt .TotalPages 1}}{{end}} {{if .Posts}}
{{else}}{{end}} {{if .ShowFooterBranding }} {{ end }} {{if .CanShowScript}} {{range .ExternalScripts}}{{end}} {{if .Script}}{{end}} {{end}} {{end}} diff --git a/templates/password-collection.tmpl b/templates/password-collection.tmpl index e0b755d..c1c9083 100644 --- a/templates/password-collection.tmpl +++ b/templates/password-collection.tmpl @@ -1,75 +1,87 @@ {{define "password-collection"}} {{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}} {{if .StyleSheet}}{{end}} + {{if .SingleUser}} + + {{end}} +

{{.DisplayTitle}}

{{if .Flashes}}
    {{range .Flashes}}
  • {{.}}
  • {{end}}
{{else}}

This blog requires a password.

{{end}}

{{if and .Script .CanShowScript}}{{end}} {{end}}