A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 
 

1407 linhas
38 KiB

  1. /*
  2. * Copyright © 2018-2022 Musing Studio LLC.
  3. *
  4. * This file is part of WriteFreely.
  5. *
  6. * WriteFreely is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU Affero General Public License, included
  8. * in the LICENSE file in this source code package.
  9. */
  10. package writefreely
  11. import (
  12. "database/sql"
  13. "encoding/json"
  14. "fmt"
  15. "html/template"
  16. "math"
  17. "net/http"
  18. "net/url"
  19. "regexp"
  20. "strconv"
  21. "strings"
  22. "unicode"
  23. "github.com/gorilla/mux"
  24. stripmd "github.com/writeas/go-strip-markdown/v2"
  25. "github.com/writeas/impart"
  26. "github.com/writeas/web-core/activitystreams"
  27. "github.com/writeas/web-core/auth"
  28. "github.com/writeas/web-core/bots"
  29. "github.com/writeas/web-core/i18n"
  30. "github.com/writeas/web-core/log"
  31. "github.com/writeas/web-core/posts"
  32. "github.com/writefreely/writefreely/author"
  33. "github.com/writefreely/writefreely/config"
  34. "github.com/writefreely/writefreely/page"
  35. "golang.org/x/net/idna"
  36. )
  37. type (
  38. // TODO: add Direction to db
  39. // TODO: add Language to db
  40. Collection struct {
  41. ID int64 `datastore:"id" json:"-"`
  42. Alias string `datastore:"alias" schema:"alias" json:"alias"`
  43. Title string `datastore:"title" schema:"title" json:"title"`
  44. Description string `datastore:"description" schema:"description" json:"description"`
  45. Direction string `schema:"dir" json:"dir,omitempty"`
  46. Language string `schema:"lang" json:"lang,omitempty"`
  47. StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
  48. Script string `datastore:"script" schema:"script" json:"script,omitempty"`
  49. Signature string `datastore:"post_signature" schema:"signature" json:"-"`
  50. Public bool `datastore:"public" json:"public"`
  51. Visibility collVisibility `datastore:"private" json:"-"`
  52. Format string `datastore:"format" json:"format,omitempty"`
  53. Views int64 `json:"views"`
  54. OwnerID int64 `datastore:"owner_id" json:"-"`
  55. PublicOwner bool `datastore:"public_owner" json:"-"`
  56. URL string `json:"url,omitempty"`
  57. Monetization string `json:"monetization_pointer,omitempty"`
  58. Verification string `json:"verification_link"`
  59. db *datastore
  60. hostName string
  61. }
  62. CollectionObj struct {
  63. Collection
  64. TotalPosts int `json:"total_posts"`
  65. Owner *User `json:"owner,omitempty"`
  66. Posts *[]PublicPost `json:"posts,omitempty"`
  67. Format *CollectionFormat
  68. }
  69. DisplayCollection struct {
  70. *CollectionObj
  71. Prefix string
  72. NavSuffix string
  73. IsTopLevel bool
  74. CurrentPage int
  75. TotalPages int
  76. Silenced bool
  77. }
  78. SubmittedCollection struct {
  79. // Data used for updating a given collection
  80. ID int64
  81. OwnerID uint64
  82. // Form helpers
  83. PreferURL string `schema:"prefer_url" json:"prefer_url"`
  84. Privacy int `schema:"privacy" json:"privacy"`
  85. Pass string `schema:"password" json:"password"`
  86. MathJax bool `schema:"mathjax" json:"mathjax"`
  87. Handle string `schema:"handle" json:"handle"`
  88. // Actual collection values updated in the DB
  89. Alias *string `schema:"alias" json:"alias"`
  90. Title *string `schema:"title" json:"title"`
  91. Description *string `schema:"description" json:"description"`
  92. StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
  93. Script *sql.NullString `schema:"script" json:"script"`
  94. Signature *sql.NullString `schema:"signature" json:"signature"`
  95. Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
  96. Verification *string `schema:"verification_link" json:"verification_link"`
  97. Visibility *int `schema:"visibility" json:"public"`
  98. Format *sql.NullString `schema:"format" json:"format"`
  99. }
  100. CollectionFormat struct {
  101. Format string
  102. }
  103. collectionReq struct {
  104. // Information about the collection request itself
  105. prefix, alias, domain string
  106. isCustomDomain bool
  107. // User-related fields
  108. isCollOwner bool
  109. isAuthorized bool
  110. }
  111. )
  112. func (sc *SubmittedCollection) FediverseHandle() string {
  113. if sc.Handle == "" {
  114. return apCustomHandleDefault
  115. }
  116. return getSlug(sc.Handle, "")
  117. }
  118. // collVisibility represents the visibility level for the collection.
  119. type collVisibility int
  120. // Visibility levels. Values are bitmasks, stored in the database as
  121. // decimal numbers. If adding types, append them to this list. If removing,
  122. // replace the desired visibility with a new value.
  123. const CollUnlisted collVisibility = 0
  124. const (
  125. CollPublic collVisibility = 1 << iota
  126. CollPrivate
  127. CollProtected
  128. )
  129. var collVisibilityStrings = map[string]collVisibility{
  130. "unlisted": CollUnlisted,
  131. "public": CollPublic,
  132. "private": CollPrivate,
  133. "protected": CollProtected,
  134. }
  135. func defaultVisibility(cfg *config.Config) collVisibility {
  136. vis, ok := collVisibilityStrings[cfg.App.DefaultVisibility]
  137. if !ok {
  138. vis = CollUnlisted
  139. }
  140. return vis
  141. }
  142. func (cf *CollectionFormat) Ascending() bool {
  143. return cf.Format == "novel"
  144. }
  145. func (cf *CollectionFormat) ShowDates() bool {
  146. return cf.Format == "blog"
  147. }
  148. func (cf *CollectionFormat) PostsPerPage() int {
  149. if cf.Format == "novel" {
  150. return postsPerPage
  151. }
  152. return postsPerPage
  153. }
  154. // Valid returns whether or not a format value is valid.
  155. func (cf *CollectionFormat) Valid() bool {
  156. return cf.Format == "blog" ||
  157. cf.Format == "novel" ||
  158. cf.Format == "notebook"
  159. }
  160. // NewFormat creates a new CollectionFormat object from the Collection.
  161. func (c *Collection) NewFormat() *CollectionFormat {
  162. cf := &CollectionFormat{Format: c.Format}
  163. // Fill in default format
  164. if cf.Format == "" {
  165. cf.Format = "blog"
  166. }
  167. return cf
  168. }
  169. func (c *Collection) IsInstanceColl() bool {
  170. ur, _ := url.Parse(c.hostName)
  171. return c.Alias == ur.Host
  172. }
  173. func (c *Collection) IsUnlisted() bool {
  174. return c.Visibility == 0
  175. }
  176. func (c *Collection) IsPrivate() bool {
  177. return c.Visibility&CollPrivate != 0
  178. }
  179. func (c *Collection) IsProtected() bool {
  180. return c.Visibility&CollProtected != 0
  181. }
  182. func (c *Collection) IsPublic() bool {
  183. return c.Visibility&CollPublic != 0
  184. }
  185. func (c *Collection) FriendlyVisibility() string {
  186. if c.IsPrivate() {
  187. return "Private"
  188. }
  189. if c.IsPublic() {
  190. return "Public"
  191. }
  192. if c.IsProtected() {
  193. return "Password-protected"
  194. }
  195. return "Unlisted"
  196. }
  197. func (c *Collection) ShowFooterBranding() bool {
  198. // TODO: implement this setting
  199. return true
  200. }
  201. // CanonicalURL returns a fully-qualified URL to the collection.
  202. func (c *Collection) CanonicalURL() string {
  203. return c.RedirectingCanonicalURL(false)
  204. }
  205. func (c *Collection) DisplayCanonicalURL() string {
  206. us := c.CanonicalURL()
  207. u, err := url.Parse(us)
  208. if err != nil {
  209. return us
  210. }
  211. p := u.Path
  212. if p == "/" {
  213. p = ""
  214. }
  215. d := u.Hostname()
  216. d, _ = idna.ToUnicode(d)
  217. return d + p
  218. }
  219. // RedirectingCanonicalURL returns the fully-qualified canonical URL for the Collection, with a trailing slash. The
  220. // hostName field needs to be populated for this to work correctly.
  221. func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
  222. if c.hostName == "" {
  223. // If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail
  224. 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/writefreely/writefreely/issues/new?template=bug_report.md")
  225. }
  226. if isSingleUser {
  227. return c.hostName + "/"
  228. }
  229. return fmt.Sprintf("%s/%s/", c.hostName, c.Alias)
  230. }
  231. // PrevPageURL provides a full URL for the previous page of collection posts,
  232. // returning a /page/N result for pages >1
  233. func (c *Collection) PrevPageURL(prefix, navSuffix string, n int, tl bool) string {
  234. u := ""
  235. if n == 2 {
  236. // Previous page is 1; no need for /page/ prefix
  237. if prefix == "" {
  238. u = navSuffix + "/"
  239. }
  240. // Else leave off trailing slash
  241. } else {
  242. u = fmt.Sprintf("%s/page/%d", navSuffix, n-1)
  243. }
  244. if tl {
  245. return u
  246. }
  247. return "/" + prefix + c.Alias + u
  248. }
  249. // NextPageURL provides a full URL for the next page of collection posts
  250. func (c *Collection) NextPageURL(prefix, navSuffix string, n int, tl bool) string {
  251. if tl {
  252. return fmt.Sprintf("%s/page/%d", navSuffix, n+1)
  253. }
  254. return fmt.Sprintf("/%s%s%s/page/%d", prefix, c.Alias, navSuffix, n+1)
  255. }
  256. func (c *Collection) DisplayTitle() string {
  257. if c.Title != "" {
  258. return c.Title
  259. }
  260. return c.Alias
  261. }
  262. func (c *Collection) StyleSheetDisplay() template.CSS {
  263. return template.CSS(c.StyleSheet)
  264. }
  265. // ForPublic modifies the Collection for public consumption, such as via
  266. // the API.
  267. func (c *Collection) ForPublic() {
  268. c.URL = c.CanonicalURL()
  269. }
  270. var isAvatarChar = regexp.MustCompile("[a-z0-9]").MatchString
  271. func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person {
  272. accountRoot := c.FederatedAccount()
  273. p := activitystreams.NewPerson(accountRoot)
  274. p.URL = c.CanonicalURL()
  275. uname := c.Alias
  276. p.PreferredUsername = uname
  277. p.Name = c.DisplayTitle()
  278. p.Summary = c.Description
  279. if p.Name != "" {
  280. if av := c.AvatarURL(); av != "" {
  281. p.Icon = activitystreams.Image{
  282. Type: "Image",
  283. MediaType: "image/png",
  284. URL: av,
  285. }
  286. }
  287. }
  288. collID := c.ID
  289. if len(ids) > 0 {
  290. collID = ids[0]
  291. }
  292. pub, priv := c.db.GetAPActorKeys(collID)
  293. if pub != nil {
  294. p.AddPubKey(pub)
  295. p.SetPrivKey(priv)
  296. }
  297. return p
  298. }
  299. func (c *Collection) AvatarURL() string {
  300. fl := string(unicode.ToLower([]rune(c.DisplayTitle())[0]))
  301. if !isAvatarChar(fl) {
  302. return ""
  303. }
  304. return c.hostName + "/img/avatars/" + fl + ".png"
  305. }
  306. func (c *Collection) FederatedAPIBase() string {
  307. return c.hostName + "/"
  308. }
  309. func (c *Collection) FederatedAccount() string {
  310. accountUser := c.Alias
  311. return c.FederatedAPIBase() + "api/collections/" + accountUser
  312. }
  313. func (c *Collection) RenderMathJax() bool {
  314. return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
  315. }
  316. func (c *Collection) MonetizationURL() string {
  317. if c.Monetization == "" {
  318. return ""
  319. }
  320. return strings.Replace(c.Monetization, "$", "https://", 1)
  321. }
  322. // DisplayDescription returns the description with rendered Markdown and HTML.
  323. func (c *Collection) DisplayDescription() *template.HTML {
  324. if c.Description == "" {
  325. s := template.HTML("")
  326. return &s
  327. }
  328. t := template.HTML(posts.ApplyBasicAccessibleMarkdown([]byte(c.Description)))
  329. return &t
  330. }
  331. // PlainDescription returns the description with all Markdown and HTML removed.
  332. func (c *Collection) PlainDescription() string {
  333. if c.Description == "" {
  334. return ""
  335. }
  336. desc := stripHTMLWithoutEscaping(c.Description)
  337. desc = stripmd.Strip(desc)
  338. return desc
  339. }
  340. func (c CollectionPage) DisplayMonetization() string {
  341. return displayMonetization(c.Monetization, c.Alias)
  342. }
  343. func (c *DisplayCollection) Direction() string {
  344. if c.Language == "" {
  345. return "auto"
  346. }
  347. if i18n.LangIsRTL(c.Language) {
  348. return "rtl"
  349. }
  350. return "ltr"
  351. }
  352. func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
  353. reqJSON := IsJSON(r)
  354. alias := r.FormValue("alias")
  355. title := r.FormValue("title")
  356. var missingParams, accessToken string
  357. var u *User
  358. c := struct {
  359. Alias string `json:"alias" schema:"alias"`
  360. Title string `json:"title" schema:"title"`
  361. Web bool `json:"web" schema:"web"`
  362. }{}
  363. if reqJSON {
  364. // Decode JSON request
  365. decoder := json.NewDecoder(r.Body)
  366. err := decoder.Decode(&c)
  367. if err != nil {
  368. log.Error("Couldn't parse post update JSON request: %v\n", err)
  369. return ErrBadJSON
  370. }
  371. } else {
  372. // TODO: move form parsing to formDecoder
  373. c.Alias = alias
  374. c.Title = title
  375. }
  376. if c.Alias == "" {
  377. if c.Title != "" {
  378. // If only a title was given, just use it to generate the alias.
  379. c.Alias = getSlug(c.Title, "")
  380. } else {
  381. missingParams += "`alias` "
  382. }
  383. }
  384. if c.Title == "" {
  385. missingParams += "`title` "
  386. }
  387. if missingParams != "" {
  388. return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)}
  389. }
  390. var userID int64
  391. var err error
  392. if reqJSON && !c.Web {
  393. accessToken = r.Header.Get("Authorization")
  394. if accessToken == "" {
  395. return ErrNoAccessToken
  396. }
  397. userID = app.db.GetUserID(accessToken)
  398. if userID == -1 {
  399. return ErrBadAccessToken
  400. }
  401. } else {
  402. u = getUserSession(app, r)
  403. if u == nil {
  404. return ErrNotLoggedIn
  405. }
  406. userID = u.ID
  407. }
  408. silenced, err := app.db.IsUserSilenced(userID)
  409. if err != nil {
  410. log.Error("new collection: %v", err)
  411. return ErrInternalGeneral
  412. }
  413. if silenced {
  414. return ErrUserSilenced
  415. }
  416. if !author.IsValidUsername(app.cfg, c.Alias) {
  417. return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."}
  418. }
  419. coll, err := app.db.CreateCollection(app.cfg, c.Alias, c.Title, userID)
  420. if err != nil {
  421. // TODO: handle this
  422. return err
  423. }
  424. res := &CollectionObj{Collection: *coll}
  425. if reqJSON {
  426. return impart.WriteSuccess(w, res, http.StatusCreated)
  427. }
  428. redirectTo := "/me/c/"
  429. // TODO: redirect to pad when necessary
  430. return impart.HTTPError{http.StatusFound, redirectTo}
  431. }
  432. func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (int64, error) {
  433. accessToken := r.Header.Get("Authorization")
  434. var userID int64 = -1
  435. if accessToken != "" {
  436. userID = app.db.GetUserID(accessToken)
  437. }
  438. isCollOwner := userID == c.OwnerID
  439. if c.IsPrivate() && !isCollOwner {
  440. // Collection is private, but user isn't authenticated
  441. return -1, ErrCollectionNotFound
  442. }
  443. if c.IsProtected() {
  444. // TODO: check access token
  445. return -1, ErrCollectionUnauthorizedRead
  446. }
  447. return userID, nil
  448. }
  449. // fetchCollection handles the API endpoint for retrieving collection data.
  450. func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
  451. if IsActivityPubRequest(r) {
  452. return handleFetchCollectionActivities(app, w, r)
  453. }
  454. vars := mux.Vars(r)
  455. alias := vars["alias"]
  456. // TODO: move this logic into a common getCollection function
  457. // Get base Collection data
  458. c, err := app.db.GetCollection(alias)
  459. if err != nil {
  460. return err
  461. }
  462. c.hostName = app.cfg.App.Host
  463. // Redirect users who aren't requesting JSON
  464. reqJSON := IsJSON(r)
  465. if !reqJSON {
  466. return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
  467. }
  468. // Check permissions
  469. userID, err := apiCheckCollectionPermissions(app, r, c)
  470. if err != nil {
  471. return err
  472. }
  473. isCollOwner := userID == c.OwnerID
  474. // Fetch extra data about the Collection
  475. res := &CollectionObj{Collection: *c}
  476. if c.PublicOwner {
  477. u, err := app.db.GetUserByID(res.OwnerID)
  478. if err != nil {
  479. // Log the error and just continue
  480. log.Error("Error getting user for collection: %v", err)
  481. } else {
  482. res.Owner = u
  483. }
  484. }
  485. // TODO: check status for silenced
  486. app.db.GetPostsCount(res, isCollOwner)
  487. // Strip non-public information
  488. res.Collection.ForPublic()
  489. return impart.WriteSuccess(w, res, http.StatusOK)
  490. }
  491. // fetchCollectionPosts handles an API endpoint for retrieving a collection's
  492. // posts.
  493. func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) error {
  494. vars := mux.Vars(r)
  495. alias := vars["alias"]
  496. c, err := app.db.GetCollection(alias)
  497. if err != nil {
  498. return err
  499. }
  500. c.hostName = app.cfg.App.Host
  501. // Check permissions
  502. userID, err := apiCheckCollectionPermissions(app, r, c)
  503. if err != nil {
  504. return err
  505. }
  506. isCollOwner := userID == c.OwnerID
  507. // Get page
  508. page := 1
  509. if p := r.FormValue("page"); p != "" {
  510. pInt, _ := strconv.Atoi(p)
  511. if pInt > 0 {
  512. page = pInt
  513. }
  514. }
  515. ps, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false)
  516. if err != nil {
  517. return err
  518. }
  519. coll := &CollectionObj{Collection: *c, Posts: ps}
  520. app.db.GetPostsCount(coll, isCollOwner)
  521. // Strip non-public information
  522. coll.Collection.ForPublic()
  523. // Transform post bodies if needed
  524. if r.FormValue("body") == "html" {
  525. for _, p := range *coll.Posts {
  526. p.Content = posts.ApplyMarkdown([]byte(p.Content))
  527. }
  528. }
  529. return impart.WriteSuccess(w, coll, http.StatusOK)
  530. }
  531. type CollectionPage struct {
  532. page.StaticPage
  533. *DisplayCollection
  534. IsCustomDomain bool
  535. IsWelcome bool
  536. IsOwner bool
  537. IsCollLoggedIn bool
  538. CanPin bool
  539. Username string
  540. Monetization string
  541. Collections *[]Collection
  542. PinnedPosts *[]PublicPost
  543. IsAdmin bool
  544. CanInvite bool
  545. // Helper field for Chorus mode
  546. CollAlias string
  547. }
  548. type TagCollectionPage struct {
  549. CollectionPage
  550. Tag string
  551. }
  552. func (tcp TagCollectionPage) PrevPageURL(prefix string, n int, tl bool) string {
  553. u := fmt.Sprintf("/tag:%s", tcp.Tag)
  554. if n > 2 {
  555. u += fmt.Sprintf("/page/%d", n-1)
  556. }
  557. if tl {
  558. return u
  559. }
  560. return "/" + prefix + tcp.Alias + u
  561. }
  562. func (tcp TagCollectionPage) NextPageURL(prefix string, n int, tl bool) string {
  563. if tl {
  564. return fmt.Sprintf("/tag:%s/page/%d", tcp.Tag, n+1)
  565. }
  566. return fmt.Sprintf("/%s%s/tag:%s/page/%d", prefix, tcp.Alias, tcp.Tag, n+1)
  567. }
  568. func NewCollectionObj(c *Collection) *CollectionObj {
  569. return &CollectionObj{
  570. Collection: *c,
  571. Format: c.NewFormat(),
  572. }
  573. }
  574. func (c *CollectionObj) ScriptDisplay() template.JS {
  575. return template.JS(c.Script)
  576. }
  577. var jsSourceCommentReg = regexp.MustCompile("(?m)^// src:(.+)$")
  578. func (c *CollectionObj) ExternalScripts() []template.URL {
  579. scripts := []template.URL{}
  580. if c.Script == "" {
  581. return scripts
  582. }
  583. matches := jsSourceCommentReg.FindAllStringSubmatch(c.Script, -1)
  584. for _, m := range matches {
  585. scripts = append(scripts, template.URL(strings.TrimSpace(m[1])))
  586. }
  587. return scripts
  588. }
  589. func (c *CollectionObj) CanShowScript() bool {
  590. return false
  591. }
  592. func processCollectionRequest(cr *collectionReq, vars map[string]string, w http.ResponseWriter, r *http.Request) error {
  593. cr.prefix = vars["prefix"]
  594. cr.alias = vars["collection"]
  595. // Normalize the URL, redirecting user to consistent post URL
  596. if cr.alias != strings.ToLower(cr.alias) {
  597. return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", strings.ToLower(cr.alias))}
  598. }
  599. return nil
  600. }
  601. // processCollectionPermissions checks the permissions for the given
  602. // collectionReq, returning a Collection if access is granted; otherwise this
  603. // renders any necessary collection pages, for example, if requesting a custom
  604. // domain that doesn't yet have a collection associated, or if a collection
  605. // requires a password. In either case, this will return nil, nil -- thus both
  606. // values should ALWAYS be checked to determine whether or not to continue.
  607. func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) {
  608. // Display collection if this is a collection
  609. var c *Collection
  610. var err error
  611. if app.cfg.App.SingleUser {
  612. c, err = app.db.GetCollectionByID(1)
  613. } else {
  614. c, err = app.db.GetCollection(cr.alias)
  615. }
  616. // TODO: verify we don't reveal the existence of a private collection with redirection
  617. if err != nil {
  618. if err, ok := err.(impart.HTTPError); ok {
  619. if err.Status == http.StatusNotFound {
  620. if cr.isCustomDomain {
  621. // User is on the site from a custom domain
  622. //tErr := pages["404-domain.tmpl"].ExecuteTemplate(w, "base", pageForHost(page.StaticPage{}, r))
  623. //if tErr != nil {
  624. //log.Error("Unable to render 404-domain page: %v", err)
  625. //}
  626. return nil, nil
  627. }
  628. if len(cr.alias) >= minIDLen && len(cr.alias) <= maxIDLen {
  629. // Alias is within post ID range, so just be sure this isn't a post
  630. if app.db.PostIDExists(cr.alias) {
  631. // TODO: use StatusFound for vanity post URLs when we implement them
  632. return nil, impart.HTTPError{http.StatusMovedPermanently, "/" + cr.alias}
  633. }
  634. }
  635. // Redirect if necessary
  636. newAlias := app.db.GetCollectionRedirect(cr.alias)
  637. if newAlias != "" {
  638. return nil, impart.HTTPError{http.StatusFound, "/" + newAlias + "/"}
  639. }
  640. }
  641. }
  642. return nil, err
  643. }
  644. c.hostName = app.cfg.App.Host
  645. // Update CollectionRequest to reflect owner status
  646. cr.isCollOwner = u != nil && u.ID == c.OwnerID
  647. // Check permissions
  648. if !cr.isCollOwner {
  649. if c.IsPrivate() {
  650. return nil, ErrCollectionNotFound
  651. } else if c.IsProtected() {
  652. uname := ""
  653. if u != nil {
  654. uname = u.Username
  655. }
  656. // TODO: move this to all permission checks?
  657. suspended, err := app.db.IsUserSilenced(c.OwnerID)
  658. if err != nil {
  659. log.Error("process protected collection permissions: %v", err)
  660. return nil, err
  661. }
  662. if suspended {
  663. return nil, ErrCollectionNotFound
  664. }
  665. // See if we've authorized this collection
  666. cr.isAuthorized = isAuthorizedForCollection(app, c.Alias, r)
  667. if !cr.isAuthorized {
  668. p := struct {
  669. page.StaticPage
  670. *CollectionObj
  671. Username string
  672. Next string
  673. Flashes []template.HTML
  674. }{
  675. StaticPage: pageForReq(app, r),
  676. CollectionObj: &CollectionObj{Collection: *c},
  677. Username: uname,
  678. Next: r.FormValue("g"),
  679. Flashes: []template.HTML{},
  680. }
  681. // Get owner information
  682. p.CollectionObj.Owner, err = app.db.GetUserByID(c.OwnerID)
  683. if err != nil {
  684. // Log the error and just continue
  685. log.Error("Error getting user for collection: %v", err)
  686. }
  687. flashes, _ := getSessionFlashes(app, w, r, nil)
  688. for _, flash := range flashes {
  689. p.Flashes = append(p.Flashes, template.HTML(flash))
  690. }
  691. err = templates["password-collection"].ExecuteTemplate(w, "password-collection", p)
  692. if err != nil {
  693. log.Error("Unable to render password-collection: %v", err)
  694. return nil, err
  695. }
  696. return nil, nil
  697. }
  698. }
  699. }
  700. return c, nil
  701. }
  702. func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) {
  703. u := getUserSession(app, r)
  704. return u, nil
  705. }
  706. func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
  707. coll := &DisplayCollection{
  708. CollectionObj: NewCollectionObj(c),
  709. CurrentPage: page,
  710. Prefix: cr.prefix,
  711. IsTopLevel: isSingleUser,
  712. }
  713. c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
  714. return coll
  715. }
  716. // getCollectionPage returns the collection page as an int. If the parsed page value is not
  717. // greater than 0 then the default value of 1 is returned.
  718. func getCollectionPage(vars map[string]string) int {
  719. if p, _ := strconv.Atoi(vars["page"]); p > 0 {
  720. return p
  721. }
  722. return 1
  723. }
  724. // handleViewCollection displays the requested Collection
  725. func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) error {
  726. vars := mux.Vars(r)
  727. cr := &collectionReq{}
  728. err := processCollectionRequest(cr, vars, w, r)
  729. if err != nil {
  730. return err
  731. }
  732. u, err := checkUserForCollection(app, cr, r, false)
  733. if err != nil {
  734. return err
  735. }
  736. page := getCollectionPage(vars)
  737. c, err := processCollectionPermissions(app, cr, u, w, r)
  738. if c == nil || err != nil {
  739. return err
  740. }
  741. c.hostName = app.cfg.App.Host
  742. silenced, err := app.db.IsUserSilenced(c.OwnerID)
  743. if err != nil {
  744. log.Error("view collection: %v", err)
  745. return ErrInternalGeneral
  746. }
  747. // Serve ActivityStreams data now, if requested
  748. if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
  749. ac := c.PersonObject()
  750. ac.Context = []interface{}{activitystreams.Namespace}
  751. setCacheControl(w, apCacheTime)
  752. return impart.RenderActivityJSON(w, ac, http.StatusOK)
  753. }
  754. // Fetch extra data about the Collection
  755. // TODO: refactor out this logic, shared in collection.go:fetchCollection()
  756. coll := newDisplayCollection(c, cr, page)
  757. coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage())))
  758. if coll.TotalPages > 0 && page > coll.TotalPages {
  759. redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
  760. if !app.cfg.App.SingleUser {
  761. redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
  762. }
  763. return impart.HTTPError{http.StatusFound, redirURL}
  764. }
  765. coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false)
  766. // Serve collection
  767. displayPage := CollectionPage{
  768. DisplayCollection: coll,
  769. IsCollLoggedIn: cr.isAuthorized,
  770. StaticPage: pageForReq(app, r),
  771. IsCustomDomain: cr.isCustomDomain,
  772. IsWelcome: r.FormValue("greeting") != "",
  773. CollAlias: c.Alias,
  774. }
  775. displayPage.IsAdmin = u != nil && u.IsAdmin()
  776. displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
  777. var owner *User
  778. if u != nil {
  779. displayPage.Username = u.Username
  780. displayPage.IsOwner = u.ID == coll.OwnerID
  781. if displayPage.IsOwner {
  782. // Add in needed information for users viewing their own collection
  783. owner = u
  784. displayPage.CanPin = true
  785. pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
  786. if err != nil {
  787. log.Error("unable to fetch collections: %v", err)
  788. }
  789. displayPage.Collections = pubColls
  790. }
  791. }
  792. isOwner := owner != nil
  793. if !isOwner {
  794. // Current user doesn't own collection; retrieve owner information
  795. owner, err = app.db.GetUserByID(coll.OwnerID)
  796. if err != nil {
  797. // Log the error and just continue
  798. log.Error("Error getting user for collection: %v", err)
  799. }
  800. }
  801. if !isOwner && silenced {
  802. return ErrCollectionNotFound
  803. }
  804. displayPage.Silenced = isOwner && silenced
  805. displayPage.Owner = owner
  806. coll.Owner = displayPage.Owner
  807. // Add more data
  808. // TODO: fix this mess of collections inside collections
  809. displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
  810. displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
  811. collTmpl := "collection"
  812. if app.cfg.App.Chorus {
  813. collTmpl = "chorus-collection"
  814. }
  815. err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
  816. if err != nil {
  817. log.Error("Unable to render collection index: %v", err)
  818. }
  819. // Update collection view count
  820. go func() {
  821. // Don't update if owner is viewing the collection.
  822. if u != nil && u.ID == coll.OwnerID {
  823. return
  824. }
  825. // Only update for human views
  826. if r.Method == "HEAD" || bots.IsBot(r.UserAgent()) {
  827. return
  828. }
  829. _, err := app.db.Exec("UPDATE collections SET view_count = view_count + 1 WHERE id = ?", coll.ID)
  830. if err != nil {
  831. log.Error("Unable to update collections count: %v", err)
  832. }
  833. }()
  834. return err
  835. }
  836. func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error {
  837. vars := mux.Vars(r)
  838. handle := vars["handle"]
  839. remoteUser, err := app.db.GetProfilePageFromHandle(app, handle)
  840. if err != nil || remoteUser == "" {
  841. log.Error("Couldn't find user %s: %v", handle, err)
  842. return ErrRemoteUserNotFound
  843. }
  844. return impart.HTTPError{Status: http.StatusFound, Message: remoteUser}
  845. }
  846. func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error {
  847. vars := mux.Vars(r)
  848. tag := vars["tag"]
  849. cr := &collectionReq{}
  850. err := processCollectionRequest(cr, vars, w, r)
  851. if err != nil {
  852. return err
  853. }
  854. u, err := checkUserForCollection(app, cr, r, false)
  855. if err != nil {
  856. return err
  857. }
  858. page := getCollectionPage(vars)
  859. c, err := processCollectionPermissions(app, cr, u, w, r)
  860. if c == nil || err != nil {
  861. return err
  862. }
  863. coll := newDisplayCollection(c, cr, page)
  864. taggedPostIDs, err := app.db.GetAllPostsTaggedIDs(c, tag, cr.isCollOwner)
  865. if err != nil {
  866. return err
  867. }
  868. ttlPosts := len(taggedPostIDs)
  869. pagePosts := coll.Format.PostsPerPage()
  870. coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts)))
  871. if coll.TotalPages > 0 && page > coll.TotalPages {
  872. redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
  873. if !app.cfg.App.SingleUser {
  874. redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
  875. }
  876. return impart.HTTPError{http.StatusFound, redirURL}
  877. }
  878. coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
  879. if coll.Posts != nil && len(*coll.Posts) == 0 {
  880. return ErrCollectionPageNotFound
  881. }
  882. // Serve collection
  883. displayPage := TagCollectionPage{
  884. CollectionPage: CollectionPage{
  885. DisplayCollection: coll,
  886. StaticPage: pageForReq(app, r),
  887. IsCustomDomain: cr.isCustomDomain,
  888. },
  889. Tag: tag,
  890. }
  891. var owner *User
  892. if u != nil {
  893. displayPage.Username = u.Username
  894. displayPage.IsOwner = u.ID == coll.OwnerID
  895. if displayPage.IsOwner {
  896. // Add in needed information for users viewing their own collection
  897. owner = u
  898. displayPage.CanPin = true
  899. pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
  900. if err != nil {
  901. log.Error("unable to fetch collections: %v", err)
  902. }
  903. displayPage.Collections = pubColls
  904. }
  905. }
  906. isOwner := owner != nil
  907. if !isOwner {
  908. // Current user doesn't own collection; retrieve owner information
  909. owner, err = app.db.GetUserByID(coll.OwnerID)
  910. if err != nil {
  911. // Log the error and just continue
  912. log.Error("Error getting user for collection: %v", err)
  913. }
  914. if owner.IsSilenced() {
  915. return ErrCollectionNotFound
  916. }
  917. }
  918. displayPage.Silenced = owner != nil && owner.IsSilenced()
  919. displayPage.Owner = owner
  920. coll.Owner = displayPage.Owner
  921. // Add more data
  922. // TODO: fix this mess of collections inside collections
  923. displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
  924. displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
  925. err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
  926. if err != nil {
  927. log.Error("Unable to render collection tag page: %v", err)
  928. }
  929. return nil
  930. }
  931. func handleViewCollectionLang(app *App, w http.ResponseWriter, r *http.Request) error {
  932. vars := mux.Vars(r)
  933. lang := vars["lang"]
  934. cr := &collectionReq{}
  935. err := processCollectionRequest(cr, vars, w, r)
  936. if err != nil {
  937. return err
  938. }
  939. u, err := checkUserForCollection(app, cr, r, false)
  940. if err != nil {
  941. return err
  942. }
  943. page := getCollectionPage(vars)
  944. c, err := processCollectionPermissions(app, cr, u, w, r)
  945. if c == nil || err != nil {
  946. return err
  947. }
  948. coll := newDisplayCollection(c, cr, page)
  949. coll.Language = lang
  950. coll.NavSuffix = fmt.Sprintf("/lang:%s", lang)
  951. ttlPosts, err := app.db.GetCollLangTotalPosts(coll.ID, lang)
  952. if err != nil {
  953. log.Error("Unable to getCollLangTotalPosts: %s", err)
  954. }
  955. pagePosts := coll.Format.PostsPerPage()
  956. coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts)))
  957. if coll.TotalPages > 0 && page > coll.TotalPages {
  958. redirURL := fmt.Sprintf("/lang:%s/page/%d", lang, coll.TotalPages)
  959. if !app.cfg.App.SingleUser {
  960. redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
  961. }
  962. return impart.HTTPError{http.StatusFound, redirURL}
  963. }
  964. coll.Posts, _ = app.db.GetLangPosts(app.cfg, c, lang, page, cr.isCollOwner)
  965. if err != nil {
  966. return ErrCollectionPageNotFound
  967. }
  968. // Serve collection
  969. displayPage := struct {
  970. CollectionPage
  971. Tag string
  972. }{
  973. CollectionPage: CollectionPage{
  974. DisplayCollection: coll,
  975. StaticPage: pageForReq(app, r),
  976. IsCustomDomain: cr.isCustomDomain,
  977. },
  978. Tag: lang,
  979. }
  980. var owner *User
  981. if u != nil {
  982. displayPage.Username = u.Username
  983. displayPage.IsOwner = u.ID == coll.OwnerID
  984. if displayPage.IsOwner {
  985. // Add in needed information for users viewing their own collection
  986. owner = u
  987. displayPage.CanPin = true
  988. pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
  989. if err != nil {
  990. log.Error("unable to fetch collections: %v", err)
  991. }
  992. displayPage.Collections = pubColls
  993. }
  994. }
  995. isOwner := owner != nil
  996. if !isOwner {
  997. // Current user doesn't own collection; retrieve owner information
  998. owner, err = app.db.GetUserByID(coll.OwnerID)
  999. if err != nil {
  1000. // Log the error and just continue
  1001. log.Error("Error getting user for collection: %v", err)
  1002. }
  1003. if owner.IsSilenced() {
  1004. return ErrCollectionNotFound
  1005. }
  1006. }
  1007. displayPage.Silenced = owner != nil && owner.IsSilenced()
  1008. displayPage.Owner = owner
  1009. coll.Owner = displayPage.Owner
  1010. // Add more data
  1011. // TODO: fix this mess of collections inside collections
  1012. displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
  1013. displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
  1014. collTmpl := "collection"
  1015. if app.cfg.App.Chorus {
  1016. collTmpl = "chorus-collection"
  1017. }
  1018. err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
  1019. if err != nil {
  1020. log.Error("Unable to render collection lang page: %v", err)
  1021. }
  1022. return nil
  1023. }
  1024. func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
  1025. vars := mux.Vars(r)
  1026. slug := vars["slug"]
  1027. cr := &collectionReq{}
  1028. err := processCollectionRequest(cr, vars, w, r)
  1029. if err != nil {
  1030. return err
  1031. }
  1032. // Normalize the URL, redirecting user to consistent post URL
  1033. loc := fmt.Sprintf("/%s", slug)
  1034. if !app.cfg.App.SingleUser {
  1035. loc = fmt.Sprintf("/%s/%s", cr.alias, slug)
  1036. }
  1037. return impart.HTTPError{http.StatusFound, loc}
  1038. }
  1039. func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error {
  1040. reqJSON := IsJSON(r)
  1041. vars := mux.Vars(r)
  1042. collAlias := vars["alias"]
  1043. isWeb := r.FormValue("web") == "1"
  1044. u := &User{}
  1045. if reqJSON && !isWeb {
  1046. // Ensure an access token was given
  1047. accessToken := r.Header.Get("Authorization")
  1048. u.ID = app.db.GetUserID(accessToken)
  1049. if u.ID == -1 {
  1050. return ErrBadAccessToken
  1051. }
  1052. } else {
  1053. u = getUserSession(app, r)
  1054. if u == nil {
  1055. return ErrNotLoggedIn
  1056. }
  1057. }
  1058. silenced, err := app.db.IsUserSilenced(u.ID)
  1059. if err != nil {
  1060. log.Error("existing collection: %v", err)
  1061. return ErrInternalGeneral
  1062. }
  1063. if silenced {
  1064. return ErrUserSilenced
  1065. }
  1066. if r.Method == "DELETE" {
  1067. err := app.db.DeleteCollection(collAlias, u.ID)
  1068. if err != nil {
  1069. // TODO: if not HTTPError, report error to admin
  1070. log.Error("Unable to delete collection: %s", err)
  1071. return err
  1072. }
  1073. addSessionFlash(app, w, r, "Deleted your blog, "+collAlias+".", nil)
  1074. return impart.HTTPError{Status: http.StatusNoContent}
  1075. }
  1076. c := SubmittedCollection{OwnerID: uint64(u.ID)}
  1077. if reqJSON {
  1078. // Decode JSON request
  1079. decoder := json.NewDecoder(r.Body)
  1080. err = decoder.Decode(&c)
  1081. if err != nil {
  1082. log.Error("Couldn't parse collection update JSON request: %v\n", err)
  1083. return ErrBadJSON
  1084. }
  1085. } else {
  1086. err = r.ParseForm()
  1087. if err != nil {
  1088. log.Error("Couldn't parse collection update form request: %v\n", err)
  1089. return ErrBadFormData
  1090. }
  1091. err = app.formDecoder.Decode(&c, r.PostForm)
  1092. if err != nil {
  1093. log.Error("Couldn't decode collection update form request: %v\n", err)
  1094. return ErrBadFormData
  1095. }
  1096. }
  1097. err = app.db.UpdateCollection(app, &c, collAlias)
  1098. if err != nil {
  1099. if err, ok := err.(impart.HTTPError); ok {
  1100. if reqJSON {
  1101. return err
  1102. }
  1103. addSessionFlash(app, w, r, err.Message, nil)
  1104. return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias}
  1105. } else {
  1106. log.Error("Couldn't update collection: %v\n", err)
  1107. return err
  1108. }
  1109. }
  1110. if reqJSON {
  1111. return impart.WriteSuccess(w, struct {
  1112. }{}, http.StatusOK)
  1113. }
  1114. addSessionFlash(app, w, r, "Blog updated!", nil)
  1115. return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias}
  1116. }
  1117. // collectionAliasFromReq takes a request and returns the collection alias
  1118. // if it can be ascertained, as well as whether or not the collection uses a
  1119. // custom domain.
  1120. func collectionAliasFromReq(r *http.Request) string {
  1121. vars := mux.Vars(r)
  1122. alias := vars["subdomain"]
  1123. isSubdomain := alias != ""
  1124. if !isSubdomain {
  1125. // Fall back to write.as/{collection} since this isn't a custom domain
  1126. alias = vars["collection"]
  1127. }
  1128. return alias
  1129. }
  1130. func handleWebCollectionUnlock(app *App, w http.ResponseWriter, r *http.Request) error {
  1131. var readReq struct {
  1132. Alias string `schema:"alias" json:"alias"`
  1133. Pass string `schema:"password" json:"password"`
  1134. Next string `schema:"to" json:"to"`
  1135. }
  1136. // Get params
  1137. if impart.ReqJSON(r) {
  1138. decoder := json.NewDecoder(r.Body)
  1139. err := decoder.Decode(&readReq)
  1140. if err != nil {
  1141. log.Error("Couldn't parse readReq JSON request: %v\n", err)
  1142. return ErrBadJSON
  1143. }
  1144. } else {
  1145. err := r.ParseForm()
  1146. if err != nil {
  1147. log.Error("Couldn't parse readReq form request: %v\n", err)
  1148. return ErrBadFormData
  1149. }
  1150. err = app.formDecoder.Decode(&readReq, r.PostForm)
  1151. if err != nil {
  1152. log.Error("Couldn't decode readReq form request: %v\n", err)
  1153. return ErrBadFormData
  1154. }
  1155. }
  1156. if readReq.Alias == "" {
  1157. return impart.HTTPError{http.StatusBadRequest, "Need a collection `alias` to read."}
  1158. }
  1159. if readReq.Pass == "" {
  1160. return impart.HTTPError{http.StatusBadRequest, "Please supply a password."}
  1161. }
  1162. var collHashedPass []byte
  1163. err := app.db.QueryRow("SELECT password FROM collectionpasswords INNER JOIN collections ON id = collection_id WHERE alias = ?", readReq.Alias).Scan(&collHashedPass)
  1164. if err != nil {
  1165. if err == sql.ErrNoRows {
  1166. log.Error("No collectionpassword found when trying to read collection %s", readReq.Alias)
  1167. return impart.HTTPError{http.StatusInternalServerError, "Something went very wrong. The humans have been alerted."}
  1168. }
  1169. return err
  1170. }
  1171. if !auth.Authenticated(collHashedPass, []byte(readReq.Pass)) {
  1172. return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
  1173. }
  1174. // Success; set cookie
  1175. session, err := app.sessionStore.Get(r, blogPassCookieName)
  1176. if err == nil {
  1177. session.Values[readReq.Alias] = true
  1178. err = session.Save(r, w)
  1179. if err != nil {
  1180. log.Error("Didn't save unlocked blog '%s': %v", readReq.Alias, err)
  1181. }
  1182. }
  1183. next := "/" + readReq.Next
  1184. if !app.cfg.App.SingleUser {
  1185. next = "/" + readReq.Alias + next
  1186. }
  1187. return impart.HTTPError{http.StatusFound, next}
  1188. }
  1189. func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool {
  1190. authd := false
  1191. session, err := app.sessionStore.Get(r, blogPassCookieName)
  1192. if err == nil {
  1193. _, authd = session.Values[alias]
  1194. }
  1195. return authd
  1196. }
  1197. func logOutCollection(app *App, alias string, w http.ResponseWriter, r *http.Request) error {
  1198. session, err := app.sessionStore.Get(r, blogPassCookieName)
  1199. if err != nil {
  1200. return err
  1201. }
  1202. // Remove this from map of blogs logged into
  1203. delete(session.Values, alias)
  1204. // If not auth'd with any blog, delete entire cookie
  1205. if len(session.Values) == 0 {
  1206. session.Options.MaxAge = -1
  1207. }
  1208. return session.Save(r, w)
  1209. }
  1210. func handleLogOutCollection(app *App, w http.ResponseWriter, r *http.Request) error {
  1211. alias := collectionAliasFromReq(r)
  1212. var c *Collection
  1213. var err error
  1214. if app.cfg.App.SingleUser {
  1215. c, err = app.db.GetCollectionByID(1)
  1216. } else {
  1217. c, err = app.db.GetCollection(alias)
  1218. }
  1219. if err != nil {
  1220. return err
  1221. }
  1222. if !c.IsProtected() {
  1223. // Invalid to log out of this collection
  1224. return ErrCollectionPageNotFound
  1225. }
  1226. err = logOutCollection(app, c.Alias, w, r)
  1227. if err != nil {
  1228. addSessionFlash(app, w, r, "Logging out failed. Try clearing cookies for this site, instead.", nil)
  1229. }
  1230. return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
  1231. }