Support Web Monetized split content

Ref T770
This commit is contained in:
Matt Baer 2021-06-07 15:52:24 -04:00
parent 9341784c0c
commit e42ba392c6
18 changed files with 578 additions and 24 deletions

View file

@ -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 var token string
if reqJSON && !signup.Web { if reqJSON && !signup.Web {
token, err = app.db.GetAccessToken(u.ID) token, err = app.db.GetAccessToken(u.ID)

View file

@ -353,6 +353,17 @@ func (c *Collection) RenderMathJax() bool {
return c.db.CollectionHasAttribute(c.ID, "render_mathjax") 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 { func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r) reqJSON := IsJSON(r)
alias := r.FormValue("alias") alias := r.FormValue("alias")

View file

@ -813,6 +813,7 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll
c.Signature = signature.String c.Signature = signature.String
c.Format = format.String c.Format = format.String
c.Public = c.IsPublic() c.Public = c.IsPublic()
c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer")
c.db = db c.db = db
@ -1182,7 +1183,7 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
} }
p.extractData() p.extractData()
p.augmentContent(c) p.augmentContent(c)
p.formatContent(cfg, c, includeFuture) p.formatContent(cfg, c, includeFuture, false)
posts = append(posts, p.processPost()) posts = append(posts, p.processPost())
} }
@ -1247,7 +1248,7 @@ func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag strin
} }
p.extractData() p.extractData()
p.augmentContent(c) p.augmentContent(c)
p.formatContent(cfg, c, includeFuture) p.formatContent(cfg, c, includeFuture, false)
posts = append(posts, p.processPost()) posts = append(posts, p.processPost())
} }
@ -1652,6 +1653,14 @@ func (db *datastore) GetCollections(u *User, hostName string) (*[]Collection, er
c.URL = c.CanonicalURL() c.URL = c.CanonicalURL()
c.Public = c.IsPublic() 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) colls = append(colls, c)
} }
err = rows.Err() err = rows.Err()
@ -1698,6 +1707,9 @@ func (db *datastore) GetPublicCollections(hostName string) (*[]Collection, error
c.URL = c.CanonicalURL() c.URL = c.CanonicalURL()
c.Public = c.IsPublic() c.Public = c.IsPublic()
// Add Monetization information
c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer")
colls = append(colls, c) colls = append(colls, c)
} }
err = rows.Err() err = rows.Err()

View file

@ -97,6 +97,10 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
var title, permalink string var title, permalink string
for _, p := range *coll.Posts { 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() title = p.PlainDisplayTitle()
permalink = fmt.Sprintf("%s%s", baseUrl, p.Slug.String) permalink = fmt.Sprintf("%s%s", baseUrl, p.Slug.String)
feed.Items = append(feed.Items, &Item{ feed.Items = append(feed.Items, &Item{

View file

@ -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 { func (h *Handler) OAuth(f handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
h.handleOAuthError(w, r, func() error { 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)) 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) { func (h *Handler) handleOAuthError(w http.ResponseWriter, r *http.Request, err error) {
if err == nil { if err == nil {
return return

View file

@ -393,6 +393,14 @@ body {
} }
} }
img {
&.paid {
height: 0.86em;
vertical-align: middle;
margin-bottom: 0.1em;
}
}
nav#full-nav { nav#full-nav {
margin: 0; 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 { div.flat-select {
display: inline-block; display: inline-block;
position: relative; position: relative;

View file

@ -37,6 +37,25 @@ body#post article, pre, .hljs {
font-size: 1.2em; 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 */ /* Post mixins */
.article-code() { .article-code() {
background-color: #f8f8f8; background-color: #f8f8f8;

160
monetization.go Normal file
View file

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

View file

@ -42,12 +42,46 @@ var (
mentionReg = regexp.MustCompile(`@([A-Za-z0-9._%+-]+)(@[A-Za-z0-9.-]+\.[A-Za-z]+)\b`) 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"+`<p class="split">Your subscriber content begins here.</p>`+"\n\n", 1)
} else {
if spl > -1 {
p.Content = p.Content[:spl+len(shortCodePaid)]
p.Content = strings.Replace(p.Content, shortCodePaid, "\n\n"+`<p class="split">Continue reading with a <strong>Coil</strong> membership.</p>`+"\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() baseURL := c.CanonicalURL()
// TODO: redundant // TODO: redundant
if !isSingleUser { if !isSingleUser {
baseURL = "/" + c.Alias + "/" baseURL = "/" + c.Alias + "/"
} }
p.handlePremiumContent(c, isOwner, isPostPage, cfg)
p.Content = strings.Replace(p.Content, "&lt;!--paid-->", "<!--paid-->", 1)
p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String))) p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String)))
p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL, cfg)) p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL, cfg))
if exc := strings.Index(string(p.Content), "<!--more-->"); exc > -1 { if exc := strings.Index(string(p.Content), "<!--more-->"); 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) { func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool, isPostPage bool) {
p.Post.formatContent(cfg, &p.Collection.Collection, isOwner) p.Post.formatContent(cfg, &p.Collection.Collection, isOwner, isPostPage)
} }
func (p *Post) augmentContent(c *Collection) { func (p *Post) augmentContent(c *Collection) {
@ -78,6 +112,12 @@ func (p *PublicPost) augmentContent() {
p.Post.augmentContent(&p.Collection.Collection) p.Post.augmentContent(&p.Collection.Collection)
} }
func (p *PublicPost) augmentReadingDestination() {
if p.IsPaid {
p.HTMLContent += template.HTML("\n\n" + `<p><a class="read-more" href="` + p.Collection.CanonicalURL() + p.Slug.String + `">` + localStr("Read more...", p.Language.String) + `</a> ($)</p>`)
}
}
func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string { func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
return applyMarkdownSpecial(data, false, baseURL, cfg) return applyMarkdownSpecial(data, false, baseURL, cfg)
} }

View file

@ -48,6 +48,8 @@ const (
postIDLen = 10 postIDLen = 10
postMetaDateFormat = "2006-01-02 15:04:05" postMetaDateFormat = "2006-01-02 15:04:05"
shortCodePaid = "<!--paid-->"
) )
type ( type (
@ -109,6 +111,7 @@ type (
HTMLExcerpt template.HTML `db:"content" json:"-"` HTMLExcerpt template.HTML `db:"content" json:"-"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Images []string `json:"images,omitempty"` Images []string `json:"images,omitempty"`
IsPaid bool `json:"paid"`
OwnerName string `json:"owner,omitempty"` OwnerName string `json:"owner,omitempty"`
} }
@ -129,6 +132,20 @@ type (
Collection *CollectionObj `json:"collection,omitempty"` 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 { RawPost struct {
Id, Slug string Id, Slug string
Title string Title string
@ -269,6 +286,14 @@ func (p *Post) HasTitleLink() bool {
return hasLink 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 { func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r) vars := mux.Vars(r)
friendlyID := vars["post"] friendlyID := vars["post"]
@ -1154,7 +1179,8 @@ func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
o.Name = p.DisplayTitle() o.Name = p.DisplayTitle()
p.augmentContent() p.augmentContent()
if p.HTMLContent == template.HTML("") { if p.HTMLContent == template.HTML("") {
p.formatContent(cfg, false) p.formatContent(cfg, false, false)
p.augmentReadingDestination()
} }
o.Content = string(p.HTMLContent) o.Content = string(p.HTMLContent)
if p.Language.Valid { if p.Language.Valid {
@ -1502,20 +1528,8 @@ Are you sure it was ever here?`,
p.extractData() p.extractData()
p.Content = strings.Replace(p.Content, "<!--more-->", "", 1) p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
// TODO: move this to function // TODO: move this to function
p.formatContent(app.cfg, cr.isCollOwner) p.formatContent(app.cfg, cr.isCollOwner, true)
tp := struct { tp := CollectionPostPage{
*PublicPost
page.StaticPage
IsOwner bool
IsPinned bool
IsCustomDomain bool
Monetization string
PinnedPosts *[]PublicPost
IsFound bool
IsAdmin bool
CanInvite bool
Silenced bool
}{
PublicPost: p, PublicPost: p,
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
IsOwner: cr.isCollOwner, IsOwner: cr.isCollOwner,

View file

@ -134,6 +134,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
// Handle collections // Handle collections
write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST") write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
apiColls := write.PathPrefix("/api/collections/").Subrouter() 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("/"+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.AllReader(fetchCollection)).Methods("GET")
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE") 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", handler.All(newPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET") 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:[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}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST") apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="15mm"
height="15mm"
viewBox="0 0 15 15"
version="1.1"
id="svg8"
sodipodi:docname="paidarticle.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="3.959798"
inkscape:cx="32.178691"
inkscape:cy="9.7652796"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="1280"
inkscape:window-height="720"
inkscape:window-x="0"
inkscape:window-y="26"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-97.650089,-94.91132)">
<circle
style="opacity:0.83300003;fill:#72bf85;fill-opacity:1;stroke:none;stroke-width:1.14235425;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path819"
cx="105.15009"
cy="102.41132"
r="7.5" />
<g
aria-label="$"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:10.58333302px;line-height:125%;font-family:'Open Sans';-inkscape-font-specification:'Open Sans Bold';letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="text817"
transform="translate(0.15009808,-0.15009445)">
<path
d="m 107.59415,103.90759 q 0,0.82166 -0.59427,1.32292 -0.59428,0.49609 -1.66399,0.59428 v 1.05936 h -0.70796 v -1.03869 q -1.26091,-0.0258 -2.21175,-0.44442 v -1.36426 q 0.44958,0.22221 1.08003,0.39274 0.63562,0.17053 1.13172,0.20154 v -1.60197 l -0.34624,-0.13436 q -1.02319,-0.40307 -1.4521,-0.87333 -0.42375,-0.47542 -0.42375,-1.17305 0,-0.74931 0.58394,-1.229903 0.58912,-0.485759 1.63815,-0.589112 v -0.790649 h 0.70796 v 0.769979 q 1.18339,0.05168 2.13941,0.475423 l -0.48576,1.209232 q -0.80615,-0.33073 -1.65365,-0.40308 v 1.52445 q 1.00769,0.38758 1.43144,0.6718 0.42892,0.28422 0.62529,0.62528 0.20153,0.34107 0.20153,0.79582 z m -1.55546,0.0775 q 0,-0.21704 -0.1757,-0.3669 -0.1757,-0.14986 -0.5271,-0.31006 v 1.28675 q 0.7028,-0.11886 0.7028,-0.60979 z m -2.07739,-3.13675 q 0,0.22737 0.15503,0.37723 0.1602,0.1447 0.5116,0.29973 v -1.2144 q -0.66663,0.0982 -0.66663,0.53744 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:'Open Sans';-inkscape-font-specification:'Open Sans Bold';fill:#ffffff;fill-opacity:1;stroke-width:0.26458332px"
id="path821"
inkscape:connector-curvature="0" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -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<p class="font sans">For <strong>$5 per month</strong>, you can read this and other great writing across our site and other websites that support Web Monetization.</p>')
$readmoreSell.insertAdjacentHTML("beforeend", '\n\n<p class="font sans"><a href="https://coil.com/signup?ref=writefreely" class="btn cta" target="coil">Get started</a> <a href="https://coil.com/?ref=writefreely" class="btn cta secondary">Learn more</a></p>')
}
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);
}

View file

@ -142,4 +142,13 @@ function unpinPost(e, postID) {
})(); })();
} catch (e) { /* ¯\_(ツ)_/¯ */ } } catch (e) { /* ¯\_(ツ)_/¯ */ }
</script> </script>
{{if and .Monetization (not .IsOwner)}}
<script src="/js/webmonetization.js"></script>
<script>
window.collAlias = '{{.Collection.Alias}}'
window.postSlug = '{{.Slug.String}}'
initMonetization()
</script>
{{end}}
</html>{{end}} </html>{{end}}

