Big refactoring: Avoid global vars almost everywhere

pull/7/head
Jan-Lukas Else 2 years ago
parent 9f9ff58a0d
commit 9714d65679

42
.vscode/tasks.json vendored

@ -1,15 +1,33 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build",
"type": "shell",
"command": "go build --tags \"libsqlite3 linux sqlite_fts5\"",
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
"version": "2.0.0",
"tasks": [
{
"label": "Build",
"type": "shell",
"command": "go build",
"options": {
"env": {
"GOFLAGS": "-tags=linux,libsqlite3,sqlite_fts5"
}
},
"group": {
"kind": "build",
"isDefault": true
}
]
}
},
{
"label": "Test",
"type": "shell",
"command": "go test",
"options": {
"env": {
"GOFLAGS": "-tags=linux,libsqlite3,sqlite_fts5"
}
},
"group": {
"kind": "test",
"isDefault": true
}
}
]
}

@ -1,7 +1,6 @@
package main
import (
"crypto/rsa"
"crypto/x509"
"database/sql"
"encoding/json"
@ -14,50 +13,41 @@ import (
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-fed/httpsig"
)
var (
apPrivateKey *rsa.PrivateKey
apPostSigner httpsig.Signer
apPostSignMutex *sync.Mutex = &sync.Mutex{}
webfingerResources map[string]*configBlog
webfingerAccts map[string]string
)
func initActivityPub() error {
if !appConfig.ActivityPub.Enabled {
func (a *goBlog) initActivityPub() error {
if !a.cfg.ActivityPub.Enabled {
return nil
}
// Add hooks
postPostHooks = append(postPostHooks, func(p *post) {
a.pPostHooks = append(a.pPostHooks, func(p *post) {
if p.isPublishedSectionPost() {
p.apPost()
a.apPost(p)
}
})
postUpdateHooks = append(postUpdateHooks, func(p *post) {
a.pUpdateHooks = append(a.pUpdateHooks, func(p *post) {
if p.isPublishedSectionPost() {
p.apUpdate()
a.apUpdate(p)
}
})
postDeleteHooks = append(postDeleteHooks, func(p *post) {
p.apDelete()
a.pDeleteHooks = append(a.pDeleteHooks, func(p *post) {
a.apDelete(p)
})
// Prepare webfinger
webfingerResources = map[string]*configBlog{}
webfingerAccts = map[string]string{}
for name, blog := range appConfig.Blogs {
acct := "acct:" + name + "@" + appConfig.Server.publicHostname
webfingerResources[acct] = blog
webfingerResources[blog.apIri()] = blog
webfingerAccts[blog.apIri()] = acct
a.webfingerResources = map[string]*configBlog{}
a.webfingerAccts = map[string]string{}
for name, blog := range a.cfg.Blogs {
acct := "acct:" + name + "@" + a.cfg.Server.publicHostname
a.webfingerResources[acct] = blog
a.webfingerResources[a.apIri(blog)] = blog
a.webfingerAccts[a.apIri(blog)] = acct
}
// Read key and prepare signing
pkfile, err := os.ReadFile(appConfig.ActivityPub.KeyPath)
pkfile, err := os.ReadFile(a.cfg.ActivityPub.KeyPath)
if err != nil {
return err
}
@ -65,11 +55,11 @@ func initActivityPub() error {
if privateKeyDecoded == nil {
return errors.New("failed to decode private key")
}
apPrivateKey, err = x509.ParsePKCS1PrivateKey(privateKeyDecoded.Bytes)
a.apPrivateKey, err = x509.ParsePKCS1PrivateKey(privateKeyDecoded.Bytes)
if err != nil {
return err
}
apPostSigner, _, err = httpsig.NewSigner(
a.apPostSigner, _, err = httpsig.NewSigner(
[]httpsig.Algorithm{httpsig.RSA_SHA256},
httpsig.DigestSha256,
[]string{httpsig.RequestTarget, "date", "host", "digest"},
@ -80,32 +70,32 @@ func initActivityPub() error {
return err
}
// Init send queue
initAPSendQueue()
a.initAPSendQueue()
return nil
}
func apHandleWebfinger(w http.ResponseWriter, r *http.Request) {
blog, ok := webfingerResources[r.URL.Query().Get("resource")]
func (a *goBlog) apHandleWebfinger(w http.ResponseWriter, r *http.Request) {
blog, ok := a.webfingerResources[r.URL.Query().Get("resource")]
if !ok {
serveError(w, r, "Resource not found", http.StatusNotFound)
a.serveError(w, r, "Resource not found", http.StatusNotFound)
return
}
b, _ := json.Marshal(map[string]interface{}{
"subject": webfingerAccts[blog.apIri()],
"subject": a.webfingerAccts[a.apIri(blog)],
"aliases": []string{
webfingerAccts[blog.apIri()],
blog.apIri(),
a.webfingerAccts[a.apIri(blog)],
a.apIri(blog),
},
"links": []map[string]string{
{
"rel": "self",
"type": contentTypeAS,
"href": blog.apIri(),
"href": a.apIri(blog),
},
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": blog.apIri(),
"href": a.apIri(blog),
},
},
})
@ -113,19 +103,19 @@ func apHandleWebfinger(w http.ResponseWriter, r *http.Request) {
_, _ = writeMinified(w, contentTypeJSON, b)
}
func apHandleInbox(w http.ResponseWriter, r *http.Request) {
func (a *goBlog) apHandleInbox(w http.ResponseWriter, r *http.Request) {
blogName := chi.URLParam(r, "blog")
blog := appConfig.Blogs[blogName]
blog := a.cfg.Blogs[blogName]
if blog == nil {
serveError(w, r, "Inbox not found", http.StatusNotFound)
a.serveError(w, r, "Inbox not found", http.StatusNotFound)
return
}
blogIri := blog.apIri()
blogIri := a.apIri(blog)
// Verify request
requestActor, requestKey, requestActorStatus, err := apVerifySignature(r)
if err != nil {
// Send 401 because signature could not be verified
serveError(w, r, err.Error(), http.StatusUnauthorized)
a.serveError(w, r, err.Error(), http.StatusUnauthorized)
return
}
if requestActorStatus != 0 {
@ -134,12 +124,12 @@ func apHandleInbox(w http.ResponseWriter, r *http.Request) {
if err == nil {
u.Fragment = ""
u.RawFragment = ""
_ = apRemoveFollower(blogName, u.String())
_ = a.db.apRemoveFollower(blogName, u.String())
w.WriteHeader(http.StatusOK)
return
}
}
serveError(w, r, "Error when trying to get request actor", http.StatusBadRequest)
a.serveError(w, r, "Error when trying to get request actor", http.StatusBadRequest)
return
}
// Parse activity
@ -147,29 +137,29 @@ func apHandleInbox(w http.ResponseWriter, r *http.Request) {
err = json.NewDecoder(r.Body).Decode(&activity)
_ = r.Body.Close()
if err != nil {
serveError(w, r, "Failed to decode body", http.StatusBadRequest)
a.serveError(w, r, "Failed to decode body", http.StatusBadRequest)
return
}
// Get and check activity actor
activityActor, ok := activity["actor"].(string)
if !ok {
serveError(w, r, "actor in activity is no string", http.StatusBadRequest)
a.serveError(w, r, "actor in activity is no string", http.StatusBadRequest)
return
}
if activityActor != requestActor.ID {
serveError(w, r, "Request actor isn't activity actor", http.StatusForbidden)
a.serveError(w, r, "Request actor isn't activity actor", http.StatusForbidden)
return
}
// Do
switch activity["type"] {
case "Follow":
apAccept(blogName, blog, activity)
a.apAccept(blogName, blog, activity)
case "Undo":
{
if object, ok := activity["object"].(map[string]interface{}); ok {
if objectType, ok := object["type"].(string); ok && objectType == "Follow" {
if iri, ok := object["actor"].(string); ok && iri == activityActor {
_ = apRemoveFollower(blogName, activityActor)
_ = a.db.apRemoveFollower(blogName, activityActor)
}
}
}
@ -181,13 +171,13 @@ func apHandleInbox(w http.ResponseWriter, r *http.Request) {
id, hasID := object["id"].(string)
if hasReplyToString && hasID && len(inReplyTo) > 0 && len(id) > 0 && strings.Contains(inReplyTo, blogIri) {
// It's an ActivityPub reply; save reply as webmention
_ = createWebmention(id, inReplyTo)
_ = a.createWebmention(id, inReplyTo)
} else if content, hasContent := object["content"].(string); hasContent && hasID && len(id) > 0 {
// May be a mention; find links to blog and save them as webmentions
if links, err := allLinksFromHTML(strings.NewReader(content), id); err == nil {
for _, link := range links {
if strings.Contains(link, blogIri) {
_ = createWebmention(id, link)
_ = a.createWebmention(id, link)
}
}
}
@ -198,21 +188,21 @@ func apHandleInbox(w http.ResponseWriter, r *http.Request) {
case "Block":
{
if object, ok := activity["object"].(string); ok && len(object) > 0 && object == activityActor {
_ = apRemoveFollower(blogName, activityActor)
_ = a.db.apRemoveFollower(blogName, activityActor)
}
}
case "Like":
{
likeObject, likeObjectOk := activity["object"].(string)
if likeObjectOk && len(likeObject) > 0 && strings.Contains(likeObject, blogIri) {
sendNotification(fmt.Sprintf("%s liked %s", activityActor, likeObject))
a.sendNotification(fmt.Sprintf("%s liked %s", activityActor, likeObject))
}
}
case "Announce":
{
announceObject, announceObjectOk := activity["object"].(string)
if announceObjectOk && len(announceObject) > 0 && strings.Contains(announceObject, blogIri) {
sendNotification(fmt.Sprintf("%s announced %s", activityActor, announceObject))
a.sendNotification(fmt.Sprintf("%s announced %s", activityActor, announceObject))
}
}
}
@ -277,8 +267,8 @@ func apGetRemoteActor(iri string) (*asPerson, int, error) {
return actor, 0, nil
}
func apGetAllInboxes(blog string) ([]string, error) {
rows, err := appDb.query("select distinct inbox from activitypub_followers where blog = @blog", sql.Named("blog", blog))
func (db *database) apGetAllInboxes(blog string) ([]string, error) {
rows, err := db.query("select distinct inbox from activitypub_followers where blog = @blog", sql.Named("blog", blog))
if err != nil {
return nil, err
}
@ -294,27 +284,27 @@ func apGetAllInboxes(blog string) ([]string, error) {
return inboxes, nil
}
func apAddFollower(blog, follower, inbox string) error {
_, err := appDb.exec("insert or replace into activitypub_followers (blog, follower, inbox) values (@blog, @follower, @inbox)", sql.Named("blog", blog), sql.Named("follower", follower), sql.Named("inbox", inbox))
func (db *database) apAddFollower(blog, follower, inbox string) error {
_, err := db.exec("insert or replace into activitypub_followers (blog, follower, inbox) values (@blog, @follower, @inbox)", sql.Named("blog", blog), sql.Named("follower", follower), sql.Named("inbox", inbox))
return err
}
func apRemoveFollower(blog, follower string) error {
_, err := appDb.exec("delete from activitypub_followers where blog = @blog and follower = @follower", sql.Named("blog", blog), sql.Named("follower", follower))
func (db *database) apRemoveFollower(blog, follower string) error {
_, err := db.exec("delete from activitypub_followers where blog = @blog and follower = @follower", sql.Named("blog", blog), sql.Named("follower", follower))
return err
}
func apRemoveInbox(inbox string) error {
_, err := appDb.exec("delete from activitypub_followers where inbox = @inbox", sql.Named("inbox", inbox))
func (db *database) apRemoveInbox(inbox string) error {
_, err := db.exec("delete from activitypub_followers where inbox = @inbox", sql.Named("inbox", inbox))
return err
}
func (p *post) apPost() {
n := p.toASNote()
apSendToAllFollowers(p.Blog, map[string]interface{}{
func (a *goBlog) apPost(p *post) {
n := a.toASNote(p)
a.apSendToAllFollowers(p.Blog, map[string]interface{}{
"@context": asContext,
"actor": appConfig.Blogs[p.Blog].apIri(),
"id": p.fullURL(),
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
"id": a.fullPostURL(p),
"published": n.Published,
"type": "Create",
"object": n,
@ -322,46 +312,46 @@ func (p *post) apPost() {
if n.InReplyTo != "" {
// Is reply, so announce it
time.Sleep(30 * time.Second)
p.apAnnounce()
a.apAnnounce(p)
}
}
func (p *post) apUpdate() {
apSendToAllFollowers(p.Blog, map[string]interface{}{
func (a *goBlog) apUpdate(p *post) {
a.apSendToAllFollowers(p.Blog, map[string]interface{}{
"@context": asContext,
"actor": appConfig.Blogs[p.Blog].apIri(),
"id": p.fullURL(),
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
"id": a.fullPostURL(p),
"published": time.Now().Format("2006-01-02T15:04:05-07:00"),
"type": "Update",
"object": p.toASNote(),
"object": a.toASNote(p),
})
}
func (p *post) apAnnounce() {
apSendToAllFollowers(p.Blog, map[string]interface{}{
func (a *goBlog) apAnnounce(p *post) {
a.apSendToAllFollowers(p.Blog, map[string]interface{}{
"@context": asContext,
"actor": appConfig.Blogs[p.Blog].apIri(),
"id": p.fullURL() + "#announce",
"published": p.toASNote().Published,
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
"id": a.fullPostURL(p) + "#announce",
"published": a.toASNote(p).Published,
"type": "Announce",
"object": p.fullURL(),
"object": a.fullPostURL(p),
})
}
func (p *post) apDelete() {
apSendToAllFollowers(p.Blog, map[string]interface{}{
func (a *goBlog) apDelete(p *post) {
a.apSendToAllFollowers(p.Blog, map[string]interface{}{
"@context": asContext,
"actor": appConfig.Blogs[p.Blog].apIri(),
"id": p.fullURL() + "#delete",
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
"id": a.fullPostURL(p) + "#delete",
"type": "Delete",
"object": map[string]string{
"id": p.fullURL(),
"id": a.fullPostURL(p),
"type": "Tombstone",
},
})
}
func apAccept(blogName string, blog *configBlog, follow map[string]interface{}) {
func (a *goBlog) apAccept(blogName string, blog *configBlog, follow map[string]interface{}) {
// it's a follow, write it down
newFollower := follow["actor"].(string)
log.Println("New follow request:", newFollower)
@ -381,7 +371,7 @@ func apAccept(blogName string, blog *configBlog, follow map[string]interface{})
if endpoints := follower.Endpoints; endpoints != nil && endpoints.SharedInbox != "" {
inbox = endpoints.SharedInbox
}
if err = apAddFollower(blogName, follower.ID, inbox); err != nil {
if err = a.db.apAddFollower(blogName, follower.ID, inbox); err != nil {
return
}
// remove @context from the inner activity
@ -389,37 +379,37 @@ func apAccept(blogName string, blog *configBlog, follow map[string]interface{})
accept := map[string]interface{}{
"@context": asContext,
"to": follow["actor"],
"actor": blog.apIri(),
"actor": a.apIri(blog),
"object": follow,
"type": "Accept",
}
_, accept["id"] = apNewID(blog)
_ = apQueueSendSigned(blog.apIri(), follower.Inbox, accept)
_, accept["id"] = a.apNewID(blog)
_ = a.db.apQueueSendSigned(a.apIri(blog), follower.Inbox, accept)
}
func apSendToAllFollowers(blog string, activity interface{}) {
inboxes, err := apGetAllInboxes(blog)
func (a *goBlog) apSendToAllFollowers(blog string, activity interface{}) {
inboxes, err := a.db.apGetAllInboxes(blog)
if err != nil {
log.Println("Failed to retrieve inboxes:", err.Error())
return
}
apSendTo(appConfig.Blogs[blog].apIri(), activity, inboxes)
a.db.apSendTo(a.apIri(a.cfg.Blogs[blog]), activity, inboxes)
}
func apSendTo(blogIri string, activity interface{}, inboxes []string) {
func (db *database) apSendTo(blogIri string, activity interface{}, inboxes []string) {
for _, i := range inboxes {
go func(inbox string) {
_ = apQueueSendSigned(blogIri, inbox, activity)
_ = db.apQueueSendSigned(blogIri, inbox, activity)
}(i)
}
}
func apNewID(blog *configBlog) (hash string, url string) {
return hash, blog.apIri() + generateRandomString(16)
func (a *goBlog) apNewID(blog *configBlog) (hash string, url string) {
return hash, a.apIri(blog) + generateRandomString(16)
}
func (b *configBlog) apIri() string {
return appConfig.Server.PublicAddress + b.Path
func (a *goBlog) apIri(b *configBlog) string {
return a.cfg.Server.PublicAddress + b.Path
}
func apRequestIsSuccess(code int) bool {

@ -19,10 +19,10 @@ type apRequest struct {
Try int
}
func initAPSendQueue() {
func (a *goBlog) initAPSendQueue() {
go func() {
for {
qi, err := peekQueue("ap")
qi, err := a.db.peekQueue("ap")
if err != nil {
log.Println(err.Error())
continue
@ -31,22 +31,22 @@ func initAPSendQueue() {
err = gob.NewDecoder(bytes.NewReader(qi.content)).Decode(&r)
if err != nil {
log.Println(err.Error())
_ = qi.dequeue()
_ = a.db.dequeue(qi)
continue
}
if err := apSendSigned(r.BlogIri, r.To, r.Activity); err != nil {
if err := a.apSendSigned(r.BlogIri, r.To, r.Activity); err != nil {
if r.Try++; r.Try < 20 {
// Try it again
qi.content, _ = r.encode()
_ = qi.reschedule(time.Duration(r.Try) * 10 * time.Minute)
_ = a.db.reschedule(qi, time.Duration(r.Try)*10*time.Minute)
continue
} else {
log.Printf("Request to %s failed for the 20th time", r.To)
log.Println()
_ = apRemoveInbox(r.To)
_ = a.db.apRemoveInbox(r.To)
}
}
err = qi.dequeue()
err = a.db.dequeue(qi)
if err != nil {
log.Println(err.Error())
}
@ -58,7 +58,7 @@ func initAPSendQueue() {
}()
}
func apQueueSendSigned(blogIri, to string, activity interface{}) error {
func (db *database) apQueueSendSigned(blogIri, to string, activity interface{}) error {
body, err := json.Marshal(activity)
if err != nil {
return err
@ -71,7 +71,7 @@ func apQueueSendSigned(blogIri, to string, activity interface{}) error {
if err != nil {
return err
}
return enqueue("ap", b, time.Now())
return db.enqueue("ap", b, time.Now())
}
func (r *apRequest) encode() ([]byte, error) {
@ -83,7 +83,7 @@ func (r *apRequest) encode() ([]byte, error) {
return buf.Bytes(), nil
}
func apSendSigned(blogIri, to string, activity []byte) error {
func (a *goBlog) apSendSigned(blogIri, to string, activity []byte) error {
// Create request context with timeout
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
@ -105,9 +105,9 @@ func apSendSigned(blogIri, to string, activity []byte) error {
r.Header.Set(contentType, contentTypeASUTF8)
r.Header.Set("Host", iri.Host)
// Sign request
apPostSignMutex.Lock()
err = apPostSigner.SignRequest(apPrivateKey, blogIri+"#main-key", r, activity)
apPostSignMutex.Unlock()
a.apPostSignMutex.Lock()
err = a.apPostSigner.SignRequest(a.apPrivateKey, blogIri+"#main-key", r, activity)
a.apPostSignMutex.Unlock()
if err != nil {
return err
}

@ -22,9 +22,9 @@ var asCheckMediaTypes = []contenttype.MediaType{
const asRequestKey requestContextKey = "asRequest"
func checkActivityStreamsRequest(next http.Handler) http.Handler {
func (a *goBlog) checkActivityStreamsRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if ap := appConfig.ActivityPub; ap != nil && ap.Enabled {
if ap := a.cfg.ActivityPub; ap != nil && ap.Enabled {
// Check if accepted media type is not HTML
if mt, _, err := contenttype.GetAcceptableMediaType(r, asCheckMediaTypes); err == nil && mt.String() != asCheckMediaTypes[0].String() {
next.ServeHTTP(rw, r.WithContext(context.WithValue(r.Context(), asRequestKey, true)))
@ -87,21 +87,21 @@ type asEndpoints struct {
SharedInbox string `json:"sharedInbox,omitempty"`
}
func (p *post) serveActivityStreams(w http.ResponseWriter) {
b, _ := json.Marshal(p.toASNote())
func (a *goBlog) serveActivityStreamsPost(p *post, w http.ResponseWriter) {
b, _ := json.Marshal(a.toASNote(p))
w.Header().Set(contentType, contentTypeASUTF8)
_, _ = writeMinified(w, contentTypeAS, b)
}
func (p *post) toASNote() *asNote {
func (a *goBlog) toASNote(p *post) *asNote {
// Create a Note object
as := &asNote{
Context: asContext,
To: []string{"https://www.w3.org/ns/activitystreams#Public"},
MediaType: contentTypeHTML,
ID: p.fullURL(),
URL: p.fullURL(),
AttributedTo: appConfig.Blogs[p.Blog].apIri(),
ID: a.fullPostURL(p),
URL: a.fullPostURL(p),
AttributedTo: a.apIri(a.cfg.Blogs[p.Blog]),
}
// Name and Type
if title := p.title(); title != "" {
@ -111,9 +111,9 @@ func (p *post) toASNote() *asNote {
as.Type = "Note"
}
// Content
as.Content = string(p.absoluteHTML())
as.Content = string(a.absoluteHTML(p))
// Attachments
if images := p.Parameters[appConfig.Micropub.PhotoParam]; len(images) > 0 {
if images := p.Parameters[a.cfg.Micropub.PhotoParam]; len(images) > 0 {
for _, image := range images {
as.Attachment = append(as.Attachment, &asAttachment{
Type: "Image",
@ -122,12 +122,12 @@ func (p *post) toASNote() *asNote {
}
}
// Tags
for _, tagTax := range appConfig.ActivityPub.TagsTaxonomies {
for _, tagTax := range a.cfg.ActivityPub.TagsTaxonomies {
for _, tag := range p.Parameters[tagTax] {
as.Tag = append(as.Tag, &asTag{
Type: "Hashtag",
Name: tag,
Href: appConfig.Server.PublicAddress + appConfig.Blogs[p.Blog].getRelativePath(fmt.Sprintf("/%s/%s", tagTax, urlize(tag))),
Href: a.cfg.Server.PublicAddress + a.cfg.Blogs[p.Blog].getRelativePath(fmt.Sprintf("/%s/%s", tagTax, urlize(tag))),
})
}
}
@ -144,30 +144,31 @@ func (p *post) toASNote() *asNote {
}
}
// Reply
if replyLink := p.firstParameter(appConfig.Micropub.ReplyParam); replyLink != "" {
if replyLink := p.firstParameter(a.cfg.Micropub.ReplyParam); replyLink != "" {
as.InReplyTo = replyLink
}
return as
}
func (b *configBlog) serveActivityStreams(blog string, w http.ResponseWriter, r *http.Request) {
publicKeyDer, err := x509.MarshalPKIXPublicKey(&apPrivateKey.PublicKey)
func (a *goBlog) serveActivityStreams(blog string, w http.ResponseWriter, r *http.Request) {
b := a.cfg.Blogs[blog]
publicKeyDer, err := x509.MarshalPKIXPublicKey(&(a.apPrivateKey.PublicKey))
if err != nil {
serveError(w, r, "Failed to marshal public key", http.StatusInternalServerError)
a.serveError(w, r, "Failed to marshal public key", http.StatusInternalServerError)
return
}
asBlog := &asPerson{
Context: asContext,
Type: "Person",
ID: b.apIri(),
URL: b.apIri(),
ID: a.apIri(b),
URL: a.apIri(b),
Name: b.Title,
Summary: b.Description,
PreferredUsername: blog,
Inbox: appConfig.Server.PublicAddress + "/activitypub/inbox/" + blog,
Inbox: a.cfg.Server.PublicAddress + "/activitypub/inbox/" + blog,
PublicKey: &asPublicKey{
Owner: b.apIri(),
ID: b.apIri() + "#main-key",
Owner: a.apIri(b),
ID: a.apIri(b) + "#main-key",
PublicKeyPem: string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Headers: nil,
@ -176,10 +177,10 @@ func (b *configBlog) serveActivityStreams(blog string, w http.ResponseWriter, r
},
}
// Add profile picture
if appConfig.User.Picture != "" {
if a.cfg.User.Picture != "" {
asBlog.Icon = &asAttachment{
Type: "Image",
URL: appConfig.User.Picture,
URL: a.cfg.User.Picture,
}
}
jb, _ := json.Marshal(asBlog)

@ -0,0 +1,73 @@
package main
import (
"crypto/rsa"
"html/template"
"net/http"
"sync"
ts "git.jlel.se/jlelse/template-strings"
"github.com/go-chi/chi/v5"
"github.com/go-fed/httpsig"
rotatelogs "github.com/lestrrat-go/file-rotatelogs"
"github.com/yuin/goldmark"
"golang.org/x/sync/singleflight"
)
type goBlog struct {
// ActivityPub
apPrivateKey *rsa.PrivateKey
apPostSigner httpsig.Signer
apPostSignMutex sync.Mutex
webfingerResources map[string]*configBlog
webfingerAccts map[string]string
// Assets
assetFileNames map[string]string
assetFiles map[string]*assetFile
// Blogroll
blogrollCacheGroup singleflight.Group
// Cache
cache *cache
// Config
cfg *config
// Database
db *database
// Hooks
pPostHooks []postHookFunc
pUpdateHooks []postHookFunc
pDeleteHooks []postHookFunc
// HTTP
d *dynamicHandler
privateMode bool
privateModeHandler []func(http.Handler) http.Handler
captchaHandler http.Handler
micropubRouter *chi.Mux
indieAuthRouter *chi.Mux
webmentionsRouter *chi.Mux
notificationsRouter *chi.Mux
activitypubRouter *chi.Mux
editorRouter *chi.Mux
commentsRouter *chi.Mux
searchRouter *chi.Mux
setBlogMiddlewares map[string]func(http.Handler) http.Handler
sectionMiddlewares map[string]func(http.Handler) http.Handler
taxonomyMiddlewares map[string]func(http.Handler) http.Handler
photosMiddlewares map[string]func(http.Handler) http.Handler
searchMiddlewares map[string]func(http.Handler) http.Handler
customPagesMiddlewares map[string]func(http.Handler) http.Handler
commentsMiddlewares map[string]func(http.Handler) http.Handler
// Logs
logf *rotatelogs.RotateLogs
// Markdown
md, absoluteMd goldmark.Markdown
// Regex Redirects
regexRedirects []*regexRedirect
// Rendering
templates map[string]*template.Template
// Sessions
loginSessions, captchaSessions *dbSessionStore
// Template strings
ts *ts.TemplateStrings
// Tor
torAddress string
}

@ -11,14 +11,14 @@ import (
"github.com/pquerna/otp/totp"
)
func checkCredentials(username, password, totpPasscode string) bool {
return username == appConfig.User.Nick &&
password == appConfig.User.Password &&
(appConfig.User.TOTP == "" || totp.Validate(totpPasscode, appConfig.User.TOTP))
func (a *goBlog) checkCredentials(username, password, totpPasscode string) bool {
return username == a.cfg.User.Nick &&
password == a.cfg.User.Password &&
(a.cfg.User.TOTP == "" || totp.Validate(totpPasscode, a.cfg.User.TOTP))
}
func checkAppPasswords(username, password string) bool {
for _, apw := range appConfig.User.AppPasswords {
func (a *goBlog) checkAppPasswords(username, password string) bool {
for _, apw := range a.cfg.User.AppPasswords {
if apw.Username == username && apw.Password == password {
return true
}
@ -26,11 +26,11 @@ func checkAppPasswords(username, password string) bool {
return false
}
func jwtKey() []byte {
return []byte(appConfig.Server.JWTSecret)
func (a *goBlog) jwtKey() []byte {
return []byte(a.cfg.Server.JWTSecret)
}
func authMiddleware(next http.Handler) http.Handler {
func (a *goBlog) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Check if already logged in
if loggedIn, ok := r.Context().Value(loggedInKey).(bool); ok && loggedIn {
@ -38,12 +38,12 @@ func authMiddleware(next http.Handler) http.Handler {
return
}
// 2. Check BasicAuth (just for app passwords)
if username, password, ok := r.BasicAuth(); ok && checkAppPasswords(username, password) {
if username, password, ok := r.BasicAuth(); ok && a.checkAppPasswords(username, password) {
next.ServeHTTP(w, r)
return
}
// 3. Check login cookie
if checkLoginCookie(r) {
if a.checkLoginCookie(r) {
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), loggedInKey, true)))
return
}
@ -57,12 +57,12 @@ func authMiddleware(next http.Handler) http.Handler {
_ = r.ParseForm()
b = []byte(r.PostForm.Encode())
}
render(w, r, templateLogin, &renderData{
a.render(w, r, templateLogin, &renderData{
Data: map[string]interface{}{
"loginmethod": r.Method,
"loginheaders": base64.StdEncoding.EncodeToString(h),
"loginbody": base64.StdEncoding.EncodeToString(b),
"totp": appConfig.User.TOTP != "",
"totp": a.cfg.User.TOTP != "",
},
})
})
@ -70,9 +70,9 @@ func authMiddleware(next http.Handler) http.Handler {
const loggedInKey requestContextKey = "loggedIn"
func checkLoggedIn(next http.Handler) http.Handler {
func (a *goBlog) checkLoggedIn(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if checkLoginCookie(r) {
if a.checkLoginCookie(r) {
next.ServeHTTP(rw, r.WithContext(context.WithValue(r.Context(), loggedInKey, true)))
return
}
@ -80,8 +80,8 @@ func checkLoggedIn(next http.Handler) http.Handler {
})
}
func checkLoginCookie(r *http.Request) bool {
ses, err := loginSessionsStore.Get(r, "l")
func (a *goBlog) checkLoginCookie(r *http.Request) bool {
ses, err := a.loginSessions.Get(r, "l")
if err == nil && ses != nil {
if login, ok := ses.Values["login"]; ok && login.(bool) {
return true
@ -90,15 +90,15 @@ func checkLoginCookie(r *http.Request) bool {
return false
}
func checkIsLogin(next http.Handler) http.Handler {
func (a *goBlog) checkIsLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if !checkLogin(rw, r) {
if !a.checkLogin(rw, r) {
next.ServeHTTP(rw, r)
}
})
}
func checkLogin(w http.ResponseWriter, r *http.Request) bool {
func (a *goBlog) checkLogin(w http.ResponseWriter, r *http.Request) bool {
if r.Method != http.MethodPost {
return false
}
@ -109,8 +109,8 @@ func checkLogin(w http.ResponseWriter, r *http.Request) bool {
return false
}
// Check credential
if !checkCredentials(r.FormValue("username"), r.FormValue("password"), r.FormValue("token")) {
serveError(w, r, "Incorrect credentials", http.StatusUnauthorized)
if !a.checkCredentials(r.FormValue("username"), r.FormValue("password"), r.FormValue("token")) {
a.serveError(w, r, "Incorrect credentials", http.StatusUnauthorized)
return true
}
// Prepare original request
@ -124,20 +124,20 @@ func checkLogin(w http.ResponseWriter, r *http.Request) bool {
req.Header[k] = v
}
// Cookie
ses, err := loginSessionsStore.Get(r, "l")
ses, err := a.loginSessions.Get(r, "l")
if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError)
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return true
}
ses.Values["login"] = true
cookie, err := loginSessionsStore.SaveGetCookie(r, w, ses)
cookie, err := a.loginSessions.SaveGetCookie(r, w, ses)
if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError)
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return true
}
req.AddCookie(cookie)
// Serve original request
d.ServeHTTP(w, req)
a.d.ServeHTTP(w, req)
return true
}
@ -146,9 +146,9 @@ func serveLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
func serveLogout(w http.ResponseWriter, r *http.Request) {
if ses, err := loginSessionsStore.Get(r, "l"); err == nil && ses != nil {
_ = loginSessionsStore.Delete(r, w, ses)
func (a *goBlog) serveLogout(w http.ResponseWriter, r *http.Request) {
if ses, err := a.loginSessions.Get(r, "l"); err == nil && ses != nil {
_ = a.loginSessions.Delete(r, w, ses)
}
http.Redirect(w, r, "/", http.StatusFound)
}

@ -13,28 +13,25 @@ import (
"github.com/kaorimatz/go-opml"
servertiming "github.com/mitchellh/go-server-timing"
"github.com/thoas/go-funk"
"golang.org/x/sync/singleflight"
)
var blogrollCacheGroup singleflight.Group
func serveBlogroll(w http.ResponseWriter, r *http.Request) {
func (a *goBlog) serveBlogroll(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string)
t := servertiming.FromContext(r.Context()).NewMetric("bg").Start()
outlines, err, _ := blogrollCacheGroup.Do(blog, func() (interface{}, error) {
return getBlogrollOutlines(blog)
outlines, err, _ := a.blogrollCacheGroup.Do(blog, func() (interface{}, error) {
return a.getBlogrollOutlines(blog)
})
t.Stop()
if err != nil {
log.Println("Failed to get outlines:", err.Error())
serveError(w, r, "", http.StatusInternalServerError)
log.Printf("Failed to get outlines: %v", err)
a.serveError(w, r, "", http.StatusInternalServerError)
return
}
if appConfig.Cache != nil && appConfig.Cache.Enable {
setInternalCacheExpirationHeader(w, r, int(appConfig.Cache.Expiration))
if a.cfg.Cache != nil && a.cfg.Cache.Enable {
setInternalCacheExpirationHeader(w, r, int(a.cfg.Cache.Expiration))
}
c := appConfig.Blogs[blog].Blogroll
render(w, r, templateBlogroll, &renderData{
c := a.cfg.Blogs[blog].Blogroll
a.render(w, r, templateBlogroll, &renderData{
BlogString: blog,
Data: map[string]interface{}{
"Title": c.Title,
@ -45,34 +42,32 @@ func serveBlogroll(w http.ResponseWriter, r *http.Request) {
})
}
func serveBlogrollExport(w http.ResponseWriter, r *http.Request) {
func (a *goBlog) serveBlogrollExport(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string)
outlines, err, _ := blogrollCacheGroup.Do(blog, func() (interface{}, error) {
return getBlogrollOutlines(blog)
outlines, err, _ := a.blogrollCacheGroup.Do(blog, func() (interface{}, error) {
return a.getBlogrollOutlines(blog)
})
if err != nil {
log.Println("Failed to get outlines:", err.Error())
serveError(w, r, "", http.StatusInternalServerError)
log.Printf("Failed to get outlines: %v", err)
a.serveError(w, r, "", http.StatusInternalServerError)
return
}
if appConfig.Cache != nil && appConfig.Cache.Enable {
setInternalCacheExpirationHeader(w, r, int(appConfig.Cache.Expiration))
if a.cfg.Cache != nil && a.cfg.Cache.Enable {
setInternalCacheExpirationHeader(w, r, int(a.cfg.Cache.Expiration))
}
w.Header().Set(contentType, contentTypeXMLUTF8)
mw := minifier.Writer(contentTypeXML, w)
defer func() {
_ = mw.Close()
}()
_ = opml.Render(mw, &opml.OPML{
var opmlBytes bytes.Buffer
_ = opml.Render(&opmlBytes, &opml.OPML{
Version: "2.0",
DateCreated: time.Now().UTC(),
Outlines: outlines.([]*opml.Outline),
})
_, _ = writeMinified(w, contentTypeXML, opmlBytes.Bytes())
}
func getBlogrollOutlines(blog string) ([]*opml.Outline, error) {
config := appConfig.Blogs[blog].Blogroll
if cache := loadOutlineCache(blog); cache != nil {
func (a *goBlog) getBlogrollOutlines(blog string) ([]*opml.Outline, error) {
config := a.cfg.Blogs[blog].Blogroll
if cache := a.db.loadOutlineCache(blog); cache != nil {
return cache, nil
}
req, err := http.NewRequest(http.MethodGet, config.Opml, nil)
@ -112,22 +107,22 @@ func getBlogrollOutlines(blog string) ([]*opml.Outline, error) {
} else {
outlines = sortOutlines(outlines)
}
cacheOutlines(blog, outlines)
a.db.cacheOutlines(blog, outlines)
return outlines, nil
}
func cacheOutlines(blog string, outlines []*opml.Outline) {
func (db *database) cacheOutlines(blog string, outlines []*opml.Outline) {
var opmlBuffer bytes.Buffer
_ = opml.Render(&opmlBuffer, &opml.OPML{
Version: "2.0",
DateCreated: time.Now().UTC(),
Outlines: outlines,
})
_ = cachePersistently("blogroll_"+blog, opmlBuffer.Bytes())
_ = db.cachePersistently("blogroll_"+blog, opmlBuffer.Bytes())
}
func loadOutlineCache(blog string) []*opml.Outline {
data, err := retrievePersistentCache("blogroll_" + blog)
func (db *database) loadOutlineCache(blog string) []*opml.Outline {
data, err := db.retrievePersistentCache("blogroll_" + blog)
if err != nil || data == nil {
return nil
}

@ -9,19 +9,19 @@ import (
"golang.org/x/sync/singleflight"
)
func initBlogStats() {
func (a *goBlog) initBlogStats() {
f := func(p *post) {
resetBlogStats(p.Blog)
a.db.resetBlogStats(p.Blog)
}
postPostHooks = append(postPostHooks, f)
postUpdateHooks = append(postUpdateHooks, f)
postDeleteHooks = append(postDeleteHooks, f)
a.pPostHooks = append(a.pPostHooks, f)
a.pUpdateHooks = append(a.pUpdateHooks, f)
a.pDeleteHooks = append(a.pDeleteHooks, f)
}
func serveBlogStats(w http.ResponseWriter, r *http.Request) {
func (a *goBlog) serveBlogStats(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string)
canonical := blogPath(blog) + appConfig.Blogs[blog].BlogStats.Path
render(w, r, templateBlogStats, &renderData{
canonical := a.blogPath(blog) + a.cfg.Blogs[blog].BlogStats.Path
a.render(w, r, templateBlogStats, &renderData{
BlogString: blog,
Canonical: canonical,
Data: map[string]interface{}{
@ -32,24 +32,24 @@ func serveBlogStats(w http.ResponseWriter, r *http.Request) {
var blogStatsCacheGroup singleflight.Group
func serveBlogStatsTable(w http.ResponseWriter, r *http.Request) {
func (a *goBlog) serveBlogStatsTable(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string)
data, err, _ := blogStatsCacheGroup.Do(blog, func() (interface{}, error) {
return getBlogStats(blog)
return a.db.getBlogStats(blog)
})
if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError)
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return
}
// Render
render(w, r, templateBlogStatsTable, &renderData{
a.render(w, r, templateBlogStatsTable, &renderData{
BlogString: blog,
Data: data,
})
}
func getBlogStats(blog string) (data map[string]interface{}, err error) {
if stats := loadBlogStatsCache(blog); stats != nil {
func (db *database) getBlogStats(blog string) (data map[string]interface{}, err error) {
if stats := db.loadBlogStatsCache(blog); stats != nil {
return stats, nil
}
// Build query
@ -67,7 +67,7 @@ func getBlogStats(blog string) (data map[string]interface{}, err error) {
Name, Posts, Chars, Words, WordsPerPost string
}
// Count total posts
row, err := appDb.queryRow("select *, "+wordsPerPost+" from (select "+postCount+", "+charCount+", "+wordCount+" from ("+query+"))", params...)
row, err := db.queryRow("select *, "+wordsPerPost+" from (select "+postCount+", "+charCount+", "+wordCount+" from ("+query+"))", params...)
if err != nil {
return nil, err
}
@ -76,7 +76,7 @@ func getBlogStats(blog string) (data map[string]interface{}, err error) {
return nil, err
}
// Count posts per year
rows, err := appDb.query("select *, "+wordsPerPost+" from (select year, "+postCount+", "+charCount+", "+wordCount+" from ("+query+") where published != '' group by year order by year desc)", params...)
rows, err := db.query("select *, "+wordsPerPost+" from (select year, "+postCount+", "+charCount+", "+wordCount+" from ("+query+") where published != '' group by year order by year desc)", params...)
if err != nil {
return nil, err
}
@ -90,7 +90,7 @@ func getBlogStats(blog string) (data map[string]interface{}, err error) {
}
}
// Count posts without date
row, err = appDb.queryRow("select *, "+wordsPerPost+" from (select "+postCount+", "+charCount+", "+wordCount+" from ("+query+") where published = '')", params...)
row, err = db.queryRow("select *, "+wordsPerPost+" from (select "+postCount+", "+charCount+", "+wordCount+" from ("+query+") where published = '')", params...)
if err != nil {
return nil, err
}
@ -102,7 +102,7 @@ func getBlogStats(blog string) (data map[string]interface{}, err error) {
months := map[string][]statsTableType{}
month := statsTableType{}
for _, year := range years {
rows, err = appDb.query("select *, "+wordsPerPost+" from (select month, "+postCount+", "+charCount+", "+wordCount+" from ("+query+") where published != '' and year = @year group by month order by month desc)", append(params, sql.Named("year", year.Name))...)
rows, err = db.query("select *, "+wordsPerPost+" from (select month, "+postCount+", "+charCount+", "+wordCount+" from ("+query+") where published != '' and year = @year group by month order by month desc)", append(params, sql.Named("year", year.Name))...)
if err != nil {
return nil, err
}
@ -120,17 +120,17 @@ func getBlogStats(blog string) (data map[string]interface{}, err error) {
"withoutdate": noDate,
"months": months,
}
cacheBlogStats(blog, data)
db.cacheBlogStats(blog, data)
return data, nil
}
func cacheBlogStats(blog string, stats map[string]interface{}) {
func (db *database) cacheBlogStats(blog string, stats map[string]interface{}) {
jb, _ := json.Marshal(stats)
_ = cachePersistently("blogstats_"+blog, jb)
_ = db.cachePersistently("blogstats_"+blog, jb)
}
func loadBlogStatsCache(blog string) (stats map[string]interface{}) {
data, err := retrievePersistentCache("blogstats_" + blog)
func (db *database) loadBlogStatsCache(blog string) (stats map[string]interface{}) {
data, err := db.retrievePersistentCache("blogstats_" + blog)
if err != nil || data == nil {
return nil
}
@ -141,6 +141,6 @@ func loadBlogStatsCache(blog string) (stats map[string]interface{}) {
return stats
}
func resetBlogStats(blog string) {
_ = clearPersistentCache("blogstats_" + blog)
func (db *database) resetBlogStats(blog string) {
_ = db.clearPersistentCache("blogstats_" + blog)
}

@ -20,17 +20,22 @@ import (
"golang.org/x/sync/singleflight"
)
const (
cacheInternalExpirationHeader = "Goblog-Expire"
)
const cacheInternalExpirationHeader = "Goblog-Expire"
var (
cacheGroup singleflight.Group
cacheR *ristretto.Cache
)
type cache struct {
g singleflight.Group
c *ristretto.Cache
cfg *configCache
}
func initCache() (err error) {
cacheR, err = ristretto.NewCache(&ristretto.Config{
func (a *goBlog) initCache() (err error) {
a.cache = &cache{
cfg: a.cfg.Cache,
}
if a.cache.cfg != nil && !a.cache.cfg.Enable {
return nil
}
a.cache.c, err = ristretto.NewCache(&ristretto.Config{
NumCounters: 5000,
MaxCost: 20000000, // 20 MB
BufferItems: 16,
@ -52,13 +57,14 @@ func initCache() (err error) {
return
}
func cacheMiddleware(next http.Handler) http.Handler {
func (c *cache) cacheMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Do checks
if !appConfig.Cache.Enable {
if c.c == nil {
// No cache configured
next.ServeHTTP(w, r)
return
}
// Do checks
if !(r.Method == http.MethodGet || r.Method == http.MethodHead) {
next.ServeHTTP(w, r)
return
@ -74,32 +80,32 @@ func cacheMiddleware(next http.Handler) http.Handler {
// Search and serve cache
key := cacheKey(r)
// Get cache or render it
cacheInterface, _, _ := cacheGroup.Do(key, func() (interface{}, error) {
return getCache(key, next, r), nil
cacheInterface, _, _ := c.g.Do(key, func() (interface{}, error) {
return c.getCache(key, next, r), nil
})
cache := cacheInterface.(*cacheItem)
ci := cacheInterface.(*cacheItem)
// copy cached headers
for k, v := range cache.header {
for k, v := range ci.header {
w.Header()[k] = v
}
setCacheHeaders(w, cache)
c.setCacheHeaders(w, ci)
// check conditional request
if ifNoneMatchHeader := r.Header.Get("If-None-Match"); ifNoneMatchHeader != "" && ifNoneMatchHeader == cache.eTag {
if ifNoneMatchHeader := r.Header.Get("If-None-Match"); ifNoneMatchHeader != "" && ifNoneMatchHeader == ci.eTag {
// send 304
w.WriteHeader(http.StatusNotModified)
return
}
if ifModifiedSinceHeader := r.Header.Get("If-Modified-Since"); ifModifiedSinceHeader != "" {
if t, err := dateparse.ParseAny(ifModifiedSinceHeader); err == nil && t.After(cache.creationTime) {
if t, err := dateparse.ParseAny(ifModifiedSinceHeader); err == nil && t.After(ci.creationTime) {
// send 304
w.WriteHeader(http.StatusNotModified)
return
}
}
// set status code
w.WriteHeader(cache.code)
w.WriteHeader(ci.code)
// write cached body
_, _ = w.Write(cache.body)
_, _ = w.Write(ci.body)
})
}
@ -125,14 +131,14 @@ func cacheURLString(u *url.URL) string {
return buf.String()
}
func setCacheHeaders(w http.ResponseWriter, cache *cacheItem) {
func (c *cache) setCacheHeaders(w http.ResponseWriter, cache *cacheItem) {
w.Header().Set("ETag", cache.eTag)
w.Header().Set("Last-Modified", cache.creationTime.UTC().Format(http.TimeFormat))
if w.Header().Get("Cache-Control") == "" {
if cache.expiration != 0 {
w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d,stale-while-revalidate=%d", cache.expiration, cache.expiration))