Merge branch 'writefreely:develop' into mastodon-author-attribution

pull/1141/head
Jonathan S. 4 days ago committed by GitHub
commit e1afb69a6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      Makefile
  2. 2
      README.md
  3. 196
      activitypub.go
  4. 2
      admin.go
  5. 7
      app.go
  6. 42
      collections.go
  7. 53
      database.go
  8. 49
      database_activitypub.go
  9. 2
      export.go
  10. 2
      feed.go
  11. 2
      gopher.go
  12. 26
      less/core.less
  13. 4
      less/post-temp.less
  14. 1
      migrations/migrations.go
  15. 38
      migrations/v16.go
  16. 13
      posts.go
  17. 6
      routes.go
  18. 2
      sitemap.go
  19. 6
      templates.go
  20. 118
      templates/collection-archive.tmpl
  21. 9
      templates/collection-post.tmpl
  22. 14
      templates/include/posts.tmpl
  23. 2
      templates/user/stats.tmpl

@ -83,9 +83,9 @@ install : build
release : clean ui
mkdir -p $(BUILDPATH)
cp -r templates $(BUILDPATH)
cp -r pages $(BUILDPATH)
cp -r static $(BUILDPATH)
rsync -av --exclude=".*" templates $(BUILDPATH)
rsync -av --exclude=".*" pages $(BUILDPATH)
rsync -av --exclude=".*" static $(BUILDPATH)
rm -r $(BUILDPATH)/static/local
scripts/invalidate-css.sh $(BUILDPATH)
mkdir $(BUILDPATH)/keys

