/*
 * Copyright © 2018-2021 Musing Studio LLC.
 *
 * This file is part of WriteFreely.
 *
 * WriteFreely is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License, included
 * in the LICENSE file in this source code package.
 */

package writefreely

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"github.com/writefreely/writefreely/spam"
	"html/template"
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"time"

	"github.com/gorilla/mux"
	"github.com/gosimple/slug"
	"github.com/guregu/null"
	"github.com/guregu/null/zero"
	"github.com/kylemcc/twitter-text-go/extract"
	"github.com/microcosm-cc/bluemonday"
	stripmd "github.com/writeas/go-strip-markdown/v2"
	"github.com/writeas/impart"
	"github.com/writeas/monday"
	"github.com/writeas/web-core/activitystreams"
	"github.com/writeas/web-core/bots"
	"github.com/writeas/web-core/converter"
	"github.com/writeas/web-core/i18n"
	"github.com/writeas/web-core/log"
	"github.com/writeas/web-core/tags"
	"github.com/writefreely/writefreely/page"
	"github.com/writefreely/writefreely/parse"
)

const (
	// Post ID length bounds
	minIDLen      = 10
	maxIDLen      = 10
	userPostIDLen = 10
	postIDLen     = 10

	postMetaDateFormat = "2006-01-02 15:04:05"

	shortCodePaid = "<!--paid-->"
)

type (
	AnonymousPost struct {
		ID          string
		Content     string
		HTMLContent template.HTML
		Font        string
		Language    string
		Direction   string
		Title       string
		GenTitle    string
		Description string
		Author      string
		Views       int64
		Images      []string
		IsPlainText bool
		IsCode      bool
		IsLinkable  bool
	}

	AuthenticatedPost struct {
		ID  string `json:"id" schema:"id"`
		Web bool   `json:"web" schema:"web"`
		*SubmittedPost
	}

	// SubmittedPost represents a post supplied by a client for publishing or
	// updating. Since Title and Content can be updated to "", they are
	// pointers that can be easily tested to detect changes.
	SubmittedPost struct {
		Slug     *string                  `json:"slug" schema:"slug"`
		Title    *string                  `json:"title" schema:"title"`
		Content  *string                  `json:"body" schema:"body"`
		Font     string                   `json:"font" schema:"font"`
		IsRTL    converter.NullJSONBool   `json:"rtl" schema:"rtl"`
		Language converter.NullJSONString `json:"lang" schema:"lang"`
		Created  *string                  `json:"created" schema:"created"`
	}

	// Post represents a post as found in the database.
	Post struct {
		ID             string        `db:"id" json:"id"`
		Slug           null.String   `db:"slug" json:"slug,omitempty"`
		Font           string        `db:"text_appearance" json:"appearance"`
		Language       zero.String   `db:"language" json:"language"`
		RTL            zero.Bool     `db:"rtl" json:"rtl"`
		Privacy        int64         `db:"privacy" json:"-"`
		OwnerID        null.Int      `db:"owner_id" json:"-"`
		CollectionID   null.Int      `db:"collection_id" json:"-"`
		PinnedPosition null.Int      `db:"pinned_position" json:"-"`
		Created        time.Time     `db:"created" json:"created"`
		Updated        time.Time     `db:"updated" json:"updated"`
		ViewCount      int64         `db:"view_count" json:"-"`
		LikeCount      int64         `db:"like_count" json:"likes"`
		Title          zero.String   `db:"title" json:"title"`
		HTMLTitle      template.HTML `db:"title" json:"-"`
		Content        string        `db:"content" json:"body"`
		HTMLContent    template.HTML `db:"content" json:"-"`
		HTMLExcerpt    template.HTML `db:"content" json:"-"`
		Tags           []string      `json:"tags"`
		Images         []string      `json:"images,omitempty"`
		IsPaid         bool          `json:"paid"`

		OwnerName string `json:"owner,omitempty"`
	}

	// PublicPost holds properties for a publicly returned post, i.e. a post in
	// a context where the viewer may not be the owner. As such, sensitive
	// metadata for the post is hidden and properties supporting the display of
	// the post are added.
	PublicPost struct {
		*Post
		IsSubdomain bool           `json:"-"`
		IsTopLevel  bool           `json:"-"`
		DisplayDate string         `json:"-"`
		Views       int64          `json:"views"`
		Likes       int64          `json:"likes"`
		Owner       *PublicUser    `json:"-"`
		IsOwner     bool           `json:"-"`
		URL         string         `json:"url,omitempty"`
		Collection  *CollectionObj `json:"collection,omitempty"`
	}

	CollectionPostPage struct {
		*PublicPost
		page.StaticPage
		IsOwner        bool
		IsPinned       bool
		IsCustomDomain bool
		Monetization   string
		Verification   string
		PinnedPosts    *[]PublicPost
		IsFound        bool
		IsAdmin        bool
		CanInvite      bool
		Silenced       bool

		// Helper field for Chorus mode
		CollAlias string
	}

	RawPost struct {
		Id, Slug     string
		Title        string
		Content      string
		Views        int64
		Font         string
		Created      time.Time
		Updated      time.Time
		IsRTL        sql.NullBool
		Language     sql.NullString
		OwnerID      int64
		CollectionID sql.NullInt64

		Found bool
		Gone  bool
	}

	AnonymousAuthPost struct {
		ID    string `json:"id"`
		Token string `json:"token"`
	}
	ClaimPostRequest struct {
		*AnonymousAuthPost
		CollectionAlias  string `json:"collection"`
		CreateCollection bool   `json:"create_collection"`

		// Generated properties
		Slug string `json:"-"`
	}
	ClaimPostResult struct {
		ID           string      `json:"id,omitempty"`
		Code         int         `json:"code,omitempty"`
		ErrorMessage string      `json:"error_msg,omitempty"`
		Post         *PublicPost `json:"post,omitempty"`
	}
)

