@@ -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 | |||||
} |
@@ -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 | |||||
} |
@@ -0,0 +1,8 @@ | |||||
package writefreely | |||||
import "mime" | |||||
func IsJSON(h string) bool { | |||||
ct, _, _ := mime.ParseMediaType(h) | |||||
return ct == "application/json" | |||||
} |
@@ -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(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover))) | ||||
write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo))) | 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 | // Handle posts | ||||
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST") | write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST") | ||||
posts := write.PathPrefix("/api/posts/").Subrouter() | 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") | write.HandleFunc("/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET") | ||||
// Collections | // Collections | ||||
if cfg.App.SingleUser { | if cfg.App.SingleUser { | ||||
RouteCollections(handler, write.PathPrefix("/").Subrouter()) | |||||
} else { | } else { | ||||
write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelOptional)) | |||||
write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelOptional)) | |||||
RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter()) | |||||
// Posts | // Posts | ||||
write.HandleFunc("/{post}", handler.Web(handleViewPost, UserLevelOptional)) | write.HandleFunc("/{post}", handler.Web(handleViewPost, UserLevelOptional)) | ||||
} | } | ||||
write.HandleFunc("/", handler.Web(handleViewHome, 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") | |||||
} |
@@ -13,6 +13,8 @@ const ( | |||||
sessionLength = 180 * day | sessionLength = 180 * day | ||||
cookieName = "wfu" | cookieName = "wfu" | ||||
cookieUserVal = "u" | cookieUserVal = "u" | ||||
blogPassCookieName = "ub" | |||||
) | ) | ||||
// initSession creates the cookie store. It depends on the keychain already | // initSession creates the cookie store. It depends on the keychain already | ||||
@@ -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 | |||||
} |
@@ -89,7 +89,6 @@ function unpinPost(e, postID) { | |||||
// Hide current page | // Hide current page | ||||
var $pinnedNavLink = $header.getElementsByTagName('nav')[0].querySelector('.pinned.selected'); | var $pinnedNavLink = $header.getElementsByTagName('nav')[0].querySelector('.pinned.selected'); | ||||
$pinnedNavLink.style.display = 'none'; | $pinnedNavLink.style.display = 'none'; | ||||
try { _paq.push(['trackEvent', 'Post', 'unpin', 'post']); } catch(e) {} | |||||
}; | }; | ||||
var $pinBtn = $header.getElementsByClassName('unpin')[0]; | var $pinBtn = $header.getElementsByClassName('unpin')[0]; | ||||
@@ -365,7 +365,6 @@ H.getEl('set-now').on('click', function(e) { | |||||
// whatevs | // whatevs | ||||
} | } | ||||
</script> | </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"> | <link href="/css/icons.css" rel="stylesheet"> | ||||
</body> | </body> | ||||
</html>{{end}} | </html>{{end}} |
@@ -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."} | |||||
} |