Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,43 @@ activityPub:

2. On your old Fediverse account, initiate the move to your GoBlog account using your old server's migration feature.

**Moving Between GoBlog Domains:**

If you're changing your GoBlog's domain (e.g., from `goblog.example` to `newgoblog.example`) while keeping the same GoBlog instance:

1. Add the old domain to your config file under `server.alternateDomains`:

```yaml
server:
publicAddress: https://newgoblog.example
alternateDomains:
- goblog.example
```

2. Configure your reverse proxy to serve both old and new domains (see reverse proxy configuration section)
3. Restart GoBlog to apply the config changes
4. Run the domain move command:

```bash
./GoBlog activitypub domainmove goblog.example newgoblog.example
```

This command:
- Sends Move activities from the old-domain actors to the new-domain actors for ALL blogs
- Notifies all followers to migrate to the new domain
- Keeps both domains accessible: old domain serves ActivityPub/Webfinger normally but redirects other requests

After the migration is complete and followers have migrated:
- Remove the old domain from `server.alternateDomains` in your config
- Restart GoBlog

**Important Notes:**
- All blogs share the same domain in GoBlog, so the move affects all blogs
- When using a reverse proxy, ensure both domains are configured correctly (both old and new)
- The `publicHttps` option will work correctly for both domains
- Old domain continues to serve ActivityPub/Webfinger requests to maintain federation
- Non-ActivityPub requests to old domain are redirected to new domain with HTTP 301

**Migration from GoBlog to another Fediverse server:**

If you're moving away from GoBlog to another Fediverse server:
Expand Down Expand Up @@ -1157,6 +1194,28 @@ Sends Move activities to all followers, instructing them that your account has m

Clears the `movedTo` setting from a blog's ActivityPub profile. Use this if you need to undo a migration or if you accidentally set the wrong target.

### Domain Move

```bash
./GoBlog --config ./config/config.yml activitypub domainmove old.example.com new.example.com
```

Moves all blogs' ActivityPub presence from one domain to another within the same GoBlog instance. This is used when changing your blog's domain while keeping the same GoBlog installation.

**Before running:**
1. Add old domain to `server.alternateDomains` in config
2. Configure your reverse proxy to serve both old and new domains
3. Update `server.publicAddress` to the new domain
4. Restart GoBlog

**What it does:**
- Sends Move activities to all followers of all blogs
- Old domain continues serving ActivityPub/Webfinger requests
- Non-ActivityPub requests to old domain redirect to new domain

**After migration:**
Remove the old domain from `server.alternateDomains` in config and restart.

### Profiling

