gitshark

Clone repository

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

← Commits

✨ (federation): Follow remote repositories from the Following page

08571361b52baa2fccea348c545173105863e504 · Miggi · 2026-07-08T15:19:42Z

Changes

16 files changed, +883 -4

MODIFY README.md +3 -0
diff --git a/README.md b/README.md
index 007fa33..7f69fac 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/main/java/de/workaround/federation/AcceptHandler.java b/src/main/java/de/workaround/federation/AcceptHandler.java
new file mode 100644
index 0000000..523e458
--- /dev/null
+++ b/src/main/java/de/workaround/federation/AcceptHandler.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActivityDispatcher.java b/src/main/java/de/workaround/federation/ActivityDispatcher.java
index 985143d..f38a7bd 100644
--- a/src/main/java/de/workaround/federation/ActivityDispatcher.java
+++ b/src/main/java/de/workaround/federation/ActivityDispatcher.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActivityPubClient.java b/src/main/java/de/workaround/federation/ActivityPubClient.java
index 3fc64f4..1897e31 100644
--- a/src/main/java/de/workaround/federation/ActivityPubClient.java
+++ b/src/main/java/de/workaround/federation/ActivityPubClient.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActorDocuments.java b/src/main/java/de/workaround/federation/ActorDocuments.java
index 9b626ce..21baa30 100644
--- a/src/main/java/de/workaround/federation/ActorDocuments.java
+++ b/src/main/java/de/workaround/federation/ActorDocuments.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/RemoteFollowService.java b/src/main/java/de/workaround/federation/RemoteFollowService.java
new file mode 100644
index 0000000..0bc393a
--- /dev/null
+++ b/src/main/java/de/workaround/federation/RemoteFollowService.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/WebFingerResource.java b/src/main/java/de/workaround/federation/WebFingerResource.java
index ca3edab..eee7957 100644
--- a/src/main/java/de/workaround/federation/WebFingerResource.java
+++ b/src/main/java/de/workaround/federation/WebFingerResource.java
@@ -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
diff --git a/src/main/java/de/workaround/model/RemoteFollow.java b/src/main/java/de/workaround/model/RemoteFollow.java
new file mode 100644
index 0000000..903b22a
--- /dev/null
+++ b/src/main/java/de/workaround/model/RemoteFollow.java
@@ -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} &#8594; {@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
diff --git a/src/main/java/de/workaround/web/FollowingResource.java b/src/main/java/de/workaround/web/FollowingResource.java
new file mode 100644
index 0000000..b882647
--- /dev/null
+++ b/src/main/java/de/workaround/web/FollowingResource.java
@@ -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
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index b2e1b0c..1f31ce2 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -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
diff --git a/src/main/resources/db/migration/V9__remote_follows.sql b/src/main/resources/db/migration/V9__remote_follows.sql
new file mode 100644
index 0000000..cc2c17f
--- /dev/null
+++ b/src/main/resources/db/migration/V9__remote_follows.sql
@@ -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
diff --git a/src/main/resources/templates/FollowingResource/following.html b/src/main/resources/templates/FollowingResource/following.html
new file mode 100644
index 0000000..c5a4a89
--- /dev/null
+++ b/src/main/resources/templates/FollowingResource/following.html
@@ -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
diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html
index 9c370a5..20878f1 100644
--- a/src/main/resources/templates/layout.html
+++ b/src/main/resources/templates/layout.html
@@ -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
diff --git a/src/test/java/de/workaround/federation/RemoteFollowServiceTest.java b/src/test/java/de/workaround/federation/RemoteFollowServiceTest.java
new file mode 100644
index 0000000..37557b8
--- /dev/null
+++ b/src/test/java/de/workaround/federation/RemoteFollowServiceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/WebFingerResolveTest.java b/src/test/java/de/workaround/federation/WebFingerResolveTest.java
new file mode 100644
index 0000000..3c2f62b
--- /dev/null
+++ b/src/test/java/de/workaround/federation/WebFingerResolveTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/FollowingPagesTest.java b/src/test/java/de/workaround/web/FollowingPagesTest.java
new file mode 100644
index 0000000..73aaf81
--- /dev/null
+++ b/src/test/java/de/workaround/web/FollowingPagesTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog