Add basic ActivityPub support and other things

This commit is contained in:
Jan-Lukas Else 2020-10-26 17:37:31 +01:00
parent b9856175b9
commit d13b0a5394
17 changed files with 596 additions and 220 deletions

327
activityPub.go Normal file
View File

@ -0,0 +1,327 @@
package main
import (
"bytes"
"context"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/go-chi/chi"
"github.com/go-fed/httpsig"
)
var (
apPrivateKey *rsa.PrivateKey
apPostSigner httpsig.Signer
apPostSignMutex *sync.Mutex = &sync.Mutex{}
)
func initActivityPub() error {
pkfile, err := ioutil.ReadFile(appConfig.ActivityPub.KeyPath)
if err != nil {
return err
}
privateKeyDecoded, _ := pem.Decode(pkfile)
if privateKeyDecoded == nil {
return errors.New("failed to decode private key")
}
apPrivateKey, err = x509.ParsePKCS1PrivateKey(privateKeyDecoded.Bytes)
if err != nil {
return err
}
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
digestAlgorithm := httpsig.DigestSha256
headersToSign := []string{httpsig.RequestTarget, "date", "host", "digest"}
apPostSigner, _, err = httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 0)
if err != nil {
return err
}
return nil
}
func apHandleWebfinger(w http.ResponseWriter, r *http.Request) {
re, err := regexp.Compile(`^acct:(.*)@` + regexp.QuoteMeta(appConfig.Server.Domain) + `$`)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
name := re.ReplaceAllString(r.URL.Query().Get("resource"), "$1")
blog := appConfig.Blogs[name]
if blog == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
w.Header().Set(contentType, "application/jrd+json"+charsetUtf8Suffix)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"subject": "acct:" + name + "@" + appConfig.Server.Domain,
"links": []map[string]string{
{
"rel": "self",
"type": contentTypeAS,
"href": blog.apIri(),
},
},
})
}
func apHandleInbox(w http.ResponseWriter, r *http.Request) {
blogName := chi.URLParam(r, "blog")
blog := appConfig.Blogs[blogName]
if blog == nil {
http.Error(w, "Inbox not found", http.StatusNotFound)
return
}
activity := make(map[string]interface{})
err := json.NewDecoder(r.Body).Decode(&activity)
_ = r.Body.Close()
if err != nil {
http.Error(w, "Failed to decode body", http.StatusBadRequest)
return
}
switch activity["type"] {
case "Follow":
apAccept(blogName, blog, activity)
case "Undo":
{
if object, ok := activity["object"].(map[string]interface{}); ok {
if objectType, ok := object["type"].(string); ok && objectType == "Follow" {
if iri, ok := object["actor"].(string); ok && iri == activity["actor"] {
_ = apRemoveFollower(blogName, iri)
}
}
}
}
case "Create":
{
if object, ok := activity["object"].(map[string]interface{}); ok {
inReplyTo, hasReplyToString := object["inReplyTo"].(string)
id, hadID := object["id"].(string)
if hasReplyToString && hadID && len(inReplyTo) > 0 && len(id) > 0 && strings.Contains(inReplyTo, blog.apIri()) {
// It's an ActivityPub reply
// TODO: Save reply to database
} else if hadID && len(id) > 0 {
// May be a mention
// TODO: Save to database
}
}
}
case "Delete":
{
if object, ok := activity["object"].(string); ok && len(object) > 0 && activity["actor"] == object {
_ = apRemoveFollower(blogName, object)
}
}
case "Like":
case "Announce":
{
// TODO: Save to database
}
}
// Return 201
w.WriteHeader(http.StatusCreated)
}
func handleWellKnownHostMeta(w http.ResponseWriter, r *http.Request) {
w.Header().Set(contentType, "application/xrd+xml"+charsetUtf8Suffix)
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="https://` + r.Host + `/.well-known/webfinger?resource={uri}"/></XRD>`))
}
func apGetRemoteActor(iri string) (*asPerson, error) {
req, err := http.NewRequest(http.MethodGet, iri, nil)
if err != nil {
return nil, err
}
req.Header.Add("Accept", contentTypeAS)
req.Header.Add("User-Agent", "GoBlog")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if !apRequestIsSuccess(resp.StatusCode) {
return nil, err
}
actor := &asPerson{}
err = json.NewDecoder(resp.Body).Decode(actor)
defer resp.Body.Close()
if err != nil {
return nil, err
}
return actor, nil
}
func apGetAllFollowers(blog string) (map[string]string, error) {
rows, err := appDb.Query("select follower, inbox from activitypub_followers where blog = ?", blog)
if err != nil {
return nil, err
}
followers := map[string]string{}
for rows.Next() {
var follower, inbox string
err = rows.Scan(&follower, &inbox)
if err != nil {
return nil, err
}
followers[follower] = inbox
}
return nil, nil
}
func apAddFollower(blog, follower, inbox string) error {
startWritingToDb()
defer finishWritingToDb()
_, err := appDb.Exec("insert or replace into activitypub_followers (blog, follower, inbox) values (?, ?, ?)", blog, follower, inbox)
if err != nil {
return err
}
return nil
}
func apRemoveFollower(blog, follower string) error {
startWritingToDb()
defer finishWritingToDb()
_, err := appDb.Exec("delete from activitypub_followers where blog = ? and follower = ?", blog, follower)
if err != nil {
return err
}
return nil
}
func apPost(p *post) {
n := p.toASNote()
create := make(map[string]interface{})
create["@context"] = asContext
create["actor"] = appConfig.Blogs[p.Blog].apIri()
create["id"] = appConfig.Server.PublicAddress + p.Path
create["published"] = n.Published
create["type"] = "Create"
create["object"] = n
apSendToAllFollowers(p.Blog, create)
}
func apUpdate(p *post) {
// TODO
}
func apDelete(p *post) {
// TODO
}
func apAccept(blogName string, blog *configBlog, follow map[string]interface{}) {
// it's a follow, write it down
newFollower := follow["actor"].(string)
log.Println("New follow request:", newFollower)
// check we aren't following ourselves
if newFollower == follow["object"] {
// actor and object are equal
return
}
follower, err := apGetRemoteActor(newFollower)
if err != nil {
// Couldn't retrieve remote actor info
log.Println("Failed to retrieve remote actor info:", newFollower)
return
}
// Add or update follower
apAddFollower(blogName, follower.ID, follower.Inbox)
// remove @context from the inner activity
delete(follow, "@context")
accept := make(map[string]interface{})
accept["@context"] = asContext
accept["to"] = follow["actor"]
_, accept["id"] = apNewID(blog)
accept["actor"] = blog.apIri()
accept["object"] = follow
accept["type"] = "Accept"
err = apSendSigned(blog, accept, follower.Inbox)
if err != nil {
log.Printf("Failed to accept: %s\n%s\n", follower.ID, err.Error())
return
}
log.Println("Follower accepted:", follower.ID)
}
func apSendToAllFollowers(blog string, activity interface{}) {
followers, err := apGetAllFollowers(blog)
if err != nil {
log.Println("Failed to retrieve followers:", err.Error())
return
}
apSendTo(appConfig.Blogs[blog], activity, followers)
}
func apSendTo(blog *configBlog, activity interface{}, followers map[string]string) {
for _, i := range followers {
go func(inbox string) {
_ = apSendSigned(blog, activity, inbox)
}(i)
}
}
func apSendSigned(blog *configBlog, activity interface{}, to string) error {
// Marshal to json
body, err := json.Marshal(activity)
if err != nil {
return err
}
// Copy body to sign it
bodyCopy := make([]byte, len(body))
copy(bodyCopy, body)
// Create request context with timeout
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// Create request
r, err := http.NewRequestWithContext(ctx, http.MethodPost, to, bytes.NewBuffer(body))
if err != nil {
return err
}
iri, err := url.Parse(to)
if err != nil {
return err
}
r.Header.Add("Accept-Charset", "utf-8")
r.Header.Add("Date", time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05")+" GMT")
r.Header.Add("User-Agent", "GoBlog")
r.Header.Add("Accept", contentTypeASUTF8)
r.Header.Add(contentType, contentTypeASUTF8)
r.Header.Add("Host", iri.Host)
// Sign request
apPostSignMutex.Lock()
err = apPostSigner.SignRequest(apPrivateKey, blog.apIri()+"#main-key", r, bodyCopy)
apPostSignMutex.Unlock()
if err != nil {
return err
}
// Do request
resp, err := http.DefaultClient.Do(r)
if !apRequestIsSuccess(resp.StatusCode) {
body, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()
return fmt.Errorf("signed request failed with status %d: %s", resp.StatusCode, string(body))
}
return err
}
func apNewID(blog *configBlog) (hash string, url string) {
return hash, blog.apIri() + generateRandomString(16)
}
func (b *configBlog) apIri() string {
return appConfig.Server.PublicAddress + b.Path
}
func apRequestIsSuccess(code int) bool {
return code == http.StatusOK || code == http.StatusCreated || code == http.StatusAccepted || code == http.StatusNoContent
}

154
activityStreams.go Normal file
View File

@ -0,0 +1,154 @@
package main
import (
"crypto/x509"
"encoding/pem"
"net/http"
"strings"
"time"
"github.com/araddon/dateparse"
)
var asContext = []string{"https://www.w3.org/ns/activitystreams"}
func manipulateAsPath(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if lowerAccept := strings.ToLower(r.Header.Get("Accept")); (strings.Contains(lowerAccept, contentTypeAS) || strings.Contains(lowerAccept, "application/ld+json")) && !strings.Contains(lowerAccept, contentTypeHTML) {
// Is ActivityStream, add ".as" to differentiate cache and also trigger as function
r.URL.Path += ".as"
}
next.ServeHTTP(rw, r)
})
}
type asNote struct {
Context interface{} `json:"@context,omitempty"`
To []string `json:"to,omitempty"`
InReplyTo string `json:"inReplyTo,omitempty"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Content string `json:"content,omitempty"`
MediaType string `json:"mediaType,omitempty"`
Attachment []*asAttachment `json:"attachment,omitempty"`
Published string `json:"published,omitempty"`
Updated string `json:"updated,omitempty"`
ID string `json:"id,omitempty"`
URL string `json:"url,omitempty"`
AttributedTo string `json:"attributedTo,omitempty"`
}
type asPerson struct {
Context interface{} `json:"@context,omitempty"`
ID string `json:"id,omitempty"`
URL string `json:"url,omitempty"`
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Summary string `json:"summary,omitempty"`
PreferredUsername string `json:"preferredUsername,omitempty"`
Icon *asAttachment `json:"icon,omitempty"`
Inbox string `json:"inbox,omitempty"`
PublicKey *asPublicKey `json:"publicKey,omitempty"`
}
type asAttachment struct {
Type string `json:"type,omitempty"`
URL string `json:"url,omitempty"`
}
type asPublicKey struct {
ID string `json:"id,omitempty"`
Owner string `json:"owner,omitempty"`
PublicKeyPem string `json:"publicKeyPem,omitempty"`
}
func (p *post) serveActivityStreams(w http.ResponseWriter) {
// Send JSON
w.Header().Add(contentType, contentTypeASUTF8)
_ = json.NewEncoder(w).Encode(p.toASNote())
}
func (p *post) toASNote() *asNote {
// Create a Note object
as := &asNote{
Context: asContext,
To: []string{"https://www.w3.org/ns/activitystreams#Public"},
MediaType: contentTypeHTML,
ID: appConfig.Server.PublicAddress + p.Path,
URL: appConfig.Server.PublicAddress + p.Path,
AttributedTo: appConfig.Blogs[p.Blog].apIri(),
}
// Name and Type
if title := p.title(); title != "" {
as.Name = title
as.Type = "Article"
} else {
as.Type = "Note"
}
// Content
as.Content = string(p.html())
// Attachments
if images := p.Parameters[appConfig.Blogs[p.Blog].ActivityStreams.ImagesParameter]; len(images) > 0 {
for _, image := range images {
as.Attachment = append(as.Attachment, &asAttachment{
Type: "Image",
URL: image,
})
}
}
// Dates
dateFormat := "2006-01-02T15:04:05-07:00"
if p.Published != "" {
if t, err := dateparse.ParseIn(p.Published, time.Local); err == nil {
as.Published = t.Format(dateFormat)
}
}
if p.Updated != "" {
if t, err := dateparse.ParseIn(p.Updated, time.Local); err == nil {
as.Updated = t.Format(dateFormat)
}
}
// Reply
if replyLink := p.firstParameter(appConfig.Blogs[p.Blog].ActivityStreams.ReplyParameter); replyLink != "" {
as.InReplyTo = replyLink
}
return as
}
func (b *configBlog) serveActivityStreams(blog string, w http.ResponseWriter) {
publicKeyDer, err := x509.MarshalPKIXPublicKey(&apPrivateKey.PublicKey)
if err != nil {
http.Error(w, "Failed to marshal public key", http.StatusInternalServerError)
return
}
// Send JSON
w.Header().Add(contentType, contentTypeASUTF8)
asBlog := &asPerson{
Context: asContext,
Type: "Person",
ID: b.apIri(),
URL: b.apIri(),
Name: b.Title,
Summary: b.Description,
PreferredUsername: blog,
Inbox: appConfig.Server.PublicAddress + "/activitypub/inbox/" + blog,
PublicKey: &asPublicKey{
Owner: appConfig.Server.PublicAddress + b.Path,
ID: appConfig.Server.PublicAddress + b.Path + "#main-key",
PublicKeyPem: string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Headers: nil,
Bytes: publicKeyDer,
})),
},
}
// Add profile picture
if appConfig.User.Picture != "" {
asBlog.Icon = &asAttachment{
Type: "Image",
URL: appConfig.User.Picture,
}
}
_ = json.NewEncoder(w).Encode(asBlog)
}

View File

@ -1,124 +0,0 @@
package main
import (
"net/http"
"strings"
"time"
"github.com/araddon/dateparse"
)
func manipulateAsPath(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if lowerAccept := strings.ToLower(r.Header.Get("Accept")); (strings.Contains(lowerAccept, "application/activity+json") || strings.Contains(lowerAccept, "application/ld+json")) && !strings.Contains(lowerAccept, "text/html") {
// Is ActivityStream, add ".as" to differentiate cache and also trigger as function
r.URL.Path += ".as"
}
next.ServeHTTP(rw, r)
})
}
type asPost struct {
Context []string `json:"@context"`
To []string `json:"to"`
InReplyTo string `json:"inReplyTo,omitempty"`
Name string `json:"name,omitempty"`
Type string `json:"type"`
Content string `json:"content"`
MediaType string `json:"mediaType"`
Attachment []*asAttachment `json:"attachment,omitempty"`
Published string `json:"published"`
Updated string `json:"updated,omitempty"`
ID string `json:"id"`
URL string `json:"url"`
AttributedTo string `json:"attributedTo"`
}
type asAttachment struct {
Type string `json:"type"`
URL string `json:"url"`
}
func servePostActivityStreams(w http.ResponseWriter, r *http.Request) {
// Remove ".as" from path again
r.URL.Path = strings.TrimSuffix(r.URL.Path, ".as")
// Fetch post from db
p, err := getPost(slashTrimmedPath(r))
if err == errPostNotFound {
serve404(w, r)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Create a Note object
as := &asPost{
Context: []string{"https://www.w3.org/ns/activitystreams"},
To: []string{"https://www.w3.org/ns/activitystreams#Public"},
MediaType: "text/html",
ID: appConfig.Server.PublicAddress + p.Path,
URL: appConfig.Server.PublicAddress + p.Path,
AttributedTo: appConfig.Server.PublicAddress,
}
// Name and Type
if title := p.title(); title != "" {
as.Name = title
as.Type = "Article"
} else {
as.Type = "Note"
}
// Content
as.Content = string(p.html())
// Attachments
if images := p.Parameters[appConfig.Blogs[p.Blog].ActivityStreams.ImagesParameter]; len(images) > 0 {
for _, image := range images {
as.Attachment = append(as.Attachment, &asAttachment{
Type: "Image",
URL: image,
})
}
}
// Dates
dateFormat := "2006-01-02T15:04:05-07:00"
if p.Published != "" {
if t, err := dateparse.ParseIn(p.Published, time.Local); err == nil {
as.Published = t.Format(dateFormat)
}
}
if p.Updated != "" {
if t, err := dateparse.ParseIn(p.Updated, time.Local); err == nil {
as.Published = t.Format(dateFormat)
}
}
// Reply
if replyLink := p.firstParameter(appConfig.Blogs[p.Blog].ActivityStreams.ReplyParameter); replyLink != "" {
as.InReplyTo = replyLink
}
// Send JSON
w.Header().Add(contentType, contentTypeJSONUTF8)
_ = json.NewEncoder(w).Encode(as)
}
type asPerson struct {
Context []string `json:"@context"`
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Summary string `json:"summary"`
Attachment []struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
} `json:"attachment"`
PreferredUsername string `json:"preferredUsername"`
Icon struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"icon"`
Inbox string `json:"inbox"`
PublicKey struct {
ID string `json:"id"`
Owner string `json:"owner"`
PublicKeyPem string `json:"publicKeyPem"`
} `json:"publicKey"`
}

13
blogs.go Normal file
View File

@ -0,0 +1,13 @@
package main
import "strings"
func (blog *configBlog) getRelativePath(path string) string {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if blog.Path != "/" {
return blog.Path + path
}
return path
}

View File

@ -18,6 +18,7 @@ type config struct {
Hugo *configHugo `mapstructure:"hugo"`
Micropub *configMicropub `mapstructure:"micropub"`
PathRedirects []*configRegexRedirect `mapstructure:"pathRedirects"`
ActivityPub *configActivityPub `mapstructure:"activityPub"`
}
type configServer struct {
@ -102,6 +103,7 @@ type configUser struct {
Nick string `mapstructure:"nick"`
Name string `mapstructure:"name"`
Password string `mapstructure:"password"`
Picture string `mapstructure:"picture"`
}
type configHooks struct {
@ -145,6 +147,11 @@ type configRegexRedirect struct {
To string `mapstructure:"to"`
}
type configActivityPub struct {
Enabled bool `mapstructure:"enabled"`
KeyPath string `mapstructure:"keyPath"`
}
var appConfig = &config{}
func initConfig() error {
@ -176,6 +183,7 @@ func initConfig() error {
viper.SetDefault("micropub.audioParam", "audio")
viper.SetDefault("micropub.photoParam", "images")
viper.SetDefault("micropub.photoDescriptionParam", "imagealts")
viper.SetDefault("activityPub.keyPath", "data/private.pem")
// Unmarshal config
err = viper.Unmarshal(appConfig)
if err != nil {

View File

@ -1,14 +1,10 @@
package main
import (
"bufio"
"database/sql"
"log"
"os"
"sync"
_ "github.com/mattn/go-sqlite3"
"github.com/schollz/sqlite3dump"
)
var appDb *sql.DB
@ -28,7 +24,6 @@ func startWritingToDb() {
func finishWritingToDb() {
appDbWriteMutex.Unlock()
dumpDb()
}
func closeDb() error {
@ -41,25 +36,3 @@ func vacuumDb() {
defer finishWritingToDb()
_, _ = appDb.Exec("VACUUM;")
}
func dumpDb() {
appDbWriteMutex.Lock()
defer appDbWriteMutex.Unlock()
f, err := os.OpenFile(appConfig.Db.File+".dump", os.O_RDWR|os.O_CREATE, 0644)
defer f.Close()
if err != nil {
log.Println("Failed to open dump file:", err.Error())
return
}
w := bufio.NewWriter(f)
err = sqlite3dump.DumpDB(appDb, w)
if err != nil {
log.Println("Failed to dump database:", err.Error())
return
}
err = w.Flush()
if err != nil {
log.Println("Failed to write dump:", err.Error())
return
}
}

View File

@ -22,15 +22,8 @@ func migrateDb() error {
CREATE TABLE indieauthauth (time text not null, code text not null, me text not null, client text not null, redirect text not null, scope text not null);
CREATE TABLE indieauthtoken (time text not null, token text not null, me text not null, client text not null, scope text not null);
CREATE INDEX index_iat_token on indieauthtoken (token);
`)
return err
},
},
&migrator.Migration{
Name: "00002",
Func: func(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE autocert (key text not null primary key, data blob not null, created text not null);
CREATE TABLE activitypub_followers (blog text not null, follower text not null, inbox text not null, primary key (blog, follower));
`)
return err
},

10
go.mod
View File

@ -8,6 +8,7 @@ require (
github.com/andybalholm/cascadia v1.2.0 // indirect
github.com/araddon/dateparse v0.0.0-20201001162425-8aadafed4dc4
github.com/go-chi/chi v4.1.2+incompatible
github.com/go-fed/httpsig v1.0.1-0.20200711113112-812070f75b67
github.com/goodsign/monday v1.0.1-0.20201007115131-c065b60ec611
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect
github.com/gorilla/feeds v1.1.1
@ -24,21 +25,20 @@ require (
github.com/mitchellh/mapstructure v1.3.3 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pelletier/go-toml v1.8.1 // indirect
github.com/schollz/sqlite3dump v1.2.4
github.com/smartystreets/assertions v1.2.0 // indirect
github.com/snabb/sitemap v1.0.0
github.com/spf13/afero v1.4.1 // indirect
github.com/spf13/cast v1.3.1
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.7.1
github.com/tdewolff/minify/v2 v2.9.9
github.com/tdewolff/minify/v2 v2.9.10
github.com/vcraescu/go-paginator v0.0.0-20200923074551-426b20f3ae8a
github.com/yuin/goldmark v1.2.1
github.com/yuin/goldmark-emoji v1.0.1
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0 // indirect
golang.org/x/sync v0.0.0-20201008141435-b3e1573b7520
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 // indirect
golang.org/x/net v0.0.0-20201026091529-146b70c837a4 // indirect
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9
golang.org/x/sys v0.0.0-20201026133411-418715ba6fdd // indirect
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
gopkg.in/ini.v1 v1.62.0 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect

21
go.sum
View File

@ -63,6 +63,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-fed/httpsig v1.0.1-0.20200711113112-812070f75b67 h1:T4Tv75EmaqlHubh4+cK2eSySNvNA8O6gRB6qwuzfOCM=
github.com/go-fed/httpsig v1.0.1-0.20200711113112-812070f75b67/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@ -179,7 +181,6 @@ github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
@ -230,8 +231,6 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/schollz/sqlite3dump v1.2.4 h1:b3dgcKLsHZhF6OsB2EK+e/oA77vh4P/45TAh2R35OFI=
github.com/schollz/sqlite3dump v1.2.4/go.mod h1:SEajZA5udi52Taht5xQYlFfHwr7AIrqPrLDrAoFv17o=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
@ -275,8 +274,8 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tdewolff/minify/v2 v2.9.9 h1:5POLhoyTEWNNHADzlwH83AhvpKVAdpS7fOfaWIOWOTw=
github.com/tdewolff/minify/v2 v2.9.9/go.mod h1:U1Nc+/YBSB0FPEarqcgkYH3Ep4DNyyIbOyl5P4eWMuo=
github.com/tdewolff/minify/v2 v2.9.10 h1:p+ifTTl+JMFFLDYNAm7nxQ9XuCG10HTW00wlPAZ7aoE=
github.com/tdewolff/minify/v2 v2.9.10/go.mod h1:U1Nc+/YBSB0FPEarqcgkYH3Ep4DNyyIbOyl5P4eWMuo=
github.com/tdewolff/parse/v2 v2.5.5 h1:b7ICJa4I/54JQGEGgTte8DiyJPKcC5g8V773QMzkeUM=
github.com/tdewolff/parse/v2 v2.5.5/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho=
github.com/tdewolff/test v1.0.6 h1:76mzYJQ83Op284kMT+63iCNCI7NEERsIN8dLM+RiKr4=
@ -343,8 +342,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0 h1:5kGOVHlq0euqwzgTC9Vu15p6fV1Wi0ArVi8da2urnVg=
golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201026091529-146b70c837a4 h1:awiuzyrRjJDb+OXi9ceHO3SDxVoN3JER57mhtqkdQBs=
golang.org/x/net v0.0.0-20201026091529-146b70c837a4/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -354,8 +353,8 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201008141435-b3e1573b7520 h1:Bx6FllMpG4NWDOfhMBz1VR2QYNp/SAOHPIAsaVmxfPo=
golang.org/x/sync v0.0.0-20201008141435-b3e1573b7520/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -375,8 +374,8 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c h1:UIcGWL6/wpCfyGuJnRFJRurA+yj8RrW7Q6x2YMCXt6c=
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 h1:5jaG59Zhd+8ZXe8C+lgiAGqkOaZBruqrWclLkgAww34=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201026133411-418715ba6fdd h1:+7OQgGrJBd80e8ASl94G3xIpokulXXzB/dikfre4ho0=
golang.org/x/sys v0.0.0-20201026133411-418715ba6fdd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=

75
http.go
View File

@ -7,31 +7,39 @@ import (
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"golang.org/x/crypto/acme/autocert"
)
const contentType = "Content-Type"
const charsetUtf8Suffix = "; charset=utf-8"
const contentTypeHTML = "text/html"
const contentTypeHTMLUTF8 = contentTypeHTML + charsetUtf8Suffix
const contentTypeJSON = "application/json"
const contentTypeJSONUTF8 = contentTypeJSON + charsetUtf8Suffix
const contentTypeWWWForm = "application/x-www-form-urlencoded"
const contentTypeMultipartForm = "multipart/form-data"
const (
contentType = "Content-Type"
var d *dynamicHandler
charsetUtf8Suffix = "; charset=utf-8"
contentTypeHTML = "text/html"
contentTypeJSON = "application/json"
contentTypeWWWForm = "application/x-www-form-urlencoded"
contentTypeMultipartForm = "multipart/form-data"
contentTypeAS = "application/activity+json"
contentTypeHTMLUTF8 = contentTypeHTML + charsetUtf8Suffix
contentTypeJSONUTF8 = contentTypeJSON + charsetUtf8Suffix
contentTypeASUTF8 = contentTypeAS + charsetUtf8Suffix
)
var (
d *dynamicHandler
)
func startServer() (err error) {
d = newDynamicHandler()
h, err := buildHandler()
d = &dynamicHandler{}
err = reloadRouter()
if err != nil {
return
}
d.swapHandler(h)
localAddress := ":" + strconv.Itoa(appConfig.Server.Port)
if appConfig.Server.PublicHTTPS {
cache, err := newAutocertCache()
@ -83,6 +91,9 @@ func buildHandler() (http.Handler, error) {
r.Use(middleware.Compress(flate.DefaultCompression))
r.Use(middleware.StripSlashes)
r.Use(middleware.GetHead)
if !appConfig.Cache.Enable {
r.Use(middleware.NoCache)
}
// Profiler
if appConfig.Server.Debug {
@ -119,14 +130,27 @@ func buildHandler() (http.Handler, error) {
indieauthRouter.Post("/token", indieAuthToken)
})
// ActivityPub and stuff
if appConfig.ActivityPub.Enabled {
r.Post("/activitypub/inbox/{blog}", apHandleInbox)
r.Get("/.well-known/webfinger", apHandleWebfinger)
r.Get("/.well-known/host-meta", handleWellKnownHostMeta)
}
// Posts
allPostPaths, err := allPostPaths()
if err != nil {
return nil, err
}
var postMW []func(http.Handler) http.Handler
if appConfig.ActivityPub.Enabled {
postMW = []func(http.Handler) http.Handler{manipulateAsPath, cacheMiddleware, minifier.Middleware}
} else {
postMW = []func(http.Handler) http.Handler{cacheMiddleware, minifier.Middleware}
}
for _, path := range allPostPaths {
if path != "" {
r.With(manipulateAsPath, cacheMiddleware, minifier.Middleware).Get(path, servePost)
r.With(postMW...).Get(path, servePost)
}
}
@ -191,7 +215,13 @@ func buildHandler() (http.Handler, error) {
}
// Blog
r.With(cacheMiddleware, minifier.Middleware).Get(fullBlogPath, serveHome(blog, blogPath))
var mw []func(http.Handler) http.Handler
if appConfig.ActivityPub.Enabled {
mw = []func(http.Handler) http.Handler{manipulateAsPath, cacheMiddleware, minifier.Middleware}
} else {
mw = []func(http.Handler) http.Handler{cacheMiddleware, minifier.Middleware}
}
r.With(mw...).Get(fullBlogPath, serveHome(blog, blogPath))
r.With(cacheMiddleware, minifier.Middleware).Get(fullBlogPath+feedPath, serveHome(blog, blogPath))
r.With(cacheMiddleware, minifier.Middleware).Get(blogPath+paginationPath, serveHome(blog, blogPath))
@ -228,24 +258,15 @@ func securityHeaders(next http.Handler) http.Handler {
}
type dynamicHandler struct {
realHandler http.Handler
changeMutex *sync.Mutex
}
func newDynamicHandler() *dynamicHandler {
return &dynamicHandler{
changeMutex: &sync.Mutex{},
}
realHandler atomic.Value
}
func (d *dynamicHandler) swapHandler(h http.Handler) {
d.changeMutex.Lock()
d.realHandler = h
d.changeMutex.Unlock()
d.realHandler.Store(h)
}
func (d *dynamicHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
d.realHandler.ServeHTTP(w, r)
d.realHandler.Load().(http.Handler).ServeHTTP(w, r)
}
func slashTrimmedPath(r *http.Request) string {

View File

@ -50,6 +50,13 @@ func main() {
return
}
initCache()
if appConfig.ActivityPub.Enabled {
err = initActivityPub()
if err != nil {
log.Fatal(err)
return
}
}
// Start cron hooks
startHourlyHooks()

View File

@ -29,9 +29,9 @@ type post struct {
}
func servePost(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".as") {
servePostActivityStreams(w, r)
return
as := strings.HasSuffix(r.URL.Path, ".as")
if as {
r.URL.Path = strings.TrimSuffix(r.URL.Path, ".as")
}
path := slashTrimmedPath(r)
p, err := getPost(path)
@ -42,6 +42,10 @@ func servePost(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if as {
p.serveActivityStreams(w)
return
}
render(w, templatePost, &renderData{
blogString: p.Blog,
Data: p,
@ -87,10 +91,17 @@ func (p *postPaginationAdapter) Slice(offset, length int, data interface{}) erro
}
func serveHome(blog string, path string) func(w http.ResponseWriter, r *http.Request) {
return serveIndex(&indexConfig{
blog: blog,
path: path,
})
return func(w http.ResponseWriter, r *http.Request) {
as := strings.HasSuffix(r.URL.Path, ".as")
if as {
appConfig.Blogs[blog].serveActivityStreams(blog, w)
return
}
serveIndex(&indexConfig{
blog: blog,
path: path,
})(w, r)
}
}
func serveSection(blog string, path string, section *section) func(w http.ResponseWriter, r *http.Request) {

View File

@ -62,7 +62,7 @@ func (p *post) checkPost() error {
p.Section = appConfig.Blogs[p.Blog].DefaultSection
}
if p.Slug == "" {
random := generateRandomString(now, 5)
random := generateRandomString(5)
p.Slug = fmt.Sprintf("%v-%02d-%02d-%v", now.Year(), int(now.Month()), now.Day(), random)
}
published, _ := dateparse.ParseIn(p.Published, time.Local)

View File

@ -98,13 +98,7 @@ func initRendering() error {
"urlize": urlize,
"sort": sortedStrings,
"blogRelative": func(blog *configBlog, path string) string {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if blog.Path != "/" {
return blog.Path + path
}
return path
return blog.getRelativePath(path)
},
"jsonFile": func(filename string) *map[string]interface{} {
parsed := &map[string]interface{}{}

View File

@ -26,10 +26,10 @@ func sortedStrings(s []string) []string {
return s
}
func generateRandomString(now time.Time, n int) string {
rand.Seed(now.UnixNano())
func generateRandomString(chars int) string {
rand.Seed(time.Now().UnixNano())
letters := []rune("abcdefghijklmnopqrstuvwxyz")
b := make([]rune, n)
b := make([]rune, chars)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}