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	"fmt"
16	"html/template"
17	"math"
18	"net/http"
19	"strconv"
20	"time"
21
22	. "github.com/gorilla/feeds"
23	"github.com/gorilla/mux"
24	stripmd "github.com/writeas/go-strip-markdown/v2"
25	"github.com/writeas/impart"
26	"github.com/writeas/web-core/log"
27	"github.com/writeas/web-core/memo"
28	"github.com/writefreely/writefreely/page"
29)
30
31const (
32	tlFeedLimit      = 100
33	tlAPIPageLimit   = 10
34	tlMaxAuthorPosts = 5
35	tlPostsPerPage   = 16
36	tlMaxPostCache   = 250
37	tlCacheDur       = 10 * time.Minute
38)
39
40type localTimeline struct {
41	m     *memo.Memo
42	posts *[]PublicPost
43
44	// Configuration values
45	postsPerPage int
46}
47
48type readPublication struct {
49	page.StaticPage
50	Posts       *[]PublicPost
51	CurrentPage int
52	TotalPages  int
53	SelTopic    string
54	IsAdmin     bool
55	CanInvite   bool
56
57	// Customizable page content
58	ContentTitle string
59	Content      template.HTML
60}
61
62func initLocalTimeline(app *App) {
63	app.timeline = &localTimeline{
64		postsPerPage: tlPostsPerPage,
65		m:            memo.New(app.FetchPublicPosts, tlCacheDur),
66	}
67}
68
69// satisfies memo.Func
70func (app *App) FetchPublicPosts() (interface{}, error) {
71	// Conditions
72	limit := fmt.Sprintf("LIMIT %d", tlMaxPostCache)
73	// This is better than the hard limit when limiting posts from individual authors
74	// ageCond := `p.created >= ` + app.db.dateSub(3, "month") + ` AND `
75
76	// Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months
77	rows, err := app.db.Query(`SELECT p.id, c.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated
78	FROM collections c
79	LEFT JOIN posts p ON p.collection_id = c.id
80	LEFT JOIN users u ON u.id = p.owner_id
81	WHERE c.privacy = 1 AND (p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0
82	ORDER BY p.created DESC
83	` + limit)
84	if err != nil {
85		log.Error("Failed selecting from posts: %v", err)
86		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()}
87	}
88	defer rows.Close()
89
90	ap := map[string]uint{}
91
92	posts := []PublicPost{}
93	for rows.Next() {
94		p := &Post{}
95		c := &Collection{}
96		var alias, title sql.NullString
97		err = rows.Scan(&p.ID, &c.ID, &alias, &title, &p.Slug, &p.Title, &p.Content, &p.Font, &p.Language, &p.RTL, &p.Created, &p.Updated)
98		if err != nil {
99			log.Error("[READ] Unable to scan row, skipping: %v", err)
100			continue
101		}
102		c.hostName = app.cfg.App.Host
103
104		isCollectionPost := alias.Valid
105		if isCollectionPost {
106			c.Alias = alias.String
107			if c.Alias != "" && ap[c.Alias] == tlMaxAuthorPosts {
108				// Don't add post if we've hit the post-per-author limit
109				continue
110			}
111
112			c.Public = true
113			c.Title = title.String
114			c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer")
115		}
116
117		p.extractData()
118		p.handlePremiumContent(c, false, false, app.cfg)
119		p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), "", app.cfg))
120		fp := p.processPost()
121		if isCollectionPost {
122			fp.Collection = &CollectionObj{Collection: *c}
123		}
124
125		posts = append(posts, fp)
126		ap[c.Alias]++
127	}
128
129	return posts, nil
130}
131
132func viewLocalTimelineAPI(app *App, w http.ResponseWriter, r *http.Request) error {
133	updateTimelineCache(app.timeline, false)
134
135	skip, _ := strconv.Atoi(r.FormValue("skip"))
136
137	posts := []PublicPost{}
138	for i := skip; i < skip+tlAPIPageLimit && i < len(*app.timeline.posts); i++ {
139		posts = append(posts, (*app.timeline.posts)[i])
140	}
141
142	return impart.WriteSuccess(w, posts, http.StatusOK)
143}
144
145func viewLocalTimeline(app *App, w http.ResponseWriter, r *http.Request) error {
146	if !app.cfg.App.LocalTimeline {
147		return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."}
148	}
149
150	vars := mux.Vars(r)
151	var p int
152	page := 1
153	p, _ = strconv.Atoi(vars["page"])
154	if p > 0 {
155		page = p
156	}
157
158	return showLocalTimeline(app, w, r, page, vars["author"], vars["tag"])
159}
160
161// updateTimelineCache will reset and update the cache if it is stale or
162// the boolean passed in is true.
163func updateTimelineCache(tl *localTimeline, reset bool) {
164	if reset {
165		tl.m.Reset()
166	}
167
168	// Fetch posts if the cache is empty, has been reset or enough time has
169	// passed since last cache.
170	if tl.posts == nil || reset || tl.m.Invalidate() {
171		log.Info("[READ] Updating post cache")
172
173		postsInterfaces, err := tl.m.Get()
174		if err != nil {
175			log.Error("[READ] Unable to cache posts: %v", err)
176		} else {
177			castPosts := postsInterfaces.([]PublicPost)
178			tl.posts = &castPosts
179		}
180	}
181
182}
183
184func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page int, author, tag string) error {
185	updateTimelineCache(app.timeline, false)
186
187	pl := len(*(app.timeline.posts))
188	ttlPages := int(math.Ceil(float64(pl) / float64(app.timeline.postsPerPage)))
189
190	start := 0
191	if page > 1 {
192		start = app.timeline.postsPerPage * (page - 1)
193		if start > pl {
194			return impart.HTTPError{http.StatusFound, fmt.Sprintf("/read/p/%d", ttlPages)}
195		}
196	}
197	end := app.timeline.postsPerPage * page
198	if end > pl {
199		end = pl
200	}
201	var posts []PublicPost
202	if author != "" {
203		posts = []PublicPost{}
204		for _, p := range *app.timeline.posts {
205			if author == "anonymous" {
206				if p.Collection == nil {
207					posts = append(posts, p)
208				}
209			} else if p.Collection != nil && p.Collection.Alias == author {
210				posts = append(posts, p)
211			}
212		}
213	} else if tag != "" {
214		posts = []PublicPost{}
215		for _, p := range *app.timeline.posts {
216			if p.HasTag(tag) {
217				posts = append(posts, p)
218			}
219		}
220	} else {
221		posts = *app.timeline.posts
222		posts = posts[start:end]
223	}
224
225	d := &readPublication{
226		StaticPage:  pageForReq(app, r),
227		Posts:       &posts,
228		CurrentPage: page,
229		TotalPages:  ttlPages,
230		SelTopic:    tag,
231	}
232	if app.cfg.App.Chorus {
233		u := getUserSession(app, r)
234		d.IsAdmin = u != nil && u.IsAdmin()
235		d.CanInvite = canUserInvite(app.cfg, d.IsAdmin)
236	}
237	c, err := getReaderSection(app)
238	if err != nil {
239		return err
240	}
241	d.ContentTitle = c.Title.String
242	d.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg))
243
244	err = templates["read"].ExecuteTemplate(w, "base", d)
245	if err != nil {
246		log.Error("Unable to render reader: %v", err)
247		fmt.Fprintf(w, ":(")
248	}
249	return nil
250}
251
252// NextPageURL provides a full URL for the next page of collection posts
253func (c *readPublication) NextPageURL(n int) string {
254	return fmt.Sprintf("/read/p/%d", n+1)
255}
256
257// PrevPageURL provides a full URL for the previous page of collection posts,
258// returning a /page/N result for pages >1
259func (c *readPublication) PrevPageURL(n int) string {
260	if n == 2 {
261		// Previous page is 1; no need for /p/ prefix
262		return "/read"
263	}
264	return fmt.Sprintf("/read/p/%d", n-1)
265}
266
267// handlePostIDRedirect handles a route where a post ID is given and redirects
268// the user to the canonical post URL.
269func handlePostIDRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
270	vars := mux.Vars(r)
271	postID := vars["post"]
272	p, err := app.db.GetPost(postID, 0)
273	if err != nil {
274		return err
275	}
276
277	if !p.CollectionID.Valid {
278		// No collection; send to normal URL
279		// NOTE: not handling single user blogs here since this handler is only used for the Reader
280		return impart.HTTPError{http.StatusFound, app.cfg.App.Host + "/" + postID + ".md"}
281	}
282
283	c, err := app.db.GetCollectionBy("id = ?", fmt.Sprintf("%d", p.CollectionID.Int64))
284	if err != nil {
285		return err
286	}
287	c.hostName = app.cfg.App.Host
288
289	// Retrieve collection information and send user to canonical URL
290	return impart.HTTPError{http.StatusFound, c.CanonicalURL() + p.Slug.String}
291}
292
293func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) error {
294	if !app.cfg.App.LocalTimeline {
295		return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."}
296	}
297
298	updateTimelineCache(app.timeline, false)
299
300	feed := &Feed{
301		Title:       app.cfg.App.SiteName + " Reader",
302		Link:        &Link{Href: app.cfg.App.Host},
303		Description: "Read the latest posts from " + app.cfg.App.SiteName + ".",
304		Created:     time.Now(),
305	}
306
307	c := 0
308	var title, permalink, author string
309	for _, p := range *app.timeline.posts {
310		if c == tlFeedLimit {
311			break
312		}
313
314		title = p.PlainDisplayTitle()
315		permalink = p.CanonicalURL(app.cfg.App.Host)
316		if p.Collection != nil {
317			author = p.Collection.Title
318		} else {
319			author = "Anonymous"
320		}
321		i := &Item{
322			Id:          app.cfg.App.Host + "/read/a/" + p.ID,
323			Title:       title,
324			Link:        &Link{Href: permalink},
325			Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>",
326			Content:     applyMarkdown([]byte(p.Content), "", app.cfg),
327			Author:      &Author{author, ""},
328			Created:     p.Created,
329			Updated:     p.Updated,
330		}
331		feed.Items = append(feed.Items, i)
332		c++
333	}
334
335	rss, err := feed.ToRss()
336	if err != nil {
337		return err
338	}
339
340	fmt.Fprint(w, rss)
341	return nil
342}
343