diff --git a/Makefile b/Makefile index 9f9ae97..d925a8d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 7831e57..3c1ea90 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/activitypub.go b/activitypub.go index 6a3b0a1..f6f8792 100644 --- a/activitypub.go +++ b/activitypub.go @@ -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())) } diff --git a/admin.go b/admin.go index 258d0b8..6e0f3d5 100644 --- a/admin.go +++ b/admin.go @@ -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) diff --git a/app.go b/app.go index 3b4756a..93d359c 100644 --- a/app.go +++ b/app.go @@ -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" diff --git a/collections.go b/collections.go index 0ccca2e..90e02ba 100644 --- a/collections.go +++ b/collections.go @@ -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) diff --git a/database.go b/database.go index c5f239f..84898e0 100644 --- a/database.go +++ b/database.go @@ -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 { diff --git a/database_activitypub.go b/database_activitypub.go new file mode 100644 index 0000000..9df3724 --- /dev/null +++ b/database_activitypub.go @@ -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 +} diff --git a/export.go b/export.go index e6a09c1..a89ed7d 100644 --- a/export.go +++ b/export.go @@ -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) } diff --git a/feed.go b/feed.go index 68dd2c0..ae27b3a 100644 --- a/feed.go +++ b/feed.go @@ -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 := "" diff --git a/gopher.go b/gopher.go index 2ac1590..45bbec4 100644 --- a/gopher.go +++ b/gopher.go @@ -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 } diff --git a/less/core.less b/less/core.less index a64056a..6dde923 100644 --- a/less/core.less +++ b/less/core.less @@ -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; } -} \ No newline at end of file +} diff --git a/less/post-temp.less b/less/post-temp.less index aec7d26..bb818c1 100644 --- a/less/post-temp.less +++ b/less/post-temp.less @@ -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 { diff --git a/migrations/migrations.go b/migrations/migrations.go index fc638ee..6b5b094 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -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 diff --git a/migrations/v16.go b/migrations/v16.go new file mode 100644 index 0000000..03ce78a --- /dev/null +++ b/migrations/v16.go @@ -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 +} diff --git a/posts.go b/posts.go index 19f01fd..f4ab8b1 100644 --- a/posts.go +++ b/posts.go @@ -49,6 +49,12 @@ const ( postIDLen = 10 postMetaDateFormat = "2006-01-02 15:04:05" +) + +type PostType string + +const ( + postArch PostType = "archive" shortCodePaid = "" ) @@ -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{ diff --git a/routes.go b/routes.go index efa79ea..a5b0f2f 100644 --- a/routes.go +++ b/routes.go @@ -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)) diff --git a/sitemap.go b/sitemap.go index 0bbcefb..22e4bb4 100644 --- a/sitemap.go +++ b/sitemap.go @@ -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 diff --git a/templates.go b/templates.go index 3bb7d13..484bb99 100644 --- a/templates.go +++ b/templates.go @@ -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...)) diff --git a/templates/collection-archive.tmpl b/templates/collection-archive.tmpl new file mode 100644 index 0000000..5256c58 --- /dev/null +++ b/templates/collection-archive.tmpl @@ -0,0 +1,118 @@ +{{define "collection"}} + + + + + Archive — {{.Collection.DisplayTitle}} + + + {{if .CustomCSS}}{{end}} + + + {{if gt .CurrentPage 1}}{{end}} + {{if lt .CurrentPage .TotalPages}}{{end}} + {{if not .IsPrivate}}{{end}} + + + + + + + + + + + + + + + + + {{template "collection-meta" .}} + {{if .StyleSheet}}{{end}} + + + + +
+ +
+

{{.Collection.DisplayTitle}}

+ +
+ +{{if .Posts -}} +
+ {{- else -}} +
+ {{- end}} + +

Archive

+ + {{if .Flash}} +
+

{{.Flash}}

+
+ {{end}} + + + + {{template "paging" .}} + + {{if .Posts}}
{{else}}{{end}} + + {{if .ShowFooterBranding }} + + {{ end }} + + + +{{if .CanShowScript}} + {{range .ExternalScripts}}{{end}} + {{if .Collection.Script}}{{end}} +{{end}} + +{{end}} diff --git a/templates/collection-post.tmpl b/templates/collection-post.tmpl index 54d5298..280ab0e 100644 --- a/templates/collection-post.tmpl +++ b/templates/collection-post.tmpl @@ -4,7 +4,7 @@ {{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}} - + {{if .CustomCSS}}{{end}} @@ -45,7 +45,7 @@ - +
@@ -55,12 +55,13 @@ {{range .PinnedPosts}}{{.PlainDisplayTitle}}{{end}} {{end}} {{ if and .IsOwner .IsFound }}{{largeNumFmt .Views}} {{pluralize "view" "views" .Views}} + {{if .Likes}}{{largeNumFmt .Likes}} {{pluralize "like" "likes" .Likes}}{{end}} Edit {{if .IsPinned}}Unpin{{end}} {{ end }}
- + {{if .Silenced}} {{template "user-silenced"}} {{end}} @@ -70,7 +71,7 @@ {{ end }} - + {{if .Collection.CanShowScript}} {{range .Collection.ExternalScripts}}{{end}} {{if .Collection.Script}}{{end}} diff --git a/templates/include/posts.tmpl b/templates/include/posts.tmpl index c3401fa..6a0d847 100644 --- a/templates/include/posts.tmpl +++ b/templates/include/posts.tmpl @@ -61,4 +61,16 @@ {{localstr "Read more..." .Language.String}}{{else}}
{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}{{end}}{{.HTMLContent}}
{{end}}{{ end }} {{ end }} -{{define "paid-badge"}} {{end}} \ No newline at end of file +{{define "paid-badge"}} {{end}} + +{{define "paging"}} + {{if gt .TotalPages 1}}{{end}} +{{end}} diff --git a/templates/user/stats.tmpl b/templates/user/stats.tmpl index b7f3322..a0c08ec 100644 --- a/templates/user/stats.tmpl +++ b/templates/user/stats.tmpl @@ -51,11 +51,13 @@ td.none { Post {{if not .Collection}}Blog{{end}} Total Views + {{if .Federation}}Likes{{end}} {{range .TopPosts}} {{if ne .DisplayTitle ""}}{{.DisplayTitle}}{{else}}{{.ID}}{{end}} {{ if not $.Collection }}{{if .Collection}}{{.Collection.Title}}{{else}}Draft{{end}}{{ end }} {{.ViewCount}} + {{if $.Federation}}{{.LikeCount}}{{end}} {{end}}