✨ (federation): Follow a remote user and see their repositories grouped
Changes
15 files changed, +642 -28
MODIFY
README.md
+2 -0
@@ -80,6 +80,8 @@
80
80
`/admin/runners`; secrets are stored hashed. Workflow execution (fetching and running jobs) is a
81
81
follow-up phase. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
82
82
[architecture](docs/maintainers/ci-runners.md)
83
+ activities from; local users can in turn follow a remote repository — or a whole remote user, whose
84
+ public repositories are then followed and shown grouped — and read their pushes (see below)
83
85
84
86
## Federation (ForgeFed)
85
87
MODIFY
docs/admins/federation.md
+2 -1
@@ -134,7 +134,8 @@
134
134
| `federation_keys` | Local actor keypairs (public + private PEM) |
135
135
| `remote_actors` | Cache of fetched remote actors (inbox, public key; 6h TTL) |
136
136
| `repository_followers` | Remote actors following local repositories |
137
-| `remote_follows` | Local users' follows of remote repositories (`PENDING`/`ACCEPTED`) |
137
+| `remote_follows` | Local users' follows of remote repositories (`PENDING`/`ACCEPTED`); `via_user_actor_id` tags follows fanned out from a followed user |
138
+| `remote_user_follows` | Local users' follows of remote users (each expands to a `remote_follows` row per public repo) |
138
139
| `received_pushes` | `Push` activities received from followed repositories (feed) |
139
140
| `federation_outbox` / `federation_inbox` | Published activities / inbound dedup log |
140
141
| `federation_delivery` | Outbound delivery queue |
MODIFY
docs/maintainers/federation-roadmap.md
+11 -8
@@ -41,20 +41,23 @@
41
41
42
42
## Stories
43
43
44
-### Story 1 — Follow a user, see their aggregated repositories *(in progress)*
44
+### Story 1 — Follow a user, see their aggregated repositories *(done)*
45
45
46
46
Follow a remote `Person` actor and see their public repositories, grouped, with
47
47
recent push activity per repo — instead of following each repository one by one.
48
48
49
-Needs:
50
49
- ✅ `Person` actor exposes a `repositories` collection (`/ap/users/{username}/repositories`,
51
- public repos only) and advertises it in the actor document — **done**.
52
-- Outbound follow generalised to a `Person` target (today it targets repository
53
- actors only).
54
-- Persist followed remote users; fetch and cache their repository collection.
55
-- `/following` UI groups activity by user → repository.
50
+ public repos only) and advertises it in the actor document.
51
+- ✅ Follow a remote user: `RemoteFollowService.followUser` resolves the `Person`,
52
+ reads their repositories collection via `RemoteRepositoryDirectory`, and fans
53
+ out to a repository follow per public repo (tagged `viaUserActorId`), reusing
54
+ the existing Follow/Accept/Push machinery.
55
+- ✅ Followed users persisted (`remote_user_follows`); the `/following` UI groups
56
+ repositories and their push activity under each followed user.
56
57
57
-Independently shippable, no merge-request risk. This is the next increment.
58
+Implemented as a **snapshot** at follow time (see the follow-a-user re-sync gap
59
+in [forgefed.md](forgefed.md)); repos the remote adds later need a re-follow to
60
+appear. A live re-sync is a possible follow-up.
58
61
59
62
### Story 2 — Cross-instance fork with upstream tracking *(issue #12)*
60
63
MODIFY
docs/maintainers/forgefed.md
+25 -7
@@ -35,15 +35,16 @@
35
35
| `FollowHandler` / `UndoHandler` | Inbound: remote actor (un)follows a local repository |
36
36
| `AcceptHandler` | Inbound: remote accepts a `Follow` we sent — flips our follow to `ACCEPTED` |
37
37
| `PushHandler` | Inbound: stores `Push` from repositories local users follow |
38
-| `RemoteFollowService` | Outbound: follow/unfollow a remote repository, push feed query |
38
+| `RemoteFollowService` | Outbound: follow/unfollow a remote repository or user, push feed query |
39
+| `RemoteRepositoryDirectory` | Outbound: reads a remote `Person`'s `repositories` collection (fan-out source for follow-a-user) |
39
40
| `FederationPushService` | Outbound: fans out `Push` to followers from the git post-receive hook |
40
41
| `DeliveryService` | Persisted outbound queue with retry/backoff/dead-letter |
41
42
42
43
Web UI: `web/FollowingResource` + Qute template (`/following` page). Persistence
43
44
in `model/` (`FederationKey`, `RemoteActor`, `RepositoryFollower`,
44
-`RemoteFollow`, `ReceivedPush`, `InboxActivity`, `OutboxActivity`,
45
-`FederationDelivery`), schema in `db/migration/V2__federation.sql` and
46
-`V9__federation_following.sql`.
45
+`RemoteFollow`, `RemoteUserFollow`, `ReceivedPush`, `InboxActivity`,
46
+`OutboxActivity`, `FederationDelivery`), schema in `db/migration/V2__federation.sql`,
47
+`V9__federation_following.sql`, and `V19__federation_user_follows.sql`.
47
48
48
49
## Actor model
49
50
@@ -72,6 +73,15 @@
72
73
matches the one we followed. Unfollow enqueues `Undo(Follow)` and deletes the
73
74
row.
74
75
76
+**Outbound follow-a-user** (`RemoteFollowService.followUser`): input resolves to
77
+a remote `Person` actor id → `RemoteRepositoryDirectory` reads that Person's
78
+`repositories` collection → a `RemoteUserFollow` row is persisted and one
79
+repository follow is fanned out per public repo (each tagged with
80
+`viaUserActorId`), reusing the ordinary follow path above. The set is a snapshot
81
+at follow time. `unfollowUser` undoes every tagged repository follow, then
82
+removes the `RemoteUserFollow`. The `/following` page groups repositories under
83
+their followed user; directly-followed repositories are listed separately.
84
+
75
85
**Inbound follow** (`FollowHandler`): a remote actor follows one of our public
76
86
repositories → persist `RepositoryFollower` → enqueue a signed `Accept` back.
77
87
`UndoHandler` removes the follower again.
@@ -168,15 +178,17 @@
168
178
169
179
- Actor documents and WebFinger discovery for repositories, users, and the
170
180
instance; outbox and followers collections. The `Person` actor advertises and
171
- serves a `repositories` collection of its public repository actors
172
- (foundation for following a user — see the
173
- [federated collaboration roadmap](federation-roadmap.md), Story 1).
181
+ serves a `repositories` collection of its public repository actors.
174
182
- Inbound `Follow`/`Undo(Follow)` on public repositories, answered with a
175
183
signed `Accept` (remote users can follow local repos).
176
184
- `Push` fan-out to remote followers from both git transports.
177
185
- Outbound follow/unfollow of remote repositories by handle or actor URL,
178
186
including `Accept` confirmation tracking (`PENDING` → `ACCEPTED`) and the
179
187
received-pushes feed — the `/following` UI covers all of it.
188
+- Outbound follow/unfollow of a remote **user**: reads the `Person`'s
189
+ `repositories` collection and fans out to a repository follow per public repo,
190
+ shown grouped in the `/following` UI (federated-collaboration roadmap Story 1).
191
+ Snapshot at follow time — new remote repositories are not auto-picked-up.
180
192
- HTTP Signature signing/verification, per-actor keys, inbound dedup, peer
181
193
allowlist, SSRF guard, delivery queue with retry and dead-letter.
182
194
- Tested git-shark↔git-shark, including a scripted local two-host trial (see
@@ -223,5 +235,11 @@
223
235
federation.
224
236
- **Follower/feed UI depth** — repository pages don't show remote followers;
225
237
the push feed is a flat newest-50 with no pagination or per-repo filtering.
238
+- **Follow-a-user re-sync** — the fanned-out repository set is a snapshot at
239
+ follow time; there is no re-fetch of a followed user's `repositories`
240
+ collection, so repos they add later are never picked up. Also,
241
+ `RemoteRepositoryDirectory` reads only the first collection page — it does not
242
+ follow `next` pagination, fine for the git-shark↔git-shark scope but a gap for
243
+ users with large repository lists or broader ForgeFed peers.
226
244
- **Delivery observability** — dead-letters are only visible via SQL; no
227
245
admin UI or metrics.
MODIFY
docs/users/federation.md
+19 -2
@@ -13,8 +13,10 @@
13
13
14
14
## What you can do
15
15
16
-- **Follow a remote public repository** and get its `Push` activity feed.
17
-- **Unfollow** it again.
16
+- **Follow a remote user** and get all of their public repositories at once,
17
+ grouped under that user, each with its own `Push` feed.
18
+- **Follow a single remote public repository** and get its `Push` activity feed.
19
+- **Unfollow** either again.
18
20
- **Be discovered**: your user and every public repository on your instance are
19
21
visible to other ForgeFed servers.
20
22
@@ -24,6 +26,21 @@
24
26
25
27
---
26
28
29
+## Following a remote user
30
+
31
+Open **Following** in the header navigation (you must be logged in) and use the
32
+**Follow remote user** form with either a `username@host` handle or the user's
33
+actor URL (`https://shark.example.com/ap/users/alice`). Your instance reads the
34
+user's public repository list and follows each repository for you; they appear
35
+grouped under the user, and their pushes flow into the **Recent pushes** feed
36
+like any other follow.
37
+
38
+The repository set is a **snapshot taken when you follow** — repositories the
39
+remote user creates afterwards are not picked up automatically. Unfollowing the
40
+user unfollows every repository that was fanned out from it.
41
+
42
+---
43
+
27
44
## Following a remote repository
28
45
29
46
Open **Following** in the header navigation (you must be logged in), then enter
MODIFY
src/main/java/de/workaround/federation/ActivityPubClient.java
+60 -0
@@ -10,6 +10,8 @@
10
10
import java.security.PublicKey;
11
11
import java.time.Duration;
12
12
import java.time.Instant;
13
+import java.util.ArrayList;
14
+import java.util.List;
13
15
import java.util.Map;
14
16
import java.util.Optional;
15
17
@@ -117,6 +119,64 @@
117
119
return Optional.of(actor);
118
120
}
119
121
122
+ /**
123
+ * Fetches an {@code OrderedCollection} document and returns its item ids: textual items are taken
124
+ * verbatim, object items contribute their {@code id}. SSRF-guarded like every outbound fetch;
125
+ * returns an empty list on any failure so callers never abort on an unreachable collection.
126
+ */
127
+ public List<String> fetchCollectionItemIds(String collectionUrl)
128
+ {
129
+ URI uri;
130
+ try
131
+ {
132
+ uri = guard.requireSafe(collectionUrl);
133
+ }
134
+ catch (RemoteUrlGuard.UnsafeUrlException e)
135
+ {
136
+ return List.of();
137
+ }
138
+ try
139
+ {
140
+ HttpResponse<String> response = http.send(
141
+ HttpRequest.newBuilder(uri)
142
+ .header("Accept", ActivityPubMedia.ACTIVITY_JSON)
143
+ .timeout(Duration.ofSeconds(15))
144
+ .GET().build(),
145
+ HttpResponse.BodyHandlers.ofString());
146
+ if (response.statusCode() / 100 != 2 || response.body().length() > MAX_RESPONSE_BYTES)
147
+ {
148
+ return List.of();
149
+ }
150
+ JsonNode doc = mapper.readTree(response.body());
151
+ List<String> ids = new ArrayList<>();
152
+ for (JsonNode item : doc.path("orderedItems"))
153
+ {
154
+ if (item.isTextual())
155
+ {
156
+ ids.add(item.asText());
157
+ }
158
+ else
159
+ {
160
+ String id = item.path("id").asText(null);
161
+ if (id != null)
162
+ {
163
+ ids.add(id);
164
+ }
165
+ }
166
+ }
167
+ return ids;
168
+ }
169
+ catch (IOException e)
170
+ {
171
+ return List.of();
172
+ }
173
+ catch (InterruptedException e)
174
+ {
175
+ Thread.currentThread().interrupt();
176
+ return List.of();
177
+ }
178
+ }
179
+
120
180
/** Resolves the public key named by a {@code keyId} (actor id + {@code #main-key} fragment). */
121
181
public Optional<PublicKey> fetchPublicKey(String keyId)
122
182
{
MODIFY
src/main/java/de/workaround/federation/RemoteFollowService.java
+110 -2
@@ -3,6 +3,8 @@
3
3
import java.util.List;
4
4
import java.util.UUID;
5
5
6
+import org.jboss.logging.Logger;
7
+
6
8
import com.fasterxml.jackson.core.JsonProcessingException;
7
9
import com.fasterxml.jackson.databind.ObjectMapper;
8
10
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -11,6 +13,7 @@
11
13
import de.workaround.model.ReceivedPush;
12
14
import de.workaround.model.RemoteActor;
13
15
import de.workaround.model.RemoteFollow;
16
+import de.workaround.model.RemoteUserFollow;
14
17
import de.workaround.model.User;
15
18
import jakarta.enterprise.context.ApplicationScoped;
16
19
import jakarta.inject.Inject;
@@ -25,6 +28,8 @@
25
28
@ApplicationScoped
26
29
public class RemoteFollowService
27
30
{
31
+ private static final Logger LOG = Logger.getLogger(RemoteFollowService.class);
32
+
28
33
/** A follow/unfollow request that cannot be fulfilled; the message is safe to show the user. */
29
34
public static class RemoteFollowException extends RuntimeException
30
35
{
@@ -58,6 +63,12 @@
58
63
RemoteFollow.Repo follows;
59
64
60
65
@Inject
66
+ RemoteUserFollow.Repo userFollows;
67
+
68
+ @Inject
69
+ RemoteRepositoryDirectory directory;
70
+
71
+ @Inject
61
72
ReceivedPush.Repo pushes;
62
73
63
74
@Inject
@@ -78,14 +89,23 @@
78
89
{
79
90
throw new RemoteFollowException("Choose a username before following remote repositories");
80
91
}
81
- String actorId = resolveActorId(input);
92
+ return followRepositoryActor(user, resolveActorId(input), null);
93
+ }
94
+
95
+ /**
96
+ * Records a follow of one repository actor and enqueues the signed {@code Follow}. Idempotent per
97
+ * (user, actor). {@code viaUserActorId} tags follows created by fanning out a followed user's
98
+ * repositories collection; pass {@code null} for a direct repository follow.
99
+ */
100
+ private RemoteFollow followRepositoryActor(User user, String actorId, String viaUserActorId)
101
+ {
82
102
RemoteFollow existing = follows.findByUserAndRemoteActorId(user, actorId).orElse(null);
83
103
if (existing != null)
84
104
{
85
105
return existing;
86
106
}
87
107
RemoteActor remote = client.fetchActor(actorId)
88
- .orElseThrow(() -> new RemoteFollowException("Could not resolve remote repository: " + input));
108
+ .orElseThrow(() -> new RemoteFollowException("Could not resolve remote repository: " + actorId));
89
109
90
110
keyService.getOrCreate(FederationKey.ActorType.PERSON, user.id.toString());
91
111
String personId = uris.person(user);
@@ -96,6 +116,7 @@
96
116
follow.user = users.findById(user.id); // re-attach — callers may pass a detached user
97
117
follow.remoteActorId = actorId;
98
118
follow.followActivityId = activityId;
119
+ follow.viaUserActorId = viaUserActorId;
99
120
follow.persist();
100
121
101
122
delivery.enqueue(remote.inbox, FederationKey.ActorType.PERSON, user.id.toString(),
@@ -103,6 +124,93 @@
103
124
return follow;
104
125
}
105
126
127
+ /**
128
+ * Follows a remote {@code Person} named by {@code input} (actor URL or {@code username@host}
129
+ * handle): fans out to a repository follow per public repository the remote advertises. Idempotent
130
+ * per (user, remote person). The repository set is a snapshot at follow time.
131
+ */
132
+ @Transactional
133
+ public RemoteUserFollow followUser(User user, String input)
134
+ {
135
+ requireOperational();
136
+ if (user.username == null)
137
+ {
138
+ throw new RemoteFollowException("Choose a username before following remote users");
139
+ }
140
+ String actorId = resolveActorId(input);
141
+ User attached = users.findById(user.id);
142
+ RemoteUserFollow existing = userFollows.findByUserAndRemoteUserActorId(attached, actorId).orElse(null);
143
+ if (existing != null)
144
+ {
145
+ return existing;
146
+ }
147
+ RemoteUserFollow userFollow = new RemoteUserFollow();
148
+ userFollow.user = attached;
149
+ userFollow.remoteUserActorId = actorId;
150
+ userFollow.handle = displayHandle(input, actorId);
151
+ userFollow.persist();
152
+
153
+ for (String repoActorId : directory.repositoriesOf(actorId))
154
+ {
155
+ try
156
+ {
157
+ followRepositoryActor(user, repoActorId, actorId);
158
+ }
159
+ catch (RemoteFollowException e)
160
+ {
161
+ // One unreachable repository must not abort the whole user follow.
162
+ LOG.debugf("Skipping unresolvable repository %s of %s: %s", repoActorId, actorId, e.getMessage());
163
+ }
164
+ }
165
+ return userFollow;
166
+ }
167
+
168
+ /** Unfollows a remote user: undoes every repository follow fanned out from it, then removes it. */
169
+ @Transactional
170
+ public void unfollowUser(User user, UUID userFollowId)
171
+ {
172
+ RemoteUserFollow userFollow = userFollows.findById(userFollowId);
173
+ if (userFollow == null || !userFollow.user.id.equals(user.id))
174
+ {
175
+ return; // not this user's follow — nothing to do
176
+ }
177
+ for (RemoteFollow follow : follows.findByUserAndViaUserActorId(users.findById(user.id),
178
+ userFollow.remoteUserActorId))
179
+ {
180
+ unfollow(user, follow.id);
181
+ }
182
+ userFollows.deleteById(userFollow.id);
183
+ }
184
+
185
+ /** The handle to display: the entered {@code username@host} form, else the resolved actor id. */
186
+ private String displayHandle(String input, String actorId)
187
+ {
188
+ String trimmed = input == null ? "" : input.trim();
189
+ boolean isHandle = !trimmed.startsWith("http://") && !trimmed.startsWith("https://")
190
+ && trimmed.contains("@");
191
+ return isHandle ? trimmed : actorId;
192
+ }
193
+
194
+ @Transactional
195
+ public List<RemoteUserFollow> listUsers(User user)
196
+ {
197
+ return userFollows.findByUser(users.findById(user.id));
198
+ }
199
+
200
+ /** The repository follows fanned out from a followed user, for grouped display. */
201
+ @Transactional
202
+ public List<RemoteFollow> repositoriesOfFollowedUser(User user, String remoteUserActorId)
203
+ {
204
+ return follows.findByUserAndViaUserActorId(users.findById(user.id), remoteUserActorId);
205
+ }
206
+
207
+ /** Repository follows the user created directly (not via a followed user). */
208
+ @Transactional
209
+ public List<RemoteFollow> standaloneRepositoryFollows(User user)
210
+ {
211
+ return follows.findStandaloneByUser(users.findById(user.id));
212
+ }
213
+
106
214
/** Sends an {@code Undo(Follow)} to the remote and removes the user's follow record. */
107
215
@Transactional
108
216
public void unfollow(User user, UUID followId)
ADD
src/main/java/de/workaround/federation/RemoteRepositoryDirectory.java
+26 -0
@@ -0,0 +1,26 @@
1
+package de.workaround.federation;
2
+
3
+import java.util.List;
4
+
5
+import jakarta.enterprise.context.ApplicationScoped;
6
+import jakarta.inject.Inject;
7
+
8
+/**
9
+ * Discovers the public repositories of a remote {@code Person} by reading the {@code repositories}
10
+ * collection git-shark advertises on its Person actors (at {@code <person-actor-id>/repositories}).
11
+ * The convention path keeps discovery to a single fetch for the git-shark↔git-shark case;
12
+ * broader ForgeFed peers that publish the link under a different path are out of scope for now.
13
+ */
14
+@ApplicationScoped
15
+public class RemoteRepositoryDirectory
16
+{
17
+ @Inject
18
+ ActivityPubClient client;
19
+
20
+ /** Repository actor ids advertised by the remote Person, or empty if none/unreachable. */
21
+ public List<String> repositoriesOf(String personActorId)
22
+ {
23
+ return client.fetchCollectionItemIds(personActorId + "/repositories");
24
+ }
25
+
26
+}
MODIFY
src/main/java/de/workaround/model/RemoteFollow.java
+15 -0
@@ -42,6 +42,13 @@
42
42
@Column(columnDefinition = "text")
43
43
public String followActivityId;
44
44
45
+ /**
46
+ * Null for a directly-followed repository; the remote {@code Person} actor id when this follow was
47
+ * created by fanning out a followed user's repositories collection (see {@link RemoteUserFollow}).
48
+ */
49
+ @Column(columnDefinition = "text")
50
+ public String viaUserActorId;
51
+
45
52
@Enumerated(EnumType.STRING)
46
53
public State state = State.PENDING;
47
54
@@ -72,6 +79,14 @@
72
79
73
80
@HQL("select f from RemoteFollow f where f.user = :user order by f.createdAt")
74
81
List<RemoteFollow> findByUser(User user);
82
+
83
+ @HQL("select f from RemoteFollow f where f.user = :user and f.viaUserActorId = :viaUserActorId"
84
+ + " order by f.createdAt")
85
+ List<RemoteFollow> findByUserAndViaUserActorId(User user, String viaUserActorId);
86
+
87
+ @HQL("select f from RemoteFollow f where f.user = :user and f.viaUserActorId is null"
88
+ + " order by f.createdAt")
89
+ List<RemoteFollow> findStandaloneByUser(User user);
75
90
}
76
91
77
92
}
ADD
src/main/java/de/workaround/model/RemoteUserFollow.java
+57 -0
@@ -0,0 +1,57 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Column;
14
+import jakarta.persistence.Entity;
15
+import jakarta.persistence.GeneratedValue;
16
+import jakarta.persistence.GenerationType;
17
+import jakarta.persistence.Id;
18
+import jakarta.persistence.ManyToOne;
19
+import jakarta.persistence.Table;
20
+
21
+/**
22
+ * A local user's follow of a remote {@code Person}. Following a user is a convenience over following
23
+ * each of their repositories: on creation we fan out to a {@link RemoteFollow} per public repository
24
+ * in the remote's repositories collection (each tagged with {@code viaUserActorId} = this actor id).
25
+ * The set is a snapshot at follow time — repositories the remote adds later are not picked up until a
26
+ * re-sync.
27
+ */
28
+@Entity
29
+@Table(name = "remote_user_follows")
30
+public class RemoteUserFollow implements PanacheEntity.Managed
31
+{
32
+ @Id
33
+ @GeneratedValue(strategy = GenerationType.UUID)
34
+ public UUID id;
35
+
36
+ @ManyToOne(optional = false)
37
+ public User user;
38
+
39
+ @Column(columnDefinition = "text")
40
+ public String remoteUserActorId;
41
+
42
+ /** The human-facing handle the user entered ({@code username@host}) or the actor id if unknown. */
43
+ @Column(columnDefinition = "text")
44
+ public String handle;
45
+
46
+ public Instant createdAt = Instant.now();
47
+
48
+ public interface Repo extends PanacheRepository.Managed<RemoteUserFollow, UUID>
49
+ {
50
+ @Find
51
+ Optional<RemoteUserFollow> findByUserAndRemoteUserActorId(User user, String remoteUserActorId);
52
+
53
+ @HQL("select f from RemoteUserFollow f where f.user = :user order by f.createdAt")
54
+ List<RemoteUserFollow> findByUser(User user);
55
+ }
56
+
57
+}
MODIFY
src/main/java/de/workaround/web/FollowingResource.java
+49 -8
@@ -8,6 +8,7 @@
8
8
import de.workaround.federation.RemoteFollowService;
9
9
import de.workaround.model.ReceivedPush;
10
10
import de.workaround.model.RemoteFollow;
11
+import de.workaround.model.RemoteUserFollow;
11
12
import de.workaround.model.User;
12
13
import io.quarkus.qute.CheckedTemplate;
13
14
import io.quarkus.qute.TemplateInstance;
@@ -22,16 +23,25 @@
22
23
import jakarta.ws.rs.core.MediaType;
23
24
import jakarta.ws.rs.core.Response;
24
25
25
-/** Follows of repositories on remote instances: list, follow by handle/URL, unfollow. */
26
+/**
27
+ * Follows on remote instances: follow a whole remote user (fanning out to their public repositories,
28
+ * shown grouped) or a single repository by handle/URL, list them with their received-push feed, and
29
+ * unfollow either.
30
+ */
26
31
@Path("/following")
27
32
@Produces(MediaType.TEXT_HTML)
28
33
public class FollowingResource
29
34
{
35
+ /** A followed remote user together with the repository follows fanned out from it. */
36
+ public record UserGroup(RemoteUserFollow follow, List<RemoteFollow> repositories)
37
+ {
38
+ }
39
+
30
40
@CheckedTemplate
31
41
static class Templates
32
42
{
33
- static native TemplateInstance following(List<RemoteFollow> follows, List<ReceivedPush> pushes,
34
- String error);
43
+ static native TemplateInstance following(List<UserGroup> users, List<RemoteFollow> follows,
44
+ List<ReceivedPush> pushes, String error);
35
45
}
36
46
37
47
@Inject
@@ -43,8 +53,7 @@
43
53
@GET
44
54
public TemplateInstance list()
45
55
{
46
- User user = currentUser.require();
47
- return Templates.following(service.list(user), service.recentPushes(user), null);
56
+ return render(currentUser.require(), null);
48
57
}
49
58
50
59
@POST
@@ -59,9 +68,24 @@
59
68
}
60
69
catch (RemoteFollowService.RemoteFollowException e)
61
70
{
62
- return Response.status(Response.Status.BAD_REQUEST)
63
- .entity(Templates.following(service.list(user), service.recentPushes(user), e.getMessage()))
64
- .build();
71
+ return Response.status(Response.Status.BAD_REQUEST).entity(render(user, e.getMessage())).build();
72
+ }
73
+ }
74
+
75
+ @POST
76
+ @Path("users")
77
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
78
+ public Response followUser(@FormParam("handle") String handle)
79
+ {
80
+ User user = currentUser.require();
81
+ try
82
+ {
83
+ service.followUser(user, handle);
84
+ return Response.seeOther(URI.create("/following")).build();
85
+ }
86
+ catch (RemoteFollowService.RemoteFollowException e)
87
+ {
88
+ return Response.status(Response.Status.BAD_REQUEST).entity(render(user, e.getMessage())).build();
65
89
}
66
90
}
67
91
@@ -73,4 +97,21 @@
73
97
return Response.seeOther(URI.create("/following")).build();
74
98
}
75
99
100
+ @POST
101
+ @Path("users/{id}/unfollow")
102
+ public Response unfollowUser(@PathParam("id") UUID id)
103
+ {
104
+ service.unfollowUser(currentUser.require(), id);
105
+ return Response.seeOther(URI.create("/following")).build();
106
+ }
107
+
108
+ private TemplateInstance render(User user, String error)
109
+ {
110
+ List<UserGroup> users = service.listUsers(user).stream()
111
+ .map(uf -> new UserGroup(uf, service.repositoriesOfFollowedUser(user, uf.remoteUserActorId)))
112
+ .toList();
113
+ return Templates.following(users, service.standaloneRepositoryFollows(user),
114
+ service.recentPushes(user), error);
115
+ }
116
+
76
117
}
ADD
src/main/resources/db/migration/V19__federation_user_follows.sql
+18 -0
@@ -0,0 +1,18 @@
1
+-- Follow-a-user (federated collaboration Story 1): a local user follows a remote
2
+-- Person. We fan out to a repository follow per public repo in that Person's
3
+-- repositories collection, tagging each with via_user_actor_id so the /following
4
+-- page can group them under the user and unfollow-user can undo them together.
5
+create table remote_user_follows
6
+(
7
+ id uuid primary key,
8
+ user_id uuid not null references users (id) on delete cascade,
9
+ remote_user_actor_id text not null,
10
+ handle text not null,
11
+ created_at timestamptz not null default now(),
12
+ unique (user_id, remote_user_actor_id)
13
+);
14
+
15
+-- Null for a directly-followed repository; set to the remote Person actor id when
16
+-- the follow was created by fanning out a followed user's repositories collection.
17
+alter table remote_follows
18
+ add column via_user_actor_id text;
MODIFY
src/main/resources/templates/FollowingResource/following.html
+38 -0
@@ -4,6 +4,36 @@
4
4
{#if error}
5
5
<p class="error">{error}</p>
6
6
{/if}
7
+
8
+{#if users}
9
+<h2>Followed users</h2>
10
+{#for group in users}
11
+<section class="followed-user">
12
+ <h3>
13
+ <a href="{group.follow.remoteUserActorId}" rel="noopener noreferrer">{group.follow.handle}</a>
14
+ <form class="inline" method="post" action="/following/users/{group.follow.id}/unfollow">
15
+ <button class="btn btn-danger btn-sm">Unfollow user</button>
16
+ </form>
17
+ </h3>
18
+ {#if group.repositories}
19
+ <table>
20
+ <tr><th>Repository</th><th>State</th><th>Since</th></tr>
21
+ {#for follow in group.repositories}
22
+ <tr>
23
+ <td><a href="{follow.remoteActorId}" rel="noopener noreferrer">{follow.remoteActorId}</a></td>
24
+ <td>{#if follow.accepted}Accepted{#else}Pending{/if}</td>
25
+ <td>{follow.createdAt}</td>
26
+ </tr>
27
+ {/for}
28
+ </table>
29
+ {#else}
30
+ <p>No public repositories discovered for this user.</p>
31
+ {/if}
32
+</section>
33
+{/for}
34
+{/if}
35
+
36
+<h2>Followed repositories</h2>
7
37
<table>
8
38
<tr><th>Remote repository</th><th>State</th><th>Since</th><th class="actions"></th></tr>
9
39
{#for follow in follows}
@@ -19,6 +49,7 @@
19
49
</tr>
20
50
{/for}
21
51
</table>
52
+
22
53
{#if pushes}
23
54
<h2>Recent pushes</h2>
24
55
<table>
@@ -33,6 +64,13 @@
33
64
{/for}
34
65
</table>
35
66
{/if}
67
+
68
+<h2>Follow remote user</h2>
69
+<form method="post" action="/following/users">
70
+ <p><label>Handle or actor URL <input name="handle" placeholder="username@remote-host" required></label></p>
71
+ <button class="btn btn-primary">Follow user</button>
72
+</form>
73
+
36
74
<h2>Follow remote repository</h2>
37
75
<form method="post" action="/following">
38
76
<p><label>Handle or actor URL <input name="handle" placeholder="owner/name@remote-host" required></label></p>
ADD
src/test/java/de/workaround/federation/RemoteUserFollowTest.java
+154 -0
@@ -0,0 +1,154 @@
1
+package de.workaround.federation;
2
+
3
+import java.time.Instant;
4
+import java.util.HashMap;
5
+import java.util.List;
6
+import java.util.Map;
7
+import java.util.UUID;
8
+
9
+import org.junit.jupiter.api.BeforeEach;
10
+import org.junit.jupiter.api.Test;
11
+
12
+import de.workaround.model.DeliveryTask;
13
+import de.workaround.model.RemoteActor;
14
+import de.workaround.model.RemoteFollow;
15
+import de.workaround.model.RemoteUserFollow;
16
+import de.workaround.model.User;
17
+import io.quarkus.test.junit.QuarkusMock;
18
+import io.quarkus.test.junit.QuarkusTest;
19
+import jakarta.inject.Inject;
20
+import jakarta.transaction.Transactional;
21
+
22
+import static org.junit.jupiter.api.Assertions.assertEquals;
23
+import static org.junit.jupiter.api.Assertions.assertTrue;
24
+
25
+/**
26
+ * Following a remote user fans out to a repository follow per public repository the remote
27
+ * advertises; unfollowing the user undoes them all. The remote's repositories collection is stubbed
28
+ * (no live HTTP), and each repository actor is seeded into the remote-actor cache.
29
+ */
30
+@QuarkusTest
31
+class RemoteUserFollowTest
32
+{
33
+ @Inject
34
+ RemoteFollowService service;
35
+
36
+ @Inject
37
+ RemoteFollow.Repo follows;
38
+
39
+ @Inject
40
+ RemoteUserFollow.Repo userFollows;
41
+
42
+ @Inject
43
+ DeliveryTask.Repo deliveries;
44
+
45
+ @Inject
46
+ User.Repo userRepo;
47
+
48
+ /** Canned repositories collection per person actor id, consulted by the stubbed directory. */
49
+ private final Map<String, List<String>> canned = new HashMap<>();
50
+
51
+ @BeforeEach
52
+ void installDirectoryStub()
53
+ {
54
+ canned.clear();
55
+ QuarkusMock.installMockForType(new RemoteRepositoryDirectory()
56
+ {
57
+ @Override
58
+ public List<String> repositoriesOf(String personActorId)
59
+ {
60
+ return canned.getOrDefault(personActorId, List.of());
61
+ }
62
+ }, RemoteRepositoryDirectory.class);
63
+ }
64
+
65
+ @Test
66
+ void followUserFansOutToRepositoryFollows()
67
+ {
68
+ User user = persistUser("ruf-alice-" + unique());
69
+ String person = "https://peer.test/ap/users/bob-" + unique();
70
+ String repoOne = "https://peer.test/ap/repos/bob/one-" + unique();
71
+ String repoTwo = "https://peer.test/ap/repos/bob/two-" + unique();
72
+ seedRemoteActor(repoOne);
73
+ seedRemoteActor(repoTwo);
74
+ canned.put(person, List.of(repoOne, repoTwo));
75
+
76
+ RemoteUserFollow uf = service.followUser(user, person);
77
+
78
+ assertEquals(person, uf.remoteUserActorId);
79
+ List<RemoteFollow> repos = service.repositoriesOfFollowedUser(user, person);
80
+ assertEquals(2, repos.size());
81
+ assertTrue(repos.stream().allMatch(f -> person.equals(f.viaUserActorId)));
82
+ assertTrue(repos.stream().allMatch(f -> f.state == RemoteFollow.State.PENDING));
83
+ assertTrue(deliveries.findDue(Instant.now()).stream()
84
+ .anyMatch(t -> t.targetInbox.equals(repoOne + "/inbox") && t.payload.contains("\"Follow\"")));
85
+ assertTrue(deliveries.findDue(Instant.now()).stream()
86
+ .anyMatch(t -> t.targetInbox.equals(repoTwo + "/inbox") && t.payload.contains("\"Follow\"")));
87
+ }
88
+
89
+ @Test
90
+ void followUserIsIdempotent()
91
+ {
92
+ User user = persistUser("ruf-ida-" + unique());
93
+ String person = "https://peer.test/ap/users/bob-" + unique();
94
+ String repoOne = "https://peer.test/ap/repos/bob/one-" + unique();
95
+ seedRemoteActor(repoOne);
96
+ canned.put(person, List.of(repoOne));
97
+
98
+ RemoteUserFollow first = service.followUser(user, person);
99
+ RemoteUserFollow second = service.followUser(user, person);
100
+
101
+ assertEquals(first.id, second.id);
102
+ assertEquals(1, service.listUsers(user).size());
103
+ assertEquals(1, service.repositoriesOfFollowedUser(user, person).size());
104
+ }
105
+
106
+ @Test
107
+ void unfollowUserUndoesEveryDerivedFollow()
108
+ {
109
+ User user = persistUser("ruf-frank-" + unique());
110
+ String person = "https://peer.test/ap/users/bob-" + unique();
111
+ String repoOne = "https://peer.test/ap/repos/bob/one-" + unique();
112
+ String repoTwo = "https://peer.test/ap/repos/bob/two-" + unique();
113
+ seedRemoteActor(repoOne);
114
+ seedRemoteActor(repoTwo);
115
+ canned.put(person, List.of(repoOne, repoTwo));
116
+ RemoteUserFollow uf = service.followUser(user, person);
117
+
118
+ service.unfollowUser(user, uf.id);
119
+
120
+ assertTrue(service.listUsers(user).isEmpty());
121
+ assertTrue(service.repositoriesOfFollowedUser(user, person).isEmpty());
122
+ assertTrue(deliveries.findDue(Instant.now()).stream()
123
+ .anyMatch(t -> t.targetInbox.equals(repoOne + "/inbox") && t.payload.contains("\"Undo\"")));
124
+ }
125
+
126
+ @Transactional
127
+ void seedRemoteActor(String actorId)
128
+ {
129
+ RemoteActor actor = new RemoteActor();
130
+ actor.actorId = actorId;
131
+ actor.inbox = actorId + "/inbox";
132
+ actor.publicKeyPem = "-----BEGIN PUBLIC KEY-----\nstub\n-----END PUBLIC KEY-----";
133
+ actor.fetchedAt = Instant.now();
134
+ actor.persist();
135
+ }
136
+
137
+ @Transactional
138
+ User persistUser(String name)
139
+ {
140
+ return userRepo.findByOidcSubOptional(name).orElseGet(() -> {
141
+ User user = new User();
142
+ user.oidcSub = name;
143
+ user.username = name;
144
+ user.persist();
145
+ return user;
146
+ });
147
+ }
148
+
149
+ private static String unique()
150
+ {
151
+ return UUID.randomUUID().toString().substring(0, 8);
152
+ }
153
+
154
+}
MODIFY
src/test/java/de/workaround/web/FollowingPagesTest.java
+56 -0
@@ -1,6 +1,7 @@
1
1
package de.workaround.web;
2
2
3
3
import java.time.Instant;
4
+import java.util.List;
4
5
import java.util.UUID;
5
6
6
7
import org.junit.jupiter.api.Test;
@@ -9,7 +10,9 @@
9
10
import com.fasterxml.jackson.databind.node.ObjectNode;
10
11
11
12
import de.workaround.federation.ActivityDispatcher;
13
+import de.workaround.federation.RemoteRepositoryDirectory;
12
14
import de.workaround.model.RemoteActor;
15
+import io.quarkus.test.junit.QuarkusMock;
13
16
import io.quarkus.test.junit.QuarkusTest;
14
17
import io.quarkus.test.security.TestSecurity;
15
18
import jakarta.inject.Inject;
@@ -112,6 +115,59 @@
112
115
}
113
116
114
117
@Test
118
+ @TestSecurity(user = "user-follow-tester")
119
+ void followUserGroupsRepositoriesAndUnfollows()
120
+ {
121
+ String person = "https://peer.test/ap/users/bob-" + unique();
122
+ String repo = "https://peer.test/ap/repos/bob/lib-" + unique();
123
+ seedRemoteActor(repo, repo + "/inbox");
124
+ QuarkusMock.installMockForType(new RemoteRepositoryDirectory()
125
+ {
126
+ @Override
127
+ public List<String> repositoriesOf(String personActorId)
128
+ {
129
+ return person.equals(personActorId) ? List.of(repo) : List.of();
130
+ }
131
+ }, RemoteRepositoryDirectory.class);
132
+
133
+ given()
134
+ .when().get("/following")
135
+ .then()
136
+ .statusCode(200)
137
+ .body(containsString("Follow remote user"));
138
+
139
+ given()
140
+ .redirects().follow(false)
141
+ .formParam("handle", person)
142
+ .when().post("/following/users")
143
+ .then()
144
+ .statusCode(anyOf(is(302), is(303)));
145
+
146
+ String body = given()
147
+ .when().get("/following")
148
+ .then()
149
+ .statusCode(200)
150
+ .body(containsString(person))
151
+ .body(containsString(repo))
152
+ .extract().body().asString();
153
+
154
+ String userFollowId = body.substring(body.indexOf("/following/users/") + "/following/users/".length());
155
+ userFollowId = userFollowId.substring(0, userFollowId.indexOf("/unfollow"));
156
+
157
+ given()
158
+ .redirects().follow(false)
159
+ .when().post("/following/users/" + userFollowId + "/unfollow")
160
+ .then()
161
+ .statusCode(anyOf(is(302), is(303)));
162
+
163
+ given()
164
+ .when().get("/following")
165
+ .then()
166
+ .statusCode(200)
167
+ .body(not(containsString(repo)));
168
+ }
169
+
170
+ @Test
115
171
void anonymousIsRedirectedToLogin()
116
172
{
117
173
given()