A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1230 lines
33 KiB

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