Skip to content

Commit cabdcd0

Browse files
authored
basic activitypub support and change to AGPL v3 (#19)
* Basic activitypub support, server side rendering and change to AGPL v3 * Add github workflow * Contents * Update README.md * Add features list
1 parent 32f4468 commit cabdcd0

File tree

17 files changed

+1372
-736
lines changed

17 files changed

+1372
-736
lines changed

.github/workflows/go.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Go CI
2+
3+
on:
4+
push:
5+
branches: [ main, master ]
6+
pull_request:
7+
branches: [ main, master ]
8+
9+
jobs:
10+
build-and-test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout code
14+
uses: actions/checkout@v4
15+
- name: Set up Go
16+
uses: actions/setup-go@v5
17+
with:
18+
go-version: '1.24.1'
19+
- name: Install dependencies
20+
run: go mod download
21+
- name: Run tests
22+
run: go test -v ./...

LICENSE

Lines changed: 217 additions & 202 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ A simple blog system using microservices
44

55
![image](https://github.com/user-attachments/assets/6874c8fe-7a1d-4b8f-a67c-306e4a8d4825)
66

7+
## Features
8+
- Microservices-based architecture (Users, Posts, Comments, Web)
9+
- Minimalist static web UI (feed, profiles, login, signup)
10+
- AI co-authoring for posts (OpenAI integration)
11+
- Tagging and tag-based filtering for posts
12+
- ActivityPub federation (Fediverse support: actors, inbox/outbox, followers, following, WebFinger)
13+
- Persistent key and follower management for ActivityPub
14+
- REST API for all major features
15+
- GitHub Actions CI for Go tests
16+
- Licensed under AGPL v3
17+
18+
## Contents
19+
- [Services](#services)
20+
- [Web Interface (Static UI)](#web-interface-static-ui)
21+
- [AI Co-authoring Feature](#ai-co-authoring-feature)
22+
- [Tag Features](#tag-features)
23+
- [Getting Started](#getting-started)
24+
- [API Endpoints](#api-endpoints)
25+
- [Posts](#posts)
26+
- [Comments](#comments)
27+
- [Users](#users)
28+
- [Tags](#tags)
29+
- [ActivityPub & Federation Support](#activitypub--federation-support)
30+
- [Project Structure](#project-structure)
731

832
## Services
933

@@ -145,6 +169,59 @@ Browse to [http://localhost:8080](http://localhost:8080)
145169
- `GET /tags?post_id=:id`: Get tags for a specific post
146170
- `GET /posts/by-tag/:tag`: Get posts with a specific tag
147171

172+
## ActivityPub & Federation Support
173+
174+
This blog platform supports [ActivityPub](https://www.w3.org/TR/activitypub/) federation, allowing your blog to participate in the decentralized social web (Fediverse).
175+
176+
### Features
177+
- **ActivityPub Actor**: Each user is discoverable as an ActivityPub actor at `/users/:username/actor`.
178+
- **Inbox/Outbox**: Implements `/users/:username/inbox` and `/users/:username/outbox` for receiving and publishing activities.
179+
- **Followers/Following**: Exposes `/users/:username/followers` and `/users/:username/following` endpoints. Followers are stored persistently.
180+
- **WebFinger Discovery**: Supports `/.well-known/webfinger` for user discovery by remote servers.
181+
- **Federated Posting**: When a user is followed by remote Fediverse users, new posts are automatically sent as ActivityPub `Create` activities to all followers' inboxes.
182+
- **Persistent Follower Storage**: Followers are stored using `go-micro.dev/v5/store` for reliability and scalability.
183+
- **Inbox Accepts**: Handles incoming `Follow` requests and responds with `Accept` activities.
184+
- **Outbox**: Exposes all user posts as ActivityPub `Create` activities for remote consumption.
185+
186+
### How it Works
187+
- When a remote user follows a blog user, their inbox URL is stored as a follower.
188+
- When a new post is published, a `Create` activity is sent to all followers' inboxes.
189+
- Followers are managed in persistent storage, not just in memory.
190+
- The outbox endpoint lists all posts as ActivityPub activities for the user.
191+
192+
### Example Endpoints
193+
- `GET /.well-known/webfinger?resource=acct:[email protected]` – WebFinger discovery
194+
- `GET /users/alice/actor` – ActivityPub actor for Alice
195+
- `POST /users/alice/inbox` – Receive ActivityPub activities (Follow, Like, etc.)
196+
- `GET /users/alice/outbox` – List Alice's posts as ActivityPub Create activities
197+
- `GET /users/alice/followers` – List Alice's followers
198+
199+
### Usage & Configuration
200+
201+
To enable ActivityPub federation, you must set the `DOMAIN` environment variable to your public domain name (e.g. `example.com`). This is required so that outgoing ActivityPub activities use the correct URLs for your actors and posts.
202+
203+
Example:
204+
205+
```bash
206+
export DOMAIN=example.com
207+
```
208+
209+
- The `DOMAIN` variable should be set in the environment for all services (especially `users`, `posts`, and `web`).
210+
- If running locally for testing, you can use `localhost` or your local IP, but federation will only work with a public domain.
211+
212+
After setting `DOMAIN`, start your services as usual:
213+
214+
```bash
215+
micro run
216+
```
217+
218+
Your blog will now be discoverable and federated on the Fediverse using your configured domain.
219+
220+
### Notes
221+
- HTTP signatures for outgoing federation are now implemented (recommended for production).
222+
- Follower management is persistent and robust.
223+
- The platform is compatible with Mastodon, Friendica, and other Fediverse software.
224+
148225
## Project Structure
149226

150227
```

comments/handler/linkpreview.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package handler
22

33
import (
4+
"golang.org/x/net/html"
45
"net/http"
56
"regexp"
67
"strings"
7-
"golang.org/x/net/html"
88
)
99

1010
// Extracts the first URL from a string (simple regex)

posts/handler/ai.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import (
44
"context"
55
"os"
66

7-
pb "github.com/micro/blog/posts/proto"
87
"github.com/micro/blog/posts/openai"
8+
pb "github.com/micro/blog/posts/proto"
99
)
1010

1111
// Generate implements the AI co-authoring feature
@@ -14,16 +14,16 @@ func (h *Handler) Generate(ctx context.Context, req *pb.GenerateRequest, res *pb
1414
res.Error = "Empty prompt provided"
1515
return nil
1616
}
17-
17+
1818
apiKey := os.Getenv("OPENAI_API_KEY")
1919
client := openai.NewClient(apiKey)
20-
20+
2121
generatedContent, err := client.GeneratePostContent(req.Prompt)
2222
if err != nil {
2323
res.Error = "Error generating content: " + err.Error()
2424
return nil
2525
}
26-
26+
2727
res.Content = generatedContent
2828
return nil
2929
}

posts/handler/linkpreview.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package handler
22

33
import (
4+
"golang.org/x/net/html"
45
"net/http"
56
"regexp"
67
"strings"
7-
"golang.org/x/net/html"
88
)
99

1010
// Extracts the first URL from a string (simple regex)

posts/handler/posts.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/google/uuid"
1010
pb "github.com/micro/blog/posts/proto"
11+
users "github.com/micro/blog/users/handler"
1112
"go-micro.dev/v5/store"
1213
)
1314

@@ -50,6 +51,8 @@ func (h *Handler) Create(ctx context.Context, req *pb.CreateRequest, res *pb.Cre
5051
b, err := json.Marshal(post)
5152
if err == nil {
5253
_ = postStore.Write(&store.Record{Key: "post-" + post.Id, Value: b})
54+
// ActivityPub federation: send Create activity to followers
55+
go users.SendActivityPubCreate(post.AuthorName, post)
5356
}
5457

5558
return nil

users/handler/activitypub.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package handler
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"strings"
7+
)
8+
9+
// In-memory followers map for demo (replace with persistent storage in production)
10+
var followers = make(map[string]map[string]bool) // username -> set of follower actor IDs
11+
12+
// ActivityPubActor represents a minimal ActivityPub Person actor
13+
func ActivityPubActor(w http.ResponseWriter, r *http.Request) {
14+
username := strings.TrimPrefix(r.URL.Path, "/users/")
15+
username = strings.TrimSuffix(username, "/actor")
16+
actor := map[string]interface{}{
17+
"@context": "https://www.w3.org/ns/activitystreams",
18+
"id": r.Host + "/users/" + username + "/actor",
19+
"type": "Person",
20+
"preferredUsername": username,
21+
"inbox": r.Host + "/users/" + username + "/inbox",
22+
"outbox": r.Host + "/users/" + username + "/outbox",
23+
"publicKey": map[string]interface{}{
24+
"id": r.Host + "/users/" + username + "#main-key",
25+
"owner": r.Host + "/users/" + username + "/actor",
26+
"publicKeyPem": "TODO: Add public key",
27+
},
28+
}
29+
w.Header().Set("Content-Type", "application/activity+json")
30+
json.NewEncoder(w).Encode(actor)
31+
}
32+
33+
// ActivityPubInbox handles incoming ActivityPub activities
34+
// Update ActivityPubInbox to use persistent followers storage
35+
func ActivityPubInbox(w http.ResponseWriter, r *http.Request) {
36+
username := strings.TrimPrefix(r.URL.Path, "/users/")
37+
username = strings.TrimSuffix(username, "/inbox")
38+
var activity map[string]interface{}
39+
if err := json.NewDecoder(r.Body).Decode(&activity); err != nil {
40+
w.WriteHeader(http.StatusBadRequest)
41+
return
42+
}
43+
typeVal, _ := activity["type"].(string)
44+
switch typeVal {
45+
case "Follow":
46+
actor, _ := activity["actor"].(string)
47+
fmap := loadFollowers(username)
48+
fmap[actor] = true
49+
saveFollowers(username, fmap)
50+
// Respond with Accept activity
51+
accept := map[string]interface{}{
52+
"@context": "https://www.w3.org/ns/activitystreams",
53+
"id": r.Host + "/users/" + username + "/accepts/" + actor,
54+
"type": "Accept",
55+
"actor": r.Host + "/users/" + username + "/actor",
56+
"object": activity,
57+
}
58+
w.Header().Set("Content-Type", "application/activity+json")
59+
json.NewEncoder(w).Encode(accept)
60+
return
61+
case "Like", "Announce":
62+
// Optionally handle likes and boosts
63+
w.WriteHeader(http.StatusAccepted)
64+
return
65+
default:
66+
w.WriteHeader(http.StatusAccepted)
67+
return
68+
}
69+
}
70+
71+
// ActivityPubOutbox returns the user's posts as Create activities
72+
func ActivityPubOutbox(w http.ResponseWriter, r *http.Request) {
73+
username := strings.TrimPrefix(r.URL.Path, "/users/")
74+
username = strings.TrimSuffix(username, "/outbox")
75+
posts, err := FetchPostsByUsername(username)
76+
if err != nil {
77+
w.WriteHeader(http.StatusInternalServerError)
78+
return
79+
}
80+
orderedItems := []interface{}{}
81+
for _, post := range posts {
82+
create := map[string]interface{}{
83+
"@context": "https://www.w3.org/ns/activitystreams",
84+
"id": w.Header().Get("Host") + "/users/" + username + "/outbox/" + post.Id,
85+
"type": "Create",
86+
"actor": w.Header().Get("Host") + "/users/" + username + "/actor",
87+
"object": map[string]interface{}{
88+
"id": w.Header().Get("Host") + "/posts/" + post.Id,
89+
"type": "Note",
90+
"attributedTo": w.Header().Get("Host") + "/users/" + username + "/actor",
91+
"content": post.Content,
92+
"published": post.CreatedAt,
93+
"to": []string{"https://www.w3.org/ns/activitystreams#Public"},
94+
},
95+
}
96+
orderedItems = append(orderedItems, create)
97+
}
98+
outbox := map[string]interface{}{
99+
"@context": "https://www.w3.org/ns/activitystreams",
100+
"id": w.Header().Get("Host") + "/users/" + username + "/outbox",
101+
"type": "OrderedCollection",
102+
"totalItems": len(orderedItems),
103+
"orderedItems": orderedItems,
104+
}
105+
w.Header().Set("Content-Type", "application/activity+json")
106+
json.NewEncoder(w).Encode(outbox)
107+
}
108+
109+
// Followers endpoint: returns a list of follower actor IDs
110+
func ActivityPubFollowers(w http.ResponseWriter, r *http.Request) {
111+
username := strings.TrimPrefix(r.URL.Path, "/users/")
112+
username = strings.TrimSuffix(username, "/followers")
113+
fmap := loadFollowers(username)
114+
ids := []string{}
115+
for id := range fmap {
116+
ids = append(ids, id)
117+
}
118+
resp := map[string]interface{}{
119+
"@context": "https://www.w3.org/ns/activitystreams",
120+
"id": r.Host + "/users/" + username + "/followers",
121+
"type": "Collection",
122+
"totalItems": len(ids),
123+
"items": ids,
124+
}
125+
w.Header().Set("Content-Type", "application/activity+json")
126+
json.NewEncoder(w).Encode(resp)
127+
}
128+
129+
// Following endpoint: returns an empty list for now (implement if you track following)
130+
func ActivityPubFollowing(w http.ResponseWriter, r *http.Request) {
131+
username := strings.TrimPrefix(r.URL.Path, "/users/")
132+
username = strings.TrimSuffix(username, "/following")
133+
resp := map[string]interface{}{
134+
"@context": "https://www.w3.org/ns/activitystreams",
135+
"id": r.Host + "/users/" + username + "/following",
136+
"type": "Collection",
137+
"totalItems": 0,
138+
"items": []string{},
139+
}
140+
w.Header().Set("Content-Type", "application/activity+json")
141+
json.NewEncoder(w).Encode(resp)
142+
}

0 commit comments

Comments
 (0)