From ddc7087d1ee9eee7889e7b37d9e3029f526fd137 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 25 May 2021 10:17:57 -0400 Subject: [PATCH 1/6] Fix Web Monetization option not showing on Customize page --- templates/user/collection.tmpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl index 1783b7c..041c107 100644 --- a/templates/user/collection.tmpl +++ b/templates/user/collection.tmpl @@ -157,12 +157,12 @@ textarea.section.norm { - {{if .Monetization}} + {{if .UserPage.StaticPage.AppCfg.Monetization}}

Web Monetization

Web Monetization enables you to receive micropayments from readers that have a Coil membership. Add your payment pointer to enable Web Monetization on your blog.

- +
{{end}} From 85fb2a952bbd879b19c689c8238a70265c3cbacd Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Mon, 7 Jun 2021 14:53:22 -0400 Subject: [PATCH 2/6] Support setting `description` on user registration --- account.go | 7 ++++--- app.go | 4 ++-- database.go | 6 +++--- users.go | 3 +++ 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/account.go b/account.go index d1e6a21..65d39c7 100644 --- a/account.go +++ b/account.go @@ -167,7 +167,7 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr } // Create actual user - if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil { + if err := app.db.CreateUser(app.cfg, u, desiredUsername, signup.Description); err != nil { return nil, err } @@ -193,8 +193,9 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr } resUser.Collections = &[]Collection{ { - Alias: signup.Alias, - Title: title, + Alias: signup.Alias, + Title: title, + Description: signup.Description, }, } diff --git a/app.go b/app.go index ed2ba04..40eb858 100644 --- a/app.go +++ b/app.go @@ -621,7 +621,7 @@ func DoConfig(app *App, configSections string) { // Create blog log.Info("Creating user %s...\n", u.Username) - err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName) + err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName, "") if err != nil { log.Error("Unable to create user: %s", err) os.Exit(1) @@ -866,7 +866,7 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error { userType = "admin" } log.Info("Creating %s %s...", userType, usernameDesc) - err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername) + err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername, "") if err != nil { return fmt.Errorf("Unable to create user: %s", err) } diff --git a/database.go b/database.go index df300ce..49a2312 100644 --- a/database.go +++ b/database.go @@ -51,7 +51,7 @@ var ( ) type writestore interface { - CreateUser(*config.Config, *User, string) error + CreateUser(*config.Config, *User, string, string) error UpdateUserEmail(keys *key.Keychain, userID int64, email string) error UpdateEncryptedUserEmail(int64, []byte) error GetUserByID(int64) (*User, error) @@ -179,7 +179,7 @@ func (db *datastore) dateSub(l int, unit string) string { } // CreateUser creates a new user in the database from the given User, UPDATING it in the process with the user's ID. -func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string) error { +func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string, collectionDesc string) error { if db.PostIDExists(u.Username) { return impart.HTTPError{http.StatusConflict, "Invalid collection name."} } @@ -213,7 +213,7 @@ func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle str if collectionTitle == "" { collectionTitle = u.Username } - res, err = t.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", u.Username, collectionTitle, "", defaultVisibility(cfg), u.ID, 0) + res, err = t.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", u.Username, collectionTitle, collectionDesc, defaultVisibility(cfg), u.ID, 0) if err != nil { t.Rollback() if db.isDuplicateKeyErr(err) { diff --git a/users.go b/users.go index add76cd..fe2f2c8 100644 --- a/users.go +++ b/users.go @@ -43,6 +43,9 @@ type ( Honeypot string `json:"fullname" schema:"fullname"` Normalize bool `json:"normalize" schema:"normalize"` Signup bool `json:"signup" schema:"signup"` + + // Feature fields + Description string `json:"description" schema:"description"` } // AuthUser contains information for a newly authenticated user (either From 9341784c0c062556d8b04e575b6f02583916d54c Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Mon, 7 Jun 2021 15:09:12 -0400 Subject: [PATCH 3/6] Fix OAuth signup with collection description --- oauth.go | 2 +- oauth_signup.go | 2 +- oauth_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauth.go b/oauth.go index e28e21a..2958721 100644 --- a/oauth.go +++ b/oauth.go @@ -99,7 +99,7 @@ type OAuthDatastore interface { ValidateOAuthState(context.Context, string) (string, string, int64, string, error) GenerateOAuthState(context.Context, string, string, int64, string) (string, error) - CreateUser(*config.Config, *User, string) error + CreateUser(*config.Config, *User, string, string) error GetUserByID(int64) (*User, error) } diff --git a/oauth_signup.go b/oauth_signup.go index b1256be..8dff416 100644 --- a/oauth_signup.go +++ b/oauth_signup.go @@ -127,7 +127,7 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R displayName = r.FormValue(oauthParamUsername) } - err = h.DB.CreateUser(h.Config, newUser, displayName) + err = h.DB.CreateUser(h.Config, newUser, displayName, "") if err != nil { return h.showOauthSignupPage(app, w, r, tp, err) } diff --git a/oauth_test.go b/oauth_test.go index 5694416..553c1cb 100644 --- a/oauth_test.go +++ b/oauth_test.go @@ -110,7 +110,7 @@ func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserI return -1, nil } -func (m *MockOAuthDatastore) CreateUser(cfg *config.Config, u *User, username string) error { +func (m *MockOAuthDatastore) CreateUser(cfg *config.Config, u *User, username, description string) error { if m.DoCreateUser != nil { return m.DoCreateUser(cfg, u, username) } From e42ba392c6f53e21ee56f32cd4abc5ee67ef4dc1 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Mon, 7 Jun 2021 15:52:24 -0400 Subject: [PATCH 4/6] Support Web Monetized split content Ref T770 --- account.go | 17 +++ collections.go | 11 ++ database.go | 16 ++- feed.go | 4 + handle.go | 52 +++++++++ less/core.less | 21 ++++ less/post-temp.less | 19 +++ monetization.go | 160 ++++++++++++++++++++++++++ postrender.go | 46 +++++++- posts.go | 44 ++++--- routes.go | 2 + static/img/paidarticle.svg | 78 +++++++++++++ static/js/webmonetization.js | 94 +++++++++++++++ templates/chorus-collection-post.tmpl | 9 ++ templates/collection-post.tmpl | 9 ++ templates/include/post-render.tmpl | 2 +- templates/include/posts.tmpl | 15 ++- users.go | 3 +- 18 files changed, 578 insertions(+), 24 deletions(-) create mode 100644 monetization.go create mode 100644 static/img/paidarticle.svg create mode 100644 static/js/webmonetization.js diff --git a/account.go b/account.go index 65d39c7..72d12ee 100644 --- a/account.go +++ b/account.go @@ -199,6 +199,23 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr }, } + var coll *Collection + if signup.Monetization != "" { + if coll == nil { + coll, err = app.db.GetCollection(signup.Alias) + if err != nil { + log.Error("Unable to get new collection '%s' for monetization on signup: %v", signup.Alias, err) + return nil, err + } + } + err = app.db.SetCollectionAttribute(coll.ID, "monetization_pointer", signup.Monetization) + if err != nil { + log.Error("Unable to add monetization on signup: %v", err) + return nil, err + } + coll.Monetization = signup.Monetization + } + var token string if reqJSON && !signup.Web { token, err = app.db.GetAccessToken(u.ID) diff --git a/collections.go b/collections.go index 30795d5..52fa089 100644 --- a/collections.go +++ b/collections.go @@ -353,6 +353,17 @@ func (c *Collection) RenderMathJax() bool { return c.db.CollectionHasAttribute(c.ID, "render_mathjax") } +func (c *Collection) MonetizationURL() string { + if c.Monetization == "" { + return "" + } + return strings.Replace(c.Monetization, "$", "https://", 1) +} + +func (c CollectionPage) DisplayMonetization() string { + return displayMonetization(c.Monetization, c.Alias) +} + func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) alias := r.FormValue("alias") diff --git a/database.go b/database.go index 7042b63..fefc3c1 100644 --- a/database.go +++ b/database.go @@ -813,6 +813,7 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll c.Signature = signature.String c.Format = format.String c.Public = c.IsPublic() + c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer") c.db = db @@ -1182,7 +1183,7 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu } p.extractData() p.augmentContent(c) - p.formatContent(cfg, c, includeFuture) + p.formatContent(cfg, c, includeFuture, false) posts = append(posts, p.processPost()) } @@ -1247,7 +1248,7 @@ func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag strin } p.extractData() p.augmentContent(c) - p.formatContent(cfg, c, includeFuture) + p.formatContent(cfg, c, includeFuture, false) posts = append(posts, p.processPost()) } @@ -1652,6 +1653,14 @@ func (db *datastore) GetCollections(u *User, hostName string) (*[]Collection, er c.URL = c.CanonicalURL() c.Public = c.IsPublic() + /* + // NOTE: future functionality + if visibility != nil { // TODO: && visibility == CollPublic { + // Add Monetization info when retrieving all public collections + c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer") + } + */ + colls = append(colls, c) } err = rows.Err() @@ -1698,6 +1707,9 @@ func (db *datastore) GetPublicCollections(hostName string) (*[]Collection, error c.URL = c.CanonicalURL() c.Public = c.IsPublic() + // Add Monetization information + c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer") + colls = append(colls, c) } err = rows.Err() diff --git a/feed.go b/feed.go index 3062e26..32c6591 100644 --- a/feed.go +++ b/feed.go @@ -97,6 +97,10 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error { var title, permalink string for _, p := range *coll.Posts { + // Add necessary path back to the web browser for Web Monetization if needed + p.Collection = coll.CollectionObj // augmentReadingDestination requires a populated Collection field + p.augmentReadingDestination() + // Create the item for the feed title = p.PlainDisplayTitle() permalink = fmt.Sprintf("%s%s", baseUrl, p.Slug.String) feed.Items = append(feed.Items, &Item{ diff --git a/handle.go b/handle.go index 4c454ec..1cbf114 100644 --- a/handle.go +++ b/handle.go @@ -574,6 +574,38 @@ func (h *Handler) All(f handlerFunc) http.HandlerFunc { } } +func (h *Handler) PlainTextAPI(f handlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h.handleTextError(w, r, func() error { + // TODO: return correct "success" status + status := 200 + start := time.Now() + + defer func() { + if e := recover(); e != nil { + log.Error("%s:\n%s", e, debug.Stack()) + status = http.StatusInternalServerError + w.WriteHeader(status) + fmt.Fprintf(w, "Something didn't work quite right. The robots have alerted the humans.") + } + + log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\" \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent(), r.Host)) + }() + + err := f(h.app.App(), w, r) + if err != nil { + if err, ok := err.(impart.HTTPError); ok { + status = err.Status + } else { + status = http.StatusInternalServerError + } + } + + return err + }()) + } +} + func (h *Handler) OAuth(f handlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h.handleOAuthError(w, r, func() error { @@ -842,6 +874,26 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error) h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r)) } +func (h *Handler) handleTextError(w http.ResponseWriter, r *http.Request, err error) { + if err == nil { + return + } + + if err, ok := err.(impart.HTTPError); ok { + if err.Status >= 300 && err.Status < 400 { + sendRedirect(w, err.Status, err.Message) + return + } + + w.WriteHeader(err.Status) + fmt.Fprintf(w, http.StatusText(err.Status)) + return + } + + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "This is an unhelpful error message for a miscellaneous internal error.") +} + func (h *Handler) handleOAuthError(w http.ResponseWriter, r *http.Request, err error) { if err == nil { return diff --git a/less/core.less b/less/core.less index 6401ceb..75a801b 100644 --- a/less/core.less +++ b/less/core.less @@ -393,6 +393,14 @@ body { } } +img { + &.paid { + height: 0.86em; + vertical-align: middle; + margin-bottom: 0.1em; + } +} + nav#full-nav { margin: 0; @@ -743,6 +751,19 @@ input, button, select.inputform, textarea.inputform, a.btn { } } +.btn.cta.secondary, input[type=submit].secondary { + background: transparent; + color: @primary; + &:hover { + background-color: #f9f9f9; + } +} + +.btn.cta.disabled { + background-color: desaturate(@primary, 100%) !important; + border-color: desaturate(@primary, 100%) !important; +} + div.flat-select { display: inline-block; position: relative; diff --git a/less/post-temp.less b/less/post-temp.less index 7ab5d92..aec7d26 100644 --- a/less/post-temp.less +++ b/less/post-temp.less @@ -37,6 +37,25 @@ body#post article, pre, .hljs { font-size: 1.2em; } +p.split { + color: #6161FF; + font-style: italic; + font-size: 0.86em; +} + +#readmore-sell { + padding: 1em 1em 2em; + background-color: #fafafa; + p.split { + color: black; + font-style: normal; + font-size: 1.4em; + } + .cta + .cta { + margin-left: 0.5em; + } +} + /* Post mixins */ .article-code() { background-color: #f8f8f8; diff --git a/monetization.go b/monetization.go new file mode 100644 index 0000000..92375c2 --- /dev/null +++ b/monetization.go @@ -0,0 +1,160 @@ +/* + * Copyright © 2020-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "bytes" + "fmt" + "github.com/gorilla/mux" + "github.com/writeas/impart" + "github.com/writeas/web-core/log" + "io/ioutil" + "net/http" + "net/url" + "os" + "strings" +) + +func displayMonetization(monetization, alias string) string { + if monetization == "" { + return "" + } + + ptrURL, err := url.Parse(strings.Replace(monetization, "$", "https://", 1)) + if err == nil { + if strings.HasSuffix(ptrURL.Host, ".xrptipbot.com") { + // xrp tip bot doesn't support stream receipts, so return plain pointer + return monetization + } + } + + u := os.Getenv("PAYMENT_HOST") + if u == "" { + return "$webmonetization.org/api/receipts/" + url.PathEscape(monetization) + } + u += "/" + alias + return u +} + +func handleSPSPEndpoint(app *App, w http.ResponseWriter, r *http.Request) error { + idStr := r.FormValue("id") + id, err := url.QueryUnescape(idStr) + if err != nil { + log.Error("Unable to unescape: %s", err) + return err + } + + var c *Collection + if strings.IndexRune(id, '.') > 0 && app.cfg.App.SingleUser { + c, err = app.db.GetCollectionByID(1) + } else { + c, err = app.db.GetCollection(id) + } + if err != nil { + return err + } + + pointer := c.Monetization + if pointer == "" { + err := impart.HTTPError{http.StatusNotFound, "No monetization pointer."} + return err + } + + fmt.Fprintf(w, pointer) + return nil +} + +func handleGetSplitContent(app *App, w http.ResponseWriter, r *http.Request) error { + var collID int64 + var collLookupID string + var coll *Collection + var err error + vars := mux.Vars(r) + if collAlias := vars["alias"]; collAlias != "" { + // Fetch collection information, since an alias is provided + coll, err = app.db.GetCollection(collAlias) + if err != nil { + return err + } + collID = coll.ID + collLookupID = coll.Alias + } + + p, err := app.db.GetPost(vars["post"], collID) + if err != nil { + return err + } + + receipt := r.FormValue("receipt") + if receipt == "" { + return impart.HTTPError{http.StatusBadRequest, "No `receipt` given."} + } + err = verifyReceipt(receipt, collLookupID) + if err != nil { + return err + } + + d := struct { + Content string `json:"body"` + HTMLContent string `json:"html_body"` + }{} + + if exc := strings.Index(p.Content, shortCodePaid); exc > -1 { + baseURL := "" + if coll != nil { + baseURL = coll.CanonicalURL() + } + + d.Content = p.Content[exc+len(shortCodePaid):] + d.HTMLContent = applyMarkdown([]byte(d.Content), baseURL, app.cfg) + } + + return impart.WriteSuccess(w, d, http.StatusOK) +} + +func verifyReceipt(receipt, id string) error { + receiptsHost := os.Getenv("RECEIPTS_HOST") + if receiptsHost == "" { + receiptsHost = "https://webmonetization.org/api/receipts/verify?id=" + id + } else { + receiptsHost = fmt.Sprintf("%s/receipts?id=%s", receiptsHost, id) + } + + log.Info("Verifying receipt %s at %s", receipt, receiptsHost) + r, err := http.NewRequest("POST", receiptsHost, bytes.NewBufferString(receipt)) + if err != nil { + log.Error("Unable to create new request to %s: %s", receiptsHost, err) + return err + } + + resp, err := http.DefaultClient.Do(r) + if err != nil { + log.Error("Unable to Do() request to %s: %s", receiptsHost, err) + return err + } + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Error("Unable to read %s response body: %s", receiptsHost, err) + return err + } + log.Info("Status : %s", resp.Status) + log.Info("Response: %s", body) + + if resp.StatusCode != http.StatusOK { + log.Error("Bad response from %s:\nStatus: %d\n%s", receiptsHost, resp.StatusCode, string(body)) + return impart.HTTPError{resp.StatusCode, string(body)} + } + return nil +} diff --git a/postrender.go b/postrender.go index 55d0cdf..8e71109 100644 --- a/postrender.go +++ b/postrender.go @@ -42,12 +42,46 @@ var ( mentionReg = regexp.MustCompile(`@([A-Za-z0-9._%+-]+)(@[A-Za-z0-9.-]+\.[A-Za-z]+)\b`) ) -func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool) { +func (p *Post) handlePremiumContent(c *Collection, isOwner, postPage bool, cfg *config.Config) { + if c.Monetization != "" { + // User has Web Monetization enabled, so split content if it exists + spl := strings.Index(p.Content, shortCodePaid) + p.IsPaid = spl > -1 + if postPage { + // We're viewing the individual post + if isOwner { + p.Content = strings.Replace(p.Content, shortCodePaid, "\n\n"+`