func (p *Post) Direction() string {
	if p.RTL.Valid {
		if p.RTL.Bool {
			return "rtl"
		}
		return "ltr"
	}
	return "auto"
}

// DisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) DisplayTitle() string {
	if p.Title.String != "" {
		return p.Title.String
	}
	t := friendlyPostTitle(p.Content, p.ID)
	return t
}

// PlainDisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) PlainDisplayTitle() string {
	if t := stripmd.Strip(p.DisplayTitle()); t != "" {
		return t
	}
	return p.ID
}

// FormattedDisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) FormattedDisplayTitle() template.HTML {
	if p.HTMLTitle != "" {
		return p.HTMLTitle
	}
	return template.HTML(p.DisplayTitle())
}

// Summary gives a shortened summary of the post based on the post's title,
// especially for display in a longer list of posts. It extracts a summary for
// posts in the Title\n\nBody format, returning nothing if the entire was short
// enough that the extracted title == extracted summary.
func (p Post) Summary() string {
	if p.Content == "" {
		return ""
	}
	p.Content = stripHTMLWithoutEscaping(p.Content)
	// and Markdown
	p.Content = stripmd.StripOptions(p.Content, stripmd.Options{SkipImages: true})

	title := p.Title.String
	var desc string
	if title == "" {
		// No title, so generate one
		title = friendlyPostTitle(p.Content, p.ID)
		desc = postDescription(p.Content, title, p.ID)
		if desc == title {
			return ""
		}
		return desc
	}

	return shortPostDescription(p.Content)
}

func (p Post) SummaryHTML() template.HTML {
	return template.HTML(p.Summary())
}

// Excerpt shows any text that comes before a (more) tag.
// TODO: use HTMLExcerpt in templates instead of this method
func (p *Post) Excerpt() template.HTML {
	return p.HTMLExcerpt
}

func (p *Post) CreatedDate() string {
	return p.Created.Format("2006-01-02")
}

func (p *Post) Created8601() string {
	return p.Created.Format("2006-01-02T15:04:05Z")
}

func (p *Post) IsScheduled() bool {
	return p.Created.After(time.Now())
}

func (p *Post) HasTag(tag string) bool {
	// Regexp looks for tag and has a non-capturing group at the end looking
	// for the end of the word.
	// Assisted by: https://stackoverflow.com/a/35192941/1549194
	hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content)
	return hasTag
}

func (p *Post) HasTitleLink() bool {
	if p.Title.String == "" {
		return false
	}
	hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String)
	return hasLink
}

func (c CollectionPostPage) DisplayMonetization() string {
	if c.Collection == nil {
		log.Info("CollectionPostPage.DisplayMonetization: c.Collection is nil")
		return ""
	}
	return displayMonetization(c.Monetization, c.Collection.Alias)
}

func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
	vars := mux.Vars(r)
	friendlyID := vars["post"]

	// NOTE: until this is done better, be sure to keep this in parity with
	// isRaw() and viewCollectionPost()
	isJSON := strings.HasSuffix(friendlyID, ".json")
	isXML := strings.HasSuffix(friendlyID, ".xml")
	isCSS := strings.HasSuffix(friendlyID, ".css")
	isMarkdown := strings.HasSuffix(friendlyID, ".md")
	isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown

	// Display reserved page if that is requested resource
	if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok {
		return handleTemplatedPage(app, w, r, t)
	} else if r.URL.Path == "/sitemap.xml" && !app.cfg.App.SingleUser {
		return impart.HTTPError{Status: http.StatusNotFound, Message: "Page not found."}
	} else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" {
		// Serve static file
		app.shttp.ServeHTTP(w, r)
		return nil
	}

	// Display collection if this is a collection
	c, _ := app.db.GetCollection(friendlyID)
	if c != nil {
		return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)}
	}

	// Normalize the URL, redirecting user to consistent post URL
	if friendlyID != strings.ToLower(friendlyID) {
		return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))}
	}

	ext := ""
	if isRaw {
		parts := strings.Split(friendlyID, ".")
		friendlyID = parts[0]
		if len(parts) > 1 {
			ext = "." + parts[1]
		}
	}

	var ownerID sql.NullInt64
	var collectionID sql.NullInt64
	var title string
	var content string
	var font string
	var language []byte
	var rtl []byte
	var views int64
	var post *AnonymousPost
	var found bool
	var gone bool

	fixedID := slug.Make(friendlyID)
	if fixedID != friendlyID {
		return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)}
	}

	err := app.db.QueryRow("SELECT owner_id, collection_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?", friendlyID).Scan(&ownerID, &collectionID, &title, &content, &font, &views, &language, &rtl)
	switch {
	case err == sql.ErrNoRows:
		found = false

		// Output the error in the correct format
		if isJSON {
			content = "{\"error\": \"Post not found.\"}"
		} else if isRaw {
			content = "Post not found."
		} else {
			return ErrPostNotFound
		}
	case err != nil:
		found = false

		log.Error("Post loading err: %s\n", err)
		return ErrInternalGeneral
	default:
		found = true

		var d string
		if len(rtl) == 0 {
			d = "auto"
		} else if rtl[0] == 49 {
			// TODO: find a cleaner way to get this (possibly NULL) value
			d = "rtl"
		} else {
			d = "ltr"
		}
		generatedTitle := friendlyPostTitle(content, friendlyID)
		sanitizedContent := content
		if font != "code" {
			sanitizedContent = template.HTMLEscapeString(content)
		}
		var desc string
		if title == "" {
			desc = postDescription(content, title, friendlyID)
		} else {
			desc = shortPostDescription(content)
		}
		post = &AnonymousPost{
			ID:          friendlyID,
			Content:     sanitizedContent,
			Title:       title,
			GenTitle:    generatedTitle,
			Description: desc,
			Author:      "",
			Font:        font,
			IsPlainText: isRaw,
			IsCode:      font == "code",
			IsLinkable:  font != "code",
			Views:       views,
			Language:    string(language),
			Direction:   d,
		}
		if !isRaw {
			post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg))
			post.Images = extractImages(post.Content)
		}
	}

	var silenced bool
	if found {
		silenced, err = app.db.IsUserSilenced(ownerID.Int64)
		if err != nil {
			log.Error("view post: %v", err)
		}
	}

	var protectDraft bool
	if found && collectionID.Valid {
		collection, err := app.db.GetCollectionByID(collectionID.Int64)
		if err != nil {
			log.Error("view post: %v", err)
		}

		protectDraft = collection.IsPrivate() || collection.IsProtected()
	}

	// Check if post has been unpublished
	if title == "" && content == "" {
		gone = true

		if isJSON {
			content = "{\"error\": \"Post was unpublished.\"}"
		} else if isCSS {
			content = ""
		} else if isRaw {
			content = "Post was unpublished."
		} else {
			return ErrPostUnpublished
		}
	}

	var u = &User{}
	if isRaw {
		contentType := "text/plain"
		if isJSON {
			contentType = "application/json"
		} else if isCSS {
			contentType = "text/css"
		} else if isXML {
			contentType = "application/xml"
		} else if isMarkdown {
			contentType = "text/markdown"
		}
		w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
		if isMarkdown && post.Title != "" {
			fmt.Fprintf(w, "%s\n", post.Title)
			for i := 1; i <= len(post.Title); i++ {
				fmt.Fprintf(w, "=")
			}
			fmt.Fprintf(w, "\n\n")
		}
		fmt.Fprint(w, content)

		if !found {
			return ErrPostNotFound
		} else if gone {
			return ErrPostUnpublished
		}
	} else {
		var err error
		page := struct {
			*AnonymousPost
			page.StaticPage
			Username string
			IsOwner  bool
			SiteURL  string
			Silenced bool
		}{
			AnonymousPost: post,
			StaticPage:    pageForReq(app, r),
			SiteURL:       app.cfg.App.Host,
		}
		if u = getUserSession(app, r); u != nil {
			page.Username = u.Username
			page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
		}

		if !page.IsOwner && silenced {
			return ErrPostNotFound
		}

		if !page.IsOwner && protectDraft {
			return ErrPostNotFound
		}
		page.Silenced = silenced
		err = templates["post"].ExecuteTemplate(w, "post", page)
		if err != nil {
			log.Error("Post template execute error: %v", err)
		}
	}

	go func() {
		if u != nil && ownerID.Valid && ownerID.Int64 == u.ID {
			// Post is owned by someone; skip view increment since that person is viewing this post.
			return
		}
		// Update stats for non-raw post views
		if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
			_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID)
			if err != nil {
				log.Error("Unable to update posts count: %v", err)
			}
		}
	}()

	return nil
}

// API v2 funcs
// newPost creates a new post with or without an owning Collection.
//
// Endpoints:
//   - /posts
//   - /posts?collection={alias}
//   - ? /collections/{alias}/posts
func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
	reqJSON := IsJSON(r)
	vars := mux.Vars(r)
	collAlias := vars["alias"]
	if collAlias == "" {
		collAlias = r.FormValue("collection")
	}
	accessToken := r.Header.Get("Authorization")
	if accessToken == "" {
		// TODO: remove this
		accessToken = r.FormValue("access_token")
	}

	// FIXME: determine web submission with Content-Type header
	var u *User
	var userID int64 = -1
	var username string
	if accessToken == "" {
		u = getUserSession(app, r)
		if u != nil {
			userID = u.ID
			username = u.Username
		}
	} else {
		userID = app.db.GetUserID(accessToken)
	}
	silenced, err := app.db.IsUserSilenced(userID)
	if err != nil {
		log.Error("new post: %v", err)
	}
	if silenced {
		return ErrUserSilenced
	}

	if userID == -1 {
		return ErrNotLoggedIn
	}

	if accessToken == "" && u == nil && collAlias != "" {
		return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."}
	}

	// Get post data
	var p *SubmittedPost
	if reqJSON {
		decoder := json.NewDecoder(r.Body)
		err = decoder.Decode(&p)
		if err != nil {
			log.Error("Couldn't parse new post JSON request: %v\n", err)
			return ErrBadJSON
		}
		if p.Title == nil {
			t := ""
			p.Title = &t
		}
		if strings.TrimSpace(*(p.Title)) == "" && (p.Content == nil || strings.TrimSpace(*(p.Content)) == "") {
			return ErrNoPublishableContent
		}
		if p.Content == nil {
			c := ""
			p.Content = &c
		}

	} else {
		post := r.FormValue("body")
		appearance := r.FormValue("font")
		title := r.FormValue("title")
		rtlValue := r.FormValue("rtl")
		langValue := r.FormValue("lang")
		if strings.TrimSpace(post) == "" {
			return ErrNoPublishableContent
		}

		var isRTL, rtlValid bool
		if rtlValue == "auto" && langValue != "" {
			isRTL = i18n.LangIsRTL(langValue)
			rtlValid = true
		} else {
			isRTL = rtlValue == "true"
			rtlValid = rtlValue != "" && langValue != ""
		}

		// Create a new post
		p = &SubmittedPost{
			Title:    &title,
			Content:  &post,
			Font:     appearance,
			IsRTL:    converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}},
			Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}},
		}
	}
	if !p.isFontValid() {
		p.Font = "norm"
	}

	var newPost *PublicPost = &PublicPost{}
	var coll *Collection
	if accessToken != "" {
		newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host)
	} else {
		//return ErrNotLoggedIn
		// TODO: verify user is logged in
		var collID int64
		if collAlias != "" {
			coll, err = app.db.GetCollection(collAlias)
			if err != nil {
				return err
			}
			coll.hostName = app.cfg.App.Host
			if coll.OwnerID != u.ID {
				return ErrForbiddenCollection
			}
			collID = coll.ID
		}
		// TODO: return PublicPost from createPost
		newPost.Post, err = app.db.CreatePost(userID, collID, p)
	}
	if err != nil {
		return err
	}
	if coll != nil {
		coll.ForPublic()
		newPost.Collection = &CollectionObj{Collection: *coll}
	}

	newPost.extractData()
	newPost.OwnerName = username
	newPost.URL = newPost.CanonicalURL(app.cfg.App.Host)

	// Write success now
	response := impart.WriteSuccess(w, newPost, http.StatusCreated)

	if newPost.Collection != nil {
		if !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
			go federatePost(app, newPost, newPost.Collection.ID, false)
		}
		if app.cfg.Email.Enabled() && newPost.Collection.EmailSubsEnabled() {
			go app.db.InsertJob(&PostJob{
				PostID: newPost.ID,
				Action: "email",
				Delay:  emailSendDelay,
			})
		}
	}

	return response
}

