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 "net/http" 19 "net/url" 20 "regexp" 21 "strings" 22 "time" 23 24 "github.com/gorilla/mux" 25 "github.com/guregu/null" 26 "github.com/guregu/null/zero" 27 "github.com/kylemcc/twitter-text-go/extract" 28 "github.com/microcosm-cc/bluemonday" 29 stripmd "github.com/writeas/go-strip-markdown/v2" 30 "github.com/writeas/impart" 31 "github.com/writeas/monday" 32 "github.com/writeas/slug" 33 "github.com/writeas/web-core/activitystreams" 34 "github.com/writeas/web-core/bots" 35 "github.com/writeas/web-core/converter" 36 "github.com/writeas/web-core/i18n" 37 "github.com/writeas/web-core/log" 38 "github.com/writeas/web-core/tags" 39 "github.com/writefreely/writefreely/page" 40 "github.com/writefreely/writefreely/parse" 41) 42 43const ( 44 // Post ID length bounds 45 minIDLen = 10 46 maxIDLen = 10 47 userPostIDLen = 10 48 postIDLen = 10 49 50 postMetaDateFormat = "2006-01-02 15:04:05" 51 52 shortCodePaid = "<!--paid-->" 53) 54 55type ( 56 AnonymousPost struct { 57 ID string 58 Content string 59 HTMLContent template.HTML 60 Font string 61 Language string 62 Direction string 63 Title string 64 GenTitle string 65 Description string 66 Author string 67 Views int64 68 Images []string 69 IsPlainText bool 70 IsCode bool 71 IsLinkable bool 72 } 73 74 AuthenticatedPost struct { 75 ID string `json:"id" schema:"id"` 76 Web bool `json:"web" schema:"web"` 77 *SubmittedPost 78 } 79 80 // SubmittedPost represents a post supplied by a client for publishing or 81 // updating. Since Title and Content can be updated to "", they are 82 // pointers that can be easily tested to detect changes. 83 SubmittedPost struct { 84 Slug *string `json:"slug" schema:"slug"` 85 Title *string `json:"title" schema:"title"` 86 Content *string `json:"body" schema:"body"` 87 Font string `json:"font" schema:"font"` 88 IsRTL converter.NullJSONBool `json:"rtl" schema:"rtl"` 89 Language converter.NullJSONString `json:"lang" schema:"lang"` 90 Created *string `json:"created" schema:"created"` 91 } 92 93 // Post represents a post as found in the database. 94 Post struct { 95 ID string `db:"id" json:"id"` 96 Slug null.String `db:"slug" json:"slug,omitempty"` 97 Font string `db:"text_appearance" json:"appearance"` 98 Language zero.String `db:"language" json:"language"` 99 RTL zero.Bool `db:"rtl" json:"rtl"` 100 Privacy int64 `db:"privacy" json:"-"` 101 OwnerID null.Int `db:"owner_id" json:"-"` 102 CollectionID null.Int `db:"collection_id" json:"-"` 103 PinnedPosition null.Int `db:"pinned_position" json:"-"` 104 Created time.Time `db:"created" json:"created"` 105 Updated time.Time `db:"updated" json:"updated"` 106 ViewCount int64 `db:"view_count" json:"-"` 107 Title zero.String `db:"title" json:"title"` 108 HTMLTitle template.HTML `db:"title" json:"-"` 109 Content string `db:"content" json:"body"` 110 HTMLContent template.HTML `db:"content" json:"-"` 111 HTMLExcerpt template.HTML `db:"content" json:"-"` 112 Tags []string `json:"tags"` 113 Images []string `json:"images,omitempty"` 114 IsPaid bool `json:"paid"` 115 116 OwnerName string `json:"owner,omitempty"` 117 } 118 119 // PublicPost holds properties for a publicly returned post, i.e. a post in 120 // a context where the viewer may not be the owner. As such, sensitive 121 // metadata for the post is hidden and properties supporting the display of 122 // the post are added. 123 PublicPost struct { 124 *Post 125 IsSubdomain bool `json:"-"` 126 IsTopLevel bool `json:"-"` 127 DisplayDate string `json:"-"` 128 Views int64 `json:"views"` 129 Owner *PublicUser `json:"-"` 130 IsOwner bool `json:"-"` 131 URL string `json:"url,omitempty"` 132 Collection *CollectionObj `json:"collection,omitempty"` 133 } 134 135 CollectionPostPage struct { 136 *PublicPost 137 page.StaticPage 138 IsOwner bool 139 IsPinned bool 140 IsCustomDomain bool 141 Monetization string 142 PinnedPosts *[]PublicPost 143 IsFound bool 144 IsAdmin bool 145 CanInvite bool 146 Silenced bool 147 148 // Helper field for Chorus mode 149 CollAlias string 150 } 151 152 RawPost struct { 153 Id, Slug string 154 Title string 155 Content string 156 Views int64 157 Font string 158 Created time.Time 159 Updated time.Time 160 IsRTL sql.NullBool 161 Language sql.NullString 162 OwnerID int64 163 CollectionID sql.NullInt64 164 165 Found bool 166 Gone bool 167 } 168 169 AnonymousAuthPost struct { 170 ID string `json:"id"` 171 Token string `json:"token"` 172 } 173 ClaimPostRequest struct { 174 *AnonymousAuthPost 175 CollectionAlias string `json:"collection"` 176 CreateCollection bool `json:"create_collection"` 177 178 // Generated properties 179 Slug string `json:"-"` 180 } 181 ClaimPostResult struct { 182 ID string `json:"id,omitempty"` 183 Code int `json:"code,omitempty"` 184 ErrorMessage string `json:"error_msg,omitempty"` 185 Post *PublicPost `json:"post,omitempty"` 186 } 187) 188 189func (p *Post) Direction() string { 190 if p.RTL.Valid { 191 if p.RTL.Bool { 192 return "rtl" 193 } 194 return "ltr" 195 } 196 return "auto" 197} 198 199// DisplayTitle dynamically generates a title from the Post's contents if it 200// doesn't already have an explicit title. 201func (p *Post) DisplayTitle() string { 202 if p.Title.String != "" { 203 return p.Title.String 204 } 205 t := friendlyPostTitle(p.Content, p.ID) 206 return t 207} 208 209// PlainDisplayTitle dynamically generates a title from the Post's contents if it 210// doesn't already have an explicit title. 211func (p *Post) PlainDisplayTitle() string { 212 if t := stripmd.Strip(p.DisplayTitle()); t != "" { 213 return t 214 } 215 return p.ID 216} 217 218// FormattedDisplayTitle dynamically generates a title from the Post's contents if it 219// doesn't already have an explicit title. 220func (p *Post) FormattedDisplayTitle() template.HTML { 221 if p.HTMLTitle != "" { 222 return p.HTMLTitle 223 } 224 return template.HTML(p.DisplayTitle()) 225} 226 227// Summary gives a shortened summary of the post based on the post's title, 228// especially for display in a longer list of posts. It extracts a summary for 229// posts in the Title\n\nBody format, returning nothing if the entire was short 230// enough that the extracted title == extracted summary. 231func (p Post) Summary() string { 232 if p.Content == "" { 233 return "" 234 } 235 p.Content = stripHTMLWithoutEscaping(p.Content) 236 // and Markdown 237 p.Content = stripmd.StripOptions(p.Content, stripmd.Options{SkipImages: true}) 238 239 title := p.Title.String 240 var desc string 241 if title == "" { 242 // No title, so generate one 243 title = friendlyPostTitle(p.Content, p.ID) 244 desc = postDescription(p.Content, title, p.ID) 245 if desc == title { 246 return "" 247 } 248 return desc 249 } 250 251 return shortPostDescription(p.Content) 252} 253 254func (p Post) SummaryHTML() template.HTML { 255 return template.HTML(p.Summary()) 256} 257 258// Excerpt shows any text that comes before a (more) tag. 259// TODO: use HTMLExcerpt in templates instead of this method 260func (p *Post) Excerpt() template.HTML { 261 return p.HTMLExcerpt 262} 263 264func (p *Post) CreatedDate() string { 265 return p.Created.Format("2006-01-02") 266} 267 268func (p *Post) Created8601() string { 269 return p.Created.Format("2006-01-02T15:04:05Z") 270} 271 272func (p *Post) IsScheduled() bool { 273 return p.Created.After(time.Now()) 274} 275 276func (p *Post) HasTag(tag string) bool { 277 // Regexp looks for tag and has a non-capturing group at the end looking 278 // for the end of the word. 279 // Assisted by: https://stackoverflow.com/a/35192941/1549194 280 hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content) 281 return hasTag 282} 283 284func (p *Post) HasTitleLink() bool { 285 if p.Title.String == "" { 286 return false 287 } 288 hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String) 289 return hasLink 290} 291 292func (c CollectionPostPage) DisplayMonetization() string { 293 if c.Collection == nil { 294 log.Info("CollectionPostPage.DisplayMonetization: c.Collection is nil") 295 return "" 296 } 297 return displayMonetization(c.Monetization, c.Collection.Alias) 298} 299 300func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { 301 vars := mux.Vars(r) 302 friendlyID := vars["post"] 303 304 // NOTE: until this is done better, be sure to keep this in parity with 305 // isRaw() and viewCollectionPost() 306 isJSON := strings.HasSuffix(friendlyID, ".json") 307 isXML := strings.HasSuffix(friendlyID, ".xml") 308 isCSS := strings.HasSuffix(friendlyID, ".css") 309 isMarkdown := strings.HasSuffix(friendlyID, ".md") 310 isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown 311 312 // Display reserved page if that is requested resource 313 if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok { 314 return handleTemplatedPage(app, w, r, t) 315 } else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" { 316 // Serve static file 317 app.shttp.ServeHTTP(w, r) 318 return nil 319 } 320 321 // Display collection if this is a collection 322 c, _ := app.db.GetCollection(friendlyID) 323 if c != nil { 324 return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)} 325 } 326 327 // Normalize the URL, redirecting user to consistent post URL 328 if friendlyID != strings.ToLower(friendlyID) { 329 return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))} 330 } 331 332 ext := "" 333 if isRaw { 334 parts := strings.Split(friendlyID, ".") 335 friendlyID = parts[0] 336 if len(parts) > 1 { 337 ext = "." + parts[1] 338 } 339 } 340 341 var ownerID sql.NullInt64 342 var title string 343 var content string 344 var font string 345 var language []byte 346 var rtl []byte 347 var views int64 348 var post *AnonymousPost 349 var found bool 350 var gone bool 351 352 fixedID := slug.Make(friendlyID) 353 if fixedID != friendlyID { 354 return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)} 355 } 356 357 err := app.db.QueryRow(fmt.Sprintf("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?"), friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl) 358 switch { 359 case err == sql.ErrNoRows: 360 found = false 361 362 // Output the error in the correct format 363 if isJSON { 364 content = "{\"error\": \"Post not found.\"}" 365 } else if isRaw { 366 content = "Post not found." 367 } else { 368 return ErrPostNotFound 369 } 370 case err != nil: 371 found = false 372 373 log.Error("Post loading err: %s\n", err) 374 return ErrInternalGeneral 375 default: 376 found = true 377 378 var d string 379 if len(rtl) == 0 { 380 d = "auto" 381 } else if rtl[0] == 49 { 382 // TODO: find a cleaner way to get this (possibly NULL) value 383 d = "rtl" 384 } else { 385 d = "ltr" 386 } 387 generatedTitle := friendlyPostTitle(content, friendlyID) 388 sanitizedContent := content 389 if font != "code" { 390 sanitizedContent = template.HTMLEscapeString(content) 391 } 392 var desc string 393 if title == "" { 394 desc = postDescription(content, title, friendlyID) 395 } else { 396 desc = shortPostDescription(content) 397 } 398 post = &AnonymousPost{ 399 ID: friendlyID, 400 Content: sanitizedContent, 401 Title: title, 402 GenTitle: generatedTitle, 403 Description: desc, 404 Author: "", 405 Font: font, 406 IsPlainText: isRaw, 407 IsCode: font == "code", 408 IsLinkable: font != "code", 409 Views: views, 410 Language: string(language), 411 Direction: d, 412 } 413 if !isRaw { 414 post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg)) 415 post.Images = extractImages(post.Content) 416 } 417 } 418 419 var silenced bool 420 if found { 421 silenced, err = app.db.IsUserSilenced(ownerID.Int64) 422 if err != nil { 423 log.Error("view post: %v", err) 424 } 425 } 426 427 // Check if post has been unpublished 428 if title == "" && content == "" { 429 gone = true 430 431 if isJSON { 432 content = "{\"error\": \"Post was unpublished.\"}" 433 } else if isCSS { 434 content = "" 435 } else if isRaw { 436 content = "Post was unpublished." 437 } else { 438 return ErrPostUnpublished 439 } 440 } 441 442 var u = &User{} 443 if isRaw { 444 contentType := "text/plain" 445 if isJSON { 446 contentType = "application/json" 447 } else if isCSS { 448 contentType = "text/css" 449 } else if isXML { 450 contentType = "application/xml" 451 } else if isMarkdown { 452 contentType = "text/markdown" 453 } 454 w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType)) 455 if isMarkdown && post.Title != "" { 456 fmt.Fprintf(w, "%s\n", post.Title) 457 for i := 1; i <= len(post.Title); i++ { 458 fmt.Fprintf(w, "=") 459 } 460 fmt.Fprintf(w, "\n\n") 461 } 462 fmt.Fprint(w, content) 463 464 if !found { 465 return ErrPostNotFound 466 } else if gone { 467 return ErrPostUnpublished 468 } 469 } else { 470 var err error 471 page := struct { 472 *AnonymousPost 473 page.StaticPage 474 Username string 475 IsOwner bool 476 SiteURL string 477 Silenced bool 478 }{ 479 AnonymousPost: post, 480 StaticPage: pageForReq(app, r), 481 SiteURL: app.cfg.App.Host, 482 } 483 if u = getUserSession(app, r); u != nil { 484 page.Username = u.Username 485 page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID 486 } 487 488 if !page.IsOwner && silenced { 489 return ErrPostNotFound 490 } 491 page.Silenced = silenced 492 err = templates["post"].ExecuteTemplate(w, "post", page) 493 if err != nil { 494 log.Error("Post template execute error: %v", err) 495 } 496 } 497 498 go func() { 499 if u != nil && ownerID.Valid && ownerID.Int64 == u.ID { 500 // Post is owned by someone; skip view increment since that person is viewing this post. 501 return 502 } 503 // Update stats for non-raw post views 504 if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) { 505 _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID) 506 if err != nil { 507 log.Error("Unable to update posts count: %v", err) 508 } 509 } 510 }() 511 512 return nil 513} 514 515// API v2 funcs 516// newPost creates a new post with or without an owning Collection. 517// 518// Endpoints: 519// /posts 520// /posts?collection={alias} 521// ? /collections/{alias}/posts 522func newPost(app *App, w http.ResponseWriter, r *http.Request) error { 523 reqJSON := IsJSON(r) 524 vars := mux.Vars(r) 525 collAlias := vars["alias"] 526 if collAlias == "" { 527 collAlias = r.FormValue("collection") 528 } 529 accessToken := r.Header.Get("Authorization") 530 if accessToken == "" { 531 // TODO: remove this 532 accessToken = r.FormValue("access_token") 533 } 534 535 // FIXME: determine web submission with Content-Type header 536 var u *User 537 var userID int64 = -1 538 var username string 539 if accessToken == "" { 540 u = getUserSession(app, r) 541 if u != nil { 542 userID = u.ID 543 username = u.Username 544 } 545 } else { 546 userID = app.db.GetUserID(accessToken) 547 } 548 silenced, err := app.db.IsUserSilenced(userID) 549 if err != nil { 550 log.Error("new post: %v", err) 551 } 552 if silenced { 553 return ErrUserSilenced 554 } 555 556 if userID == -1 { 557 return ErrNotLoggedIn 558 } 559 560 if accessToken == "" && u == nil && collAlias != "" { 561 return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."} 562 } 563 564 // Get post data 565 var p *SubmittedPost 566 if reqJSON { 567 decoder := json.NewDecoder(r.Body) 568 err = decoder.Decode(&p) 569 if err != nil { 570 log.Error("Couldn't parse new post JSON request: %v\n", err) 571 return ErrBadJSON 572 } 573 if p.Title == nil { 574 t := "" 575 p.Title = &t 576 } 577 if strings.TrimSpace(*(p.Title)) == "" && (p.Content == nil || strings.TrimSpace(*(p.Content)) == "") { 578 return ErrNoPublishableContent 579 } 580 if p.Content == nil { 581 c := "" 582 p.Content = &c 583 } 584 585 } else { 586 post := r.FormValue("body") 587 appearance := r.FormValue("font") 588 title := r.FormValue("title") 589 rtlValue := r.FormValue("rtl") 590 langValue := r.FormValue("lang") 591 if strings.TrimSpace(post) == "" { 592 return ErrNoPublishableContent 593 } 594 595 var isRTL, rtlValid bool 596 if rtlValue == "auto" && langValue != "" { 597 isRTL = i18n.LangIsRTL(langValue) 598 rtlValid = true 599 } else { 600 isRTL = rtlValue == "true" 601 rtlValid = rtlValue != "" && langValue != "" 602 } 603 604 // Create a new post 605 p = &SubmittedPost{ 606 Title: &title, 607 Content: &post, 608 Font: appearance, 609 IsRTL: converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}}, 610 Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}}, 611 } 612 } 613 if !p.isFontValid() { 614 p.Font = "norm" 615 } 616 617 var newPost *PublicPost = &PublicPost{} 618 var coll *Collection 619 if accessToken != "" { 620 newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host) 621 } else { 622 //return ErrNotLoggedIn 623 // TODO: verify user is logged in 624 var collID int64 625 if collAlias != "" { 626 coll, err = app.db.GetCollection(collAlias) 627 if err != nil { 628 return err 629 } 630 coll.hostName = app.cfg.App.Host 631 if coll.OwnerID != u.ID { 632 return ErrForbiddenCollection 633 } 634 collID = coll.ID 635 } 636 // TODO: return PublicPost from createPost 637 newPost.Post, err = app.db.CreatePost(userID, collID, p) 638 } 639 if err != nil { 640 return err 641 } 642 if coll != nil { 643 coll.ForPublic() 644 newPost.Collection = &CollectionObj{Collection: *coll} 645 } 646 647 newPost.extractData() 648 newPost.OwnerName = username 649 newPost.URL = newPost.CanonicalURL(app.cfg.App.Host) 650 651 // Write success now 652 response := impart.WriteSuccess(w, newPost, http.StatusCreated) 653 654 if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) { 655 go federatePost(app, newPost, newPost.Collection.ID, false) 656 } 657 658 return response 659} 660 661func existingPost(app *App, w http.ResponseWriter, r *http.Request) error { 662 reqJSON := IsJSON(r) 663 vars := mux.Vars(r) 664 postID := vars["post"] 665 666 p := AuthenticatedPost{ID: postID} 667 var err error 668 669 if reqJSON { 670 // Decode JSON request 671 decoder := json.NewDecoder(r.Body) 672 err = decoder.Decode(&p) 673 if err != nil { 674 log.Error("Couldn't parse post update JSON request: %v\n", err) 675 return ErrBadJSON 676 } 677 } else { 678 err = r.ParseForm() 679 if err != nil { 680 log.Error("Couldn't parse post update form request: %v\n", err) 681 return ErrBadFormData 682 } 683 684 // Can't decode to a nil SubmittedPost property, so create instance now 685 p.SubmittedPost = &SubmittedPost{} 686 err = app.formDecoder.Decode(&p, r.PostForm) 687 if err != nil { 688 log.Error("Couldn't decode post update form request: %v\n", err) 689 return ErrBadFormData 690 } 691 } 692 693 if p.Web { 694 p.IsRTL.Valid = true 695 } 696 697 if p.SubmittedPost == nil { 698 return ErrPostNoUpdatableVals 699 } 700 701 // Ensure an access token was given 702 accessToken := r.Header.Get("Authorization") 703 // Get user's cookie session if there's no token 704 var u *User 705 //var username string 706 if accessToken == "" { 707 u = getUserSession(app, r) 708 if u != nil { 709 //username = u.Username 710 } 711 } 712 if u == nil && accessToken == "" { 713 return ErrNoAccessToken 714 } 715 716 // Get user ID from current session or given access token, if one was given. 717 var userID int64 718 if u != nil { 719 userID = u.ID 720 } else if accessToken != "" { 721 userID, err = AuthenticateUser(app.db, accessToken) 722 if err != nil { 723 return err 724 } 725 } 726 727 silenced, err := app.db.IsUserSilenced(userID) 728 if err != nil { 729 log.Error("existing post: %v", err) 730 } 731 if silenced { 732 return ErrUserSilenced 733 } 734 735 // Modify post struct 736 p.ID = postID 737 738 err = app.db.UpdateOwnedPost(&p, userID) 739 if err != nil { 740 if reqJSON { 741 return err 742 } 743 744 if err, ok := err.(impart.HTTPError); ok { 745 addSessionFlash(app, w, r, err.Message, nil) 746 } else { 747 addSessionFlash(app, w, r, err.Error(), nil) 748 } 749 } 750 751 var pRes *PublicPost 752 pRes, err = app.db.GetPost(p.ID, 0) 753 if reqJSON { 754 if err != nil { 755 return err 756 } 757 pRes.extractData() 758 } 759 760 if pRes.CollectionID.Valid { 761 coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64) 762 if err == nil && !app.cfg.App.Private && app.cfg.App.Federation { 763 coll.hostName = app.cfg.App.Host 764 pRes.Collection = &CollectionObj{Collection: *coll} 765 go federatePost(app, pRes, pRes.Collection.ID, true) 766 } 767 } 768 769 // Write success now 770 if reqJSON { 771 return impart.WriteSuccess(w, pRes, http.StatusOK) 772 } 773 774 addSessionFlash(app, w, r, "Changes saved.", nil) 775 collectionAlias := vars["alias"] 776 redirect := "/" + postID + "/meta" 777 if collectionAlias != "" { 778 collPre := "/" + collectionAlias 779 if app.cfg.App.SingleUser { 780 collPre = "" 781 } 782 redirect = collPre + "/" + pRes.Slug.String + "/edit/meta" 783 } else { 784 if app.cfg.App.SingleUser { 785 redirect = "/d" + redirect 786 } 787 } 788 w.Header().Set("Location", redirect) 789 w.WriteHeader(http.StatusFound) 790 791 return nil 792} 793 794func deletePost(app *App, w http.ResponseWriter, r *http.Request) error { 795 vars := mux.Vars(r) 796 friendlyID := vars["post"] 797 editToken := r.FormValue("token") 798 799 var ownerID int64 800 var u *User 801 accessToken := r.Header.Get("Authorization") 802 if accessToken == "" && editToken == "" { 803 u = getUserSession(app, r) 804 if u == nil { 805 return ErrNoAccessToken 806 } 807 } 808 809 var res sql.Result 810 var t *sql.Tx 811 var err error 812 var collID sql.NullInt64 813 var coll *Collection 814 var pp *PublicPost 815 if editToken != "" { 816 // TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries 817 var dummy int64 818 err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy) 819 switch { 820 case err == sql.ErrNoRows: 821 return impart.HTTPError{http.StatusNotFound, "Post not found."} 822 } 823 err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy) 824 switch { 825 case err == sql.ErrNoRows: 826 // Post already has an owner. This could provide a bad experience 827 // for the user, but it's more important to ensure data isn't lost 828 // unexpectedly. So prevent deletion via token. 829 return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."} 830 } 831 res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken) 832 } else if accessToken != "" || u != nil { 833 // Caller provided some way to authenticate; assume caller expects the 834 // post to be deleted based on a specific post owner, thus we should 835 // return corresponding errors. 836 if accessToken != "" { 837 ownerID = app.db.GetUserID(accessToken) 838 if ownerID == -1 { 839 return ErrBadAccessToken 840 } 841 } else { 842 ownerID = u.ID 843 } 844 845 // TODO: don't make two queries 846 var realOwnerID sql.NullInt64 847 err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID) 848 if err != nil { 849 return err 850 } 851 if !collID.Valid { 852 // There's no collection; simply delete the post 853 res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID) 854 } else { 855 // Post belongs to a collection; do any additional clean up 856 coll, err = app.db.GetCollectionBy("id = ?", collID.Int64) 857 if err != nil { 858 log.Error("Unable to get collection: %v", err) 859 return err 860 } 861 if app.cfg.App.Federation { 862 // First fetch full post for federation 863 pp, err = app.db.GetOwnedPost(friendlyID, ownerID) 864 if err != nil { 865 log.Error("Unable to get owned post: %v", err) 866 return err 867 } 868 collObj := &CollectionObj{Collection: *coll} 869 pp.Collection = collObj 870 } 871 872 t, err = app.db.Begin() 873 if err != nil { 874 log.Error("No begin: %v", err) 875 return err 876 } 877 res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID) 878 } 879 } else { 880 return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."} 881 } 882 if err != nil { 883 return err 884 } 885 886 affected, err := res.RowsAffected() 887 if err != nil { 888 if t != nil { 889 t.Rollback() 890 log.Error("Rows affected err! Rolling back") 891 } 892 return err 893 } else if affected == 0 { 894 if t != nil { 895 t.Rollback() 896 log.Error("No rows affected! Rolling back") 897 } 898 return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."} 899 } 900 if t != nil { 901 t.Commit() 902 } 903 if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation { 904 go deleteFederatedPost(app, pp, collID.Int64) 905 } 906 907 return impart.HTTPError{Status: http.StatusNoContent} 908} 909 910// addPost associates a post with the authenticated user. 911func addPost(app *App, w http.ResponseWriter, r *http.Request) error { 912 var ownerID int64 913 914 // Authenticate user 915 at := r.Header.Get("Authorization") 916 if at != "" { 917 ownerID = app.db.GetUserID(at) 918 if ownerID == -1 { 919 return ErrBadAccessToken 920 } 921 } else { 922 u := getUserSession(app, r) 923 if u == nil { 924 return ErrNotLoggedIn 925 } 926 ownerID = u.ID 927 } 928 929 silenced, err := app.db.IsUserSilenced(ownerID) 930 if err != nil { 931 log.Error("add post: %v", err) 932 } 933 if silenced { 934 return ErrUserSilenced 935 } 936 937 // Parse claimed posts in format: 938 // [{"id": "...", "token": "..."}] 939 var claims *[]ClaimPostRequest 940 decoder := json.NewDecoder(r.Body) 941 err = decoder.Decode(&claims) 942 if err != nil { 943 return ErrBadJSONArray 944 } 945 946 vars := mux.Vars(r) 947 collAlias := vars["alias"] 948 949 // Update all given posts 950 res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims) 951 if err != nil { 952 return err 953 } 954 955 if !app.cfg.App.Private && app.cfg.App.Federation { 956 for _, pRes := range *res { 957 if pRes.Code != http.StatusOK { 958 continue 959 } 960 if !pRes.Post.Created.After(time.Now()) { 961 pRes.Post.Collection.hostName = app.cfg.App.Host 962 go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false) 963 } 964 } 965 } 966 return impart.WriteSuccess(w, res, http.StatusOK) 967} 968 969func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error { 970 var ownerID int64 971 972 // Authenticate user 973 at := r.Header.Get("Authorization") 974 if at != "" { 975 ownerID = app.db.GetUserID(at) 976 if ownerID == -1 { 977 return ErrBadAccessToken 978 } 979 } else { 980 u := getUserSession(app, r) 981 if u == nil { 982 return ErrNotLoggedIn 983 } 984 ownerID = u.ID 985 } 986 987 // Parse posts in format: 988 // ["..."] 989 var postIDs []string 990 decoder := json.NewDecoder(r.Body) 991 err := decoder.Decode(&postIDs) 992 if err != nil { 993 return ErrBadJSONArray 994 } 995 996 // Update all given posts 997 res, err := app.db.DispersePosts(ownerID, postIDs) 998 if err != nil { 999 return err 1000 } 1001 return impart.WriteSuccess(w, res, http.StatusOK) 1002} 1003 1004type ( 1005 PinPostResult struct { 1006 ID string `json:"id,omitempty"` 1007 Code int `json:"code,omitempty"` 1008 ErrorMessage string `json:"error_msg,omitempty"` 1009 } 1010) 1011 1012// pinPost pins a post to a blog 1013func pinPost(app *App, w http.ResponseWriter, r *http.Request) error { 1014 var userID int64 1015 1016 // Authenticate user 1017 at := r.Header.Get("Authorization") 1018 if at != "" { 1019 userID = app.db.GetUserID(at) 1020 if userID == -1 { 1021 return ErrBadAccessToken 1022 } 1023 } else { 1024 u := getUserSession(app, r) 1025 if u == nil { 1026 return ErrNotLoggedIn 1027 } 1028 userID = u.ID 1029 } 1030 1031 silenced, err := app.db.IsUserSilenced(userID) 1032 if err != nil { 1033 log.Error("pin post: %v", err) 1034 } 1035 if silenced { 1036 return ErrUserSilenced 1037 } 1038 1039 // Parse request 1040 var posts []struct { 1041 ID string `json:"id"` 1042 Position int64 `json:"position"` 1043 } 1044 decoder := json.NewDecoder(r.Body) 1045 err = decoder.Decode(&posts) 1046 if err != nil { 1047 return ErrBadJSONArray 1048 } 1049 1050 // Validate data 1051 vars := mux.Vars(r) 1052 collAlias := vars["alias"] 1053 1054 coll, err := app.db.GetCollection(collAlias) 1055 if err != nil { 1056 return err 1057 } 1058 if coll.OwnerID != userID { 1059 return ErrForbiddenCollection 1060 } 1061 1062 // Do (un)pinning 1063 isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin" 1064 res := []PinPostResult{} 1065 for _, p := range posts { 1066 err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position) 1067 ppr := PinPostResult{ID: p.ID} 1068 if err != nil { 1069 ppr.Code = http.StatusInternalServerError 1070 // TODO: set error messsage 1071 } else { 1072 ppr.Code = http.StatusOK 1073 } 1074 res = append(res, ppr) 1075 } 1076 return impart.WriteSuccess(w, res, http.StatusOK) 1077} 1078 1079func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error { 1080 var collID int64 1081 var coll *Collection 1082 var err error 1083 vars := mux.Vars(r) 1084 if collAlias := vars["alias"]; collAlias != "" { 1085 // Fetch collection information, since an alias is provided 1086 coll, err = app.db.GetCollection(collAlias) 1087 if err != nil { 1088 return err 1089 } 1090 collID = coll.ID 1091 } 1092 1093 p, err := app.db.GetPost(vars["post"], collID) 1094 if err != nil { 1095 return err 1096 } 1097 if coll == nil && p.CollectionID.Valid { 1098 // Collection post is getting fetched by post ID, not coll alias + post slug, so get coll info now. 1099 coll, err = app.db.GetCollectionByID(p.CollectionID.Int64) 1100 if err != nil { 1101 return err 1102 } 1103 } 1104 if coll != nil { 1105 coll.hostName = app.cfg.App.Host 1106 _, err = apiCheckCollectionPermissions(app, r, coll) 1107 if err != nil { 1108 return err 1109 } 1110 } 1111 1112 silenced, err := app.db.IsUserSilenced(p.OwnerID.Int64) 1113 if err != nil { 1114 log.Error("fetch post: %v", err) 1115 } 1116 if silenced { 1117 return ErrPostNotFound 1118 } 1119 1120 p.extractData() 1121 1122 accept := r.Header.Get("Accept") 1123 if strings.Contains(accept, "application/activity+json") { 1124 if coll == nil { 1125 // This is a draft post; 404 for now 1126 // TODO: return ActivityObject 1127 return impart.HTTPError{http.StatusNotFound, ""} 1128 } 1129 1130 p.Collection = &CollectionObj{Collection: *coll} 1131 po := p.ActivityObject(app) 1132 po.Context = []interface{}{activitystreams.Namespace} 1133 setCacheControl(w, apCacheTime) 1134 return impart.RenderActivityJSON(w, po, http.StatusOK) 1135 } 1136 1137 return impart.WriteSuccess(w, p, http.StatusOK) 1138} 1139 1140func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error { 1141 vars := mux.Vars(r) 1142 p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"]) 1143 if err != nil { 1144 return err 1145 } 1146 1147 return impart.WriteSuccess(w, p, http.StatusOK) 1148} 1149 1150func (p *Post) processPost() PublicPost { 1151 res := &PublicPost{Post: p, Views: 0} 1152 res.Views = p.ViewCount 1153 // TODO: move to own function 1154 loc := monday.FuzzyLocale(p.Language.String) 1155 res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc) 1156 1157 return *res 1158} 1159 1160func (p *PublicPost) CanonicalURL(hostName string) string { 1161 if p.Collection == nil || p.Collection.Alias == "" { 1162 return hostName + "/" + p.ID + ".md" 1163 } 1164 return p.Collection.CanonicalURL() + p.Slug.String 1165} 1166 1167func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object { 1168 cfg := app.cfg 1169 var o *activitystreams.Object 1170 if cfg.App.NotesOnly || strings.Index(p.Content, "\n\n") == -1 { 1171 o = activitystreams.NewNoteObject() 1172 } else { 1173 o = activitystreams.NewArticleObject() 1174 } 1175 o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID 1176 o.Published = p.Created 1177 o.URL = p.CanonicalURL(cfg.App.Host) 1178 o.AttributedTo = p.Collection.FederatedAccount() 1179 o.CC = []string{ 1180 p.Collection.FederatedAccount() + "/followers", 1181 } 1182 o.Name = p.DisplayTitle() 1183 p.augmentContent() 1184 if p.HTMLContent == template.HTML("") { 1185 p.formatContent(cfg, false, false) 1186 p.augmentReadingDestination() 1187 } 1188 o.Content = string(p.HTMLContent) 1189 if p.Language.Valid { 1190 o.ContentMap = map[string]string{ 1191 p.Language.String: string(p.HTMLContent), 1192 } 1193 } 1194 if len(p.Tags) == 0 { 1195 o.Tag = []activitystreams.Tag{} 1196 } else { 1197 var tagBaseURL string 1198 if isSingleUser { 1199 tagBaseURL = p.Collection.CanonicalURL() + "tag:" 1200 } else { 1201 if cfg.App.Chorus { 1202 tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName) 1203 } else { 1204 tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias) 1205 } 1206 } 1207 for _, t := range p.Tags { 1208 o.Tag = append(o.Tag, activitystreams.Tag{ 1209 Type: activitystreams.TagHashtag, 1210 HRef: tagBaseURL + t, 1211 Name: "#" + t, 1212 }) 1213 } 1214 } 1215 if len(p.Images) > 0 { 1216 for _, i := range p.Images { 1217 o.Attachment = append(o.Attachment, activitystreams.NewImageAttachment(i)) 1218 } 1219 } 1220 // Find mentioned users 1221 mentionedUsers := make(map[string]string) 1222 1223 stripper := bluemonday.StrictPolicy() 1224 content := stripper.Sanitize(p.Content) 1225 mentions := mentionReg.FindAllString(content, -1) 1226 1227 for _, handle := range mentions { 1228 actorIRI, err := app.db.GetProfilePageFromHandle(app, handle) 1229 if err != nil { 1230 log.Info("Couldn't find user '%s' locally or remotely", handle) 1231 continue 1232 } 1233 mentionedUsers[handle] = actorIRI 1234 } 1235 1236 for handle, iri := range mentionedUsers { 1237 o.CC = append(o.CC, iri) 1238 o.Tag = append(o.Tag, activitystreams.Tag{Type: "Mention", HRef: iri, Name: handle}) 1239 } 1240 return o 1241} 1242 1243// TODO: merge this into getSlugFromPost or phase it out 1244func getSlug(title, lang string) string { 1245 return getSlugFromPost("", title, lang) 1246} 1247 1248func getSlugFromPost(title, body, lang string) string { 1249 if title == "" { 1250 title = postTitle(body, body) 1251 } 1252 title = parse.PostLede(title, false) 1253 // Truncate lede if needed 1254 title, _ = parse.TruncToWord(title, 80) 1255 var s string 1256 if lang != "" && len(lang) == 2 { 1257 s = slug.MakeLang(title, lang) 1258 } else { 1259 s = slug.Make(title) 1260 } 1261 1262 // Transliteration may cause the slug to expand past the limit, so truncate again 1263 s, _ = parse.TruncToWord(s, 80) 1264 return strings.TrimFunc(s, func(r rune) bool { 1265 // TruncToWord doesn't respect words in a slug, since spaces are replaced 1266 // with hyphens. So remove any trailing hyphens. 1267 return r == '-' 1268 }) 1269} 1270 1271// isFontValid returns whether or not the submitted post's appearance is valid. 1272func (p *SubmittedPost) isFontValid() bool { 1273 validFonts := map[string]bool{ 1274 "norm": true, 1275 "sans": true, 1276 "mono": true, 1277 "wrap": true, 1278 "code": true, 1279 } 1280 1281 _, valid := validFonts[p.Font] 1282 return valid 1283} 1284 1285func getRawPost(app *App, friendlyID string) *RawPost { 1286 var content, font, title string 1287 var isRTL sql.NullBool 1288 var lang sql.NullString 1289 var ownerID sql.NullInt64 1290 var created, updated time.Time 1291 1292 err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, updated, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &updated, &ownerID) 1293 switch { 1294 case err == sql.ErrNoRows: 1295 return &RawPost{Content: "", Found: false, Gone: false} 1296 case err != nil: 1297 log.Error("Unable to fetch getRawPost: %s", err) 1298 return &RawPost{Content: "", Found: true, Gone: false} 1299 } 1300 1301 return &RawPost{ 1302 Title: title, 1303 Content: content, 1304 Font: font, 1305 Created: created, 1306 Updated: updated, 1307 IsRTL: isRTL, 1308 Language: lang, 1309 OwnerID: ownerID.Int64, 1310 Found: true, 1311 Gone: content == "" && title == "", 1312 } 1313 1314} 1315 1316// TODO; return a Post! 1317func getRawCollectionPost(app *App, slug, collAlias string) *RawPost { 1318 var id, title, content, font string 1319 var isRTL sql.NullBool 1320 var lang sql.NullString 1321 var created, updated time.Time 1322 var ownerID null.Int 1323 var views int64 1324 var err error 1325 1326 if app.cfg.App.SingleUser { 1327 err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID) 1328 } else { 1329 err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID) 1330 } 1331 switch { 1332 case err == sql.ErrNoRows: 1333 return &RawPost{Content: "", Found: false, Gone: false} 1334 case err != nil: 1335 log.Error("Unable to fetch getRawCollectionPost: %s", err) 1336 return &RawPost{Content: "", Found: true, Gone: false} 1337 } 1338 1339 return &RawPost{ 1340 Id: id, 1341 Slug: slug, 1342 Title: title, 1343 Content: content, 1344 Font: font, 1345 Created: created, 1346 Updated: updated, 1347 IsRTL: isRTL, 1348 Language: lang, 1349 OwnerID: ownerID.Int64, 1350 Found: true, 1351 Gone: content == "" && title == "", 1352 Views: views, 1353 } 1354} 1355 1356func isRaw(r *http.Request) bool { 1357 vars := mux.Vars(r) 1358 slug := vars["slug"] 1359 1360 // NOTE: until this is done better, be sure to keep this in parity with 1361 // isRaw in viewCollectionPost() and handleViewPost() 1362 isJSON := strings.HasSuffix(slug, ".json") 1363 isXML := strings.HasSuffix(slug, ".xml") 1364 isMarkdown := strings.HasSuffix(slug, ".md") 1365 return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown 1366} 1367 1368func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error { 1369 vars := mux.Vars(r) 1370 slug := vars["slug"] 1371 1372 // NOTE: until this is done better, be sure to keep this in parity with 1373 // isRaw() and handleViewPost() 1374 isJSON := strings.HasSuffix(slug, ".json") 1375 isXML := strings.HasSuffix(slug, ".xml") 1376 isMarkdown := strings.HasSuffix(slug, ".md") 1377 isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown 1378 1379 cr := &collectionReq{} 1380 err := processCollectionRequest(cr, vars, w, r) 1381 if err != nil { 1382 return err 1383 } 1384 1385 // Check for hellbanned users 1386 u, err := checkUserForCollection(app, cr, r, true) 1387 if err != nil { 1388 return err 1389 } 1390 1391 // Normalize the URL, redirecting user to consistent post URL 1392 if slug != strings.ToLower(slug) { 1393 loc := fmt.Sprintf("/%s", strings.ToLower(slug)) 1394 if !app.cfg.App.SingleUser { 1395 loc = "/" + cr.alias + loc 1396 } 1397 return impart.HTTPError{http.StatusMovedPermanently, loc} 1398 } 1399 1400 // Display collection if this is a collection 1401 var c *Collection 1402 if app.cfg.App.SingleUser { 1403 c, err = app.db.GetCollectionByID(1) 1404 } else { 1405 c, err = app.db.GetCollection(cr.alias) 1406 } 1407 if err != nil { 1408 if err, ok := err.(impart.HTTPError); ok { 1409 if err.Status == http.StatusNotFound { 1410 // Redirect if necessary 1411 newAlias := app.db.GetCollectionRedirect(cr.alias) 1412 if newAlias != "" { 1413 return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug} 1414 } 1415 } 1416 } 1417 return err 1418 } 1419 c.hostName = app.cfg.App.Host 1420 1421 silenced, err := app.db.IsUserSilenced(c.OwnerID) 1422 if err != nil { 1423 log.Error("view collection post: %v", err) 1424 } 1425 1426 // Check collection permissions 1427 if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) { 1428 return ErrPostNotFound 1429 } 1430 if c.IsProtected() && (u == nil || u.ID != c.OwnerID) { 1431 if silenced { 1432 return ErrPostNotFound 1433 } else if !isAuthorizedForCollection(app, c.Alias, r) { 1434 return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug} 1435 } 1436 } 1437 1438 cr.isCollOwner = u != nil && c.OwnerID == u.ID 1439 1440 if isRaw { 1441 slug = strings.Split(slug, ".")[0] 1442 } 1443 1444 // Fetch extra data about the Collection 1445 // TODO: refactor out this logic, shared in collection.go:fetchCollection() 1446 coll := NewCollectionObj(c) 1447 owner, err := app.db.GetUserByID(coll.OwnerID) 1448 if err != nil { 1449 // Log the error and just continue 1450 log.Error("Error getting user for collection: %v", err) 1451 } else { 1452 coll.Owner = owner 1453 } 1454 1455 postFound := true 1456 p, err := app.db.GetPost(slug, coll.ID) 1457 if err != nil { 1458 if err == ErrCollectionPageNotFound { 1459 postFound = false 1460 1461 if slug == "feed" { 1462 // User tried to access blog feed without a trailing slash, and 1463 // there's no post with a slug "feed" 1464 return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "feed/"} 1465 } 1466 1467 po := &Post{ 1468 Slug: null.NewString(slug, true), 1469 Font: "norm", 1470 Language: zero.NewString("en", true), 1471 RTL: zero.NewBool(false, true), 1472 Content: `<p class="msg">This page is missing.</p> 1473 1474Are you sure it was ever here?`, 1475 } 1476 pp := po.processPost() 1477 p = &pp 1478 } else { 1479 return err 1480 } 1481 } 1482 1483 // Check if the authenticated user is the post owner 1484 p.IsOwner = u != nil && u.ID == p.OwnerID.Int64 1485 p.Collection = coll 1486 p.IsTopLevel = app.cfg.App.SingleUser 1487 1488 // Only allow a post owner or admin to view a post for silenced collections 1489 if silenced && !p.IsOwner && (u == nil || !u.IsAdmin()) { 1490 return ErrPostNotFound 1491 } 1492 1493 // Check if post has been unpublished 1494 if p.Content == "" && p.Title.String == "" { 1495 return impart.HTTPError{http.StatusGone, "Post was unpublished."} 1496 } 1497 1498 p.augmentContent() 1499 1500 // Serve collection post 1501 if isRaw { 1502 contentType := "text/plain" 1503 if isJSON { 1504 contentType = "application/json" 1505 } else if isXML { 1506 contentType = "application/xml" 1507 } else if isMarkdown { 1508 contentType = "text/markdown" 1509 } 1510 w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType)) 1511 if !postFound { 1512 w.WriteHeader(http.StatusNotFound) 1513 fmt.Fprintf(w, "Post not found.") 1514 // TODO: return error instead, so status is correctly reflected in logs 1515 return nil 1516 } 1517 if isMarkdown && p.Title.String != "" { 1518 fmt.Fprintf(w, "# %s\n\n", p.Title.String) 1519 } 1520 fmt.Fprint(w, p.Content) 1521 } else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { 1522 if !postFound { 1523 return ErrCollectionPageNotFound 1524 } 1525 p.extractData() 1526 ap := p.ActivityObject(app) 1527 ap.Context = []interface{}{activitystreams.Namespace} 1528 setCacheControl(w, apCacheTime) 1529 return impart.RenderActivityJSON(w, ap, http.StatusOK) 1530 } else { 1531 p.extractData() 1532 p.Content = strings.Replace(p.Content, "<!--more-->", "", 1) 1533 // TODO: move this to function 1534 p.formatContent(app.cfg, cr.isCollOwner, true) 1535 tp := CollectionPostPage{ 1536 PublicPost: p, 1537 StaticPage: pageForReq(app, r), 1538 IsOwner: cr.isCollOwner, 1539 IsCustomDomain: cr.isCustomDomain, 1540 IsFound: postFound, 1541 Silenced: silenced, 1542 CollAlias: c.Alias, 1543 } 1544 tp.IsAdmin = u != nil && u.IsAdmin() 1545 tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) 1546 tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner) 1547 tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p) 1548 tp.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") 1549 1550 if !postFound { 1551 w.WriteHeader(http.StatusNotFound) 1552 } 1553 postTmpl := "collection-post" 1554 if app.cfg.App.Chorus { 1555 postTmpl = "chorus-collection-post" 1556 } 1557 if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil { 1558 log.Error("Error in %s template: %v", postTmpl, err) 1559 } 1560 } 1561 1562 go func() { 1563 if p.OwnerID.Valid { 1564 // Post is owned by someone. Don't update stats if owner is viewing the post. 1565 if u != nil && p.OwnerID.Int64 == u.ID { 1566 return 1567 } 1568 } 1569 // Update stats for non-raw post views 1570 if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) { 1571 _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID) 1572 if err != nil { 1573 log.Error("Unable to update posts count: %v", err) 1574 } 1575 } 1576 }() 1577 1578 return nil 1579} 1580 1581// TODO: move this to utils after making it more generic 1582func PostsContains(sl *[]PublicPost, s *PublicPost) bool { 1583 for _, e := range *sl { 1584 if e.ID == s.ID { 1585 return true 1586 } 1587 } 1588 return false 1589} 1590 1591func (p *Post) extractData() { 1592 p.Tags = tags.Extract(p.Content) 1593 p.extractImages() 1594} 1595 1596func (rp *RawPost) UserFacingCreated() string { 1597 return rp.Created.Format(postMetaDateFormat) 1598} 1599 1600func (rp *RawPost) Created8601() string { 1601 return rp.Created.Format("2006-01-02T15:04:05Z") 1602} 1603 1604func (rp *RawPost) Updated8601() string { 1605 if rp.Updated.IsZero() { 1606 return "" 1607 } 1608 return rp.Updated.Format("2006-01-02T15:04:05Z") 1609} 1610 1611var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|image)$`) 1612 1613func (p *Post) extractImages() { 1614 p.Images = extractImages(p.Content) 1615} 1616 1617func extractImages(content string) []string { 1618 matches := extract.ExtractUrls(content) 1619 urls := map[string]bool{} 1620 for i := range matches { 1621 uRaw := matches[i].Text 1622 // Parse the extracted text so we can examine the path 1623 u, err := url.Parse(uRaw) 1624 if err != nil { 1625 continue 1626 } 1627 // Ensure the path looks like it leads to an image file 1628 if !imageURLRegex.MatchString(u.Path) { 1629 continue 1630 } 1631 urls[uRaw] = true 1632 } 1633 1634 resURLs := make([]string, 0) 1635 for k := range urls { 1636 resURLs = append(resURLs, k) 1637 } 1638 return resURLs 1639} 1640