diff --git a/collections.go b/collections.go
index 0ba4089..0ec98df 100644
--- a/collections.go
+++ b/collections.go
@@ -2,33 +2,49 @@ package writefreely
import (
"database/sql"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "math"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "unicode"
+
+ "github.com/gorilla/mux"
+ "github.com/writeas/impart"
+ "github.com/writeas/web-core/activitystreams"
+ "github.com/writeas/web-core/auth"
+ "github.com/writeas/web-core/bots"
+ "github.com/writeas/web-core/log"
+ waposts "github.com/writeas/web-core/posts"
+ "github.com/writeas/writefreely/author"
+ "github.com/writeas/writefreely/page"
)
type (
+ // TODO: add Direction to db
+ // TODO: add Language to db
Collection struct {
- ID int64 `datastore:"id" json:"-"`
- Alias string `datastore:"alias" schema:"alias" json:"alias"`
- Title string `datastore:"title" schema:"title" json:"title"`
- Description string `datastore:"description" schema:"description" json:"description"`
- Direction string `schema:"dir" json:"dir,omitempty"`
- Language string `schema:"lang" json:"lang,omitempty"`
- StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
- Script string `datastore:"script" schema:"script" json:"script,omitempty"`
- Public bool `datastore:"public" json:"public"`
- Visibility collVisibility `datastore:"private" json:"-"`
- Format string `datastore:"format" json:"format,omitempty"`
- Views int64 `json:"views"`
- OwnerID int64 `datastore:"owner_id" json:"-"`
- PublicOwner bool `datastore:"public_owner" json:"-"`
- PreferSubdomain bool `datastore:"prefer_subdomain" json:"-"`
- Domain string `datastore:"domain" json:"domain,omitempty"`
- IsDomainActive bool `datastore:"is_active" json:"-"`
- IsSecure bool `datastore:"is_secure" json:"-"`
- CustomHandle string `datastore:"handle" json:"-"`
- Email string `json:"email,omitempty"`
- URL string `json:"url,omitempty"`
-
- app *app
+ ID int64 `datastore:"id" json:"-"`
+ Alias string `datastore:"alias" schema:"alias" json:"alias"`
+ Title string `datastore:"title" schema:"title" json:"title"`
+ Description string `datastore:"description" schema:"description" json:"description"`
+ Direction string `schema:"dir" json:"dir,omitempty"`
+ Language string `schema:"lang" json:"lang,omitempty"`
+ StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
+ Script string `datastore:"script" schema:"script" json:"script,omitempty"`
+ Public bool `datastore:"public" json:"public"`
+ Visibility collVisibility `datastore:"private" json:"-"`
+ Format string `datastore:"format" json:"format,omitempty"`
+ Views int64 `json:"views"`
+ OwnerID int64 `datastore:"owner_id" json:"-"`
+ PublicOwner bool `datastore:"public_owner" json:"-"`
+ URL string `json:"url,omitempty"`
+
+ db *datastore
}
CollectionObj struct {
Collection
@@ -36,6 +52,14 @@ type (
Owner *User `json:"owner,omitempty"`
Posts *[]PublicPost `json:"posts,omitempty"`
}
+ DisplayCollection struct {
+ *CollectionObj
+ Prefix string
+ IsTopLevel bool
+ CurrentPage int
+ TotalPages int
+ Format *CollectionFormat
+ }
SubmittedCollection struct {
// Data used for updating a given collection
ID int64
@@ -45,25 +69,973 @@ type (
PreferURL string `schema:"prefer_url" json:"prefer_url"`
Privacy int `schema:"privacy" json:"privacy"`
Pass string `schema:"password" json:"password"`
- Federate bool `schema:"federate" json:"federate"`
MathJax bool `schema:"mathjax" json:"mathjax"`
Handle string `schema:"handle" json:"handle"`
// Actual collection values updated in the DB
- Alias *string `schema:"alias" json:"alias"`
- Title *string `schema:"title" json:"title"`
- Description *string `schema:"description" json:"description"`
- StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
- Script *sql.NullString `schema:"script" json:"script"`
- Visibility *int `schema:"visibility" json:"public"`
- Format *sql.NullString `schema:"format" json:"format"`
- PreferSubdomain *bool `schema:"prefer_subdomain" json:"prefer_subdomain"`
- Domain *sql.NullString `schema:"domain" json:"domain"`
+ Alias *string `schema:"alias" json:"alias"`
+ Title *string `schema:"title" json:"title"`
+ Description *string `schema:"description" json:"description"`
+ StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
+ Script *sql.NullString `schema:"script" json:"script"`
+ Visibility *int `schema:"visibility" json:"public"`
+ Format *sql.NullString `schema:"format" json:"format"`
}
CollectionFormat struct {
Format string
}
+
+ collectionReq struct {
+ // Information about the collection request itself
+ prefix, alias, domain string
+ isCustomDomain bool
+
+ // User-related fields
+ isCollOwner bool
+ }
)
+func (sc *SubmittedCollection) FediverseHandle() string {
+ if sc.Handle == "" {
+ return apCustomHandleDefault
+ }
+ return getSlug(sc.Handle, "")
+}
+
// collVisibility represents the visibility level for the collection.
type collVisibility int
+
+// Visibility levels. Values are bitmasks, stored in the database as
+// decimal numbers. If adding types, append them to this list. If removing,
+// replace the desired visibility with a new value.
+const CollUnlisted collVisibility = 0
+const (
+ CollPublic collVisibility = 1 << iota
+ CollPrivate
+ CollProtected
+)
+
+func (cf *CollectionFormat) Ascending() bool {
+ return cf.Format == "novel"
+}
+func (cf *CollectionFormat) ShowDates() bool {
+ return cf.Format == "blog"
+}
+func (cf *CollectionFormat) PostsPerPage() int {
+ if cf.Format == "novel" {
+ return postsPerPage
+ }
+ return postsPerPage
+}
+
+// Valid returns whether or not a format value is valid.
+func (cf *CollectionFormat) Valid() bool {
+ return cf.Format == "blog" ||
+ cf.Format == "novel" ||
+ cf.Format == "notebook"
+}
+
+// NewFormat creates a new CollectionFormat object from the Collection.
+func (c *Collection) NewFormat() *CollectionFormat {
+ cf := &CollectionFormat{Format: c.Format}
+
+ // Fill in default format
+ if cf.Format == "" {
+ cf.Format = "blog"
+ }
+
+ return cf
+}
+
+func (c *Collection) IsUnlisted() bool {
+ return c.Visibility == 0
+}
+
+func (c *Collection) IsPrivate() bool {
+ return c.Visibility&CollPrivate != 0
+}
+
+func (c *Collection) IsProtected() bool {
+ return c.Visibility&CollProtected != 0
+}
+
+func (c *Collection) IsPublic() bool {
+ return c.Visibility&CollPublic != 0
+}
+
+func (c *Collection) FriendlyVisibility() string {
+ if c.IsPrivate() {
+ return "Private"
+ }
+ if c.IsPublic() {
+ return "Public"
+ }
+ if c.IsProtected() {
+ return "Password-protected"
+ }
+ return "Unlisted"
+}
+
+func (c *Collection) ShowFooterBranding() bool {
+ // TODO: implement this setting
+ return true
+}
+
+// CanonicalURL returns a fully-qualified URL to the collection.
+func (c *Collection) CanonicalURL() string {
+ return c.RedirectingCanonicalURL(false)
+}
+
+func (c *Collection) DisplayCanonicalURL() string {
+ us := c.CanonicalURL()
+ u, err := url.Parse(us)
+ if err != nil {
+ return us
+ }
+ p := u.Path
+ if p == "/" {
+ p = ""
+ }
+ return u.Hostname() + p
+}
+
+func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
+ if isSingleUser {
+ return hostName + "/"
+ }
+
+ return fmt.Sprintf("%s/%s/", hostName, c.Alias)
+}
+
+// PrevPageURL provides a full URL for the previous page of collection posts,
+// returning a /page/N result for pages >1
+func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
+ u := ""
+ if n == 2 {
+ // Previous page is 1; no need for /page/ prefix
+ if prefix == "" {
+ u = "/"
+ }
+ // Else leave off trailing slash
+ } else {
+ u = fmt.Sprintf("/page/%d", n-1)
+ }
+
+ if tl {
+ return u
+ }
+ return "/" + prefix + c.Alias + u
+}
+
+// NextPageURL provides a full URL for the next page of collection posts
+func (c *Collection) NextPageURL(prefix string, n int, tl bool) string {
+ if tl {
+ return fmt.Sprintf("/page/%d", n+1)
+ }
+ return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1)
+}
+
+func (c *Collection) DisplayTitle() string {
+ if c.Title != "" {
+ return c.Title
+ }
+ return c.Alias
+}
+
+func (c *Collection) StyleSheetDisplay() template.CSS {
+ return template.CSS(c.StyleSheet)
+}
+
+// ForPublic modifies the Collection for public consumption, such as via
+// the API.
+func (c *Collection) ForPublic() {
+ c.ID = 0
+ c.URL = c.CanonicalURL()
+}
+
+var isLowerLetter = regexp.MustCompile("[a-z]").MatchString
+
+func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person {
+ accountRoot := c.FederatedAccount()
+ p := activitystreams.NewPerson(accountRoot)
+ p.URL = c.CanonicalURL()
+ uname := c.Alias
+ p.PreferredUsername = uname
+ p.Name = c.DisplayTitle()
+ p.Summary = c.Description
+ if p.Name != "" {
+ fl := string(unicode.ToLower([]rune(p.Name)[0]))
+ if isLowerLetter(fl) {
+ p.Icon = activitystreams.Image{
+ Type: "Image",
+ MediaType: "image/png",
+ URL: hostName + "/img/avatars/" + fl + ".png",
+ }
+ }
+ }
+
+ collID := c.ID
+ if len(ids) > 0 {
+ collID = ids[0]
+ }
+ pub, priv := c.db.GetAPActorKeys(collID)
+ if pub != nil {
+ p.AddPubKey(pub)
+ p.SetPrivKey(priv)
+ }
+
+ return p
+}
+
+func (c *Collection) FederatedAPIBase() string {
+ return hostName
+}
+
+func (c *Collection) FederatedAccount() string {
+ accountUser := c.Alias
+ return c.FederatedAPIBase() + "api/collections/" + accountUser
+}
+
+func (c *Collection) RenderMathJax() bool {
+ return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
+}
+
+func newCollection(app *app, w http.ResponseWriter, r *http.Request) error {
+ reqJSON := IsJSON(r.Header.Get("Content-Type"))
+ alias := r.FormValue("alias")
+ title := r.FormValue("title")
+
+ var missingParams, accessToken string
+ var u *User
+ c := struct {
+ Alias string `json:"alias" schema:"alias"`
+ Title string `json:"title" schema:"title"`
+ Web bool `json:"web" schema:"web"`
+ }{}
+ if reqJSON {
+ // Decode JSON request
+ decoder := json.NewDecoder(r.Body)
+ err := decoder.Decode(&c)
+ if err != nil {
+ log.Error("Couldn't parse post update JSON request: %v\n", err)
+ return ErrBadJSON
+ }
+ } else {
+ // TODO: move form parsing to formDecoder
+ c.Alias = alias
+ c.Title = title
+ }
+
+ if c.Alias == "" {
+ if c.Title != "" {
+ // If only a title was given, just use it to generate the alias.
+ c.Alias = getSlug(c.Title, "")
+ } else {
+ missingParams += "`alias` "
+ }
+ }
+ if c.Title == "" {
+ missingParams += "`title` "
+ }
+ if missingParams != "" {
+ return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)}
+ }
+
+ if reqJSON && !c.Web {
+ accessToken = r.Header.Get("Authorization")
+ if accessToken == "" {
+ return ErrNoAccessToken
+ }
+ } else {
+ u = getUserSession(app, r)
+ if u == nil {
+ return ErrNotLoggedIn
+ }
+ }
+
+ if !author.IsValidUsername(app.cfg, c.Alias) {
+ return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."}
+ }
+
+ var coll *Collection
+ var err error
+ if accessToken != "" {
+ coll, err = app.db.CreateCollectionFromToken(c.Alias, c.Title, accessToken)
+ if err != nil {
+ // TODO: handle this
+ return err
+ }
+ } else {
+ coll, err = app.db.CreateCollection(c.Alias, c.Title, u.ID)
+ if err != nil {
+ // TODO: handle this
+ return err
+ }
+ }
+
+ res := &CollectionObj{Collection: *coll}
+
+ if reqJSON {
+ return impart.WriteSuccess(w, res, http.StatusCreated)
+ }
+ redirectTo := "/me/c/"
+ // TODO: redirect to pad when necessary
+ return impart.HTTPError{http.StatusFound, redirectTo}
+}
+
+func apiCheckCollectionPermissions(app *app, r *http.Request, c *Collection) (int64, error) {
+ accessToken := r.Header.Get("Authorization")
+ var userID int64 = -1
+ if accessToken != "" {
+ userID = app.db.GetUserID(accessToken)
+ }
+ isCollOwner := userID == c.OwnerID
+ if c.IsPrivate() && !isCollOwner {
+ // Collection is private, but user isn't authenticated
+ return -1, ErrCollectionNotFound
+ }
+ if c.IsProtected() {
+ // TODO: check access token
+ return -1, ErrCollectionUnauthorizedRead
+ }
+
+ return userID, nil
+}
+
+// fetchCollection handles the API endpoint for retrieving collection data.
+func fetchCollection(app *app, w http.ResponseWriter, r *http.Request) error {
+ accept := r.Header.Get("Accept")
+ if strings.Contains(accept, "application/activity+json") {
+ return handleFetchCollectionActivities(app, w, r)
+ }
+
+ vars := mux.Vars(r)
+ alias := vars["alias"]
+
+ // TODO: move this logic into a common getCollection function
+ // Get base Collection data
+ c, err := app.db.GetCollection(alias)
+ if err != nil {
+ return err
+ }
+ // Redirect users who aren't requesting JSON
+ reqJSON := IsJSON(r.Header.Get("Content-Type"))
+ if !reqJSON {
+ return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
+ }
+
+ // Check permissions
+ userID, err := apiCheckCollectionPermissions(app, r, c)
+ if err != nil {
+ return err
+ }
+ isCollOwner := userID == c.OwnerID
+
+ // Fetch extra data about the Collection
+ res := &CollectionObj{Collection: *c}
+ if c.PublicOwner {
+ u, err := app.db.GetUserByID(res.OwnerID)
+ if err != nil {
+ // Log the error and just continue
+ log.Error("Error getting user for collection: %v", err)
+ } else {
+ res.Owner = u
+ }
+ }
+ app.db.GetPostsCount(res, isCollOwner)
+ // Strip non-public information
+ res.Collection.ForPublic()
+
+ return impart.WriteSuccess(w, res, http.StatusOK)
+}
+
+// fetchCollectionPosts handles an API endpoint for retrieving a collection's
+// posts.
+func fetchCollectionPosts(app *app, w http.ResponseWriter, r *http.Request) error {
+ vars := mux.Vars(r)
+ alias := vars["alias"]
+
+ c, err := app.db.GetCollection(alias)
+ if err != nil {
+ return err
+ }
+
+ // Check permissions
+ userID, err := apiCheckCollectionPermissions(app, r, c)
+ if err != nil {
+ return err
+ }
+ isCollOwner := userID == c.OwnerID
+
+ // Get page
+ page := 1
+ if p := r.FormValue("page"); p != "" {
+ pInt, _ := strconv.Atoi(p)
+ if pInt > 0 {
+ page = pInt
+ }
+ }
+
+ posts, err := app.db.GetPosts(c, page, isCollOwner)
+ if err != nil {
+ return err
+ }
+ coll := &CollectionObj{Collection: *c, Posts: posts}
+ app.db.GetPostsCount(coll, isCollOwner)
+ // Strip non-public information
+ coll.Collection.ForPublic()
+
+ // Transform post bodies if needed
+ if r.FormValue("body") == "html" {
+ for _, p := range *coll.Posts {
+ p.Content = waposts.ApplyMarkdown([]byte(p.Content))
+ }
+ }
+
+ return impart.WriteSuccess(w, coll, http.StatusOK)
+}
+
+type CollectionPage struct {
+ page.StaticPage
+ *DisplayCollection
+ IsCustomDomain bool
+ IsWelcome bool
+ IsOwner bool
+ CanPin bool
+ Username string
+ Collections *[]Collection
+ PinnedPosts *[]PublicPost
+}
+
+func (c *CollectionObj) ScriptDisplay() template.JS {
+ return template.JS(c.Script)
+}
+
+var jsSourceCommentReg = regexp.MustCompile("(?m)^// src:(.+)$")
+
+func (c *CollectionObj) ExternalScripts() []template.URL {
+ scripts := []template.URL{}
+ if c.Script == "" {
+ return scripts
+ }
+
+ matches := jsSourceCommentReg.FindAllStringSubmatch(c.Script, -1)
+ for _, m := range matches {
+ scripts = append(scripts, template.URL(strings.TrimSpace(m[1])))
+ }
+ return scripts
+}
+
+func (c *CollectionObj) CanShowScript() bool {
+ return false
+}
+
+func processCollectionRequest(cr *collectionReq, vars map[string]string, w http.ResponseWriter, r *http.Request) error {
+ cr.prefix = vars["prefix"]
+ cr.alias = vars["collection"]
+ // Normalize the URL, redirecting user to consistent post URL
+ if cr.alias != strings.ToLower(cr.alias) {
+ return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", strings.ToLower(cr.alias))}
+ }
+
+ return nil
+}
+
+// processCollectionPermissions checks the permissions for the given
+// collectionReq, returning a Collection if access is granted; otherwise this
+// renders any necessary collection pages, for example, if requesting a custom
+// domain that doesn't yet have a collection associated, or if a collection
+// requires a password. In either case, this will return nil, nil -- thus both
+// values should ALWAYS be checked to determine whether or not to continue.
+func processCollectionPermissions(app *app, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) {
+ // Display collection if this is a collection
+ var c *Collection
+ var err error
+ if app.cfg.App.SingleUser {
+ c, err = app.db.GetCollectionByID(1)
+ } else {
+ c, err = app.db.GetCollection(cr.alias)
+ }
+ // TODO: verify we don't reveal the existence of a private collection with redirection
+ if err != nil {
+ if err, ok := err.(impart.HTTPError); ok {
+ if err.Status == http.StatusNotFound {
+ if cr.isCustomDomain {
+ // User is on the site from a custom domain
+ //tErr := pages["404-domain.tmpl"].ExecuteTemplate(w, "base", pageForHost(page.StaticPage{}, r))
+ //if tErr != nil {
+ //log.Error("Unable to render 404-domain page: %v", err)
+ //}
+ return nil, nil
+ }
+ if len(cr.alias) >= minIDLen && len(cr.alias) <= maxIDLen {
+ // Alias is within post ID range, so just be sure this isn't a post
+ if app.db.PostIDExists(cr.alias) {
+ // TODO: use StatusFound for vanity post URLs when we implement them
+ return nil, impart.HTTPError{http.StatusMovedPermanently, "/" + cr.alias}
+ }
+ }
+ // Redirect if necessary
+ newAlias := app.db.GetCollectionRedirect(cr.alias)
+ if newAlias != "" {
+ return nil, impart.HTTPError{http.StatusFound, "/" + newAlias + "/"}
+ }
+ }
+ }
+ return nil, err
+ }
+
+ // Update CollectionRequest to reflect owner status
+ cr.isCollOwner = u != nil && u.ID == c.OwnerID
+
+ // Check permissions
+ if !cr.isCollOwner {
+ if c.IsPrivate() {
+ return nil, ErrCollectionNotFound
+ } else if c.IsProtected() {
+ uname := ""
+ if u != nil {
+ uname = u.Username
+ }
+
+ // See if we've authorized this collection
+ authd := isAuthorizedForCollection(app, c.Alias, r)
+
+ if !authd {
+ p := struct {
+ page.StaticPage
+ *CollectionObj
+ Username string
+ Next string
+ Flashes []template.HTML
+ }{
+ StaticPage: pageForReq(app, r),
+ CollectionObj: &CollectionObj{Collection: *c},
+ Username: uname,
+ Next: r.FormValue("g"),
+ Flashes: []template.HTML{},
+ }
+ // Get owner information
+ p.CollectionObj.Owner, err = app.db.GetUserByID(c.OwnerID)
+ if err != nil {
+ // Log the error and just continue
+ log.Error("Error getting user for collection: %v", err)
+ }
+
+ flashes, _ := getSessionFlashes(app, w, r, nil)
+ for _, flash := range flashes {
+ p.Flashes = append(p.Flashes, template.HTML(flash))
+ }
+ err = templates["password-collection"].ExecuteTemplate(w, "password-collection", p)
+ if err != nil {
+ log.Error("Unable to render password-collection: %v", err)
+ return nil, err
+ }
+ return nil, nil
+ }
+ }
+ }
+ return c, nil
+}
+
+func checkUserForCollection(app *app, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) {
+ u := getUserSession(app, r)
+ return u, nil
+}
+
+func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
+ coll := &DisplayCollection{
+ CollectionObj: &CollectionObj{Collection: *c},
+ CurrentPage: page,
+ Prefix: cr.prefix,
+ IsTopLevel: isSingleUser,
+ Format: c.NewFormat(),
+ }
+ c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
+ return coll
+}
+
+func getCollectionPage(vars map[string]string) int {
+ page := 1
+ var p int
+ p, _ = strconv.Atoi(vars["page"])
+ if p > 0 {
+ page = p
+ }
+ return page
+}
+
+// handleViewCollection displays the requested Collection
+func handleViewCollection(app *app, w http.ResponseWriter, r *http.Request) error {
+ vars := mux.Vars(r)
+ cr := &collectionReq{}
+
+ err := processCollectionRequest(cr, vars, w, r)
+ if err != nil {
+ return err
+ }
+
+ u, err := checkUserForCollection(app, cr, r, false)
+ if err != nil {
+ return err
+ }
+
+ page := getCollectionPage(vars)
+
+ c, err := processCollectionPermissions(app, cr, u, w, r)
+ if c == nil || err != nil {
+ return err
+ }
+
+ // Serve ActivityStreams data now, if requested
+ if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
+ ac := c.PersonObject()
+ ac.Context = []interface{}{activitystreams.Namespace}
+ return impart.RenderActivityJSON(w, ac, http.StatusOK)
+ }
+
+ // Fetch extra data about the Collection
+ // TODO: refactor out this logic, shared in collection.go:fetchCollection()
+ coll := newDisplayCollection(c, cr, page)
+
+ coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage())))
+ if coll.TotalPages > 0 && page > coll.TotalPages {
+ redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
+ if !app.cfg.App.SingleUser {
+ redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
+ }
+ return impart.HTTPError{http.StatusFound, redirURL}
+ }
+
+ coll.Posts, _ = app.db.GetPosts(c, page, cr.isCollOwner)
+
+ // Serve collection
+ displayPage := CollectionPage{
+ DisplayCollection: coll,
+ StaticPage: pageForReq(app, r),
+ IsCustomDomain: cr.isCustomDomain,
+ IsWelcome: r.FormValue("greeting") != "",
+ }
+ var owner *User
+ if u != nil {
+ displayPage.Username = u.Username
+ displayPage.IsOwner = u.ID == coll.OwnerID
+ if displayPage.IsOwner {
+ // Add in needed information for users viewing their own collection
+ owner = u
+ displayPage.CanPin = true
+
+ pubColls, err := app.db.GetPublishableCollections(owner)
+ if err != nil {
+ log.Error("unable to fetch collections: %v", err)
+ }
+ displayPage.Collections = pubColls
+ }
+ }
+ if owner == nil {
+ // Current user doesn't own collection; retrieve owner information
+ 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)
+ }
+ }
+ displayPage.Owner = owner
+ coll.Owner = displayPage.Owner
+
+ // Add more data
+ // TODO: fix this mess of collections inside collections
+ displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj)
+
+ err = templates["collection"].ExecuteTemplate(w, "collection", displayPage)
+ if err != nil {
+ log.Error("Unable to render collection index: %v", err)
+ }
+
+ // Update collection view count
+ go func() {
+ // Don't update if owner is viewing the collection.
+ if u != nil && u.ID == coll.OwnerID {
+ return
+ }
+ // Only update for human views
+ if r.Method == "HEAD" || bots.IsBot(r.UserAgent()) {
+ return
+ }
+
+ _, err := app.db.Exec("UPDATE collections SET view_count = view_count + 1 WHERE id = ?", coll.ID)
+ if err != nil {
+ log.Error("Unable to update collections count: %v", err)
+ }
+ }()
+
+ return err
+}
+
+func handleViewCollectionTag(app *app, w http.ResponseWriter, r *http.Request) error {
+ vars := mux.Vars(r)
+ tag := vars["tag"]
+
+ cr := &collectionReq{}
+ err := processCollectionRequest(cr, vars, w, r)
+ if err != nil {
+ return err
+ }
+
+ u, err := checkUserForCollection(app, cr, r, false)
+ if err != nil {
+ return err
+ }
+
+ page := getCollectionPage(vars)
+
+ c, err := processCollectionPermissions(app, cr, u, w, r)
+ if c == nil || err != nil {
+ return err
+ }
+
+ coll := newDisplayCollection(c, cr, page)
+
+ coll.Posts, _ = app.db.GetPostsTagged(c, tag, page, cr.isCollOwner)
+ if coll.Posts != nil && len(*coll.Posts) == 0 {
+ return ErrCollectionPageNotFound
+ }
+
+ // Serve collection
+ displayPage := struct {
+ CollectionPage
+ Tag string
+ }{
+ CollectionPage: CollectionPage{
+ DisplayCollection: coll,
+ StaticPage: pageForReq(app, r),
+ IsCustomDomain: cr.isCustomDomain,
+ },
+ Tag: tag,
+ }
+ var owner *User
+ if u != nil {
+ displayPage.Username = u.Username
+ displayPage.IsOwner = u.ID == coll.OwnerID
+ if displayPage.IsOwner {
+ // Add in needed information for users viewing their own collection
+ owner = u
+ displayPage.CanPin = true
+
+ pubColls, err := app.db.GetPublishableCollections(owner)
+ if err != nil {
+ log.Error("unable to fetch collections: %v", err)
+ }
+ displayPage.Collections = pubColls
+ }
+ }
+ if owner == nil {
+ // Current user doesn't own collection; retrieve owner information
+ 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)
+ }
+ }
+ displayPage.Owner = owner
+ coll.Owner = displayPage.Owner
+ // Add more data
+ // TODO: fix this mess of collections inside collections
+ displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj)
+
+ err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
+ if err != nil {
+ log.Error("Unable to render collection tag page: %v", err)
+ }
+
+ return nil
+}
+
+func handleCollectionPostRedirect(app *app, w http.ResponseWriter, r *http.Request) error {
+ vars := mux.Vars(r)
+ slug := vars["slug"]
+
+ cr := &collectionReq{}
+ err := processCollectionRequest(cr, vars, w, r)
+ if err != nil {
+ return err
+ }
+
+ // Normalize the URL, redirecting user to consistent post URL
+ loc := fmt.Sprintf("/%s", slug)
+ if !app.cfg.App.SingleUser {
+ loc = fmt.Sprintf("/%s/%s", cr.alias, slug)
+ }
+ return impart.HTTPError{http.StatusFound, loc}
+}
+
+func existingCollection(app *app, w http.ResponseWriter, r *http.Request) error {
+ reqJSON := IsJSON(r.Header.Get("Content-Type"))
+ vars := mux.Vars(r)
+ collAlias := vars["alias"]
+ isWeb := r.FormValue("web") == "1"
+
+ var u *User
+ if reqJSON && !isWeb {
+ // Ensure an access token was given
+ accessToken := r.Header.Get("Authorization")
+ u = &User{}
+ u.ID = app.db.GetUserID(accessToken)
+ if u.ID == -1 {
+ return ErrBadAccessToken
+ }
+ } else {
+ u = getUserSession(app, r)
+ if u == nil {
+ return ErrNotLoggedIn
+ }
+ }
+
+ if r.Method == "DELETE" {
+ err := app.db.DeleteCollection(collAlias, u.ID)
+ if err != nil {
+ // TODO: if not HTTPError, report error to admin
+ log.Error("Unable to delete collection: %s", err)
+ return err
+ }
+ addSessionFlash(app, w, r, "Deleted your blog, "+collAlias+".", nil)
+ return impart.HTTPError{Status: http.StatusNoContent}
+ }
+
+ c := SubmittedCollection{OwnerID: uint64(u.ID)}
+ var err error
+
+ if reqJSON {
+ // Decode JSON request
+ decoder := json.NewDecoder(r.Body)
+ err = decoder.Decode(&c)
+ if err != nil {
+ log.Error("Couldn't parse collection update JSON request: %v\n", err)
+ return ErrBadJSON
+ }
+ } else {
+ err = r.ParseForm()
+ if err != nil {
+ log.Error("Couldn't parse collection update form request: %v\n", err)
+ return ErrBadFormData
+ }
+
+ err = app.formDecoder.Decode(&c, r.PostForm)
+ if err != nil {
+ log.Error("Couldn't decode collection update form request: %v\n", err)
+ return ErrBadFormData
+ }
+ }
+
+ err = app.db.UpdateCollection(&c, collAlias)
+ if err != nil {
+ if err, ok := err.(impart.HTTPError); ok {
+ if reqJSON {
+ return err
+ }
+ addSessionFlash(app, w, r, err.Message, nil)
+ return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias}
+ } else {
+ log.Error("Couldn't update collection: %v\n", err)
+ return err
+ }
+ }
+
+ if reqJSON {
+ return impart.WriteSuccess(w, struct {
+ }{}, http.StatusOK)
+ }
+
+ addSessionFlash(app, w, r, "Blog updated!", nil)
+ return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias}
+}
+
+// collectionAliasFromReq takes a request and returns the collection alias
+// if it can be ascertained, as well as whether or not the collection uses a
+// custom domain.
+func collectionAliasFromReq(r *http.Request) string {
+ vars := mux.Vars(r)
+ alias := vars["subdomain"]
+ isSubdomain := alias != ""
+ if !isSubdomain {
+ // Fall back to write.as/{collection} since this isn't a custom domain
+ alias = vars["collection"]
+ }
+ return alias
+}
+
+func handleWebCollectionUnlock(app *app, w http.ResponseWriter, r *http.Request) error {
+ var readReq struct {
+ Alias string `schema:"alias" json:"alias"`
+ Pass string `schema:"password" json:"password"`
+ Next string `schema:"to" json:"to"`
+ }
+
+ // Get params
+ if impart.ReqJSON(r) {
+ decoder := json.NewDecoder(r.Body)
+ err := decoder.Decode(&readReq)
+ if err != nil {
+ log.Error("Couldn't parse readReq JSON request: %v\n", err)
+ return ErrBadJSON
+ }
+ } else {
+ err := r.ParseForm()
+ if err != nil {
+ log.Error("Couldn't parse readReq form request: %v\n", err)
+ return ErrBadFormData
+ }
+
+ err = app.formDecoder.Decode(&readReq, r.PostForm)
+ if err != nil {
+ log.Error("Couldn't decode readReq form request: %v\n", err)
+ return ErrBadFormData
+ }
+ }
+
+ if readReq.Alias == "" {
+ return impart.HTTPError{http.StatusBadRequest, "Need a collection `alias` to read."}
+ }
+ if readReq.Pass == "" {
+ return impart.HTTPError{http.StatusBadRequest, "Please supply a password."}
+ }
+
+ var collHashedPass []byte
+ err := app.db.QueryRow("SELECT password FROM collectionpasswords INNER JOIN collections ON id = collection_id WHERE alias = ?", readReq.Alias).Scan(&collHashedPass)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ log.Error("No collectionpassword found when trying to read collection %s", readReq.Alias)
+ return impart.HTTPError{http.StatusInternalServerError, "Something went very wrong. The humans have been alerted."}
+ }
+ return err
+ }
+
+ if !auth.Authenticated(collHashedPass, []byte(readReq.Pass)) {
+ return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
+ }
+
+ // Success; set cookie
+ session, err := app.sessionStore.Get(r, blogPassCookieName)
+ if err == nil {
+ session.Values[readReq.Alias] = true
+ err = session.Save(r, w)
+ if err != nil {
+ log.Error("Didn't save unlocked blog '%s': %v", readReq.Alias, err)
+ }
+ }
+
+ next := "/" + readReq.Next
+ if !app.cfg.App.SingleUser {
+ next = "/" + readReq.Alias + next
+ }
+ return impart.HTTPError{http.StatusFound, next}
+}
+
+func isAuthorizedForCollection(app *app, alias string, r *http.Request) bool {
+ authd := false
+ session, err := app.sessionStore.Get(r, blogPassCookieName)
+ if err == nil {
+ _, authd = session.Values[alias]
+ }
+ return authd
+}
diff --git a/export.go b/export.go
new file mode 100644
index 0000000..56b5676
--- /dev/null
+++ b/export.go
@@ -0,0 +1,114 @@
+package writefreely
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/csv"
+ "github.com/writeas/web-core/log"
+ "strings"
+ "time"
+)
+
+func exportPostsCSV(u *User, posts *[]PublicPost) []byte {
+ var b bytes.Buffer
+
+ r := [][]string{
+ {"id", "slug", "blog", "url", "created", "title", "body"},
+ }
+ for _, p := range *posts {
+ var blog string
+ if p.Collection != nil {
+ blog = p.Collection.Alias
+ }
+ f := []string{p.ID, p.Slug.String, blog, p.CanonicalURL(), p.Created8601(), p.Title.String, strings.Replace(p.Content, "\n", "\\n", -1)}
+ r = append(r, f)
+ }
+
+ w := csv.NewWriter(&b)
+ w.WriteAll(r) // calls Flush internally
+ if err := w.Error(); err != nil {
+ log.Info("error writing csv:", err)
+ }
+
+ return b.Bytes()
+}
+
+type exportedTxt struct {
+ Name, Body string
+ Mod time.Time
+}
+
+func exportPostsZip(u *User, posts *[]PublicPost) []byte {
+ // Create a buffer to write our archive to.
+ b := new(bytes.Buffer)
+
+ // Create a new zip archive.
+ w := zip.NewWriter(b)
+
+ // Add some files to the archive.
+ var filename string
+ files := []exportedTxt{}
+ for _, p := range *posts {
+ filename = ""
+ if p.Collection != nil {
+ filename += p.Collection.Alias + "/"
+ }
+ if p.Slug.String != "" {
+ filename += p.Slug.String + "_"
+ }
+ filename += p.ID + ".txt"
+ files = append(files, exportedTxt{filename, p.Content, p.Created})
+ }
+
+ for _, file := range files {
+ head := &zip.FileHeader{Name: file.Name}
+ head.SetModTime(file.Mod)
+ f, err := w.CreateHeader(head)
+ if err != nil {
+ log.Error("export zip header: %v", err)
+ }
+ _, err = f.Write([]byte(file.Body))
+ if err != nil {
+ log.Error("export zip write: %v", err)
+ }
+ }
+
+ // Make sure to check the error on Close.
+ err := w.Close()
+ if err != nil {
+ log.Error("export zip close: %v", err)
+ }
+
+ return b.Bytes()
+}
+
+func compileFullExport(app *app, u *User) *ExportUser {
+ exportUser := &ExportUser{
+ User: u,
+ }
+
+ colls, err := app.db.GetCollections(u)
+ if err != nil {
+ log.Error("unable to fetch collections: %v", err)
+ }
+
+ posts, err := app.db.GetAnonymousPosts(u)
+ if err != nil {
+ log.Error("unable to fetch anon posts: %v", err)
+ }
+ exportUser.AnonymousPosts = *posts
+
+ var collObjs []CollectionObj
+ for _, c := range *colls {
+ co := &CollectionObj{Collection: c}
+ co.Posts, err = app.db.GetPosts(&c, 0, true)
+ if err != nil {
+ log.Error("unable to get collection posts: %v", err)
+ }
+ app.db.GetPostsCount(co, true)
+ collObjs = append(collObjs, *co)
+ }
+ exportUser.Collections = &collObjs
+
+ return exportUser
+}
diff --git a/feed.go b/feed.go
new file mode 100644
index 0000000..906c06f
--- /dev/null
+++ b/feed.go
@@ -0,0 +1,100 @@
+package writefreely
+
+import (
+ "fmt"
+ . "github.com/gorilla/feeds"
+ "github.com/gorilla/mux"
+ stripmd "github.com/writeas/go-strip-markdown"
+ "github.com/writeas/web-core/log"
+ "net/http"
+ "time"
+)
+
+func ViewFeed(app *app, w http.ResponseWriter, req *http.Request) error {
+ alias := collectionAliasFromReq(req)
+
+ // Display collection if this is a collection
+ var c *Collection
+ var err error
+ if app.cfg.App.SingleUser {
+ c, err = app.db.GetCollection(alias)
+ } else {
+ c, err = app.db.GetCollectionByID(1)
+ }
+ if err != nil {
+ return nil
+ }
+
+ if c.IsPrivate() || c.IsProtected() {
+ return ErrCollectionNotFound
+ }
+
+ // Fetch extra data about the Collection
+ // TODO: refactor out this logic, shared in collection.go:fetchCollection()
+ coll := &DisplayCollection{CollectionObj: &CollectionObj{Collection: *c}}
+ if c.PublicOwner {
+ u, 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 = u
+ }
+ }
+
+ tag := mux.Vars(req)["tag"]
+ if tag != "" {
+ coll.Posts, _ = app.db.GetPostsTagged(c, tag, 1, false)
+ } else {
+ coll.Posts, _ = app.db.GetPosts(c, 1, false)
+ }
+
+ author := ""
+ if coll.Owner != nil {
+ author = coll.Owner.Username
+ }
+
+ collectionTitle := coll.DisplayTitle()
+ if tag != "" {
+ collectionTitle = tag + " — " + collectionTitle
+ }
+
+ baseUrl := coll.CanonicalURL()
+ basePermalinkUrl := baseUrl
+ siteURL := baseUrl
+ if tag != "" {
+ siteURL += "tag:" + tag
+ }
+
+ feed := &Feed{
+ Title: collectionTitle,
+ Link: &Link{Href: siteURL},
+ Description: coll.Description,
+ Author: &Author{author, ""},
+ Created: time.Now(),
+ }
+
+ var title, permalink string
+ for _, p := range *coll.Posts {
+ title = p.PlainDisplayTitle()
+ permalink = fmt.Sprintf("%s%s", baseUrl, p.Slug.String)
+ feed.Items = append(feed.Items, &Item{
+ Id: fmt.Sprintf("%s%s", basePermalinkUrl, p.Slug.String),
+ Title: title,
+ Link: &Link{Href: permalink},
+ Description: "",
+ Content: applyMarkdown([]byte(p.Content)),
+ Author: &Author{author, ""},
+ Created: p.Created,
+ Updated: p.Updated,
+ })
+ }
+
+ rss, err := feed.ToRss()
+ if err != nil {
+ return err
+ }
+
+ fmt.Fprint(w, rss)
+ return nil
+}
diff --git a/request.go b/request.go
new file mode 100644
index 0000000..3b72b44
--- /dev/null
+++ b/request.go
@@ -0,0 +1,8 @@
+package writefreely
+
+import "mime"
+
+func IsJSON(h string) bool {
+ ct, _, _ := mime.ParseMediaType(h)
+ return ct == "application/json"
+}
diff --git a/routes.go b/routes.go
index 92462b6..fafc4c1 100644
--- a/routes.go
+++ b/routes.go
@@ -35,6 +35,48 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto
write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover)))
write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo)))
+ // Handle logged in user sections
+ me := write.PathPrefix("/me").Subrouter()
+ me.HandleFunc("/", handler.Redirect("/me", UserLevelUser))
+ me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET")
+ me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET")
+ me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET")
+ me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET")
+ me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET")
+ me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET")
+ me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
+ me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
+ me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
+ me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
+ me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
+ me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET")
+ me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
+
+ write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET")
+ apiMe := write.PathPrefix("/api/me/").Subrouter()
+ apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET")
+ apiMe.HandleFunc("/posts", handler.UserAPI(viewMyPostsAPI)).Methods("GET")
+ apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET")
+ apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
+ apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
+
+ // Sign up validation
+ write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
+
+ // Handle collections
+ write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
+ apiColls := write.PathPrefix("/api/collections/").Subrouter()
+ apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(fetchCollection)).Methods("GET")
+ apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE")
+ apiColls.HandleFunc("/{alias}/posts", handler.All(fetchCollectionPosts)).Methods("GET")
+ apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST")
+ apiColls.HandleFunc("/{alias}/posts/{post}", handler.All(fetchPost)).Methods("GET")
+ apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST")
+ apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.All(fetchPostProperty)).Methods("GET")
+ apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
+ apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
+ apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST")
+
// Handle posts
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
posts := write.PathPrefix("/api/posts/").Subrouter()
@@ -56,9 +98,26 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto
write.HandleFunc("/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET")
// Collections
if cfg.App.SingleUser {
+ RouteCollections(handler, write.PathPrefix("/").Subrouter())
} else {
+ write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelOptional))
+ write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelOptional))
+ RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter())
// Posts
write.HandleFunc("/{post}", handler.Web(handleViewPost, UserLevelOptional))
}
write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional))
}
+
+func RouteCollections(handler *Handler, r *mux.Router) {
+ r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelOptional))
+ r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelOptional))
+ r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelOptional))
+ r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelOptional))
+ r.HandleFunc("/sitemap.xml", handler.All(handleViewSitemap))
+ r.HandleFunc("/feed/", handler.All(ViewFeed))
+ r.HandleFunc("/{slug}", handler.Web(viewCollectionPost, UserLevelOptional))
+ r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
+ r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
+ r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelOptional)).Methods("GET")
+}
diff --git a/session.go b/session.go
index 931b87b..6e8e4fd 100644
--- a/session.go
+++ b/session.go
@@ -13,6 +13,8 @@ const (
sessionLength = 180 * day
cookieName = "wfu"
cookieUserVal = "u"
+
+ blogPassCookieName = "ub"
)
// initSession creates the cookie store. It depends on the keychain already
diff --git a/sitemap.go b/sitemap.go
new file mode 100644
index 0000000..0712bf6
--- /dev/null
+++ b/sitemap.go
@@ -0,0 +1,94 @@
+package writefreely
+
+import (
+ "fmt"
+ "github.com/gorilla/mux"
+ "github.com/ikeikeikeike/go-sitemap-generator/stm"
+ "github.com/writeas/web-core/log"
+ "net/http"
+ "time"
+)
+
+func buildSitemap(host, alias string) *stm.Sitemap {
+ sm := stm.NewSitemap()
+ sm.SetDefaultHost(host)
+ if alias != "/" {
+ sm.SetSitemapsPath(alias)
+ }
+
+ sm.Create()
+
+ // Note: Do not call `sm.Finalize()` because it flushes
+ // the underlying datastructure from memory to disk.
+
+ return sm
+}
+
+func handleViewSitemap(app *app, w http.ResponseWriter, r *http.Request) error {
+ vars := mux.Vars(r)
+
+ // Determine canonical blog URL
+ alias := vars["collection"]
+ subdomain := vars["subdomain"]
+ isSubdomain := subdomain != ""
+ if isSubdomain {
+ alias = subdomain
+ }
+
+ host := fmt.Sprintf("%s/%s/", app.cfg.App.Host, alias)
+ var c *Collection
+ var err error
+ pre := "/"
+ if app.cfg.App.SingleUser {
+ c, err = app.db.GetCollectionByID(1)
+ } else {
+ c, err = app.db.GetCollection(alias)
+ }
+ if err != nil {
+ return err
+ }
+
+ if !isSubdomain {
+ pre += alias + "/"
+ }
+ host = c.CanonicalURL()
+
+ sm := buildSitemap(host, pre)
+ posts, err := app.db.GetPosts(c, 0, false)
+ if err != nil {
+ log.Error("Error getting posts: %v", err)
+ return err
+ }
+ lastSiteMod := time.Now()
+ for i, p := range *posts {
+ if i == 0 {
+ lastSiteMod = p.Updated
+ }
+ u := stm.URL{
+ "loc": p.Slug.String,
+ "changefreq": "weekly",
+ "mobile": true,
+ "lastmod": p.Updated,
+ }
+ if len(p.Images) > 0 {
+ imgs := []stm.URL{}
+ for _, i := range p.Images {
+ imgs = append(imgs, stm.URL{"loc": i, "title": ""})
+ }
+ u["image"] = imgs
+ }
+ sm.Add(u)
+ }
+
+ // Add top URL
+ sm.Add(stm.URL{
+ "loc": pre,
+ "changefreq": "daily",
+ "priority": "1.0",
+ "lastmod": lastSiteMod,
+ })
+
+ w.Write(sm.XMLContent())
+
+ return nil
+}
diff --git a/templates/collection-post.tmpl b/templates/collection-post.tmpl
index 289ee60..e581ad9 100644
--- a/templates/collection-post.tmpl
+++ b/templates/collection-post.tmpl
@@ -89,7 +89,6 @@ function unpinPost(e, postID) {
// Hide current page
var $pinnedNavLink = $header.getElementsByTagName('nav')[0].querySelector('.pinned.selected');
$pinnedNavLink.style.display = 'none';
- try { _paq.push(['trackEvent', 'Post', 'unpin', 'post']); } catch(e) {}
};
var $pinBtn = $header.getElementsByClassName('unpin')[0];
diff --git a/templates/edit-meta.tmpl b/templates/edit-meta.tmpl
index 026dd49..57eada5 100644
--- a/templates/edit-meta.tmpl
+++ b/templates/edit-meta.tmpl
@@ -365,7 +365,6 @@ H.getEl('set-now').on('click', function(e) {
// whatevs
}
-