func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
	reqJSON := IsJSON(r)
	vars := mux.Vars(r)
	postID := vars["post"]

	p := AuthenticatedPost{ID: postID}
	var err error

	if reqJSON {
		// Decode JSON request
		decoder := json.NewDecoder(r.Body)
		err = decoder.Decode(&p)
		if err != nil {
			log.Error("Couldn't parse post update JSON request: %v\n", err)
			return ErrBadJSON
		}
	} else {
		err = r.ParseForm()
		if err != nil {
			log.Error("Couldn't parse post update form request: %v\n", err)
			return ErrBadFormData
		}

		// Can't decode to a nil SubmittedPost property, so create instance now
		p.SubmittedPost = &SubmittedPost{}
		err = app.formDecoder.Decode(&p, r.PostForm)
		if err != nil {
			log.Error("Couldn't decode post update form request: %v\n", err)
			return ErrBadFormData
		}
	}

	if p.Web {
		p.IsRTL.Valid = true
	}

	if p.SubmittedPost == nil {
		return ErrPostNoUpdatableVals
	}

	// Ensure an access token was given
	accessToken := r.Header.Get("Authorization")
	// Get user's cookie session if there's no token
	var u *User
	//var username string
	if accessToken == "" {
		u = getUserSession(app, r)
		if u != nil {
			//username = u.Username
		}
	}
	if u == nil && accessToken == "" {
		return ErrNoAccessToken
	}

	// Get user ID from current session or given access token, if one was given.
	var userID int64
	if u != nil {
		userID = u.ID
	} else if accessToken != "" {
		userID, err = AuthenticateUser(app.db, accessToken)
		if err != nil {
			return err
		}
	}

	silenced, err := app.db.IsUserSilenced(userID)
	if err != nil {
		log.Error("existing post: %v", err)
	}
	if silenced {
		return ErrUserSilenced
	}

	// Modify post struct
	p.ID = postID

	err = app.db.UpdateOwnedPost(&p, userID)
	if err != nil {
		if reqJSON {
			return err
		}

		if err, ok := err.(impart.HTTPError); ok {
			addSessionFlash(app, w, r, err.Message, nil)
		} else {
			addSessionFlash(app, w, r, err.Error(), nil)
		}
	}

	var pRes *PublicPost
	pRes, err = app.db.GetPost(p.ID, 0)
	if reqJSON {
		if err != nil {
			return err
		}
		pRes.extractData()
	}

	if pRes.CollectionID.Valid {
		coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64)
		if err == nil && !app.cfg.App.Private && app.cfg.App.Federation {
			coll.hostName = app.cfg.App.Host
			pRes.Collection = &CollectionObj{Collection: *coll}
			go federatePost(app, pRes, pRes.Collection.ID, true)
		}
	}

	// Write success now
	if reqJSON {
		return impart.WriteSuccess(w, pRes, http.StatusOK)
	}

	addSessionFlash(app, w, r, "Changes saved.", nil)
	collectionAlias := vars["alias"]
	redirect := "/" + postID + "/meta"
	if collectionAlias != "" {
		collPre := "/" + collectionAlias
		if app.cfg.App.SingleUser {
			collPre = ""
		}
		redirect = collPre + "/" + pRes.Slug.String + "/edit/meta"
	} else {
		if app.cfg.App.SingleUser {
			redirect = "/d" + redirect
		}
	}
	w.Header().Set("Location", redirect)
	w.WriteHeader(http.StatusFound)

	return nil
}

