Support Web Monetizationpull/402/head
@@ -826,6 +826,9 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques | |||||
return ErrCollectionNotFound | return ErrCollectionNotFound | ||||
} | } | ||||
// Add collection properties | |||||
c.MonetizationPointer = app.db.GetCollectionAttribute(c.ID, "monetization_pointer") | |||||
silenced, err := app.db.IsUserSilenced(u.ID) | silenced, err := app.db.IsUserSilenced(u.ID) | ||||
if err != nil { | if err != nil { | ||||
log.Error("view edit collection %v", err) | log.Error("view edit collection %v", err) | ||||
@@ -529,6 +529,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt | |||||
} | } | ||||
apper.App().cfg.App.Federation = r.FormValue("federation") == "on" | apper.App().cfg.App.Federation = r.FormValue("federation") == "on" | ||||
apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on" | apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on" | ||||
apper.App().cfg.App.Monetization = r.FormValue("monetization") == "on" | |||||
apper.App().cfg.App.Private = r.FormValue("private") == "on" | apper.App().cfg.App.Private = r.FormValue("private") == "on" | ||||
apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on" | apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on" | ||||
if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil { | if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil { | ||||
@@ -56,6 +56,8 @@ type ( | |||||
PublicOwner bool `datastore:"public_owner" json:"-"` | PublicOwner bool `datastore:"public_owner" json:"-"` | ||||
URL string `json:"url,omitempty"` | URL string `json:"url,omitempty"` | ||||
MonetizationPointer string `json:"monetization_pointer,omitempty"` | |||||
db *datastore | db *datastore | ||||
hostName string | hostName string | ||||
} | } | ||||
@@ -87,14 +89,15 @@ type ( | |||||
Handle string `schema:"handle" json:"handle"` | Handle string `schema:"handle" json:"handle"` | ||||
// Actual collection values updated in the DB | // Actual collection values updated in the DB | ||||
Alias *string `schema:"alias" json:"alias"` | |||||
Title *string `schema:"title" json:"title"` | |||||
Description *string `schema:"description" json:"description"` | |||||
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` | |||||
Script *sql.NullString `schema:"script" json:"script"` | |||||
Signature *sql.NullString `schema:"signature" json:"signature"` | |||||
Visibility *int `schema:"visibility" json:"public"` | |||||
Format *sql.NullString `schema:"format" json:"format"` | |||||
Alias *string `schema:"alias" json:"alias"` | |||||
Title *string `schema:"title" json:"title"` | |||||
Description *string `schema:"description" json:"description"` | |||||
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` | |||||
Script *sql.NullString `schema:"script" json:"script"` | |||||
Signature *sql.NullString `schema:"signature" json:"signature"` | |||||
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"` | |||||
Visibility *int `schema:"visibility" json:"public"` | |||||
Format *sql.NullString `schema:"format" json:"format"` | |||||
} | } | ||||
CollectionFormat struct { | CollectionFormat struct { | ||||
Format string | Format string | ||||
@@ -552,6 +555,7 @@ type CollectionPage struct { | |||||
IsOwner bool | IsOwner bool | ||||
CanPin bool | CanPin bool | ||||
Username string | Username string | ||||
Monetization string | |||||
Collections *[]Collection | Collections *[]Collection | ||||
PinnedPosts *[]PublicPost | PinnedPosts *[]PublicPost | ||||
IsAdmin bool | IsAdmin bool | ||||
@@ -829,6 +833,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro | |||||
// Add more data | // Add more data | ||||
// TODO: fix this mess of collections inside collections | // TODO: fix this mess of collections inside collections | ||||
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) | displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) | ||||
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") | |||||
collTmpl := "collection" | collTmpl := "collection" | ||||
if app.cfg.App.Chorus { | if app.cfg.App.Chorus { | ||||
@@ -947,6 +952,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e | |||||
// Add more data | // Add more data | ||||
// TODO: fix this mess of collections inside collections | // TODO: fix this mess of collections inside collections | ||||
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) | displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) | ||||
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") | |||||
err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage) | err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage) | ||||
if err != nil { | if err != nil { | ||||
@@ -1,5 +1,5 @@ | |||||
/* | /* | ||||
* Copyright © 2018-2019 A Bunch Tell LLC. | |||||
* Copyright © 2018-2020 A Bunch Tell LLC. | |||||
* | * | ||||
* This file is part of WriteFreely. | * This file is part of WriteFreely. | ||||
* | * | ||||
@@ -137,9 +137,11 @@ type ( | |||||
MinUsernameLen int `ini:"min_username_len"` | MinUsernameLen int `ini:"min_username_len"` | ||||
MaxBlogs int `ini:"max_blogs"` | MaxBlogs int `ini:"max_blogs"` | ||||
// Options for public instances | |||||
// Federation | // Federation | ||||
Federation bool `ini:"federation"` | |||||
PublicStats bool `ini:"public_stats"` | |||||
Federation bool `ini:"federation"` | |||||
PublicStats bool `ini:"public_stats"` | |||||
Monetization bool `ini:"monetization"` | |||||
// Access | // Access | ||||
Private bool `ini:"private"` | Private bool `ini:"private"` | ||||
@@ -905,6 +905,29 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro | |||||
} | } | ||||
} | } | ||||
// Update Monetization value | |||||
if c.Monetization != nil { | |||||
skipUpdate := false | |||||
if *c.Monetization != "" { | |||||
// Strip away any excess spaces | |||||
trimmed := strings.TrimSpace(*c.Monetization) | |||||
// Only update value when it starts with "$", per spec: https://paymentpointers.org | |||||
if strings.HasPrefix(trimmed, "$") { | |||||
c.Monetization = &trimmed | |||||
} else { | |||||
// Value appears invalid, so don't update | |||||
skipUpdate = true | |||||
} | |||||
} | |||||
if !skipUpdate { | |||||
_, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = ?", collID, "monetization_pointer", *c.Monetization, *c.Monetization) | |||||
if err != nil { | |||||
log.Error("Unable to insert monetization_pointer value: %v", err) | |||||
return err | |||||
} | |||||
} | |||||
} | |||||
// Update rest of the collection data | // Update rest of the collection data | ||||
res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...) | res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...) | ||||
if err != nil { | if err != nil { | ||||
@@ -2162,6 +2185,28 @@ func (db *datastore) CollectionHasAttribute(id int64, attr string) bool { | |||||
return true | return true | ||||
} | } | ||||
func (db *datastore) GetCollectionAttribute(id int64, attr string) string { | |||||
var v string | |||||
err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&v) | |||||
switch { | |||||
case err == sql.ErrNoRows: | |||||
return "" | |||||
case err != nil: | |||||
log.Error("Couldn't SELECT value in getCollectionAttribute for attribute '%s': %v", attr, err) | |||||
return "" | |||||
} | |||||
return v | |||||
} | |||||
func (db *datastore) SetCollectionAttribute(id int64, attr, v string) error { | |||||
_, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", id, attr, v) | |||||
if err != nil { | |||||
log.Error("Unable to INSERT into collectionattributes: %v", err) | |||||
return err | |||||
} | |||||
return nil | |||||
} | |||||
// DeleteAccount will delete the entire account for userID | // DeleteAccount will delete the entire account for userID | ||||
func (db *datastore) DeleteAccount(userID int64) error { | func (db *datastore) DeleteAccount(userID int64) error { | ||||
// Get all collections | // Get all collections | ||||
@@ -1476,6 +1476,7 @@ Are you sure it was ever here?`, | |||||
IsOwner bool | IsOwner bool | ||||
IsPinned bool | IsPinned bool | ||||
IsCustomDomain bool | IsCustomDomain bool | ||||
Monetization string | |||||
PinnedPosts *[]PublicPost | PinnedPosts *[]PublicPost | ||||
IsFound bool | IsFound bool | ||||
IsAdmin bool | IsAdmin bool | ||||
@@ -1493,6 +1494,7 @@ Are you sure it was ever here?`, | |||||
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) | tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) | ||||
tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner) | tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner) | ||||
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p) | tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p) | ||||
tp.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") | |||||
if !postFound { | if !postFound { | ||||
w.WriteHeader(http.StatusNotFound) | w.WriteHeader(http.StatusNotFound) | ||||
@@ -29,6 +29,7 @@ | |||||
<meta property="og:updated_time" content="{{.Created8601}}" /> | <meta property="og:updated_time" content="{{.Created8601}}" /> | ||||
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}} | {{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}} | ||||
<meta property="article:published_time" content="{{.Created8601}}"> | <meta property="article:published_time" content="{{.Created8601}}"> | ||||
{{template "collection-meta" .}} | |||||
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}} | {{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}} | ||||
<style type="text/css"> | <style type="text/css"> | ||||
body footer { | body footer { | ||||
@@ -27,6 +27,7 @@ | |||||
<meta property="og:url" content="{{.CanonicalURL}}" /> | <meta property="og:url" content="{{.CanonicalURL}}" /> | ||||
<meta property="og:description" content="{{.Description}}" /> | <meta property="og:description" content="{{.Description}}" /> | ||||
<meta property="og:image" content="{{.AvatarURL}}"> | <meta property="og:image" content="{{.AvatarURL}}"> | ||||
{{template "collection-meta" .}} | |||||
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}} | {{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}} | ||||
<style type="text/css"> | <style type="text/css"> | ||||
body#collection header { | body#collection header { | ||||
@@ -31,6 +31,7 @@ | |||||
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}} | {{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}} | ||||
<meta property="article:published_time" content="{{.Created8601}}"> | <meta property="article:published_time" content="{{.Created8601}}"> | ||||
{{ end }} | {{ end }} | ||||
{{template "collection-meta" .}} | |||||
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}} | {{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}} | ||||
{{if .Collection.RenderMathJax}} | {{if .Collection.RenderMathJax}} | ||||
@@ -29,6 +29,7 @@ | |||||
<meta property="og:type" content="article" /> | <meta property="og:type" content="article" /> | ||||
<meta property="og:url" content="{{.CanonicalURL}}tag:{{.Tag}}" /> | <meta property="og:url" content="{{.CanonicalURL}}tag:{{.Tag}}" /> | ||||
<meta property="og:image" content="{{.Collection.AvatarURL}}"> | <meta property="og:image" content="{{.Collection.AvatarURL}}"> | ||||
{{template "collection-meta" .}} | |||||
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}} | {{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}} | ||||
{{if .Collection.RenderMathJax}} | {{if .Collection.RenderMathJax}} | ||||
@@ -27,6 +27,7 @@ | |||||
<meta property="og:url" content="{{.CanonicalURL}}" /> | <meta property="og:url" content="{{.CanonicalURL}}" /> | ||||
<meta property="og:description" content="{{.Description}}" /> | <meta property="og:description" content="{{.Description}}" /> | ||||
<meta property="og:image" content="{{.AvatarURL}}"> | <meta property="og:image" content="{{.AvatarURL}}"> | ||||
{{template "collection-meta" .}} | |||||
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}} | {{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}} | ||||
{{if .RenderMathJax}} | {{if .RenderMathJax}} | ||||
@@ -1,4 +1,10 @@ | |||||
<!-- Miscelaneous render related template parts we use multiple times --> | <!-- Miscelaneous render related template parts we use multiple times --> | ||||
{{define "collection-meta"}} | |||||
{{if .Monetization -}} | |||||
<meta name="monetization" content="{{.Monetization}}" /> | |||||
{{- end}} | |||||
{{end}} | |||||
{{define "highlighting"}} | {{define "highlighting"}} | ||||
<script> | <script> | ||||
// TODO: this feels more like a mutation observer | // TODO: this feels more like a mutation observer | ||||
@@ -137,6 +137,13 @@ select { | |||||
<div><input type="checkbox" name="public_stats" id="public_stats" {{if .Config.PublicStats}}checked="checked"{{end}} /></div> | <div><input type="checkbox" name="public_stats" id="public_stats" {{if .Config.PublicStats}}checked="checked"{{end}} /></div> | ||||
</div> | </div> | ||||
<div class="features row"> | <div class="features row"> | ||||
<div><label for="monetization"> | |||||
Monetization | |||||
<p>Enable blogs on this site to receive micro­pay­ments from readers via <a target="wm" href="https://webmonetization.org/">Web Monetization</a>.</p> | |||||
</label></div> | |||||
<div><input type="checkbox" name="monetization" id="monetization" {{if .Config.Monetization}}checked="checked"{{end}} /></div> | |||||
</div> | |||||
<div class="features row"> | |||||
<div><label for="min_username_len"> | <div><label for="min_username_len"> | ||||
Minimum Username Length | Minimum Username Length | ||||
<p>The minimum number of characters allowed in a username. (Recommended: 2 or more.)</p> | <p>The minimum number of characters allowed in a username. (Recommended: 2 or more.)</p> | ||||
@@ -151,6 +151,16 @@ textarea.section.norm { | |||||
</div> | </div> | ||||
</div> | </div> | ||||
{{if .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" /> | |||||
</div> | |||||
</div> | |||||
{{end}} | |||||
<div class="option" style="text-align: center; margin-top: 4em;"> | <div class="option" style="text-align: center; margin-top: 4em;"> | ||||
<input type="submit" id="save-changes" value="Save changes" /> | <input type="submit" id="save-changes" value="Save changes" /> | ||||
<p><a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">View Blog</a></p> | <p><a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">View Blog</a></p> | ||||