{{.PlainDisplayTitle}}
+ + {{else}} + + {{end}} +{{if .Collection}}from {{.Collection.DisplayTitle}}{{else}}Anonymous{{end}}
+ {{if .Excerpt}}{{.Content}}
{{ else }}{{.HTMLContent}}{{ end }}diff --git a/admin.go b/admin.go index 508ac59..7f78a85 100644 --- a/admin.go +++ b/admin.go @@ -6,6 +6,7 @@ import ( "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/auth" + "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" "net/http" "runtime" @@ -126,6 +127,11 @@ func handleAdminUpdateConfig(app *app, u *User, w http.ResponseWriter, r *http.R app.cfg.App.Federation = r.FormValue("federation") == "on" app.cfg.App.PublicStats = r.FormValue("public_stats") == "on" app.cfg.App.Private = r.FormValue("private") == "on" + app.cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on" + if app.cfg.App.LocalTimeline && app.timeline == nil { + log.Info("Initializing local timeline...") + initLocalTimeline(app) + } m := "?cm=Configuration+saved." err = config.Save(app.cfg, app.cfgFile) diff --git a/app.go b/app.go index 8d5fd4d..759bcd0 100644 --- a/app.go +++ b/app.go @@ -60,6 +60,8 @@ type app struct { keys *keychain sessionStore *sessions.CookieStore formDecoder *schema.Decoder + + timeline *localTimeline } // handleViewHome shows page at root path. Will be the Pad if logged in and the @@ -423,6 +425,12 @@ func Serve() { // Handle app routes initRoutes(handler, r, app.cfg, app.db) + // Handle local timeline, if enabled + if app.cfg.App.LocalTimeline { + log.Info("Initializing local timeline...") + initLocalTimeline(app) + } + // Handle static files fs := http.FileServer(http.Dir(staticDir)) shttp.Handle("/", fs) diff --git a/config/config.go b/config/config.go index 7b4c3e8..1341633 100644 --- a/config/config.go +++ b/config/config.go @@ -50,6 +50,9 @@ type ( Federation bool `ini:"federation"` PublicStats bool `ini:"public_stats"` Private bool `ini:"private"` + + // Additional functions + LocalTimeline bool `ini:"local_timeline"` } Config struct { diff --git a/database.go b/database.go index 5166a9a..a493ed9 100644 --- a/database.go +++ b/database.go @@ -132,6 +132,13 @@ func (db *datastore) upsert(indexedCols ...string) string { return "ON DUPLICATE KEY UPDATE" } +func (db *datastore) dateSub(l int, unit string) string { + if db.driverName == driverSQLite { + return fmt.Sprintf("DATETIME('now', '-%d %s')", l, unit) + } + return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit) +} + func (db *datastore) isDuplicateKeyErr(err error) bool { if db.driverName == driverSQLite { if err, ok := err.(sqlite3.Error); ok { diff --git a/read.go b/read.go new file mode 100644 index 0000000..cd725c4 --- /dev/null +++ b/read.go @@ -0,0 +1,292 @@ +package writefreely + +import ( + "database/sql" + "fmt" + . "github.com/gorilla/feeds" + "github.com/gorilla/mux" + stripmd "github.com/writeas/go-strip-markdown" + "github.com/writeas/impart" + "github.com/writeas/web-core/log" + "github.com/writeas/web-core/memo" + "github.com/writeas/writefreely/page" + "html/template" + "math" + "net/http" + "strconv" + "time" +) + +const ( + tlFeedLimit = 100 + tlAPIPageLimit = 10 + tlMaxAuthorPosts = 5 + tlPostsPerPage = 16 +) + +type localTimeline struct { + m *memo.Memo + posts *[]PublicPost + + // Configuration values + postsPerPage int +} + +type readPublication struct { + page.StaticPage + Posts *[]PublicPost + CurrentPage int + TotalPages int +} + +func initLocalTimeline(app *app) { + app.timeline = &localTimeline{ + postsPerPage: tlPostsPerPage, + m: memo.New(app.db.FetchPublicPosts, 10*time.Minute), + } +} + +// satisfies memo.Func +func (db *datastore) FetchPublicPosts() (interface{}, error) { + // Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months + rows, err := db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated + FROM collections c + LEFT JOIN posts p ON p.collection_id = c.id + WHERE c.privacy = 1 AND (p.created >= ` + db.dateSub(3, "month") + ` AND p.created <= ` + db.now() + ` AND pinned_position IS NULL) + ORDER BY p.created DESC`) + if err != nil { + log.Error("Failed selecting from posts: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()} + } + defer rows.Close() + + ap := map[string]uint{} + + posts := []PublicPost{} + for rows.Next() { + p := &Post{} + c := &Collection{} + var alias, title sql.NullString + err = rows.Scan(&p.ID, &alias, &title, &p.Slug, &p.Title, &p.Content, &p.Font, &p.Language, &p.RTL, &p.Created, &p.Updated) + if err != nil { + log.Error("[READ] Unable to scan row, skipping: %v", err) + continue + } + isCollectionPost := alias.Valid + if isCollectionPost { + c.Alias = alias.String + if c.Alias != "" && ap[c.Alias] == tlMaxAuthorPosts { + // Don't add post if we've hit the post-per-author limit + continue + } + + c.Public = true + c.Title = title.String + } + + p.extractData() + p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content))) + fp := p.processPost() + if isCollectionPost { + fp.Collection = &CollectionObj{Collection: *c} + } + + posts = append(posts, fp) + ap[c.Alias]++ + } + + return posts, nil +} + +func viewLocalTimelineAPI(app *app, w http.ResponseWriter, r *http.Request) error { + updateTimelineCache(app.timeline) + + skip, _ := strconv.Atoi(r.FormValue("skip")) + + posts := []PublicPost{} + for i := skip; i < skip+tlAPIPageLimit && i < len(*app.timeline.posts); i++ { + posts = append(posts, (*app.timeline.posts)[i]) + } + + return impart.WriteSuccess(w, posts, http.StatusOK) +} + +func viewLocalTimeline(app *app, w http.ResponseWriter, r *http.Request) error { + if !app.cfg.App.LocalTimeline { + return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} + } + + vars := mux.Vars(r) + var p int + page := 1 + p, _ = strconv.Atoi(vars["page"]) + if p > 0 { + page = p + } + + return showLocalTimeline(app, w, r, page, vars["author"], vars["tag"]) +} + +func updateTimelineCache(tl *localTimeline) { + // Fetch posts if enough time has passed since last cache + if tl.posts == nil || tl.m.Invalidate() { + log.Info("[READ] Updating post cache") + var err error + var postsInterfaces interface{} + postsInterfaces, err = tl.m.Get() + if err != nil { + log.Error("[READ] Unable to cache posts: %v", err) + } else { + castPosts := postsInterfaces.([]PublicPost) + tl.posts = &castPosts + } + } +} + +func showLocalTimeline(app *app, w http.ResponseWriter, r *http.Request, page int, author, tag string) error { + updateTimelineCache(app.timeline) + + pl := len(*(app.timeline.posts)) + ttlPages := int(math.Ceil(float64(pl) / float64(app.timeline.postsPerPage))) + + start := 0 + if page > 1 { + start = app.timeline.postsPerPage * (page - 1) + if start > pl { + return impart.HTTPError{http.StatusFound, fmt.Sprintf("/read/p/%d", ttlPages)} + } + } + end := app.timeline.postsPerPage * page + if end > pl { + end = pl + } + var posts []PublicPost + if author != "" { + posts = []PublicPost{} + for _, p := range *app.timeline.posts { + if author == "anonymous" { + if p.Collection == nil { + posts = append(posts, p) + } + } else if p.Collection != nil && p.Collection.Alias == author { + posts = append(posts, p) + } + } + } else if tag != "" { + posts = []PublicPost{} + for _, p := range *app.timeline.posts { + if p.HasTag(tag) { + posts = append(posts, p) + } + } + } else { + posts = *app.timeline.posts + posts = posts[start:end] + } + + d := &readPublication{ + pageForReq(app, r), + &posts, + page, + ttlPages, + } + + err := templates["read"].ExecuteTemplate(w, "base", d) + if err != nil { + log.Error("Unable to render reader: %v", err) + fmt.Fprintf(w, ":(") + } + return nil +} + +// NextPageURL provides a full URL for the next page of collection posts +func (c *readPublication) NextPageURL(n int) string { + return fmt.Sprintf("/read/p/%d", n+1) +} + +// PrevPageURL provides a full URL for the previous page of collection posts, +// returning a /page/N result for pages >1 +func (c *readPublication) PrevPageURL(n int) string { + if n == 2 { + // Previous page is 1; no need for /p/ prefix + return "/read" + } + return fmt.Sprintf("/read/p/%d", n-1) +} + +// handlePostIDRedirect handles a route where a post ID is given and redirects +// the user to the canonical post URL. +func handlePostIDRedirect(app *app, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + postID := vars["post"] + p, err := app.db.GetPost(postID, 0) + if err != nil { + return err + } + + if !p.CollectionID.Valid { + // No collection; send to normal URL + // NOTE: not handling single user blogs here since this handler is only used for the Reader + return impart.HTTPError{http.StatusFound, app.cfg.App.Host + "/" + postID + ".md"} + } + + c, err := app.db.GetCollectionBy("id = ?", fmt.Sprintf("%d", p.CollectionID.Int64)) + if err != nil { + return err + } + + // Retrieve collection information and send user to canonical URL + return impart.HTTPError{http.StatusFound, c.CanonicalURL() + p.Slug.String} +} + +func viewLocalTimelineFeed(app *app, w http.ResponseWriter, req *http.Request) error { + if !app.cfg.App.LocalTimeline { + return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} + } + + updateTimelineCache(app.timeline) + + feed := &Feed{ + Title: app.cfg.App.SiteName + " Reader", + Link: &Link{Href: app.cfg.App.Host}, + Description: "Read the latest posts from " + app.cfg.App.SiteName + ".", + Created: time.Now(), + } + + c := 0 + var title, permalink, author string + for _, p := range *app.timeline.posts { + if c == tlFeedLimit { + break + } + + title = p.PlainDisplayTitle() + permalink = p.CanonicalURL() + if p.Collection != nil { + author = p.Collection.Title + } else { + author = "Anonymous" + permalink += ".md" + } + i := &Item{ + Id: app.cfg.App.Host + "/read/a/" + p.ID, + Title: title, + Link: &Link{Href: permalink}, + Description: "", + Content: applyMarkdown([]byte(p.Content)), + Author: &Author{author, ""}, + Created: p.Created, + Updated: p.Updated, + } + feed.Items = append(feed.Items, i) + c++ + } + + rss, err := feed.ToRss() + if err != nil { + return err + } + + fmt.Fprint(w, rss) + return nil +} diff --git a/routes.go b/routes.go index df57373..646319f 100644 --- a/routes.go +++ b/routes.go @@ -121,6 +121,12 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto // Handle special pages first write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) + // TODO: show a reader-specific 404 page if the function is disabled + // TODO: change this based on configuration for either public or private-to-this-instance + readPerm := UserLevelOptional + + write.HandleFunc("/read", handler.Web(viewLocalTimeline, readPerm)) + RouteRead(handler, readPerm, write.PathPrefix("/read").Subrouter()) draftEditPrefix := "" if cfg.App.SingleUser { @@ -158,3 +164,13 @@ func RouteCollections(handler *Handler, r *mux.Router) { r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser)) r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelOptional)).Methods("GET") } + +func RouteRead(handler *Handler, readPerm UserLevel, r *mux.Router) { + r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm)) + r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm)) + r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm)) + r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm)) + r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm)) + r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm)) + r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm)) +} diff --git a/templates/base.tmpl b/templates/base.tmpl index 6c58ebf..01ead5b 100644 --- a/templates/base.tmpl +++ b/templates/base.tmpl @@ -10,7 +10,7 @@ -
+Read the latest posts from {{.SiteName}}. {{if .Username}}To showcase your writing here, go to your blog settings and select the Public option.{{end}}
+{{if .Collection}}from {{.Collection.DisplayTitle}}{{else}}Anonymous{{end}}
+ {{if .Excerpt}}{{.Content}}
{{ else }}{{.HTMLContent}}{{ end }}No posts here yet!
+A password is required to read this blog.
+This blog is displayed on the public reader, and to anyone with its link.
+ {{else}}The public reader is currently turned off for this community.
{{end}} +