func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
	vars := mux.Vars(r)
	friendlyID := vars["post"]
	editToken := r.FormValue("token")

	var ownerID int64
	var u *User
	accessToken := r.Header.Get("Authorization")
	if accessToken == "" && editToken == "" {
		u = getUserSession(app, r)
		if u == nil {
			return ErrNoAccessToken
		}
	}

	var res sql.Result
	var t *sql.Tx
	var err error
	var collID sql.NullInt64
	var coll *Collection
	var pp *PublicPost
	if editToken != "" {
		// TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries
		var dummy int64
		err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy)
		switch {
		case err == sql.ErrNoRows:
			return impart.HTTPError{http.StatusNotFound, "Post not found."}
		}
		err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy)
		switch {
		case err == sql.ErrNoRows:
			// Post already has an owner. This could provide a bad experience
			// for the user, but it's more important to ensure data isn't lost
			// unexpectedly. So prevent deletion via token.
			return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."}
		}
		res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken)
	} else if accessToken != "" || u != nil {
		// Caller provided some way to authenticate; assume caller expects the
		// post to be deleted based on a specific post owner, thus we should
		// return corresponding errors.
		if accessToken != "" {
			ownerID = app.db.GetUserID(accessToken)
			if ownerID == -1 {
				return ErrBadAccessToken
			}
		} else {
			ownerID = u.ID
		}

		// TODO: don't make two queries
		var realOwnerID sql.NullInt64
		err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID)
		if err != nil {
			return err
		}
		if !collID.Valid {
			// There's no collection; simply delete the post
			res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
		} else {
			// Post belongs to a collection; do any additional clean up
			coll, err = app.db.GetCollectionBy("id = ?", collID.Int64)
			if err != nil {
				log.Error("Unable to get collection: %v", err)
				return err
			}
			if app.cfg.App.Federation {
				// First fetch full post for federation
				pp, err = app.db.GetOwnedPost(friendlyID, ownerID)
				if err != nil {
					log.Error("Unable to get owned post: %v", err)
					return err
				}
				collObj := &CollectionObj{Collection: *coll}
				pp.Collection = collObj
			}

			t, err = app.db.Begin()
			if err != nil {
				log.Error("No begin: %v", err)
				return err
			}
			res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
		}
	} else {
		return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."}
	}
	if err != nil {
		return err
	}

	affected, err := res.RowsAffected()
	if err != nil {
		if t != nil {
			t.Rollback()
			log.Error("Rows affected err! Rolling back")
		}
		return err
	} else if affected == 0 {
		if t != nil {
			t.Rollback()
			log.Error("No rows affected! Rolling back")
		}
		return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."}
	}
	if t != nil {
		t.Commit()
	}
	if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation {
		go deleteFederatedPost(app, pp, collID.Int64)
	}

	return impart.HTTPError{Status: http.StatusNoContent}
}

// addPost associates a post with the authenticated user.
func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
	var ownerID int64

	// Authenticate user
	at := r.Header.Get("Authorization")
	if at != "" {
		ownerID = app.db.GetUserID(at)
		if ownerID == -1 {
			return ErrBadAccessToken
		}
	} else {
		u := getUserSession(app, r)
		if u == nil {
			return ErrNotLoggedIn
		}
		ownerID = u.ID
	}

	silenced, err := app.db.IsUserSilenced(ownerID)
	if err != nil {
		log.Error("add post: %v", err)
	}
	if silenced {
		return ErrUserSilenced
	}

	// Parse claimed posts in format:
	// [{"id": "...", "token": "..."}]
	var claims *[]ClaimPostRequest
	decoder := json.NewDecoder(r.Body)
	err = decoder.Decode(&claims)
	if err != nil {
		return ErrBadJSONArray
	}

	vars := mux.Vars(r)
	collAlias := vars["alias"]

	// Update all given posts
	res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims)
	if err != nil {
		return err
	}

	for _, pRes := range *res {
		if pRes.Code != http.StatusOK {
			continue
		}
		if !app.cfg.App.Private && app.cfg.App.Federation {
			if !pRes.Post.Created.After(time.Now()) {
				pRes.Post.Collection.hostName = app.cfg.App.Host
				go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
			}
		}
		if app.cfg.Email.Enabled() && pRes.Post.Collection.EmailSubsEnabled() {
			go app.db.InsertJob(&PostJob{
				PostID: pRes.Post.ID,
				Action: "email",
				Delay:  emailSendDelay,
			})
		}
	}
	return impart.WriteSuccess(w, res, http.StatusOK)
}

func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error {
	var ownerID int64

	// Authenticate user
	at := r.Header.Get("Authorization")
	if at != "" {
		ownerID = app.db.GetUserID(at)
		if ownerID == -1 {
			return ErrBadAccessToken
		}
	} else {
		u := getUserSession(app, r)
		if u == nil {
			return ErrNotLoggedIn
		}
		ownerID = u.ID
	}

	// Parse posts in format:
	// ["..."]
	var postIDs []string
	decoder := json.NewDecoder(r.Body)
	err := decoder.Decode(&postIDs)
	if err != nil {
		return ErrBadJSONArray
	}

	// Update all given posts
	res, err := app.db.DispersePosts(ownerID, postIDs)
	if err != nil {
		return err
	}
	return impart.WriteSuccess(w, res, http.StatusOK)
}

type (
	PinPostResult struct {
		ID           string `json:"id,omitempty"`
		Code         int    `json:"code,omitempty"`
		ErrorMessage string `json:"error_msg,omitempty"`
	}
)

// pinPost pins a post to a blog
func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
	var userID int64

	// Authenticate user
	at := r.Header.Get("Authorization")
	if at != "" {
		userID = app.db.GetUserID(at)
		if userID == -1 {
			return ErrBadAccessToken
		}
	} else {
		u := getUserSession(app, r)
		if u == nil {
			return ErrNotLoggedIn
		}
		userID = u.ID
	}

	silenced, err := app.db.IsUserSilenced(userID)
	if err != nil {
		log.Error("pin post: %v", err)
	}
	if silenced {
		return ErrUserSilenced
	}

	// Parse request
	var posts []struct {
		ID       string `json:"id"`
		Position int64  `json:"position"`
	}
	decoder := json.NewDecoder(r.Body)
	err = decoder.Decode(&posts)
	if err != nil {
		return ErrBadJSONArray
	}

	// Validate data
	vars := mux.Vars(r)
	collAlias := vars["alias"]

	coll, err := app.db.GetCollection(collAlias)
	if err != nil {
		return err
	}
	if coll.OwnerID != userID {
		return ErrForbiddenCollection
	}

	// Do (un)pinning
	isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin"
	res := []PinPostResult{}
	for _, p := range posts {
		err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position)
		ppr := PinPostResult{ID: p.ID}
		if err != nil {
			ppr.Code = http.StatusInternalServerError
			// TODO: set error message
		} else {
			ppr.Code = http.StatusOK
		}
		res = append(res, ppr)
	}
	return impart.WriteSuccess(w, res, http.StatusOK)
}