Your subscriber content begins here.

`+"\n\n", 1) + } else { + if spl > -1 { + p.Content = p.Content[:spl+len(shortCodePaid)] + p.Content = strings.Replace(p.Content, shortCodePaid, "\n\n"+`

Continue reading with a Coil membership.

`+"\n\n", 1) + } + } + } else { + // We've viewing the post on the collection landing + if spl > -1 { + baseURL := c.CanonicalURL() + if isOwner { + baseURL = "/" + c.Alias + "/" + } + + p.Content = p.Content[:spl+len(shortCodePaid)] + p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:spl]), baseURL, cfg)) + } + } + } +} + +func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool, isPostPage bool) { baseURL := c.CanonicalURL() // TODO: redundant if !isSingleUser { baseURL = "/" + c.Alias + "/" } + + p.handlePremiumContent(c, isOwner, isPostPage, cfg) + p.Content = strings.Replace(p.Content, "<!--paid-->", "", 1) + p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String))) p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL, cfg)) if exc := strings.Index(string(p.Content), ""); exc > -1 { @@ -55,8 +89,8 @@ func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool) { } } -func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool) { - p.Post.formatContent(cfg, &p.Collection.Collection, isOwner) +func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool, isPostPage bool) { + p.Post.formatContent(cfg, &p.Collection.Collection, isOwner, isPostPage) } func (p *Post) augmentContent(c *Collection) { @@ -78,6 +112,12 @@ func (p *PublicPost) augmentContent() { p.Post.augmentContent(&p.Collection.Collection) } +func (p *PublicPost) augmentReadingDestination() { + if p.IsPaid { + p.HTMLContent += template.HTML("\n\n" + `

