✨ (federation): Follow remote repositories from the Following page
Changes
16 files changed, +883 -4
MODIFY
README.md
+3 -0
@@ -51,6 +51,9 @@
51
51
- A remote actor `POST`s a signed `Follow` to a repo inbox → recorded + `Accept`'d; `Undo` unfollows
52
52
- On push, a `Push` activity is published to the outbox and delivered to followers via a persisted,
53
53
retrying, HTTP-Signature-signed delivery queue
54
+- Outbound: logged-in users can follow a remote repository/actor from the **Following** page by
55
+ handle (`owner/name@host`) or actor URL; the handle is resolved via WebFinger and a signed `Follow`
56
+ is enqueued for delivery, tracked as PENDING until the remote `Accept`s (unfollow sends `Undo`)
54
57
55
58
Inbound activities must carry a valid HTTP Signature from an **allowlisted** peer; outbound fetches
56
59
are HTTPS-only, allowlist-bound, and SSRF-guarded (no private/loopback/link-local targets). Issues
ADD
src/main/java/de/workaround/federation/AcceptHandler.java
+43 -0
@@ -0,0 +1,43 @@
1
+package de.workaround.federation;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+
5
+import de.workaround.model.RemoteFollow;
6
+import jakarta.enterprise.context.ApplicationScoped;
7
+import jakarta.inject.Inject;
8
+import jakarta.transaction.Transactional;
9
+import org.jboss.logging.Logger;
10
+
11
+/**
12
+ * Handles an inbound {@code Accept} of a {@code Follow} we sent: marks the matching outbound
13
+ * follow {@code ACCEPTED}, but only when the accepting actor is the one that was followed.
14
+ */
15
+@ApplicationScoped
16
+public class AcceptHandler
17
+{
18
+ private static final Logger LOG = Logger.getLogger(AcceptHandler.class);
19
+
20
+ @Inject
21
+ RemoteFollow.Repo follows;
22
+
23
+ @Transactional
24
+ public void handle(JsonNode accept)
25
+ {
26
+ String followActivityId = LocalActors.idOf(accept.path("object"));
27
+ String acceptingActorId = LocalActors.idOf(accept.path("actor"));
28
+ if (followActivityId == null || acceptingActorId == null)
29
+ {
30
+ return;
31
+ }
32
+ follows.findByFollowActivityId(followActivityId).ifPresentOrElse(follow ->
33
+ {
34
+ if (!follow.remoteActorId.equals(acceptingActorId))
35
+ {
36
+ LOG.warnf("Ignoring Accept of %s from unexpected actor %s", followActivityId, acceptingActorId);
37
+ return;
38
+ }
39
+ follow.state = RemoteFollow.State.ACCEPTED;
40
+ }, () -> LOG.debugf("Accept references no known outbound follow: %s", followActivityId));
41
+ }
42
+
43
+}
MODIFY
src/main/java/de/workaround/federation/ActivityDispatcher.java
+6 -2
@@ -8,7 +8,8 @@
8
8
9
9
/**
10
10
* Routes a verified, deduplicated inbound activity to the right handler. Inbound {@code Accept}
11
- * (acknowledging a follow we sent) is logged; unknown types are stored-and-ignored.
11
+ * (acknowledging a follow we sent) confirms the outbound follow; unknown types are
12
+ * stored-and-ignored.
12
13
*/
13
14
@ApplicationScoped
14
15
public class ActivityDispatcher
@@ -21,6 +22,9 @@
21
22
@Inject
22
23
UndoHandler undoHandler;
23
24
25
+ @Inject
26
+ AcceptHandler acceptHandler;
27
+
24
28
/** Called within the inbox receipt transaction, after the activity id has been recorded. */
25
29
public void dispatch(JsonNode activity)
26
30
{
@@ -29,7 +33,7 @@
29
33
{
30
34
case "Follow" -> followHandler.handle(activity);
31
35
case "Undo" -> undoHandler.handle(activity);
32
- case "Accept" -> LOG.debugf("Received Accept: %s", activity.path("id").asText(""));
36
+ case "Accept" -> acceptHandler.handle(activity);
33
37
default -> LOG.debugf("Ignoring unsupported activity type: %s", type);
34
38
}
35
39
}
MODIFY
src/main/java/de/workaround/federation/ActivityPubClient.java
+57 -0
@@ -2,6 +2,7 @@
2
2
3
3
import java.io.IOException;
4
4
import java.net.URI;
5
+import java.net.URLEncoder;
5
6
import java.net.http.HttpClient;
6
7
import java.net.http.HttpRequest;
7
8
import java.net.http.HttpResponse;
@@ -37,6 +38,9 @@
37
38
RemoteUrlGuard guard;
38
39
39
40
@Inject
41
+ FederationConfig config;
42
+
43
+ @Inject
40
44
RemoteActor.Repo remoteActors;
41
45
42
46
@Inject
@@ -120,6 +124,59 @@
120
124
return fetchActor(actorId).map(actor -> ActorKeyService.parsePublic(actor.publicKeyPem));
121
125
}
122
126
127
+ /**
128
+ * Resolves {@code acct:{identifier}@{host}} via the host's WebFinger endpoint to the actor id in
129
+ * its {@code self} link. The host may carry a non-default port (dev two-host trials); https is
130
+ * used unless the dev-insecure flag permits http.
131
+ */
132
+ public Optional<String> resolveWebFinger(String identifier, String host)
133
+ {
134
+ String scheme = config.devAllowInsecure() ? "http" : "https";
135
+ String resource = "acct:" + identifier + "@" + host;
136
+ String url = scheme + "://" + host + "/.well-known/webfinger?resource="
137
+ + URLEncoder.encode(resource, java.nio.charset.StandardCharsets.UTF_8);
138
+ URI uri;
139
+ try
140
+ {
141
+ uri = guard.requireSafe(url);
142
+ }
143
+ catch (RemoteUrlGuard.UnsafeUrlException e)
144
+ {
145
+ return Optional.empty();
146
+ }
147
+ try
148
+ {
149
+ HttpResponse<String> response = http.send(
150
+ HttpRequest.newBuilder(uri)
151
+ .header("Accept", ActivityPubMedia.JRD_JSON)
152
+ .timeout(Duration.ofSeconds(15))
153
+ .GET().build(),
154
+ HttpResponse.BodyHandlers.ofString());
155
+ if (response.statusCode() / 100 != 2 || response.body().length() > MAX_RESPONSE_BYTES)
156
+ {
157
+ return Optional.empty();
158
+ }
159
+ JsonNode jrd = mapper.readTree(response.body());
160
+ for (JsonNode link : jrd.path("links"))
161
+ {
162
+ if ("self".equals(link.path("rel").asText("")))
163
+ {
164
+ return Optional.ofNullable(link.path("href").asText(null));
165
+ }
166
+ }
167
+ return Optional.empty();
168
+ }
169
+ catch (IOException e)
170
+ {
171
+ return Optional.empty();
172
+ }
173
+ catch (InterruptedException e)
174
+ {
175
+ Thread.currentThread().interrupt();
176
+ return Optional.empty();
177
+ }
178
+ }
179
+
123
180
/** Signs and POSTs the activity to the inbox, returning the outcome (never throws on network errors). */
124
181
public DeliveryOutcome deliver(String inbox, byte[] payload, String keyId, PrivateKey signingKey)
125
182
{
MODIFY
src/main/java/de/workaround/federation/ActorDocuments.java
+24 -0
@@ -135,6 +135,30 @@
135
135
return node;
136
136
}
137
137
138
+ /** Builds a {@code Follow} of a remote actor, attributed to a local actor. */
139
+ public ObjectNode follow(String actorId, String remoteActorId, String activityId)
140
+ {
141
+ ObjectNode node = mapper.createObjectNode();
142
+ node.put("@context", ActivityPubMedia.ACTIVITYSTREAMS);
143
+ node.put("id", activityId);
144
+ node.put("type", "Follow");
145
+ node.put("actor", actorId);
146
+ node.put("object", remoteActorId);
147
+ return node;
148
+ }
149
+
150
+ /** Builds an {@code Undo} of a previously sent activity, attributed to the same local actor. */
151
+ public ObjectNode undo(String actorId, ObjectNode activity)
152
+ {
153
+ ObjectNode node = mapper.createObjectNode();
154
+ node.put("@context", ActivityPubMedia.ACTIVITYSTREAMS);
155
+ node.put("id", actorId + "/activities/" + UUID.randomUUID());
156
+ node.put("type", "Undo");
157
+ node.put("actor", actorId);
158
+ node.set("object", activity);
159
+ return node;
160
+ }
161
+
138
162
/** Builds an {@code Accept} of an inbound {@code Follow}, attributed to the followed actor. */
139
163
public ObjectNode acceptFollow(String actorId, JsonNode follow)
140
164
{
ADD
src/main/java/de/workaround/federation/RemoteFollowService.java
+167 -0
@@ -0,0 +1,167 @@
1
+package de.workaround.federation;
2
+
3
+import java.util.List;
4
+import java.util.UUID;
5
+
6
+import com.fasterxml.jackson.core.JsonProcessingException;
7
+import com.fasterxml.jackson.databind.ObjectMapper;
8
+import com.fasterxml.jackson.databind.node.ObjectNode;
9
+
10
+import de.workaround.model.FederationKey;
11
+import de.workaround.model.RemoteActor;
12
+import de.workaround.model.RemoteFollow;
13
+import de.workaround.model.User;
14
+import jakarta.enterprise.context.ApplicationScoped;
15
+import jakarta.inject.Inject;
16
+import jakarta.transaction.Transactional;
17
+
18
+/**
19
+ * Outbound following of remote repositories: resolves a handle ({@code owner/name@host}) or actor
20
+ * URL, sends a {@code Follow} signed as the user's Person actor via the delivery queue, and records
21
+ * it as {@code PENDING} until the remote's {@code Accept} confirms it. Unfollowing sends an
22
+ * {@code Undo(Follow)} and removes the record.
23
+ */
24
+@ApplicationScoped
25
+public class RemoteFollowService
26
+{
27
+ /** A follow/unfollow request that cannot be fulfilled; the message is safe to show the user. */
28
+ public static class RemoteFollowException extends RuntimeException
29
+ {
30
+ public RemoteFollowException(String message)
31
+ {
32
+ super(message);
33
+ }
34
+ }
35
+
36
+ @Inject
37
+ FederationConfig config;
38
+
39
+ @Inject
40
+ ActivityPubClient client;
41
+
42
+ @Inject
43
+ ActorKeyService keyService;
44
+
45
+ @Inject
46
+ ActorUris uris;
47
+
48
+ @Inject
49
+ ActorDocuments documents;
50
+
51
+ @Inject
52
+ DeliveryService delivery;
53
+
54
+ @Inject
55
+ RemoteFollow.Repo follows;
56
+
57
+ @Inject
58
+ User.Repo users;
59
+
60
+ @Inject
61
+ ObjectMapper mapper;
62
+
63
+ /**
64
+ * Follows the remote repository named by {@code input} — an actor URL or a
65
+ * {@code owner/name@host} handle — as the given user. Idempotent per (user, remote actor).
66
+ */
67
+ @Transactional
68
+ public RemoteFollow follow(User user, String input)
69
+ {
70
+ requireOperational();
71
+ if (user.username == null)
72
+ {
73
+ throw new RemoteFollowException("Choose a username before following remote repositories");
74
+ }
75
+ String actorId = resolveActorId(input);
76
+ RemoteFollow existing = follows.findByUserAndRemoteActorId(user, actorId).orElse(null);
77
+ if (existing != null)
78
+ {
79
+ return existing;
80
+ }
81
+ RemoteActor remote = client.fetchActor(actorId)
82
+ .orElseThrow(() -> new RemoteFollowException("Could not resolve remote repository: " + input));
83
+
84
+ keyService.getOrCreate(FederationKey.ActorType.PERSON, user.id.toString());
85
+ String personId = uris.person(user);
86
+ String activityId = personId + "/activities/" + UUID.randomUUID();
87
+ ObjectNode followActivity = documents.follow(personId, actorId, activityId);
88
+
89
+ RemoteFollow follow = new RemoteFollow();
90
+ follow.user = users.findById(user.id); // re-attach — callers may pass a detached user
91
+ follow.remoteActorId = actorId;
92
+ follow.followActivityId = activityId;
93
+ follow.persist();
94
+
95
+ delivery.enqueue(remote.inbox, FederationKey.ActorType.PERSON, user.id.toString(),
96
+ uris.keyId(personId), bytes(followActivity));
97
+ return follow;
98
+ }
99
+
100
+ /** Sends an {@code Undo(Follow)} to the remote and removes the user's follow record. */
101
+ @Transactional
102
+ public void unfollow(User user, UUID followId)
103
+ {
104
+ RemoteFollow follow = follows.findById(followId);
105
+ if (follow == null || !follow.user.id.equals(user.id))
106
+ {
107
+ return; // not this user's follow — nothing to do
108
+ }
109
+ String personId = uris.person(user);
110
+ client.fetchActor(follow.remoteActorId).ifPresent(remote ->
111
+ {
112
+ ObjectNode original = documents.follow(personId, follow.remoteActorId, follow.followActivityId);
113
+ ObjectNode undo = documents.undo(personId, original);
114
+ delivery.enqueue(remote.inbox, FederationKey.ActorType.PERSON, user.id.toString(),
115
+ uris.keyId(personId), bytes(undo));
116
+ });
117
+ follows.deleteById(follow.id);
118
+ }
119
+
120
+ @Transactional
121
+ public List<RemoteFollow> list(User user)
122
+ {
123
+ return follows.findByUser(user);
124
+ }
125
+
126
+ /** Resolves the input to a remote actor id: pass URLs through, WebFinger-resolve handles. */
127
+ private String resolveActorId(String input)
128
+ {
129
+ String trimmed = input == null ? "" : input.trim();
130
+ if (trimmed.isEmpty())
131
+ {
132
+ throw new RemoteFollowException("Enter a repository handle (owner/name@host) or actor URL");
133
+ }
134
+ if (trimmed.startsWith("https://") || trimmed.startsWith("http://"))
135
+ {
136
+ return trimmed;
137
+ }
138
+ int at = trimmed.lastIndexOf('@');
139
+ if (at <= 0 || at == trimmed.length() - 1)
140
+ {
141
+ throw new RemoteFollowException("Not a valid handle — expected owner/name@host");
142
+ }
143
+ return client.resolveWebFinger(trimmed.substring(0, at), trimmed.substring(at + 1))
144
+ .orElseThrow(() -> new RemoteFollowException("Could not resolve handle: " + trimmed));
145
+ }
146
+
147
+ private void requireOperational()
148
+ {
149
+ if (!config.operational())
150
+ {
151
+ throw new RemoteFollowException("Federation is not enabled on this instance");
152
+ }
153
+ }
154
+
155
+ private byte[] bytes(ObjectNode node)
156
+ {
157
+ try
158
+ {
159
+ return mapper.writeValueAsBytes(node);
160
+ }
161
+ catch (JsonProcessingException e)
162
+ {
163
+ throw new IllegalStateException("Failed to serialize activity", e);
164
+ }
165
+ }
166
+
167
+}
MODIFY
src/main/java/de/workaround/federation/WebFingerResource.java
+3 -1
@@ -92,7 +92,9 @@
92
92
93
93
private String baseHost()
94
94
{
95
- return URI.create(config.baseUrl()).getHost();
95
+ // Authority, not host: keeps a non-default port significant (acct:owner/name@host:port),
96
+ // which the dev two-host trial on one machine relies on.
97
+ return URI.create(config.baseUrl()).getAuthority();
96
98
}
97
99
98
100
}
ADD
src/main/java/de/workaround/model/RemoteFollow.java
+74 -0
@@ -0,0 +1,74 @@
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.EnumType;
16
+import jakarta.persistence.Enumerated;
17
+import jakarta.persistence.GeneratedValue;
18
+import jakarta.persistence.GenerationType;
19
+import jakarta.persistence.Id;
20
+import jakarta.persistence.ManyToOne;
21
+import jakarta.persistence.Table;
22
+
23
+/**
24
+ * A local user's follow of a repository on a remote instance. {@code followActivityId} is the id
25
+ * of the {@code Follow} activity we sent; the remote's {@code Accept} references it to confirm the
26
+ * follow ({@code PENDING} → {@code ACCEPTED}).
27
+ */
28
+@Entity
29
+@Table(name = "remote_follows")
30
+public class RemoteFollow 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 remoteActorId;
41
+
42
+ @Column(columnDefinition = "text")
43
+ public String followActivityId;
44
+
45
+ @Enumerated(EnumType.STRING)
46
+ public State state = State.PENDING;
47
+
48
+ public Instant createdAt = Instant.now();
49
+
50
+ /** Template convenience: whether the remote has confirmed the follow. */
51
+ public boolean isAccepted()
52
+ {
53
+ return state == State.ACCEPTED;
54
+ }
55
+
56
+ public enum State
57
+ {
58
+ PENDING,
59
+ ACCEPTED
60
+ }
61
+
62
+ public interface Repo extends PanacheRepository.Managed<RemoteFollow, UUID>
63
+ {
64
+ @Find
65
+ Optional<RemoteFollow> findByUserAndRemoteActorId(User user, String remoteActorId);
66
+
67
+ @Find
68
+ Optional<RemoteFollow> findByFollowActivityId(String followActivityId);
69
+
70
+ @HQL("select f from RemoteFollow f where f.user = :user order by f.createdAt")
71
+ List<RemoteFollow> findByUser(User user);
72
+ }
73
+
74
+}
ADD
src/main/java/de/workaround/web/FollowingResource.java
+73 -0
@@ -0,0 +1,73 @@
1
+package de.workaround.web;
2
+
3
+import java.net.URI;
4
+import java.util.List;
5
+import java.util.UUID;
6
+
7
+import de.workaround.account.CurrentUser;
8
+import de.workaround.federation.RemoteFollowService;
9
+import de.workaround.model.RemoteFollow;
10
+import de.workaround.model.User;
11
+import io.quarkus.qute.CheckedTemplate;
12
+import io.quarkus.qute.TemplateInstance;
13
+import jakarta.inject.Inject;
14
+import jakarta.ws.rs.Consumes;
15
+import jakarta.ws.rs.FormParam;
16
+import jakarta.ws.rs.GET;
17
+import jakarta.ws.rs.POST;
18
+import jakarta.ws.rs.Path;
19
+import jakarta.ws.rs.PathParam;
20
+import jakarta.ws.rs.Produces;
21
+import jakarta.ws.rs.core.MediaType;
22
+import jakarta.ws.rs.core.Response;
23
+
24
+/** Follows of repositories on remote instances: list, follow by handle/URL, unfollow. */
25
+@Path("/following")
26
+@Produces(MediaType.TEXT_HTML)
27
+public class FollowingResource
28
+{
29
+ @CheckedTemplate
30
+ static class Templates
31
+ {
32
+ static native TemplateInstance following(List<RemoteFollow> follows, String error);
33
+ }
34
+
35
+ @Inject
36
+ CurrentUser currentUser;
37
+
38
+ @Inject
39
+ RemoteFollowService service;
40
+
41
+ @GET
42
+ public TemplateInstance list()
43
+ {
44
+ return Templates.following(service.list(currentUser.require()), null);
45
+ }
46
+
47
+ @POST
48
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
49
+ public Response follow(@FormParam("handle") String handle)
50
+ {
51
+ User user = currentUser.require();
52
+ try
53
+ {
54
+ service.follow(user, handle);
55
+ return Response.seeOther(URI.create("/following")).build();
56
+ }
57
+ catch (RemoteFollowService.RemoteFollowException e)
58
+ {
59
+ return Response.status(Response.Status.BAD_REQUEST)
60
+ .entity(Templates.following(service.list(user), e.getMessage()))
61
+ .build();
62
+ }
63
+ }
64
+
65
+ @POST
66
+ @Path("{id}/unfollow")
67
+ public Response unfollow(@PathParam("id") UUID id)
68
+ {
69
+ service.unfollow(currentUser.require(), id);
70
+ return Response.seeOther(URI.create("/following")).build();
71
+ }
72
+
73
+}
MODIFY
src/main/resources/application.properties
+1 -1
@@ -52,7 +52,7 @@
52
52
%dev.gitshark.dev.adopt-username=true
53
53
54
54
# Pages that require a logged-in user (git transport does its own auth)
55
-quarkus.http.auth.permission.authenticated.paths=/settings/*,/repos/new,/login,/onboarding
55
+quarkus.http.auth.permission.authenticated.paths=/settings/*,/repos/new,/login,/onboarding,/following,/following/*
56
56
quarkus.http.auth.permission.authenticated.policy=authenticated
57
57
58
58
# Native image: JGit holds static Random/lazy state that must not be build-time initialized
ADD
src/main/resources/db/migration/V9__remote_follows.sql
+13 -0
@@ -0,0 +1,13 @@
1
+-- Outbound follows: a local user following a repository on a remote instance.
2
+-- follow_activity_id is the id of the Follow activity we sent; the inbound Accept
3
+-- references it to confirm the follow (state PENDING -> ACCEPTED).
4
+create table remote_follows
5
+(
6
+ id uuid primary key,
7
+ user_id uuid not null references users (id) on delete cascade,
8
+ remote_actor_id text not null,
9
+ follow_activity_id text not null unique,
10
+ state varchar(16) not null default 'PENDING' check (state in ('PENDING', 'ACCEPTED')),
11
+ created_at timestamptz not null default now(),
12
+ unique (user_id, remote_actor_id)
13
+);
ADD
src/main/resources/templates/FollowingResource/following.html
+27 -0
@@ -0,0 +1,27 @@
1
+{#include layout}
2
+{#title}Following – git-shark{/title}
3
+<h1>Following</h1>
4
+{#if error}
5
+<p class="error">{error}</p>
6
+{/if}
7
+<table>
8
+ <tr><th>Remote repository</th><th>State</th><th>Since</th><th class="actions"></th></tr>
9
+ {#for follow in follows}
10
+ <tr>
11
+ <td><a href="{follow.remoteActorId}" rel="noopener noreferrer">{follow.remoteActorId}</a></td>
12
+ <td>{#if follow.accepted}Accepted{#else}Pending{/if}</td>
13
+ <td>{follow.createdAt}</td>
14
+ <td class="actions">
15
+ <form class="inline" method="post" action="/following/{follow.id}/unfollow">
16
+ <button class="btn btn-danger btn-sm">Unfollow</button>
17
+ </form>
18
+ </td>
19
+ </tr>
20
+ {/for}
21
+</table>
22
+<h2>Follow remote repository</h2>
23
+<form method="post" action="/following">
24
+ <p><label>Handle or actor URL <input name="handle" placeholder="owner/name@remote-host" required></label></p>
25
+ <button class="btn btn-primary">Follow</button>
26
+</form>
27
+{/include}
MODIFY
src/main/resources/templates/layout.html
+1 -0
@@ -14,6 +14,7 @@
14
14
<nav>
15
15
{#insert nav}
16
16
{#if cdi:currentUser.loggedIn}
17
+ <a href="/following">Following</a>
17
18
<a href="/settings/profile">Profile</a>
18
19
<a href="/settings/keys">SSH keys</a>
19
20
<a href="/settings/tokens">Access tokens</a>
ADD
src/test/java/de/workaround/federation/RemoteFollowServiceTest.java
+188 -0
@@ -0,0 +1,188 @@
1
+package de.workaround.federation;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.UUID;
6
+
7
+import org.junit.jupiter.api.Test;
8
+
9
+import com.fasterxml.jackson.databind.ObjectMapper;
10
+import com.fasterxml.jackson.databind.node.ObjectNode;
11
+
12
+import de.workaround.model.DeliveryTask;
13
+import de.workaround.model.RemoteActor;
14
+import de.workaround.model.RemoteFollow;
15
+import de.workaround.model.User;
16
+import io.quarkus.test.junit.QuarkusTest;
17
+import jakarta.inject.Inject;
18
+import jakarta.transaction.Transactional;
19
+
20
+import static org.junit.jupiter.api.Assertions.assertEquals;
21
+import static org.junit.jupiter.api.Assertions.assertThrows;
22
+import static org.junit.jupiter.api.Assertions.assertTrue;
23
+
24
+/**
25
+ * Outbound following of a remote repository: a signed {@code Follow} is enqueued and recorded as
26
+ * PENDING, an inbound {@code Accept} confirms it, and unfollowing enqueues an {@code Undo}.
27
+ */
28
+@QuarkusTest
29
+class RemoteFollowServiceTest
30
+{
31
+ @Inject
32
+ RemoteFollowService service;
33
+
34
+ @Inject
35
+ ActivityDispatcher dispatcher;
36
+
37
+ @Inject
38
+ ActorUris uris;
39
+
40
+ @Inject
41
+ RemoteFollow.Repo follows;
42
+
43
+ @Inject
44
+ DeliveryTask.Repo deliveries;
45
+
46
+ @Inject
47
+ User.Repo userRepo;
48
+
49
+ @Inject
50
+ ObjectMapper mapper;
51
+
52
+ @Test
53
+ void followByActorUrlRecordsPendingAndEnqueuesSignedFollow()
54
+ {
55
+ User user = persistUser("rf-alice-" + unique());
56
+ String remote = "https://peer.test/ap/repos/bob/lib-" + unique();
57
+ seedRemoteActor(remote, remote + "/inbox");
58
+
59
+ RemoteFollow follow = service.follow(user, remote);
60
+
61
+ assertEquals(RemoteFollow.State.PENDING, follow.state);
62
+ assertEquals(remote, follow.remoteActorId);
63
+ String personKeyId = uris.keyId(uris.person(user));
64
+ assertTrue(deliveries.findDue(Instant.now()).stream()
65
+ .anyMatch(t -> t.targetInbox.equals(remote + "/inbox")
66
+ && t.signerKeyId.equals(personKeyId)
67
+ && t.payload.contains("\"Follow\"")
68
+ && t.payload.contains(follow.followActivityId)));
69
+ }
70
+
71
+ @Test
72
+ void followTwiceIsIdempotent()
73
+ {
74
+ User user = persistUser("rf-ida-" + unique());
75
+ String remote = "https://peer.test/ap/repos/bob/lib-" + unique();
76
+ seedRemoteActor(remote, remote + "/inbox");
77
+
78
+ RemoteFollow first = service.follow(user, remote);
79
+ RemoteFollow second = service.follow(user, remote);
80
+
81
+ assertEquals(first.id, second.id);
82
+ }
83
+
84
+ @Test
85
+ void followOfUnresolvableActorFails()
86
+ {
87
+ User user = persistUser("rf-eve-" + unique());
88
+
89
+ assertThrows(RemoteFollowService.RemoteFollowException.class,
90
+ () -> service.follow(user, "https://not-allowlisted.test/ap/repos/x/y"));
91
+ }
92
+
93
+ @Test
94
+ void inboundAcceptMarksFollowAccepted()
95
+ {
96
+ User user = persistUser("rf-carol-" + unique());
97
+ String remote = "https://peer.test/ap/repos/bob/lib-" + unique();
98
+ seedRemoteActor(remote, remote + "/inbox");
99
+ RemoteFollow follow = service.follow(user, remote);
100
+
101
+ dispatcher.dispatch(accept(remote, follow.followActivityId));
102
+
103
+ assertEquals(RemoteFollow.State.ACCEPTED, stateOf(follow.id));
104
+ }
105
+
106
+ @Test
107
+ void acceptFromWrongActorIsIgnored()
108
+ {
109
+ User user = persistUser("rf-dan-" + unique());
110
+ String remote = "https://peer.test/ap/repos/bob/lib-" + unique();
111
+ seedRemoteActor(remote, remote + "/inbox");
112
+ RemoteFollow follow = service.follow(user, remote);
113
+
114
+ dispatcher.dispatch(accept("https://peer.test/ap/repos/mallory/other", follow.followActivityId));
115
+
116
+ assertEquals(RemoteFollow.State.PENDING, stateOf(follow.id));
117
+ }
118
+
119
+ @Test
120
+ void unfollowEnqueuesUndoAndRemovesFollow()
121
+ {
122
+ User user = persistUser("rf-frank-" + unique());
123
+ String remote = "https://peer.test/ap/repos/bob/lib-" + unique();
124
+ seedRemoteActor(remote, remote + "/inbox");
125
+ RemoteFollow follow = service.follow(user, remote);
126
+
127
+ service.unfollow(user, follow.id);
128
+
129
+ assertTrue(list(user).isEmpty());
130
+ assertTrue(deliveries.findDue(Instant.now()).stream()
131
+ .anyMatch(t -> t.targetInbox.equals(remote + "/inbox")
132
+ && t.payload.contains("\"Undo\"")
133
+ && t.payload.contains(follow.followActivityId)));
134
+ }
135
+
136
+ private ObjectNode accept(String actor, String followActivityId)
137
+ {
138
+ ObjectNode node = mapper.createObjectNode();
139
+ node.put("id", "https://peer.test/activities/" + UUID.randomUUID());
140
+ node.put("type", "Accept");
141
+ node.put("actor", actor);
142
+ ObjectNode follow = node.putObject("object");
143
+ follow.put("id", followActivityId);
144
+ follow.put("type", "Follow");
145
+ return node;
146
+ }
147
+
148
+ @Transactional
149
+ RemoteFollow.State stateOf(UUID followId)
150
+ {
151
+ return follows.findById(followId).state;
152
+ }
153
+
154
+ @Transactional
155
+ List<RemoteFollow> list(User user)
156
+ {
157
+ return follows.findByUser(userRepo.findById(user.id));
158
+ }
159
+
160
+ @Transactional
161
+ void seedRemoteActor(String actorId, String inbox)
162
+ {
163
+ RemoteActor actor = new RemoteActor();
164
+ actor.actorId = actorId;
165
+ actor.inbox = inbox;
166
+ actor.publicKeyPem = "-----BEGIN PUBLIC KEY-----\nstub\n-----END PUBLIC KEY-----";
167
+ actor.fetchedAt = Instant.now();
168
+ actor.persist();
169
+ }
170
+
171
+ @Transactional
172
+ User persistUser(String name)
173
+ {
174
+ return userRepo.findByOidcSubOptional(name).orElseGet(() -> {
175
+ User user = new User();
176
+ user.oidcSub = name;
177
+ user.username = name;
178
+ user.persist();
179
+ return user;
180
+ });
181
+ }
182
+
183
+ private static String unique()
184
+ {
185
+ return UUID.randomUUID().toString().substring(0, 8);
186
+ }
187
+
188
+}
ADD
src/test/java/de/workaround/federation/WebFingerResolveTest.java
+98 -0
@@ -0,0 +1,98 @@
1
+package de.workaround.federation;
2
+
3
+import java.util.Map;
4
+import java.util.Optional;
5
+import java.util.UUID;
6
+
7
+import org.junit.jupiter.api.Test;
8
+
9
+import de.workaround.git.GitRepositoryService;
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.User;
12
+import io.quarkus.test.junit.QuarkusTest;
13
+import io.quarkus.test.junit.QuarkusTestProfile;
14
+import io.quarkus.test.junit.TestProfile;
15
+import jakarta.inject.Inject;
16
+import jakarta.transaction.Transactional;
17
+
18
+import static org.junit.jupiter.api.Assertions.assertEquals;
19
+import static org.junit.jupiter.api.Assertions.assertTrue;
20
+
21
+/**
22
+ * Client-side WebFinger resolution against this instance's own endpoint: a repository handle
23
+ * ({@code owner/name@host}) resolves to the repository's actor id, including a non-default port in
24
+ * the host (the dev two-host trial setup).
25
+ */
26
+@QuarkusTest
27
+@TestProfile(WebFingerResolveTest.SelfResolveProfile.class)
28
+class WebFingerResolveTest
29
+{
30
+ public static class SelfResolveProfile implements QuarkusTestProfile
31
+ {
32
+ @Override
33
+ public Map<String, String> getConfigOverrides()
34
+ {
35
+ return Map.of(
36
+ "quarkus.http.test-port", "8082",
37
+ "gitshark.federation.enabled", "true",
38
+ "gitshark.federation.base-url", "http://localhost:8082",
39
+ "gitshark.federation.dev-allow-insecure", "true",
40
+ "gitshark.federation.peer-allowlist", "localhost,127.0.0.1");
41
+ }
42
+ }
43
+
44
+ @Inject
45
+ ActivityPubClient client;
46
+
47
+ @Inject
48
+ GitRepositoryService service;
49
+
50
+ @Inject
51
+ ActorUris uris;
52
+
53
+ @Inject
54
+ User.Repo userRepo;
55
+
56
+ @Test
57
+ void resolvesRepositoryHandleToActorId()
58
+ {
59
+ User owner = persistUser("wf-bob-" + unique());
60
+ Repository repo = createRepo(owner, "lib-" + unique());
61
+
62
+ Optional<String> actorId =
63
+ client.resolveWebFinger(owner.username + "/" + repo.name, "localhost:8082");
64
+
65
+ assertTrue(actorId.isPresent(), "handle must resolve via WebFinger");
66
+ assertEquals(uris.repository(repo), actorId.get());
67
+ }
68
+
69
+ @Test
70
+ void unknownHandleResolvesEmpty()
71
+ {
72
+ assertTrue(client.resolveWebFinger("nobody/nothing-" + unique(), "localhost:8082").isEmpty());
73
+ }
74
+
75
+ @Transactional
76
+ Repository createRepo(User owner, String name)
77
+ {
78
+ return service.create(owner, name, Repository.Visibility.PUBLIC, null);
79
+ }
80
+
81
+ @Transactional
82
+ User persistUser(String name)
83
+ {
84
+ return userRepo.findByOidcSubOptional(name).orElseGet(() -> {
85
+ User user = new User();
86
+ user.oidcSub = name;
87
+ user.username = name;
88
+ user.persist();
89
+ return user;
90
+ });
91
+ }
92
+
93
+ private static String unique()
94
+ {
95
+ return UUID.randomUUID().toString().substring(0, 8);
96
+ }
97
+
98
+}
ADD
src/test/java/de/workaround/web/FollowingPagesTest.java
+105 -0
@@ -0,0 +1,105 @@
1
+package de.workaround.web;
2
+
3
+import java.time.Instant;
4
+import java.util.UUID;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import de.workaround.model.RemoteActor;
9
+import io.quarkus.test.junit.QuarkusTest;
10
+import io.quarkus.test.security.TestSecurity;
11
+import jakarta.transaction.Transactional;
12
+
13
+import static io.restassured.RestAssured.given;
14
+import static org.hamcrest.CoreMatchers.containsString;
15
+import static org.hamcrest.CoreMatchers.not;
16
+import static org.hamcrest.Matchers.anyOf;
17
+import static org.hamcrest.Matchers.is;
18
+
19
+/** The /following page: follow a remote repository by handle/URL, list follows, unfollow. */
20
+@QuarkusTest
21
+class FollowingPagesTest
22
+{
23
+ @Test
24
+ @TestSecurity(user = "following-tester")
25
+ void followListAndUnfollowRoundTrip()
26
+ {
27
+ String remote = "https://peer.test/ap/repos/bob/lib-" + unique();
28
+ seedRemoteActor(remote, remote + "/inbox");
29
+
30
+ given()
31
+ .when().get("/following")
32
+ .then()
33
+ .statusCode(200)
34
+ .body(containsString("Follow remote repository"));
35
+
36
+ given()
37
+ .redirects().follow(false)
38
+ .formParam("handle", remote)
39
+ .when().post("/following")
40
+ .then()
41
+ .statusCode(anyOf(is(302), is(303)));
42
+
43
+ String body = given()
44
+ .when().get("/following")
45
+ .then()
46
+ .statusCode(200)
47
+ .body(containsString(remote))
48
+ .body(containsString("Pending"))
49
+ .extract().body().asString();
50
+
51
+ String followId = body.substring(body.indexOf("/following/") + "/following/".length());
52
+ followId = followId.substring(0, followId.indexOf("/unfollow"));
53
+
54
+ given()
55
+ .redirects().follow(false)
56
+ .when().post("/following/" + followId + "/unfollow")
57
+ .then()
58
+ .statusCode(anyOf(is(302), is(303)));
59
+
60
+ given()
61
+ .when().get("/following")
62
+ .then()
63
+ .statusCode(200)
64
+ .body(not(containsString(remote)));
65
+ }
66
+
67
+ @Test
68
+ @TestSecurity(user = "following-err-tester")
69
+ void unresolvableHandleShowsError()
70
+ {
71
+ given()
72
+ .formParam("handle", "https://not-allowlisted.test/ap/repos/x/y")
73
+ .when().post("/following")
74
+ .then()
75
+ .statusCode(400)
76
+ .body(containsString("Could not resolve"));
77
+ }
78
+
79
+ @Test
80
+ void anonymousIsRedirectedToLogin()
81
+ {
82
+ given()
83
+ .redirects().follow(false)
84
+ .when().get("/following")
85
+ .then()
86
+ .statusCode(302);
87
+ }
88
+
89
+ @Transactional
90
+ void seedRemoteActor(String actorId, String inbox)
91
+ {
92
+ RemoteActor actor = new RemoteActor();
93
+ actor.actorId = actorId;
94
+ actor.inbox = inbox;
95
+ actor.publicKeyPem = "-----BEGIN PUBLIC KEY-----\nstub\n-----END PUBLIC KEY-----";
96
+ actor.fetchedAt = Instant.now();
97
+ actor.persist();
98
+ }
99
+
100
+ private static String unique()
101
+ {
102
+ return UUID.randomUUID().toString().substring(0, 8);
103
+ }
104
+
105
+}