func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
	var collID int64
	var coll *Collection
	var err error
	vars := mux.Vars(r)
	if collAlias := vars["alias"]; collAlias != "" {
		// Fetch collection information, since an alias is provided
		coll, err = app.db.GetCollection(collAlias)
		if err != nil {
			return err
		}
		collID = coll.ID
	}

	p, err := app.db.GetPost(vars["post"], collID)
	if err != nil {
		return err
	}
	if coll == nil && p.CollectionID.Valid {
		// Collection post is getting fetched by post ID, not coll alias + post slug, so get coll info now.
		coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
		if err != nil {
			return err
		}
	}
	if coll != nil {
		coll.hostName = app.cfg.App.Host
		_, err = apiCheckCollectionPermissions(app, r, coll)
		if err != nil {
			return err
		}
	}

	silenced, err := app.db.IsUserSilenced(p.OwnerID.Int64)
	if err != nil {
		log.Error("fetch post: %v", err)
	}
	if silenced {
		return ErrPostNotFound
	}

	p.extractData()

	if IsActivityPubRequest(r) {
		if coll == nil {
			// This is a draft post; 404 for now
			// TODO: return ActivityObject
			return impart.HTTPError{http.StatusNotFound, ""}
		}

		p.Collection = &CollectionObj{Collection: *coll}
		po := p.ActivityObject(app)
		po.Context = []interface{}{activitystreams.Namespace}
		setCacheControl(w, apCacheTime)
		return impart.RenderActivityJSON(w, po, http.StatusOK)
	}

	return impart.WriteSuccess(w, p, http.StatusOK)
}

func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
	vars := mux.Vars(r)
	p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"])
	if err != nil {
		return err
	}

	return impart.WriteSuccess(w, p, http.StatusOK)
}

func (p *Post) processPost() PublicPost {
	res := &PublicPost{Post: p, Views: 0}
	res.Views = p.ViewCount
	res.Likes = p.LikeCount
	// TODO: move to own function
	loc := monday.FuzzyLocale(p.Language.String)
	res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)

	return *res
}

func (p *PublicPost) CanonicalURL(hostName string) string {
	if p.Collection == nil || p.Collection.Alias == "" {
		return hostName + "/" + p.ID + ".md"
	}
	return p.Collection.CanonicalURL() + p.Slug.String
}

func (pp *PublicPost) DisplayCanonicalURL() string {
	us := pp.CanonicalURL(pp.Collection.hostName)
	u, err := url.Parse(us)
	if err != nil {
		return us
	}
	return u.Hostname() + u.Path
}

func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
	cfg := app.cfg
	var o *activitystreams.Object
	if cfg.App.NotesOnly || strings.Index(p.Content, "\n\n") == -1 {
		o = activitystreams.NewNoteObject()
	} else {
		o = activitystreams.NewArticleObject()
	}
	o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
	o.Published = p.Created
	o.URL = p.CanonicalURL(cfg.App.Host)
	o.AttributedTo = p.Collection.FederatedAccount()
	o.CC = []string{
		p.Collection.FederatedAccount() + "/followers",
	}
	o.Name = p.DisplayTitle()
	p.augmentContent()
	if p.HTMLContent == template.HTML("") {
		p.formatContent(cfg, false, false)
		p.augmentReadingDestination()
	}
	o.Content = string(p.HTMLContent)
	if p.Language.Valid {
		o.ContentMap = map[string]string{
			p.Language.String: string(p.HTMLContent),
		}
	}
	if len(p.Tags) == 0 {
		o.Tag = []activitystreams.Tag{}
	} else {
		var tagBaseURL string
		if isSingleUser {
			tagBaseURL = p.Collection.CanonicalURL() + "tag:"
		} else {
			if cfg.App.Chorus {
				tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName)
			} else {
				tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
			}
		}
		for _, t := range p.Tags {
			o.Tag = append(o.Tag, activitystreams.Tag{
				Type: activitystreams.TagHashtag,
				HRef: tagBaseURL + t,
				Name: "#" + t,
			})
		}
	}
	if len(p.Images) > 0 {
		for _, i := range p.Images {
			o.Attachment = append(o.Attachment, activitystreams.NewImageAttachment(i))
		}
	}
	// Find mentioned users
	mentionedUsers := make(map[string]string)

	stripper := bluemonday.StrictPolicy()
	content := stripper.Sanitize(p.Content)
	mentions := mentionReg.FindAllString(content, -1)

	for _, handle := range mentions {
		actorIRI, err := app.db.GetProfilePageFromHandle(app, handle)
		if err != nil {
			log.Info("Couldn't find user '%s' locally or remotely", handle)
			continue
		}
		mentionedUsers[handle] = actorIRI
	}

	for handle, iri := range mentionedUsers {
		o.CC = append(o.CC, iri)
		o.Tag = append(o.Tag, activitystreams.Tag{Type: "Mention", HRef: iri, Name: handle})
	}
	return o
}

// TODO: merge this into getSlugFromPost or phase it out
func getSlug(title, lang string) string {
	return getSlugFromPost("", title, lang)
}

func getSlugFromPost(title, body, lang string) string {
	if title == "" {
		// Remove Markdown, so e.g. link URLs and image alt text don't make it into the slug
		body = strings.TrimSpace(stripmd.StripOptions(body, stripmd.Options{SkipImages: true}))
		title = postTitle(body, body)
	}
	title = parse.PostLede(title, false)
	// Truncate lede if needed
	title, _ = parse.TruncToWord(title, 80)
	var s string
	if lang != "" && len(lang) == 2 {
		s = slug.MakeLang(title, lang)
	} else {
		s = slug.Make(title)
	}

	// Transliteration may cause the slug to expand past the limit, so truncate again
	s, _ = parse.TruncToWord(s, 80)
	return strings.TrimFunc(s, func(r rune) bool {
		// TruncToWord doesn't respect words in a slug, since spaces are replaced
		// with hyphens. So remove any trailing hyphens.
		return r == '-'
	})
}