@ -86,4 +86,4 @@ Before contributing anything, please read our [Contributing Guide](https://githu
## License
Copyright © 2018-2022 [Musing Studio LLC](https://musing.studio) and contributing authors. Licensed under the [AGPL](https://github.com/writefreely/writefreely/blob/develop/LICENSE).
Copyright © 2018-2025 [Musing Studio LLC](https://musing.studio) and contributing authors. Licensed under the [AGPL](https://github.com/writefreely/writefreely/blob/develop/LICENSE).

@ -22,6 +22,7 @@ import (
"net/http/httputil"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@ -45,6 +46,11 @@ const (
apCacheTime = time.Minute
)
var (
apCollectionPostIRIRegex = regexp.MustCompile("/api/collections/([a-z0-9\\-]+)/posts/([a-z0-9\\-]+)$")
apDraftPostIRIRegex = regexp.MustCompile("/api/posts/([a-z0-9\\-]+)$")
)
var instanceColl *Collection
func initActivityPub(app *App) {
@ -195,7 +201,7 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p)
ocp.OrderedItems = []interface{}{}
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false)
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false, "")
for _, pp := range *posts {
pp.Collection = res
o := pp.ActivityObject(app)
@ -351,11 +357,60 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
a := streams.NewAccept()
p := c.PersonObject()
var to *url.URL
var isFollow, isUnfollow bool
var isFollow, isUnfollow, isLike, isUnlike bool
var likePostID, unlikePostID string
fullActor := &activitystreams.Person{}
var remoteUser *RemoteUser
res := &streams.Resolver{
LikeCallback: func(l *streams.Like) error {
isLike = true
// 1) Use the Like concrete type here
// 2) Errors are propagated to res.Deserialize call below
m["@context"] = []string{activitystreams.Namespace}
b, _ := json.Marshal(m)
if debugging {
log.Info("Like: %s", b)
}
_, likeID := l.GetId()
if likeID == nil {
log.Error("Didn't resolve Like ID")
}
if p := l.HasObject(0); p == streams.NoPresence {
return fmt.Errorf("no object for Like activity at index 0")
}
obj := l.Raw().GetObjectIRI(0)
/*
// TODO: handle this more robustly
l.ResolveObject(&streams.Resolver{
LinkCallback: func(link *streams.Link) error {
return nil
},
}, 0)
*/
if obj == nil {
return fmt.Errorf("didn't get ObjectIRI to Like")
}
likePostID, err = parsePostIDFromURL(app, obj)
if err != nil {
return err
}
// Finally, get actor information
_, from := l.GetActor(0)
if from == nil {
return fmt.Errorf("No valid actor string")
}
fullActor, remoteUser, err = getActor(app, from.String())
if err != nil {
return err
}
return nil
},
FollowCallback: func(f *streams.Follow) error {
isFollow = true
@ -394,8 +449,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
return impart.RenderActivityJSON(w, m, http.StatusOK)
},
UndoCallback: func(u *streams.Undo) error {
isUnfollow = true
m["@context"] = []string{activitystreams.Namespace}
b, _ := json.Marshal(m)
if debugging {
@ -403,6 +456,37 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
}
a.AppendObject(u.Raw())
// Check type -- we handle Undo:Like and Undo:Follow
_, err := u.ResolveObject(&streams.Resolver{
LikeCallback: func(like *streams.Like) error {
isUnlike = true
_, from := like.GetActor(0)
obj := like.Raw().GetObjectIRI(0)
if obj == nil {
return fmt.Errorf("didn't get ObjectIRI for Undo Like")
}
unlikePostID, err = parsePostIDFromURL(app, obj)
if err != nil {
return err
}
fullActor, remoteUser, err = getActor(app, from.String())
if err != nil {
return err
}
return nil
},
// TODO: add FollowCallback for more robust handling
}, 0)
if err != nil {
return err
}
if isUnlike {
return nil
}
isUnfollow = true
_, to = u.GetActor(0)
// TODO: get actor from object.object, not object
obj := u.Raw().GetObjectIRI(0)
@ -435,6 +519,81 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
return err
}
// Handle synchronous activities
if isLike {
t, err := app.db.Begin()
if err != nil {
log.Error("Unable to start transaction: %v", err)
return fmt.Errorf("unable to start transaction: %v", err)
}
var remoteUserID int64
if remoteUser != nil {
remoteUserID = remoteUser.ID
} else {
remoteUserID, err = apAddRemoteUser(app, t, fullActor)
}
// Add like
_, err = t.Exec("INSERT INTO remote_likes (post_id, remote_user_id, created) VALUES (?, ?, "+app.db.now()+")", likePostID, remoteUserID)
if err != nil {
if !app.db.isDuplicateKeyErr(err) {
t.Rollback()
log.Error("Couldn't add like in DB: %v\n", err)
return fmt.Errorf("Couldn't add like in DB: %v", err)
} else {
t.Rollback()
log.Error("Couldn't add like in DB: %v\n", err)
return fmt.Errorf("Couldn't add like in DB: %v", err)
}
}
err = t.Commit()
if err != nil {
t.Rollback()
log.Error("Rolling back after Commit(): %v\n", err)
return fmt.Errorf("Rolling back after Commit(): %v\n", err)
}
if debugging {
log.Info("Successfully liked post %s by remote user %s", likePostID, remoteUser.URL)
}
return impart.RenderActivityJSON(w, "", http.StatusOK)
} else if isUnlike {
t, err := app.db.Begin()
if err != nil {
log.Error("Unable to start transaction: %v", err)
return fmt.Errorf("unable to start transaction: %v", err)
}
var remoteUserID int64
if remoteUser != nil {
remoteUserID = remoteUser.ID
} else {
remoteUserID, err = apAddRemoteUser(app, t, fullActor)
}
// Remove like
_, err = t.Exec("DELETE FROM remote_likes WHERE post_id = ? AND remote_user_id = ?", unlikePostID, remoteUserID)
if err != nil {
t.Rollback()
log.Error("Couldn't delete Like from DB: %v\n", err)
return fmt.Errorf("Couldn't delete Like from DB: %v", err)
}
err = t.Commit()
if err != nil {
t.Rollback()
log.Error("Rolling back after Commit(): %v\n", err)
return fmt.Errorf("Rolling back after Commit(): %v\n", err)
}
if debugging {
log.Info("Successfully un-liked post %s by remote user %s", unlikePostID, remoteUser.URL)
}
return impart.RenderActivityJSON(w, "", http.StatusOK)
}
go func() {
if to == nil {
if debugging {
@ -469,6 +628,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
if remoteUser != nil {
followerID = remoteUser.ID
} else {
// TODO: use apAddRemoteUser() here, instead!
// Add follower locally, since it wasn't found before
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL)
if err != nil {
@ -964,6 +1124,34 @@ func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error {
return nil
}
func parsePostIDFromURL(app *App, u *url.URL) (string, error) {
// Get post ID from URL
var collAlias, slug, postID string
if m := apCollectionPostIRIRegex.FindStringSubmatch(u.String()); len(m) == 3 {
collAlias = m[1]
slug = m[2]
} else if m = apDraftPostIRIRegex.FindStringSubmatch(u.String()); len(m) == 2 {
postID = m[1]
} else {
return "", fmt.Errorf("unable to match objectIRI: %s", u)
}
// Get postID if all we have is collection and slug
if collAlias != "" && slug != "" {
c, err := app.db.GetCollection(collAlias)
if err != nil {
return "", err
}
p, err := app.db.GetPost(slug, c.ID)
if err != nil {
return "", err
}
postID = p.ID
}
return postID, nil
}
func setCacheControl(w http.ResponseWriter, ttl time.Duration) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
}

@ -208,7 +208,7 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
p.Flashes, _ = getSessionFlashes(app, w, r, nil)
p.TotalUsers = app.db.GetAllUsersCount()
ttlPages := p.TotalUsers / adminUsersPerPage
ttlPages := (p.TotalUsers - 1) / adminUsersPerPage + 1
p.TotalPages = []int{}
for i := 1; i <= int(ttlPages); i++ {
p.TotalPages = append(p.TotalPages, i)

@ -46,9 +46,10 @@ import (
)
const (
staticDir = "static"
assumedTitleLen = 80
postsPerPage = 10
staticDir = "static"
assumedTitleLen = 80
postsPerPage = 10
postsPerArchPage = 40
serverSoftware = "WriteFreely"
softwareURL = "https://writefreely.org"

@ -608,7 +608,7 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
}
}
ps, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false)
ps, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false, "")
if err != nil {
return err
}
@ -828,15 +828,18 @@ func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPost
return u, nil
}
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
func newDisplayCollection(c *Collection, cr *collectionReq, page int) (*DisplayCollection, error) {
coll := &DisplayCollection{
CollectionObj: NewCollectionObj(c),
CurrentPage: page,
Prefix: cr.prefix,
IsTopLevel: isSingleUser,
}
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
return coll
err := c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
if err != nil {
return nil, err
}
return coll, nil
}
// getCollectionPage returns the collection page as an int. If the parsed page value is not
@ -888,9 +891,23 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
// Fetch extra data about the Collection
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
coll := newDisplayCollection(c, cr, page)
coll, err := newDisplayCollection(c, cr, page)
if err != nil {
return err
}
coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage())))
var ct PostType
if isArchiveView(r) {
ct = postArch
}
// FIXME: this number will be off when user has pinned posts but isn't a Pro user
ppp := coll.Format.PostsPerPage()
if ct == postArch {
ppp = postsPerArchPage
}
coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(ppp)))
if coll.TotalPages > 0 && page > coll.TotalPages {
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
if !app.cfg.App.SingleUser {
@ -899,7 +916,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
return impart.HTTPError{http.StatusFound, redirURL}
}
coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false)
coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false, "")
// Serve collection
displayPage := CollectionPage{
@ -958,6 +975,9 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
collTmpl := "collection"
if app.cfg.App.Chorus {
collTmpl = "chorus-collection"
} else if isArchiveView(r) {
displayPage.NavSuffix = "/archive/"
collTmpl = "collection-archive"
}
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
if err != nil {
@ -984,6 +1004,10 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
return err
}
func isArchiveView(r *http.Request) bool {
return strings.HasSuffix(r.RequestURI, "/archive/") || mux.Vars(r)["archive"] == "archive"
}
func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
handle := vars["handle"]
@ -1019,7 +1043,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
return err
}
coll := newDisplayCollection(c, cr, page)
coll, _ := newDisplayCollection(c, cr, page)
taggedPostIDs, err := app.db.GetAllPostsTaggedIDs(c, tag, cr.isCollOwner)
if err != nil {
@ -1117,7 +1141,7 @@ func handleViewCollectionLang(app *App, w http.ResponseWriter, r *http.Request)
return err
}
coll := newDisplayCollection(c, cr, page)
coll, _ := newDisplayCollection(c, cr, page)
coll.Language = lang
coll.NavSuffix = fmt.Sprintf("/lang:%s", lang)

@ -19,6 +19,8 @@ import (
"strings"
"time"
"github.com/writeas/monday"
"github.com/go-sql-driver/mysql"
"github.com/writeas/web-core/silobridge"
wf_db "github.com/writefreely/writefreely/db"
@ -115,8 +117,9 @@ type writestore interface {
DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error)
ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
GetPostsCount(c *CollectionObj, includeFuture bool)
GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error)
GetPostLikeCounts(postID string) (int64, error)
GetPostsCount(c *CollectionObj, includeFuture bool) error
GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool, contentType PostType) (*[]PublicPost, error)
GetAllPostsTaggedIDs(c *Collection, tag string, includeFuture bool) ([]string, error)
GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error)
@ -1174,6 +1177,12 @@ func (db *datastore) GetPost(id string, collectionID int64) (*PublicPost, error)
return nil, ErrPostUnpublished
}
// Get additional information needed before processing post data
p.LikeCount, err = db.GetPostLikeCounts(p.ID)
if err != nil {
return nil, err
}
res := p.processPost()
if ownerName.Valid {
res.Owner = &PublicUser{Username: ownerName.String}
@ -1236,10 +1245,22 @@ func (db *datastore) GetPostProperty(id string, collectionID int64, property str
return res, nil
}
func (db *datastore) GetPostLikeCounts(postID string) (int64, error) {
var count int64
err := db.QueryRow("SELECT COUNT(*) FROM remote_likes WHERE post_id = ?", postID).Scan(&count)
switch {
case err == sql.ErrNoRows:
count = 0
case err != nil:
return 0, err
}
return count, nil
}
// GetPostsCount modifies the CollectionObj to include the correct number of
// standard (non-pinned) posts. It will return future posts if `includeFuture`
// is true.
func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) error {
var count int64
timeCondition := ""
if !includeFuture {
@ -1252,16 +1273,18 @@ func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
case err != nil:
log.Error("Failed selecting from collections: %v", err)
c.TotalPosts = 0
return err
}
c.TotalPosts = int(count)
return nil
}
// GetPosts retrieves all posts for the given Collection.
// It will return future posts if `includeFuture` is true.
// It will include only standard (non-pinned) posts unless `includePinned` is true.
// TODO: change includeFuture to isOwner, since that's how it's used
func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) {
func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool, contentType PostType) (*[]PublicPost, error) {
collID := c.ID
cf := c.NewFormat()
@ -1275,6 +1298,9 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
if page == 0 {
start = 0
pagePosts = 1000
} else if contentType == postArch {
pagePosts = postsPerArchPage
start = page*pagePosts - pagePosts
}
limitStr := ""
@ -1289,6 +1315,7 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
if !includePinned {
pinnedCondition = "AND pinned_position IS NULL"
}
// FUTURE: handle different post contentType's here
rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? "+pinnedCondition+" "+timeCondition+" ORDER BY created "+order+limitStr, collID)
if err != nil {
log.Error("Failed selecting from posts: %v", err)
@ -1309,7 +1336,13 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
p.augmentContent(c)
p.formatContent(cfg, c, includeFuture, false)
posts = append(posts, p.processPost())
pubPost := p.processPost()
if contentType == postArch {
// Overwrite DisplayDate with special Archive page version
loc := monday.FuzzyLocale(pubPost.Language.String)
pubPost.DisplayDate = monday.Format(pubPost.Created, monday.LongNoYrFormatsByLocale[loc], loc)
}
posts = append(posts, pubPost)
}
err = rows.Err()
if err != nil {
@ -1982,7 +2015,7 @@ func (db *datastore) GetMeStats(u *User) userMeStats {
func (db *datastore) GetTotalCollections() (collCount int64, err error) {
err = db.QueryRow(`
SELECT COUNT(*)
SELECT COUNT(*)
FROM collections c
LEFT JOIN users u ON u.id = c.owner_id
WHERE u.status = 0`).Scan(&collCount)
@ -3108,10 +3141,10 @@ func (db *datastore) GetEmailSubscribers(collID int64, reqConfirmed bool) ([]*Em
if reqConfirmed {
cond = " AND confirmed = 1"
}
rows, err := db.Query(`SELECT s.id, collection_id, user_id, s.email, u.email, subscribed, token, confirmed, allow_export
FROM emailsubscribers s
LEFT JOIN users u
ON u.id = user_id
rows, err := db.Query(`SELECT s.id, collection_id, user_id, s.email, u.email, subscribed, token, confirmed, allow_export
FROM emailsubscribers s
LEFT JOIN users u
ON u.id = user_id
WHERE collection_id = ?`+cond+`
ORDER BY subscribed DESC`, collID)
if err != nil {

@ -0,0 +1,49 @@
/*
* Copyright © 2024 Musing Studio 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 (
"database/sql"
"fmt"
"github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/log"
)
func apAddRemoteUser(app *App, t *sql.Tx, fullActor *activitystreams.Person) (int64, error) {
// Add remote user locally, since it wasn't found before
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL)
if err != nil {
t.Rollback()
return -1, fmt.Errorf("couldn't add new remoteuser in DB: %v", err)
}
remoteUserID, err := res.LastInsertId()
if err != nil {
t.Rollback()
return -1, fmt.Errorf("no lastinsertid for followers, rolling back: %v", err)
}
// Add in key
_, err = t.Exec("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)", fullActor.PublicKey.ID, remoteUserID, fullActor.PublicKey.PublicKeyPEM)
if err != nil {
if !app.db.isDuplicateKeyErr(err) {
t.Rollback()
log.Error("Couldn't add follower keys in DB: %v\n", err)
return -1, fmt.Errorf("couldn't add follower keys in DB: %v", err)
} else {
t.Rollback()
log.Error("Couldn't add follower keys in DB: %v\n", err)
return -1, fmt.Errorf("couldn't add follower keys in DB: %v", err)
}
}
return remoteUserID, nil
}

@ -119,7 +119,7 @@ func compileFullExport(app *App, u *User) *ExportUser {
var collObjs []CollectionObj
for _, c := range *colls {
co := &CollectionObj{Collection: c}
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true)
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true, "")
if err != nil {
log.Error("unable to get collection posts: %v", err)
}

@ -67,7 +67,7 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
if tag != "" {
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, 1, false)
} else {
coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false)
coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false, "")
}
author := ""

@ -111,7 +111,7 @@ func handleGopherCollection(app *App, w gopher.ResponseWriter, r *gopher.Request
w.WriteInfo(c.Description)
}
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false)
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false, "")
if err != nil {
return err
}

@ -5,7 +5,7 @@ body {
-moz-osx-font-smoothing: grayscale;
background-color: white;
color: #111;
h1, header h2 {
a {
color: @headerTextColor;
@ -672,6 +672,26 @@ body#collection article, body#subpage article {
}
}
}
#wrapper.archive {
h1 {
margin: 0 !important;
}
ul {
list-style: none;
li {
display: flex;
justify-content: space-between;
line-height: 1.4;
margin: 0.5em 0;
}
.year {
font-weight: bold;
font-size: 1.5em;
}
}
}
body#post article {
p.badge {
font-size: 0.9em;
@ -1101,7 +1121,7 @@ li {
}
}
ul.errors {
ul.errors {
padding: 0;
text-indent: 0;
li.urgent {
@ -1637,4 +1657,4 @@ p#emailsub {
font-weight: bold;
margin-left: 0.25em;
}
}
}

@ -12,7 +12,7 @@ body {
&:hover {
.opacity(1);
}
h1 {
font-size: 1.6em;
}
@ -30,7 +30,7 @@ body {
}
}
article, pre, .hljs {
article, pre, .hljs, #wrapper.archive ul {
padding: 0.5em 2rem 1.5em;
}
body#post article, pre, .hljs {

@ -71,6 +71,7 @@ var migrations = []Migration{
New("support newsletters", supportLetters), // V12 -> V13
New("support password resetting", supportPassReset), // V13 -> V14
New("speed up blog post retrieval", addPostRetrievalIndex), // V14 -> V15
New("support ActivityPub likes", supportRemoteLikes), // V15 -> V16 (v0.16.0)
}
// CurrentVer returns the current migration version the application is on

@ -0,0 +1,38 @@
/*
* Copyright © 2024 Musing Studio 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 migrations
func supportRemoteLikes(db *datastore) error {
t, err := db.Begin()
if err != nil {
t.Rollback()
return err
}
_, err = t.Exec(`CREATE TABLE remote_likes (
post_id ` + db.typeChar(16) + ` NOT NULL,
remote_user_id ` + db.typeInt() + ` NOT NULL,
created ` + db.typeDateTime() + ` NOT NULL,
PRIMARY KEY (post_id,remote_user_id)
)`)
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}

@ -49,6 +49,12 @@ const (
postIDLen = 10
postMetaDateFormat = "2006-01-02 15:04:05"
)
type PostType string
const (
postArch PostType = "archive"
shortCodePaid = "<!--paid-->"
)
@ -105,6 +111,7 @@ type (
Created time.Time `db:"created" json:"created"`
Updated time.Time `db:"updated" json:"updated"`
ViewCount int64 `db:"view_count" json:"-"`
LikeCount int64 `db:"like_count" json:"likes"`
Title zero.String `db:"title" json:"title"`
HTMLTitle template.HTML `db:"title" json:"-"`
Content string `db:"content" json:"body"`
@ -127,6 +134,7 @@ type (
IsTopLevel bool `json:"-"`
DisplayDate string `json:"-"`
Views int64 `json:"views"`
Likes int64 `json:"likes"`
Owner *PublicUser `json:"-"`
IsOwner bool `json:"-"`
URL string `json:"url,omitempty"`
@ -1184,6 +1192,7 @@ func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
func (p *Post) processPost() PublicPost {
res := &PublicPost{Post: p, Views: 0}
res.Views = p.ViewCount
res.Likes = p.LikeCount
// TODO: move to own function
loc := monday.FuzzyLocale(p.Language.String)
res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
@ -1507,6 +1516,10 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
// User tried to access blog feed without a trailing slash, and
// there's no post with a slug "feed"
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "feed/"}
} else if slug == "archive" {
// User tried to access blog Archive without a trailing slash, and
// there's no post with a slug "archive"
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "archive/"}
}
po := &Post{

@ -159,12 +159,12 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
// Handle posts
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
posts := write.PathPrefix("/api/posts/").Subrouter()
posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST")
posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.AllReader(fetchPost)).Methods("GET")
posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(existingPost)).Methods("POST", "PUT")
posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(deletePost)).Methods("DELETE")
posts.HandleFunc("/{post:[a-zA-Z0-9]+}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST")
write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
@ -221,6 +221,8 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
func RouteCollections(handler *Handler, r *mux.Router) {
r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional))
r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
r.HandleFunc("/archive/", handler.Web(handleViewCollection, UserLevelReader))
r.HandleFunc("/{archive:archive}/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
r.HandleFunc("/lang:{lang:[a-z]{2}}", handler.Web(handleViewCollectionLang, UserLevelOptional))
r.HandleFunc("/lang:{lang:[a-z]{2}}/page/{page:[0-9]+}", handler.Web(handleViewCollectionLang, UserLevelOptional))
r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))

@ -66,7 +66,7 @@ func handleViewSitemap(app *App, w http.ResponseWriter, r *http.Request) error {
host = c.CanonicalURL()
sm := buildSitemap(host, pre)
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false)
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false, "")
if err != nil {
log.Error("Error getting posts: %v", err)
return err

@ -14,8 +14,8 @@ import (
"errors"
"html/template"
"io"
"os"
"net/http"
"os"
"path/filepath"
"strings"
@ -70,14 +70,14 @@ 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" || name == "read" {
if name == "collection" || name == "collection-tags" || name == "collection-archive" || 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"))
}
if name == "chorus-collection" || name == "chorus-collection-post" {
files = append(files, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"))
}
if name == "collection" || name == "collection-tags" || name == "collection-post" || name == "post" || name == "chorus-collection" || name == "chorus-collection-post" {
if name == "collection" || name == "collection-tags" || name == "collection-archive" || name == "collection-post" || name == "post" || name == "chorus-collection" || name == "chorus-collection-post" {
files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl"))
}
templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))

@ -0,0 +1,118 @@
{{define "collection"}}<!DOCTYPE HTML>
<html>
<head prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article#">
<meta charset="utf-8">
<title>Archive &mdash; {{.Collection.DisplayTitle}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="canonical" href="{{.CanonicalURL}}">
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} &raquo; Feed" href="{{.CanonicalURL}}feed/" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content="WriteFreely">
<meta name="description" content="{{.PlainDescription}}">
<meta itemprop="name" content="{{.DisplayTitle}}">
<meta itemprop="description" content="{{.PlainDescription}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.DisplayTitle}}">
<meta name="twitter:image" content="{{.AvatarURL}}">
<meta name="twitter:description" content="{{.PlainDescription}}">
<meta property="og:title" content="{{.DisplayTitle}}" />
<meta property="og:site_name" content="{{.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}" />
<meta property="og:description" content="{{.PlainDescription}}" />
<meta property="og:image" content="{{.AvatarURL}}">
{{template "collection-meta" .}}
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
</head>
<body id="subpage">
<div id="overlay"></div>
<header>
<h1 dir="{{.Direction}}" id="blog-title"><a href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1>
<nav>
{{if .PinnedPosts}}
{{range .PinnedPosts}}<a class="pinned" href="{{if $.IsOwner}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL}}{{end}}">{{.DisplayTitle}}</a>{{end}}
{{end}}
</nav>
</header>
{{if .Posts -}}
<section id="wrapper" class="archive" itemscope itemtype="http://schema.org/Blog">
{{- else -}}
<div id="wrapper" class="archive">
{{- end}}
<h1>Archive</h1>
{{if .Flash}}
<div class="alert success flash">
<p>{{.Flash}}</p>
</div>
{{end}}
<ul>
{{ $curYear := 0 }}
{{ range $el := .Posts }}
{{if ne $curYear .Created.Year}}<li class="year">{{.Created.Year}}</li>{{ $curYear = .Created.Year }}{{end}}
<li>
{{if .HasTitleLink -}}
{{.HTMLTitleArrow}}
{{- else -}}
<a href="{{if $.SingleUser}}/{{else}}/{{$.Alias}}/{{end}}{{.Slug.String}}" itemprop="url" class="u-url">
{{- if .DisplayTitle -}}
{{- .DisplayTitle -}}
{{- else -}}
(Untitled)
{{end}}
</a>
{{- end}}
{{if .IsScheduled}}[Scheduled]{{end}}
{{if $.Format.ShowDates -}}
<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">
{{- if .HasTitleLink -}}
<a href="{{if $.SingleUser}}/{{else}}/{{$.Alias}}/{{end}}{{.Slug.String}}" itemprop="url">
{{- end -}}
{{.DisplayDate}}
{{- if .HasTitleLink -}}
{{- if .IsPaid}}{{template "paid-badge" (dict "CDNHost" $.CDNHost)}}{{end -}}</a>
{{- end -}}
</time>
{{- else -}}
{{- if .HasTitleLink -}}
<a href="{{if $.SingleUser}}/{{else}}/{{$.Alias}}/{{end}}{{.Slug.String}}" itemprop="url">view</a>
{{- end -}}
{{- end}}
</li>
{{end}}
</ul>
{{template "paging" .}}
{{if .Posts}}</section>{{else}}</div>{{end}}
{{if .ShowFooterBranding }}
<footer>
<hr />
<nav dir="ltr">
{{if not .SingleUser}}<a class="home pubd" href="/">{{.SiteName}}</a> &middot; {{end}}powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a>
</nav>
</footer>
{{ end }}
</body>
{{if .CanShowScript}}
{{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Collection.Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
{{end}}
<script src="/js/localdate.js"></script>
</html>{{end}}

@ -4,7 +4,7 @@
<meta charset="utf-8">
<title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
@ -45,7 +45,7 @@
</head>
<body id="post">
<div id="overlay"></div>
<header>
@ -55,12 +55,13 @@
{{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}
{{end}}
{{ if and .IsOwner .IsFound }}<span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span>
{{if .Likes}}<span class="views" dir="ltr"><strong>{{largeNumFmt .Likes}}</strong> {{pluralize "like" "likes" .Likes}}</span>{{end}}
<a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a>
{{if .IsPinned}}<a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">Unpin</a>{{end}}
{{ end }}
</nav>
</header>
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
@ -70,7 +71,7 @@
<footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav></footer>
{{ end }}
</body>
{{if .Collection.CanShowScript}}
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}

@ -61,4 +61,16 @@
<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}}
{{define "paid-badge"}}<img class="paid" alt="Paid article" src="/img/paidarticle.svg" /> {{end}}
{{define "paging"}}
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">&#8672; {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} &#8674;</a>{{end}}
{{else}}
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">&#8672; Older</a>{{end}}
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">Newer &#8674;</a>{{end}}
{{end}}
</nav>{{end}}
{{end}}

@ -51,11 +51,13 @@ td.none {
<th>Post</th>
{{if not .Collection}}<th>Blog</th>{{end}}
<th class="num">Total Views</th>
{{if .Federation}}<th class="num">Likes</th>{{end}}
</tr>
{{range .TopPosts}}<tr>
<td style="word-break: break-all;"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}/{{.ID}}{{end}}">{{if ne .DisplayTitle ""}}{{.DisplayTitle}}{{else}}<em>{{.ID}}</em>{{end}}</a></td>
{{ if not $.Collection }}<td>{{if .Collection}}<a href="{{.Collection.CanonicalURL}}">{{.Collection.Title}}</a>{{else}}<em>Draft</em>{{end}}</td>{{ end }}
<td class="num">{{.ViewCount}}</td>
{{if $.Federation}}<td class="num">{{.LikeCount}}</td>{{end}}
</tr>{{end}}
</table>

Loading…
Cancel
Save