Browse Source

Add collection handlers, routes, feeds, sitemaps

tags/v0.1.0
Matt Baer 5 years ago
parent
commit
ebeacff43c
10 changed files with 1503 additions and 35 deletions
  1. +1005
    -33
      collections.go
  2. +114
    -0
      export.go
  3. +100
    -0
      feed.go
  4. +8
    -0
      request.go
  5. +59
    -0
      routes.go
  6. +2
    -0
      session.go
  7. +94
    -0
      sitemap.go
  8. +0
    -1
      templates/collection-post.tmpl
  9. +0
    -1
      templates/edit-meta.tmpl
  10. +121
    -0
      unregisteredusers.go

+ 1005
- 33
collections.go
File diff suppressed because it is too large
View File


+ 114
- 0
export.go View File

@@ -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
}

+ 100
- 0
feed.go View File

@@ -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: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>",
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
}

+ 8
- 0
request.go View File

@@ -0,0 +1,8 @@
package writefreely

import "mime"

func IsJSON(h string) bool {
ct, _, _ := mime.ParseMediaType(h)
return ct == "application/json"
}

+ 59
- 0
routes.go View File

@@ -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")
}

+ 2
- 0
session.go View File

@@ -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


+ 94
- 0
sitemap.go View File

@@ -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
}

+ 0
- 1
templates/collection-post.tmpl View File

@@ -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];


+ 0
- 1
templates/edit-meta.tmpl View File

@@ -365,7 +365,6 @@ H.getEl('set-now').on('click', function(e) {
// whatevs
}
</script>
<noscript><p><img src="https://analytics.write.as/piwik.php?idsite=1" style="border:0;" alt="" /></p></noscript>
<link href="/css/icons.css" rel="stylesheet">
</body>
</html>{{end}}

+ 121
- 0
unregisteredusers.go View File

@@ -0,0 +1,121 @@
package writefreely

import (
"database/sql"
"encoding/json"
"github.com/writeas/impart"
"github.com/writeas/web-core/log"
"net/http"
)

func handleWebSignup(app *app, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))

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

err = app.formDecoder.Decode(&ur, r.PostForm)
if err != nil {
log.Error("Couldn't decode signup form request: %v\n", err)
return ErrBadFormData
}
}
ur.Web = true

_, err := signupWithRegistration(app, ur, w, r)
if err != nil {
return err
}
return impart.HTTPError{http.StatusFound, "/"}
}

// { "username": "asdf" }
// result: { code: 204 }
func handleUsernameCheck(app *app, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))

// Get params
var d struct {
Username string `json:"username"`
}
if reqJSON {
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&d)
if err != nil {
log.Error("Couldn't decode username check: %v\n", err)
return ErrBadFormData
}
} else {
return impart.HTTPError{http.StatusNotAcceptable, "Must be JSON request"}
}

// Check if username is okay
finalUsername := getSlug(d.Username, "")
if finalUsername == "" {
errMsg := "Invalid username"
if d.Username != "" {
// Username was provided, but didn't convert into valid latin characters
errMsg += " - must have at least 2 letters or numbers"
}
return impart.HTTPError{http.StatusBadRequest, errMsg + "."}
}
if app.db.PostIDExists(finalUsername) {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
var un string
err := app.db.QueryRow("SELECT username FROM users WHERE username = ?", finalUsername).Scan(&un)
switch {
case err == sql.ErrNoRows:
return impart.WriteSuccess(w, finalUsername, http.StatusOK)
case err != nil:
log.Error("Couldn't SELECT username: %v", err)
return impart.HTTPError{http.StatusInternalServerError, "We messed up."}
}

// Username was found, so it's taken
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}

func getValidUsername(app *app, reqName, prevName string) (string, *impart.HTTPError) {
// Check if username is okay
finalUsername := getSlug(reqName, "")
if finalUsername == "" {
errMsg := "Invalid username"
if reqName != "" {
// Username was provided, but didn't convert into valid latin characters
errMsg += " - must have at least 2 letters or numbers"
}
return "", &impart.HTTPError{http.StatusBadRequest, errMsg + "."}
}
if finalUsername == prevName {
return "", &impart.HTTPError{http.StatusNotModified, "Username unchanged."}
}
if app.db.PostIDExists(finalUsername) {
return "", &impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
var un string
err := app.db.QueryRow("SELECT username FROM users WHERE username = ?", finalUsername).Scan(&un)
switch {
case err == sql.ErrNoRows:
return finalUsername, nil
case err != nil:
log.Error("Couldn't SELECT username: %v", err)
return "", &impart.HTTPError{http.StatusInternalServerError, "We messed up."}
}

// Username was found, so it's taken
return "", &impart.HTTPError{http.StatusConflict, "Username is already taken."}
}

Loading…
Cancel
Save