// isFontValid returns whether or not the submitted post's appearance is valid.
func (p *SubmittedPost) isFontValid() bool {
	validFonts := map[string]bool{
		"norm": true,
		"sans": true,
		"mono": true,
		"wrap": true,
		"code": true,
	}

	_, valid := validFonts[p.Font]
	return valid
}

func getRawPost(app *App, friendlyID string) *RawPost {
	var content, font, title string
	var isRTL sql.NullBool
	var lang sql.NullString
	var ownerID sql.NullInt64
	var created, updated time.Time

	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)
	switch {
	case err == sql.ErrNoRows:
		return &RawPost{Content: "", Found: false, Gone: false}
	case err != nil:
		log.Error("Unable to fetch getRawPost: %s", err)
		return &RawPost{Content: "", Found: true, Gone: false}
	}

	return &RawPost{
		Title:    title,
		Content:  content,
		Font:     font,
		Created:  created,
		Updated:  updated,
		IsRTL:    isRTL,
		Language: lang,
		OwnerID:  ownerID.Int64,
		Found:    true,
		Gone:     content == "" && title == "",
	}

}

// TODO; return a Post!
func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
	var id, title, content, font string
	var isRTL sql.NullBool
	var lang sql.NullString
	var created, updated time.Time
	var ownerID null.Int
	var views int64
	var err error

	if app.cfg.App.SingleUser {
		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)
	} else {
		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)
	}
	switch {
	case err == sql.ErrNoRows:
		return &RawPost{Content: "", Found: false, Gone: false}
	case err != nil:
		log.Error("Unable to fetch getRawCollectionPost: %s", err)
		return &RawPost{Content: "", Found: true, Gone: false}
	}

	return &RawPost{
		Id:       id,
		Slug:     slug,
		Title:    title,
		Content:  content,
		Font:     font,
		Created:  created,
		Updated:  updated,
		IsRTL:    isRTL,
		Language: lang,
		OwnerID:  ownerID.Int64,
		Found:    true,
		Gone:     content == "" && title == "",
		Views:    views,
	}
}

func isRaw(r *http.Request) bool {
	vars := mux.Vars(r)
	slug := vars["slug"]

	// NOTE: until this is done better, be sure to keep this in parity with
	// isRaw in viewCollectionPost() and handleViewPost()
	isJSON := strings.HasSuffix(slug, ".json")
	isXML := strings.HasSuffix(slug, ".xml")
	isMarkdown := strings.HasSuffix(slug, ".md")
	return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
}

func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error {
	vars := mux.Vars(r)
	slug := vars["slug"]

	// NOTE: until this is done better, be sure to keep this in parity with
	// isRaw() and handleViewPost()
	isJSON := strings.HasSuffix(slug, ".json")
	isXML := strings.HasSuffix(slug, ".xml")
	isMarkdown := strings.HasSuffix(slug, ".md")
	isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown

	cr := &collectionReq{}
	err := processCollectionRequest(cr, vars, w, r)
	if err != nil {
		return err
	}

	// Check for hellbanned users
	u, err := checkUserForCollection(app, cr, r, true)
	if err != nil {
		return err
	}

	// Normalize the URL, redirecting user to consistent post URL
	if slug != strings.ToLower(slug) {
		loc := fmt.Sprintf("/%s", strings.ToLower(slug))
		if !app.cfg.App.SingleUser {
			loc = "/" + cr.alias + loc
		}
		return impart.HTTPError{http.StatusMovedPermanently, loc}
	}

	// Display collection if this is a collection
	var c *Collection
	if app.cfg.App.SingleUser {
		c, err = app.db.GetCollectionByID(1)
	} else {
		c, err = app.db.GetCollection(cr.alias)
	}
	if err != nil {
		if err, ok := err.(impart.HTTPError); ok {
			if err.Status == http.StatusNotFound {
				// Redirect if necessary
				newAlias := app.db.GetCollectionRedirect(cr.alias)
				if newAlias != "" {
					return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug}
				}
			}
		}
		return err
	}
	c.hostName = app.cfg.App.Host

	silenced, err := app.db.IsUserSilenced(c.OwnerID)
	if err != nil {
		log.Error("view collection post: %v", err)
	}

	// Check collection permissions
	if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
		return ErrPostNotFound
	}
	if c.IsProtected() && (u == nil || u.ID != c.OwnerID) {
		if silenced {
			return ErrPostNotFound
		} else if !isAuthorizedForCollection(app, c.Alias, r) {
			return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
		}
	}

	cr.isCollOwner = u != nil && c.OwnerID == u.ID

	if isRaw {
		slug = strings.Split(slug, ".")[0]
	}

	// Fetch extra data about the Collection
	// TODO: refactor out this logic, shared in collection.go:fetchCollection()
	coll := NewCollectionObj(c)
	owner, err := app.db.GetUserByID(coll.OwnerID)
	if err != nil {
		// Log the error and just continue
		log.Error("Error getting user for collection: %v", err)
	} else {
		coll.Owner = owner
	}

	postFound := true
	p, err := app.db.GetPost(slug, coll.ID)
	if err != nil {
		if err == ErrCollectionPageNotFound {
			postFound = false

			if slug == "feed" {
				// User tried to access blog feed without a trailing slash, and
				// there's no post with a slug "feed"
				return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "feed/"}
			}

			po := &Post{
				Slug:     null.NewString(slug, true),
				Font:     "norm",
				Language: zero.NewString("en", true),
				RTL:      zero.NewBool(false, true),
				Content: `<p class="msg">This page is missing.</p>

Are you sure it was ever here?`,
			}
			pp := po.processPost()
			p = &pp
		} else {
			return err
		}
	}

	// Check if the authenticated user is the post owner
	p.IsOwner = u != nil && u.ID == p.OwnerID.Int64
	p.Collection = coll
	p.IsTopLevel = app.cfg.App.SingleUser

	// Only allow a post owner or admin to view a post for silenced collections
	if silenced && !p.IsOwner && (u == nil || !u.IsAdmin()) {
		return ErrPostNotFound
	}

	// Check if post has been unpublished
	if p.Content == "" && p.Title.String == "" {
		return impart.HTTPError{http.StatusGone, "Post was unpublished."}
	}

	p.augmentContent()

	// Serve collection post
	if isRaw {
		contentType := "text/plain"
		if isJSON {
			contentType = "application/json"
		} else if isXML {
			contentType = "application/xml"
		} else if isMarkdown {
			contentType = "text/markdown"
		}
		w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
		if !postFound {
			w.WriteHeader(http.StatusNotFound)
			fmt.Fprintf(w, "Post not found.")
			// TODO: return error instead, so status is correctly reflected in logs
			return nil
		}
		if isMarkdown && p.Title.String != "" {
			fmt.Fprintf(w, "# %s\n\n", p.Title.String)
		}
		fmt.Fprint(w, p.Content)
	} else if IsActivityPubRequest(r) {
		if !postFound {
			return ErrCollectionPageNotFound
		}
		p.extractData()
		ap := p.ActivityObject(app)
		ap.Context = []interface{}{activitystreams.Namespace}
		setCacheControl(w, apCacheTime)
		return impart.RenderActivityJSON(w, ap, http.StatusOK)
	} else {
		p.extractData()
		p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
		if app.cfg.Email.Enabled() && c.EmailSubsEnabled() {
			// TODO: indicate plan is inactive or subs disabled when OWNER is viewing their own post.
			if u != nil && u.IsEmailSubscriber(app, c.ID) {
				p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates. <a href="/api/collections/`+c.Alias+`/email/unsubscribe?slug=`+p.Slug.String+`">Unsubscribe</a>.</p>`, -1)
			} else {
				p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<form method="post" id="emailsub" action="/api/collections/`+c.Alias+`/email/subscribe"><input type="hidden" name="slug" value="`+p.Slug.String+`" /><input type="hidden" name="web" value="1" /><div style="position: absolute; left: -5000px;" aria-hidden="true"><input type="email" name="`+spam.HoneypotFieldName()+`" tabindex="-1" value="" /><input type="password" name="fake_password" tabindex="-1" placeholder="password" autocomplete="new-password" /></div><input type="email" name="email" placeholder="me@example.com" /><input type="submit" id="subscribe-btn" value="Subscribe" /></form>`, -1)
			}
		}
		p.Content = strings.Replace(p.Content, "&lt;!--emailsub-->", "<!--emailsub-->", 1)
		// TODO: move this to function
		p.formatContent(app.cfg, cr.isCollOwner, true)
		tp := CollectionPostPage{
			PublicPost:     p,
			StaticPage:     pageForReq(app, r),
			IsOwner:        cr.isCollOwner,
			IsCustomDomain: cr.isCustomDomain,
			IsFound:        postFound,
			Silenced:       silenced,
			CollAlias:      c.Alias,
		}
		tp.IsAdmin = u != nil && u.IsAdmin()
		tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
		tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
		tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
		tp.Monetization = coll.Monetization
		tp.Verification = coll.Verification

		if !postFound {
			w.WriteHeader(http.StatusNotFound)
		}
		postTmpl := "collection-post"
		if app.cfg.App.Chorus {
			postTmpl = "chorus-collection-post"
		}
		if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil {
			log.Error("Error in %s template: %v", postTmpl, err)
		}
	}

	go func() {
		if p.OwnerID.Valid {
			// Post is owned by someone. Don't update stats if owner is viewing the post.
			if u != nil && p.OwnerID.Int64 == u.ID {
				return
			}
		}
		// Update stats for non-raw post views
		if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
			_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID)
			if err != nil {
				log.Error("Unable to update posts count: %v", err)
			}
		}
	}()

	return nil
}

// TODO: move this to utils after making it more generic
func PostsContains(sl *[]PublicPost, s *PublicPost) bool {
	for _, e := range *sl {
		if e.ID == s.ID {
			return true
		}
	}
	return false
}

func (p *Post) extractData() {
	p.Tags = tags.Extract(p.Content)
	p.extractImages()
}

func (p *Post) IsSans() bool {
	return p.Font == "sans"
}

func (p *Post) IsMonospace() bool {
	return p.Font == "mono"
}

func (rp *RawPost) UserFacingCreated() string {
	return rp.Created.Format(postMetaDateFormat)
}

func (rp *RawPost) Created8601() string {
	return rp.Created.Format("2006-01-02T15:04:05Z")
}

func (rp *RawPost) Updated8601() string {
	if rp.Updated.IsZero() {
		return ""
	}
	return rp.Updated.Format("2006-01-02T15:04:05Z")
}

var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|avif|avifs|webp|jxl|image)$`)

func (p *Post) extractImages() {
	p.Images = extractImages(p.Content)
}

func extractImages(content string) []string {
	matches := extract.ExtractUrls(content)
	urls := map[string]bool{}
	for i := range matches {
		uRaw := matches[i].Text
		// Parse the extracted text so we can examine the path
		u, err := url.Parse(uRaw)
		if err != nil {
			continue
		}
		// Ensure the path looks like it leads to an image file
		if !imageURLRegex.MatchString(u.Path) {
			continue
		}
		urls[uRaw] = true
	}

	resURLs := make([]string, 0)
	for k := range urls {
		resURLs = append(resURLs, k)
	}
	return resURLs
}
