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