View file

@ -132,4 +132,13 @@ function unpinPost(e, postID) {
})(); })();
} catch (e) { /* ¯\_(ツ)_/¯ */ } } catch (e) { /* ¯\_(ツ)_/¯ */ }
</script> </script>
{{if and .Monetization (not .IsOwner)}}
<script src="/js/webmonetization.js"></script>
<script>
window.collAlias = '{{.Collection.Alias}}'
window.postSlug = '{{.Slug.String}}'
initMonetization()
</script>
{{end}}
</html>{{end}} </html>{{end}}

View file

@ -1,7 +1,7 @@
<!-- Miscelaneous render related template parts we use multiple times --> <!-- Miscelaneous render related template parts we use multiple times -->
{{define "collection-meta"}} {{define "collection-meta"}}
{{if .Monetization -}} {{if .Monetization -}}
<meta name="monetization" content="{{.Monetization}}" /> <meta name="monetization" content="{{.DisplayMonetization}}" />
{{- end}} {{- end}}
{{end}} {{end}}

View file

@ -1,7 +1,13 @@
{{ define "posts" }} {{ define "posts" }}
{{ range $el := .Posts }}<article id="post-{{.ID}}" class="{{.Font}} h-entry" itemscope itemtype="http://schema.org/BlogPosting"> {{ range $el := .Posts }}<article id="post-{{.ID}}" class="{{.Font}} h-entry" itemscope itemtype="http://schema.org/BlogPosting">
{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}} {{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}
{{if .Title.String}}<h2 class="post-title" itemprop="name" class="p-name">{{if .HasTitleLink}}{{.HTMLTitle}} <a class="user hidden action" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{else}}<a href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}" itemprop="url" class="u-url">{{.HTMLTitle}}</a>{{end}} {{if .Title.String}}<h2 class="post-title" itemprop="name" class="p-name">
{{- if .HasTitleLink -}}
{{.HTMLTitle}} <a class="user hidden action" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view{{if .IsPaid}} {{template "paid-badge" .}}{{end}}</a>
{{- else -}}
{{- if .IsPaid}}{{template "paid-badge" .}}{{end -}}
<a href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}" itemprop="url" class="u-url">{{.HTMLTitle}}</a>
{{- end}}
{{if $.IsOwner}} {{if $.IsOwner}}
<a class="user hidden action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/edit">edit</a> <a class="user hidden action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/edit">edit</a>
{{if $.CanPin}}<a class="user hidden pin action" href="/{{$.Alias}}/{{.Slug.String}}/pin" onclick="pinPost(event, '{{.ID}}', '{{.Slug.String}}', '{{.PlainDisplayTitle}}')">pin</a>{{end}} {{if $.CanPin}}<a class="user hidden pin action" href="/{{$.Alias}}/{{.Slug.String}}/pin" onclick="pinPost(event, '{{.ID}}', '{{.Slug.String}}', '{{.PlainDisplayTitle}}')">pin</a>{{end}}
@ -24,7 +30,10 @@
{{if $.Format.ShowDates}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{$.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time>{{end}} {{if $.Format.ShowDates}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{$.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time>{{end}}
{{else}} {{else}}
<h2 class="post-title" itemprop="name"> <h2 class="post-title" itemprop="name">
{{if $.Format.ShowDates}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time>{{end}} {{if $.Format.ShowDates -}}
{{- if .IsPaid}}{{template "paid-badge" .}}{{end -}}
<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time>
{{- end}}
{{if $.IsOwner}} {{if $.IsOwner}}
{{if not $.Format.ShowDates}}<a class="user hidden action" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}} {{if not $.Format.ShowDates}}<a class="user hidden action" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}}
<a class="user hidden action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/edit">edit</a> <a class="user hidden action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/edit">edit</a>
@ -51,3 +60,5 @@
<a class="read-more" href="{{$.CanonicalURL}}{{.Slug.String}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}" class="book e-content">{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}<a class="hidden action" href="{{if $.IsOwner}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}}{{.HTMLContent}}</div>{{end}}</article>{{ end }} <a class="read-more" href="{{$.CanonicalURL}}{{.Slug.String}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}" class="book e-content">{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}<a class="hidden action" href="{{if $.IsOwner}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}}{{.HTMLContent}}</div>{{end}}</article>{{ end }}
{{ end }} {{ end }}
{{define "paid-badge"}}<img class="paid" alt="Paid article" src="/img/paidarticle.svg" /> {{end}}

View file

@ -46,6 +46,7 @@ type (
// Feature fields // 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 // AuthUser contains information for a newly authenticated user (either