gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (federation): Follow a remote user and see their repositories grouped

82f208e1a6634d73a0af297484fa1ce146ad6c8d · Michael Hainz · 2026-07-16T18:02:27Z

Changes

15 files changed, +642 -28

MODIFY README.md +2 -0
diff --git a/README.md b/README.md
index 117f195..4df8f23 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/admins/federation.md b/docs/admins/federation.md
index 3ae7b80..9604e79 100644
--- a/docs/admins/federation.md
+++ b/docs/admins/federation.md
@@ -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
diff --git a/docs/maintainers/federation-roadmap.md b/docs/maintainers/federation-roadmap.md
index 5407bbf..e5d1b9b 100644
--- a/docs/maintainers/federation-roadmap.md
+++ b/docs/maintainers/federation-roadmap.md
@@ -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
diff --git a/docs/maintainers/forgefed.md b/docs/maintainers/forgefed.md
index ac0a56a..e6dc97f 100644
--- a/docs/maintainers/forgefed.md
+++ b/docs/maintainers/forgefed.md
@@ -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
diff --git a/docs/users/federation.md b/docs/users/federation.md
index 0fe0619..8c0543f 100644
--- a/docs/users/federation.md
+++ b/docs/users/federation.md
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActivityPubClient.java b/src/main/java/de/workaround/federation/ActivityPubClient.java
index e31be1b..8b4fb10 100644
--- a/src/main/java/de/workaround/federation/ActivityPubClient.java
+++ b/src/main/java/de/workaround/federation/ActivityPubClient.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/RemoteFollowService.java b/src/main/java/de/workaround/federation/RemoteFollowService.java
index 50daae4..33b1ee6 100644
--- a/src/main/java/de/workaround/federation/RemoteFollowService.java
+++ b/src/main/java/de/workaround/federation/RemoteFollowService.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/RemoteRepositoryDirectory.java b/src/main/java/de/workaround/federation/RemoteRepositoryDirectory.java
new file mode 100644
index 0000000..065a37d
--- /dev/null
+++ b/src/main/java/de/workaround/federation/RemoteRepositoryDirectory.java
@@ -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&#8596;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
diff --git a/src/main/java/de/workaround/model/RemoteFollow.java b/src/main/java/de/workaround/model/RemoteFollow.java
index 627944f..f5ffc74 100644
--- a/src/main/java/de/workaround/model/RemoteFollow.java
+++ b/src/main/java/de/workaround/model/RemoteFollow.java
@@ -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
diff --git a/src/main/java/de/workaround/model/RemoteUserFollow.java b/src/main/java/de/workaround/model/RemoteUserFollow.java
new file mode 100644
index 0000000..4bedcd0
--- /dev/null
+++ b/src/main/java/de/workaround/model/RemoteUserFollow.java
@@ -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
diff --git a/src/main/java/de/workaround/web/FollowingResource.java b/src/main/java/de/workaround/web/FollowingResource.java
index e7b636d..046b518 100644
--- a/src/main/java/de/workaround/web/FollowingResource.java
+++ b/src/main/java/de/workaround/web/FollowingResource.java
@@ -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
diff --git a/src/main/resources/db/migration/V19__federation_user_follows.sql b/src/main/resources/db/migration/V19__federation_user_follows.sql
new file mode 100644
index 0000000..46084c4
--- /dev/null
+++ b/src/main/resources/db/migration/V19__federation_user_follows.sql
@@ -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
diff --git a/src/main/resources/templates/FollowingResource/following.html b/src/main/resources/templates/FollowingResource/following.html
index 5026452..2364476 100644
--- a/src/main/resources/templates/FollowingResource/following.html
+++ b/src/main/resources/templates/FollowingResource/following.html
@@ -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
diff --git a/src/test/java/de/workaround/federation/RemoteUserFollowTest.java b/src/test/java/de/workaround/federation/RemoteUserFollowTest.java
new file mode 100644
index 0000000..77ba1d2
--- /dev/null
+++ b/src/test/java/de/workaround/federation/RemoteUserFollowTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/FollowingPagesTest.java b/src/test/java/de/workaround/web/FollowingPagesTest.java
index cc0f300..c42d464 100644
--- a/src/test/java/de/workaround/web/FollowingPagesTest.java
+++ b/src/test/java/de/workaround/web/FollowingPagesTest.java
@@ -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()

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog