Merge branch 'develop' into archive-page
This commit is contained in:
commit
fbf505cff0
14 changed files with 327 additions and 23 deletions
8
Makefile
8
Makefile
|
@ -1,5 +1,5 @@
|
||||||
GITREV=`git describe | cut -c 2-`
|
GITREV=`git describe | cut -c 2-`
|
||||||
LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'"
|
LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)' -extldflags '-static'"
|
||||||
|
|
||||||
GOCMD=go
|
GOCMD=go
|
||||||
GOINSTALL=$(GOCMD) install $(LDFLAGS)
|
GOINSTALL=$(GOCMD) install $(LDFLAGS)
|
||||||
|
@ -83,9 +83,9 @@ install : build
|
||||||
|
|
||||||
release : clean ui
|
release : clean ui
|
||||||
mkdir -p $(BUILDPATH)
|
mkdir -p $(BUILDPATH)
|
||||||
cp -r templates $(BUILDPATH)
|
rsync -av --exclude=".*" templates $(BUILDPATH)
|
||||||
cp -r pages $(BUILDPATH)
|
rsync -av --exclude=".*" pages $(BUILDPATH)
|
||||||
cp -r static $(BUILDPATH)
|
rsync -av --exclude=".*" static $(BUILDPATH)
|
||||||
rm -r $(BUILDPATH)/static/local
|
rm -r $(BUILDPATH)/static/local
|
||||||
scripts/invalidate-css.sh $(BUILDPATH)
|
scripts/invalidate-css.sh $(BUILDPATH)
|
||||||
mkdir $(BUILDPATH)/keys
|
mkdir $(BUILDPATH)/keys
|
||||||
|
|
|
@ -86,4 +86,4 @@ Before contributing anything, please read our [Contributing Guide](https://githu
|
||||||
|
|
||||||
## License
|
## 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).
|
||||||
|
|
194
activitypub.go
194
activitypub.go
|
@ -22,6 +22,7 @@ import (
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -45,6 +46,11 @@ const (
|
||||||
apCacheTime = time.Minute
|
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
|
var instanceColl *Collection
|
||||||
|
|
||||||
func initActivityPub(app *App) {
|
func initActivityPub(app *App) {
|
||||||
|
@ -351,11 +357,60 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
a := streams.NewAccept()
|
a := streams.NewAccept()
|
||||||
p := c.PersonObject()
|
p := c.PersonObject()
|
||||||
var to *url.URL
|
var to *url.URL
|
||||||
var isFollow, isUnfollow bool
|
var isFollow, isUnfollow, isLike, isUnlike bool
|
||||||
|
var likePostID, unlikePostID string
|
||||||
fullActor := &activitystreams.Person{}
|
fullActor := &activitystreams.Person{}
|
||||||
var remoteUser *RemoteUser
|
var remoteUser *RemoteUser
|
||||||
|
|
||||||
res := &streams.Resolver{
|
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 {
|
FollowCallback: func(f *streams.Follow) error {
|
||||||
isFollow = true
|
isFollow = true
|
||||||
|
|
||||||
|
@ -394,8 +449,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
return impart.RenderActivityJSON(w, m, http.StatusOK)
|
return impart.RenderActivityJSON(w, m, http.StatusOK)
|
||||||
},
|
},
|
||||||
UndoCallback: func(u *streams.Undo) error {
|
UndoCallback: func(u *streams.Undo) error {
|
||||||
isUnfollow = true
|
|
||||||
|
|
||||||
m["@context"] = []string{activitystreams.Namespace}
|
m["@context"] = []string{activitystreams.Namespace}
|
||||||
b, _ := json.Marshal(m)
|
b, _ := json.Marshal(m)
|
||||||
if debugging {
|
if debugging {
|
||||||
|
@ -403,6 +456,37 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
a.AppendObject(u.Raw())
|
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)
|
_, to = u.GetActor(0)
|
||||||
// TODO: get actor from object.object, not object
|
// TODO: get actor from object.object, not object
|
||||||
obj := u.Raw().GetObjectIRI(0)
|
obj := u.Raw().GetObjectIRI(0)
|
||||||
|
@ -435,6 +519,81 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
return err
|
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() {
|
go func() {
|
||||||
if to == nil {
|
if to == nil {
|
||||||
if debugging {
|
if debugging {
|
||||||
|
@ -469,6 +628,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
if remoteUser != nil {
|
if remoteUser != nil {
|
||||||
followerID = remoteUser.ID
|
followerID = remoteUser.ID
|
||||||
} else {
|
} else {
|
||||||
|
// TODO: use apAddRemoteUser() here, instead!
|
||||||
// Add follower locally, since it wasn't found before
|
// 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)
|
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 {
|
if err != nil {
|
||||||
|
@ -964,6 +1124,34 @@ func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error {
|
||||||
return nil
|
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) {
|
func setCacheControl(w http.ResponseWriter, ttl time.Duration) {
|
||||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
|
||||||
}
|
}
|
||||||
|
|
4
app.go
4
app.go
|
@ -893,12 +893,12 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
||||||
if isAdmin {
|
if isAdmin {
|
||||||
// Abort if trying to create admin user, but one already exists
|
// Abort if trying to create admin user, but one already exists
|
||||||
if firstUser != nil {
|
if firstUser != nil {
|
||||||
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username)
|
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely user create [USER]:[PASSWORD]", firstUser.Username)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Abort if trying to create regular user, but no admin exists yet
|
// Abort if trying to create regular user, but no admin exists yet
|
||||||
if firstUser == nil {
|
if firstUser == nil {
|
||||||
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin")
|
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely user create --admin [USER]:[PASSWORD]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
22
database.go
22
database.go
|
@ -14,12 +14,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/writeas/monday"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/writeas/monday"
|
||||||
|
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
"github.com/writeas/web-core/silobridge"
|
"github.com/writeas/web-core/silobridge"
|
||||||
wf_db "github.com/writefreely/writefreely/db"
|
wf_db "github.com/writefreely/writefreely/db"
|
||||||
|
@ -116,6 +117,7 @@ type writestore interface {
|
||||||
DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error)
|
DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error)
|
||||||
ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
|
ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
|
||||||
|
|
||||||
|
GetPostLikeCounts(postID string) (int64, error)
|
||||||
GetPostsCount(c *CollectionObj, includeFuture bool) error
|
GetPostsCount(c *CollectionObj, includeFuture bool) error
|
||||||
GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool, contentType PostType) (*[]PublicPost, 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)
|
GetAllPostsTaggedIDs(c *Collection, tag string, includeFuture bool) ([]string, error)
|
||||||
|
@ -1175,6 +1177,12 @@ func (db *datastore) GetPost(id string, collectionID int64) (*PublicPost, error)
|
||||||
return nil, ErrPostUnpublished
|
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()
|
res := p.processPost()
|
||||||
if ownerName.Valid {
|
if ownerName.Valid {
|
||||||
res.Owner = &PublicUser{Username: ownerName.String}
|
res.Owner = &PublicUser{Username: ownerName.String}
|
||||||
|
@ -1237,6 +1245,18 @@ func (db *datastore) GetPostProperty(id string, collectionID int64, property str
|
||||||
return res, nil
|
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
|
// GetPostsCount modifies the CollectionObj to include the correct number of
|
||||||
// standard (non-pinned) posts. It will return future posts if `includeFuture`
|
// standard (non-pinned) posts. It will return future posts if `includeFuture`
|
||||||
// is true.
|
// is true.
|
||||||
|
|
49
database_activitypub.go
Normal file
49
database_activitypub.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -71,6 +71,7 @@ var migrations = []Migration{
|
||||||
New("support newsletters", supportLetters), // V12 -> V13
|
New("support newsletters", supportLetters), // V12 -> V13
|
||||||
New("support password resetting", supportPassReset), // V13 -> V14
|
New("support password resetting", supportPassReset), // V13 -> V14
|
||||||
New("speed up blog post retrieval", addPostRetrievalIndex), // V14 -> V15
|
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
|
// CurrentVer returns the current migration version the application is on
|
||||||
|
|
38
migrations/v16.go
Normal file
38
migrations/v16.go
Normal file
|
@ -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
|
||||||
|
}
|
3
posts.go
3
posts.go
|
@ -111,6 +111,7 @@ type (
|
||||||
Created time.Time `db:"created" json:"created"`
|
Created time.Time `db:"created" json:"created"`
|
||||||
Updated time.Time `db:"updated" json:"updated"`
|
Updated time.Time `db:"updated" json:"updated"`
|
||||||
ViewCount int64 `db:"view_count" json:"-"`
|
ViewCount int64 `db:"view_count" json:"-"`
|
||||||
|
LikeCount int64 `db:"like_count" json:"likes"`
|
||||||
Title zero.String `db:"title" json:"title"`
|
Title zero.String `db:"title" json:"title"`
|
||||||
HTMLTitle template.HTML `db:"title" json:"-"`
|
HTMLTitle template.HTML `db:"title" json:"-"`
|
||||||
Content string `db:"content" json:"body"`
|
Content string `db:"content" json:"body"`
|
||||||
|
@ -133,6 +134,7 @@ type (
|
||||||
IsTopLevel bool `json:"-"`
|
IsTopLevel bool `json:"-"`
|
||||||
DisplayDate string `json:"-"`
|
DisplayDate string `json:"-"`
|
||||||
Views int64 `json:"views"`
|
Views int64 `json:"views"`
|
||||||
|
Likes int64 `json:"likes"`
|
||||||
Owner *PublicUser `json:"-"`
|
Owner *PublicUser `json:"-"`
|
||||||
IsOwner bool `json:"-"`
|
IsOwner bool `json:"-"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
|
@ -1190,6 +1192,7 @@ func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
func (p *Post) processPost() PublicPost {
|
func (p *Post) processPost() PublicPost {
|
||||||
res := &PublicPost{Post: p, Views: 0}
|
res := &PublicPost{Post: p, Views: 0}
|
||||||
res.Views = p.ViewCount
|
res.Views = p.ViewCount
|
||||||
|
res.Likes = p.LikeCount
|
||||||
// TODO: move to own function
|
// TODO: move to own function
|
||||||
loc := monday.FuzzyLocale(p.Language.String)
|
loc := monday.FuzzyLocale(p.Language.String)
|
||||||
res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
|
res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
|
||||||
|
|
|
@ -159,12 +159,12 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
// Handle posts
|
// Handle posts
|
||||||
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
|
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
|
||||||
posts := write.PathPrefix("/api/posts/").Subrouter()
|
posts := write.PathPrefix("/api/posts/").Subrouter()
|
||||||
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.AllReader(fetchPost)).Methods("GET")
|
|
||||||
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT")
|
|
||||||
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE")
|
|
||||||
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
|
|
||||||
posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
|
posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
|
||||||
posts.HandleFunc("/disperse", handler.All(dispersePost)).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")
|
||||||
|
|
||||||
write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
|
write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
|
||||||
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
|
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
|
||||||
|
|
|
@ -188,21 +188,22 @@ var movePostHTML = function(postID) {
|
||||||
}
|
}
|
||||||
return $tmpl.innerHTML.replace(/POST_ID/g, postID);
|
return $tmpl.innerHTML.replace(/POST_ID/g, postID);
|
||||||
}
|
}
|
||||||
var createPostEl = function(post, owned) {
|
var createPostEl = function(post, owned, singleUser) {
|
||||||
var $post = document.createElement('div');
|
var $post = document.createElement('div');
|
||||||
let p = H.createPost(post.id, "", post.body)
|
let p = H.createPost(post.id, "", post.body)
|
||||||
var title = (post.title || p.title || post.id);
|
var title = (post.title || p.title || post.id);
|
||||||
title = title.replace(/</g, "<");
|
title = title.replace(/</g, "<");
|
||||||
|
var postLink = (singleUser ? '/d/' : '/') + post.id
|
||||||
$post.id = 'post-' + post.id;
|
$post.id = 'post-' + post.id;
|
||||||
$post.className = 'post';
|
$post.className = 'post';
|
||||||
$post.innerHTML = '<h3><a href="/' + post.id + '">' + title + '</a></h3>';
|
$post.innerHTML = '<h3><a href="' + postLink + '">' + title + '</a></h3>';
|
||||||
|
|
||||||
var posted = "";
|
var posted = "";
|
||||||
if (post.created) {
|
if (post.created) {
|
||||||
posted = getFormattedDate(new Date(post.created))
|
posted = getFormattedDate(new Date(post.created))
|
||||||
}
|
}
|
||||||
var hasDraft = H.exists('draft' + post.id);
|
var hasDraft = H.exists('draft' + post.id);
|
||||||
$post.innerHTML += '<h4><date>' + posted + '</date> <a class="action" href="/pad/' + post.id + '">edit' + (hasDraft ? 'ed' : '') + '</a> <a class="delete action" href="/' + post.id + '" onclick="delPost(event, \'' + post.id + '\'' + (owned === true ? ', true' : '') + ')">delete</a> '+movePostHTML(post.id)+'</h4>';
|
$post.innerHTML += '<h4><date>' + posted + '</date> <a class="action" href="' + postLink + '/edit">edit' + (hasDraft ? 'ed' : '') + '</a> <a class="delete action" href="/' + post.id + '" onclick="delPost(event, \'' + post.id + '\'' + (owned === true ? ', true' : '') + ')">delete</a> '+movePostHTML(post.id)+'</h4>';
|
||||||
|
|
||||||
if (post.error) {
|
if (post.error) {
|
||||||
$post.innerHTML += '<p class="error"><strong>Sync error:</strong> ' + post.error + ' <nav><a href="#" onclick="localPosts.dismissError(event, this)">dismiss</a> <a href="#" onclick="localPosts.deletePost(event, this, \''+post.id+'\')">remove post</a></nav></p>';
|
$post.innerHTML += '<p class="error"><strong>Sync error:</strong> ' + post.error + ' <nav><a href="#" onclick="localPosts.dismissError(event, this)">dismiss</a> <a href="#" onclick="localPosts.deletePost(event, this, \''+post.id+'\')">remove post</a></nav></p>';
|
||||||
|
|
|
@ -55,6 +55,7 @@
|
||||||
{{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}}
|
{{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}}
|
{{end}}
|
||||||
{{ if and .IsOwner .IsFound }}<span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span>
|
{{ 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>
|
<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}}
|
{{if .IsPinned}}<a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">Unpin</a>{{end}}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
@ -202,8 +202,9 @@ function loadMorePosts() {
|
||||||
if (http.readyState == 4) {
|
if (http.readyState == 4) {
|
||||||
if (http.status == 200) {
|
if (http.status == 200) {
|
||||||
var data = JSON.parse(http.responseText);
|
var data = JSON.parse(http.responseText);
|
||||||
|
var singleUser = {{ .SingleUser }};
|
||||||
for (var i=0; i<data.data.length; i++) {
|
for (var i=0; i<data.data.length; i++) {
|
||||||
$posts.el.appendChild(createPostEl(data.data[i], true));
|
$posts.el.appendChild(createPostEl(data.data[i], true, singleUser));
|
||||||
}
|
}
|
||||||
if (data.data.length < 10) {
|
if (data.data.length < 10) {
|
||||||
$loadMore.el.parentNode.removeChild($loadMore.el);
|
$loadMore.el.parentNode.removeChild($loadMore.el);
|
||||||
|
|
|
@ -51,11 +51,13 @@ td.none {
|
||||||
<th>Post</th>
|
<th>Post</th>
|
||||||
{{if not .Collection}}<th>Blog</th>{{end}}
|
{{if not .Collection}}<th>Blog</th>{{end}}
|
||||||
<th class="num">Total Views</th>
|
<th class="num">Total Views</th>
|
||||||
|
{{if .Federation}}<th class="num">Likes</th>{{end}}
|
||||||
</tr>
|
</tr>
|
||||||
{{range .TopPosts}}<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>
|
<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 }}
|
{{ 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>
|
<td class="num">{{.ViewCount}}</td>
|
||||||
|
{{if $.Federation}}<td class="num">{{.LikeCount}}</td>{{end}}
|
||||||
</tr>{{end}}
|
</tr>{{end}}
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue