Compare commits
76 commits
Author | SHA1 | Date | |
---|---|---|---|
|
72fa575fee | ||
|
01e239e657 | ||
|
79ab0a3786 | ||
|
188b41ef53 | ||
|
c249abdb10 | ||
|
2b79ab0313 | ||
|
db66a885fb | ||
|
79a66ff140 | ||
|
4601951bbf | ||
|
e1db89311d | ||
|
f7966996d7 | ||
|
a7fa19f2e4 | ||
|
7b5d583b76 | ||
|
b765c02166 | ||
|
0f9c32161c | ||
|
63963b6b19 | ||
|
d9deb29730 | ||
|
e65b73dc73 | ||
|
0be229cdaa | ||
|
4504617c1a | ||
|
17039e620e | ||
|
fbf505cff0 | ||
|
3966f9fa40 | ||
|
387ddac892 | ||
|
e3b94d7fb5 | ||
|
121a21d900 | ||
|
eab66ee5fc | ||
|
83e0a57338 | ||
|
b643e0520f | ||
|
f88aa393c5 | ||
|
eca7bcda0a | ||
|
6b4179fa01 | ||
|
e29f371232 | ||
|
b7de165b76 | ||
|
f49c0b1c4c | ||
|
2fcd45819f | ||
|
d06077c432 | ||
|
5b6d17c9b9 | ||
|
c046dd04e7 | ||
|
6384f4667b | ||
|
82de4558de | ||
|
ec7d336bc2 | ||
|
6d57d9d6a1 | ||
|
7fbf49a0f0 | ||
|
76818287d6 | ||
|
646fff775c | ||
|
ec8e0b4a8b | ||
|
d1c4efc143 | ||
|
c2ea61c82e | ||
|
6e01bb7d94 | ||
|
94f12dfc29 | ||
|
9421cfd422 | ||
|
8193a41082 | ||
|
1b20d3704f | ||
|
9c0a2f8b13 | ||
|
74a0947fdb | ||
|
984e5bc415 | ||
|
7f1cc6bf8f | ||
|
0ce5d3ba26 | ||
|
8d3d7419cd | ||
|
6b47fd9e35 | ||
|
25808c7281 | ||
|
2f37c666fb | ||
|
4f3bacb182 | ||
|
71033ecc3d | ||
|
f80139456e | ||
|
5198add7aa | ||
|
096430f1cd | ||
|
4753eef550 | ||
|
d159e0a1ca | ||
|
52d6ea60f3 | ||
|
2a668d18d3 | ||
|
b9f50883a9 | ||
|
680c0f5564 | ||
|
1b69a89c59 | ||
|
b0b06ec945 |
35 changed files with 940 additions and 114 deletions
34
Dockerfile.prod
Normal file
34
Dockerfile.prod
Normal file
|
@ -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"]
|
14
Makefile
14
Makefile
|
@ -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)
|
||||
|
@ -41,6 +41,12 @@ build-darwin: deps
|
|||
fi
|
||||
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-darwin-arm64: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=darwin/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-arm6: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
|
@ -109,6 +115,10 @@ release : clean ui
|
|||
mv build/$(BINARY_NAME)-darwin-10.12-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-darwin-arm64
|
||||
mv build/$(BINARY_NAME)-darwin-arm64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_arm64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-windows
|
||||
mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe
|
||||
cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME)
|
||||
|
@ -145,5 +155,5 @@ clean :
|
|||
-rm -rf tmp
|
||||
cd less/; $(MAKE) clean $(MFLAGS)
|
||||
|
||||
force_look :
|
||||
force_look :
|
||||
true
|
||||
|
|
|
@ -69,6 +69,7 @@ For common platforms, start with our [pre-built binaries](https://github.com/wri
|
|||
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
|
||||
|
||||
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
||||
* [Nanos Repository](https://repo.ops.city/v2/packages/eyberg/writefreely/show)
|
||||
|
||||
## Documentation
|
||||
|
||||
|
@ -86,4 +87,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).
|
||||
|
|
33
account.go
33
account.go
|
@ -13,7 +13,7 @@ package writefreely
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/mailgun/mailgun-go"
|
||||
"github.com/writefreely/writefreely/mailer"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
@ -1378,13 +1378,19 @@ func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
|
||||
func emailPasswordReset(app *App, toEmail, token string) error {
|
||||
// Send email
|
||||
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
|
||||
mlr, err := mailer.New(app.cfg.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email."
|
||||
|
||||
plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)
|
||||
m := mailgun.NewMessage(app.cfg.App.SiteName+" <noreply-password@"+app.cfg.Email.Domain+">", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||
m, err := mlr.NewMessage(app.cfg.App.SiteName+" <noreply-password@"+app.cfg.Email.Domain+">", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.AddTag("Password Reset")
|
||||
m.SetHtml(fmt.Sprintf(`<html>
|
||||
m.SetHTML(fmt.Sprintf(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||
|
@ -1394,8 +1400,7 @@ func emailPasswordReset(app *App, toEmail, token string) error {
|
|||
</div>
|
||||
</body>
|
||||
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara))
|
||||
_, _, err := gun.Send(m)
|
||||
return err
|
||||
return mlr.Send(m)
|
||||
}
|
||||
|
||||
func loginViaEmail(app *App, alias, redirectTo string) error {
|
||||
|
@ -1424,15 +1429,21 @@ func loginViaEmail(app *App, alias, redirectTo string) error {
|
|||
}
|
||||
|
||||
// Send email
|
||||
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
|
||||
mlr, err := mailer.New(app.cfg.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toEmail := u.EmailClear(app.keys)
|
||||
footerPara := "This link will only work once and expires in 15 minutes. Didn't ask us to log in? You can safely ignore this email."
|
||||
|
||||
plainMsg := fmt.Sprintf("Log in to %s here: %s/login?to=%s&with=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, footerPara)
|
||||
m := mailgun.NewMessage(app.cfg.App.SiteName+" <noreply-login@"+app.cfg.Email.Domain+">", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||
m, err := mlr.NewMessage(app.cfg.App.SiteName+" <noreply-login@"+app.cfg.Email.Domain+">", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.AddTag("Email Login")
|
||||
|
||||
m.SetHtml(fmt.Sprintf(`<html>
|
||||
m.SetHTML(fmt.Sprintf(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||
|
@ -1441,9 +1452,7 @@ func loginViaEmail(app *App, alias, redirectTo string) error {
|
|||
</div>
|
||||
</body>
|
||||
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, app.cfg.App.SiteName, footerPara))
|
||||
_, _, err = gun.Send(m)
|
||||
|
||||
return err
|
||||
return mlr.Send(m)
|
||||
}
|
||||
|
||||
func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
|
||||
|
|
207
activitypub.go
207
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
|
||||
|
||||
|
@ -381,6 +436,17 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
a.AppendObject(f.Raw())
|
||||
_, to = f.GetActor(0)
|
||||
obj := f.Raw().GetObjectIRI(0)
|
||||
if obj == nil {
|
||||
if debugging {
|
||||
log.Error("GetObjectIRI on Follow for actor is empty; trying object")
|
||||
}
|
||||
ao := f.Raw().GetObject(0)
|
||||
if ao == nil {
|
||||
log.Error("Fell back to GetObject and none parsed, so no actor ID! Follow request probably FAILED!")
|
||||
} else {
|
||||
obj = ao.GetId()
|
||||
}
|
||||
}
|
||||
a.AppendActor(obj)
|
||||
|
||||
// First get actor information
|
||||
|
@ -394,8 +460,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 +467,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 +530,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 +639,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 +1135,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()))
|
||||
}
|
||||
|
|
2
admin.go
2
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)
|
||||
|
|
25
app.go
25
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"
|
||||
|
@ -428,15 +429,11 @@ func Initialize(apper Apper, debug bool) (*App, error) {
|
|||
|
||||
initActivityPub(apper.App())
|
||||
|
||||
if apper.App().cfg.Email.Domain != "" || apper.App().cfg.Email.MailgunPrivate != "" {
|
||||
if apper.App().cfg.Email.Domain == "" {
|
||||
log.Error("[FAILED] Starting publish jobs queue: no [letters]domain config value set.")
|
||||
} else if apper.App().cfg.Email.MailgunPrivate == "" {
|
||||
log.Error("[FAILED] Starting publish jobs queue: no [letters]mailgun_private config value set.")
|
||||
} else {
|
||||
log.Info("Starting publish jobs queue...")
|
||||
go startPublishJobsQueue(apper.App())
|
||||
}
|
||||
if apper.App().cfg.Email.Enabled() {
|
||||
log.Info("Starting publish jobs queue...")
|
||||
go startPublishJobsQueue(apper.App())
|
||||
} else {
|
||||
log.Error("[FAILED] Starting publish jobs queue: no email provider is configured.")
|
||||
}
|
||||
|
||||
// Handle local timeline, if enabled
|
||||
|
@ -892,12 +889,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]")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
@ -881,16 +884,29 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
// Serve ActivityStreams data now, if requested
|
||||
if IsActivityPubRequest(r) {
|
||||
ac := c.PersonObject()
|
||||
ac.Context = []interface{}{activitystreams.Namespace}
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ac, http.StatusOK)
|
||||
}
|
||||
|
||||
// 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 +915,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 +974,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 +1003,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 +1042,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 +1140,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)
|
||||
|
||||
|
|
|
@ -171,8 +171,17 @@ type (
|
|||
}
|
||||
|
||||
EmailCfg struct {
|
||||
// SMTP configuration values
|
||||
Host string `ini:"smtp_host"`
|
||||
Port int `ini:"smtp_port"`
|
||||
Username string `ini:"smtp_username"`
|
||||
Password string `ini:"smtp_password"`
|
||||
EnableStartTLS bool `ini:"smtp_enable_start_tls"`
|
||||
|
||||
// Mailgun configuration values
|
||||
Domain string `ini:"domain"`
|
||||
MailgunPrivate string `ini:"mailgun_private"`
|
||||
MailgunEurope bool `ini:"mailgun_europe"`
|
||||
}
|
||||
|
||||
// Config holds the complete configuration for running a writefreely instance
|
||||
|
@ -242,7 +251,8 @@ func (ac *AppCfg) LandingPath() string {
|
|||
}
|
||||
|
||||
func (lc EmailCfg) Enabled() bool {
|
||||
return lc.Domain != "" && lc.MailgunPrivate != ""
|
||||
return (lc.Domain != "" && lc.MailgunPrivate != "") ||
|
||||
lc.Username != "" && lc.Password != "" && lc.Host != "" && lc.Port > 0
|
||||
}
|
||||
|
||||
func (ac AppCfg) SignupPath() string {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
53
database.go
53
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 {
|
||||
|
|
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
|
||||
}
|
25
docker-compose.prod.yml
Normal file
25
docker-compose.prod.yml
Normal file
|
@ -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
|
37
email.go
37
email.go
|
@ -14,6 +14,7 @@ import (
|
|||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/writefreely/writefreely/mailer"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
@ -21,7 +22,6 @@ import (
|
|||
|
||||
"github.com/aymerick/douceur/inliner"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mailgun/mailgun-go"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/data"
|
||||
|
@ -307,8 +307,14 @@ Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.Ca
|
|||
|
||||
Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%`
|
||||
|
||||
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
|
||||
m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg)
|
||||
mlr, err := mailer.New(app.cfg.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := mlr.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo)
|
||||
if replyTo != "" {
|
||||
m.SetReplyTo(replyTo)
|
||||
|
@ -405,13 +411,13 @@ Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/un
|
|||
return err
|
||||
}
|
||||
|
||||
m.SetHtml(html)
|
||||
m.SetHTML(html)
|
||||
|
||||
log.Info("[email] Adding %d recipient(s)", len(subs))
|
||||
for _, s := range subs {
|
||||
e := s.FinalEmail(app.keys)
|
||||
log.Info("[email] Adding %s", e)
|
||||
err = m.AddRecipientAndVariables(e, map[string]interface{}{
|
||||
err = m.AddRecipientAndVariables(e, map[string]string{
|
||||
"id": s.ID,
|
||||
"to": e,
|
||||
"token": s.Token,
|
||||
|
@ -421,8 +427,8 @@ Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/un
|
|||
}
|
||||
}
|
||||
|
||||
res, _, err := gun.Send(m)
|
||||
log.Info("[email] Send result: %s", res)
|
||||
err = mlr.Send(m)
|
||||
log.Info("[email] Email sent")
|
||||
if err != nil {
|
||||
log.Error("Unable to send post email: %v", err)
|
||||
return err
|
||||
|
@ -437,17 +443,23 @@ func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) er
|
|||
}
|
||||
|
||||
// Send email
|
||||
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
|
||||
mlr, err := mailer.New(app.cfg.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser):
|
||||
|
||||
` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + `
|
||||
|
||||
If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.`
|
||||
m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email))
|
||||
m, err := mlr.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.AddTag("Email Verification")
|
||||
|
||||
m.SetHtml(`<html>
|
||||
m.SetHTML(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="font-size: 1.2em;">
|
||||
<p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p>
|
||||
|
@ -456,7 +468,10 @@ If you didn't subscribe to this site or you're not sure why you're getting this
|
|||
</div>
|
||||
</body>
|
||||
</html>`)
|
||||
gun.Send(m)
|
||||
err = mlr.Send(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -119,7 +119,7 @@ func compileFullExport(app *App, u *User) *ExportUser {
|
|||
var collObjs []CollectionObj
|
||||
for _, c := range *colls {
|
||||
co := &CollectionObj{Collection: c}
|
||||
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true)
|
||||
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true, "")
|
||||
if err != nil {
|
||||
log.Error("unable to get collection posts: %v", err)
|
||||
}
|
||||
|
|
2
feed.go
2
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 := ""
|
||||
|
|
19
go.mod
19
go.mod
|
@ -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
|
||||
|
@ -26,7 +27,7 @@ require (
|
|||
github.com/mailgun/mailgun-go v2.0.0+incompatible
|
||||
github.com/manifoldco/promptui v0.9.0
|
||||
github.com/mattn/go-sqlite3 v1.14.21
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mitchellh/go-wordwrap v1.0.1
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
||||
github.com/onsi/ginkgo v1.16.4 // indirect
|
||||
|
@ -45,14 +46,15 @@ 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 github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
|
||||
require (
|
||||
code.as/core/socks v1.0.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
|
@ -67,8 +69,9 @@ require (
|
|||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect
|
||||
github.com/gofrs/uuid v3.3.0+incompatible // indirect
|
||||
github.com/gologme/log v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // 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
|
||||
|
@ -81,11 +84,13 @@ require (
|
|||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sasha-s/go-deadlock v0.3.1 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // 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
|
||||
)
|
||||
|
|
34
go.sum
34
go.sum
|
@ -74,12 +74,14 @@ 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=
|
||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/feeds v1.1.2 h1:pxzZ5PD3RJdhFH2FsJJ4x6PqMqbgFk1+Vez4XWBW8Iw=
|
||||
github.com/gorilla/feeds v1.1.2/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
|
||||
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
|
@ -91,6 +93,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 +114,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=
|
||||
|
@ -125,8 +133,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
|||
github.com/mattn/go-sqlite3 v1.14.21 h1:IXocQLOykluc3xPE0Lvy8FtggMz1G+U3mEjg+0zGizc=
|
||||
github.com/mattn/go-sqlite3 v1.14.21/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
|
||||
|
@ -170,6 +178,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
|||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
|
||||
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
|
||||
github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
|
||||
|
@ -205,6 +215,8 @@ github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ5
|
|||
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b/go.mod h1:T2UVVzt+R5KSSZe2xRSytnwc2M9AoDegi7foeIsik+M=
|
||||
github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
|
||||
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
@ -215,8 +227,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 +247,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 +275,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 +291,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=
|
||||
|
|
|
@ -111,7 +111,7 @@ func handleGopherCollection(app *App, w gopher.ResponseWriter, r *gopher.Request
|
|||
w.WriteInfo(c.Description)
|
||||
}
|
||||
|
||||
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false)
|
||||
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ body {
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: white;
|
||||
color: #111;
|
||||
|
||||
|
||||
h1, header h2 {
|
||||
a {
|
||||
color: @headerTextColor;
|
||||
|
@ -672,6 +672,26 @@ body#collection article, body#subpage article {
|
|||
}
|
||||
}
|
||||
}
|
||||
#wrapper.archive {
|
||||
h1 {
|
||||
margin: 0 !important;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
line-height: 1.4;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.year {
|
||||
font-weight: bold;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
body#post article {
|
||||
p.badge {
|
||||
font-size: 0.9em;
|
||||
|
@ -1101,7 +1121,7 @@ li {
|
|||
}
|
||||
}
|
||||
|
||||
ul.errors {
|
||||
ul.errors {
|
||||
padding: 0;
|
||||
text-indent: 0;
|
||||
li.urgent {
|
||||
|
@ -1637,4 +1657,4 @@ p#emailsub {
|
|||
font-weight: bold;
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ body {
|
|||
&:hover {
|
||||
.opacity(1);
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
@ -30,7 +30,7 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
article, pre, .hljs {
|
||||
article, pre, .hljs, #wrapper.archive ul {
|
||||
padding: 0.5em 2rem 1.5em;
|
||||
}
|
||||
body#post article, pre, .hljs {
|
||||
|
|
181
mailer/mailer.go
Normal file
181
mailer/mailer.go
Normal file
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* 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 mailer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/mailgun/mailgun-go"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
mail "github.com/xhit/go-simple-mail/v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
// Mailer holds configurations for the preferred mailing provider.
|
||||
Mailer struct {
|
||||
smtp *mail.SMTPServer
|
||||
mailGun *mailgun.MailgunImpl
|
||||
}
|
||||
|
||||
// Message holds the email contents and metadata for the preferred mailing provider.
|
||||
Message struct {
|
||||
mgMsg *mailgun.Message
|
||||
smtpMsg *SmtpMessage
|
||||
}
|
||||
|
||||
SmtpMessage struct {
|
||||
from string
|
||||
replyTo string
|
||||
subject string
|
||||
recipients []Recipient
|
||||
html string
|
||||
text string
|
||||
}
|
||||
|
||||
Recipient struct {
|
||||
email string
|
||||
vars map[string]string
|
||||
}
|
||||
)
|
||||
|
||||
// New creates a new Mailer from the instance's config.EmailCfg, returning an error if not properly configured.
|
||||
func New(eCfg config.EmailCfg) (*Mailer, error) {
|
||||
m := &Mailer{}
|
||||
if eCfg.Domain != "" && eCfg.MailgunPrivate != "" {
|
||||
m.mailGun = mailgun.NewMailgun(eCfg.Domain, eCfg.MailgunPrivate)
|
||||
if eCfg.MailgunEurope {
|
||||
m.mailGun.SetAPIBase("https://api.eu.mailgun.net/v3")
|
||||
}
|
||||
} else if eCfg.Username != "" && eCfg.Password != "" && eCfg.Host != "" && eCfg.Port > 0 {
|
||||
m.smtp = mail.NewSMTPClient()
|
||||
m.smtp.Host = eCfg.Host
|
||||
m.smtp.Port = eCfg.Port
|
||||
m.smtp.Username = eCfg.Username
|
||||
m.smtp.Password = eCfg.Password
|
||||
if eCfg.EnableStartTLS {
|
||||
m.smtp.Encryption = mail.EncryptionSTARTTLS
|
||||
}
|
||||
// To allow sending multiple email
|
||||
m.smtp.KeepAlive = true
|
||||
} else {
|
||||
return nil, fmt.Errorf("no email provider is configured")
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// NewMessage creates a new Message from the given parameters.
|
||||
func (m *Mailer) NewMessage(from, subject, text string, to ...string) (*Message, error) {
|
||||
msg := &Message{}
|
||||
if m.mailGun != nil {
|
||||
msg.mgMsg = m.mailGun.NewMessage(from, subject, text, to...)
|
||||
} else if m.smtp != nil {
|
||||
msg.smtpMsg = &SmtpMessage{
|
||||
from: from,
|
||||
replyTo: "",
|
||||
subject: subject,
|
||||
recipients: make([]Recipient, len(to)),
|
||||
html: "",
|
||||
text: text,
|
||||
}
|
||||
for _, r := range to {
|
||||
msg.smtpMsg.recipients = append(msg.smtpMsg.recipients, Recipient{r, make(map[string]string)})
|
||||
}
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// SetHTML sets the body of the message.
|
||||
func (m *Message) SetHTML(html string) {
|
||||
if m.smtpMsg != nil {
|
||||
m.smtpMsg.html = html
|
||||
} else if m.mgMsg != nil {
|
||||
m.mgMsg.SetHtml(html)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Message) SetReplyTo(replyTo string) {
|
||||
if m.smtpMsg != nil {
|
||||
m.smtpMsg.replyTo = replyTo
|
||||
} else {
|
||||
m.mgMsg.SetReplyTo(replyTo)
|
||||
}
|
||||
}
|
||||
|
||||
// AddTag attaches a tag to the Message for providers that support it.
|
||||
func (m *Message) AddTag(tag string) {
|
||||
if m.mgMsg != nil {
|
||||
m.mgMsg.AddTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Message) AddRecipientAndVariables(r string, vars map[string]string) error {
|
||||
if m.smtpMsg != nil {
|
||||
m.smtpMsg.recipients = append(m.smtpMsg.recipients, Recipient{r, vars})
|
||||
return nil
|
||||
} else {
|
||||
varsInterfaces := make(map[string]interface{}, len(vars))
|
||||
for k, v := range vars {
|
||||
varsInterfaces[k] = v
|
||||
}
|
||||
return m.mgMsg.AddRecipientAndVariables(r, varsInterfaces)
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends the given message via the preferred provider.
|
||||
func (m *Mailer) Send(msg *Message) error {
|
||||
if m.smtp != nil {
|
||||
client, err := m.smtp.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
emailSent := false
|
||||
for _, r := range msg.smtpMsg.recipients {
|
||||
customMsg := mail.NewMSG()
|
||||
customMsg.SetFrom(msg.smtpMsg.from)
|
||||
if msg.smtpMsg.replyTo != "" {
|
||||
customMsg.SetReplyTo(msg.smtpMsg.replyTo)
|
||||
}
|
||||
customMsg.SetSubject(msg.smtpMsg.subject)
|
||||
customMsg.AddTo(r.email)
|
||||
cText := msg.smtpMsg.text
|
||||
cHtml := msg.smtpMsg.html
|
||||
for v, value := range r.vars {
|
||||
placeHolder := fmt.Sprintf("%%recipient.%s%%", v)
|
||||
cText = strings.ReplaceAll(cText, placeHolder, value)
|
||||
cHtml = strings.ReplaceAll(cHtml, placeHolder, value)
|
||||
}
|
||||
customMsg.SetBody(mail.TextHTML, cHtml)
|
||||
customMsg.AddAlternative(mail.TextPlain, cText)
|
||||
e := customMsg.Error
|
||||
if e == nil {
|
||||
e = customMsg.Send(client)
|
||||
}
|
||||
if e == nil {
|
||||
emailSent = true
|
||||
} else {
|
||||
log.Error("Unable to send email to %s: %v", r.email, e)
|
||||
err = e
|
||||
}
|
||||
}
|
||||
if !emailSent {
|
||||
// only send an error if no email could be sent (to avoid retry of successfully sent emails)
|
||||
return err
|
||||
}
|
||||
} else if m.mailGun != nil {
|
||||
_, _, err := m.mailGun.Send(msg.mgMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -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
|
||||
|
|
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
|
||||
}
|
|
@ -13,7 +13,7 @@ package writefreely
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/writeas/slug"
|
||||
"github.com/gosimple/slug"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
|
15
posts.go
15
posts.go
|
@ -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"
|
||||
|
@ -49,6 +49,12 @@ const (
|
|||
postIDLen = 10
|
||||
|
||||
postMetaDateFormat = "2006-01-02 15:04:05"
|
||||
)
|
||||
|
||||
type PostType string
|
||||
|
||||
const (
|
||||
postArch PostType = "archive"
|
||||
|
||||
shortCodePaid = "<!--paid-->"
|
||||
)
|
||||
|
@ -105,6 +111,7 @@ type (
|
|||
Created time.Time `db:"created" json:"created"`
|
||||
Updated time.Time `db:"updated" json:"updated"`
|
||||
ViewCount int64 `db:"view_count" json:"-"`
|
||||
LikeCount int64 `db:"like_count" json:"likes"`
|
||||
Title zero.String `db:"title" json:"title"`
|
||||
HTMLTitle template.HTML `db:"title" json:"-"`
|
||||
Content string `db:"content" json:"body"`
|
||||
|
@ -127,6 +134,7 @@ type (
|
|||
IsTopLevel bool `json:"-"`
|
||||
DisplayDate string `json:"-"`
|
||||
Views int64 `json:"views"`
|
||||
Likes int64 `json:"likes"`
|
||||
Owner *PublicUser `json:"-"`
|
||||
IsOwner bool `json:"-"`
|
||||
URL string `json:"url,omitempty"`
|
||||
|
@ -1184,6 +1192,7 @@ func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
func (p *Post) processPost() PublicPost {
|
||||
res := &PublicPost{Post: p, Views: 0}
|
||||
res.Views = p.ViewCount
|
||||
res.Likes = p.LikeCount
|
||||
// TODO: move to own function
|
||||
loc := monday.FuzzyLocale(p.Language.String)
|
||||
res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
|
||||
|
@ -1507,6 +1516,10 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
|
|||
// User tried to access blog feed without a trailing slash, and
|
||||
// there's no post with a slug "feed"
|
||||
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "feed/"}
|
||||
} else if slug == "archive" {
|
||||
// User tried to access blog Archive without a trailing slash, and
|
||||
// there's no post with a slug "archive"
|
||||
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "archive/"}
|
||||
}
|
||||
|
||||
po := &Post{
|
||||
|
|
10
routes.go
10
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("/{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("/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/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
|
||||
|
@ -221,6 +221,8 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
|||
func RouteCollections(handler *Handler, r *mux.Router) {
|
||||
r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional))
|
||||
r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
|
||||
r.HandleFunc("/archive/", handler.Web(handleViewCollection, UserLevelReader))
|
||||
r.HandleFunc("/{archive:archive}/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
|
||||
r.HandleFunc("/lang:{lang:[a-z]{2}}", handler.Web(handleViewCollectionLang, UserLevelOptional))
|
||||
r.HandleFunc("/lang:{lang:[a-z]{2}}/page/{page:[0-9]+}", handler.Web(handleViewCollectionLang, UserLevelOptional))
|
||||
r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
|
||||
|
|
|
@ -66,7 +66,7 @@ func handleViewSitemap(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
host = c.CanonicalURL()
|
||||
|
||||
sm := buildSitemap(host, pre)
|
||||
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false)
|
||||
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false, "")
|
||||
if err != nil {
|
||||
log.Error("Error getting posts: %v", err)
|
||||
return err
|
||||
|
|
|
@ -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, "<");
|
||||
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>';
|
||||
|
|
|
@ -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...))
|
||||
|
|
118
templates/collection-archive.tmpl
Normal file
118
templates/collection-archive.tmpl
Normal file
|
@ -0,0 +1,118 @@
|
|||
{{define "collection"}}<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article#">
|
||||
<meta charset="utf-8">
|
||||
|
||||
<title>Archive — {{.Collection.DisplayTitle}}</title>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="canonical" href="{{.CanonicalURL}}">
|
||||
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
|
||||
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
|
||||
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} » Feed" href="{{.CanonicalURL}}feed/" />{{end}}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<meta name="generator" content="WriteFreely">
|
||||
<meta name="description" content="{{.PlainDescription}}">
|
||||
<meta itemprop="name" content="{{.DisplayTitle}}">
|
||||
<meta itemprop="description" content="{{.PlainDescription}}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{.DisplayTitle}}">
|
||||
<meta name="twitter:image" content="{{.AvatarURL}}">
|
||||
<meta name="twitter:description" content="{{.PlainDescription}}">
|
||||
<meta property="og:title" content="{{.DisplayTitle}}" />
|
||||
<meta property="og:site_name" content="{{.DisplayTitle}}" />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:url" content="{{.CanonicalURL}}" />
|
||||
<meta property="og:description" content="{{.PlainDescription}}" />
|
||||
<meta property="og:image" content="{{.AvatarURL}}">
|
||||
{{template "collection-meta" .}}
|
||||
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
|
||||
|
||||
</head>
|
||||
<body id="subpage">
|
||||
|
||||
<div id="overlay"></div>
|
||||
|
||||
<header>
|
||||
<h1 dir="{{.Direction}}" id="blog-title"><a href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1>
|
||||
<nav>
|
||||
{{if .PinnedPosts}}
|
||||
{{range .PinnedPosts}}<a class="pinned" href="{{if $.IsOwner}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL}}{{end}}">{{.DisplayTitle}}</a>{{end}}
|
||||
{{end}}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{{if .Posts -}}
|
||||
<section id="wrapper" class="archive" itemscope itemtype="http://schema.org/Blog">
|
||||
{{- else -}}
|
||||
<div id="wrapper" class="archive">
|
||||
{{- end}}
|
||||
|
||||
<h1>Archive</h1>
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="alert success flash">
|
||||
<p>{{.Flash}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<ul>
|
||||
{{ $curYear := 0 }}
|
||||
{{ range $el := .Posts }}
|
||||
{{if ne $curYear .Created.Year}}<li class="year">{{.Created.Year}}</li>{{ $curYear = .Created.Year }}{{end}}
|
||||
<li>
|
||||
{{if .HasTitleLink -}}
|
||||
{{.HTMLTitleArrow}}
|
||||
{{- else -}}
|
||||
<a href="{{if $.SingleUser}}/{{else}}/{{$.Alias}}/{{end}}{{.Slug.String}}" itemprop="url" class="u-url">
|
||||
{{- if .DisplayTitle -}}
|
||||
{{- .DisplayTitle -}}
|
||||
{{- else -}}
|
||||
(Untitled)
|
||||
{{end}}
|
||||
</a>
|
||||
{{- end}}
|
||||
{{if .IsScheduled}}[Scheduled]{{end}}
|
||||
{{if $.Format.ShowDates -}}
|
||||
<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">
|
||||
{{- if .HasTitleLink -}}
|
||||
<a href="{{if $.SingleUser}}/{{else}}/{{$.Alias}}/{{end}}{{.Slug.String}}" itemprop="url">
|
||||
{{- end -}}
|
||||
{{.DisplayDate}}
|
||||
{{- if .HasTitleLink -}}
|
||||
{{- if .IsPaid}}{{template "paid-badge" (dict "CDNHost" $.CDNHost)}}{{end -}}</a>
|
||||
{{- end -}}
|
||||
</time>
|
||||
{{- else -}}
|
||||
{{- if .HasTitleLink -}}
|
||||
<a href="{{if $.SingleUser}}/{{else}}/{{$.Alias}}/{{end}}{{.Slug.String}}" itemprop="url">view</a>
|
||||
{{- end -}}
|
||||
{{- end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
{{template "paging" .}}
|
||||
|
||||
{{if .Posts}}</section>{{else}}</div>{{end}}
|
||||
|
||||
{{if .ShowFooterBranding }}
|
||||
<footer>
|
||||
<hr />
|
||||
<nav dir="ltr">
|
||||
{{if not .SingleUser}}<a class="home pubd" href="/">{{.SiteName}}</a> · {{end}}powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a>
|
||||
</nav>
|
||||
</footer>
|
||||
{{ end }}
|
||||
|
||||
</body>
|
||||
|
||||
{{if .CanShowScript}}
|
||||
{{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
|
||||
{{if .Collection.Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
|
||||
{{end}}
|
||||
<script src="/js/localdate.js"></script>
|
||||
</html>{{end}}
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
|
||||
<title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title>
|
||||
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
|
@ -45,7 +45,7 @@
|
|||
|
||||
</head>
|
||||
<body id="post">
|
||||
|
||||
|
||||
<div id="overlay"></div>
|
||||
|
||||
<header>
|
||||
|
@ -55,12 +55,13 @@
|
|||
{{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}
|
||||
{{end}}
|
||||
{{ if and .IsOwner .IsFound }}<span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span>
|
||||
<a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a>
|
||||
{{if .Likes}}<span class="views" dir="ltr"><strong>{{largeNumFmt .Likes}}</strong> {{pluralize "like" "likes" .Likes}}</span>{{end}}
|
||||
<a href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a>
|
||||
{{if .IsPinned}}<a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">Unpin</a>{{end}}
|
||||
{{ end }}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
||||
{{if .Silenced}}
|
||||
{{template "user-silenced"}}
|
||||
{{end}}
|
||||
|
@ -70,7 +71,7 @@
|
|||
<footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav></footer>
|
||||
{{ end }}
|
||||
</body>
|
||||
|
||||
|
||||
{{if .Collection.CanShowScript}}
|
||||
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
|
||||
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
|
||||
|
|
|
@ -61,4 +61,16 @@
|
|||
<a class="read-more" href="{{$.CanonicalURL}}{{.Slug.String}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}" class="book e-content">{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}<a class="hidden action" href="{{if $.IsOwner}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}}{{.HTMLContent}}</div>{{end}}</article>{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{define "paid-badge"}}<img class="paid" alt="Paid article" src="/img/paidarticle.svg" /> {{end}}
|
||||
{{define "paid-badge"}}<img class="paid" alt="Paid article" src="/img/paidarticle.svg" /> {{end}}
|
||||
|
||||
{{define "paging"}}
|
||||
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
||||
{{else}}
|
||||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
||||
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}}
|
||||
{{end}}
|
||||
</nav>{{end}}
|
||||
{{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…
Add table
Reference in a new issue