` + localStr("Read more...", p.Language.String) + ` ($)

`) + } +} + func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string { return applyMarkdownSpecial(data, false, baseURL, cfg) } diff --git a/posts.go b/posts.go index f60c3af..6ceaaee 100644 --- a/posts.go +++ b/posts.go @@ -48,6 +48,8 @@ const ( postIDLen = 10 postMetaDateFormat = "2006-01-02 15:04:05" + + shortCodePaid = "" ) type ( @@ -109,6 +111,7 @@ type ( HTMLExcerpt template.HTML `db:"content" json:"-"` Tags []string `json:"tags"` Images []string `json:"images,omitempty"` + IsPaid bool `json:"paid"` OwnerName string `json:"owner,omitempty"` } @@ -129,6 +132,20 @@ type ( Collection *CollectionObj `json:"collection,omitempty"` } + CollectionPostPage struct { + *PublicPost + page.StaticPage + IsOwner bool + IsPinned bool + IsCustomDomain bool + Monetization string + PinnedPosts *[]PublicPost + IsFound bool + IsAdmin bool + CanInvite bool + Silenced bool + } + RawPost struct { Id, Slug string Title string @@ -269,6 +286,14 @@ func (p *Post) HasTitleLink() bool { return hasLink } +func (c CollectionPostPage) DisplayMonetization() string { + if c.Collection == nil { + log.Info("CollectionPostPage.DisplayMonetization: c.Collection is nil") + return "" + } + return displayMonetization(c.Monetization, c.Collection.Alias) +} + func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) friendlyID := vars["post"] @@ -1154,7 +1179,8 @@ func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object { o.Name = p.DisplayTitle() p.augmentContent() if p.HTMLContent == template.HTML("") { - p.formatContent(cfg, false) + p.formatContent(cfg, false, false) + p.augmentReadingDestination() } o.Content = string(p.HTMLContent) if p.Language.Valid { @@ -1502,20 +1528,8 @@ Are you sure it was ever here?`, p.extractData() p.Content = strings.Replace(p.Content, "", "", 1) // TODO: move this to function - p.formatContent(app.cfg, cr.isCollOwner) - tp := struct { - *PublicPost - page.StaticPage - IsOwner bool - IsPinned bool - IsCustomDomain bool - Monetization string - PinnedPosts *[]PublicPost - IsFound bool - IsAdmin bool - CanInvite bool - Silenced bool - }{ + p.formatContent(app.cfg, cr.isCollOwner, true) + tp := CollectionPostPage{ PublicPost: p, StaticPage: pageForReq(app, r), IsOwner: cr.isCollOwner, diff --git a/routes.go b/routes.go index 1244e97..213958d 100644 --- a/routes.go +++ b/routes.go @@ -134,6 +134,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { // Handle collections write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST") apiColls := write.PathPrefix("/api/collections/").Subrouter() + apiColls.HandleFunc("/monetization-pointer", handler.PlainTextAPI(handleSPSPEndpoint)).Methods("GET") apiColls.HandleFunc("/"+host, handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE") @@ -141,6 +142,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET") apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST") + apiColls.HandleFunc("/{alias}/posts/{post}/splitcontent", handler.AllReader(handleGetSplitContent)).Methods("GET", "POST") apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST") apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST") diff --git a/static/img/paidarticle.svg b/static/img/paidarticle.svg new file mode 100644 index 0000000..788e208 --- /dev/null +++ b/static/img/paidarticle.svg @@ -0,0 +1,78 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/static/js/webmonetization.js b/static/js/webmonetization.js new file mode 100644 index 0000000..bbd828c --- /dev/null +++ b/static/js/webmonetization.js @@ -0,0 +1,94 @@ +/* + * Copyright © 2020-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +let unlockingSplitContent = false; +let unlockedSplitContent = false; +let pendingSplitContent = false; + +function showWMPaywall($content, $split) { + let $readmoreSell = document.createElement('div') + $readmoreSell.id = 'readmore-sell'; + $content.insertAdjacentElement('beforeend', $readmoreSell); + $readmoreSell.appendChild($split); + $readmoreSell.insertAdjacentHTML("beforeend", '\n\n

For $5 per month, you can read this and other great writing across our site and other websites that support Web Monetization.

') + $readmoreSell.insertAdjacentHTML("beforeend", '\n\n

Get started Learn more

') +} + +function initMonetization() { + let $content = document.querySelector('.e-content') + let $post = document.getElementById('post-body') + let $split = $post.querySelector('.split') + if (document.monetization === undefined || $split == null) { + if ($split) { + showWMPaywall($content, $split) + } + return + } + + document.monetization.addEventListener('monetizationstop', function(event) { + if (pendingSplitContent) { + // We've seen the 'pending' activity, so we can assume things will work + document.monetization.removeEventListener('monetizationstop', progressHandler) + return + } + + // We're getting 'stop' without ever starting, so display the paywall. + showWMPaywall($content, $split) + }); + + document.monetization.addEventListener('monetizationpending', function (event) { + pendingSplitContent = true + }) + + let progressHandler = function(event) { + if (unlockedSplitContent) { + document.monetization.removeEventListener('monetizationprogress', progressHandler) + return + } + if (!unlockingSplitContent && !unlockedSplitContent) { + unlockingSplitContent = true + getSplitContent(event.detail.receipt, function (status, data) { + unlockingSplitContent = false + if (status == 200) { + $split.textContent = "Your subscriber perks start here." + $split.insertAdjacentHTML("afterend", "\n\n"+data.data.html_body) + } else { + $split.textContent = "Something went wrong while unlocking subscriber content." + } + unlockedSplitContent = true + }) + } + } + + function getSplitContent(receipt, callback) { + let params = "receipt="+encodeURIComponent(receipt) + + let http = new XMLHttpRequest(); + http.open("POST", "/api/collections/" + window.collAlias + "/posts/" + window.postSlug + "/splitcontent", true); + + // Send the proper header information along with the request + http.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + + http.onreadystatechange = function () { + if (http.readyState == 4) { + callback(http.status, JSON.parse(http.responseText)); + } + } + http.send(params); + } + + document.monetization.addEventListener('monetizationstart', function() { + if (!unlockedSplitContent) { + $split.textContent = "Unlocking subscriber content..." + } + document.monetization.removeEventListener('monetizationstart', progressHandler) + }); + document.monetization.addEventListener('monetizationprogress', progressHandler); +} \ No newline at end of file diff --git a/templates/chorus-collection-post.tmpl b/templates/chorus-collection-post.tmpl index 22f2d8f..537e4ef 100644 --- a/templates/chorus-collection-post.tmpl +++ b/templates/chorus-collection-post.tmpl @@ -142,4 +142,13 @@ function unpinPost(e, postID) { })(); } catch (e) { /* ¯\_(ツ)_/¯ */ } + + {{if and .Monetization (not .IsOwner)}} + + + {{end}} {{end}} diff --git a/templates/collection-post.tmpl b/templates/collection-post.tmpl index bba2936..5d56abd 100644 --- a/templates/collection-post.tmpl +++ b/templates/collection-post.tmpl @@ -132,4 +132,13 @@ function unpinPost(e, postID) { })(); } catch (e) { /* ¯\_(ツ)_/¯ */ } + + {{if and .Monetization (not .IsOwner)}} + + + {{end}} {{end}} diff --git a/templates/include/post-render.tmpl b/templates/include/post-render.tmpl index beb98aa..5b84845 100644 --- a/templates/include/post-render.tmpl +++ b/templates/include/post-render.tmpl @@ -1,7 +1,7 @@ {{define "collection-meta"}} {{if .Monetization -}} - + {{- end}} {{end}} diff --git a/templates/include/posts.tmpl b/templates/include/posts.tmpl index b1ccbf2..c3401fa 100644 --- a/templates/include/posts.tmpl +++ b/templates/include/posts.tmpl @@ -1,7 +1,13 @@ {{ define "posts" }} {{ range $el := .Posts }}
{{if .IsScheduled}}

Scheduled

{{end}} - {{if .Title.String}}

{{if .HasTitleLink}}{{.HTMLTitle}} {{else}}{{end}} + {{if .Title.String}}

+ {{- if .HasTitleLink -}} + {{.HTMLTitle}} + {{- else -}} + {{- if .IsPaid}}{{template "paid-badge" .}}{{end -}} + + {{- end}} {{if $.IsOwner}} {{if $.CanPin}}{{end}} @@ -24,7 +30,10 @@ {{if $.Format.ShowDates}}{{end}} {{else}}

- {{if $.Format.ShowDates}}{{end}} + {{if $.Format.ShowDates -}} + {{- if .IsPaid}}{{template "paid-badge" .}}{{end -}} + + {{- end}} {{if $.IsOwner}} {{if not $.Format.ShowDates}}{{end}} @@ -51,3 +60,5 @@ {{localstr "Read more..." .Language.String}}{{else}}
{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}{{end}}{{.HTMLContent}}
{{end}}

{{ end }} {{ end }} + +{{define "paid-badge"}} {{end}} \ No newline at end of file diff --git a/users.go b/users.go index fe2f2c8..cc6764f 100644 --- a/users.go +++ b/users.go @@ -45,7 +45,8 @@ type ( Signup bool `json:"signup" schema:"signup"` // Feature fields - Description string `json:"description" schema:"description"` + Description string `json:"description" schema:"description"` + Monetization string `json:"monetization" schema:"monetization"` } // AuthUser contains information for a newly authenticated user (either From c05f7056c4ea483cdb33f93d1d5d0e40df710dbd Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Wed, 9 Jun 2021 10:04:28 -0400 Subject: [PATCH 5/6] Fix collection rendering in Chorus mode --- collections.go | 4 ++++ posts.go | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/collections.go b/collections.go index 52fa089..f79cc2d 100644 --- a/collections.go +++ b/collections.go @@ -582,6 +582,9 @@ type CollectionPage struct { PinnedPosts *[]PublicPost IsAdmin bool CanInvite bool + + // Helper field for Chorus mode + CollAlias string } func NewCollectionObj(c *Collection) *CollectionObj { @@ -818,6 +821,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, IsWelcome: r.FormValue("greeting") != "", + CollAlias: c.Alias, } displayPage.IsAdmin = u != nil && u.IsAdmin() displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin) diff --git a/posts.go b/posts.go index 6ceaaee..4d8d019 100644 --- a/posts.go +++ b/posts.go @@ -144,6 +144,9 @@ type ( IsAdmin bool CanInvite bool Silenced bool + + // Helper field for Chorus mode + CollAlias string } RawPost struct { @@ -1536,6 +1539,7 @@ Are you sure it was ever here?`, IsCustomDomain: cr.isCustomDomain, IsFound: postFound, Silenced: silenced, + CollAlias: c.Alias, } tp.IsAdmin = u != nil && u.IsAdmin() tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) @@ -1551,7 +1555,7 @@ Are you sure it was ever here?`, postTmpl = "chorus-collection-post" } if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil { - log.Error("Error in collection-post template: %v", err) + log.Error("Error in %s template: %v", postTmpl, err) } } From 42db4b38f6993f687f292d56e1ee7723438fc82f Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Wed, 9 Jun 2021 11:09:53 -0400 Subject: [PATCH 6/6] Truncate paid posts and show badge on Reader --- read.go | 6 ++++-- templates.go | 2 +- templates/read.tmpl | 17 ++++++++++++----- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/read.go b/read.go index 7fb700e..35a671f 100644 --- a/read.go +++ b/read.go @@ -74,7 +74,7 @@ func (app *App) FetchPublicPosts() (interface{}, error) { // ageCond := `p.created >= ` + app.db.dateSub(3, "month") + ` AND ` // 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 := app.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 + rows, err := app.db.Query(`SELECT p.id, c.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 LEFT JOIN users u ON u.id = p.owner_id @@ -94,7 +94,7 @@ func (app *App) FetchPublicPosts() (interface{}, error) { 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) + err = rows.Scan(&p.ID, &c.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 @@ -111,9 +111,11 @@ func (app *App) FetchPublicPosts() (interface{}, error) { c.Public = true c.Title = title.String + c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer") } p.extractData() + p.handlePremiumContent(c, false, false, app.cfg) p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), "", app.cfg)) fp := p.processPost() if isCollectionPost { diff --git a/templates.go b/templates.go index 3871258..e0c728e 100644 --- a/templates.go +++ b/templates.go @@ -71,7 +71,7 @@ func initTemplate(parentDir, name string) { filepath.Join(parentDir, templatesDir, "base.tmpl"), filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"), } - if name == "collection" || name == "collection-tags" || name == "chorus-collection" { + if name == "collection" || name == "collection-tags" || name == "chorus-collection" || name == "read" { // These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl" files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl")) } diff --git a/templates/read.tmpl b/templates/read.tmpl index fe42f7b..c032970 100644 --- a/templates/read.tmpl +++ b/templates/read.tmpl @@ -90,11 +90,18 @@ {{ if gt (len .Posts) 0 }}
{{range .Posts}}
- {{if .Title.String}}

- - {{else}} -

- {{end}} + {{if .Title.String -}} +

+ {{- if .IsPaid}}{{template "paid-badge" .}}{{end -}} + +

+ + {{- else -}} +

+ {{- if .IsPaid}}{{template "paid-badge" .}}{{end -}} + +

+ {{- end}}

{{if .Collection}}from {{.Collection.DisplayTitle}}{{else}}Anonymous{{end}}

{{if .Excerpt}}
{{.Excerpt}}