```bash
Expand Down
133 changes: 129 additions & 4 deletions activityPub.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,22 +123,47 @@ func (a *goBlog) prepareWebfinger() {
a.webfingerResources[acct] = blog
a.webfingerResources[a.apIri(blog)] = blog
a.webfingerAccts[a.apIri(blog)] = acct

// Also add alternate domains for webfinger resolution
for _, altDomain := range a.cfg.Server.AlternateDomains {
altAcct := "acct:" + name + "@" + altDomain
altIri := a.apIriForDomain(blog, altDomain)
a.webfingerResources[altAcct] = blog
a.webfingerResources[altIri] = blog
a.webfingerAccts[altIri] = altAcct
}
}
}

func (a *goBlog) apHandleWebfinger(w http.ResponseWriter, r *http.Request) {
blog, ok := a.webfingerResources[r.URL.Query().Get("resource")]
resource := r.URL.Query().Get("resource")
blog, ok := a.webfingerResources[resource]
if !ok {
a.serveError(w, r, "Resource not found", http.StatusNotFound)
return
}
apIri := a.apIri(blog)

// Determine which domain to use in the response based on the Host header
requestedDomain := r.Host
if requestedDomain == "" {
requestedDomain = a.cfg.Server.publicHostname
}

// Use the requested domain for generating the IRI
apIri := a.apIriForDomain(blog, requestedDomain)
acct, ok := a.webfingerAccts[apIri]
if !ok {
// Fallback to default domain if requested domain not found
apIri = a.apIri(blog)
acct = a.webfingerAccts[apIri]
}

// Encode
pr, pw := io.Pipe()
go func() {
_ = pw.CloseWithError(json.NewEncoder(pw).Encode(map[string]any{
"subject": a.webfingerAccts[apIri],
"aliases": []string{a.webfingerAccts[apIri], apIri},
"subject": acct,
"aliases": []string{acct, apIri},
"links": []map[string]string{
{
"rel": "self", "type": contenttype.AS, "href": apIri,
Expand Down Expand Up @@ -610,6 +635,15 @@ func (a *goBlog) apIri(b *configBlog) string {
return a.getFullAddress(b.getRelativePath(""))
}

func (a *goBlog) apIriForDomain(b *configBlog, domain string) string {
// Build IRI using specified domain instead of configured domain
scheme := "http"
if a.cfg.Server.PublicHTTPS || strings.HasPrefix(a.cfg.Server.PublicAddress, "https") {
scheme = "https"
}
return scheme + "://" + domain + b.getRelativePath("")
}

func (a *goBlog) apAPIri(b *configBlog) ap.IRI {
return ap.IRI(a.apIri(b))
}
Expand Down Expand Up @@ -862,3 +896,94 @@ func (a *goBlog) apLoadRemoteIRI(blog string, id ap.IRI) (ap.Item, error) {

return it, nil
}

func (a *goBlog) apDomainMove(oldDomain, newDomain string) error {
// Validate domains
if oldDomain == "" || newDomain == "" {
return fmt.Errorf("both old and new domains must be specified")
}
if oldDomain == newDomain {
return fmt.Errorf("old and new domains must be different")
}

// Check that newDomain matches the current configured domain
if newDomain != a.cfg.Server.publicHostname {
a.info("Warning: new domain does not match configured publicHostname", "new", newDomain, "configured", a.cfg.Server.publicHostname)
}

a.info("Starting domain move for all blogs", "from", oldDomain, "to", newDomain)

// Note: The old domain should be added to the config file manually
// This function assumes it has already been added to server.alternateDomains

// Verify old domain is in alternate domains
found := false
for _, ad := range a.cfg.Server.AlternateDomains {
if ad == oldDomain {
found = true
break
}
}
if !found {
return fmt.Errorf("old domain %s must be added to server.alternateDomains in config before running domain move", oldDomain)
}

// Refresh webfinger resources to include the alternate domain
a.prepareWebfinger()

// Purge cache to ensure updated actor profiles are served
a.purgeCache()

// Move all blogs
for blogName, blog := range a.cfg.Blogs {
a.info("Processing blog", "blog", blogName)

// Get all followers for this blog
followers, err := a.db.apGetAllFollowers(blogName)
if err != nil {
a.error("Failed to get followers for blog", "blog", blogName, "err", err)
continue
}

if len(followers) == 0 {
a.info("No followers to notify", "blog", blogName)
continue
}

a.info("Sending Move activities to followers", "blog", blogName, "count", len(followers))

// Get all follower inboxes
inboxes, err := a.db.apGetAllInboxes(blogName)
if err != nil {
a.error("Failed to get follower inboxes", "blog", blogName, "err", err)
continue
}

// Create Move activity from old domain actor to new domain actor
oldDomainActor := a.apIriForDomain(blog, oldDomain)
newDomainActor := a.apIriForDomain(blog, newDomain)

// Set movedTo for this blog to point to the new domain actor
if err := a.setApMovedTo(blogName, newDomainActor); err != nil {
a.error("Failed to set movedTo", "blog", blogName, "err", err)
continue
}

move := ap.ActivityNew(ap.MoveType, ap.IRI(oldDomainActor+"#move-"+fmt.Sprintf("%d", utcNowNanos())), ap.IRI(oldDomainActor))
move.Actor = ap.IRI(oldDomainActor)
move.Target = ap.IRI(newDomainActor)
move.To.Append(a.apGetFollowersCollectionId(blogName, blog))

// Send Move activity to all follower inboxes
uniqueInboxes := lo.Uniq(inboxes)
a.apSendTo(oldDomainActor, move, uniqueInboxes...)

a.info("Domain move activities queued", "blog", blogName, "count", len(uniqueInboxes))
}

a.info("Domain move completed for all blogs", "from", oldDomain, "to", newDomain)
a.info("The old domain will continue to serve ActivityPub requests but redirect other traffic")
a.info("To stop serving the old domain, remove it from server.alternateDomains in config and restart")

return nil
}
9 changes: 3 additions & 6 deletions activityPub_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ const (
gtsTestEmail = "gtsuser@example.com"
gtsTestUsername = "gtsuser"
gtsTestPassword = "GtsPassword123!@#"
gtsTestEmail2 = "gtsuser2@example.com"
gtsTestUsername2 = "gtsuser2"
gtsTestPassword2 = "GtsPassword2123!@#"
gtsServiceEmail = "gtsservice@example.com"
gtsServiceUsername = "gtsservice"
gtsServicePassword = "GtsService123!@#"
Expand Down Expand Up @@ -418,12 +421,6 @@ func TestIntegrationActivityPubWithGoToSocial(t *testing.T) {

}

const (
gtsTestEmail2 = "gtsuser2@example.com"
gtsTestUsername2 = "gtsuser2"
gtsTestPassword2 = "GtsPassword456!@#"
)

func TestIntegrationActivityPubMoveFollowers(t *testing.T) {
requireDocker(t)

Expand Down
87 changes: 87 additions & 0 deletions activityStreams.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/url"
"strings"

"github.com/araddon/dateparse"
ct "github.com/elnormous/contenttype"
Expand Down Expand Up @@ -172,6 +173,79 @@ func (a *goBlog) toApPerson(blog string) *ap.Actor {
apBlog.AlsoKnownAs = append(apBlog.AlsoKnownAs, ap.IRI(aka))
}

// Add alternate domains to alsoKnownAs
for _, altDomain := range a.cfg.Server.AlternateDomains {
altIri := a.apIriForDomain(b, altDomain)
apBlog.AlsoKnownAs = append(apBlog.AlsoKnownAs, ap.IRI(altIri))
}

// Check if this blog has a movedTo target set (account migration)
if movedTo, err := a.getApMovedTo(blog); err == nil && movedTo != "" {
apBlog.MovedTo = ap.IRI(movedTo)
}

return apBlog
}

func (a *goBlog) toApPersonForDomain(blog, domain string) *ap.Actor {
b := a.cfg.Blogs[blog]

// Use the specified domain for the IRI
apIri := ap.IRI(a.apIriForDomain(b, domain))

apBlog := ap.PersonNew(apIri)
apBlog.URL = apIri

apBlog.Name = ap.NaturalLanguageValues{{Lang: b.Lang, Value: a.renderMdTitle(b.Title)}}
apBlog.Summary = ap.NaturalLanguageValues{{Lang: b.Lang, Value: b.Description}}
apBlog.PreferredUsername = ap.NaturalLanguageValues{{Lang: b.Lang, Value: blog}}

// Inbox and Followers use the same domain
scheme := "http"
if a.cfg.Server.PublicHTTPS || strings.HasPrefix(a.cfg.Server.PublicAddress, "https") {
scheme = "https"
}
apBlog.Inbox = ap.IRI(scheme + "://" + domain + "/activitypub/inbox/" + blog)
apBlog.Followers = ap.IRI(scheme + "://" + domain + "/activitypub/followers/" + blog)

apBlog.PublicKey.Owner = apIri
apBlog.PublicKey.ID = ap.IRI(a.apIriForDomain(b, domain) + "#main-key")
apBlog.PublicKey.PublicKeyPem = string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Headers: nil,
Bytes: a.apPubKeyBytes,
}))

if a.hasProfileImage() {
icon := &ap.Image{}
icon.Type = ap.ImageType
icon.MediaType = ap.MimeType(contenttype.JPEG)
icon.URL = ap.IRI(scheme + "://" + domain + a.profileImagePath(profileImageFormatJPEG, 0, 0))
apBlog.Icon = icon
}

for _, ad := range a.cfg.ActivityPub.AttributionDomains {
apBlog.AttributionDomains = append(apBlog.AttributionDomains, ap.IRI(ad))
}

for _, aka := range a.cfg.ActivityPub.AlsoKnownAs {
apBlog.AlsoKnownAs = append(apBlog.AlsoKnownAs, ap.IRI(aka))
}

// Add alternate domains and configured domain to alsoKnownAs
// Include the main configured domain as an alias
mainIri := a.apIri(b)
if mainIri != apIri.String() {
apBlog.AlsoKnownAs = append(apBlog.AlsoKnownAs, ap.IRI(mainIri))
}

for _, altDomain := range a.cfg.Server.AlternateDomains {
if altDomain != domain {
altIri := a.apIriForDomain(b, altDomain)
apBlog.AlsoKnownAs = append(apBlog.AlsoKnownAs, ap.IRI(altIri))
}
}

// Check if this blog has a movedTo target set (account migration)
if movedTo, err := a.getApMovedTo(blog); err == nil && movedTo != "" {
apBlog.MovedTo = ap.IRI(movedTo)
Expand All @@ -181,6 +255,19 @@ func (a *goBlog) toApPerson(blog string) *ap.Actor {
}

func (a *goBlog) serveActivityStreams(w http.ResponseWriter, r *http.Request, status int, blog string) {
// Check if the request is for an alternate domain
requestedDomain := r.Host
if requestedDomain != "" && requestedDomain != a.cfg.Server.publicHostname {
// Check if this is an alternate domain
for _, altDomain := range a.cfg.Server.AlternateDomains {
if altDomain == requestedDomain {
// Serve actor with the alternate domain
a.serveAPItem(w, r, status, a.toApPersonForDomain(blog, requestedDomain))
return
}
}
}
// Serve the default actor
a.serveAPItem(w, r, status, a.toApPerson(blog))
}

Expand Down
Loading
Loading