Big refactoring: Avoid global vars almost everywhere

This commit is contained in:
Jan-Lukas Else 2021-06-06 14:39:42 +02:00
parent 9f9ff58a0d
commit 9714d65679
62 changed files with 1477 additions and 1378 deletions

42
.vscode/tasks.json vendored
View File

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

View File

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

View File

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

View File

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

73
app.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -20,17 +20,22 @@ import (
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
) )
const ( const cacheInternalExpirationHeader = "Goblog-Expire"
cacheInternalExpirationHeader = "Goblog-Expire"
)
var ( type cache struct {
cacheGroup singleflight.Group g singleflight.Group
cacheR *ristretto.Cache c *ristretto.Cache
) cfg *configCache
}
func initCache() (err error) { func (a *goBlog) initCache() (err error) {
cacheR, err = ristretto.NewCache(&ristretto.Config{ 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, NumCounters: 5000,
MaxCost: 20000000, // 20 MB MaxCost: 20000000, // 20 MB
BufferItems: 16, BufferItems: 16,
@ -52,13 +57,14 @@ func initCache() (err error) {
return 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Do checks if c.c == nil {
if !appConfig.Cache.Enable { // No cache configured
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
// Do checks
if !(r.Method == http.MethodGet || r.Method == http.MethodHead) { if !(r.Method == http.MethodGet || r.Method == http.MethodHead) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
@ -74,32 +80,32 @@ func cacheMiddleware(next http.Handler) http.Handler {
// Search and serve cache // Search and serve cache
key := cacheKey(r) key := cacheKey(r)
// Get cache or render it // Get cache or render it
cacheInterface, _, _ := cacheGroup.Do(key, func() (interface{}, error) { cacheInterface, _, _ := c.g.Do(key, func() (interface{}, error) {
return getCache(key, next, r), nil return c.getCache(key, next, r), nil
}) })
cache := cacheInterface.(*cacheItem) ci := cacheInterface.(*cacheItem)
// copy cached headers // copy cached headers
for k, v := range cache.header { for k, v := range ci.header {
w.Header()[k] = v w.Header()[k] = v
} }
setCacheHeaders(w, cache) c.setCacheHeaders(w, ci)
// check conditional request // 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 // send 304
w.WriteHeader(http.StatusNotModified) w.WriteHeader(http.StatusNotModified)
return return
} }
if ifModifiedSinceHeader := r.Header.Get("If-Modified-Since"); ifModifiedSinceHeader != "" { 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 // send 304
w.WriteHeader(http.StatusNotModified) w.WriteHeader(http.StatusNotModified)
return return
} }
} }
// set status code // set status code
w.WriteHeader(cache.code) w.WriteHeader(ci.code)
// write cached body // write cached body
_, _ = w.Write(cache.body) _, _ = w.Write(ci.body)
}) })
} }
@ -125,14 +131,14 @@ func cacheURLString(u *url.URL) string {
return buf.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("ETag", cache.eTag)
w.Header().Set("Last-Modified", cache.creationTime.UTC().Format(http.TimeFormat)) w.Header().Set("Last-Modified", cache.creationTime.UTC().Format(http.TimeFormat))
if w.Header().Get("Cache-Control") == "" { if w.Header().Get("Cache-Control") == "" {
if cache.expiration != 0 { if cache.expiration != 0 {
w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d,stale-while-revalidate=%d", cache.expiration, cache.expiration)) w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d,stale-while-revalidate=%d", cache.expiration, cache.expiration))
} else { } else {
w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d,s-max-age=%d,stale-while-revalidate=%d", appConfig.Cache.Expiration, appConfig.Cache.Expiration/3, appConfig.Cache.Expiration)) w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d,s-max-age=%d,stale-while-revalidate=%d", c.cfg.Expiration, c.cfg.Expiration/3, c.cfg.Expiration))
} }
} }
} }
@ -146,8 +152,8 @@ type cacheItem struct {
body []byte body []byte
} }
func getCache(key string, next http.Handler, r *http.Request) (item *cacheItem) { func (c *cache) getCache(key string, next http.Handler, r *http.Request) (item *cacheItem) {
if rItem, ok := cacheR.Get(key); ok { if rItem, ok := c.c.Get(key); ok {
item = rItem.(*cacheItem) item = rItem.(*cacheItem)
} }
if item == nil { if item == nil {
@ -198,10 +204,10 @@ func getCache(key string, next http.Handler, r *http.Request) (item *cacheItem)
// Save cache // Save cache
if cch := item.header.Get("Cache-Control"); !strings.Contains(cch, "no-store") && !strings.Contains(cch, "private") && !strings.Contains(cch, "no-cache") { if cch := item.header.Get("Cache-Control"); !strings.Contains(cch, "no-store") && !strings.Contains(cch, "private") && !strings.Contains(cch, "no-cache") {
if exp == 0 { if exp == 0 {
cacheR.Set(key, item, 0) c.c.Set(key, item, 0)
} else { } else {
ttl := time.Duration(exp) * time.Second ttl := time.Duration(exp) * time.Second
cacheR.SetWithTTL(key, item, 0, ttl) c.c.SetWithTTL(key, item, 0, ttl)
} }
} }
} else { } else {
@ -210,8 +216,8 @@ func getCache(key string, next http.Handler, r *http.Request) (item *cacheItem)
return item return item
} }
func purgeCache() { func (c *cache) purge() {
cacheR.Clear() c.c.Clear()
} }
func setInternalCacheExpirationHeader(w http.ResponseWriter, r *http.Request, expiration int) { func setInternalCacheExpirationHeader(w http.ResponseWriter, r *http.Request, expiration int) {

View File

@ -10,10 +10,10 @@ import (
"github.com/dchest/captcha" "github.com/dchest/captcha"
) )
func captchaMiddleware(next http.Handler) http.Handler { func (a *goBlog) captchaMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Check Cookie // 1. Check Cookie
ses, err := captchaSessionsStore.Get(r, "c") ses, err := a.captchaSessions.Get(r, "c")
if err == nil && ses != nil { if err == nil && ses != nil {
if captcha, ok := ses.Values["captcha"]; ok && captcha.(bool) { if captcha, ok := ses.Values["captcha"]; ok && captcha.(bool) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@ -30,7 +30,7 @@ func captchaMiddleware(next http.Handler) http.Handler {
_ = r.ParseForm() _ = r.ParseForm()
b = []byte(r.PostForm.Encode()) b = []byte(r.PostForm.Encode())
} }
render(w, r, templateCaptcha, &renderData{ a.render(w, r, templateCaptcha, &renderData{
Data: map[string]string{ Data: map[string]string{
"captchamethod": r.Method, "captchamethod": r.Method,
"captchaheaders": base64.StdEncoding.EncodeToString(h), "captchaheaders": base64.StdEncoding.EncodeToString(h),
@ -41,15 +41,15 @@ func captchaMiddleware(next http.Handler) http.Handler {
}) })
} }
func checkIsCaptcha(next http.Handler) http.Handler { func (a *goBlog) checkIsCaptcha(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if !checkCaptcha(rw, r) { if !a.checkCaptcha(rw, r) {
next.ServeHTTP(rw, r) next.ServeHTTP(rw, r)
} }
}) })
} }
func checkCaptcha(w http.ResponseWriter, r *http.Request) bool { func (a *goBlog) checkCaptcha(w http.ResponseWriter, r *http.Request) bool {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
return false return false
} }
@ -71,20 +71,20 @@ func checkCaptcha(w http.ResponseWriter, r *http.Request) bool {
} }
// Check captcha and create cookie // Check captcha and create cookie
if captcha.VerifyString(r.FormValue("captchaid"), r.FormValue("digits")) { if captcha.VerifyString(r.FormValue("captchaid"), r.FormValue("digits")) {
ses, err := captchaSessionsStore.Get(r, "c") ses, err := a.captchaSessions.Get(r, "c")
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return true return true
} }
ses.Values["captcha"] = true ses.Values["captcha"] = true
cookie, err := captchaSessionsStore.SaveGetCookie(r, w, ses) cookie, err := a.captchaSessions.SaveGetCookie(r, w, ses)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return true return true
} }
req.AddCookie(cookie) req.AddCookie(cookie)
} }
// Serve original request // Serve original request
d.ServeHTTP(w, req) a.d.ServeHTTP(w, req)
return true return true
} }

View File

