Merge branch 'develop' of github.com:writefreely/writefreely into develop

pull/1128/head^2
Matt Baer 2 weeks ago
commit 121a21d900
  1. 34
      Dockerfile.prod
  2. 2
      Makefile
  3. 194
      activitypub.go
  4. 4
      app.go
  5. 18
      config/setup.go
  6. 19
      database.go
  7. 49
      database_activitypub.go
  8. 25
      docker-compose.prod.yml
  9. 12
      go.mod
  10. 23
      go.sum
  11. 1
      migrations/migrations.go
  12. 38
      migrations/v16.go
  13. 2
      oauth_slack.go
  14. 5
      posts.go
  15. 8
      routes.go
  16. 7
      static/js/posts.js
  17. 9
      templates/collection-post.tmpl
  18. 3
      templates/user/articles.tmpl
  19. 2
      templates/user/stats.tmpl

@ -0,0 +1,34 @@
FROM golang:alpine AS build
LABEL org.opencontainers.image.source="https://github.com/writefreely/writefreely"
LABEL org.opencontainers.image.description="WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing."
RUN apk update --no-cache && \
apk upgrade --no-cache && \
apk add --no-cache nodejs npm make g++ git sqlite-dev patch && \
npm install -g less less-plugin-clean-css && \
mkdir -p /go/src/github.com/writefreely/writefreely
COPY . /go/src/github.com/writefreely/writefreely
WORKDIR /go/src/github.com/writefreely/writefreely
ENV NODE_OPTIONS=--openssl-legacy-provider
RUN cat ossl_legacy.cnf >> /etc/ssl/openssl.cnf && \
make build && \
make ui
FROM alpine
RUN apk update --no-cache && \
apk upgrade --no-cache && \
apk add --no-cache openssl ca-certificates && \
mkdir /usr/share/writefreely
COPY --from=build /go/src/github.com/writefreely/writefreely/cmd/writefreely/writefreely /usr/bin
COPY --from=build /go/src/github.com/writefreely/writefreely/pages /usr/share/writefreely/pages
COPY --from=build /go/src/github.com/writefreely/writefreely/static /usr/share/writefreely/static
COPY --from=build /go/src/github.com/writefreely/writefreely/templates /usr/share/writefreely/templates
ENV WRITEFREELY_DOCKER=True
ENV HOME=/data
WORKDIR /data
CMD ["/usr/bin/writefreely"]

@ -1,5 +1,5 @@
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
GOINSTALL=$(GOCMD) install $(LDFLAGS)

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

@ -892,12 +892,12 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
if isAdmin {
// Abort if trying to create admin user, but one already exists
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 {
// Abort if trying to create regular user, but no admin exists yet
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]")
}
}

@ -12,12 +12,14 @@ package config
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/fatih/color"
"github.com/manifoldco/promptui"
"github.com/mitchellh/go-wordwrap"
"github.com/writeas/web-core/auth"
"strconv"
"strings"
)
type SetupData struct {
@ -80,6 +82,8 @@ func Configure(fname string, configSections string) (*SetupData, error) {
isDevEnv := envType == "Development"
isStandalone := envType == "Production, standalone"
_, isDocker := os.LookupEnv("WRITEFREELY_DOCKER")
data.Config.Server.Dev = isDevEnv
if isDevEnv || !isStandalone {
@ -150,6 +154,16 @@ func Configure(fname string, configSections string) (*SetupData, error) {
data.Config.Server.TLSKeyPath = ""
}
// If running in docker:
// 1. always bind to 0.0.0.0 instead of localhost
// 2. set paths of static files in UNIX manners
if !isDevEnv && isDocker {
data.Config.Server.TemplatesParentDir = "/usr/share/writefreely"
data.Config.Server.StaticParentDir = "/usr/share/writefreely"
data.Config.Server.PagesParentDir = "/usr/share/writefreely"
data.Config.Server.Bind = "0.0.0.0"
}
fmt.Println()
}

@ -115,6 +115,7 @@ type writestore interface {
DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error)
ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
GetPostLikeCounts(postID string) (int64, error)
GetPostsCount(c *CollectionObj, includeFuture bool)
GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error)
GetAllPostsTaggedIDs(c *Collection, tag string, includeFuture bool) ([]string, error)
@ -1174,6 +1175,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,6 +1243,18 @@ 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.

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

@ -0,0 +1,25 @@
services:
app:
image: writefreely
container_name: writefreely
volumes:
- ./data:/data
ports:
- 127.0.0.1:8080:8080
depends_on:
- db
restart: unless-stopped
db:
image: lscr.io/linuxserver/mariadb
container_name: writefreely-mariadb
volumes:
- ./db:/config
environment:
- PUID=65534
- PGID=65534
- TZ=Etc/UTC
- MYSQL_DATABASE=writefreely
- MYSQL_USER=writefreely
- MYSQL_PASSWORD=P@ssw0rd
restart: unless-stopped

@ -19,6 +19,7 @@ require (
github.com/gorilla/mux v1.8.1
github.com/gorilla/schema v1.4.1
github.com/gorilla/sessions v1.3.0
github.com/gosimple/slug v1.14.0
github.com/guregu/null v4.0.0+incompatible
github.com/hashicorp/go-multierror v1.1.1
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
@ -45,12 +46,11 @@ require (
github.com/writeas/import v0.2.1
github.com/writeas/monday v1.3.0
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
github.com/writeas/slug v1.2.0
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b
github.com/writefreely/go-nodeinfo v1.2.0
golang.org/x/crypto v0.24.0
golang.org/x/net v0.26.0
golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0
)
require (
@ -69,6 +69,7 @@ require (
github.com/gologme/log v1.2.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/joho/godotenv v1.3.0 // indirect
github.com/jtolds/gls v4.2.1+incompatible // indirect
@ -83,9 +84,10 @@ require (
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/writeas/go-writeas/v2 v2.0.2 // indirect
github.com/writeas/openssl-go v1.0.0 // indirect
github.com/writeas/slug v1.2.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/ini.v1 v1.62.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

@ -74,6 +74,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
@ -91,6 +92,10 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es=
github.com/gosimple/slug v1.14.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw=
github.com/guregu/null v4.0.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
@ -108,9 +113,11 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g=
github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o=
@ -215,8 +222,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -235,8 +242,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -263,8 +270,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -279,8 +286,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=

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

@ -13,7 +13,7 @@ package writefreely
import (
"context"
"errors"
"github.com/writeas/slug"
"github.com/gosimple/slug"
"net/http"
"net/url"
"strings"

@ -23,6 +23,7 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/gosimple/slug"
"github.com/guregu/null"
"github.com/guregu/null/zero"
"github.com/kylemcc/twitter-text-go/extract"
@ -30,7 +31,6 @@ import (
stripmd "github.com/writeas/go-strip-markdown/v2"
"github.com/writeas/impart"
"github.com/writeas/monday"
"github.com/writeas/slug"
"github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/bots"
"github.com/writeas/web-core/converter"
@ -105,6 +105,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 +128,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 +1186,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)

@ -159,10 +159,10 @@ 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("/{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("/{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")

@ -188,21 +188,22 @@ var movePostHTML = function(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');
let p = H.createPost(post.id, "", post.body)
var title = (post.title || p.title || post.id);
title = title.replace(/</g, "&lt;");
var postLink = (singleUser ? '/d/' : '/') + post.id
$post.id = 'post-' + post.id;
$post.className = 'post';
$post.innerHTML = '<h3><a href="/' + post.id + '">' + title + '</a></h3>';
$post.innerHTML = '<h3><a href="' + postLink + '">' + title + '</a></h3>';
var posted = "";
if (post.created) {
posted = getFormattedDate(new Date(post.created))
}
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) {
$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>';

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

@ -202,8 +202,9 @@ function loadMorePosts() {
if (http.readyState == 4) {
if (http.status == 200) {
var data = JSON.parse(http.responseText);
var singleUser = {{ .SingleUser }};
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) {
$loadMore.el.parentNode.removeChild($loadMore.el);

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