Kaynağa Gözat

Add Reader section

This adds a "Reader" section of the site for admins who want to enable
it for their instance. That means visitors can go to /read and see who
has publicly shared their writing. They can also follow all public posts
via RSS by going to /read/feed/. Writers on an instance with this
`local_timeline` setting enabled can publish to the timeline by going
into their blog settings and choosing the "Public" visibility setting.

The `local_timeline` feature is disabled by default, as is the Public
setting on writer blogs. Enabling it adds a "Reader" navigation item and
enables the reader endpoints. This feature will also consume more
memory, as public posts are cached in memory for 10 minutes.

These changes include code ported over from Read.Write.as, and thus
include some experimental features like filtering public posts by tags
and authors. These features aren't well-tested or complete.

Closes T554
tags/v0.6.0
Matt Baer 5 yıl önce
ebeveyn
işleme
25a68d0c0e
12 değiştirilmiş dosya ile 473 ekleme ve 1 silme
  1. +6
    -0
      admin.go
  2. +8
    -0
      app.go
  3. +3
    -0
      config/config.go
  4. +7
    -0
      database.go
  5. +292
    -0
      read.go
  6. +16
    -0
      routes.go
  7. +3
    -1
      templates/base.tmpl
  8. +1
    -0
      templates/include/footer.tmpl
  9. +127
    -0
      templates/read.tmpl
  10. +2
    -0
      templates/user/admin.tmpl
  11. +7
    -0
      templates/user/collection.tmpl
  12. +1
    -0
      templates/user/include/footer.tmpl

+ 6
- 0
admin.go Dosyayı Görüntüle

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


+ 8
- 0
app.go Dosyayı Görüntüle

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


+ 3
- 0
config/config.go Dosyayı Görüntüle

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


+ 7
- 0
database.go Dosyayı Görüntüle

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


+ 292
- 0
read.go Dosyayı Görüntüle

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

+ 16
- 0
routes.go Dosyayı Görüntüle

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

+ 3
- 1
templates/base.tmpl Dosyayı Görüntüle

@@ -10,7 +10,7 @@
<meta name="application-url" content="{{.Host}}">
<meta property="og:site_name" content="{{.SiteName}}" />
</head>
<body>
<body {{template "body-attrs" .}}>
<div id="overlay"></div>
<header>
<h2><a href="/">{{.SiteName}}</a></h2>
@@ -18,6 +18,7 @@
<nav id="user-nav">
<nav class="tabs">
<a href="/about"{{if eq .Path "/about"}} class="selected"{{end}}>About</a>
{{if and (not .SingleUser) .LocalTimeline}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>Reader</a>{{end}}
{{if and (not .SingleUser) (not .Username)}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>Log in</a>{{end}}
</nav>
</nav>
@@ -53,3 +54,4 @@
{{end}}
</body>
</html>{{end}}
{{define "body-attrs"}}{{end}}

+ 1
- 0
templates/include/footer.tmpl Dosyayı Görüntüle

@@ -16,6 +16,7 @@
<h3><a class="home" href="/">{{.SiteName}}</a></h3>
<ul>
<li><a href="/about">about</a></li>
{{if and (not .SingleUser) .LocalTimeline}}<a href="/read">reader</a>{{end}}
<li><a href="/privacy">privacy</a></li>
</ul>
</div>


+ 127
- 0
templates/read.tmpl Dosyayı Görüntüle

@@ -0,0 +1,127 @@
{{define "head"}}<title>{{.SiteName}} Reader</title>
<link rel="alternate" type="application/rss+xml" title="{{.SiteName}} Reader" href="/read/feed/" />
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .CurrentPage}}">{{end}}
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .CurrentPage}}">{{end}}

<meta name="description" content="Read the latest posts from {{.SiteName}}.">
<meta itemprop="name" content="{{.SiteName}} Reader">
<meta itemprop="description" content="Read the latest posts from {{.SiteName}}.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{.SiteName}} Reader">
<meta name="twitter:description" content="Read the latest posts from {{.SiteName}}.">
<meta property="og:title" content="{{.SiteName}} Reader" />
<meta property="og:type" content="object" />
<meta property="og:description" content="Read the latest posts from {{.SiteName}}." />

<style>
.heading h1 {
font-weight: 300;
text-align: center;
margin: 3em 0 0;
}
.heading p {
text-align: center;
margin: 1.5em 0 4.5em;
font-size: 1.1em;
color: #777;
}
#wrapper {
font-size: 1.2em;
}
.preview {
max-height: 180px;
overflow: hidden;
position: relative;
}
.preview .over {
position: absolute;
top: 5em;
bottom: 0;
left: 0;
right: 0;
/* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#ffffff+0,ffffff+100&0+0,1+100 */
background: -moz-linear-gradient(top, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(top, rgba(255,255,255,0) 0%,rgba(255,255,255,1) 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to bottom, rgba(255,255,255,0) 0%,rgba(255,255,255,1) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#00ffffff', endColorstr='#ffffff',GradientType=0 ); /* IE6-9 */
}
p.source {
font-size: 0.86em;
margin-top: 0.25em;
margin-bottom: 0;
}
.attention-box {
text-align: center;
font-size: 1.1em;
}
.attention-box hr { margin: 4rem auto; }
hr { max-width: 40rem; }
header {
padding: 0 !important;
text-align: left !important;
margin: 1em !important;
max-width: 100% !important;
}
body#collection header nav {
display: inline !important;
margin: 0 0 0 1em !important;
}
header nav#user-nav {
margin-left: 0 !important;
}
</style>
{{end}}
{{define "body-attrs"}}id="collection"{{end}}
{{define "content"}}
<div class="content-container snug" style="max-width: 40rem;">
<h1 style="text-align:center">Reader</h1>
<p>Read the latest posts from {{.SiteName}}. {{if .Username}}To showcase your writing here, go to your <a href="/me/c/">blog</a> settings and select the <em>Public</em> option.{{end}}</p>
</div>
<div id="wrapper">
{{ if gt (len .Posts) 0 }}
<section itemscope itemtype="http://schema.org/Blog">
{{range .Posts}}<article class="{{.Font}} h-entry" itemscope itemtype="http://schema.org/BlogPosting">
{{if .Title.String}}<h2 class="post-title" itemprop="name" class="p-name"><a href="{{if .Slug.String}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}" itemprop="url" class="u-url">{{.PlainDisplayTitle}}</a></h2>
<time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{.Collection.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time>
{{else}}
<h2 class="post-title" itemprop="name"><time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time></h2>
{{end}}
<p class="source">{{if .Collection}}from <a href="{{.Collection.CanonicalURL}}">{{.Collection.DisplayTitle}}</a>{{else}}<em>Anonymous</em>{{end}}</p>
{{if .Excerpt}}<div class="p-summary" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{.Excerpt}}</div>
<a class="read-more" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div class="e-content preview" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{ if not .HTMLContent }}<p id="post-body" class="e-content preview">{{.Content}}</p>{{ else }}{{.HTMLContent}}{{ end }}<div class="over">&nbsp;</div></div>
<a class="read-more maybe" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{end}}</article>
{{end}}
</section>
{{ else }}
<div class="attention-box">
<p>No posts here yet!</p>
</div>
{{ end }}

{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .CurrentPage}}">&#8672; Older</a>{{end}}
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .CurrentPage}}">Newer &#8674;</a>{{end}}
</nav>{{end}}

</div>

<script type="text/javascript">
(function() {
var $articles = document.querySelectorAll('article');
for (var i=0; i<$articles.length; i++) {
var $art = $articles[i];
var $more = $art.querySelector('.read-more.maybe');
if ($more != null) {
if ($art.querySelector('.e-content.preview').clientHeight < 180) {
$more.parentNode.removeChild($more);
var $overlay = $art.querySelector('.over');
$overlay.parentNode.removeChild($overlay);
}
}
}
})();
</script>
{{end}}

+ 2
- 0
templates/user/admin.tmpl Dosyayı Görüntüle

@@ -113,6 +113,8 @@ function savePage(el) {
<dd><input type="checkbox" name="public_stats" id="public_stats" {{if .Config.PublicStats}}checked="checked"{{end}} /></dd>
<dt><label for="private">Private Instance</label></dt>
<dd><input type="checkbox" name="private" id="private" {{if .Config.Private}}checked="checked"{{end}} /></dd>
<dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="local_timeline">Local Timeline</label></dt>
<dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="local_timeline" id="local_timeline" {{if .Config.LocalTimeline}}checked="checked"{{end}} /></dd>
</dl>
<input type="submit" value="Save Configuration" />
</div>


+ 7
- 0
templates/user/collection.tmpl Dosyayı Görüntüle

@@ -58,6 +58,13 @@
</label>
<p>A password is required to read this blog.</p>
</li>
<li>
<label class="option-text{{if not .LocalTimeline}} disabled{{end}}"><input type="radio" name="visibility" id="visibility-public" value="1" {{if .IsPublic}}checked="checked"{{end}} {{if not .LocalTimeline}}disabled="disabled"{{end}} />
Public
</label>
{{if .LocalTimeline}}<p>This blog is displayed on the public <a href="/read">reader</a>, and to anyone with its link.</p>
{{else}}<p>The public reader is currently turned off for this community.</p>{{end}}
</li>
</ul>
</div>
</div>


+ 1
- 0
templates/user/include/footer.tmpl Dosyayı Görüntüle

@@ -9,6 +9,7 @@
<nav>
<a class="home" href="/">{{.SiteName}}</a>
<a href="/about">about</a>
{{if and (not .SingleUser) .LocalTimeline}}<a href="/read">reader</a>{{end}}
<a href="https://writefreely.org/guide" target="guide">writer's guide</a>
<a href="/privacy">privacy</a>
<a href="https://writefreely.org">writefreely {{.Version}}</a>


Yükleniyor…
İptal
Kaydet