@@ -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(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") | |||
} |
@@ -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 | |||
@@ -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 | |||
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]; | |||
@@ -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}} |
@@ -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."} | |||
} |