Merge pull request #463 from writefreely/wm-fix

Web Monetization fixes + exclusive content
pull/474/head
Matt Baer 4 years ago committed by GitHub
commit e7245536f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 20
      account.go
  2. 4
      app.go
  3. 15
      collections.go
  4. 22
      database.go
  5. 4
      feed.go
  6. 52
      handle.go
  7. 21
      less/core.less
  8. 19
      less/post-temp.less
  9. 160
      monetization.go
  10. 2
      oauth.go
  11. 2
      oauth_signup.go
  12. 2
      oauth_test.go
  13. 46
      postrender.go
  14. 50
      posts.go
  15. 6
      read.go
  16. 2
      routes.go
  17. 78
      static/img/paidarticle.svg
  18. 94
      static/js/webmonetization.js
  19. 2
      templates.go
  20. 9
      templates/chorus-collection-post.tmpl
  21. 9
      templates/collection-post.tmpl
  22. 2
      templates/include/post-render.tmpl
  23. 15
      templates/include/posts.tmpl
  24. 15
      templates/read.tmpl
  25. 4
      templates/user/collection.tmpl
  26. 4
      users.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
}
@ -195,9 +195,27 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
{
Alias: signup.Alias,
Title: title,
Description: signup.Description,
},
}
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)

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

@ -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")
@ -571,6 +582,9 @@ type CollectionPage struct {
PinnedPosts *[]PublicPost
IsAdmin bool
CanInvite bool
// Helper field for Chorus mode
CollAlias string
}
func NewCollectionObj(c *Collection) *CollectionObj {
@ -807,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)

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

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

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

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

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

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

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

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

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

@ -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"+`<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()
// TODO: redundant
if !isSingleUser {
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.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL, cfg))
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) {
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" + `<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 {
return applyMarkdownSpecial(data, false, baseURL, cfg)
}

@ -48,6 +48,8 @@ const (
postIDLen = 10
postMetaDateFormat = "2006-01-02 15:04:05"
shortCodePaid = "<!--paid-->"
)
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,23 @@ 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
// Helper field for Chorus mode
CollAlias string
}
RawPost struct {
Id, Slug string
Title string
@ -269,6 +289,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 +1182,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,26 +1531,15 @@ Are you sure it was ever here?`,
p.extractData()
p.Content = strings.Replace(p.Content, "<!--more-->", "", 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,
IsCustomDomain: cr.isCustomDomain,
IsFound: postFound,
Silenced: silenced,
CollAlias: c.Alias,
}
tp.IsAdmin = u != nil && u.IsAdmin()
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
@ -1537,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)
}
}

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

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

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

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

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

@ -142,4 +142,13 @@ function unpinPost(e, postID) {
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
</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}}

@ -132,4 +132,13 @@ function unpinPost(e, postID) {
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
</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}}

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

@ -1,7 +1,13 @@
{{ define "posts" }}
{{ 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 .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}}
<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}}
@ -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}}
{{else}}
<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 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>
@ -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 }}
{{ end }}
{{define "paid-badge"}}<img class="paid" alt="Paid article" src="/img/paidarticle.svg" /> {{end}}

@ -90,11 +90,18 @@
{{ 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 .Host}}.md{{end}}" itemprop="url" class="u-url">{{.PlainDisplayTitle}}</a></h2>
{{if .Title.String -}}
<h2 class="post-title" itemprop="name" class="p-name">
{{- if .IsPaid}}{{template "paid-badge" .}}{{end -}}
<a href="{{if .Slug.String}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}" itemprop="url" class="u-url">{{.PlainDisplayTitle}}</a>
</h2>
<time class="dt-published" datetime="{{.Created8601}}" 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="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time></h2>
{{end}}
{{- else -}}
<h2 class="post-title" itemprop="name">
{{- if .IsPaid}}{{template "paid-badge" .}}{{end -}}
<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.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>

@ -157,12 +157,12 @@ textarea.section.norm {
</div>
</div>
{{if .Monetization}}
{{if .UserPage.StaticPage.AppCfg.Monetization}}
<div class="option">
<h2>Web Monetization</h2>
<div class="section">
<p class="explain">Web Monetization enables you to receive micropayments from readers that have a <a href="https://coil.com">Coil membership</a>. Add your payment pointer to enable Web Monetization on your blog.</p>
<input type="text" name="monetization_pointer" style="width:100%" value="{{.MonetizationPointer}}" placeholder="$wallet.example.com/alice" />
<input type="text" name="monetization_pointer" style="width:100%" value="{{.Collection.Monetization}}" placeholder="$wallet.example.com/alice" />
</div>
</div>
{{end}}

@ -43,6 +43,10 @@ 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"`
Monetization string `json:"monetization" schema:"monetization"`
}
// AuthUser contains information for a newly authenticated user (either

Loading…
Cancel
Save