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