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