@ -11,8 +11,8 @@ import (
"time" "time"
) )
func checkAllExternalLinks() { func (a *goBlog) checkAllExternalLinks() {
allPosts, err := getPosts(&postsRequestConfig{status: statusPublished}) allPosts, err := a.db.getPosts(&postsRequestConfig{status: statusPublished})
if err != nil { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
return return
@ -30,45 +30,49 @@ func checkAllExternalLinks() {
} }
responses := map[string]int{} responses := map[string]int{}
rm := sync.RWMutex{} rm := sync.RWMutex{}
for i := 0; i < 20; i++ { processFunc := func() {
go func() { defer wg.Done()
defer wg.Done() wg.Add(1)
wg.Add(1) for postLinkPair := range linkChan {
for postLinkPair := range linkChan { if strings.HasPrefix(postLinkPair.Second, a.cfg.Server.PublicAddress) {
rm.RLock() continue
_, ok := responses[postLinkPair.Second]
rm.RUnlock()
if !ok {
req, err := http.NewRequest(http.MethodGet, postLinkPair.Second, nil)
if err != nil {
fmt.Println(err.Error())
continue
}
// User-Agent from Tor
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
resp, err := client.Do(req)
if err != nil {
fmt.Println(postLinkPair.Second+" ("+postLinkPair.First+"):", err.Error())
continue
}
status := resp.StatusCode
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
rm.Lock()
responses[postLinkPair.Second] = status
rm.Unlock()
}
rm.RLock()
if response, ok := responses[postLinkPair.Second]; ok && !checkSuccessStatus(response) {
fmt.Println(postLinkPair.Second+" ("+postLinkPair.First+"):", response)
}
rm.RUnlock()
} }
}() rm.RLock()
_, ok := responses[postLinkPair.Second]
rm.RUnlock()
if !ok {
req, err := http.NewRequest(http.MethodGet, postLinkPair.Second, nil)
if err != nil {
fmt.Println(err.Error())
continue
}
// User-Agent from Tor
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
resp, err := client.Do(req)
if err != nil {
fmt.Println(postLinkPair.Second+" ("+postLinkPair.First+"):", err.Error())
continue
}
status := resp.StatusCode
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
rm.Lock()
responses[postLinkPair.Second] = status
rm.Unlock()
}
rm.RLock()
if response, ok := responses[postLinkPair.Second]; ok && !checkSuccessStatus(response) {
fmt.Println(postLinkPair.Second+" ("+postLinkPair.First+"):", response)
}
rm.RUnlock()
}
} }
err = getExternalLinks(allPosts, linkChan) for i := 0; i < 20; i++ {
go processFunc()
}
err = a.getExternalLinks(allPosts, linkChan)
if err != nil { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
return return
@ -80,17 +84,15 @@ func checkSuccessStatus(status int) bool {
return status >= 200 && status < 400 return status >= 200 && status < 400
} }
func getExternalLinks(posts []*post, linkChan chan<- stringPair) error { func (a *goBlog) getExternalLinks(posts []*post, linkChan chan<- stringPair) error {
wg := new(sync.WaitGroup) wg := new(sync.WaitGroup)
for _, p := range posts { for _, p := range posts {
wg.Add(1) wg.Add(1)
go func(p *post) { go func(p *post) {
defer wg.Done() defer wg.Done()
links, _ := allLinksFromHTML(strings.NewReader(string(p.absoluteHTML())), p.fullURL()) links, _ := allLinksFromHTML(strings.NewReader(string(a.absoluteHTML(p))), a.fullPostURL(p))
for _, link := range links { for _, link := range links {
if !strings.HasPrefix(link, appConfig.Server.PublicAddress) { linkChan <- stringPair{a.fullPostURL(p), link}
linkChan <- stringPair{p.fullURL(), link}
}
} }
}(p) }(p)
} }

View File

@ -20,36 +20,36 @@ type comment struct {
Comment string Comment string
} }
func serveComment(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveComment(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id")) id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
row, err := appDb.queryRow("select id, target, name, website, comment from comments where id = @id", sql.Named("id", id)) row, err := a.db.queryRow("select id, target, name, website, comment from comments where id = @id", sql.Named("id", id))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
comment := &comment{} comment := &comment{}
if err = row.Scan(&comment.ID, &comment.Target, &comment.Name, &comment.Website, &comment.Comment); err == sql.ErrNoRows { if err = row.Scan(&comment.ID, &comment.Target, &comment.Name, &comment.Website, &comment.Comment); err == sql.ErrNoRows {
serve404(w, r) a.serve404(w, r)
return return
} else if err != nil { } else if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
blog := r.Context().Value(blogContextKey).(string) blog := r.Context().Value(blogContextKey).(string)
render(w, r, templateComment, &renderData{ a.render(w, r, templateComment, &renderData{
BlogString: blog, BlogString: blog,
Canonical: appConfig.Server.PublicAddress + appConfig.Blogs[blog].getRelativePath(fmt.Sprintf("/comment/%d", id)), Canonical: a.cfg.Server.PublicAddress + a.cfg.Blogs[blog].getRelativePath(fmt.Sprintf("/comment/%d", id)),
Data: comment, Data: comment,
}) })
} }
func createComment(w http.ResponseWriter, r *http.Request) { func (a *goBlog) createComment(w http.ResponseWriter, r *http.Request) {
// Check target // Check target
target := checkCommentTarget(w, r) target := a.checkCommentTarget(w, r)
if target == "" { if target == "" {
return return
} }
@ -57,7 +57,7 @@ func createComment(w http.ResponseWriter, r *http.Request) {
strict := bluemonday.StrictPolicy() strict := bluemonday.StrictPolicy()
comment := strings.TrimSpace(strict.Sanitize(r.FormValue("comment"))) comment := strings.TrimSpace(strict.Sanitize(r.FormValue("comment")))
if comment == "" { if comment == "" {
serveError(w, r, "Comment is empty", http.StatusBadRequest) a.serveError(w, r, "Comment is empty", http.StatusBadRequest)
return return
} }
name := strings.TrimSpace(strict.Sanitize(r.FormValue("name"))) name := strings.TrimSpace(strict.Sanitize(r.FormValue("name")))
@ -66,35 +66,35 @@ func createComment(w http.ResponseWriter, r *http.Request) {
} }
website := strings.TrimSpace(strict.Sanitize(r.FormValue("website"))) website := strings.TrimSpace(strict.Sanitize(r.FormValue("website")))
// Insert // Insert
result, err := appDb.exec("insert into comments (target, comment, name, website) values (@target, @comment, @name, @website)", sql.Named("target", target), sql.Named("comment", comment), sql.Named("name", name), sql.Named("website", website)) result, err := a.db.exec("insert into comments (target, comment, name, website) values (@target, @comment, @name, @website)", sql.Named("target", target), sql.Named("comment", comment), sql.Named("name", name), sql.Named("website", website))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
if commentID, err := result.LastInsertId(); err != nil { if commentID, err := result.LastInsertId(); err != nil {
// Serve error // Serve error
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
} else { } else {
commentAddress := fmt.Sprintf("%s/%d", blogPath(r.Context().Value(blogContextKey).(string))+"/comment", commentID) commentAddress := fmt.Sprintf("%s/%d", a.blogPath(r.Context().Value(blogContextKey).(string))+"/comment", commentID)
// Send webmention // Send webmention
_ = createWebmention(appConfig.Server.PublicAddress+commentAddress, appConfig.Server.PublicAddress+target) _ = a.createWebmention(a.cfg.Server.PublicAddress+commentAddress, a.cfg.Server.PublicAddress+target)
// Redirect to comment // Redirect to comment
http.Redirect(w, r, commentAddress, http.StatusFound) http.Redirect(w, r, commentAddress, http.StatusFound)
} }
} }
func checkCommentTarget(w http.ResponseWriter, r *http.Request) string { func (a *goBlog) checkCommentTarget(w http.ResponseWriter, r *http.Request) string {
target := r.FormValue("target") target := r.FormValue("target")
if target == "" { if target == "" {
serveError(w, r, "No target specified", http.StatusBadRequest) a.serveError(w, r, "No target specified", http.StatusBadRequest)
return "" return ""
} else if !strings.HasPrefix(target, appConfig.Server.PublicAddress) { } else if !strings.HasPrefix(target, a.cfg.Server.PublicAddress) {
serveError(w, r, "Bad target", http.StatusBadRequest) a.serveError(w, r, "Bad target", http.StatusBadRequest)
return "" return ""
} }
targetURL, err := url.Parse(target) targetURL, err := url.Parse(target)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return "" return ""
} }
return targetURL.Path return targetURL.Path
@ -114,10 +114,10 @@ func buildCommentsQuery(config *commentsRequestConfig) (query string, args []int
return return
} }
func getComments(config *commentsRequestConfig) ([]*comment, error) { func (db *database) getComments(config *commentsRequestConfig) ([]*comment, error) {
comments := []*comment{} comments := []*comment{}
query, args := buildCommentsQuery(config) query, args := buildCommentsQuery(config)
rows, err := appDb.query(query, args...) rows, err := db.query(query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -132,10 +132,10 @@ func getComments(config *commentsRequestConfig) ([]*comment, error) {
return comments, nil return comments, nil
} }
func countComments(config *commentsRequestConfig) (count int, err error) { func (db *database) countComments(config *commentsRequestConfig) (count int, err error) {
query, params := buildCommentsQuery(config) query, params := buildCommentsQuery(config)
query = "select count(*) from (" + query + ")" query = "select count(*) from (" + query + ")"
row, err := appDb.queryRow(query, params...) row, err := db.queryRow(query, params...)
if err != nil { if err != nil {
return return
} }
@ -143,7 +143,7 @@ func countComments(config *commentsRequestConfig) (count int, err error) {
return return
} }
func deleteComment(id int) error { func (db *database) deleteComment(id int) error {
_, err := appDb.exec("delete from comments where id = @id", sql.Named("id", id)) _, err := db.exec("delete from comments where id = @id", sql.Named("id", id))
return err return err
} }

View File

@ -13,11 +13,12 @@ import (
type commentsPaginationAdapter struct { type commentsPaginationAdapter struct {
config *commentsRequestConfig config *commentsRequestConfig
nums int64 nums int64
db *database
} }
func (p *commentsPaginationAdapter) Nums() (int64, error) { func (p *commentsPaginationAdapter) Nums() (int64, error) {
if p.nums == 0 { if p.nums == 0 {
nums, _ := countComments(p.config) nums, _ := p.db.countComments(p.config)
p.nums = int64(nums) p.nums = int64(nums)
} }
return p.nums, nil return p.nums, nil
@ -28,23 +29,23 @@ func (p *commentsPaginationAdapter) Slice(offset, length int, data interface{})
modifiedConfig.offset = offset modifiedConfig.offset = offset
modifiedConfig.limit = length modifiedConfig.limit = length
comments, err := getComments(&modifiedConfig) comments, err := p.db.getComments(&modifiedConfig)
reflect.ValueOf(data).Elem().Set(reflect.ValueOf(&comments).Elem()) reflect.ValueOf(data).Elem().Set(reflect.ValueOf(&comments).Elem())
return err return err
} }
func commentsAdmin(w http.ResponseWriter, r *http.Request) { func (a *goBlog) commentsAdmin(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string) blog := r.Context().Value(blogContextKey).(string)
commentsPath := r.Context().Value(pathContextKey).(string) commentsPath := r.Context().Value(pathContextKey).(string)
// Adapter // Adapter
pageNoString := chi.URLParam(r, "page") pageNoString := chi.URLParam(r, "page")
pageNo, _ := strconv.Atoi(pageNoString) pageNo, _ := strconv.Atoi(pageNoString)
p := paginator.New(&commentsPaginationAdapter{config: &commentsRequestConfig{}}, 5) p := paginator.New(&commentsPaginationAdapter{config: &commentsRequestConfig{}, db: a.db}, 5)
p.SetPage(pageNo) p.SetPage(pageNo)
var comments []*comment var comments []*comment
err := p.Results(&comments) err := p.Results(&comments)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
// Navigation // Navigation
@ -70,7 +71,7 @@ func commentsAdmin(w http.ResponseWriter, r *http.Request) {
} }
nextPath = fmt.Sprintf("%s/page/%d", commentsPath, nextPage) nextPath = fmt.Sprintf("%s/page/%d", commentsPath, nextPage)
// Render // Render
render(w, r, templateCommentsAdmin, &renderData{ a.render(w, r, templateCommentsAdmin, &renderData{
BlogString: blog, BlogString: blog,
Data: map[string]interface{}{ Data: map[string]interface{}{
"Comments": comments, "Comments": comments,
@ -82,17 +83,17 @@ func commentsAdmin(w http.ResponseWriter, r *http.Request) {
}) })
} }
func commentsAdminDelete(w http.ResponseWriter, r *http.Request) { func (a *goBlog) commentsAdminDelete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.FormValue("commentid")) id, err := strconv.Atoi(r.FormValue("commentid"))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
err = deleteComment(id) err = a.db.deleteComment(id)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
purgeCache() a.cache.purge()
http.Redirect(w, r, ".", http.StatusFound) http.Redirect(w, r, ".", http.StatusFound)
} }

View File

@ -224,9 +224,7 @@ type configWebmention struct {
DisableReceiving bool `mapstructure:"disableReceiving"` DisableReceiving bool `mapstructure:"disableReceiving"`
} }
var appConfig = &config{} func (a *goBlog) initConfig() error {
func initConfig() error {
viper.SetConfigName("config") viper.SetConfigName("config")
viper.AddConfigPath("./config/") viper.AddConfigPath("./config/")
err := viper.ReadInConfig() err := viper.ReadInConfig()
@ -258,52 +256,53 @@ func initConfig() error {
viper.SetDefault("webmention.disableSending", false) viper.SetDefault("webmention.disableSending", false)
viper.SetDefault("webmention.disableReceiving", false) viper.SetDefault("webmention.disableReceiving", false)
// Unmarshal config // Unmarshal config
err = viper.Unmarshal(appConfig) a.cfg = &config{}
err = viper.Unmarshal(a.cfg)
if err != nil { if err != nil {
return err return err
} }
// Check config // Check config
publicURL, err := url.Parse(appConfig.Server.PublicAddress) publicURL, err := url.Parse(a.cfg.Server.PublicAddress)
if err != nil { if err != nil {
return err return err
} }
appConfig.Server.publicHostname = publicURL.Hostname() a.cfg.Server.publicHostname = publicURL.Hostname()
if appConfig.Server.ShortPublicAddress != "" { if a.cfg.Server.ShortPublicAddress != "" {
shortPublicURL, err := url.Parse(appConfig.Server.ShortPublicAddress) shortPublicURL, err := url.Parse(a.cfg.Server.ShortPublicAddress)
if err != nil { if err != nil {
return err return err
} }
appConfig.Server.shortPublicHostname = shortPublicURL.Hostname() a.cfg.Server.shortPublicHostname = shortPublicURL.Hostname()
} }
if appConfig.Server.JWTSecret == "" { if a.cfg.Server.JWTSecret == "" {
return errors.New("no JWT secret configured") return errors.New("no JWT secret configured")
} }
if len(appConfig.Blogs) == 0 { if len(a.cfg.Blogs) == 0 {
return errors.New("no blog configured") return errors.New("no blog configured")
} }
if len(appConfig.DefaultBlog) == 0 || appConfig.Blogs[appConfig.DefaultBlog] == nil { if len(a.cfg.DefaultBlog) == 0 || a.cfg.Blogs[a.cfg.DefaultBlog] == nil {
return errors.New("no default blog or default blog not present") return errors.New("no default blog or default blog not present")
} }
if appConfig.Micropub.MediaStorage != nil { if a.cfg.Micropub.MediaStorage != nil {
if appConfig.Micropub.MediaStorage.MediaURL == "" || if a.cfg.Micropub.MediaStorage.MediaURL == "" ||
appConfig.Micropub.MediaStorage.BunnyStorageKey == "" || a.cfg.Micropub.MediaStorage.BunnyStorageKey == "" ||
appConfig.Micropub.MediaStorage.BunnyStorageName == "" { a.cfg.Micropub.MediaStorage.BunnyStorageName == "" {
appConfig.Micropub.MediaStorage.BunnyStorageKey = "" a.cfg.Micropub.MediaStorage.BunnyStorageKey = ""
appConfig.Micropub.MediaStorage.BunnyStorageName = "" a.cfg.Micropub.MediaStorage.BunnyStorageName = ""
} }
appConfig.Micropub.MediaStorage.MediaURL = strings.TrimSuffix(appConfig.Micropub.MediaStorage.MediaURL, "/") a.cfg.Micropub.MediaStorage.MediaURL = strings.TrimSuffix(a.cfg.Micropub.MediaStorage.MediaURL, "/")
} }
if pm := appConfig.PrivateMode; pm != nil && pm.Enabled { if pm := a.cfg.PrivateMode; pm != nil && pm.Enabled {
appConfig.ActivityPub = &configActivityPub{Enabled: false} a.cfg.ActivityPub = &configActivityPub{Enabled: false}
} }
if wm := appConfig.Webmention; wm != nil && wm.DisableReceiving { if wm := a.cfg.Webmention; wm != nil && wm.DisableReceiving {
// Disable comments for all blogs // Disable comments for all blogs
for _, b := range appConfig.Blogs { for _, b := range a.cfg.Blogs {
b.Comments = &comments{Enabled: false} b.Comments = &comments{Enabled: false}
} }
} }
// Check config for each blog // Check config for each blog
for _, blog := range appConfig.Blogs { for _, blog := range a.cfg.Blogs {
if br := blog.Blogroll; br != nil && br.Enabled && br.Opml == "" { if br := blog.Blogroll; br != nil && br.Enabled && br.Opml == "" {
br.Enabled = false br.Enabled = false
} }
@ -311,6 +310,6 @@ func initConfig() error {
return nil return nil
} }
func httpsConfigured() bool { func (a *goBlog) httpsConfigured() bool {
return appConfig.Server.PublicHTTPS || appConfig.Server.SecurityHeaders || strings.HasPrefix(appConfig.Server.PublicAddress, "https") return a.cfg.Server.PublicHTTPS || a.cfg.Server.SecurityHeaders || strings.HasPrefix(a.cfg.Server.PublicAddress, "https")
} }

View File

@ -4,18 +4,18 @@ import "net/http"
const customPageContextKey = "custompage" const customPageContextKey = "custompage"
func serveCustomPage(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveCustomPage(w http.ResponseWriter, r *http.Request) {
page := r.Context().Value(customPageContextKey).(*customPage) page := r.Context().Value(customPageContextKey).(*customPage)
if appConfig.Cache != nil && appConfig.Cache.Enable && page.Cache { if a.cfg.Cache != nil && a.cfg.Cache.Enable && page.Cache {
if page.CacheExpiration != 0 { if page.CacheExpiration != 0 {
setInternalCacheExpirationHeader(w, r, page.CacheExpiration) setInternalCacheExpirationHeader(w, r, page.CacheExpiration)
} else { } else {
setInternalCacheExpirationHeader(w, r, int(appConfig.Cache.Expiration)) setInternalCacheExpirationHeader(w, r, int(a.cfg.Cache.Expiration))
} }
} }
render(w, r, page.Template, &renderData{ a.render(w, r, page.Template, &renderData{
BlogString: r.Context().Value(blogContextKey).(string), BlogString: r.Context().Value(blogContextKey).(string),
Canonical: appConfig.Server.PublicAddress + page.Path, Canonical: a.cfg.Server.PublicAddress + page.Path,
Data: page.Data, Data: page.Data,
}) })
} }

View File

@ -8,18 +8,42 @@ import (
sqlite "github.com/mattn/go-sqlite3" sqlite "github.com/mattn/go-sqlite3"
"github.com/schollz/sqlite3dump" "github.com/schollz/sqlite3dump"
"golang.org/x/sync/singleflight"
) )
var appDb *goblogDb type database struct {
db *sql.DB
type goblogDb struct { stmts map[string]*sql.Stmt
db *sql.DB g singleflight.Group
statementCache map[string]*sql.Stmt persistentCacheGroup singleflight.Group
} }
func initDatabase() (err error) { func (a *goBlog) initDatabase() (err error) {
// Setup db // Setup db
sql.Register("goblog_db", &sqlite.SQLiteDriver{ db, err := a.openDatabase(a.cfg.Db.File)
if err != nil {
return err
}
// Create appDB
a.db = db
db.vacuum()
addShutdownFunc(func() {
_ = db.close()
log.Println("Closed database")
})
if a.cfg.Db.DumpFile != "" {
hourlyHooks = append(hourlyHooks, func() {
db.dump(a.cfg.Db.DumpFile)
})
db.dump(a.cfg.Db.DumpFile)
}
return nil
}
func (a *goBlog) openDatabase(file string) (*database, error) {
// Register driver
dbDriverName := generateRandomString(15)
sql.Register("goblog_db_"+dbDriverName, &sqlite.SQLiteDriver{
ConnectHook: func(c *sqlite.SQLiteConn) error { ConnectHook: func(c *sqlite.SQLiteConn) error {
if err := c.RegisterFunc("tolocal", toLocalSafe, true); err != nil { if err := c.RegisterFunc("tolocal", toLocalSafe, true); err != nil {
return err return err
@ -27,64 +51,54 @@ func initDatabase() (err error) {
if err := c.RegisterFunc("wordcount", wordCount, true); err != nil { if err := c.RegisterFunc("wordcount", wordCount, true); err != nil {
return err return err
} }
if err := c.RegisterFunc("mdtext", renderText, true); err != nil { if err := c.RegisterFunc("mdtext", a.renderText, true); err != nil {
return err return err
} }
return nil return nil
}, },
}) })
db, err := sql.Open("goblog_db", appConfig.Db.File+"?cache=shared&mode=rwc&_journal_mode=WAL") // Open db
db, err := sql.Open("goblog_db_"+dbDriverName, file+"?cache=shared&mode=rwc&_journal_mode=WAL")
if err != nil { if err != nil {
return err return nil, err
} }
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
err = db.Ping() err = db.Ping()
if err != nil { if err != nil {
return err return nil, err
} }
// Check available SQLite features // Check available SQLite features
rows, err := db.Query("pragma compile_options") rows, err := db.Query("pragma compile_options")
if err != nil { if err != nil {
return err return nil, err
} }
cos := map[string]bool{} cos := map[string]bool{}
var co string var co string
for rows.Next() { for rows.Next() {
err = rows.Scan(&co) err = rows.Scan(&co)
if err != nil { if err != nil {
return err return nil, err
} }
cos[co] = true cos[co] = true
} }
if _, ok := cos["ENABLE_FTS5"]; !ok { if _, ok := cos["ENABLE_FTS5"]; !ok {
return errors.New("sqlite not compiled with FTS5") return nil, errors.New("sqlite not compiled with FTS5")
} }
// Migrate DB // Migrate DB
err = migrateDb(db) err = migrateDb(db)
if err != nil { if err != nil {
return err return nil, err
} }
// Create appDB return &database{
appDb = &goblogDb{ db: db,
db: db, stmts: map[string]*sql.Stmt{},
statementCache: map[string]*sql.Stmt{}, }, nil
}
appDb.vacuum()
addShutdownFunc(func() {
_ = appDb.close()
log.Println("Closed database")
})
if appConfig.Db.DumpFile != "" {
hourlyHooks = append(hourlyHooks, func() {
appDb.dump()
})
appDb.dump()
}
return nil
} }
func (db *goblogDb) dump() { // Main features
f, err := os.Create(appConfig.Db.DumpFile)
func (db *database) dump(file string) {
f, err := os.Create(file)
if err != nil { if err != nil {
log.Println("Error while dump db:", err.Error()) log.Println("Error while dump db:", err.Error())
return return
@ -94,18 +108,18 @@ func (db *goblogDb) dump() {
} }
} }
func (db *goblogDb) close() error { func (db *database) close() error {
db.vacuum() db.vacuum()
return db.db.Close() return db.db.Close()
} }
func (db *goblogDb) vacuum() { func (db *database) vacuum() {
_, _ = db.exec("VACUUM") _, _ = db.exec("VACUUM")
} }
func (db *goblogDb) prepare(query string) (*sql.Stmt, error) { func (db *database) prepare(query string) (*sql.Stmt, error) {
stmt, err, _ := cacheGroup.Do(query, func() (interface{}, error) { stmt, err, _ := db.g.Do(query, func() (interface{}, error) {
stmt, ok := db.statementCache[query] stmt, ok := db.stmts[query]
if ok && stmt != nil { if ok && stmt != nil {
return stmt, nil return stmt, nil
} }
@ -113,7 +127,7 @@ func (db *goblogDb) prepare(query string) (*sql.Stmt, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
db.statementCache[query] = stmt db.stmts[query] = stmt
return stmt, nil return stmt, nil
}) })
if err != nil { if err != nil {
@ -122,7 +136,7 @@ func (db *goblogDb) prepare(query string) (*sql.Stmt, error) {
return stmt.(*sql.Stmt), nil return stmt.(*sql.Stmt), nil
} }
func (db *goblogDb) exec(query string, args ...interface{}) (sql.Result, error) { func (db *database) exec(query string, args ...interface{}) (sql.Result, error) {
stmt, err := db.prepare(query) stmt, err := db.prepare(query)
if err != nil { if err != nil {
return nil, err return nil, err
@ -130,12 +144,12 @@ func (db *goblogDb) exec(query string, args ...interface{}) (sql.Result, error)
return stmt.Exec(args...) return stmt.Exec(args...)
} }
func (db *goblogDb) execMulti(query string, args ...interface{}) (sql.Result, error) { func (db *database) execMulti(query string, args ...interface{}) (sql.Result, error) {
// Can't prepare the statement // Can't prepare the statement
return db.db.Exec(query, args...) return db.db.Exec(query, args...)
} }
func (db *goblogDb) query(query string, args ...interface{}) (*sql.Rows, error) { func (db *database) query(query string, args ...interface{}) (*sql.Rows, error) {
stmt, err := db.prepare(query) stmt, err := db.prepare(query)
if err != nil { if err != nil {
return nil, err return nil, err
@ -143,10 +157,16 @@ func (db *goblogDb) query(query string, args ...interface{}) (*sql.Rows, error)
return stmt.Query(args...) return stmt.Query(args...)
} }
func (db *goblogDb) queryRow(query string, args ...interface{}) (*sql.Row, error) { func (db *database) queryRow(query string, args ...interface{}) (*sql.Row, error) {
stmt, err := db.prepare(query) stmt, err := db.prepare(query)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return stmt.QueryRow(args...), nil return stmt.QueryRow(args...), nil
} }
// Other things
func (d *database) rebuildFTSIndex() {
_, _ = d.exec("insert into posts_fts(posts_fts) values ('rebuild')")
}

View File

@ -2,12 +2,16 @@ package main
import ( import (
"database/sql" "database/sql"
"log"
"github.com/lopezator/migrator" "github.com/lopezator/migrator"
) )
func migrateDb(db *sql.DB) error { func migrateDb(db *sql.DB) error {
m, err := migrator.New( m, err := migrator.New(
migrator.WithLogger(migrator.LoggerFunc(func(s string, i ...interface{}) {
log.Printf(s, i)
})),
migrator.Migrations( migrator.Migrations(
&migrator.Migration{ &migrator.Migration{
Name: "00001", Name: "00001",

View File

@ -11,45 +11,45 @@ import (
const editorPath = "/editor" const editorPath = "/editor"
func serveEditor(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveEditor(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string) blog := r.Context().Value(blogContextKey).(string)
render(w, r, templateEditor, &renderData{ a.render(w, r, templateEditor, &renderData{
BlogString: blog, BlogString: blog,
Data: map[string]interface{}{ Data: map[string]interface{}{
"Drafts": loadDrafts(blog), "Drafts": a.db.getDrafts(blog),
}, },
}) })
} }
func serveEditorPost(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveEditorPost(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string) blog := r.Context().Value(blogContextKey).(string)
if action := r.FormValue("editoraction"); action != "" { if action := r.FormValue("editoraction"); action != "" {
switch action { switch action {
case "loaddelete": case "loaddelete":
render(w, r, templateEditor, &renderData{ a.render(w, r, templateEditor, &renderData{
BlogString: blog, BlogString: blog,
Data: map[string]interface{}{ Data: map[string]interface{}{
"DeleteURL": r.FormValue("url"), "DeleteURL": r.FormValue("url"),
"Drafts": loadDrafts(blog), "Drafts": a.db.getDrafts(blog),
}, },
}) })
case "loadupdate": case "loadupdate":
parsedURL, err := url.Parse(r.FormValue("url")) parsedURL, err := url.Parse(r.FormValue("url"))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
post, err := getPost(parsedURL.Path) post, err := a.db.getPost(parsedURL.Path)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
render(w, r, templateEditor, &renderData{ a.render(w, r, templateEditor, &renderData{
BlogString: blog, BlogString: blog,
Data: map[string]interface{}{ Data: map[string]interface{}{
"UpdatePostURL": parsedURL.String(), "UpdatePostURL": parsedURL.String(),
"UpdatePostContent": post.toMfItem().Properties.Content[0], "UpdatePostContent": a.toMfItem(post).Properties.Content[0],
"Drafts": loadDrafts(blog), "Drafts": a.db.getDrafts(blog),
}, },
}) })
case "updatepost": case "updatepost":
@ -63,37 +63,32 @@ func serveEditorPost(w http.ResponseWriter, r *http.Request) {
}, },
}) })
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
req, err := http.NewRequest(http.MethodPost, "", bytes.NewReader(jsonBytes)) req, err := http.NewRequest(http.MethodPost, "", bytes.NewReader(jsonBytes))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
req.Header.Set(contentType, contentTypeJSON) req.Header.Set(contentType, contentTypeJSON)
editorMicropubPost(w, req, false) a.editorMicropubPost(w, req, false)
case "upload": case "upload":
editorMicropubPost(w, r, true) a.editorMicropubPost(w, r, true)
default: default:
serveError(w, r, "Unknown editoraction", http.StatusBadRequest) a.serveError(w, r, "Unknown editoraction", http.StatusBadRequest)
} }
return return
} }
editorMicropubPost(w, r, false) a.editorMicropubPost(w, r, false)
} }
func loadDrafts(blog string) []*post { func (a *goBlog) editorMicropubPost(w http.ResponseWriter, r *http.Request, media bool) {
ps, _ := getPosts(&postsRequestConfig{status: statusDraft, blog: blog})
return ps
}
func editorMicropubPost(w http.ResponseWriter, r *http.Request, media bool) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
if media { if media {
addAllScopes(http.HandlerFunc(serveMicropubMedia)).ServeHTTP(recorder, r) addAllScopes(http.HandlerFunc(a.serveMicropubMedia)).ServeHTTP(recorder, r)
} else { } else {
addAllScopes(http.HandlerFunc(serveMicropubPost)).ServeHTTP(recorder, r) addAllScopes(http.HandlerFunc(a.serveMicropubPost)).ServeHTTP(recorder, r)
} }
result := recorder.Result() result := recorder.Result()
if location := result.Header.Get("Location"); location != "" { if location := result.Header.Get("Location"); location != "" {

View File

@ -12,19 +12,19 @@ type errorData struct {
Message string Message string
} }
func serve404(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serve404(w http.ResponseWriter, r *http.Request) {
serveError(w, r, fmt.Sprintf("%s was not found", r.RequestURI), http.StatusNotFound) a.serveError(w, r, fmt.Sprintf("%s was not found", r.RequestURI), http.StatusNotFound)
} }
func serveNotAllowed(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveNotAllowed(w http.ResponseWriter, r *http.Request) {
serveError(w, r, "", http.StatusMethodNotAllowed) a.serveError(w, r, "", http.StatusMethodNotAllowed)
} }
var errorCheckMediaTypes = []contenttype.MediaType{ var errorCheckMediaTypes = []contenttype.MediaType{
contenttype.NewMediaType(contentTypeHTML), contenttype.NewMediaType(contentTypeHTML),
} }
func serveError(w http.ResponseWriter, r *http.Request, message string, status int) { func (a *goBlog) serveError(w http.ResponseWriter, r *http.Request, message string, status int) {
if mt, _, err := contenttype.GetAcceptableMediaType(r, errorCheckMediaTypes); err != nil || mt.String() != errorCheckMediaTypes[0].String() { if mt, _, err := contenttype.GetAcceptableMediaType(r, errorCheckMediaTypes); err != nil || mt.String() != errorCheckMediaTypes[0].String() {
// Request doesn't accept HTML // Request doesn't accept HTML
http.Error(w, message, status) http.Error(w, message, status)
@ -35,7 +35,7 @@ func serveError(w http.ResponseWriter, r *http.Request, message string, status i
message = http.StatusText(status) message = http.StatusText(status)
} }
w.WriteHeader(status) w.WriteHeader(status)
render(w, r, templateError, &renderData{ a.render(w, r, templateError, &renderData{
Data: &errorData{ Data: &errorData{
Title: title, Title: title,
Message: message, Message: message,

View File

@ -22,25 +22,25 @@ const (
feedAudioLength = "audiolength" feedAudioLength = "audiolength"
) )
func generateFeed(blog string, f feedType, w http.ResponseWriter, r *http.Request, posts []*post, title string, description string) { func (a *goBlog) generateFeed(blog string, f feedType, w http.ResponseWriter, r *http.Request, posts []*post, title string, description string) {
now := time.Now() now := time.Now()
if title == "" { if title == "" {
title = appConfig.Blogs[blog].Title title = a.cfg.Blogs[blog].Title
} }
if description == "" { if description == "" {
description = appConfig.Blogs[blog].Description description = a.cfg.Blogs[blog].Description
} }
feed := &feeds.Feed{ feed := &feeds.Feed{
Title: title, Title: title,
Description: description, Description: description,
Link: &feeds.Link{Href: appConfig.Server.PublicAddress + strings.TrimSuffix(r.URL.Path, "."+string(f))}, Link: &feeds.Link{Href: a.cfg.Server.PublicAddress + strings.TrimSuffix(r.URL.Path, "."+string(f))},
Created: now, Created: now,
Author: &feeds.Author{ Author: &feeds.Author{
Name: appConfig.User.Name, Name: a.cfg.User.Name,
Email: appConfig.User.Email, Email: a.cfg.User.Email,
}, },
Image: &feeds.Image{ Image: &feeds.Image{
Url: appConfig.User.Picture, Url: a.cfg.User.Picture,
}, },
} }
for _, p := range posts { for _, p := range posts {
@ -56,10 +56,10 @@ func generateFeed(blog string, f feedType, w http.ResponseWriter, r *http.Reques
} }
feed.Add(&feeds.Item{ feed.Add(&feeds.Item{
Title: p.title(), Title: p.title(),
Link: &feeds.Link{Href: p.fullURL()}, Link: &feeds.Link{Href: a.fullPostURL(p)},
Description: p.summary(), Description: a.summary(p),
Id: p.Path, Id: p.Path,
Content: string(p.absoluteHTML()), Content: string(a.absoluteHTML(p)),
Created: created, Created: created,
Updated: updated, Updated: updated,
Enclosure: enc, Enclosure: enc,
@ -82,7 +82,7 @@ func generateFeed(blog string, f feedType, w http.ResponseWriter, r *http.Reques
} }
if err != nil { if err != nil {
w.Header().Del(contentType) w.Header().Del(contentType)
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
w.Header().Set(contentType, feedMediaType+charsetUtf8Suffix) w.Header().Set(contentType, feedMediaType+charsetUtf8Suffix)

12
go.mod
View File

@ -14,11 +14,9 @@ require (
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/boombuler/barcode v1.0.1 // indirect github.com/boombuler/barcode v1.0.1 // indirect
github.com/caddyserver/certmagic v0.13.1 github.com/caddyserver/certmagic v0.13.1
// master github.com/cretz/bine v0.2.0
github.com/cretz/bine v0.1.1-0.20200124154328-f9f678b84cca
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f
// master github.com/dgraph-io/ristretto v0.1.0
github.com/dgraph-io/ristretto v0.0.4-0.20210504190834-0bf2acd73aa3
github.com/elnormous/contenttype v1.0.0 github.com/elnormous/contenttype v1.0.0
github.com/felixge/httpsnoop v1.0.2 // indirect github.com/felixge/httpsnoop v1.0.2 // indirect
github.com/go-chi/chi/v5 v5.0.3 github.com/go-chi/chi/v5 v5.0.3
@ -38,6 +36,7 @@ require (
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
github.com/lestrrat-go/strftime v1.0.4 // indirect github.com/lestrrat-go/strftime v1.0.4 // indirect
github.com/lib/pq v1.9.0 // indirect github.com/lib/pq v1.9.0 // indirect
github.com/libdns/libdns v0.2.1 // indirect
github.com/lopezator/migrator v0.3.0 github.com/lopezator/migrator v0.3.0
github.com/magiconair/properties v1.8.5 // indirect github.com/magiconair/properties v1.8.5 // indirect
github.com/mattn/go-sqlite3 v1.14.7 github.com/mattn/go-sqlite3 v1.14.7
@ -46,7 +45,7 @@ require (
github.com/mitchellh/go-server-timing v1.0.1 github.com/mitchellh/go-server-timing v1.0.1
github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect
github.com/paulmach/go.geojson v1.4.0 github.com/paulmach/go.geojson v1.4.0
github.com/pelletier/go-toml v1.9.1 // indirect github.com/pelletier/go-toml v1.9.2 // indirect
github.com/pquerna/otp v1.3.0 github.com/pquerna/otp v1.3.0
github.com/schollz/sqlite3dump v1.2.4 github.com/schollz/sqlite3dump v1.2.4
github.com/smartystreets/assertions v1.2.0 // indirect github.com/smartystreets/assertions v1.2.0 // indirect
@ -63,10 +62,9 @@ require (
github.com/yuin/goldmark-emoji v1.0.1 github.com/yuin/goldmark-emoji v1.0.1
go.uber.org/multierr v1.7.0 // indirect go.uber.org/multierr v1.7.0 // indirect
go.uber.org/zap v1.17.0 // indirect go.uber.org/zap v1.17.0 // indirect
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
golang.org/x/net v0.0.0-20210525063256-abc453219eb5 golang.org/x/net v0.0.0-20210525063256-abc453219eb5
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea // indirect golang.org/x/sys v0.0.0-20210603125802-9665404d3644 // indirect
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/ini.v1 v1.62.0 // indirect

19
go.sum
View File

@ -59,15 +59,15 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cretz/bine v0.1.1-0.20200124154328-f9f678b84cca h1:Q2r7AxHdJwWfLtBZwvW621M3sPqxPc6ITv2j1FGsYpw= github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo=
github.com/cretz/bine v0.1.1-0.20200124154328-f9f678b84cca/go.mod h1:6PF6fWAvYtwjRGkAuDEJeWNOv3a2hUouSP/yRYXmvHw= github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f h1:q/DpyjJjZs94bziQ7YkBmIlpqbVP7yw179rnzoNVX1M= github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f h1:q/DpyjJjZs94bziQ7YkBmIlpqbVP7yw179rnzoNVX1M=
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f/go.mod h1:QGrK8vMWWHQYQ3QU9bw9Y9OPNfxccGzfb41qjvVeXtY= github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f/go.mod h1:QGrK8vMWWHQYQ3QU9bw9Y9OPNfxccGzfb41qjvVeXtY=
github.com/dgraph-io/ristretto v0.0.4-0.20210504190834-0bf2acd73aa3 h1:jU/wpYsEL+8JPLf/QcjkQKI5g0dOjSuwcMjkThxt5x0= github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
github.com/dgraph-io/ristretto v0.0.4-0.20210504190834-0bf2acd73aa3/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
@ -214,8 +214,9 @@ github.com/lestrrat-go/strftime v1.0.4/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR7
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8= github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8=
github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/libdns/libdns v0.2.0 h1:ewg3ByWrdUrxrje8ChPVMBNcotg7H9LQYg+u5De2RzI=
github.com/libdns/libdns v0.2.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= github.com/libdns/libdns v0.2.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis=
github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
github.com/lopezator/migrator v0.3.0 h1:VW/rR+J8NYwPdkBxjrFdjwejpgvP59LbmANJxXuNbuk= github.com/lopezator/migrator v0.3.0 h1:VW/rR+J8NYwPdkBxjrFdjwejpgvP59LbmANJxXuNbuk=
github.com/lopezator/migrator v0.3.0/go.mod h1:bpVAVPkWSvTw8ya2Pk7E/KiNAyDWNImgivQY79o8/8I= github.com/lopezator/migrator v0.3.0/go.mod h1:bpVAVPkWSvTw8ya2Pk7E/KiNAyDWNImgivQY79o8/8I=
github.com/magiconair/properties v1.7.4-0.20170902060319-8d7837e64d3c/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.7.4-0.20170902060319-8d7837e64d3c/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
@ -262,8 +263,8 @@ github.com/paulmach/go.geojson v1.4.0 h1:5x5moCkCtDo5x8af62P9IOAYGQcYHtxz2QJ3x1D
github.com/paulmach/go.geojson v1.4.0/go.mod h1:YaKx1hKpWF+T2oj2lFJPsW/t1Q5e1jQI61eoQSTwpIs= github.com/paulmach/go.geojson v1.4.0/go.mod h1:YaKx1hKpWF+T2oj2lFJPsW/t1Q5e1jQI61eoQSTwpIs=
github.com/pelletier/go-toml v1.0.1-0.20170904195809-1d6b12b7cb29/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.0.1-0.20170904195809-1d6b12b7cb29/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.9.1 h1:a6qW1EVNZWH9WGI6CsYdD8WAylkoXBS5yv0XHlh17Tc= github.com/pelletier/go-toml v1.9.2 h1:7NiByeVF4jKSG1lDF3X8LTIkq2/bu+1uYbIm1eS5tzk=
github.com/pelletier/go-toml v1.9.1/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.2/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -450,8 +451,8 @@ golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea h1:+WiDlPBBaO+h9vPNZi8uJ3k4BkKQB7Iow3aqwHVA5hI= golang.org/x/sys v0.0.0-20210603125802-9665404d3644 h1:CA1DEQ4NdKphKeL70tvsWNdT5oFh1lOjihRcEDROi0I=
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View File

@ -6,8 +6,8 @@ import (
"net/http" "net/http"
) )
func healthcheck() bool { func (a *goBlog) healthcheck() bool {
req, err := http.NewRequest(http.MethodGet, appConfig.Server.PublicAddress+"/ping", nil) req, err := http.NewRequest(http.MethodGet, a.cfg.Server.PublicAddress+"/ping", nil)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
return false return false
@ -22,8 +22,8 @@ func healthcheck() bool {
return resp.StatusCode == 200 return resp.StatusCode == 200
} }
func healthcheckExitCode() int { func (a *goBlog) healthcheckExitCode() int {
if healthcheck() { if a.healthcheck() {
return 0 return 0
} else { } else {
return 1 return 1

View File

@ -8,68 +8,62 @@ import (
"time" "time"
) )
func preStartHooks() { func (a *goBlog) preStartHooks() {
for _, cmd := range appConfig.Hooks.PreStart { for _, cmd := range a.cfg.Hooks.PreStart {
func(cmd string) { func(cmd string) {
log.Println("Executing pre-start hook:", cmd) log.Println("Executing pre-start hook:", cmd)
executeCommand(cmd) a.cfg.Hooks.executeCommand(cmd)
}(cmd) }(cmd)
} }
} }
type postHookFunc func(*post) type postHookFunc func(*post)
var ( func (a *goBlog) postPostHooks(p *post) {
postPostHooks []postHookFunc
postUpdateHooks []postHookFunc
postDeleteHooks []postHookFunc
)
func (p *post) postPostHooks() {
// Hooks after post published // Hooks after post published
for _, cmdTmplString := range appConfig.Hooks.PostPost { for _, cmdTmplString := range a.cfg.Hooks.PostPost {
go func(p *post, cmdTmplString string) { go func(p *post, cmdTmplString string) {
executeTemplateCommand("post-post", cmdTmplString, map[string]interface{}{ a.cfg.Hooks.executeTemplateCommand("post-post", cmdTmplString, map[string]interface{}{
"URL": p.fullURL(), "URL": a.fullPostURL(p),
"Post": p, "Post": p,
}) })
}(p, cmdTmplString) }(p, cmdTmplString)
} }
for _, f := range postPostHooks { for _, f := range a.pPostHooks {
go f(p) go f(p)
} }
} }
func (p *post) postUpdateHooks() { func (a *goBlog) postUpdateHooks(p *post) {
// Hooks after post updated // Hooks after post updated
for _, cmdTmplString := range appConfig.Hooks.PostUpdate { for _, cmdTmplString := range a.cfg.Hooks.PostUpdate {
go func(p *post, cmdTmplString string) { go func(p *post, cmdTmplString string) {
executeTemplateCommand("post-update", cmdTmplString, map[string]interface{}{ a.cfg.Hooks.executeTemplateCommand("post-update", cmdTmplString, map[string]interface{}{
"URL": p.fullURL(), "URL": a.fullPostURL(p),
"Post": p, "Post": p,
}) })
}(p, cmdTmplString) }(p, cmdTmplString)
} }
for _, f := range postUpdateHooks { for _, f := range a.pUpdateHooks {
go f(p) go f(p)
} }
} }
func (p *post) postDeleteHooks() { func (a *goBlog) postDeleteHooks(p *post) {
for _, cmdTmplString := range appConfig.Hooks.PostDelete { for _, cmdTmplString := range a.cfg.Hooks.PostDelete {
go func(p *post, cmdTmplString string) { go func(p *post, cmdTmplString string) {
executeTemplateCommand("post-delete", cmdTmplString, map[string]interface{}{ a.cfg.Hooks.executeTemplateCommand("post-delete", cmdTmplString, map[string]interface{}{
"URL": p.fullURL(), "URL": a.fullPostURL(p),
"Post": p, "Post": p,
}) })
}(p, cmdTmplString) }(p, cmdTmplString)
} }
for _, f := range postDeleteHooks { for _, f := range a.pDeleteHooks {
go f(p) go f(p)
} }
} }
func executeTemplateCommand(hookType string, tmpl string, data map[string]interface{}) { func (cfg *configHooks) executeTemplateCommand(hookType string, tmpl string, data map[string]interface{}) {
cmdTmpl, err := template.New("cmd").Parse(tmpl) cmdTmpl, err := template.New("cmd").Parse(tmpl)
if err != nil { if err != nil {
log.Println("Failed to parse cmd template:", err.Error()) log.Println("Failed to parse cmd template:", err.Error())
@ -82,18 +76,18 @@ func executeTemplateCommand(hookType string, tmpl string, data map[string]interf
} }
cmd := cmdBuf.String() cmd := cmdBuf.String()
log.Println("Executing "+hookType+" hook:", cmd) log.Println("Executing "+hookType+" hook:", cmd)
executeCommand(cmd) cfg.executeCommand(cmd)
} }
var hourlyHooks = []func(){} var hourlyHooks = []func(){}
func startHourlyHooks() { func (a *goBlog) startHourlyHooks() {
// Add configured hourly hooks // Add configured hourly hooks
for _, cmd := range appConfig.Hooks.Hourly { for _, cmd := range a.cfg.Hooks.Hourly {
c := cmd c := cmd
f := func() { f := func() {
log.Println("Executing hourly hook:", c) log.Println("Executing hourly hook:", c)
executeCommand(c) a.cfg.Hooks.executeCommand(c)
} }
hourlyHooks = append(hourlyHooks, f) hourlyHooks = append(hourlyHooks, f)
} }
@ -121,8 +115,8 @@ func startHourlyHooks() {
} }
} }
func executeCommand(cmd string) { func (cfg *configHooks) executeCommand(cmd string) {
out, err := exec.Command(appConfig.Hooks.Shell, "-c", cmd).CombinedOutput() out, err := exec.Command(cfg.Shell, "-c", cmd).CombinedOutput()
if err != nil { if err != nil {
log.Println("Failed to execute command:", err.Error()) log.Println("Failed to execute command:", err.Error())
} }

406
http.go
View File

@ -44,35 +44,33 @@ const (
appUserAgent = "GoBlog" appUserAgent = "GoBlog"
) )
var d *dynamicHandler func (a *goBlog) startServer() (err error) {
func startServer() (err error) {
// Start // Start
d = &dynamicHandler{} a.d = &dynamicHandler{}
// Set basic middlewares // Set basic middlewares
var finalHandler http.Handler = d var finalHandler http.Handler = a.d
if appConfig.Server.PublicHTTPS || appConfig.Server.SecurityHeaders { if a.cfg.Server.PublicHTTPS || a.cfg.Server.SecurityHeaders {
finalHandler = securityHeaders(finalHandler) finalHandler = a.securityHeaders(finalHandler)
} }
finalHandler = servertiming.Middleware(finalHandler, nil) finalHandler = servertiming.Middleware(finalHandler, nil)
finalHandler = middleware.Heartbeat("/ping")(finalHandler) finalHandler = middleware.Heartbeat("/ping")(finalHandler)
finalHandler = middleware.Compress(flate.DefaultCompression)(finalHandler) finalHandler = middleware.Compress(flate.DefaultCompression)(finalHandler)
finalHandler = middleware.Recoverer(finalHandler) finalHandler = middleware.Recoverer(finalHandler)
if appConfig.Server.Logging { if a.cfg.Server.Logging {
finalHandler = logMiddleware(finalHandler) finalHandler = a.logMiddleware(finalHandler)
} }
// Create routers that don't change // Create routers that don't change
if err = buildStaticHandlersRouters(); err != nil { if err = a.buildStaticHandlersRouters(); err != nil {
return err return err
} }
// Load router // Load router
if err = reloadRouter(); err != nil { if err = a.reloadRouter(); err != nil {
return err return err
} }
// Start Onion service // Start Onion service
if appConfig.Server.Tor { if a.cfg.Server.Tor {
go func() { go func() {
if err := startOnionService(finalHandler); err != nil { if err := a.startOnionService(finalHandler); err != nil {
log.Println("Tor failed:", err.Error()) log.Println("Tor failed:", err.Error())
} }
}() }()
@ -84,10 +82,10 @@ func startServer() (err error) {
WriteTimeout: 5 * time.Minute, WriteTimeout: 5 * time.Minute,
} }
addShutdownFunc(shutdownServer(s, "main server")) addShutdownFunc(shutdownServer(s, "main server"))
if appConfig.Server.PublicHTTPS { if a.cfg.Server.PublicHTTPS {
// Configure // Configure
certmagic.Default.Storage = &certmagic.FileStorage{Path: "data/https"} certmagic.Default.Storage = &certmagic.FileStorage{Path: "data/https"}
certmagic.DefaultACME.Email = appConfig.Server.LetsEncryptMail certmagic.DefaultACME.Email = a.cfg.Server.LetsEncryptMail
certmagic.DefaultACME.CA = certmagic.LetsEncryptProductionCA certmagic.DefaultACME.CA = certmagic.LetsEncryptProductionCA
// Start HTTP server for redirects // Start HTTP server for redirects
httpServer := &http.Server{ httpServer := &http.Server{
@ -104,9 +102,9 @@ func startServer() (err error) {
}() }()
// Start HTTPS // Start HTTPS
s.Addr = ":https" s.Addr = ":https"
hosts := []string{appConfig.Server.publicHostname} hosts := []string{a.cfg.Server.publicHostname}
if appConfig.Server.shortPublicHostname != "" { if a.cfg.Server.shortPublicHostname != "" {
hosts = append(hosts, appConfig.Server.shortPublicHostname) hosts = append(hosts, a.cfg.Server.shortPublicHostname)
} }
listener, e := certmagic.Listen(hosts) listener, e := certmagic.Listen(hosts)
if e != nil { if e != nil {
@ -116,7 +114,7 @@ func startServer() (err error) {
return err return err
} }
} else { } else {
s.Addr = ":" + strconv.Itoa(appConfig.Server.Port) s.Addr = ":" + strconv.Itoa(a.cfg.Server.Port)
if err = s.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err = s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err return err
} }
@ -142,13 +140,13 @@ func redirectToHttps(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("https://%s%s", requestHost, r.URL.RequestURI()), http.StatusMovedPermanently) http.Redirect(w, r, fmt.Sprintf("https://%s%s", requestHost, r.URL.RequestURI()), http.StatusMovedPermanently)
} }
func reloadRouter() error { func (a *goBlog) reloadRouter() error {
h, err := buildDynamicRouter() h, err := a.buildDynamicRouter()
if err != nil { if err != nil {
return err return err
} }
d.swapHandler(h) a.d.swapHandler(h)
purgeCache() a.cache.purge()
return nil return nil
} }
@ -157,107 +155,101 @@ const (
feedPath = ".{feed:rss|json|atom}" feedPath = ".{feed:rss|json|atom}"
) )
var ( func (a *goBlog) buildStaticHandlersRouters() error {
privateMode = false if pm := a.cfg.PrivateMode; pm != nil && pm.Enabled {
privateModeHandler = []func(http.Handler) http.Handler{} a.privateMode = true
a.privateModeHandler = append(a.privateModeHandler, a.authMiddleware)
captchaHandler http.Handler } else {
a.privateMode = false
micropubRouter, indieAuthRouter, webmentionsRouter, notificationsRouter, activitypubRouter, editorRouter, commentsRouter, searchRouter *chi.Mux a.privateModeHandler = []func(http.Handler) http.Handler{}
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{}
)
func buildStaticHandlersRouters() error {
if pm := appConfig.PrivateMode; pm != nil && pm.Enabled {
privateMode = true
privateModeHandler = append(privateModeHandler, authMiddleware)
} }
captchaHandler = captcha.Server(500, 250) a.captchaHandler = captcha.Server(500, 250)
micropubRouter = chi.NewRouter() a.micropubRouter = chi.NewRouter()
micropubRouter.Use(checkIndieAuth) a.micropubRouter.Use(a.checkIndieAuth)
micropubRouter.Get("/", serveMicropubQuery) a.micropubRouter.Get("/", a.serveMicropubQuery)
micropubRouter.Post("/", serveMicropubPost) a.micropubRouter.Post("/", a.serveMicropubPost)
micropubRouter.Post(micropubMediaSubPath, serveMicropubMedia) a.micropubRouter.Post(micropubMediaSubPath, a.serveMicropubMedia)
indieAuthRouter = chi.NewRouter() a.indieAuthRouter = chi.NewRouter()
indieAuthRouter.Get("/", indieAuthRequest) a.indieAuthRouter.Get("/", a.indieAuthRequest)
indieAuthRouter.With(authMiddleware).Post("/accept", indieAuthAccept) a.indieAuthRouter.With(a.authMiddleware).Post("/accept", a.indieAuthAccept)
indieAuthRouter.Post("/", indieAuthVerification) a.indieAuthRouter.Post("/", a.indieAuthVerification)
indieAuthRouter.Get("/token", indieAuthToken) a.indieAuthRouter.Get("/token", a.indieAuthToken)
indieAuthRouter.Post("/token", indieAuthToken) a.indieAuthRouter.Post("/token", a.indieAuthToken)
webmentionsRouter = chi.NewRouter() a.webmentionsRouter = chi.NewRouter()
if wm := appConfig.Webmention; wm != nil && !wm.DisableReceiving { if wm := a.cfg.Webmention; wm != nil && !wm.DisableReceiving {
webmentionsRouter.Post("/", handleWebmention) a.webmentionsRouter.Post("/", a.handleWebmention)
webmentionsRouter.Group(func(r chi.Router) { a.webmentionsRouter.Group(func(r chi.Router) {
// Authenticated routes // Authenticated routes
r.Use(authMiddleware) r.Use(a.authMiddleware)
r.Get("/", webmentionAdmin) r.Get("/", a.webmentionAdmin)
r.Get(paginationPath, webmentionAdmin) r.Get(paginationPath, a.webmentionAdmin)
r.Post("/delete", webmentionAdminDelete) r.Post("/delete", a.webmentionAdminDelete)
r.Post("/approve", webmentionAdminApprove) r.Post("/approve", a.webmentionAdminApprove)
r.Post("/reverify", webmentionAdminReverify) r.Post("/reverify", a.webmentionAdminReverify)
}) })
} }
notificationsRouter = chi.NewRouter() a.notificationsRouter = chi.NewRouter()
notificationsRouter.Use(authMiddleware) a.notificationsRouter.Use(a.authMiddleware)
notificationsRouter.Get("/", notificationsAdmin) a.notificationsRouter.Get("/", a.notificationsAdmin)
notificationsRouter.Get(paginationPath, notificationsAdmin) a.notificationsRouter.Get(paginationPath, a.notificationsAdmin)
notificationsRouter.Post("/delete", notificationsAdminDelete) a.notificationsRouter.Post("/delete", a.notificationsAdminDelete)
if ap := appConfig.ActivityPub; ap != nil && ap.Enabled { if ap := a.cfg.ActivityPub; ap != nil && ap.Enabled {
activitypubRouter = chi.NewRouter() a.activitypubRouter = chi.NewRouter()
activitypubRouter.Post("/inbox/{blog}", apHandleInbox) a.activitypubRouter.Post("/inbox/{blog}", a.apHandleInbox)
activitypubRouter.Post("/{blog}/inbox", apHandleInbox) a.activitypubRouter.Post("/{blog}/inbox", a.apHandleInbox)
} }
editorRouter = chi.NewRouter() a.editorRouter = chi.NewRouter()
editorRouter.Use(authMiddleware) a.editorRouter.Use(a.authMiddleware)
editorRouter.Get("/", serveEditor) a.editorRouter.Get("/", a.serveEditor)
editorRouter.Post("/", serveEditorPost) a.editorRouter.Post("/", a.serveEditorPost)
commentsRouter = chi.NewRouter() a.commentsRouter = chi.NewRouter()
commentsRouter.Use(privateModeHandler...) a.commentsRouter.Use(a.privateModeHandler...)
commentsRouter.With(cacheMiddleware, noIndexHeader).Get("/{id:[0-9]+}", serveComment) a.commentsRouter.With(a.cache.cacheMiddleware, noIndexHeader).Get("/{id:[0-9]+}", a.serveComment)
commentsRouter.With(captchaMiddleware).Post("/", createComment) a.commentsRouter.With(a.captchaMiddleware).Post("/", a.createComment)
commentsRouter.Group(func(r chi.Router) { a.commentsRouter.Group(func(r chi.Router) {
// Admin // Admin
r.Use(authMiddleware) r.Use(a.authMiddleware)
r.Get("/", commentsAdmin) r.Get("/", a.commentsAdmin)
r.Get(paginationPath, commentsAdmin) r.Get(paginationPath, a.commentsAdmin)
r.Post("/delete", commentsAdminDelete) r.Post("/delete", a.commentsAdminDelete)
}) })
searchRouter = chi.NewRouter() a.searchRouter = chi.NewRouter()
searchRouter.Use(privateModeHandler...) a.searchRouter.Use(a.privateModeHandler...)
searchRouter.Use(cacheMiddleware) a.searchRouter.Use(a.cache.cacheMiddleware)
searchRouter.Get("/", serveSearch) a.searchRouter.Get("/", a.serveSearch)
searchRouter.Post("/", serveSearch) a.searchRouter.Post("/", a.serveSearch)
searchResultPath := "/" + searchPlaceholder searchResultPath := "/" + searchPlaceholder
searchRouter.Get(searchResultPath, serveSearchResult) a.searchRouter.Get(searchResultPath, a.serveSearchResult)
searchRouter.Get(searchResultPath+feedPath, serveSearchResult) a.searchRouter.Get(searchResultPath+feedPath, a.serveSearchResult)
searchRouter.Get(searchResultPath+paginationPath, serveSearchResult) a.searchRouter.Get(searchResultPath+paginationPath, a.serveSearchResult)
for blog, blogConfig := range appConfig.Blogs { a.setBlogMiddlewares = map[string]func(http.Handler) http.Handler{}
a.sectionMiddlewares = map[string]func(http.Handler) http.Handler{}
a.taxonomyMiddlewares = map[string]func(http.Handler) http.Handler{}
a.photosMiddlewares = map[string]func(http.Handler) http.Handler{}
a.searchMiddlewares = map[string]func(http.Handler) http.Handler{}
a.customPagesMiddlewares = map[string]func(http.Handler) http.Handler{}
a.commentsMiddlewares = map[string]func(http.Handler) http.Handler{}
for blog, blogConfig := range a.cfg.Blogs {
sbm := middleware.WithValue(blogContextKey, blog) sbm := middleware.WithValue(blogContextKey, blog)
setBlogMiddlewares[blog] = sbm a.setBlogMiddlewares[blog] = sbm
blogPath := blogPath(blog) blogPath := a.blogPath(blog)
for _, section := range blogConfig.Sections { for _, section := range blogConfig.Sections {
if section.Name != "" { if section.Name != "" {
secPath := blogPath + "/" + section.Name secPath := blogPath + "/" + section.Name
sectionMiddlewares[secPath] = middleware.WithValue(indexConfigKey, &indexConfig{ a.sectionMiddlewares[secPath] = middleware.WithValue(indexConfigKey, &indexConfig{
path: secPath, path: secPath,
section: section, section: section,
}) })
@ -267,12 +259,12 @@ func buildStaticHandlersRouters() error {
for _, taxonomy := range blogConfig.Taxonomies { for _, taxonomy := range blogConfig.Taxonomies {
if taxonomy.Name != "" { if taxonomy.Name != "" {
taxPath := blogPath + "/" + taxonomy.Name taxPath := blogPath + "/" + taxonomy.Name
taxonomyMiddlewares[taxPath] = middleware.WithValue(taxonomyContextKey, taxonomy) a.taxonomyMiddlewares[taxPath] = middleware.WithValue(taxonomyContextKey, taxonomy)
} }
} }
if blogConfig.Photos != nil && blogConfig.Photos.Enabled { if blogConfig.Photos != nil && blogConfig.Photos.Enabled {
photosMiddlewares[blog] = middleware.WithValue(indexConfigKey, &indexConfig{ a.photosMiddlewares[blog] = middleware.WithValue(indexConfigKey, &indexConfig{
path: blogPath + blogConfig.Photos.Path, path: blogPath + blogConfig.Photos.Path,
parameter: blogConfig.Photos.Parameter, parameter: blogConfig.Photos.Parameter,
title: blogConfig.Photos.Title, title: blogConfig.Photos.Title,
@ -282,15 +274,15 @@ func buildStaticHandlersRouters() error {
} }
if blogConfig.Search != nil && blogConfig.Search.Enabled { if blogConfig.Search != nil && blogConfig.Search.Enabled {
searchMiddlewares[blog] = middleware.WithValue(pathContextKey, blogPath+blogConfig.Search.Path) a.searchMiddlewares[blog] = middleware.WithValue(pathContextKey, blogPath+blogConfig.Search.Path)
} }
for _, cp := range blogConfig.CustomPages { for _, cp := range blogConfig.CustomPages {
customPagesMiddlewares[cp.Path] = middleware.WithValue(customPageContextKey, cp) a.customPagesMiddlewares[cp.Path] = middleware.WithValue(customPageContextKey, cp)
} }
if commentsConfig := blogConfig.Comments; commentsConfig != nil && commentsConfig.Enabled { if commentsConfig := blogConfig.Comments; commentsConfig != nil && commentsConfig.Enabled {
commentsMiddlewares[blog] = middleware.WithValue(pathContextKey, blogPath+"/comment") a.commentsMiddlewares[blog] = middleware.WithValue(pathContextKey, blogPath+"/comment")
} }
} }
@ -301,127 +293,127 @@ var (
taxValueMiddlewares = map[string]func(http.Handler) http.Handler{} taxValueMiddlewares = map[string]func(http.Handler) http.Handler{}
) )
func buildDynamicRouter() (*chi.Mux, error) { func (a *goBlog) buildDynamicRouter() (*chi.Mux, error) {
r := chi.NewRouter() r := chi.NewRouter()
// Basic middleware // Basic middleware
r.Use(redirectShortDomain) r.Use(a.redirectShortDomain)
r.Use(middleware.RedirectSlashes) r.Use(middleware.RedirectSlashes)
r.Use(middleware.CleanPath) r.Use(middleware.CleanPath)
r.Use(middleware.GetHead) r.Use(middleware.GetHead)
if !appConfig.Cache.Enable { if !a.cfg.Cache.Enable {
r.Use(middleware.NoCache) r.Use(middleware.NoCache)
} }
// No Index Header // No Index Header
if privateMode { if a.privateMode {
r.Use(noIndexHeader) r.Use(noIndexHeader)
} }
// Login middleware etc. // Login middleware etc.
r.Use(checkIsLogin) r.Use(a.checkIsLogin)
r.Use(checkIsCaptcha) r.Use(a.checkIsCaptcha)
r.Use(checkLoggedIn) r.Use(a.checkLoggedIn)
// Logout // Logout
r.With(authMiddleware).Get("/login", serveLogin) r.With(a.authMiddleware).Get("/login", serveLogin)
r.With(authMiddleware).Get("/logout", serveLogout) r.With(a.authMiddleware).Get("/logout", a.serveLogout)
// Micropub // Micropub
r.Mount(micropubPath, micropubRouter) r.Mount(micropubPath, a.micropubRouter)
// IndieAuth // IndieAuth
r.Mount("/indieauth", indieAuthRouter) r.Mount("/indieauth", a.indieAuthRouter)
// ActivityPub and stuff // ActivityPub and stuff
if ap := appConfig.ActivityPub; ap != nil && ap.Enabled { if ap := a.cfg.ActivityPub; ap != nil && ap.Enabled {
r.Mount("/activitypub", activitypubRouter) r.Mount("/activitypub", a.activitypubRouter)
r.With(cacheMiddleware).Get("/.well-known/webfinger", apHandleWebfinger) r.With(a.cache.cacheMiddleware).Get("/.well-known/webfinger", a.apHandleWebfinger)
r.With(cacheMiddleware).Get("/.well-known/host-meta", handleWellKnownHostMeta) r.With(a.cache.cacheMiddleware).Get("/.well-known/host-meta", handleWellKnownHostMeta)
r.With(cacheMiddleware).Get("/.well-known/nodeinfo", serveNodeInfoDiscover) r.With(a.cache.cacheMiddleware).Get("/.well-known/nodeinfo", a.serveNodeInfoDiscover)
r.With(cacheMiddleware).Get("/nodeinfo", serveNodeInfo) r.With(a.cache.cacheMiddleware).Get("/nodeinfo", a.serveNodeInfo)
} }
// Webmentions // Webmentions
r.Mount(webmentionPath, webmentionsRouter) r.Mount(webmentionPath, a.webmentionsRouter)
// Notifications // Notifications
r.Mount(notificationsPath, notificationsRouter) r.Mount(notificationsPath, a.notificationsRouter)
// Posts // Posts
pp, err := allPostPaths(statusPublished) pp, err := a.db.allPostPaths(statusPublished)
if err != nil { if err != nil {
return nil, err return nil, err
} }
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(privateModeHandler...) r.Use(a.privateModeHandler...)
r.Use(checkActivityStreamsRequest, cacheMiddleware) r.Use(a.checkActivityStreamsRequest, a.cache.cacheMiddleware)
for _, path := range pp { for _, path := range pp {
r.Get(path, servePost) r.Get(path, a.servePost)
} }
}) })
// Drafts // Drafts
dp, err := allPostPaths(statusDraft) dp, err := a.db.allPostPaths(statusDraft)
if err != nil { if err != nil {
return nil, err return nil, err
} }
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authMiddleware) r.Use(a.authMiddleware)
for _, path := range dp { for _, path := range dp {
r.Get(path, servePost) r.Get(path, a.servePost)
} }
}) })
// Post aliases // Post aliases
allPostAliases, err := allPostAliases() allPostAliases, err := a.db.allPostAliases()
if err != nil { if err != nil {
return nil, err return nil, err
} }
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(privateModeHandler...) r.Use(a.privateModeHandler...)
r.Use(cacheMiddleware) r.Use(a.cache.cacheMiddleware)
for _, path := range allPostAliases { for _, path := range allPostAliases {
r.Get(path, servePostAlias) r.Get(path, a.servePostAlias)
} }
}) })
// Assets // Assets
for _, path := range allAssetPaths() { for _, path := range a.allAssetPaths() {
r.Get(path, serveAsset) r.Get(path, a.serveAsset)
} }
// Static files // Static files
for _, path := range allStaticPaths() { for _, path := range allStaticPaths() {
r.Get(path, serveStaticFile) r.Get(path, a.serveStaticFile)
} }
// Media files // Media files
r.With(privateModeHandler...).Get(`/m/{file:[0-9a-fA-F]+(\.[0-9a-zA-Z]+)?}`, serveMediaFile) r.With(a.privateModeHandler...).Get(`/m/{file:[0-9a-fA-F]+(\.[0-9a-zA-Z]+)?}`, a.serveMediaFile)
// Captcha // Captcha
r.Handle("/captcha/*", captchaHandler) r.Handle("/captcha/*", a.captchaHandler)
// Short paths // Short paths
r.With(privateModeHandler...).With(cacheMiddleware).Get("/s/{id:[0-9a-fA-F]+}", redirectToLongPath) r.With(a.privateModeHandler...).With(a.cache.cacheMiddleware).Get("/s/{id:[0-9a-fA-F]+}", a.redirectToLongPath)
for blog, blogConfig := range appConfig.Blogs { for blog, blogConfig := range a.cfg.Blogs {
blogPath := blogPath(blog) blogPath := a.blogPath(blog)
sbm := setBlogMiddlewares[blog] sbm := a.setBlogMiddlewares[blog]
// Sections // Sections
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(privateModeHandler...) r.Use(a.privateModeHandler...)
r.Use(cacheMiddleware, sbm) r.Use(a.cache.cacheMiddleware, sbm)
for _, section := range blogConfig.Sections { for _, section := range blogConfig.Sections {
if section.Name != "" { if section.Name != "" {
secPath := blogPath + "/" + section.Name secPath := blogPath + "/" + section.Name
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(sectionMiddlewares[secPath]) r.Use(a.sectionMiddlewares[secPath])
r.Get(secPath, serveIndex) r.Get(secPath, a.serveIndex)
r.Get(secPath+feedPath, serveIndex) r.Get(secPath+feedPath, a.serveIndex)
r.Get(secPath+paginationPath, serveIndex) r.Get(secPath+paginationPath, a.serveIndex)
}) })
} }
} }
@ -431,14 +423,14 @@ func buildDynamicRouter() (*chi.Mux, error) {
for _, taxonomy := range blogConfig.Taxonomies { for _, taxonomy := range blogConfig.Taxonomies {
if taxonomy.Name != "" { if taxonomy.Name != "" {
taxPath := blogPath + "/" + taxonomy.Name taxPath := blogPath + "/" + taxonomy.Name
taxValues, err := allTaxonomyValues(blog, taxonomy.Name) taxValues, err := a.db.allTaxonomyValues(blog, taxonomy.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(privateModeHandler...) r.Use(a.privateModeHandler...)
r.Use(cacheMiddleware, sbm) r.Use(a.cache.cacheMiddleware, sbm)
r.With(taxonomyMiddlewares[taxPath]).Get(taxPath, serveTaxonomy) r.With(a.taxonomyMiddlewares[taxPath]).Get(taxPath, a.serveTaxonomy)
for _, tv := range taxValues { for _, tv := range taxValues {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
vPath := taxPath + "/" + urlize(tv) vPath := taxPath + "/" + urlize(tv)
@ -450,9 +442,9 @@ func buildDynamicRouter() (*chi.Mux, error) {
}) })
} }
r.Use(taxValueMiddlewares[vPath]) r.Use(taxValueMiddlewares[vPath])
r.Get(vPath, serveIndex) r.Get(vPath, a.serveIndex)
r.Get(vPath+feedPath, serveIndex) r.Get(vPath+feedPath, a.serveIndex)
r.Get(vPath+paginationPath, serveIndex) r.Get(vPath+paginationPath, a.serveIndex)
}) })
} }
}) })
@ -462,75 +454,75 @@ func buildDynamicRouter() (*chi.Mux, error) {
// Photos // Photos
if blogConfig.Photos != nil && blogConfig.Photos.Enabled { if blogConfig.Photos != nil && blogConfig.Photos.Enabled {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(privateModeHandler...) r.Use(a.privateModeHandler...)
r.Use(cacheMiddleware, sbm, photosMiddlewares[blog]) r.Use(a.cache.cacheMiddleware, sbm, a.photosMiddlewares[blog])
photoPath := blogPath + blogConfig.Photos.Path photoPath := blogPath + blogConfig.Photos.Path
r.Get(photoPath, serveIndex) r.Get(photoPath, a.serveIndex)
r.Get(photoPath+feedPath, serveIndex) r.Get(photoPath+feedPath, a.serveIndex)
r.Get(photoPath+paginationPath, serveIndex) r.Get(photoPath+paginationPath, a.serveIndex)
}) })
} }
// Search // Search
if blogConfig.Search != nil && blogConfig.Search.Enabled { if blogConfig.Search != nil && blogConfig.Search.Enabled {
searchPath := blogPath + blogConfig.Search.Path searchPath := blogPath + blogConfig.Search.Path
r.With(sbm, searchMiddlewares[blog]).Mount(searchPath, searchRouter) r.With(sbm, a.searchMiddlewares[blog]).Mount(searchPath, a.searchRouter)
} }
// Stats // Stats
if blogConfig.BlogStats != nil && blogConfig.BlogStats.Enabled { if blogConfig.BlogStats != nil && blogConfig.BlogStats.Enabled {
statsPath := blogPath + blogConfig.BlogStats.Path statsPath := blogPath + blogConfig.BlogStats.Path
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(privateModeHandler...) r.Use(a.privateModeHandler...)
r.Use(cacheMiddleware, sbm) r.Use(a.cache.cacheMiddleware, sbm)
r.Get(statsPath, serveBlogStats) r.Get(statsPath, a.serveBlogStats)
r.Get(statsPath+".table.html", serveBlogStatsTable) r.Get(statsPath+".table.html", a.serveBlogStatsTable)
}) })
} }
// Date archives // Date archives
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(privateModeHandler...) r.Use(a.privateModeHandler...)
r.Use(cacheMiddleware, sbm) r.Use(a.cache.cacheMiddleware, sbm)
yearRegex := `/{year:x|\d\d\d\d}` yearRegex := `/{year:x|\d\d\d\d}`
monthRegex := `/{month:x|\d\d}` monthRegex := `/{month:x|\d\d}`
dayRegex := `/{day:\d\d}` dayRegex := `/{day:\d\d}`
yearPath := blogPath + yearRegex yearPath := blogPath + yearRegex
r.Get(yearPath, serveDate) r.Get(yearPath, a.serveDate)
r.Get(yearPath+feedPath, serveDate) r.Get(yearPath+feedPath, a.serveDate)
r.Get(yearPath+paginationPath, serveDate) r.Get(yearPath+paginationPath, a.serveDate)
monthPath := yearPath + monthRegex monthPath := yearPath + monthRegex
r.Get(monthPath, serveDate) r.Get(monthPath, a.serveDate)
r.Get(monthPath+feedPath, serveDate) r.Get(monthPath+feedPath, a.serveDate)
r.Get(monthPath+paginationPath, serveDate) r.Get(monthPath+paginationPath, a.serveDate)
dayPath := monthPath + dayRegex dayPath := monthPath + dayRegex
r.Get(dayPath, serveDate) r.Get(dayPath, a.serveDate)
r.Get(dayPath+feedPath, serveDate) r.Get(dayPath+feedPath, a.serveDate)
r.Get(dayPath+paginationPath, serveDate) r.Get(dayPath+paginationPath, a.serveDate)
}) })
// Blog // Blog
if !blogConfig.PostAsHome { if !blogConfig.PostAsHome {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(privateModeHandler...) r.Use(a.privateModeHandler...)
r.Use(sbm) r.Use(sbm)
r.With(checkActivityStreamsRequest, cacheMiddleware).Get(blogConfig.Path, serveHome) r.With(a.checkActivityStreamsRequest, a.cache.cacheMiddleware).Get(blogConfig.Path, a.serveHome)
r.With(cacheMiddleware).Get(blogConfig.Path+feedPath, serveHome) r.With(a.cache.cacheMiddleware).Get(blogConfig.Path+feedPath, a.serveHome)
r.With(cacheMiddleware).Get(blogPath+paginationPath, serveHome) r.With(a.cache.cacheMiddleware).Get(blogPath+paginationPath, a.serveHome)
}) })
} }
// Custom pages // Custom pages
for _, cp := range blogConfig.CustomPages { for _, cp := range blogConfig.CustomPages {
scp := customPagesMiddlewares[cp.Path] scp := a.customPagesMiddlewares[cp.Path]
if cp.Cache { if cp.Cache {
r.With(privateModeHandler...).With(cacheMiddleware, sbm, scp).Get(cp.Path, serveCustomPage) r.With(a.privateModeHandler...).With(a.cache.cacheMiddleware, sbm, scp).Get(cp.Path, a.serveCustomPage)
} else { } else {
r.With(privateModeHandler...).With(sbm, scp).Get(cp.Path, serveCustomPage) r.With(a.privateModeHandler...).With(sbm, scp).Get(cp.Path, a.serveCustomPage)
} }
} }
@ -540,50 +532,50 @@ func buildDynamicRouter() (*chi.Mux, error) {
if randomPath == "" { if randomPath == "" {
randomPath = "/random" randomPath = "/random"
} }
r.With(privateModeHandler...).With(sbm).Get(blogPath+randomPath, redirectToRandomPost) r.With(a.privateModeHandler...).With(sbm).Get(blogPath+randomPath, a.redirectToRandomPost)
} }
// Editor // Editor
r.With(sbm).Mount(blogPath+"/editor", editorRouter) r.With(sbm).Mount(blogPath+"/editor", a.editorRouter)
// Comments // Comments
if commentsConfig := blogConfig.Comments; commentsConfig != nil && commentsConfig.Enabled { if commentsConfig := blogConfig.Comments; commentsConfig != nil && commentsConfig.Enabled {
commentsPath := blogPath + "/comment" commentsPath := blogPath + "/comment"
r.With(sbm, commentsMiddlewares[blog]).Mount(commentsPath, commentsRouter) r.With(sbm, a.commentsMiddlewares[blog]).Mount(commentsPath, a.commentsRouter)
} }
// Blogroll // Blogroll
if brConfig := blogConfig.Blogroll; brConfig != nil && brConfig.Enabled { if brConfig := blogConfig.Blogroll; brConfig != nil && brConfig.Enabled {
brPath := blogPath + brConfig.Path brPath := blogPath + brConfig.Path
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(privateModeHandler...) r.Use(a.privateModeHandler...)
r.Use(cacheMiddleware, sbm) r.Use(a.cache.cacheMiddleware, sbm)
r.Get(brPath, serveBlogroll) r.Get(brPath, a.serveBlogroll)
r.Get(brPath+".opml", serveBlogrollExport) r.Get(brPath+".opml", a.serveBlogrollExport)
}) })
} }
} }
// Sitemap // Sitemap
r.With(privateModeHandler...).With(cacheMiddleware).Get(sitemapPath, serveSitemap) r.With(a.privateModeHandler...).With(a.cache.cacheMiddleware).Get(sitemapPath, a.serveSitemap)
// Robots.txt - doesn't need cache, because it's too simple // Robots.txt - doesn't need cache, because it's too simple
if !privateMode { if !a.privateMode {
r.Get("/robots.txt", serveRobotsTXT) r.Get("/robots.txt", a.serveRobotsTXT)
} else { } else {
r.Get("/robots.txt", servePrivateRobotsTXT) r.Get("/robots.txt", servePrivateRobotsTXT)
} }
// Check redirects, then serve 404 // Check redirects, then serve 404
r.With(cacheMiddleware, checkRegexRedirects).NotFound(serve404) r.With(a.cache.cacheMiddleware, a.checkRegexRedirects).NotFound(a.serve404)
r.MethodNotAllowed(serveNotAllowed) r.MethodNotAllowed(a.serveNotAllowed)
return r, nil return r, nil
} }
func blogPath(blog string) string { func (a *goBlog) blogPath(blog string) string {
blogPath := appConfig.Blogs[blog].Path blogPath := a.cfg.Blogs[blog].Path
if blogPath == "/" { if blogPath == "/" {
return "" return ""
} }
@ -595,20 +587,20 @@ const pathContextKey requestContextKey = "httpPath"
var cspDomains = "" var cspDomains = ""
func refreshCSPDomains() { func (a *goBlog) refreshCSPDomains() {
cspDomains = "" cspDomains = ""
if mp := appConfig.Micropub.MediaStorage; mp != nil && mp.MediaURL != "" { if mp := a.cfg.Micropub.MediaStorage; mp != nil && mp.MediaURL != "" {
if u, err := url.Parse(mp.MediaURL); err == nil { if u, err := url.Parse(mp.MediaURL); err == nil {
cspDomains += " " + u.Hostname() cspDomains += " " + u.Hostname()
} }
} }
if len(appConfig.Server.CSPDomains) > 0 { if len(a.cfg.Server.CSPDomains) > 0 {
cspDomains += " " + strings.Join(appConfig.Server.CSPDomains, " ") cspDomains += " " + strings.Join(a.cfg.Server.CSPDomains, " ")
} }
} }
func securityHeaders(next http.Handler) http.Handler { func (a *goBlog) securityHeaders(next http.Handler) http.Handler {
refreshCSPDomains() a.refreshCSPDomains()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000;") w.Header().Set("Strict-Transport-Security", "max-age=31536000;")
w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Referrer-Policy", "no-referrer")
@ -616,8 +608,8 @@ func securityHeaders(next http.Handler) http.Handler {
w.Header().Set("X-Frame-Options", "SAMEORIGIN") w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.Header().Set("X-Xss-Protection", "1; mode=block") w.Header().Set("X-Xss-Protection", "1; mode=block")
w.Header().Set("Content-Security-Policy", "default-src 'self'"+cspDomains) w.Header().Set("Content-Security-Policy", "default-src 'self'"+cspDomains)
if appConfig.Server.Tor && torAddress != "" { if a.cfg.Server.Tor && a.torAddress != "" {
w.Header().Set("Onion-Location", fmt.Sprintf("http://%v%v", torAddress, r.RequestURI)) w.Header().Set("Onion-Location", fmt.Sprintf("http://%v%v", a.torAddress, r.RequestURI))
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })

View File

@ -8,15 +8,13 @@ import (
rotatelogs "github.com/lestrrat-go/file-rotatelogs" rotatelogs "github.com/lestrrat-go/file-rotatelogs"
) )
var logf *rotatelogs.RotateLogs func (a *goBlog) initHTTPLog() (err error) {
if !a.cfg.Server.Logging {
func initHTTPLog() (err error) {
if !appConfig.Server.Logging {
return nil return nil
} }
logf, err = rotatelogs.New( a.logf, err = rotatelogs.New(
appConfig.Server.LogFile+".%Y%m%d", a.cfg.Server.LogFile+".%Y%m%d",
rotatelogs.WithLinkName(appConfig.Server.LogFile), rotatelogs.WithLinkName(a.cfg.Server.LogFile),
rotatelogs.WithClock(rotatelogs.UTC), rotatelogs.WithClock(rotatelogs.UTC),
rotatelogs.WithMaxAge(30*24*time.Hour), rotatelogs.WithMaxAge(30*24*time.Hour),
rotatelogs.WithRotationTime(24*time.Hour), rotatelogs.WithRotationTime(24*time.Hour),
@ -24,8 +22,8 @@ func initHTTPLog() (err error) {
return return
} }
func logMiddleware(next http.Handler) http.Handler { func (a *goBlog) logMiddleware(next http.Handler) http.Handler {
h := handlers.CombinedLoggingHandler(logf, next) h := handlers.CombinedLoggingHandler(a.logf, next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Remove remote address for privacy // Remove remote address for privacy
r.RemoteAddr = "" r.RemoteAddr = ""

View File

@ -8,15 +8,15 @@ import (
const indieAuthScope requestContextKey = "scope" const indieAuthScope requestContextKey = "scope"
func checkIndieAuth(next http.Handler) http.Handler { func (a *goBlog) checkIndieAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bearerToken := r.Header.Get("Authorization") bearerToken := r.Header.Get("Authorization")
if len(bearerToken) == 0 { if len(bearerToken) == 0 {
bearerToken = r.URL.Query().Get("access_token") bearerToken = r.URL.Query().Get("access_token")
} }
tokenData, err := verifyIndieAuthToken(bearerToken) tokenData, err := a.db.verifyIndieAuthToken(bearerToken)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusUnauthorized) a.serveError(w, r, err.Error(), http.StatusUnauthorized)
return return
} }
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), indieAuthScope, strings.Join(tokenData.Scopes, " ")))) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), indieAuthScope, strings.Join(tokenData.Scopes, " "))))

View File

@ -27,10 +27,10 @@ type indieAuthData struct {
time time.Time time time.Time
} }
func indieAuthRequest(w http.ResponseWriter, r *http.Request) { func (a *goBlog) indieAuthRequest(w http.ResponseWriter, r *http.Request) {
// Authorization request // Authorization request
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
data := &indieAuthData{ data := &indieAuthData{
@ -39,21 +39,21 @@ func indieAuthRequest(w http.ResponseWriter, r *http.Request) {
State: r.Form.Get("state"), State: r.Form.Get("state"),
} }
if rt := r.Form.Get("response_type"); rt != "code" && rt != "id" && rt != "" { if rt := r.Form.Get("response_type"); rt != "code" && rt != "id" && rt != "" {
serveError(w, r, "response_type must be code", http.StatusBadRequest) a.serveError(w, r, "response_type must be code", http.StatusBadRequest)
return return
} }
if scope := r.Form.Get("scope"); scope != "" { if scope := r.Form.Get("scope"); scope != "" {
data.Scopes = strings.Split(scope, " ") data.Scopes = strings.Split(scope, " ")
} }
if !isValidProfileURL(data.ClientID) || !isValidProfileURL(data.RedirectURI) { if !isValidProfileURL(data.ClientID) || !isValidProfileURL(data.RedirectURI) {
serveError(w, r, "client_id and redirect_uri need to by valid URLs", http.StatusBadRequest) a.serveError(w, r, "client_id and redirect_uri need to by valid URLs", http.StatusBadRequest)
return return
} }
if data.State == "" { if data.State == "" {
serveError(w, r, "state must not be empty", http.StatusBadRequest) a.serveError(w, r, "state must not be empty", http.StatusBadRequest)
return return
} }
render(w, r, "indieauth", &renderData{ a.render(w, r, "indieauth", &renderData{
Data: data, Data: data,
}) })
} }
@ -79,10 +79,10 @@ func isValidProfileURL(profileURL string) bool {
return true return true
} }
func indieAuthAccept(w http.ResponseWriter, r *http.Request) { func (a *goBlog) indieAuthAccept(w http.ResponseWriter, r *http.Request) {
// Authentication flow // Authentication flow
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
data := &indieAuthData{ data := &indieAuthData{
@ -94,13 +94,13 @@ func indieAuthAccept(w http.ResponseWriter, r *http.Request) {
} }
sha := sha1.New() sha := sha1.New()
if _, err := sha.Write([]byte(data.time.String() + data.ClientID)); err != nil { if _, err := sha.Write([]byte(data.time.String() + data.ClientID)); err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
data.code = fmt.Sprintf("%x", sha.Sum(nil)) data.code = fmt.Sprintf("%x", sha.Sum(nil))
err := data.saveAuthorization() err := a.db.saveAuthorization(data)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, data.RedirectURI+"?code="+data.code+"&state="+data.State, http.StatusFound) http.Redirect(w, r, data.RedirectURI+"?code="+data.code+"&state="+data.State, http.StatusFound)
@ -114,10 +114,10 @@ type tokenResponse struct {
ClientID string `json:"client_id,omitempty"` ClientID string `json:"client_id,omitempty"`
} }
func indieAuthVerification(w http.ResponseWriter, r *http.Request) { func (a *goBlog) indieAuthVerification(w http.ResponseWriter, r *http.Request) {
// Authorization verification // Authorization verification
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
data := &indieAuthData{ data := &indieAuthData{
@ -125,33 +125,33 @@ func indieAuthVerification(w http.ResponseWriter, r *http.Request) {
ClientID: r.Form.Get("client_id"), ClientID: r.Form.Get("client_id"),
RedirectURI: r.Form.Get("redirect_uri"), RedirectURI: r.Form.Get("redirect_uri"),
} }
valid, err := data.verifyAuthorization() valid, err := a.db.verifyAuthorization(data)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
if !valid { if !valid {
serveError(w, r, "Authentication not valid", http.StatusForbidden) a.serveError(w, r, "Authentication not valid", http.StatusForbidden)
return return
} }
b, _ := json.Marshal(tokenResponse{ b, _ := json.Marshal(tokenResponse{
Me: appConfig.Server.PublicAddress, Me: a.cfg.Server.PublicAddress,
}) })
w.Header().Set(contentType, contentTypeJSONUTF8) w.Header().Set(contentType, contentTypeJSONUTF8)
_, _ = writeMinified(w, contentTypeJSON, b) _, _ = writeMinified(w, contentTypeJSON, b)
} }
func indieAuthToken(w http.ResponseWriter, r *http.Request) { func (a *goBlog) indieAuthToken(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
// Token verification // Token verification
data, err := verifyIndieAuthToken(r.Header.Get("Authorization")) data, err := a.db.verifyIndieAuthToken(r.Header.Get("Authorization"))
if err != nil { if err != nil {
serveError(w, r, "Invalid token or token not found", http.StatusUnauthorized) a.serveError(w, r, "Invalid token or token not found", http.StatusUnauthorized)
return return
} }
res := &tokenResponse{ res := &tokenResponse{
Scope: strings.Join(data.Scopes, " "), Scope: strings.Join(data.Scopes, " "),
Me: appConfig.Server.PublicAddress, Me: a.cfg.Server.PublicAddress,
ClientID: data.ClientID, ClientID: data.ClientID,
} }
b, _ := json.Marshal(res) b, _ := json.Marshal(res)
@ -160,12 +160,12 @@ func indieAuthToken(w http.ResponseWriter, r *http.Request) {
return return
} else if r.Method == http.MethodPost { } else if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
// Token Revocation // Token Revocation
if r.Form.Get("action") == "revoke" { if r.Form.Get("action") == "revoke" {
revokeIndieAuthToken(r.Form.Get("token")) a.db.revokeIndieAuthToken(r.Form.Get("token"))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
@ -176,55 +176,55 @@ func indieAuthToken(w http.ResponseWriter, r *http.Request) {
ClientID: r.Form.Get("client_id"), ClientID: r.Form.Get("client_id"),
RedirectURI: r.Form.Get("redirect_uri"), RedirectURI: r.Form.Get("redirect_uri"),
} }
valid, err := data.verifyAuthorization() valid, err := a.db.verifyAuthorization(data)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
if !valid { if !valid {
serveError(w, r, "Authentication not valid", http.StatusForbidden) a.serveError(w, r, "Authentication not valid", http.StatusForbidden)
return return
} }
if len(data.Scopes) < 1 { if len(data.Scopes) < 1 {
serveError(w, r, "No scope", http.StatusBadRequest) a.serveError(w, r, "No scope", http.StatusBadRequest)
return return
} }
data.time = time.Now() data.time = time.Now()
sha := sha1.New() sha := sha1.New()
if _, err := sha.Write([]byte(data.time.String() + data.ClientID)); err != nil { if _, err := sha.Write([]byte(data.time.String() + data.ClientID)); err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
data.token = fmt.Sprintf("%x", sha.Sum(nil)) data.token = fmt.Sprintf("%x", sha.Sum(nil))
err = data.saveToken() err = a.db.saveToken(data)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
res := &tokenResponse{ res := &tokenResponse{
TokenType: "Bearer", TokenType: "Bearer",
AccessToken: data.token, AccessToken: data.token,
Scope: strings.Join(data.Scopes, " "), Scope: strings.Join(data.Scopes, " "),
Me: appConfig.Server.PublicAddress, Me: a.cfg.Server.PublicAddress,
} }
b, _ := json.Marshal(res) b, _ := json.Marshal(res)
w.Header().Set(contentType, contentTypeJSONUTF8) w.Header().Set(contentType, contentTypeJSONUTF8)
_, _ = writeMinified(w, contentTypeJSON, b) _, _ = writeMinified(w, contentTypeJSON, b)
return return
} }
serveError(w, r, "", http.StatusBadRequest) a.serveError(w, r, "", http.StatusBadRequest)
return return
} }
} }
func (data *indieAuthData) saveAuthorization() (err error) { func (db *database) saveAuthorization(data *indieAuthData) (err error) {
_, err = appDb.exec("insert into indieauthauth (time, code, client, redirect, scope) values (?, ?, ?, ?, ?)", data.time.Unix(), data.code, data.ClientID, data.RedirectURI, strings.Join(data.Scopes, " ")) _, err = db.exec("insert into indieauthauth (time, code, client, redirect, scope) values (?, ?, ?, ?, ?)", data.time.Unix(), data.code, data.ClientID, data.RedirectURI, strings.Join(data.Scopes, " "))
return return
} }
func (data *indieAuthData) verifyAuthorization() (valid bool, err error) { func (db *database) verifyAuthorization(data *indieAuthData) (valid bool, err error) {
// code valid for 600 seconds // code valid for 600 seconds
row, err := appDb.queryRow("select code, client, redirect, scope from indieauthauth where time >= ? and code = ? and client = ? and redirect = ?", time.Now().Unix()-600, data.code, data.ClientID, data.RedirectURI) row, err := db.queryRow("select code, client, redirect, scope from indieauthauth where time >= ? and code = ? and client = ? and redirect = ?", time.Now().Unix()-600, data.code, data.ClientID, data.RedirectURI)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -239,22 +239,22 @@ func (data *indieAuthData) verifyAuthorization() (valid bool, err error) {
data.Scopes = strings.Split(scope, " ") data.Scopes = strings.Split(scope, " ")
} }
valid = true valid = true
_, err = appDb.exec("delete from indieauthauth where code = ? or time < ?", data.code, time.Now().Unix()-600) _, err = db.exec("delete from indieauthauth where code = ? or time < ?", data.code, time.Now().Unix()-600)
data.code = "" data.code = ""
return return
} }
func (data *indieAuthData) saveToken() (err error) { func (db *database) saveToken(data *indieAuthData) (err error) {
_, err = appDb.exec("insert into indieauthtoken (time, token, client, scope) values (?, ?, ?, ?)", data.time.Unix(), data.token, data.ClientID, strings.Join(data.Scopes, " ")) _, err = db.exec("insert into indieauthtoken (time, token, client, scope) values (?, ?, ?, ?)", data.time.Unix(), data.token, data.ClientID, strings.Join(data.Scopes, " "))
return return
} }
func verifyIndieAuthToken(token string) (data *indieAuthData, err error) { func (db *database) verifyIndieAuthToken(token string) (data *indieAuthData, err error) {
token = strings.ReplaceAll(token, "Bearer ", "") token = strings.ReplaceAll(token, "Bearer ", "")
data = &indieAuthData{ data = &indieAuthData{
Scopes: []string{}, Scopes: []string{},
} }
row, err := appDb.queryRow("select time, token, client, scope from indieauthtoken where token = @token", sql.Named("token", token)) row, err := db.queryRow("select time, token, client, scope from indieauthtoken where token = @token", sql.Named("token", token))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -273,8 +273,8 @@ func verifyIndieAuthToken(token string) (data *indieAuthData, err error) {
return return
} }
func revokeIndieAuthToken(token string) { func (db *database) revokeIndieAuthToken(token string) {
if token != "" { if token != "" {
_, _ = appDb.exec("delete from indieauthtoken where token=?", token) _, _ = db.exec("delete from indieauthtoken where token=?", token)
} }
} }

45
main.go
View File

@ -47,9 +47,11 @@ func main() {
}() }()
} }
app := &goBlog{}
// Initialize config // Initialize config
log.Println("Initialize configuration...") log.Println("Initialize configuration...")
if err = initConfig(); err != nil { if err = app.initConfig(); err != nil {
logErrAndQuit("Failed to init config:", err.Error()) logErrAndQuit("Failed to init config:", err.Error())
return return
} }
@ -57,7 +59,7 @@ func main() {
// Healthcheck tool // Healthcheck tool
if len(os.Args) >= 2 && os.Args[1] == "healthcheck" { if len(os.Args) >= 2 && os.Args[1] == "healthcheck" {
// Connect to public address + "/ping" and exit with 0 when successful // Connect to public address + "/ping" and exit with 0 when successful
health := healthcheckExitCode() health := app.healthcheckExitCode()
shutdown() shutdown()
os.Exit(health) os.Exit(health)
return return
@ -66,8 +68,8 @@ func main() {
// Tool to generate TOTP secret // Tool to generate TOTP secret
if len(os.Args) >= 2 && os.Args[1] == "totp-secret" { if len(os.Args) >= 2 && os.Args[1] == "totp-secret" {
key, err := totp.Generate(totp.GenerateOpts{ key, err := totp.Generate(totp.GenerateOpts{
Issuer: appConfig.Server.PublicAddress, Issuer: app.cfg.Server.PublicAddress,
AccountName: appConfig.User.Nick, AccountName: app.cfg.User.Nick,
}) })
if err != nil { if err != nil {
logErrAndQuit(err.Error()) logErrAndQuit(err.Error())
@ -82,65 +84,64 @@ func main() {
initGC() initGC()
// Execute pre-start hooks // Execute pre-start hooks
preStartHooks() app.preStartHooks()
// Initialize database and markdown // Initialize database and markdown
log.Println("Initialize database...") log.Println("Initialize database...")
if err = initDatabase(); err != nil { if err = app.initDatabase(); err != nil {
logErrAndQuit("Failed to init database:", err.Error()) logErrAndQuit("Failed to init database:", err.Error())
return return
} }
log.Println("Initialize server components...") log.Println("Initialize server components...")
initMarkdown() app.initMarkdown()
// Link check tool after init of markdown // Link check tool after init of markdown
if len(os.Args) >= 2 && os.Args[1] == "check" { if len(os.Args) >= 2 && os.Args[1] == "check" {
checkAllExternalLinks() app.checkAllExternalLinks()
shutdown() shutdown()
return return
} }
// More initializations // More initializations
initMinify() if err = app.initTemplateAssets(); err != nil { // Needs minify
if err = initTemplateAssets(); err != nil { // Needs minify
logErrAndQuit("Failed to init template assets:", err.Error()) logErrAndQuit("Failed to init template assets:", err.Error())
return return
} }
if err = initTemplateStrings(); err != nil { if err = app.initTemplateStrings(); err != nil {
logErrAndQuit("Failed to init template translations:", err.Error()) logErrAndQuit("Failed to init template translations:", err.Error())
return return
} }
if err = initRendering(); err != nil { // Needs assets and minify if err = app.initRendering(); err != nil { // Needs assets and minify
logErrAndQuit("Failed to init HTML rendering:", err.Error()) logErrAndQuit("Failed to init HTML rendering:", err.Error())
return return
} }
if err = initCache(); err != nil { if err = app.initCache(); err != nil {
logErrAndQuit("Failed to init HTTP cache:", err.Error()) logErrAndQuit("Failed to init HTTP cache:", err.Error())
return return
} }
if err = initRegexRedirects(); err != nil { if err = app.initRegexRedirects(); err != nil {
logErrAndQuit("Failed to init redirects:", err.Error()) logErrAndQuit("Failed to init redirects:", err.Error())
return return
} }
if err = initHTTPLog(); err != nil { if err = app.initHTTPLog(); err != nil {
logErrAndQuit("Failed to init HTTP logging:", err.Error()) logErrAndQuit("Failed to init HTTP logging:", err.Error())
return return
} }
if err = initActivityPub(); err != nil { if err = app.initActivityPub(); err != nil {
logErrAndQuit("Failed to init ActivityPub:", err.Error()) logErrAndQuit("Failed to init ActivityPub:", err.Error())
return return
} }
initWebmention() app.initWebmention()
initTelegram() app.initTelegram()
initBlogStats() app.initBlogStats()
initSessions() app.initSessions()
// Start cron hooks // Start cron hooks
startHourlyHooks() app.startHourlyHooks()
// Start the server // Start the server
log.Println("Starting server(s)...") log.Println("Starting server(s)...")
err = startServer() err = app.startServer()
if err != nil { if err != nil {
logErrAndQuit("Failed to start server(s):", err.Error()) logErrAndQuit("Failed to start server(s):", err.Error())
return return

View File

@ -15,9 +15,7 @@ import (
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
) )
var defaultMarkdown, absoluteMarkdown goldmark.Markdown func (a *goBlog) initMarkdown() {
func initMarkdown() {
defaultGoldmarkOptions := []goldmark.Option{ defaultGoldmarkOptions := []goldmark.Option{
goldmark.WithRendererOptions( goldmark.WithRendererOptions(
html.WithUnsafe(), html.WithUnsafe(),
@ -35,22 +33,28 @@ func initMarkdown() {
emoji.Emoji, emoji.Emoji,
), ),
} }
defaultMarkdown = goldmark.New(append(defaultGoldmarkOptions, goldmark.WithExtensions(&customExtension{absoluteLinks: false}))...) a.md = goldmark.New(append(defaultGoldmarkOptions, goldmark.WithExtensions(&customExtension{
absoluteMarkdown = goldmark.New(append(defaultGoldmarkOptions, goldmark.WithExtensions(&customExtension{absoluteLinks: true}))...) absoluteLinks: false,
publicAddress: a.cfg.Server.PublicAddress,
}))...)
a.absoluteMd = goldmark.New(append(defaultGoldmarkOptions, goldmark.WithExtensions(&customExtension{
absoluteLinks: true,
publicAddress: a.cfg.Server.PublicAddress,
}))...)
} }
func renderMarkdown(source string, absoluteLinks bool) (rendered []byte, err error) { func (a *goBlog) renderMarkdown(source string, absoluteLinks bool) (rendered []byte, err error) {
var buffer bytes.Buffer var buffer bytes.Buffer
if absoluteLinks { if absoluteLinks {
err = absoluteMarkdown.Convert([]byte(source), &buffer) err = a.absoluteMd.Convert([]byte(source), &buffer)
} else { } else {
err = defaultMarkdown.Convert([]byte(source), &buffer) err = a.md.Convert([]byte(source), &buffer)
} }
return buffer.Bytes(), err return buffer.Bytes(), err
} }
func renderText(s string) string { func (a *goBlog) renderText(s string) string {
h, err := renderMarkdown(s, false) h, err := a.renderMarkdown(s, false)
if err != nil { if err != nil {
return "" return ""
} }
@ -66,18 +70,21 @@ func renderText(s string) string {
// Links // Links
type customExtension struct { type customExtension struct {
absoluteLinks bool absoluteLinks bool
publicAddress string
} }
func (l *customExtension) Extend(m goldmark.Markdown) { func (l *customExtension) Extend(m goldmark.Markdown) {
m.Renderer().AddOptions(renderer.WithNodeRenderers( m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&customRenderer{ util.Prioritized(&customRenderer{
absoluteLinks: l.absoluteLinks, absoluteLinks: l.absoluteLinks,
publicAddress: l.publicAddress,
}, 500), }, 500),
)) ))
} }
type customRenderer struct { type customRenderer struct {
absoluteLinks bool absoluteLinks bool
publicAddress string
} }
func (c *customRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) { func (c *customRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
@ -91,8 +98,8 @@ func (c *customRenderer) renderLink(w util.BufWriter, _ []byte, node ast.Node, e
_, _ = w.WriteString("<a href=\"") _, _ = w.WriteString("<a href=\"")
// Make URL absolute if it's relative // Make URL absolute if it's relative
newDestination := util.URLEscape(n.Destination, true) newDestination := util.URLEscape(n.Destination, true)
if c.absoluteLinks && bytes.HasPrefix(newDestination, []byte("/")) { if c.absoluteLinks && c.publicAddress != "" && bytes.HasPrefix(newDestination, []byte("/")) {
_, _ = w.Write(util.EscapeHTML([]byte(appConfig.Server.PublicAddress))) _, _ = w.Write(util.EscapeHTML([]byte(c.publicAddress)))
} }
_, _ = w.Write(util.EscapeHTML(newDestination)) _, _ = w.Write(util.EscapeHTML(newDestination))
_, _ = w.WriteRune('"') _, _ = w.WriteRune('"')
@ -120,8 +127,8 @@ func (c *customRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
n := node.(*ast.Image) n := node.(*ast.Image)
// Make URL absolute if it's relative // Make URL absolute if it's relative
destination := util.URLEscape(n.Destination, true) destination := util.URLEscape(n.Destination, true)
if bytes.HasPrefix(destination, []byte("/")) { if c.publicAddress != "" && bytes.HasPrefix(destination, []byte("/")) {
destination = util.EscapeHTML(append([]byte(appConfig.Server.PublicAddress), destination...)) destination = util.EscapeHTML(append([]byte(c.publicAddress), destination...))
} else { } else {
destination = util.EscapeHTML(destination) destination = util.EscapeHTML(destination)
} }

View File

@ -27,11 +27,11 @@ func saveMediaFile(filename string, mediaFile io.Reader) (string, error) {
return "/m/" + filename, nil return "/m/" + filename, nil
} }
func serveMediaFile(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveMediaFile(w http.ResponseWriter, r *http.Request) {
f := filepath.Join(mediaFilePath, chi.URLParam(r, "file")) f := filepath.Join(mediaFilePath, chi.URLParam(r, "file"))
_, err := os.Stat(f) _, err := os.Stat(f)
if err != nil { if err != nil {
serve404(w, r) a.serve404(w, r)
return return
} }
w.Header().Add("Cache-Control", "public,max-age=31536000,immutable") w.Header().Add("Cache-Control", "public,max-age=31536000,immutable")

View File

@ -13,7 +13,7 @@ import (
const defaultCompressionWidth = 2000 const defaultCompressionWidth = 2000
const defaultCompressionHeight = 3000 const defaultCompressionHeight = 3000
func tinify(url string, config *configMicropubMedia) (location string, err error) { func (a *goBlog) tinify(url string, config *configMicropubMedia) (location string, err error) {
// Check config // Check config
if config == nil || config.TinifyKey == "" { if config == nil || config.TinifyKey == "" {
return "", errors.New("service Tinify not configured") return "", errors.New("service Tinify not configured")
@ -89,11 +89,11 @@ func tinify(url string, config *configMicropubMedia) (location string, err error
return "", err return "", err
} }
// Upload compressed file // Upload compressed file
location, err = uploadFile(fileName+"."+fileExtension, tmpFile) location, err = a.uploadFile(fileName+"."+fileExtension, tmpFile)
return return
} }
func shortPixel(url string, config *configMicropubMedia) (location string, err error) { func (a *goBlog) shortPixel(url string, config *configMicropubMedia) (location string, err error) {
// Check config // Check config
if config == nil || config.ShortPixelKey == "" { if config == nil || config.ShortPixelKey == "" {
return "", errors.New("service ShortPixel not configured") return "", errors.New("service ShortPixel not configured")
@ -146,11 +146,11 @@ func shortPixel(url string, config *configMicropubMedia) (location string, err e
return "", err return "", err
} }
// Upload compressed file // Upload compressed file
location, err = uploadFile(fileName+"."+fileExtension, tmpFile) location, err = a.uploadFile(fileName+"."+fileExtension, tmpFile)
return return
} }
func cloudflare(url string) (location string, err error) { func (a *goBlog) cloudflare(url string) (location string, err error) {
// Check url // Check url
_, allowed := compressionIsSupported(url, "jpg", "jpeg", "png") _, allowed := compressionIsSupported(url, "jpg", "jpeg", "png")
if !allowed { if !allowed {
@ -190,6 +190,6 @@ func cloudflare(url string) (location string, err error) {
return "", err return "", err
} }
// Upload compressed file // Upload compressed file
location, err = uploadFile(fileName+"."+fileExtension, tmpFile) location, err = a.uploadFile(fileName+"."+fileExtension, tmpFile)
return return
} }

View File

@ -22,13 +22,13 @@ type micropubConfig struct {
MediaEndpoint string `json:"media-endpoint,omitempty"` MediaEndpoint string `json:"media-endpoint,omitempty"`
} }
func serveMicropubQuery(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveMicropubQuery(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("q") { switch r.URL.Query().Get("q") {
case "config": case "config":
w.Header().Set(contentType, contentTypeJSONUTF8) w.Header().Set(contentType, contentTypeJSONUTF8)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
b, _ := json.Marshal(&micropubConfig{ b, _ := json.Marshal(&micropubConfig{
MediaEndpoint: appConfig.Server.PublicAddress + micropubPath + micropubMediaSubPath, MediaEndpoint: a.cfg.Server.PublicAddress + micropubPath + micropubMediaSubPath,
}) })
_, _ = writeMinified(w, contentTypeJSON, b) _, _ = writeMinified(w, contentTypeJSON, b)
case "source": case "source":
@ -36,29 +36,29 @@ func serveMicropubQuery(w http.ResponseWriter, r *http.Request) {
if urlString := r.URL.Query().Get("url"); urlString != "" { if urlString := r.URL.Query().Get("url"); urlString != "" {
u, err := url.Parse(r.URL.Query().Get("url")) u, err := url.Parse(r.URL.Query().Get("url"))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
p, err := getPost(u.Path) p, err := a.db.getPost(u.Path)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
mf = p.toMfItem() mf = a.toMfItem(p)
} else { } else {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
posts, err := getPosts(&postsRequestConfig{ posts, err := a.db.getPosts(&postsRequestConfig{
limit: limit, limit: limit,
offset: offset, offset: offset,
}) })
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
list := map[string][]*microformatItem{} list := map[string][]*microformatItem{}
for _, p := range posts { for _, p := range posts {
list["items"] = append(list["items"], p.toMfItem()) list["items"] = append(list["items"], a.toMfItem(p))
} }
mf = list mf = list
} }
@ -68,10 +68,10 @@ func serveMicropubQuery(w http.ResponseWriter, r *http.Request) {
_, _ = writeMinified(w, contentTypeJSON, b) _, _ = writeMinified(w, contentTypeJSON, b)
case "category": case "category":
allCategories := []string{} allCategories := []string{}
for blog := range appConfig.Blogs { for blog := range a.cfg.Blogs {
values, err := allTaxonomyValues(blog, appConfig.Micropub.CategoryParam) values, err := a.db.allTaxonomyValues(blog, a.cfg.Micropub.CategoryParam)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
allCategories = append(allCategories, values...) allCategories = append(allCategories, values...)
@ -83,11 +83,11 @@ func serveMicropubQuery(w http.ResponseWriter, r *http.Request) {
}) })
_, _ = writeMinified(w, contentTypeJSON, b) _, _ = writeMinified(w, contentTypeJSON, b)
default: default:
serve404(w, r) a.serve404(w, r)
} }
} }
func (p *post) toMfItem() *microformatItem { func (a *goBlog) toMfItem(p *post) *microformatItem {
params := p.Parameters params := p.Parameters
params["path"] = []string{p.Path} params["path"] = []string{p.Path}
params["section"] = []string{p.Section} params["section"] = []string{p.Section}
@ -104,20 +104,20 @@ func (p *post) toMfItem() *microformatItem {
Published: []string{p.Published}, Published: []string{p.Published},
Updated: []string{p.Updated}, Updated: []string{p.Updated},
PostStatus: []string{string(p.Status)}, PostStatus: []string{string(p.Status)},
Category: p.Parameters[appConfig.Micropub.CategoryParam], Category: p.Parameters[a.cfg.Micropub.CategoryParam],
Content: []string{content}, Content: []string{content},
URL: []string{p.fullURL()}, URL: []string{a.fullPostURL(p)},
InReplyTo: p.Parameters[appConfig.Micropub.ReplyParam], InReplyTo: p.Parameters[a.cfg.Micropub.ReplyParam],
LikeOf: p.Parameters[appConfig.Micropub.LikeParam], LikeOf: p.Parameters[a.cfg.Micropub.LikeParam],
BookmarkOf: p.Parameters[appConfig.Micropub.BookmarkParam], BookmarkOf: p.Parameters[a.cfg.Micropub.BookmarkParam],
MpSlug: []string{p.Slug}, MpSlug: []string{p.Slug},
Audio: p.Parameters[appConfig.Micropub.AudioParam], Audio: p.Parameters[a.cfg.Micropub.AudioParam],
// TODO: Photos // TODO: Photos
}, },
} }
} }
func serveMicropubPost(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveMicropubPost(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() defer r.Body.Close()
var p *post var p *post
if ct := r.Header.Get(contentType); strings.Contains(ct, contentTypeWWWForm) || strings.Contains(ct, contentTypeMultipartForm) { if ct := r.Header.Get(contentType); strings.Contains(ct, contentTypeWWWForm) || strings.Contains(ct, contentTypeMultipartForm) {
@ -128,73 +128,73 @@ func serveMicropubPost(w http.ResponseWriter, r *http.Request) {
err = r.ParseForm() err = r.ParseForm()
} }
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
if action := micropubAction(r.Form.Get("action")); action != "" { if action := micropubAction(r.Form.Get("action")); action != "" {
u, err := url.Parse(r.Form.Get("url")) u, err := url.Parse(r.Form.Get("url"))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
if action == actionDelete { if action == actionDelete {
micropubDelete(w, r, u) a.micropubDelete(w, r, u)
return return
} }
serveError(w, r, "Action not supported", http.StatusNotImplemented) a.serveError(w, r, "Action not supported", http.StatusNotImplemented)
return return
} }
p, err = convertMPValueMapToPost(r.Form) p, err = a.convertMPValueMapToPost(r.Form)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
} else if strings.Contains(ct, contentTypeJSON) { } else if strings.Contains(ct, contentTypeJSON) {
parsedMfItem := &microformatItem{} parsedMfItem := &microformatItem{}
err := json.NewDecoder(r.Body).Decode(parsedMfItem) err := json.NewDecoder(r.Body).Decode(parsedMfItem)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
if parsedMfItem.Action != "" { if parsedMfItem.Action != "" {
u, err := url.Parse(parsedMfItem.URL) u, err := url.Parse(parsedMfItem.URL)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
if parsedMfItem.Action == actionDelete { if parsedMfItem.Action == actionDelete {
micropubDelete(w, r, u) a.micropubDelete(w, r, u)
return return
} }
if parsedMfItem.Action == actionUpdate { if parsedMfItem.Action == actionUpdate {
micropubUpdate(w, r, u, parsedMfItem) a.micropubUpdate(w, r, u, parsedMfItem)
return return
} }
serveError(w, r, "Action not supported", http.StatusNotImplemented) a.serveError(w, r, "Action not supported", http.StatusNotImplemented)
return return
} }
p, err = convertMPMfToPost(parsedMfItem) p, err = a.convertMPMfToPost(parsedMfItem)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
} else { } else {
serveError(w, r, "wrong content type", http.StatusBadRequest) a.serveError(w, r, "wrong content type", http.StatusBadRequest)
return return
} }
if !strings.Contains(r.Context().Value(indieAuthScope).(string), "create") { if !strings.Contains(r.Context().Value(indieAuthScope).(string), "create") {
serveError(w, r, "create scope missing", http.StatusForbidden) a.serveError(w, r, "create scope missing", http.StatusForbidden)
return return
} }
err := p.create() err := a.createPost(p)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, p.fullURL(), http.StatusAccepted) http.Redirect(w, r, a.fullPostURL(p), http.StatusAccepted)
} }
func convertMPValueMapToPost(values map[string][]string) (*post, error) { func (a *goBlog) convertMPValueMapToPost(values map[string][]string) (*post, error) {
if h, ok := values["h"]; ok && (len(h) != 1 || h[0] != "entry") { if h, ok := values["h"]; ok && (len(h) != 1 || h[0] != "entry") {
return nil, errors.New("only entry type is supported so far") return nil, errors.New("only entry type is supported so far")
} }
@ -228,53 +228,53 @@ func convertMPValueMapToPost(values map[string][]string) (*post, error) {
delete(values, "name") delete(values, "name")
} }
if category, ok := values["category"]; ok { if category, ok := values["category"]; ok {
entry.Parameters[appConfig.Micropub.CategoryParam] = category entry.Parameters[a.cfg.Micropub.CategoryParam] = category
delete(values, "category") delete(values, "category")
} else if categories, ok := values["category[]"]; ok { } else if categories, ok := values["category[]"]; ok {
entry.Parameters[appConfig.Micropub.CategoryParam] = categories entry.Parameters[a.cfg.Micropub.CategoryParam] = categories
delete(values, "category[]") delete(values, "category[]")
} }
if inReplyTo, ok := values["in-reply-to"]; ok { if inReplyTo, ok := values["in-reply-to"]; ok {
entry.Parameters[appConfig.Micropub.ReplyParam] = inReplyTo entry.Parameters[a.cfg.Micropub.ReplyParam] = inReplyTo
delete(values, "in-reply-to") delete(values, "in-reply-to")
} }
if likeOf, ok := values["like-of"]; ok { if likeOf, ok := values["like-of"]; ok {
entry.Parameters[appConfig.Micropub.LikeParam] = likeOf entry.Parameters[a.cfg.Micropub.LikeParam] = likeOf
delete(values, "like-of") delete(values, "like-of")
} }
if bookmarkOf, ok := values["bookmark-of"]; ok { if bookmarkOf, ok := values["bookmark-of"]; ok {
entry.Parameters[appConfig.Micropub.BookmarkParam] = bookmarkOf entry.Parameters[a.cfg.Micropub.BookmarkParam] = bookmarkOf
delete(values, "bookmark-of") delete(values, "bookmark-of")
} }
if audio, ok := values["audio"]; ok { if audio, ok := values["audio"]; ok {
entry.Parameters[appConfig.Micropub.AudioParam] = audio entry.Parameters[a.cfg.Micropub.AudioParam] = audio
delete(values, "audio") delete(values, "audio")
} else if audio, ok := values["audio[]"]; ok { } else if audio, ok := values["audio[]"]; ok {
entry.Parameters[appConfig.Micropub.AudioParam] = audio entry.Parameters[a.cfg.Micropub.AudioParam] = audio
delete(values, "audio[]") delete(values, "audio[]")
} }
if photo, ok := values["photo"]; ok { if photo, ok := values["photo"]; ok {
entry.Parameters[appConfig.Micropub.PhotoParam] = photo entry.Parameters[a.cfg.Micropub.PhotoParam] = photo
delete(values, "photo") delete(values, "photo")
} else if photos, ok := values["photo[]"]; ok { } else if photos, ok := values["photo[]"]; ok {
entry.Parameters[appConfig.Micropub.PhotoParam] = photos entry.Parameters[a.cfg.Micropub.PhotoParam] = photos
delete(values, "photo[]") delete(values, "photo[]")
} }
if photoAlt, ok := values["mp-photo-alt"]; ok { if photoAlt, ok := values["mp-photo-alt"]; ok {
entry.Parameters[appConfig.Micropub.PhotoDescriptionParam] = photoAlt entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam] = photoAlt
delete(values, "mp-photo-alt") delete(values, "mp-photo-alt")
} else if photoAlts, ok := values["mp-photo-alt[]"]; ok { } else if photoAlts, ok := values["mp-photo-alt[]"]; ok {
entry.Parameters[appConfig.Micropub.PhotoDescriptionParam] = photoAlts entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam] = photoAlts
delete(values, "mp-photo-alt[]") delete(values, "mp-photo-alt[]")
} }
if location, ok := values["location"]; ok { if location, ok := values["location"]; ok {
entry.Parameters[appConfig.Micropub.LocationParam] = location entry.Parameters[a.cfg.Micropub.LocationParam] = location
delete(values, "location") delete(values, "location")
} }
for n, p := range values { for n, p := range values {
entry.Parameters[n] = append(entry.Parameters[n], p...) entry.Parameters[n] = append(entry.Parameters[n], p...)
} }
err := entry.computeExtraPostParameters() err := a.computeExtraPostParameters(entry)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -314,7 +314,7 @@ type microformatProperties struct {
Audio []string `json:"audio,omitempty"` Audio []string `json:"audio,omitempty"`
} }
func convertMPMfToPost(mf *microformatItem) (*post, error) { func (a *goBlog) convertMPMfToPost(mf *microformatItem) (*post, error) {
if len(mf.Type) != 1 || mf.Type[0] != "h-entry" { if len(mf.Type) != 1 || mf.Type[0] != "h-entry" {
return nil, errors.New("only entry type is supported so far") return nil, errors.New("only entry type is supported so far")
} }
@ -339,35 +339,35 @@ func convertMPMfToPost(mf *microformatItem) (*post, error) {
entry.Parameters["title"] = mf.Properties.Name entry.Parameters["title"] = mf.Properties.Name
} }
if len(mf.Properties.Category) > 0 { if len(mf.Properties.Category) > 0 {
entry.Parameters[appConfig.Micropub.CategoryParam] = mf.Properties.Category entry.Parameters[a.cfg.Micropub.CategoryParam] = mf.Properties.Category
} }
if len(mf.Properties.InReplyTo) == 1 { if len(mf.Properties.InReplyTo) == 1 {
entry.Parameters[appConfig.Micropub.ReplyParam] = mf.Properties.InReplyTo entry.Parameters[a.cfg.Micropub.ReplyParam] = mf.Properties.InReplyTo
} }
if len(mf.Properties.LikeOf) == 1 { if len(mf.Properties.LikeOf) == 1 {
entry.Parameters[appConfig.Micropub.LikeParam] = mf.Properties.LikeOf entry.Parameters[a.cfg.Micropub.LikeParam] = mf.Properties.LikeOf
} }
if len(mf.Properties.BookmarkOf) == 1 { if len(mf.Properties.BookmarkOf) == 1 {
entry.Parameters[appConfig.Micropub.BookmarkParam] = mf.Properties.BookmarkOf entry.Parameters[a.cfg.Micropub.BookmarkParam] = mf.Properties.BookmarkOf
} }
if len(mf.Properties.Audio) > 0 { if len(mf.Properties.Audio) > 0 {
entry.Parameters[appConfig.Micropub.AudioParam] = mf.Properties.Audio entry.Parameters[a.cfg.Micropub.AudioParam] = mf.Properties.Audio
} }
if len(mf.Properties.Photo) > 0 { if len(mf.Properties.Photo) > 0 {
for _, photo := range mf.Properties.Photo { for _, photo := range mf.Properties.Photo {
if theString, justString := photo.(string); justString { if theString, justString := photo.(string); justString {
entry.Parameters[appConfig.Micropub.PhotoParam] = append(entry.Parameters[appConfig.Micropub.PhotoParam], theString) entry.Parameters[a.cfg.Micropub.PhotoParam] = append(entry.Parameters[a.cfg.Micropub.PhotoParam], theString)
entry.Parameters[appConfig.Micropub.PhotoDescriptionParam] = append(entry.Parameters[appConfig.Micropub.PhotoDescriptionParam], "") entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam] = append(entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam], "")
} else if thePhoto, isPhoto := photo.(map[string]interface{}); isPhoto { } else if thePhoto, isPhoto := photo.(map[string]interface{}); isPhoto {
entry.Parameters[appConfig.Micropub.PhotoParam] = append(entry.Parameters[appConfig.Micropub.PhotoParam], cast.ToString(thePhoto["value"])) entry.Parameters[a.cfg.Micropub.PhotoParam] = append(entry.Parameters[a.cfg.Micropub.PhotoParam], cast.ToString(thePhoto["value"]))
entry.Parameters[appConfig.Micropub.PhotoDescriptionParam] = append(entry.Parameters[appConfig.Micropub.PhotoDescriptionParam], cast.ToString(thePhoto["alt"])) entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam] = append(entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam], cast.ToString(thePhoto["alt"]))
} }
} }
} }
if len(mf.Properties.MpSlug) == 1 { if len(mf.Properties.MpSlug) == 1 {
entry.Slug = mf.Properties.MpSlug[0] entry.Slug = mf.Properties.MpSlug[0]
} }
err := entry.computeExtraPostParameters() err := a.computeExtraPostParameters(entry)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -375,7 +375,7 @@ func convertMPMfToPost(mf *microformatItem) (*post, error) {
} }
func (p *post) computeExtraPostParameters() error { func (a *goBlog) computeExtraPostParameters(p *post) error {
p.Content = regexp.MustCompile("\r\n").ReplaceAllString(p.Content, "\n") p.Content = regexp.MustCompile("\r\n").ReplaceAllString(p.Content, "\n")
if split := strings.Split(p.Content, "---\n"); len(split) >= 3 && len(strings.TrimSpace(split[0])) == 0 { if split := strings.Split(p.Content, "---\n"); len(split) >= 3 && len(strings.TrimSpace(split[0])) == 0 {
// Contains frontmatter // Contains frontmatter
@ -405,7 +405,7 @@ func (p *post) computeExtraPostParameters() error {
p.Blog = blog[0] p.Blog = blog[0]
delete(p.Parameters, "blog") delete(p.Parameters, "blog")
} else { } else {
p.Blog = appConfig.DefaultBlog p.Blog = a.cfg.DefaultBlog
} }
if path := p.Parameters["path"]; len(path) == 1 { if path := p.Parameters["path"]; len(path) == 1 {
p.Path = path[0] p.Path = path[0]
@ -433,15 +433,15 @@ func (p *post) computeExtraPostParameters() error {
} }
if p.Path == "" && p.Section == "" { if p.Path == "" && p.Section == "" {
// Has no path or section -> default section // Has no path or section -> default section
p.Section = appConfig.Blogs[p.Blog].DefaultSection p.Section = a.cfg.Blogs[p.Blog].DefaultSection
} }
if p.Published == "" && p.Section != "" { if p.Published == "" && p.Section != "" {
// Has no published date, but section -> published now // Has no published date, but section -> published now
p.Published = time.Now().Local().String() p.Published = time.Now().Local().String()
} }
// Add images not in content // Add images not in content
images := p.Parameters[appConfig.Micropub.PhotoParam] images := p.Parameters[a.cfg.Micropub.PhotoParam]
imageAlts := p.Parameters[appConfig.Micropub.PhotoDescriptionParam] imageAlts := p.Parameters[a.cfg.Micropub.PhotoDescriptionParam]
useAlts := len(images) == len(imageAlts) useAlts := len(images) == len(imageAlts)
for i, image := range images { for i, image := range images {
if !strings.Contains(p.Content, image) { if !strings.Contains(p.Content, image) {
@ -455,26 +455,26 @@ func (p *post) computeExtraPostParameters() error {
return nil return nil
} }
func micropubDelete(w http.ResponseWriter, r *http.Request, u *url.URL) { func (a *goBlog) micropubDelete(w http.ResponseWriter, r *http.Request, u *url.URL) {
if !strings.Contains(r.Context().Value(indieAuthScope).(string), "delete") { if !strings.Contains(r.Context().Value(indieAuthScope).(string), "delete") {
serveError(w, r, "delete scope missing", http.StatusForbidden) a.serveError(w, r, "delete scope missing", http.StatusForbidden)
return return
} }
if err := deletePost(u.Path); err != nil { if err := a.deletePost(u.Path); err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
http.Redirect(w, r, u.String(), http.StatusNoContent) http.Redirect(w, r, u.String(), http.StatusNoContent)
} }
func micropubUpdate(w http.ResponseWriter, r *http.Request, u *url.URL, mf *microformatItem) { func (a *goBlog) micropubUpdate(w http.ResponseWriter, r *http.Request, u *url.URL, mf *microformatItem) {
if !strings.Contains(r.Context().Value(indieAuthScope).(string), "update") { if !strings.Contains(r.Context().Value(indieAuthScope).(string), "update") {
serveError(w, r, "update scope missing", http.StatusForbidden) a.serveError(w, r, "update scope missing", http.StatusForbidden)
return return
} }
p, err := getPost(u.Path) p, err := a.db.getPost(u.Path)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
oldPath := p.Path oldPath := p.Path
@ -491,15 +491,15 @@ func micropubUpdate(w http.ResponseWriter, r *http.Request, u *url.URL, mf *micr
case "name": case "name":
p.Parameters["title"] = cast.ToStringSlice(value) p.Parameters["title"] = cast.ToStringSlice(value)
case "category": case "category":
p.Parameters[appConfig.Micropub.CategoryParam] = cast.ToStringSlice(value) p.Parameters[a.cfg.Micropub.CategoryParam] = cast.ToStringSlice(value)
case "in-reply-to": case "in-reply-to":
p.Parameters[appConfig.Micropub.ReplyParam] = cast.ToStringSlice(value) p.Parameters[a.cfg.Micropub.ReplyParam] = cast.ToStringSlice(value)
case "like-of": case "like-of":
p.Parameters[appConfig.Micropub.LikeParam] = cast.ToStringSlice(value) p.Parameters[a.cfg.Micropub.LikeParam] = cast.ToStringSlice(value)
case "bookmark-of": case "bookmark-of":
p.Parameters[appConfig.Micropub.BookmarkParam] = cast.ToStringSlice(value) p.Parameters[a.cfg.Micropub.BookmarkParam] = cast.ToStringSlice(value)
case "audio": case "audio":
p.Parameters[appConfig.Micropub.AudioParam] = cast.ToStringSlice(value) p.Parameters[a.cfg.Micropub.AudioParam] = cast.ToStringSlice(value)
// TODO: photo // TODO: photo
} }
} }
@ -514,23 +514,23 @@ func micropubUpdate(w http.ResponseWriter, r *http.Request, u *url.URL, mf *micr
case "updated": case "updated":
p.Updated = strings.TrimSpace(strings.Join(cast.ToStringSlice(value), " ")) p.Updated = strings.TrimSpace(strings.Join(cast.ToStringSlice(value), " "))
case "category": case "category":
category := p.Parameters[appConfig.Micropub.CategoryParam] category := p.Parameters[a.cfg.Micropub.CategoryParam]
if category == nil { if category == nil {
category = []string{} category = []string{}
} }
p.Parameters[appConfig.Micropub.CategoryParam] = append(category, cast.ToStringSlice(value)...) p.Parameters[a.cfg.Micropub.CategoryParam] = append(category, cast.ToStringSlice(value)...)
case "in-reply-to": case "in-reply-to":
p.Parameters[appConfig.Micropub.ReplyParam] = cast.ToStringSlice(value) p.Parameters[a.cfg.Micropub.ReplyParam] = cast.ToStringSlice(value)
case "like-of": case "like-of":
p.Parameters[appConfig.Micropub.LikeParam] = cast.ToStringSlice(value) p.Parameters[a.cfg.Micropub.LikeParam] = cast.ToStringSlice(value)
case "bookmark-of": case "bookmark-of":
p.Parameters[appConfig.Micropub.BookmarkParam] = cast.ToStringSlice(value) p.Parameters[a.cfg.Micropub.BookmarkParam] = cast.ToStringSlice(value)
case "audio": case "audio":
audio := p.Parameters[appConfig.Micropub.CategoryParam] audio := p.Parameters[a.cfg.Micropub.CategoryParam]
if audio == nil { if audio == nil {
audio = []string{} audio = []string{}
} }
p.Parameters[appConfig.Micropub.AudioParam] = append(audio, cast.ToStringSlice(value)...) p.Parameters[a.cfg.Micropub.AudioParam] = append(audio, cast.ToStringSlice(value)...)
// TODO: photo // TODO: photo
} }
} }
@ -548,18 +548,18 @@ func micropubUpdate(w http.ResponseWriter, r *http.Request, u *url.URL, mf *micr
case "updated": case "updated":
p.Updated = "" p.Updated = ""
case "category": case "category":
delete(p.Parameters, appConfig.Micropub.CategoryParam) delete(p.Parameters, a.cfg.Micropub.CategoryParam)
case "in-reply-to": case "in-reply-to":
delete(p.Parameters, appConfig.Micropub.ReplyParam) delete(p.Parameters, a.cfg.Micropub.ReplyParam)
case "like-of": case "like-of":
delete(p.Parameters, appConfig.Micropub.LikeParam) delete(p.Parameters, a.cfg.Micropub.LikeParam)
case "bookmark-of": case "bookmark-of":
delete(p.Parameters, appConfig.Micropub.BookmarkParam) delete(p.Parameters, a.cfg.Micropub.BookmarkParam)
case "audio": case "audio":
delete(p.Parameters, appConfig.Micropub.AudioParam) delete(p.Parameters, a.cfg.Micropub.AudioParam)
case "photo": case "photo":
delete(p.Parameters, appConfig.Micropub.PhotoParam) delete(p.Parameters, a.cfg.Micropub.PhotoParam)
delete(p.Parameters, appConfig.Micropub.PhotoDescriptionParam) delete(p.Parameters, a.cfg.Micropub.PhotoDescriptionParam)
} }
} }
} }
@ -576,11 +576,11 @@ func micropubUpdate(w http.ResponseWriter, r *http.Request, u *url.URL, mf *micr
case "updated": case "updated":
p.Updated = "" p.Updated = ""
case "in-reply-to": case "in-reply-to":
delete(p.Parameters, appConfig.Micropub.ReplyParam) delete(p.Parameters, a.cfg.Micropub.ReplyParam)
case "like-of": case "like-of":
delete(p.Parameters, appConfig.Micropub.LikeParam) delete(p.Parameters, a.cfg.Micropub.LikeParam)
case "bookmark-of": case "bookmark-of":
delete(p.Parameters, appConfig.Micropub.BookmarkParam) delete(p.Parameters, a.cfg.Micropub.BookmarkParam)
// Use content to edit other parameters // Use content to edit other parameters
} }
} }
@ -588,15 +588,15 @@ func micropubUpdate(w http.ResponseWriter, r *http.Request, u *url.URL, mf *micr
} }
} }
} }
err = p.computeExtraPostParameters() err = a.computeExtraPostParameters(p)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
err = p.replace(oldPath, oldStatus) err = a.replacePost(p, oldPath, oldStatus)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, p.fullURL(), http.StatusNoContent) http.Redirect(w, r, a.fullPostURL(p), http.StatusNoContent)
} }

View File

@ -15,23 +15,23 @@ import (
const micropubMediaSubPath = "/media" const micropubMediaSubPath = "/media"
func serveMicropubMedia(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveMicropubMedia(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Context().Value(indieAuthScope).(string), "media") { if !strings.Contains(r.Context().Value(indieAuthScope).(string), "media") {
serveError(w, r, "media scope missing", http.StatusForbidden) a.serveError(w, r, "media scope missing", http.StatusForbidden)
return return
} }
if ct := r.Header.Get(contentType); !strings.Contains(ct, contentTypeMultipartForm) { if ct := r.Header.Get(contentType); !strings.Contains(ct, contentTypeMultipartForm) {
serveError(w, r, "wrong content-type", http.StatusBadRequest) a.serveError(w, r, "wrong content-type", http.StatusBadRequest)
return return
} }
err := r.ParseMultipartForm(0) err := r.ParseMultipartForm(0)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
defer func() { _ = file.Close() }() defer func() { _ = file.Close() }()
@ -39,7 +39,7 @@ func serveMicropubMedia(w http.ResponseWriter, r *http.Request) {
defer func() { _ = hashFile.Close() }() defer func() { _ = hashFile.Close() }()
fileName, err := getSHA256(hashFile) fileName, err := getSHA256(hashFile)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
fileExtension := filepath.Ext(header.Filename) fileExtension := filepath.Ext(header.Filename)
@ -55,22 +55,22 @@ func serveMicropubMedia(w http.ResponseWriter, r *http.Request) {
} }
fileName += strings.ToLower(fileExtension) fileName += strings.ToLower(fileExtension)
// Save file // Save file
location, err := uploadFile(fileName, file) location, err := a.uploadFile(fileName, file)
if err != nil { if err != nil {
serveError(w, r, "failed to save original file: "+err.Error(), http.StatusInternalServerError) a.serveError(w, r, "failed to save original file: "+err.Error(), http.StatusInternalServerError)
return return
} }
// Try to compress file (only when not in private mode) // Try to compress file (only when not in private mode)
if pm := appConfig.PrivateMode; !(pm != nil && pm.Enabled) { if pm := a.cfg.PrivateMode; !(pm != nil && pm.Enabled) {
serveCompressionError := func(ce error) { serveCompressionError := func(ce error) {
serveError(w, r, "failed to compress file: "+ce.Error(), http.StatusInternalServerError) a.serveError(w, r, "failed to compress file: "+ce.Error(), http.StatusInternalServerError)
} }
var compressedLocation string var compressedLocation string
var compressionErr error var compressionErr error
if ms := appConfig.Micropub.MediaStorage; ms != nil { if ms := a.cfg.Micropub.MediaStorage; ms != nil {
// Default ShortPixel // Default ShortPixel
if ms.ShortPixelKey != "" { if ms.ShortPixelKey != "" {
compressedLocation, compressionErr = shortPixel(location, ms) compressedLocation, compressionErr = a.shortPixel(location, ms)
} }
if compressionErr != nil { if compressionErr != nil {
serveCompressionError(compressionErr) serveCompressionError(compressionErr)
@ -78,7 +78,7 @@ func serveMicropubMedia(w http.ResponseWriter, r *http.Request) {
} }
// Fallback Tinify // Fallback Tinify
if compressedLocation == "" && ms.TinifyKey != "" { if compressedLocation == "" && ms.TinifyKey != "" {
compressedLocation, compressionErr = tinify(location, ms) compressedLocation, compressionErr = a.tinify(location, ms)
} }
if compressionErr != nil { if compressionErr != nil {
serveCompressionError(compressionErr) serveCompressionError(compressionErr)
@ -86,7 +86,7 @@ func serveMicropubMedia(w http.ResponseWriter, r *http.Request) {
} }
// Fallback Cloudflare // Fallback Cloudflare
if compressedLocation == "" && ms.CloudflareCompressionEnabled { if compressedLocation == "" && ms.CloudflareCompressionEnabled {
compressedLocation, compressionErr = cloudflare(location) compressedLocation, compressionErr = a.cloudflare(location)
} }
if compressionErr != nil { if compressionErr != nil {
serveCompressionError(compressionErr) serveCompressionError(compressionErr)
@ -101,10 +101,10 @@ func serveMicropubMedia(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, location, http.StatusCreated) http.Redirect(w, r, location, http.StatusCreated)
} }
func uploadFile(filename string, f io.Reader) (string, error) { func (a *goBlog) uploadFile(filename string, f io.Reader) (string, error) {
ms := appConfig.Micropub.MediaStorage ms := a.cfg.Micropub.MediaStorage
if ms != nil && ms.BunnyStorageKey != "" && ms.BunnyStorageName != "" { if ms != nil && ms.BunnyStorageKey != "" && ms.BunnyStorageName != "" {
return uploadToBunny(filename, f, ms) return ms.uploadToBunny(filename, f)
} }
loc, err := saveMediaFile(filename, f) loc, err := saveMediaFile(filename, f)
if err != nil { if err != nil {
@ -113,10 +113,10 @@ func uploadFile(filename string, f io.Reader) (string, error) {
if ms != nil && ms.MediaURL != "" { if ms != nil && ms.MediaURL != "" {
return ms.MediaURL + loc, nil return ms.MediaURL + loc, nil
} }
return appConfig.Server.PublicAddress + loc, nil return a.cfg.Server.PublicAddress + loc, nil
} }
func uploadToBunny(filename string, f io.Reader, config *configMicropubMedia) (location string, err error) { func (config *configMicropubMedia) uploadToBunny(filename string, f io.Reader) (location string, err error) {
if config == nil || config.BunnyStorageName == "" || config.BunnyStorageKey == "" || config.MediaURL == "" { if config == nil || config.BunnyStorageName == "" || config.BunnyStorageKey == "" || config.MediaURL == "" {
return "", errors.New("Bunny storage not completely configured") return "", errors.New("Bunny storage not completely configured")
} }

View File

@ -2,6 +2,7 @@ package main
import ( import (
"io" "io"
"sync"
"github.com/tdewolff/minify/v2" "github.com/tdewolff/minify/v2"
mCss "github.com/tdewolff/minify/v2/css" mCss "github.com/tdewolff/minify/v2/css"
@ -11,22 +12,28 @@ import (
mXml "github.com/tdewolff/minify/v2/xml" mXml "github.com/tdewolff/minify/v2/xml"
) )
var minifier *minify.M var (
initMinify sync.Once
minifier *minify.M
)
func initMinify() { func getMinifier() *minify.M {
minifier = minify.New() initMinify.Do(func() {
minifier.AddFunc(contentTypeHTML, mHtml.Minify) minifier = minify.New()
minifier.AddFunc("text/css", mCss.Minify) minifier.AddFunc(contentTypeHTML, mHtml.Minify)
minifier.AddFunc(contentTypeXML, mXml.Minify) minifier.AddFunc("text/css", mCss.Minify)
minifier.AddFunc("application/javascript", mJs.Minify) minifier.AddFunc(contentTypeXML, mXml.Minify)
minifier.AddFunc(contentTypeRSS, mXml.Minify) minifier.AddFunc("application/javascript", mJs.Minify)
minifier.AddFunc(contentTypeATOM, mXml.Minify) minifier.AddFunc(contentTypeRSS, mXml.Minify)
minifier.AddFunc(contentTypeJSONFeed, mJson.Minify) minifier.AddFunc(contentTypeATOM, mXml.Minify)
minifier.AddFunc(contentTypeAS, mJson.Minify) minifier.AddFunc(contentTypeJSONFeed, mJson.Minify)
minifier.AddFunc(contentTypeAS, mJson.Minify)
})
return minifier
} }
func writeMinified(w io.Writer, mediatype string, b []byte) (int, error) { func writeMinified(w io.Writer, mediatype string, b []byte) (int, error) {
mw := minifier.Writer(mediatype, w) mw := getMinifier().Writer(mediatype, w)
defer func() { mw.Close() }() defer func() { _ = mw.Close() }()
return mw.Write(b) return mw.Write(b)
} }

View File

@ -5,11 +5,11 @@ import (
"net/http" "net/http"
) )
func serveNodeInfoDiscover(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveNodeInfoDiscover(w http.ResponseWriter, r *http.Request) {
b, _ := json.Marshal(map[string]interface{}{ b, _ := json.Marshal(map[string]interface{}{
"links": []map[string]interface{}{ "links": []map[string]interface{}{
{ {
"href": appConfig.Server.PublicAddress + "/nodeinfo", "href": a.cfg.Server.PublicAddress + "/nodeinfo",
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1", "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
}, },
}, },
@ -18,8 +18,8 @@ func serveNodeInfoDiscover(w http.ResponseWriter, r *http.Request) {
_, _ = writeMinified(w, contentTypeJSON, b) _, _ = writeMinified(w, contentTypeJSON, b)
} }
func serveNodeInfo(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveNodeInfo(w http.ResponseWriter, r *http.Request) {
localPosts, _ := countPosts(&postsRequestConfig{ localPosts, _ := a.db.countPosts(&postsRequestConfig{
status: statusPublished, status: statusPublished,
}) })
b, _ := json.Marshal(map[string]interface{}{ b, _ := json.Marshal(map[string]interface{}{
@ -30,7 +30,7 @@ func serveNodeInfo(w http.ResponseWriter, r *http.Request) {
}, },
"usage": map[string]interface{}{ "usage": map[string]interface{}{
"users": map[string]interface{}{ "users": map[string]interface{}{
"total": len(appConfig.Blogs), "total": len(a.cfg.Blogs),
}, },
"localPosts": localPosts, "localPosts": localPosts,
}, },

View File

@ -21,15 +21,15 @@ type notification struct {
Text string Text string
} }
func sendNotification(text string) { func (a *goBlog) sendNotification(text string) {
n := &notification{ n := &notification{
Time: time.Now().Unix(), Time: time.Now().Unix(),
Text: text, Text: text,
} }
if err := saveNotification(n); err != nil { if err := a.db.saveNotification(n); err != nil {
log.Println("Failed to save notification:", err.Error()) log.Println("Failed to save notification:", err.Error())
} }
if an := appConfig.Notifications; an != nil { if an := a.cfg.Notifications; an != nil {
if tg := an.Telegram; tg != nil && tg.Enabled { if tg := an.Telegram; tg != nil && tg.Enabled {
err := sendTelegramMessage(n.Text, "", tg.BotToken, tg.ChatID) err := sendTelegramMessage(n.Text, "", tg.BotToken, tg.ChatID)
if err != nil { if err != nil {
@ -39,15 +39,15 @@ func sendNotification(text string) {
} }
} }
func saveNotification(n *notification) error { func (db *database) saveNotification(n *notification) error {
if _, err := appDb.exec("insert into notifications (time, text) values (@time, @text)", sql.Named("time", n.Time), sql.Named("text", n.Text)); err != nil { if _, err := db.exec("insert into notifications (time, text) values (@time, @text)", sql.Named("time", n.Time), sql.Named("text", n.Text)); err != nil {
return err return err
} }
return nil return nil
} }
func deleteNotification(id int) error { func (db *database) deleteNotification(id int) error {
_, err := appDb.exec("delete from notifications where id = @id", sql.Named("id", id)) _, err := db.exec("delete from notifications where id = @id", sql.Named("id", id))
return err return err
} }
@ -65,10 +65,10 @@ func buildNotificationsQuery(config *notificationsRequestConfig) (query string,
return return
} }
func getNotifications(config *notificationsRequestConfig) ([]*notification, error) { func (db *database) getNotifications(config *notificationsRequestConfig) ([]*notification, error) {
notifications := []*notification{} notifications := []*notification{}
query, args := buildNotificationsQuery(config) query, args := buildNotificationsQuery(config)
rows, err := appDb.query(query, args...) rows, err := db.query(query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -83,10 +83,10 @@ func getNotifications(config *notificationsRequestConfig) ([]*notification, erro
return notifications, nil return notifications, nil
} }
func countNotifications(config *notificationsRequestConfig) (count int, err error) { func (db *database) countNotifications(config *notificationsRequestConfig) (count int, err error) {
query, params := buildNotificationsQuery(config) query, params := buildNotificationsQuery(config)
query = "select count(*) from (" + query + ")" query = "select count(*) from (" + query + ")"
row, err := appDb.queryRow(query, params...) row, err := db.queryRow(query, params...)
if err != nil { if err != nil {
return return
} }
@ -97,11 +97,12 @@ func countNotifications(config *notificationsRequestConfig) (count int, err erro
type notificationsPaginationAdapter struct { type notificationsPaginationAdapter struct {
config *notificationsRequestConfig config *notificationsRequestConfig
nums int64 nums int64
db *database
} }
func (p *notificationsPaginationAdapter) Nums() (int64, error) { func (p *notificationsPaginationAdapter) Nums() (int64, error) {
if p.nums == 0 { if p.nums == 0 {
nums, _ := countNotifications(p.config) nums, _ := p.db.countNotifications(p.config)
p.nums = int64(nums) p.nums = int64(nums)
} }
return p.nums, nil return p.nums, nil
@ -112,21 +113,21 @@ func (p *notificationsPaginationAdapter) Slice(offset, length int, data interfac
modifiedConfig.offset = offset modifiedConfig.offset = offset
modifiedConfig.limit = length modifiedConfig.limit = length
notifications, err := getNotifications(&modifiedConfig) notifications, err := p.db.getNotifications(&modifiedConfig)
reflect.ValueOf(data).Elem().Set(reflect.ValueOf(&notifications).Elem()) reflect.ValueOf(data).Elem().Set(reflect.ValueOf(&notifications).Elem())
return err return err
} }
func notificationsAdmin(w http.ResponseWriter, r *http.Request) { func (a *goBlog) notificationsAdmin(w http.ResponseWriter, r *http.Request) {
// Adapter // Adapter
pageNoString := chi.URLParam(r, "page") pageNoString := chi.URLParam(r, "page")
pageNo, _ := strconv.Atoi(pageNoString) pageNo, _ := strconv.Atoi(pageNoString)
p := paginator.New(&notificationsPaginationAdapter{config: &notificationsRequestConfig{}}, 10) p := paginator.New(&notificationsPaginationAdapter{config: &notificationsRequestConfig{}, db: a.db}, 10)
p.SetPage(pageNo) p.SetPage(pageNo)
var notifications []*notification var notifications []*notification
err := p.Results(&notifications) err := p.Results(&notifications)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
// Navigation // Navigation
@ -152,7 +153,7 @@ func notificationsAdmin(w http.ResponseWriter, r *http.Request) {
} }
nextPath = fmt.Sprintf("%s/page/%d", notificationsPath, nextPage) nextPath = fmt.Sprintf("%s/page/%d", notificationsPath, nextPage)
// Render // Render
render(w, r, templateNotificationsAdmin, &renderData{ a.render(w, r, templateNotificationsAdmin, &renderData{
Data: map[string]interface{}{ Data: map[string]interface{}{
"Notifications": notifications, "Notifications": notifications,
"HasPrev": hasPrev, "HasPrev": hasPrev,
@ -163,15 +164,15 @@ func notificationsAdmin(w http.ResponseWriter, r *http.Request) {
}) })
} }
func notificationsAdminDelete(w http.ResponseWriter, r *http.Request) { func (a *goBlog) notificationsAdminDelete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.FormValue("notificationid")) id, err := strconv.Atoi(r.FormValue("notificationid"))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
err = deleteNotification(id) err = a.db.deleteNotification(id)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, ".", http.StatusFound) http.Redirect(w, r, ".", http.StatusFound)

View File

@ -3,21 +3,17 @@ package main
import ( import (
"database/sql" "database/sql"
"time" "time"
"golang.org/x/sync/singleflight"
) )
func cachePersistently(key string, data []byte) error { func (db *database) cachePersistently(key string, data []byte) error {
date, _ := toLocal(time.Now().String()) date, _ := toLocal(time.Now().String())
_, err := appDb.exec("insert or replace into persistent_cache(key, data, date) values(@key, @data, @date)", sql.Named("key", key), sql.Named("data", data), sql.Named("date", date)) _, err := db.exec("insert or replace into persistent_cache(key, data, date) values(@key, @data, @date)", sql.Named("key", key), sql.Named("data", data), sql.Named("date", date))
return err return err
} }
var persistentCacheGroup singleflight.Group func (db *database) retrievePersistentCache(key string) (data []byte, err error) {
d, err, _ := db.persistentCacheGroup.Do(key, func() (interface{}, error) {
func retrievePersistentCache(key string) (data []byte, err error) { if row, err := db.queryRow("select data from persistent_cache where key = @key", sql.Named("key", key)); err == sql.ErrNoRows {
d, err, _ := persistentCacheGroup.Do(key, func() (interface{}, error) {
if row, err := appDb.queryRow("select data from persistent_cache where key = @key", sql.Named("key", key)); err == sql.ErrNoRows {
return nil, nil return nil, nil
} else if err != nil { } else if err != nil {
return nil, err return nil, err
@ -32,7 +28,7 @@ func retrievePersistentCache(key string) (data []byte, err error) {
return d.([]byte), nil return d.([]byte), nil
} }
func clearPersistentCache(pattern string) error { func (db *database) clearPersistentCache(pattern string) error {
_, err := appDb.exec("delete from persistent_cache where key like @pattern", sql.Named("pattern", pattern)) _, err := db.exec("delete from persistent_cache where key like @pattern", sql.Named("pattern", pattern))
return err return err
} }

View File

@ -5,9 +5,9 @@ import (
"net/http" "net/http"
) )
func allPostAliases() ([]string, error) { func (db *database) allPostAliases() ([]string, error) {
var aliases []string var aliases []string
rows, err := appDb.query("select distinct value from post_parameters where parameter = 'aliases' and value != path") rows, err := db.query("select distinct value from post_parameters where parameter = 'aliases' and value != path")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -21,18 +21,18 @@ func allPostAliases() ([]string, error) {
return aliases, nil return aliases, nil
} }
func servePostAlias(w http.ResponseWriter, r *http.Request) { func (a *goBlog) servePostAlias(w http.ResponseWriter, r *http.Request) {
row, err := appDb.queryRow("select path from post_parameters where parameter = 'aliases' and value = @alias", sql.Named("alias", r.URL.Path)) row, err := a.db.queryRow("select path from post_parameters where parameter = 'aliases' and value = @alias", sql.Named("alias", r.URL.Path))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
var path string var path string
if err := row.Scan(&path); err == sql.ErrNoRows { if err := row.Scan(&path); err == sql.ErrNoRows {
serve404(w, r) a.serve404(w, r)
return return
} else if err != nil { } else if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, path, http.StatusFound) http.Redirect(w, r, path, http.StatusFound)

View File

@ -41,45 +41,45 @@ const (
statusDraft postStatus = "draft" statusDraft postStatus = "draft"
) )
func servePost(w http.ResponseWriter, r *http.Request) { func (a *goBlog) servePost(w http.ResponseWriter, r *http.Request) {
t := servertiming.FromContext(r.Context()).NewMetric("gp").Start() t := servertiming.FromContext(r.Context()).NewMetric("gp").Start()
p, err := getPost(r.URL.Path) p, err := a.db.getPost(r.URL.Path)
t.Stop() t.Stop()
if err == errPostNotFound { if err == errPostNotFound {
serve404(w, r) a.serve404(w, r)
return return
} else if err != nil { } else if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
if asRequest, ok := r.Context().Value(asRequestKey).(bool); ok && asRequest { if asRequest, ok := r.Context().Value(asRequestKey).(bool); ok && asRequest {
if r.URL.Path == blogPath(p.Blog) { if r.URL.Path == a.blogPath(p.Blog) {
appConfig.Blogs[p.Blog].serveActivityStreams(p.Blog, w, r) a.serveActivityStreams(p.Blog, w, r)
return return
} }
p.serveActivityStreams(w) a.serveActivityStreamsPost(p, w)
return return
} }
canonical := p.firstParameter("original") canonical := p.firstParameter("original")
if canonical == "" { if canonical == "" {
canonical = p.fullURL() canonical = a.fullPostURL(p)
} }
template := templatePost template := templatePost
if p.Path == appConfig.Blogs[p.Blog].Path { if p.Path == a.cfg.Blogs[p.Blog].Path {
template = templateStaticHome template = templateStaticHome
} }
w.Header().Add("Link", fmt.Sprintf("<%s>; rel=shortlink", p.shortURL())) w.Header().Add("Link", fmt.Sprintf("<%s>; rel=shortlink", a.shortPostURL(p)))
render(w, r, template, &renderData{ a.render(w, r, template, &renderData{
BlogString: p.Blog, BlogString: p.Blog,
Canonical: canonical, Canonical: canonical,
Data: p, Data: p,
}) })
} }
func redirectToRandomPost(rw http.ResponseWriter, r *http.Request) { func (a *goBlog) redirectToRandomPost(rw http.ResponseWriter, r *http.Request) {
randomPath, err := getRandomPostPath(r.Context().Value(blogContextKey).(string)) randomPath, err := a.getRandomPostPath(r.Context().Value(blogContextKey).(string))
if err != nil { if err != nil {
serveError(rw, r, err.Error(), http.StatusInternalServerError) a.serveError(rw, r, err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(rw, r, randomPath, http.StatusFound) http.Redirect(rw, r, randomPath, http.StatusFound)
@ -88,11 +88,12 @@ func redirectToRandomPost(rw http.ResponseWriter, r *http.Request) {
type postPaginationAdapter struct { type postPaginationAdapter struct {
config *postsRequestConfig config *postsRequestConfig
nums int64 nums int64
db *database
} }
func (p *postPaginationAdapter) Nums() (int64, error) { func (p *postPaginationAdapter) Nums() (int64, error) {
if p.nums == 0 { if p.nums == 0 {
nums, _ := countPosts(p.config) nums, _ := p.db.countPosts(p.config)
p.nums = int64(nums) p.nums = int64(nums)
} }
return p.nums, nil return p.nums, nil
@ -103,23 +104,23 @@ func (p *postPaginationAdapter) Slice(offset, length int, data interface{}) erro
modifiedConfig.offset = offset modifiedConfig.offset = offset
modifiedConfig.limit = length modifiedConfig.limit = length
posts, err := getPosts(&modifiedConfig) posts, err := p.db.getPosts(&modifiedConfig)
reflect.ValueOf(data).Elem().Set(reflect.ValueOf(&posts).Elem()) reflect.ValueOf(data).Elem().Set(reflect.ValueOf(&posts).Elem())
return err return err
} }
func serveHome(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveHome(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string) blog := r.Context().Value(blogContextKey).(string)
if asRequest, ok := r.Context().Value(asRequestKey).(bool); ok && asRequest { if asRequest, ok := r.Context().Value(asRequestKey).(bool); ok && asRequest {
appConfig.Blogs[blog].serveActivityStreams(blog, w, r) a.serveActivityStreams(blog, w, r)
return return
} }
serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{
path: blogPath(blog), path: a.blogPath(blog),
}))) })))
} }
func serveDate(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveDate(w http.ResponseWriter, r *http.Request) {
var year, month, day int var year, month, day int
if ys := chi.URLParam(r, "year"); ys != "" && ys != "x" { if ys := chi.URLParam(r, "year"); ys != "" && ys != "x" {
year, _ = strconv.Atoi(ys) year, _ = strconv.Atoi(ys)
@ -131,11 +132,11 @@ func serveDate(w http.ResponseWriter, r *http.Request) {
day, _ = strconv.Atoi(ds) day, _ = strconv.Atoi(ds)
} }
if year == 0 && month == 0 && day == 0 { if year == 0 && month == 0 && day == 0 {
serve404(w, r) a.serve404(w, r)
return return
} }
var title, dPath strings.Builder var title, dPath strings.Builder
dPath.WriteString(blogPath(r.Context().Value(blogContextKey).(string)) + "/") dPath.WriteString(a.blogPath(r.Context().Value(blogContextKey).(string)) + "/")
if year != 0 { if year != 0 {
ys := fmt.Sprintf("%0004d", year) ys := fmt.Sprintf("%0004d", year)
title.WriteString(ys) title.WriteString(ys)
@ -155,7 +156,7 @@ func serveDate(w http.ResponseWriter, r *http.Request) {
title.WriteString(fmt.Sprintf("-%02d", day)) title.WriteString(fmt.Sprintf("-%02d", day))
dPath.WriteString(fmt.Sprintf("/%02d", day)) dPath.WriteString(fmt.Sprintf("/%02d", day))
} }
serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{
path: dPath.String(), path: dPath.String(),
year: year, year: year,
month: month, month: month,
@ -179,7 +180,7 @@ type indexConfig struct {
const indexConfigKey requestContextKey = "indexConfig" const indexConfigKey requestContextKey = "indexConfig"
func serveIndex(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveIndex(w http.ResponseWriter, r *http.Request) {
ic := r.Context().Value(indexConfigKey).(*indexConfig) ic := r.Context().Value(indexConfigKey).(*indexConfig)
blog := ic.blog blog := ic.blog
if blog == "" { if blog == "" {
@ -195,7 +196,7 @@ func serveIndex(w http.ResponseWriter, r *http.Request) {
if ic.section != nil { if ic.section != nil {
sections = []string{ic.section.Name} sections = []string{ic.section.Name}
} else { } else {
for sectionKey := range appConfig.Blogs[blog].Sections { for sectionKey := range a.cfg.Blogs[blog].Sections {
sections = append(sections, sectionKey) sections = append(sections, sectionKey)
} }
} }
@ -210,14 +211,14 @@ func serveIndex(w http.ResponseWriter, r *http.Request) {
publishedMonth: ic.month, publishedMonth: ic.month,
publishedDay: ic.day, publishedDay: ic.day,
status: statusPublished, status: statusPublished,
}}, appConfig.Blogs[blog].Pagination) }, db: a.db}, a.cfg.Blogs[blog].Pagination)
p.SetPage(pageNo) p.SetPage(pageNo)
var posts []*post var posts []*post
t := servertiming.FromContext(r.Context()).NewMetric("gp").Start() t := servertiming.FromContext(r.Context()).NewMetric("gp").Start()
err := p.Results(&posts) err := p.Results(&posts)
t.Stop() t.Stop()
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
// Meta // Meta
@ -229,13 +230,13 @@ func serveIndex(w http.ResponseWriter, r *http.Request) {
title = ic.section.Title title = ic.section.Title
description = ic.section.Description description = ic.section.Description
} else if search != "" { } else if search != "" {
title = fmt.Sprintf("%s: %s", appConfig.Blogs[blog].Search.Title, search) title = fmt.Sprintf("%s: %s", a.cfg.Blogs[blog].Search.Title, search)
} }
// Clean title // Clean title
title = bluemonday.StrictPolicy().Sanitize(title) title = bluemonday.StrictPolicy().Sanitize(title)
// Check if feed // Check if feed
if ft := feedType(chi.URLParam(r, "feed")); ft != noFeed { if ft := feedType(chi.URLParam(r, "feed")); ft != noFeed {
generateFeed(blog, ft, w, r, posts, title, description) a.generateFeed(blog, ft, w, r, posts, title, description)
return return
} }
// Path // Path
@ -269,9 +270,9 @@ func serveIndex(w http.ResponseWriter, r *http.Request) {
if summaryTemplate == "" { if summaryTemplate == "" {
summaryTemplate = templateSummary summaryTemplate = templateSummary
} }
render(w, r, templateIndex, &renderData{ a.render(w, r, templateIndex, &renderData{
BlogString: blog, BlogString: blog,
Canonical: appConfig.Server.PublicAddress + path, Canonical: a.cfg.Server.PublicAddress + path,
Data: map[string]interface{}{ Data: map[string]interface{}{
"Title": title, "Title": title,
"Description": description, "Description": description,

View File

@ -13,7 +13,7 @@ import (
"github.com/araddon/dateparse" "github.com/araddon/dateparse"
) )
func (p *post) checkPost() (err error) { func (a *goBlog) checkPost(p *post) (err error) {
if p == nil { if p == nil {
return errors.New("no post") return errors.New("no post")
} }
@ -57,13 +57,13 @@ func (p *post) checkPost() (err error) {
} }
// Check blog // Check blog
if p.Blog == "" { if p.Blog == "" {
p.Blog = appConfig.DefaultBlog p.Blog = a.cfg.DefaultBlog
} }
if _, ok := appConfig.Blogs[p.Blog]; !ok { if _, ok := a.cfg.Blogs[p.Blog]; !ok {
return errors.New("blog doesn't exist") return errors.New("blog doesn't exist")
} }
// Check if section exists // Check if section exists
if _, ok := appConfig.Blogs[p.Blog].Sections[p.Section]; p.Section != "" && !ok { if _, ok := a.cfg.Blogs[p.Blog].Sections[p.Section]; p.Section != "" && !ok {
return errors.New("section doesn't exist") return errors.New("section doesn't exist")
} }
// Check path // Check path
@ -72,14 +72,14 @@ func (p *post) checkPost() (err error) {
} }
if p.Path == "" { if p.Path == "" {
if p.Section == "" { if p.Section == "" {
p.Section = appConfig.Blogs[p.Blog].DefaultSection p.Section = a.cfg.Blogs[p.Blog].DefaultSection
} }
if p.Slug == "" { if p.Slug == "" {
random := generateRandomString(5) random := generateRandomString(5)
p.Slug = fmt.Sprintf("%v-%02d-%02d-%v", now.Year(), int(now.Month()), now.Day(), random) p.Slug = fmt.Sprintf("%v-%02d-%02d-%v", now.Year(), int(now.Month()), now.Day(), random)
} }
published, _ := dateparse.ParseLocal(p.Published) published, _ := dateparse.ParseLocal(p.Published)
pathTmplString := appConfig.Blogs[p.Blog].Sections[p.Section].PathTemplate pathTmplString := a.cfg.Blogs[p.Blog].Sections[p.Section].PathTemplate
if pathTmplString == "" { if pathTmplString == "" {
return errors.New("path template empty") return errors.New("path template empty")
} }
@ -89,7 +89,7 @@ func (p *post) checkPost() (err error) {
} }
var pathBuffer bytes.Buffer var pathBuffer bytes.Buffer
err = pathTmpl.Execute(&pathBuffer, map[string]interface{}{ err = pathTmpl.Execute(&pathBuffer, map[string]interface{}{
"BlogPath": appConfig.Blogs[p.Blog].Path, "BlogPath": a.cfg.Blogs[p.Blog].Path,
"Year": published.Year(), "Year": published.Year(),
"Month": int(published.Month()), "Month": int(published.Month()),
"Day": published.Day(), "Day": published.Day(),
@ -107,12 +107,12 @@ func (p *post) checkPost() (err error) {
return nil return nil
} }
func (p *post) create() error { func (a *goBlog) createPost(p *post) error {
return p.createOrReplace(&postCreationOptions{new: true}) return a.createOrReplacePost(p, &postCreationOptions{new: true})
} }
func (p *post) replace(oldPath string, oldStatus postStatus) error { func (a *goBlog) replacePost(p *post, oldPath string, oldStatus postStatus) error {
return p.createOrReplace(&postCreationOptions{new: false, oldPath: oldPath, oldStatus: oldStatus}) return a.createOrReplacePost(p, &postCreationOptions{new: false, oldPath: oldPath, oldStatus: oldStatus})
} }
type postCreationOptions struct { type postCreationOptions struct {
@ -123,8 +123,8 @@ type postCreationOptions struct {
var postCreationMutex sync.Mutex var postCreationMutex sync.Mutex
func (p *post) createOrReplace(o *postCreationOptions) error { func (a *goBlog) createOrReplacePost(p *post, o *postCreationOptions) error {
err := p.checkPost() err := a.checkPost(p)
if err != nil { if err != nil {
return err return err
} }
@ -135,7 +135,7 @@ func (p *post) createOrReplace(o *postCreationOptions) error {
if o.new || (p.Path != o.oldPath) { if o.new || (p.Path != o.oldPath) {
// Post is new or post path was changed // Post is new or post path was changed
newPathExists := false newPathExists := false
row, err := appDb.queryRow("select exists(select 1 from posts where path = @path)", sql.Named("path", p.Path)) row, err := a.db.queryRow("select exists(select 1 from posts where path = @path)", sql.Named("path", p.Path))
if err != nil { if err != nil {
return err return err
} }
@ -169,65 +169,37 @@ func (p *post) createOrReplace(o *postCreationOptions) error {
} }
} }
// Execute // Execute
_, err = appDb.execMulti(sqlBuilder.String(), sqlArgs...) _, err = a.db.execMulti(sqlBuilder.String(), sqlArgs...)
if err != nil { if err != nil {
return err return err
} }
// Update FTS index, trigger hooks and reload router // Update FTS index, trigger hooks and reload router
rebuildFTSIndex() a.db.rebuildFTSIndex()
if p.Status == statusPublished { if p.Status == statusPublished {
if o.new || o.oldStatus == statusDraft { if o.new || o.oldStatus == statusDraft {
defer p.postPostHooks() defer a.postPostHooks(p)
} else { } else {
defer p.postUpdateHooks() defer a.postUpdateHooks(p)
} }
} }
return reloadRouter() return a.reloadRouter()
} }
func deletePost(path string) error { func (a *goBlog) deletePost(path string) error {
if path == "" { if path == "" {
return nil return nil
} }
p, err := getPost(path) p, err := a.db.getPost(path)
if err != nil { if err != nil {
return err return err
} }
_, err = appDb.exec("delete from posts where path = @path", sql.Named("path", p.Path)) _, err = a.db.exec("delete from posts where path = @path", sql.Named("path", p.Path))
if err != nil { if err != nil {
return err return err
} }
rebuildFTSIndex() a.db.rebuildFTSIndex()
defer p.postDeleteHooks() defer a.postDeleteHooks(p)
return reloadRouter() return a.reloadRouter()
}
func rebuildFTSIndex() {
_, _ = appDb.exec("insert into posts_fts(posts_fts) values ('rebuild')")
}
func getPost(path string) (*post, error) {
posts, err := getPosts(&postsRequestConfig{path: path})
if err != nil {
return nil, err
} else if len(posts) == 0 {
return nil, errPostNotFound
}
return posts[0], nil
}
func getRandomPostPath(blog string) (string, error) {
var sections []string
for sectionKey := range appConfig.Blogs[blog].Sections {
sections = append(sections, sectionKey)
}
posts, err := getPosts(&postsRequestConfig{randomOrder: true, limit: 1, blog: blog, sections: sections})
if err != nil {
return "", err
} else if len(posts) == 0 {
return "", errPostNotFound
}
return posts[0].Path, nil
} }
type postsRequestConfig struct { type postsRequestConfig struct {
@ -246,39 +218,39 @@ type postsRequestConfig struct {
randomOrder bool randomOrder bool
} }
func buildPostsQuery(config *postsRequestConfig) (query string, args []interface{}) { func buildPostsQuery(c *postsRequestConfig) (query string, args []interface{}) {
args = []interface{}{} args = []interface{}{}
defaultSelection := "select p.path as path, coalesce(content, '') as content, coalesce(published, '') as published, coalesce(updated, '') as updated, coalesce(blog, '') as blog, coalesce(section, '') as section, coalesce(status, '') as status, coalesce(parameter, '') as parameter, coalesce(value, '') as value " defaultSelection := "select p.path as path, coalesce(content, '') as content, coalesce(published, '') as published, coalesce(updated, '') as updated, coalesce(blog, '') as blog, coalesce(section, '') as section, coalesce(status, '') as status, coalesce(parameter, '') as parameter, coalesce(value, '') as value "
postsTable := "posts" postsTable := "posts"
if config.search != "" { if c.search != "" {
postsTable = "posts_fts(@search)" postsTable = "posts_fts(@search)"
args = append(args, sql.Named("search", config.search)) args = append(args, sql.Named("search", c.search))
} }
if config.status != "" && config.status != statusNil { if c.status != "" && c.status != statusNil {
postsTable = "(select * from " + postsTable + " where status = @status)" postsTable = "(select * from " + postsTable + " where status = @status)"
args = append(args, sql.Named("status", config.status)) args = append(args, sql.Named("status", c.status))
} }
if config.blog != "" { if c.blog != "" {
postsTable = "(select * from " + postsTable + " where blog = @blog)" postsTable = "(select * from " + postsTable + " where blog = @blog)"
args = append(args, sql.Named("blog", config.blog)) args = append(args, sql.Named("blog", c.blog))
} }
if config.parameter != "" { if c.parameter != "" {
postsTable = "(select distinct p.* from " + postsTable + " p left outer join post_parameters pp on p.path = pp.path where pp.parameter = @param " postsTable = "(select distinct p.* from " + postsTable + " p left outer join post_parameters pp on p.path = pp.path where pp.parameter = @param "
args = append(args, sql.Named("param", config.parameter)) args = append(args, sql.Named("param", c.parameter))
if config.parameterValue != "" { if c.parameterValue != "" {
postsTable += "and pp.value = @paramval)" postsTable += "and pp.value = @paramval)"
args = append(args, sql.Named("paramval", config.parameterValue)) args = append(args, sql.Named("paramval", c.parameterValue))
} else { } else {
postsTable += "and length(coalesce(pp.value, '')) > 1)" postsTable += "and length(coalesce(pp.value, '')) > 1)"
} }
} }
if config.taxonomy != nil && len(config.taxonomyValue) > 0 { if c.taxonomy != nil && len(c.taxonomyValue) > 0 {
postsTable = "(select distinct p.* from " + postsTable + " p left outer join post_parameters pp on p.path = pp.path where pp.parameter = @taxname and lower(pp.value) = lower(@taxval))" postsTable = "(select distinct p.* from " + postsTable + " p left outer join post_parameters pp on p.path = pp.path where pp.parameter = @taxname and lower(pp.value) = lower(@taxval))"
args = append(args, sql.Named("taxname", config.taxonomy.Name), sql.Named("taxval", config.taxonomyValue)) args = append(args, sql.Named("taxname", c.taxonomy.Name), sql.Named("taxval", c.taxonomyValue))
} }
if len(config.sections) > 0 { if len(c.sections) > 0 {
postsTable = "(select * from " + postsTable + " where" postsTable = "(select * from " + postsTable + " where"
for i, section := range config.sections { for i, section := range c.sections {
if i > 0 { if i > 0 {
postsTable += " or" postsTable += " or"
} }
@ -288,38 +260,38 @@ func buildPostsQuery(config *postsRequestConfig) (query string, args []interface
} }
postsTable += ")" postsTable += ")"
} }
if config.publishedYear != 0 { if c.publishedYear != 0 {
postsTable = "(select * from " + postsTable + " p where substr(p.published, 1, 4) = @publishedyear)" postsTable = "(select * from " + postsTable + " p where substr(p.published, 1, 4) = @publishedyear)"
args = append(args, sql.Named("publishedyear", fmt.Sprintf("%0004d", config.publishedYear))) args = append(args, sql.Named("publishedyear", fmt.Sprintf("%0004d", c.publishedYear)))
} }
if config.publishedMonth != 0 { if c.publishedMonth != 0 {
postsTable = "(select * from " + postsTable + " p where substr(p.published, 6, 2) = @publishedmonth)" postsTable = "(select * from " + postsTable + " p where substr(p.published, 6, 2) = @publishedmonth)"
args = append(args, sql.Named("publishedmonth", fmt.Sprintf("%02d", config.publishedMonth))) args = append(args, sql.Named("publishedmonth", fmt.Sprintf("%02d", c.publishedMonth)))
} }
if config.publishedDay != 0 { if c.publishedDay != 0 {
postsTable = "(select * from " + postsTable + " p where substr(p.published, 9, 2) = @publishedday)" postsTable = "(select * from " + postsTable + " p where substr(p.published, 9, 2) = @publishedday)"
args = append(args, sql.Named("publishedday", fmt.Sprintf("%02d", config.publishedDay))) args = append(args, sql.Named("publishedday", fmt.Sprintf("%02d", c.publishedDay)))
} }
defaultTables := " from " + postsTable + " p left outer join post_parameters pp on p.path = pp.path " defaultTables := " from " + postsTable + " p left outer join post_parameters pp on p.path = pp.path "
defaultSorting := " order by p.published desc " defaultSorting := " order by p.published desc "
if config.randomOrder { if c.randomOrder {
defaultSorting = " order by random() " defaultSorting = " order by random() "
} }
if config.path != "" { if c.path != "" {
query = defaultSelection + defaultTables + " where p.path = @path" + defaultSorting query = defaultSelection + defaultTables + " where p.path = @path" + defaultSorting
args = append(args, sql.Named("path", config.path)) args = append(args, sql.Named("path", c.path))
} else if config.limit != 0 || config.offset != 0 { } else if c.limit != 0 || c.offset != 0 {
query = defaultSelection + " from (select * from " + postsTable + " p " + defaultSorting + " limit @limit offset @offset) p left outer join post_parameters pp on p.path = pp.path " query = defaultSelection + " from (select * from " + postsTable + " p " + defaultSorting + " limit @limit offset @offset) p left outer join post_parameters pp on p.path = pp.path "
args = append(args, sql.Named("limit", config.limit), sql.Named("offset", config.offset)) args = append(args, sql.Named("limit", c.limit), sql.Named("offset", c.offset))
} else { } else {
query = defaultSelection + defaultTables + defaultSorting query = defaultSelection + defaultTables + defaultSorting
} }
return return
} }
func getPosts(config *postsRequestConfig) (posts []*post, err error) { func (d *database) getPosts(config *postsRequestConfig) (posts []*post, err error) {
query, queryParams := buildPostsQuery(config) query, queryParams := buildPostsQuery(config)
rows, err := appDb.query(query, queryParams...) rows, err := d.query(query, queryParams...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -351,10 +323,25 @@ func getPosts(config *postsRequestConfig) (posts []*post, err error) {
return posts, nil return posts, nil
} }
func countPosts(config *postsRequestConfig) (count int, err error) { func (d *database) getPost(path string) (*post, error) {
posts, err := d.getPosts(&postsRequestConfig{path: path})
if err != nil {
return nil, err
} else if len(posts) == 0 {
return nil, errPostNotFound
}
return posts[0], nil
}
func (d *database) getDrafts(blog string) []*post {
ps, _ := d.getPosts(&postsRequestConfig{status: statusDraft, blog: blog})
return ps
}
func (d *database) countPosts(config *postsRequestConfig) (count int, err error) {
query, params := buildPostsQuery(config) query, params := buildPostsQuery(config)
query = "select count(distinct path) from (" + query + ")" query = "select count(distinct path) from (" + query + ")"
row, err := appDb.queryRow(query, params...) row, err := d.queryRow(query, params...)
if err != nil { if err != nil {
return return
} }
@ -362,9 +349,9 @@ func countPosts(config *postsRequestConfig) (count int, err error) {
return return
} }
func allPostPaths(status postStatus) ([]string, error) { func (d *database) allPostPaths(status postStatus) ([]string, error) {
var postPaths []string var postPaths []string
rows, err := appDb.query("select path from posts where status = @status", sql.Named("status", status)) rows, err := d.query("select path from posts where status = @status", sql.Named("status", status))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -378,9 +365,23 @@ func allPostPaths(status postStatus) ([]string, error) {
return postPaths, nil return postPaths, nil
} }
func allTaxonomyValues(blog string, taxonomy string) ([]string, error) { func (a *goBlog) getRandomPostPath(blog string) (string, error) {
var sections []string
for sectionKey := range a.cfg.Blogs[blog].Sections {
sections = append(sections, sectionKey)
}
posts, err := a.db.getPosts(&postsRequestConfig{randomOrder: true, limit: 1, blog: blog, sections: sections})
if err != nil {
return "", err
} else if len(posts) == 0 {
return "", errPostNotFound
}
return posts[0].Path, nil
}
func (d *database) allTaxonomyValues(blog string, taxonomy string) ([]string, error) {
var values []string var values []string
rows, err := appDb.query("select distinct pp.value from posts p left outer join post_parameters pp on p.path = pp.path where pp.parameter = @tax and length(coalesce(pp.value, '')) > 1 and blog = @blog and status = @status", sql.Named("tax", taxonomy), sql.Named("blog", blog), sql.Named("status", statusPublished)) rows, err := d.query("select distinct pp.value from posts p left outer join post_parameters pp on p.path = pp.path where pp.parameter = @tax and length(coalesce(pp.value, '')) > 1 and blog = @blog and status = @status", sql.Named("tax", taxonomy), sql.Named("blog", blog), sql.Named("status", statusPublished))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -396,8 +397,8 @@ type publishedDate struct {
year, month, day int year, month, day int
} }
func allPublishedDates(blog string) (dates []publishedDate, err error) { func (d *database) allPublishedDates(blog string) (dates []publishedDate, err error) {
rows, err := appDb.query("select distinct substr(published, 1, 4) as year, substr(published, 6, 2) as month, substr(published, 9, 2) as day from posts where blog = @blog and status = @status and year != '' and month != '' and day != ''", sql.Named("blog", blog), sql.Named("status", statusPublished)) rows, err := d.query("select distinct substr(published, 1, 4) as year, substr(published, 6, 2) as month, substr(published, 9, 2) as day from posts where blog = @blog and status = @status and year != '' and month != '' and day != ''", sql.Named("blog", blog), sql.Named("status", statusPublished))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -8,19 +8,19 @@ import (
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
func (p *post) fullURL() string { func (a *goBlog) fullPostURL(p *post) string {
return appConfig.Server.PublicAddress + p.Path return a.cfg.Server.PublicAddress + p.Path
} }
func (p *post) shortURL() string { func (a *goBlog) shortPostURL(p *post) string {
s, err := shortenPath(p.Path) s, err := a.db.shortenPath(p.Path)
if err != nil { if err != nil {
return "" return ""
} }
if appConfig.Server.ShortPublicAddress != "" { if a.cfg.Server.ShortPublicAddress != "" {
return appConfig.Server.ShortPublicAddress + s return a.cfg.Server.ShortPublicAddress + s
} }
return appConfig.Server.PublicAddress + s return a.cfg.Server.PublicAddress + s
} }
func (p *post) firstParameter(parameter string) (result string) { func (p *post) firstParameter(parameter string) (result string) {
@ -34,11 +34,11 @@ func (p *post) title() string {
return p.firstParameter("title") return p.firstParameter("title")
} }
func (p *post) html() template.HTML { func (a *goBlog) html(p *post) template.HTML {
if p.rendered != "" { if p.rendered != "" {
return p.rendered return p.rendered
} }
htmlContent, err := renderMarkdown(p.Content, false) htmlContent, err := a.renderMarkdown(p.Content, false)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
return "" return ""
@ -47,11 +47,11 @@ func (p *post) html() template.HTML {
return p.rendered return p.rendered
} }
func (p *post) absoluteHTML() template.HTML { func (a *goBlog) absoluteHTML(p *post) template.HTML {
if p.absoluteRendered != "" { if p.absoluteRendered != "" {
return p.absoluteRendered return p.absoluteRendered
} }
htmlContent, err := renderMarkdown(p.Content, true) htmlContent, err := a.renderMarkdown(p.Content, true)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
return "" return ""
@ -62,12 +62,12 @@ func (p *post) absoluteHTML() template.HTML {
const summaryDivider = "<!--more-->" const summaryDivider = "<!--more-->"
func (p *post) summary() (summary string) { func (a *goBlog) summary(p *post) (summary string) {
summary = p.firstParameter("summary") summary = p.firstParameter("summary")
if summary != "" { if summary != "" {
return return
} }
html := string(p.html()) html := string(a.html(p))
if splitted := strings.Split(html, summaryDivider); len(splitted) > 1 { if splitted := strings.Split(html, summaryDivider); len(splitted) > 1 {
doc, _ := goquery.NewDocumentFromReader(strings.NewReader(splitted[0])) doc, _ := goquery.NewDocumentFromReader(strings.NewReader(splitted[0]))
summary = doc.Text() summary = doc.Text()
@ -78,12 +78,12 @@ func (p *post) summary() (summary string) {
return return
} }
func (p *post) translations() []*post { func (a *goBlog) translations(p *post) []*post {
translationkey := p.firstParameter("translationkey") translationkey := p.firstParameter("translationkey")
if translationkey == "" { if translationkey == "" {
return nil return nil
} }
posts, err := getPosts(&postsRequestConfig{ posts, err := a.db.getPosts(&postsRequestConfig{
parameter: "translationkey", parameter: "translationkey",
parameterValue: translationkey, parameterValue: translationkey,
}) })

View File

@ -8,11 +8,11 @@ import (
"github.com/araddon/dateparse" "github.com/araddon/dateparse"
) )
func enqueue(name string, content []byte, schedule time.Time) error { func (db *database) enqueue(name string, content []byte, schedule time.Time) error {
if len(content) == 0 { if len(content) == 0 {
return errors.New("empty content") return errors.New("empty content")
} }
_, err := appDb.exec("insert into queue (name, content, schedule) values (@name, @content, @schedule)", _, err := db.exec("insert into queue (name, content, schedule) values (@name, @content, @schedule)",
sql.Named("name", name), sql.Named("content", content), sql.Named("schedule", schedule.UTC().String())) sql.Named("name", name), sql.Named("content", content), sql.Named("schedule", schedule.UTC().String()))
return err return err
} }
@ -24,18 +24,18 @@ type queueItem struct {
schedule *time.Time schedule *time.Time
} }
func (qi *queueItem) reschedule(dur time.Duration) error { func (db *database) reschedule(qi *queueItem, dur time.Duration) error {
_, err := appDb.exec("update queue set schedule = @schedule, content = @content where id = @id", sql.Named("schedule", qi.schedule.Add(dur).UTC().String()), sql.Named("content", qi.content), sql.Named("id", qi.id)) _, err := db.exec("update queue set schedule = @schedule, content = @content where id = @id", sql.Named("schedule", qi.schedule.Add(dur).UTC().String()), sql.Named("content", qi.content), sql.Named("id", qi.id))
return err return err
} }
func (qi *queueItem) dequeue() error { func (db *database) dequeue(qi *queueItem) error {
_, err := appDb.exec("delete from queue where id = @id", sql.Named("id", qi.id)) _, err := db.exec("delete from queue where id = @id", sql.Named("id", qi.id))
return err return err
} }
func peekQueue(name string) (*queueItem, error) { func (db *database) peekQueue(name string) (*queueItem, error) {
row, err := appDb.queryRow("select id, name, content, schedule from queue where schedule <= @schedule and name = @name order by schedule asc limit 1", sql.Named("name", name), sql.Named("schedule", time.Now().UTC().String())) row, err := db.queryRow("select id, name, content, schedule from queue where schedule <= @schedule and name = @name order by schedule asc limit 1", sql.Named("name", name), sql.Named("schedule", time.Now().UTC().String()))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -5,16 +5,14 @@ import (
"regexp" "regexp"
) )
var regexRedirects []*regexRedirect
type regexRedirect struct { type regexRedirect struct {
From *regexp.Regexp From *regexp.Regexp
To string To string
Type int Type int
} }
func initRegexRedirects() error { func (a *goBlog) initRegexRedirects() error {
for _, cr := range appConfig.PathRedirects { for _, cr := range a.cfg.PathRedirects {
re, err := regexp.Compile(cr.From) re, err := regexp.Compile(cr.From)
if err != nil { if err != nil {
return err return err
@ -27,14 +25,14 @@ func initRegexRedirects() error {
if r.Type == 0 { if r.Type == 0 {
r.Type = http.StatusFound r.Type = http.StatusFound
} }
regexRedirects = append(regexRedirects, r) a.regexRedirects = append(a.regexRedirects, r)
} }
return nil return nil
} }
func checkRegexRedirects(next http.Handler) http.Handler { func (a *goBlog) checkRegexRedirects(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, re := range regexRedirects { for _, re := range a.regexRedirects {
if newPath := re.From.ReplaceAllString(r.URL.Path, re.To); r.URL.Path != newPath { if newPath := re.From.ReplaceAllString(r.URL.Path, re.To); r.URL.Path != newPath {
r.URL.Path = newPath r.URL.Path = newPath
http.Redirect(w, r, r.URL.String(), re.Type) http.Redirect(w, r, r.URL.String(), re.Type)

View File

@ -45,18 +45,17 @@ const (
templateBlogroll = "blogroll" templateBlogroll = "blogroll"
) )
var templates map[string]*template.Template = map[string]*template.Template{} func (a *goBlog) initRendering() error {
a.templates = map[string]*template.Template{}
func initRendering() error {
templateFunctions := template.FuncMap{ templateFunctions := template.FuncMap{
"menu": func(blog *configBlog, id string) *menu { "menu": func(blog *configBlog, id string) *menu {
return blog.Menus[id] return blog.Menus[id]
}, },
"user": func() *configUser { "user": func() *configUser {
return appConfig.User return a.cfg.User
}, },
"md": func(content string) template.HTML { "md": func(content string) template.HTML {
htmlContent, err := renderMarkdown(content, false) htmlContent, err := a.renderMarkdown(content, false)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
return "" return ""
@ -80,16 +79,16 @@ func initRendering() error {
return p.title() return p.title()
}, },
"content": func(p *post) template.HTML { "content": func(p *post) template.HTML {
return p.html() return a.html(p)
}, },
"summary": func(p *post) string { "summary": func(p *post) string {
return p.summary() return a.summary(p)
}, },
"translations": func(p *post) []*post { "translations": func(p *post) []*post {
return p.translations() return a.translations(p)
}, },
"shorturl": func(p *post) string { "shorturl": func(p *post) string {
return p.shortURL() return a.shortPostURL(p)
}, },
// Others // Others
"dateformat": dateFormat, "dateformat": dateFormat,
@ -120,9 +119,9 @@ func initRendering() error {
} }
return d.Before(b) return d.Before(b)
}, },
"asset": assetFileName, "asset": a.assetFileName,
"assetsri": assetSRI, "assetsri": a.assetSRI,
"string": appTs.GetTemplateStringVariantFunc(), "string": a.ts.GetTemplateStringVariantFunc(),
"include": func(templateName string, data ...interface{}) (template.HTML, error) { "include": func(templateName string, data ...interface{}) (template.HTML, error) {
if len(data) == 0 || len(data) > 2 { if len(data) == 0 || len(data) > 2 {
return "", errors.New("wrong argument count") return "", errors.New("wrong argument count")
@ -134,7 +133,7 @@ func initRendering() error {
rd = &nrd rd = &nrd
} }
var buf bytes.Buffer var buf bytes.Buffer
err := templates[templateName].ExecuteTemplate(&buf, templateName, rd) err := a.templates[templateName].ExecuteTemplate(&buf, templateName, rd)
return template.HTML(buf.String()), err return template.HTML(buf.String()), err
} }
return "", errors.New("wrong arguments") return "", errors.New("wrong arguments")
@ -142,7 +141,7 @@ func initRendering() error {
"urlize": urlize, "urlize": urlize,
"sort": sortedStrings, "sort": sortedStrings,
"absolute": func(path string) string { "absolute": func(path string) string {
return appConfig.Server.PublicAddress + path return a.cfg.Server.PublicAddress + path
}, },
"blogrelative": func(blog *configBlog, path string) string { "blogrelative": func(blog *configBlog, path string) string {
return blog.getRelativePath(path) return blog.getRelativePath(path)
@ -161,7 +160,7 @@ func initRendering() error {
return parsed return parsed
}, },
"mentions": func(absolute string) []*mention { "mentions": func(absolute string) []*mention {
mentions, _ := getWebmentions(&webmentionsRequestConfig{ mentions, _ := a.db.getWebmentions(&webmentionsRequestConfig{
target: absolute, target: absolute,
status: webmentionStatusApproved, status: webmentionStatusApproved,
asc: true, asc: true,
@ -181,7 +180,7 @@ func initRendering() error {
} }
return return
}, },
"geotitle": geoTitle, "geotitle": a.db.geoTitle,
} }
baseTemplate, err := template.New("base").Funcs(templateFunctions).ParseFiles(path.Join(templatesDir, templateBase+templatesExt)) baseTemplate, err := template.New("base").Funcs(templateFunctions).ParseFiles(path.Join(templatesDir, templateBase+templatesExt))
@ -194,7 +193,7 @@ func initRendering() error {
} }
if info.Mode().IsRegular() && path.Ext(p) == templatesExt { if info.Mode().IsRegular() && path.Ext(p) == templatesExt {
if name := strings.TrimSuffix(path.Base(p), templatesExt); name != templateBase { if name := strings.TrimSuffix(path.Base(p), templatesExt); name != templateBase {
if templates[name], err = template.Must(baseTemplate.Clone()).New(name).ParseFiles(p); err != nil { if a.templates[name], err = template.Must(baseTemplate.Clone()).New(name).ParseFiles(p); err != nil {
return err return err
} }
} }
@ -219,26 +218,26 @@ type renderData struct {
TorUsed bool TorUsed bool
} }
func render(w http.ResponseWriter, r *http.Request, template string, data *renderData) { func (a *goBlog) render(w http.ResponseWriter, r *http.Request, template string, data *renderData) {
// Server timing // Server timing
t := servertiming.FromContext(r.Context()).NewMetric("r").Start() t := servertiming.FromContext(r.Context()).NewMetric("r").Start()
// Check render data // Check render data
if data.Blog == nil { if data.Blog == nil {
if len(data.BlogString) == 0 { if len(data.BlogString) == 0 {
data.BlogString = appConfig.DefaultBlog data.BlogString = a.cfg.DefaultBlog
} }
data.Blog = appConfig.Blogs[data.BlogString] data.Blog = a.cfg.Blogs[data.BlogString]
} }
if data.BlogString == "" { if data.BlogString == "" {
for s, b := range appConfig.Blogs { for s, b := range a.cfg.Blogs {
if b == data.Blog { if b == data.Blog {
data.BlogString = s data.BlogString = s
break break
} }
} }
} }
if appConfig.Server.Tor && torAddress != "" { if a.cfg.Server.Tor && a.torAddress != "" {
data.TorAddress = fmt.Sprintf("http://%v%v", torAddress, r.RequestURI) data.TorAddress = fmt.Sprintf("http://%v%v", a.torAddress, r.RequestURI)
} }
if data.Data == nil { if data.Data == nil {
data.Data = map[string]interface{}{} data.Data = map[string]interface{}{}
@ -250,23 +249,25 @@ func render(w http.ResponseWriter, r *http.Request, template string, data *rende
// Check if comments enabled // Check if comments enabled
data.CommentsEnabled = data.Blog.Comments != nil && data.Blog.Comments.Enabled data.CommentsEnabled = data.Blog.Comments != nil && data.Blog.Comments.Enabled
// Check if able to receive webmentions // Check if able to receive webmentions
data.WebmentionReceivingEnabled = appConfig.Webmention == nil || !appConfig.Webmention.DisableReceiving data.WebmentionReceivingEnabled = a.cfg.Webmention == nil || !a.cfg.Webmention.DisableReceiving
// Check if Tor request // Check if Tor request
if torUsed, ok := r.Context().Value(torUsedKey).(bool); ok && torUsed { if torUsed, ok := r.Context().Value(torUsedKey).(bool); ok && torUsed {
data.TorUsed = true data.TorUsed = true
} }
// Set content type
w.Header().Set(contentType, contentTypeHTMLUTF8)
// Minify and write response // Minify and write response
mw := minifier.Writer(contentTypeHTML, w) var tw bytes.Buffer
defer func() { err := a.templates[template].ExecuteTemplate(&tw, template, data)
_ = mw.Close() if err != nil {
}() http.Error(w, err.Error(), http.StatusInternalServerError)
err := templates[template].ExecuteTemplate(mw, template, data) return
}
_, err = writeMinified(w, contentTypeHTML, tw.Bytes())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
// Set content type
w.Header().Set(contentType, contentTypeHTMLUTF8)
// Server timing // Server timing
t.Stop() t.Stop()
} }

View File

@ -12,8 +12,8 @@ import (
"github.com/thoas/go-funk" "github.com/thoas/go-funk"
) )
func geoTitle(lat, lon float64, lang string) string { func (db *database) geoTitle(lat, lon float64, lang string) string {
ba, err := photonReverse(lat, lon, lang) ba, err := db.photonReverse(lat, lon, lang)
if err != nil { if err != nil {
return "" return ""
} }
@ -29,9 +29,9 @@ func geoTitle(lat, lon float64, lang string) string {
return strings.Join(funk.FilterString([]string{name, city, state, country}, func(s string) bool { return s != "" }), ", ") return strings.Join(funk.FilterString([]string{name, city, state, country}, func(s string) bool { return s != "" }), ", ")
} }
func photonReverse(lat, lon float64, lang string) ([]byte, error) { func (db *database) photonReverse(lat, lon float64, lang string) ([]byte, error) {
cacheKey := fmt.Sprintf("photon-%v-%v-%v", lat, lon, lang) cacheKey := fmt.Sprintf("photon-%v-%v-%v", lat, lon, lang)
cache, _ := retrievePersistentCache(cacheKey) cache, _ := db.retrievePersistentCache(cacheKey)
if cache != nil { if cache != nil {
return cache, nil return cache, nil
} }
@ -63,6 +63,6 @@ func photonReverse(lat, lon float64, lang string) ([]byte, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
_ = cachePersistently(cacheKey, ba) _ = db.cachePersistently(cacheKey, ba)
return ba, nil return ba, nil
} }

View File

@ -5,8 +5,8 @@ import (
"net/http" "net/http"
) )
func serveRobotsTXT(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveRobotsTXT(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(fmt.Sprintf("User-agent: *\nSitemap: %v", appConfig.Server.PublicAddress+sitemapPath))) _, _ = w.Write([]byte(fmt.Sprintf("User-agent: *\nSitemap: %v", a.cfg.Server.PublicAddress+sitemapPath)))
} }
func servePrivateRobotsTXT(w http.ResponseWriter, r *http.Request) { func servePrivateRobotsTXT(w http.ResponseWriter, r *http.Request) {

View File

@ -11,26 +11,26 @@ import (
const searchPlaceholder = "{search}" const searchPlaceholder = "{search}"
func serveSearch(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveSearch(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string) blog := r.Context().Value(blogContextKey).(string)
servePath := r.Context().Value(pathContextKey).(string) servePath := r.Context().Value(pathContextKey).(string)
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
if q := r.Form.Get("q"); q != "" { if q := r.Form.Get("q"); q != "" {
http.Redirect(w, r, path.Join(servePath, searchEncode(q)), http.StatusFound) http.Redirect(w, r, path.Join(servePath, searchEncode(q)), http.StatusFound)
return return
} }
render(w, r, templateSearch, &renderData{ a.render(w, r, templateSearch, &renderData{
BlogString: blog, BlogString: blog,
Canonical: appConfig.Server.PublicAddress + servePath, Canonical: a.cfg.Server.PublicAddress + servePath,
}) })
} }
func serveSearchResult(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveSearchResult(w http.ResponseWriter, r *http.Request) {
serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{
path: r.Context().Value(pathContextKey).(string) + "/" + searchPlaceholder, path: r.Context().Value(pathContextKey).(string) + "/" + searchPlaceholder,
}))) })))
} }

View File

@ -13,46 +13,47 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
) )
var loginSessionsStore, captchaSessionsStore *dbSessionStore
const ( const (
sessionCreatedOn = "created" sessionCreatedOn = "created"
sessionModifiedOn = "modified" sessionModifiedOn = "modified"
sessionExpiresOn = "expires" sessionExpiresOn = "expires"
) )
func initSessions() { func (a *goBlog) initSessions() {
deleteExpiredSessions := func() { deleteExpiredSessions := func() {
if _, err := appDb.exec("delete from sessions where expires < @now", if _, err := a.db.exec("delete from sessions where expires < @now",
sql.Named("now", time.Now().Local().String())); err != nil { sql.Named("now", time.Now().Local().String())); err != nil {
log.Println("Failed to delete expired sessions:", err.Error()) log.Println("Failed to delete expired sessions:", err.Error())
} }
} }
deleteExpiredSessions() deleteExpiredSessions()
hourlyHooks = append(hourlyHooks, deleteExpiredSessions) hourlyHooks = append(hourlyHooks, deleteExpiredSessions)
loginSessionsStore = &dbSessionStore{ a.loginSessions = &dbSessionStore{
codecs: securecookie.CodecsFromPairs(jwtKey()), codecs: securecookie.CodecsFromPairs(a.jwtKey()),
options: &sessions.Options{ options: &sessions.Options{
Secure: httpsConfigured(), Secure: a.httpsConfigured(),
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: int((7 * 24 * time.Hour).Seconds()), MaxAge: int((7 * 24 * time.Hour).Seconds()),
}, },
db: a.db,
} }
captchaSessionsStore = &dbSessionStore{ a.captchaSessions = &dbSessionStore{
codecs: securecookie.CodecsFromPairs(jwtKey()), codecs: securecookie.CodecsFromPairs(a.jwtKey()),
options: &sessions.Options{ options: &sessions.Options{
Secure: httpsConfigured(), Secure: a.httpsConfigured(),
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: int((24 * time.Hour).Seconds()), MaxAge: int((24 * time.Hour).Seconds()),
}, },
db: a.db,
} }
} }
type dbSessionStore struct { type dbSessionStore struct {
options *sessions.Options options *sessions.Options
codecs []securecookie.Codec codecs []securecookie.Codec
db *database
} }
func (s *dbSessionStore) Get(r *http.Request, name string) (*sessions.Session, error) { func (s *dbSessionStore) Get(r *http.Request, name string) (*sessions.Session, error) {
@ -101,14 +102,14 @@ func (s *dbSessionStore) Delete(r *http.Request, w http.ResponseWriter, session
for k := range session.Values { for k := range session.Values {
delete(session.Values, k) delete(session.Values, k)
} }
if _, err := appDb.exec("delete from sessions where id = @id", sql.Named("id", session.ID)); err != nil { if _, err := s.db.exec("delete from sessions where id = @id", sql.Named("id", session.ID)); err != nil {
return err return err
} }
return nil return nil
} }
func (s *dbSessionStore) load(session *sessions.Session) (err error) { func (s *dbSessionStore) load(session *sessions.Session) (err error) {
row, err := appDb.queryRow("select data, created, modified, expires from sessions where id = @id", sql.Named("id", session.ID)) row, err := s.db.queryRow("select data, created, modified, expires from sessions where id = @id", sql.Named("id", session.ID))
if err != nil { if err != nil {
return err return err
} }
@ -144,7 +145,7 @@ func (s *dbSessionStore) insert(session *sessions.Session) (err error) {
if err != nil { if err != nil {
return err return err
} }
res, err := appDb.exec("insert into sessions(data, created, modified, expires) values(@data, @created, @modified, @expires)", res, err := s.db.exec("insert into sessions(data, created, modified, expires) values(@data, @created, @modified, @expires)",
sql.Named("data", encoded), sql.Named("created", created.Local().String()), sql.Named("modified", modified.Local().String()), sql.Named("expires", expires.Local().String())) sql.Named("data", encoded), sql.Named("created", created.Local().String()), sql.Named("modified", modified.Local().String()), sql.Named("expires", expires.Local().String()))
if err != nil { if err != nil {
return err return err
@ -168,7 +169,7 @@ func (s *dbSessionStore) save(session *sessions.Session) (err error) {
if err != nil { if err != nil {
return err return err
} }
_, err = appDb.exec("update sessions set data = @data, modified = @modified where id = @id", _, err = s.db.exec("update sessions set data = @data, modified = @modified where id = @id",
sql.Named("data", encoded), sql.Named("modified", time.Now().Local().String()), sql.Named("id", session.ID)) sql.Named("data", encoded), sql.Named("modified", time.Now().Local().String()), sql.Named("id", session.ID))
if err != nil { if err != nil {
return err return err

View File

@ -4,10 +4,10 @@ import (
"net/http" "net/http"
) )
func redirectShortDomain(next http.Handler) http.Handler { func (a *goBlog) redirectShortDomain(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if appConfig.Server.shortPublicHostname != "" && r.Host == appConfig.Server.shortPublicHostname { if a.cfg.Server.shortPublicHostname != "" && r.Host == a.cfg.Server.shortPublicHostname {
http.Redirect(rw, r, appConfig.Server.PublicAddress+r.RequestURI, http.StatusMovedPermanently) http.Redirect(rw, r, a.cfg.Server.PublicAddress+r.RequestURI, http.StatusMovedPermanently)
return return
} }
next.ServeHTTP(rw, r) next.ServeHTTP(rw, r)

View File

@ -10,17 +10,17 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
func shortenPath(p string) (string, error) { func (db *database) shortenPath(p string) (string, error) {
if p == "" { if p == "" {
return "", errors.New("empty path") return "", errors.New("empty path")
} }
id := getShortPathID(p) id := db.getShortPathID(p)
if id == -1 { if id == -1 {
_, err := appDb.exec("insert or ignore into shortpath (path) values (@path)", sql.Named("path", p)) _, err := db.exec("insert or ignore into shortpath (path) values (@path)", sql.Named("path", p))
if err != nil { if err != nil {
return "", err return "", err
} }
id = getShortPathID(p) id = db.getShortPathID(p)
} }
if id == -1 { if id == -1 {
return "", errors.New("failed to retrieve short path for " + p) return "", errors.New("failed to retrieve short path for " + p)
@ -28,11 +28,11 @@ func shortenPath(p string) (string, error) {
return fmt.Sprintf("/s/%x", id), nil return fmt.Sprintf("/s/%x", id), nil
} }
func getShortPathID(p string) (id int) { func (db *database) getShortPathID(p string) (id int) {
if p == "" { if p == "" {
return -1 return -1
} }
row, err := appDb.queryRow("select id from shortpath where path = @path", sql.Named("path", p)) row, err := db.queryRow("select id from shortpath where path = @path", sql.Named("path", p))
if err != nil { if err != nil {
return -1 return -1
} }
@ -43,21 +43,21 @@ func getShortPathID(p string) (id int) {
return id return id
} }
func redirectToLongPath(rw http.ResponseWriter, r *http.Request) { func (a *goBlog) redirectToLongPath(rw http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 16, 64) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 16, 64)
if err != nil { if err != nil {
serve404(rw, r) a.serve404(rw, r)
return return
} }
row, err := appDb.queryRow("select path from shortpath where id = @id", sql.Named("id", id)) row, err := a.db.queryRow("select path from shortpath where id = @id", sql.Named("id", id))
if err != nil { if err != nil {
serve404(rw, r) a.serve404(rw, r)
return return
} }
var path string var path string
err = row.Scan(&path) err = row.Scan(&path)
if err != nil { if err != nil {
serve404(rw, r) a.serve404(rw, r)
return return
} }
http.Redirect(rw, r, path, http.StatusMovedPermanently) http.Redirect(rw, r, path, http.StatusMovedPermanently)

View File

@ -11,24 +11,24 @@ import (
const sitemapPath = "/sitemap.xml" const sitemapPath = "/sitemap.xml"
func serveSitemap(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveSitemap(w http.ResponseWriter, r *http.Request) {
sm := sitemap.New() sm := sitemap.New()
sm.Minify = true sm.Minify = true
// Blogs // Blogs
for b, bc := range appConfig.Blogs { for b, bc := range a.cfg.Blogs {
// Blog // Blog
blogPath := bc.Path blogPath := bc.Path
if blogPath == "/" { if blogPath == "/" {
blogPath = "" blogPath = ""
} }
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + blogPath, Loc: a.cfg.Server.PublicAddress + blogPath,
}) })
// Sections // Sections
for _, section := range bc.Sections { for _, section := range bc.Sections {
if section.Name != "" { if section.Name != "" {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + bc.getRelativePath("/"+section.Name), Loc: a.cfg.Server.PublicAddress + bc.getRelativePath("/"+section.Name),
}) })
} }
} }
@ -38,27 +38,27 @@ func serveSitemap(w http.ResponseWriter, r *http.Request) {
// Taxonomy // Taxonomy
taxPath := bc.getRelativePath("/" + taxonomy.Name) taxPath := bc.getRelativePath("/" + taxonomy.Name)
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + taxPath, Loc: a.cfg.Server.PublicAddress + taxPath,
}) })
// Values // Values
if taxValues, err := allTaxonomyValues(b, taxonomy.Name); err == nil { if taxValues, err := a.db.allTaxonomyValues(b, taxonomy.Name); err == nil {
for _, tv := range taxValues { for _, tv := range taxValues {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + taxPath + "/" + urlize(tv), Loc: a.cfg.Server.PublicAddress + taxPath + "/" + urlize(tv),
}) })
} }
} }
} }
} }
// Year / month archives // Year / month archives
if dates, err := allPublishedDates(b); err == nil { if dates, err := a.db.allPublishedDates(b); err == nil {
already := map[string]bool{} already := map[string]bool{}
for _, d := range dates { for _, d := range dates {
// Year // Year
yearPath := bc.getRelativePath("/" + fmt.Sprintf("%0004d", d.year)) yearPath := bc.getRelativePath("/" + fmt.Sprintf("%0004d", d.year))
if !already[yearPath] { if !already[yearPath] {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + yearPath, Loc: a.cfg.Server.PublicAddress + yearPath,
}) })
already[yearPath] = true already[yearPath] = true
} }
@ -66,7 +66,7 @@ func serveSitemap(w http.ResponseWriter, r *http.Request) {
monthPath := yearPath + "/" + fmt.Sprintf("%02d", d.month) monthPath := yearPath + "/" + fmt.Sprintf("%02d", d.month)
if !already[monthPath] { if !already[monthPath] {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + monthPath, Loc: a.cfg.Server.PublicAddress + monthPath,
}) })
already[monthPath] = true already[monthPath] = true
} }
@ -74,7 +74,7 @@ func serveSitemap(w http.ResponseWriter, r *http.Request) {
dayPath := monthPath + "/" + fmt.Sprintf("%02d", d.day) dayPath := monthPath + "/" + fmt.Sprintf("%02d", d.day)
if !already[dayPath] { if !already[dayPath] {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + dayPath, Loc: a.cfg.Server.PublicAddress + dayPath,
}) })
already[dayPath] = true already[dayPath] = true
} }
@ -82,7 +82,7 @@ func serveSitemap(w http.ResponseWriter, r *http.Request) {
genericMonthPath := blogPath + "/x/" + fmt.Sprintf("%02d", d.month) genericMonthPath := blogPath + "/x/" + fmt.Sprintf("%02d", d.month)
if !already[genericMonthPath] { if !already[genericMonthPath] {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + genericMonthPath, Loc: a.cfg.Server.PublicAddress + genericMonthPath,
}) })
already[genericMonthPath] = true already[genericMonthPath] = true
} }
@ -90,7 +90,7 @@ func serveSitemap(w http.ResponseWriter, r *http.Request) {
genericMonthDayPath := genericMonthPath + "/" + fmt.Sprintf("%02d", d.day) genericMonthDayPath := genericMonthPath + "/" + fmt.Sprintf("%02d", d.day)
if !already[genericMonthDayPath] { if !already[genericMonthDayPath] {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + genericMonthDayPath, Loc: a.cfg.Server.PublicAddress + genericMonthDayPath,
}) })
already[genericMonthDayPath] = true already[genericMonthDayPath] = true
} }
@ -99,38 +99,38 @@ func serveSitemap(w http.ResponseWriter, r *http.Request) {
// Photos // Photos
if bc.Photos != nil && bc.Photos.Enabled { if bc.Photos != nil && bc.Photos.Enabled {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + bc.getRelativePath(bc.Photos.Path), Loc: a.cfg.Server.PublicAddress + bc.getRelativePath(bc.Photos.Path),
}) })
} }
// Search // Search
if bc.Search != nil && bc.Search.Enabled { if bc.Search != nil && bc.Search.Enabled {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + bc.getRelativePath(bc.Search.Path), Loc: a.cfg.Server.PublicAddress + bc.getRelativePath(bc.Search.Path),
}) })
} }
// Stats // Stats
if bc.BlogStats != nil && bc.BlogStats.Enabled { if bc.BlogStats != nil && bc.BlogStats.Enabled {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + bc.getRelativePath(bc.BlogStats.Path), Loc: a.cfg.Server.PublicAddress + bc.getRelativePath(bc.BlogStats.Path),
}) })
} }
// Blogroll // Blogroll
if bc.Blogroll != nil && bc.Blogroll.Enabled { if bc.Blogroll != nil && bc.Blogroll.Enabled {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + bc.getRelativePath(bc.Blogroll.Path), Loc: a.cfg.Server.PublicAddress + bc.getRelativePath(bc.Blogroll.Path),
}) })
} }
// Custom pages // Custom pages
for _, cp := range bc.CustomPages { for _, cp := range bc.CustomPages {
sm.Add(&sitemap.URL{ sm.Add(&sitemap.URL{
Loc: appConfig.Server.PublicAddress + cp.Path, Loc: a.cfg.Server.PublicAddress + cp.Path,
}) })
} }
} }
// Posts // Posts
if posts, err := getPosts(&postsRequestConfig{status: statusPublished}); err == nil { if posts, err := a.db.getPosts(&postsRequestConfig{status: statusPublished}); err == nil {
for _, p := range posts { for _, p := range posts {
item := &sitemap.URL{Loc: p.fullURL()} item := &sitemap.URL{Loc: a.fullPostURL(p)}
var lastMod time.Time var lastMod time.Time
if p.Updated != "" { if p.Updated != "" {
lastMod, _ = dateparse.ParseLocal(p.Updated) lastMod, _ = dateparse.ParseLocal(p.Updated)

View File

@ -28,7 +28,7 @@ func allStaticPaths() (paths []string) {
} }
// Gets only called by registered paths // Gets only called by registered paths
func serveStaticFile(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveStaticFile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d,s-max-age=%d,stale-while-revalidate=%d", appConfig.Cache.Expiration, appConfig.Cache.Expiration/3, appConfig.Cache.Expiration)) w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d,s-max-age=%d,stale-while-revalidate=%d", a.cfg.Cache.Expiration, a.cfg.Cache.Expiration/3, a.cfg.Cache.Expiration))
http.ServeFile(w, r, filepath.Join(staticFolder, r.URL.Path)) http.ServeFile(w, r, filepath.Join(staticFolder, r.URL.Path))
} }

View File

@ -4,17 +4,17 @@ import "net/http"
const taxonomyContextKey = "taxonomy" const taxonomyContextKey = "taxonomy"
func serveTaxonomy(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveTaxonomy(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string) blog := r.Context().Value(blogContextKey).(string)
tax := r.Context().Value(taxonomyContextKey).(*taxonomy) tax := r.Context().Value(taxonomyContextKey).(*taxonomy)
allValues, err := allTaxonomyValues(blog, tax.Name) allValues, err := a.db.allTaxonomyValues(blog, tax.Name)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
render(w, r, templateTaxonomy, &renderData{ a.render(w, r, templateTaxonomy, &renderData{
BlogString: blog, BlogString: blog,
Canonical: appConfig.Server.PublicAddress + r.URL.Path, Canonical: a.cfg.Server.PublicAddress + r.URL.Path,
Data: map[string]interface{}{ Data: map[string]interface{}{
"Taxonomy": tax, "Taxonomy": tax,
"ValueGroups": groupStrings(allValues), "ValueGroups": groupStrings(allValues),

View File

@ -13,40 +13,39 @@ import (
const telegramBaseURL = "https://api.telegram.org/bot" const telegramBaseURL = "https://api.telegram.org/bot"
func initTelegram() { func (a *goBlog) initTelegram() {
enable := false enable := false
for _, b := range appConfig.Blogs { for _, b := range a.cfg.Blogs {
if tg := b.Telegram; tg != nil && tg.Enabled && tg.BotToken != "" && tg.ChatID != "" { if tg := b.Telegram; tg != nil && tg.Enabled && tg.BotToken != "" && tg.ChatID != "" {
enable = true enable = true
} }
} }
if enable { if enable {
postPostHooks = append(postPostHooks, func(p *post) { a.pPostHooks = append(a.pPostHooks, func(p *post) {
if p.isPublishedSectionPost() { if p.isPublishedSectionPost() {
p.tgPost() tgPost(a.cfg.Blogs[p.Blog].Telegram, p.title(), a.fullPostURL(p), a.shortPostURL(p))
} }
}) })
} }
} }
func (p *post) tgPost() { func tgPost(tg *configTelegram, title, fullURL, shortURL string) {
tg := appConfig.Blogs[p.Blog].Telegram
if tg == nil || !tg.Enabled || tg.BotToken == "" || tg.ChatID == "" { if tg == nil || !tg.Enabled || tg.BotToken == "" || tg.ChatID == "" {
return return
} }
replacer := strings.NewReplacer("<", "&lt;", ">", "&gt;", "&", "&amp;") replacer := strings.NewReplacer("<", "&lt;", ">", "&gt;", "&", "&amp;")
var message bytes.Buffer var message bytes.Buffer
if title := p.title(); title != "" { if title != "" {
message.WriteString(replacer.Replace(title)) message.WriteString(replacer.Replace(title))
message.WriteString("\n\n") message.WriteString("\n\n")
} }
if tg.InstantViewHash != "" { if tg.InstantViewHash != "" {
message.WriteString("<a href=\"https://t.me/iv?rhash=" + tg.InstantViewHash + "&url=" + url.QueryEscape(p.fullURL()) + "\">") message.WriteString("<a href=\"https://t.me/iv?rhash=" + tg.InstantViewHash + "&url=" + url.QueryEscape(fullURL) + "\">")
message.WriteString(replacer.Replace(p.shortURL())) message.WriteString(replacer.Replace(shortURL))
message.WriteString("</a>") message.WriteString("</a>")
} else { } else {
message.WriteString("<a href=\"" + p.shortURL() + "\">") message.WriteString("<a href=\"" + shortURL + "\">")
message.WriteString(replacer.Replace(p.shortURL())) message.WriteString(replacer.Replace(shortURL))
message.WriteString("</a>") message.WriteString("</a>")
} }
if err := sendTelegramMessage(message.String(), "HTML", tg.BotToken, tg.ChatID); err != nil { if err := sendTelegramMessage(message.String(), "HTML", tg.BotToken, tg.ChatID); err != nil {

View File

@ -16,24 +16,23 @@ import (
const assetsFolder = "templates/assets" const assetsFolder = "templates/assets"
var assetFileNames map[string]string = map[string]string{}
var assetFiles map[string]*assetFile = map[string]*assetFile{}
type assetFile struct { type assetFile struct {
contentType string contentType string
sri string sri string
body []byte body []byte
} }
func initTemplateAssets() (err error) { func (a *goBlog) initTemplateAssets() (err error) {
a.assetFileNames = map[string]string{}
a.assetFiles = map[string]*assetFile{}
err = filepath.Walk(assetsFolder, func(path string, info os.FileInfo, err error) error { err = filepath.Walk(assetsFolder, func(path string, info os.FileInfo, err error) error {
if info.Mode().IsRegular() { if info.Mode().IsRegular() {
compiled, err := compileAsset(path) compiled, err := a.compileAsset(path)
if err != nil { if err != nil {
return err return err
} }
if compiled != "" { if compiled != "" {
assetFileNames[strings.TrimPrefix(path, assetsFolder+"/")] = compiled a.assetFileNames[strings.TrimPrefix(path, assetsFolder+"/")] = compiled
} }
} }
return nil return nil
@ -44,21 +43,22 @@ func initTemplateAssets() (err error) {
return nil return nil
} }
func compileAsset(name string) (string, error) { func (a *goBlog) compileAsset(name string) (string, error) {
content, err := os.ReadFile(name) content, err := os.ReadFile(name)
if err != nil { if err != nil {
return "", err return "", err
} }
ext := path.Ext(name) ext := path.Ext(name)
compiledExt := ext compiledExt := ext
m := getMinifier()
switch ext { switch ext {
case ".js": case ".js":
content, err = minifier.Bytes("application/javascript", content) content, err = m.Bytes("application/javascript", content)
if err != nil { if err != nil {
return "", err return "", err
} }
case ".css": case ".css":
content, err = minifier.Bytes("text/css", content) content, err = m.Bytes("text/css", content)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -76,7 +76,7 @@ func compileAsset(name string) (string, error) {
// SRI // SRI
sriHash := fmt.Sprintf("sha512-%s", base64.StdEncoding.EncodeToString(sha512Hash.Sum(nil))) sriHash := fmt.Sprintf("sha512-%s", base64.StdEncoding.EncodeToString(sha512Hash.Sum(nil)))
// Create struct // Create struct
assetFiles[compiledFileName] = &assetFile{ a.assetFiles[compiledFileName] = &assetFile{
contentType: mime.TypeByExtension(compiledExt), contentType: mime.TypeByExtension(compiledExt),
sri: sriHash, sri: sriHash,
body: content, body: content,
@ -85,27 +85,27 @@ func compileAsset(name string) (string, error) {
} }
// Function for templates // Function for templates
func assetFileName(fileName string) string { func (a *goBlog) assetFileName(fileName string) string {
return "/" + assetFileNames[fileName] return "/" + a.assetFileNames[fileName]
} }
func assetSRI(fileName string) string { func (a *goBlog) assetSRI(fileName string) string {
return assetFiles[assetFileNames[fileName]].sri return a.assetFiles[a.assetFileNames[fileName]].sri
} }
func allAssetPaths() []string { func (a *goBlog) allAssetPaths() []string {
var paths []string var paths []string
for _, name := range assetFileNames { for _, name := range a.assetFileNames {
paths = append(paths, "/"+name) paths = append(paths, "/"+name)
} }
return paths return paths
} }
// Gets only called by registered paths // Gets only called by registered paths
func serveAsset(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveAsset(w http.ResponseWriter, r *http.Request) {
af, ok := assetFiles[strings.TrimPrefix(r.URL.Path, "/")] af, ok := a.assetFiles[strings.TrimPrefix(r.URL.Path, "/")]
if !ok { if !ok {
serve404(w, r) a.serve404(w, r)
return return
} }
w.Header().Set("Cache-Control", "public,max-age=31536000,immutable") w.Header().Set("Cache-Control", "public,max-age=31536000,immutable")

View File

@ -4,13 +4,11 @@ import (
ts "git.jlel.se/jlelse/template-strings" ts "git.jlel.se/jlelse/template-strings"
) )
var appTs *ts.TemplateStrings func (a *goBlog) initTemplateStrings() (err error) {
func initTemplateStrings() (err error) {
var blogLangs []string var blogLangs []string
for _, b := range appConfig.Blogs { for _, b := range a.cfg.Blogs {
blogLangs = append(blogLangs, b.Lang) blogLangs = append(blogLangs, b.Lang)
} }
appTs, err = ts.InitTemplateStrings("templates/strings", ".yaml", "default", blogLangs...) a.ts, err = ts.InitTemplateStrings("templates/strings", ".yaml", "default", blogLangs...)
return err return err
} }

12
tor.go
View File

@ -16,13 +16,9 @@ import (
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
) )
var (
torAddress string
)
var torUsedKey requestContextKey = "tor" var torUsedKey requestContextKey = "tor"
func startOnionService(h http.Handler) error { func (a *goBlog) startOnionService(h http.Handler) error {
torDataPath, err := filepath.Abs("data/tor") torDataPath, err := filepath.Abs("data/tor")
if err != nil { if err != nil {
return err return err
@ -76,10 +72,10 @@ func startOnionService(h http.Handler) error {
return err return err
} }
defer onion.Close() defer onion.Close()
torAddress = onion.String() a.torAddress = onion.String()
log.Println("Onion service published on http://" + torAddress) log.Println("Onion service published on http://" + a.torAddress)
// Clear cache // Clear cache
purgeCache() a.cache.purge()
// Serve handler // Serve handler
s := &http.Server{ s := &http.Server{
Handler: middleware.WithValue(torUsedKey, true)(h), Handler: middleware.WithValue(torUsedKey, true)(h),

View File

@ -30,32 +30,32 @@ type mention struct {
Status webmentionStatus Status webmentionStatus
} }
func initWebmention() { func (a *goBlog) initWebmention() {
// Add hooks // Add hooks
hookFunc := func(p *post) { hookFunc := func(p *post) {
if p.Status == statusPublished { if p.Status == statusPublished {
_ = p.sendWebmentions() _ = a.sendWebmentions(p)
} }
} }
postPostHooks = append(postPostHooks, hookFunc) a.pPostHooks = append(a.pPostHooks, hookFunc)
postUpdateHooks = append(postUpdateHooks, hookFunc) a.pUpdateHooks = append(a.pUpdateHooks, hookFunc)
postDeleteHooks = append(postDeleteHooks, hookFunc) a.pDeleteHooks = append(a.pDeleteHooks, hookFunc)
// Start verifier // Start verifier
initWebmentionQueue() a.initWebmentionQueue()
} }
func handleWebmention(w http.ResponseWriter, r *http.Request) { func (a *goBlog) handleWebmention(w http.ResponseWriter, r *http.Request) {
m, err := extractMention(r) m, err := extractMention(r)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
if !isAllowedHost(httptest.NewRequest(http.MethodGet, m.Target, nil), appConfig.Server.publicHostname) { if !isAllowedHost(httptest.NewRequest(http.MethodGet, m.Target, nil), a.cfg.Server.publicHostname) {
serveError(w, r, "target not allowed", http.StatusBadRequest) a.serveError(w, r, "target not allowed", http.StatusBadRequest)
return return
} }
if err = queueMention(m); err != nil { if err = a.queueMention(m); err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)
@ -82,9 +82,9 @@ func extractMention(r *http.Request) (*mention, error) {
}, nil }, nil
} }
func webmentionExists(source, target string) bool { func (db *database) webmentionExists(source, target string) bool {
result := 0 result := 0
row, err := appDb.queryRow("select exists(select 1 from webmentions where source = ? and target = ?)", source, target) row, err := db.queryRow("select exists(select 1 from webmentions where source = ? and target = ?)", source, target)
if err != nil { if err != nil {
return false return false
} }
@ -94,26 +94,26 @@ func webmentionExists(source, target string) bool {
return result == 1 return result == 1
} }
func createWebmention(source, target string) (err error) { func (a *goBlog) createWebmention(source, target string) (err error) {
return queueMention(&mention{ return a.queueMention(&mention{
Source: source, Source: source,
Target: unescapedPath(target), Target: unescapedPath(target),
Created: time.Now().Unix(), Created: time.Now().Unix(),
}) })
} }
func deleteWebmention(id int) error { func (db *database) deleteWebmention(id int) error {
_, err := appDb.exec("delete from webmentions where id = @id", sql.Named("id", id)) _, err := db.exec("delete from webmentions where id = @id", sql.Named("id", id))
return err return err
} }
func approveWebmention(id int) error { func (db *database) approveWebmention(id int) error {
_, err := appDb.exec("update webmentions set status = ? where id = ?", webmentionStatusApproved, id) _, err := db.exec("update webmentions set status = ? where id = ?", webmentionStatusApproved, id)
return err return err
} }
func reverifyWebmention(id int) error { func (a *goBlog) reverifyWebmention(id int) error {
m, err := getWebmentions(&webmentionsRequestConfig{ m, err := a.db.getWebmentions(&webmentionsRequestConfig{
id: id, id: id,
limit: 1, limit: 1,
}) })
@ -121,7 +121,7 @@ func reverifyWebmention(id int) error {
return err return err
} }
if len(m) > 0 { if len(m) > 0 {
err = queueMention(m[0]) err = a.queueMention(m[0])
} }
return err return err
} }
@ -169,10 +169,10 @@ func buildWebmentionsQuery(config *webmentionsRequestConfig) (query string, args
return query, args return query, args
} }
func getWebmentions(config *webmentionsRequestConfig) ([]*mention, error) { func (db *database) getWebmentions(config *webmentionsRequestConfig) ([]*mention, error) {
mentions := []*mention{} mentions := []*mention{}
query, args := buildWebmentionsQuery(config) query, args := buildWebmentionsQuery(config)
rows, err := appDb.query(query, args...) rows, err := db.query(query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -187,10 +187,10 @@ func getWebmentions(config *webmentionsRequestConfig) ([]*mention, error) {
return mentions, nil return mentions, nil
} }
func countWebmentions(config *webmentionsRequestConfig) (count int, err error) { func (db *database) countWebmentions(config *webmentionsRequestConfig) (count int, err error) {
query, params := buildWebmentionsQuery(config) query, params := buildWebmentionsQuery(config)
query = "select count(*) from (" + query + ")" query = "select count(*) from (" + query + ")"
row, err := appDb.queryRow(query, params...) row, err := db.queryRow(query, params...)
if err != nil { if err != nil {
return return
} }

View File

@ -14,11 +14,12 @@ import (
type webmentionPaginationAdapter struct { type webmentionPaginationAdapter struct {
config *webmentionsRequestConfig config *webmentionsRequestConfig
nums int64 nums int64
db *database
} }
func (p *webmentionPaginationAdapter) Nums() (int64, error) { func (p *webmentionPaginationAdapter) Nums() (int64, error) {
if p.nums == 0 { if p.nums == 0 {
nums, _ := countWebmentions(p.config) nums, _ := p.db.countWebmentions(p.config)
p.nums = int64(nums) p.nums = int64(nums)
} }
return p.nums, nil return p.nums, nil
@ -29,12 +30,12 @@ func (p *webmentionPaginationAdapter) Slice(offset, length int, data interface{}
modifiedConfig.offset = offset modifiedConfig.offset = offset
modifiedConfig.limit = length modifiedConfig.limit = length
wms, err := getWebmentions(&modifiedConfig) wms, err := p.db.getWebmentions(&modifiedConfig)
reflect.ValueOf(data).Elem().Set(reflect.ValueOf(&wms).Elem()) reflect.ValueOf(data).Elem().Set(reflect.ValueOf(&wms).Elem())
return err return err
} }
func webmentionAdmin(w http.ResponseWriter, r *http.Request) { func (a *goBlog) webmentionAdmin(w http.ResponseWriter, r *http.Request) {
pageNoString := chi.URLParam(r, "page") pageNoString := chi.URLParam(r, "page")
pageNo, _ := strconv.Atoi(pageNoString) pageNo, _ := strconv.Atoi(pageNoString)
var status webmentionStatus = "" var status webmentionStatus = ""
@ -48,12 +49,12 @@ func webmentionAdmin(w http.ResponseWriter, r *http.Request) {
p := paginator.New(&webmentionPaginationAdapter{config: &webmentionsRequestConfig{ p := paginator.New(&webmentionPaginationAdapter{config: &webmentionsRequestConfig{
status: status, status: status,
sourcelike: sourcelike, sourcelike: sourcelike,
}}, 10) }, db: a.db}, 10)
p.SetPage(pageNo) p.SetPage(pageNo)
var mentions []*mention var mentions []*mention
err := p.Results(&mentions) err := p.Results(&mentions)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
// Navigation // Navigation
@ -91,7 +92,7 @@ func webmentionAdmin(w http.ResponseWriter, r *http.Request) {
query = "?" + params.Encode() query = "?" + params.Encode()
} }
// Render // Render
render(w, r, templateWebmentionAdmin, &renderData{ a.render(w, r, templateWebmentionAdmin, &renderData{
Data: map[string]interface{}{ Data: map[string]interface{}{
"Mentions": mentions, "Mentions": mentions,
"HasPrev": hasPrev, "HasPrev": hasPrev,
@ -102,45 +103,45 @@ func webmentionAdmin(w http.ResponseWriter, r *http.Request) {
}) })
} }
func webmentionAdminDelete(w http.ResponseWriter, r *http.Request) { func (a *goBlog) webmentionAdminDelete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.FormValue("mentionid")) id, err := strconv.Atoi(r.FormValue("mentionid"))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
err = deleteWebmention(id) err = a.db.deleteWebmention(id)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
purgeCache() a.cache.purge()
http.Redirect(w, r, ".", http.StatusFound) http.Redirect(w, r, ".", http.StatusFound)
} }
func webmentionAdminApprove(w http.ResponseWriter, r *http.Request) { func (a *goBlog) webmentionAdminApprove(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.FormValue("mentionid")) id, err := strconv.Atoi(r.FormValue("mentionid"))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
err = approveWebmention(id) err = a.db.approveWebmention(id)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
purgeCache() a.cache.purge()
http.Redirect(w, r, ".", http.StatusFound) http.Redirect(w, r, ".", http.StatusFound)
} }
func webmentionAdminReverify(w http.ResponseWriter, r *http.Request) { func (a *goBlog) webmentionAdminReverify(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.FormValue("mentionid")) id, err := strconv.Atoi(r.FormValue("mentionid"))
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return return
} }
err = reverifyWebmention(id) err = a.reverifyWebmention(id)
if err != nil { if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, ".", http.StatusFound) http.Redirect(w, r, ".", http.StatusFound)

View File

@ -13,40 +13,44 @@ import (
"github.com/tomnomnom/linkheader" "github.com/tomnomnom/linkheader"
) )
func (p *post) sendWebmentions() error { func (a *goBlog) sendWebmentions(p *post) error {
if wm := appConfig.Webmention; wm != nil && wm.DisableSending { if wm := a.cfg.Webmention; wm != nil && wm.DisableSending {
// Just ignore the mentions // Just ignore the mentions
return nil return nil
} }
links := []string{} links := []string{}
contentLinks, err := allLinksFromHTML(strings.NewReader(string(p.html())), p.fullURL()) contentLinks, err := allLinksFromHTML(strings.NewReader(string(a.html(p))), a.fullPostURL(p))
if err != nil { if err != nil {
return err return err
} }
links = append(links, contentLinks...) links = append(links, contentLinks...)
links = append(links, p.firstParameter("link"), p.firstParameter(appConfig.Micropub.LikeParam), p.firstParameter(appConfig.Micropub.ReplyParam), p.firstParameter(appConfig.Micropub.BookmarkParam)) links = append(links, p.firstParameter("link"), p.firstParameter(a.cfg.Micropub.LikeParam), p.firstParameter(a.cfg.Micropub.ReplyParam), p.firstParameter(a.cfg.Micropub.BookmarkParam))
for _, link := range funk.UniqString(links) { for _, link := range funk.UniqString(links) {
if link == "" { if link == "" {
continue continue
} }
// Internal mention // Internal mention
if strings.HasPrefix(link, appConfig.Server.PublicAddress) { if strings.HasPrefix(link, a.cfg.Server.PublicAddress) {
// Save mention directly // Save mention directly
if err := createWebmention(p.fullURL(), link); err != nil { if err := a.createWebmention(a.fullPostURL(p), link); err != nil {
log.Println("Failed to create webmention:", err.Error()) log.Println("Failed to create webmention:", err.Error())
} }
continue continue
} }
// External mention // External mention
if pm := appConfig.PrivateMode; pm != nil && pm.Enabled { if pm := a.cfg.PrivateMode; pm != nil && pm.Enabled {
// Private mode, don't send external mentions // Private mode, don't send external mentions
continue continue
} }
if wm := a.cfg.Webmention; wm != nil && wm.DisableSending {
// Just ignore the mention
continue
}
endpoint := discoverEndpoint(link) endpoint := discoverEndpoint(link)
if endpoint == "" { if endpoint == "" {
continue continue
} }
if err = sendWebmention(endpoint, p.fullURL(), link); err != nil { if err = sendWebmention(endpoint, a.fullPostURL(p), link); err != nil {
log.Println("Sending webmention to " + link + " failed") log.Println("Sending webmention to " + link + " failed")
continue continue
} }
@ -56,10 +60,6 @@ func (p *post) sendWebmentions() error {
} }
func sendWebmention(endpoint, source, target string) error { func sendWebmention(endpoint, source, target string) error {
if wm := appConfig.Webmention; wm != nil && wm.DisableSending {
// Just ignore the mention
return nil
}
req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(url.Values{ req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(url.Values{
"source": []string{source}, "source": []string{source},
"target": []string{target}, "target": []string{target},

View File

@ -20,10 +20,10 @@ import (
"willnorris.com/go/microformats" "willnorris.com/go/microformats"
) )
func initWebmentionQueue() { func (a *goBlog) initWebmentionQueue() {
go func() { go func() {
for { for {
qi, err := peekQueue("wm") qi, err := a.db.peekQueue("wm")
if err != nil { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
continue continue
@ -32,14 +32,14 @@ func initWebmentionQueue() {
err = gob.NewDecoder(bytes.NewReader(qi.content)).Decode(&m) err = gob.NewDecoder(bytes.NewReader(qi.content)).Decode(&m)
if err != nil { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
_ = qi.dequeue() _ = a.db.dequeue(qi)
continue continue
} }
err = m.verifyMention() err = a.verifyMention(&m)
if err != nil { if err != nil {
log.Println(fmt.Sprintf("Failed to verify webmention from %s to %s: %s", m.Source, m.Target, err.Error())) log.Println(fmt.Sprintf("Failed to verify webmention from %s to %s: %s", m.Source, m.Target, err.Error()))
} }
err = qi.dequeue() err = a.db.dequeue(qi)
if err != nil { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
} }
@ -51,26 +51,30 @@ func initWebmentionQueue() {
}() }()
} }
func queueMention(m *mention) error { func (a *goBlog) queueMention(m *mention) error {
if wm := appConfig.Webmention; wm != nil && wm.DisableReceiving { if wm := a.cfg.Webmention; wm != nil && wm.DisableReceiving {
return errors.New("webmention receiving disabled") return errors.New("webmention receiving disabled")
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := gob.NewEncoder(&buf).Encode(m); err != nil { if err := gob.NewEncoder(&buf).Encode(m); err != nil {
return err return err
} }
return enqueue("wm", buf.Bytes(), time.Now()) return a.db.enqueue("wm", buf.Bytes(), time.Now())
} }
func (m *mention) verifyMention() error { func (a *goBlog) verifyMention(m *mention) error {
req, err := http.NewRequest(http.MethodGet, m.Source, nil) req, err := http.NewRequest(http.MethodGet, m.Source, nil)
if err != nil { if err != nil {
return err return err
} }
var resp *http.Response var resp *http.Response
if strings.HasPrefix(m.Source, appConfig.Server.PublicAddress) { if strings.HasPrefix(m.Source, a.cfg.Server.PublicAddress) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
d.ServeHTTP(rec, req.WithContext(context.WithValue(req.Context(), loggedInKey, true))) for a.d == nil {
// Server not yet started
time.Sleep(10 * time.Second)
}
a.d.ServeHTTP(rec, req.WithContext(context.WithValue(req.Context(), loggedInKey, true)))
resp = rec.Result() resp = rec.Result()
} else { } else {
req.Header.Set(userAgent, appUserAgent) req.Header.Set(userAgent, appUserAgent)
@ -82,7 +86,7 @@ func (m *mention) verifyMention() error {
err = m.verifyReader(resp.Body) err = m.verifyReader(resp.Body)
_ = resp.Body.Close() _ = resp.Body.Close()
if err != nil { if err != nil {
_, err := appDb.exec("delete from webmentions where source = @source and target = @target", sql.Named("source", m.Source), sql.Named("target", m.Target)) _, err := a.db.exec("delete from webmentions where source = @source and target = @target", sql.Named("source", m.Source), sql.Named("target", m.Target))
return err return err
} }
if len(m.Content) > 500 { if len(m.Content) > 500 {
@ -92,13 +96,13 @@ func (m *mention) verifyMention() error {
m.Title = m.Title[0:57] + "…" m.Title = m.Title[0:57] + "…"
} }
newStatus := webmentionStatusVerified newStatus := webmentionStatusVerified
if webmentionExists(m.Source, m.Target) { if a.db.webmentionExists(m.Source, m.Target) {
_, err = appDb.exec("update webmentions set status = @status, title = @title, content = @content, author = @author where source = @source and target = @target", _, err = a.db.exec("update webmentions set status = @status, title = @title, content = @content, author = @author where source = @source and target = @target",
sql.Named("status", newStatus), sql.Named("title", m.Title), sql.Named("content", m.Content), sql.Named("author", m.Author), sql.Named("source", m.Source), sql.Named("target", m.Target)) sql.Named("status", newStatus), sql.Named("title", m.Title), sql.Named("content", m.Content), sql.Named("author", m.Author), sql.Named("source", m.Source), sql.Named("target", m.Target))
} else { } else {
_, err = appDb.exec("insert into webmentions (source, target, created, status, title, content, author) values (@source, @target, @created, @status, @title, @content, @author)", _, err = a.db.exec("insert into webmentions (source, target, created, status, title, content, author) values (@source, @target, @created, @status, @title, @content, @author)",
sql.Named("source", m.Source), sql.Named("target", m.Target), sql.Named("created", m.Created), sql.Named("status", newStatus), sql.Named("title", m.Title), sql.Named("content", m.Content), sql.Named("author", m.Author)) sql.Named("source", m.Source), sql.Named("target", m.Target), sql.Named("created", m.Created), sql.Named("status", newStatus), sql.Named("title", m.Title), sql.Named("content", m.Content), sql.Named("author", m.Author))
sendNotification(fmt.Sprintf("New webmention from %s to %s", m.Source, m.Target)) a.sendNotification(fmt.Sprintf("New webmention from %s to %s", m.Source, m.Target))
} }
return err return err
} }