From d13b0a53945f086fc839945fb2418a4a125290c8 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Else Date: Mon, 26 Oct 2020 17:37:31 +0100 Subject: [PATCH] Add basic ActivityPub support and other things --- activityPub.go | 327 +++++++++++++++++++++++ activityStreams.go | 154 +++++++++++ activitystreams.go | 124 --------- blogs.go | 13 + config.go | 8 + database.go | 27 -- databaseMigrations.go | 9 +- go.mod | 10 +- go.sum | 21 +- http.go | 75 ++++-- indieauth.go => indieAuth.go | 0 indieauthserver.go => indieAuthServer.go | 0 main.go | 7 + posts.go | 25 +- postsDb.go | 2 +- render.go | 8 +- utils.go | 6 +- 17 files changed, 596 insertions(+), 220 deletions(-) create mode 100644 activityPub.go create mode 100644 activityStreams.go delete mode 100644 activitystreams.go create mode 100644 blogs.go rename indieauth.go => indieAuth.go (100%) rename indieauthserver.go => indieAuthServer.go (100%) diff --git a/activityPub.go b/activityPub.go new file mode 100644 index 0000000..4b1cc2c --- /dev/null +++ b/activityPub.go @@ -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(``)) +} + +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 +} diff --git a/activityStreams.go b/activityStreams.go new file mode 100644 index 0000000..43b58f5 --- /dev/null +++ b/activityStreams.go @@ -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) + +} diff --git a/activitystreams.go b/activitystreams.go deleted file mode 100644 index ee86784..0000000 --- a/activitystreams.go +++ /dev/null @@ -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"` -} diff --git a/blogs.go b/blogs.go new file mode 100644 index 0000000..d36efaf --- /dev/null +++ b/blogs.go @@ -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 +} diff --git a/config.go b/config.go index 9083838..49bca57 100644 --- a/config.go +++ b/config.go @@ -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 { diff --git a/database.go b/database.go index 4649ec8..7284432 100644 --- a/database.go +++ b/database.go @@ -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 - } -} diff --git a/databaseMigrations.go b/databaseMigrations.go index ae90a06..5f31e4a 100644 --- a/databaseMigrations.go +++ b/databaseMigrations.go @@ -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 }, diff --git a/go.mod b/go.mod index 6f279ef..a25d9bc 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 566b9cc..2a22108 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/http.go b/http.go index e474631..fb468ef 100644 --- a/http.go +++ b/http.go @@ -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 { diff --git a/indieauth.go b/indieAuth.go similarity index 100% rename from indieauth.go rename to indieAuth.go diff --git a/indieauthserver.go b/indieAuthServer.go similarity index 100% rename from indieauthserver.go rename to indieAuthServer.go diff --git a/main.go b/main.go index 439eade..88dfda4 100644 --- a/main.go +++ b/main.go @@ -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() diff --git a/posts.go b/posts.go index 14573be..8cd0cf1 100644 --- a/posts.go +++ b/posts.go @@ -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) { diff --git a/postsDb.go b/postsDb.go index 74a4ae6..375a663 100644 --- a/postsDb.go +++ b/postsDb.go @@ -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) diff --git a/render.go b/render.go index 718acaa..7069a7a 100644 --- a/render.go +++ b/render.go @@ -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{}{} diff --git a/utils.go b/utils.go index 7c742af..5f64218 100644 --- a/utils.go +++ b/utils.go @@ -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))] }