✨ (repos): Let owners change repository visibility in settings
Changes
8 files changed, +240 -1
MODIFY
README.md
+1 -1
@@ -8,7 +8,7 @@
8
8
9
9
## Features
10
10
11
-- Create, browse, and delete personal repositories (public or private)
11
+- Create, browse, and delete personal repositories (public or private); the owner can switch a repository between public and private later on its Settings page
12
12
- **Organisations** — shared repository namespaces with guest/member/owner roles. An org owns
13
13
repositories exactly like a user (`/repos/<org>/<repo>`, same clone URL forms); org and user
14
14
names share one collision-checked handle namespace. Guests read private org repos, members
MODIFY
docs/README.md
+2 -0
@@ -12,6 +12,8 @@
12
12
name, upload or remove a profile picture.
13
13
- **[Repository image](users/repository-image.md)** — give a repository its own
14
14
picture instead of your avatar, from its owner-only Settings page.
15
+- **[Repository visibility](users/repository-visibility.md)** — what public and
16
+ private mean, and how the owner switches between them on the Settings page.
15
17
- **[Federation](users/federation.md)** — follow public repositories on other
16
18
instances, the push feed, your federated identity.
17
19
- **[Push mirrors](users/mirrors.md)** — replicate a repository to an external
MODIFY
docs/maintainers/forgefed.md
+6 -0
@@ -189,6 +189,12 @@
189
189
masks it briefly, then verification fails until re-fetch).
190
190
- **Shared inbox** — deliveries go per-follower inbox; N followers on one host
191
191
mean N deliveries.
192
+- **Visibility flips are silent** — switching a public repository to private
193
+ (owner Settings page) stops actor exposure and push fan-out immediately
194
+ (`FederationPushService` re-checks visibility per push), but existing remote
195
+ followers are kept in `repository_followers` and receive no `Reject`/`Delete`;
196
+ the remote side still lists the follow as accepted. Switching back to public
197
+ resumes fan-out to those retained followers.
192
198
- **ForgeFed beyond Push**: `Ticket` (federated issues), patches/merge-request
193
199
offers, `Fork`, stars/watch semantics. Issues, MRs, and comments are
194
200
local-only today.
ADD
docs/users/repository-visibility.md
+33 -0
@@ -0,0 +1,33 @@
1
+# Repository visibility
2
+
3
+Every repository is either **public** or **private**. You choose the visibility
4
+when creating the repository, and the owner can change it later at any time.
5
+
6
+## What visibility means
7
+
8
+- **Public** — anyone can see, browse, and clone the repository, without an
9
+ account. If the instance has [federation](federation.md) enabled, the
10
+ repository is also discoverable and followable from other ForgeFed servers.
11
+- **Private** — the repository is visible only to you, your
12
+ [collaborators](collaborators.md), and (for organisation repositories)
13
+ organisation members. To everyone else it does not exist: they get a
14
+ 404, not a permission error.
15
+
16
+## Changing the visibility
17
+
18
+1. Open the repository and go to **Settings** in the sidebar (owner only).
19
+2. In the **Visibility** section, pick *Public* or *Private* and click
20
+ **Change visibility**.
21
+
22
+The change takes effect immediately:
23
+
24
+- Making a repository **public** exposes its entire contents and history to
25
+ everyone — including anything ever committed, such as secrets in old
26
+ commits. Check the history before opening a repository up.
27
+- Making a repository **private** hides it right away from anonymous
28
+ visitors, search, `/explore`, and federation. Remote instances that were
29
+ following the repository stop receiving pushes immediately, but they are
30
+ not actively notified and may still list the follow on their side.
31
+
32
+Only the repository owner (or an organisation **owner** for org repositories)
33
+can change visibility.
MODIFY
src/main/java/de/workaround/git/GitRepositoryService.java
+12 -0
@@ -114,6 +114,18 @@
114
114
}
115
115
116
116
@Transactional
117
+ public void changeVisibility(User actor, Repository repository, Repository.Visibility visibility)
118
+ {
119
+ if (!accessPolicy.canAdmin(actor, repository))
120
+ {
121
+ throw new ForbiddenOperationException("Only the owner may change a repository's visibility");
122
+ }
123
+ Repository managed = repositories.findById(repository.id);
124
+ managed.visibility = visibility;
125
+ repository.visibility = visibility;
126
+ }
127
+
128
+ @Transactional
117
129
public void delete(User actor, Repository repository)
118
130
{
119
131
if (!accessPolicy.canAdmin(actor, repository))
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+23 -0
@@ -295,6 +295,29 @@
295
295
}
296
296
297
297
@POST
298
+ @jakarta.ws.rs.Path("visibility")
299
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
300
+ public Response changeVisibility(@PathParam("owner") String owner, @PathParam("name") String name,
301
+ @FormParam("visibility") String visibility)
302
+ {
303
+ Repository repo = requireOwner(owner, name);
304
+ Repository.Visibility parsed;
305
+ try
306
+ {
307
+ parsed = Repository.Visibility.valueOf(visibility == null ? "" : visibility);
308
+ }
309
+ catch (IllegalArgumentException e)
310
+ {
311
+ return Response.status(Response.Status.BAD_REQUEST)
312
+ .entity(Templates.settings(repo, repoNav.build(repo, uriInfo),
313
+ "Unknown visibility \"" + visibility + "\"."))
314
+ .build();
315
+ }
316
+ service.changeVisibility(currentUser.require(), repo, parsed);
317
+ return Response.seeOther(settingsUri(repo)).build();
318
+ }
319
+
320
+ @POST
298
321
@jakarta.ws.rs.Path("image/delete")
299
322
public Response deleteImage(@PathParam("owner") String owner, @PathParam("name") String name)
300
323
{
MODIFY
src/main/resources/templates/RepositoryResource/settings.html
+15 -0
@@ -11,6 +11,21 @@
11
11
<p class="error">{error}</p>
12
12
{/if}
13
13
14
+ <h2>Visibility</h2>
15
+ <p class="muted">This repository is currently <strong>{repo.visibility.name()}</strong>.
16
+ Public repositories can be seen and cloned by anyone, including other federated instances;
17
+ private repositories are visible only to you and your collaborators.</p>
18
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/visibility"
19
+ onsubmit="return confirm('Change the visibility of this repository?')">
20
+ <p>
21
+ <select name="visibility">
22
+ <option value="PUBLIC"{#if repo.visibility.name() == 'PUBLIC'} selected{/if}>Public</option>
23
+ <option value="PRIVATE"{#if repo.visibility.name() == 'PRIVATE'} selected{/if}>Private</option>
24
+ </select>
25
+ </p>
26
+ <button class="btn btn-primary">Change visibility</button>
27
+ </form>
28
+
14
29
<h2>Repository image</h2>
15
30
<p class="muted">Shown next to this repository across git-shark. Without one, the owner's profile picture is used.</p>
16
31
{#if repo.hasImage}
ADD
src/test/java/de/workaround/web/RepositoryVisibilityTest.java
+148 -0
@@ -0,0 +1,148 @@
1
+package de.workaround.web;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.git.ForbiddenOperationException;
8
+import de.workaround.git.GitRepositoryService;
9
+import de.workaround.model.Repository;
10
+import de.workaround.model.User;
11
+import io.quarkus.test.junit.QuarkusTest;
12
+import io.quarkus.test.security.TestSecurity;
13
+import jakarta.inject.Inject;
14
+import jakarta.persistence.EntityManager;
15
+import jakarta.transaction.Transactional;
16
+
17
+import static io.restassured.RestAssured.given;
18
+import static org.hamcrest.CoreMatchers.containsString;
19
+import static org.hamcrest.Matchers.anyOf;
20
+import static org.hamcrest.Matchers.is;
21
+import static org.junit.jupiter.api.Assertions.assertEquals;
22
+import static org.junit.jupiter.api.Assertions.assertThrows;
23
+
24
+@QuarkusTest
25
+class RepositoryVisibilityTest
26
+{
27
+ @Inject
28
+ GitRepositoryService service;
29
+
30
+ @Inject
31
+ Repository.Repo repositories;
32
+
33
+ @Inject
34
+ User.Repo users;
35
+
36
+ @Inject
37
+ EntityManager em;
38
+
39
+ @Test
40
+ @TestSecurity(user = "vis-owner")
41
+ void ownerChangesPrivateToPublic()
42
+ {
43
+ User owner = persistUser("vis-owner");
44
+ Repository repo = service.create(owner, "opening-up", Repository.Visibility.PRIVATE, null);
45
+
46
+ given().redirects().follow(false)
47
+ .formParam("visibility", "PUBLIC")
48
+ .when().post("/repos/vis-owner/opening-up/visibility")
49
+ .then().statusCode(anyOf(is(302), is(303)));
50
+
51
+ assertEquals(Repository.Visibility.PUBLIC, byId(repo.id).visibility);
52
+ }
53
+
54
+ @Test
55
+ @TestSecurity(user = "vis-closer")
56
+ void ownerChangesPublicToPrivate()
57
+ {
58
+ User owner = persistUser("vis-closer");
59
+ Repository repo = service.create(owner, "going-dark", Repository.Visibility.PUBLIC, null);
60
+
61
+ given().redirects().follow(false)
62
+ .formParam("visibility", "PRIVATE")
63
+ .when().post("/repos/vis-closer/going-dark/visibility")
64
+ .then().statusCode(anyOf(is(302), is(303)));
65
+
66
+ assertEquals(Repository.Visibility.PRIVATE, byId(repo.id).visibility);
67
+ }
68
+
69
+ @Test
70
+ @TestSecurity(user = "vis-stranger")
71
+ void nonOwnerCannotChangeVisibility()
72
+ {
73
+ persistUser("vis-stranger");
74
+ User owner = persistUser("vis-real-owner-" + UUID.randomUUID().toString().substring(0, 8));
75
+ Repository repo = service.create(owner, "guarded", Repository.Visibility.PRIVATE, null);
76
+
77
+ given().redirects().follow(false)
78
+ .formParam("visibility", "PUBLIC")
79
+ .when().post("/repos/" + owner.username + "/guarded/visibility")
80
+ .then().statusCode(404);
81
+
82
+ assertEquals(Repository.Visibility.PRIVATE, byId(repo.id).visibility);
83
+ }
84
+
85
+ @Test
86
+ @TestSecurity(user = "vis-bogus")
87
+ void rejectsUnknownVisibilityValue()
88
+ {
89
+ User owner = persistUser("vis-bogus");
90
+ Repository repo = service.create(owner, "bogus", Repository.Visibility.PRIVATE, null);
91
+
92
+ given().redirects().follow(false)
93
+ .formParam("visibility", "FRIENDS_ONLY")
94
+ .when().post("/repos/vis-bogus/bogus/visibility")
95
+ .then().statusCode(400);
96
+
97
+ assertEquals(Repository.Visibility.PRIVATE, byId(repo.id).visibility);
98
+ }
99
+
100
+ @Test
101
+ @TestSecurity(user = "vis-settings")
102
+ void settingsPageOffersVisibilityChange()
103
+ {
104
+ User owner = persistUser("vis-settings");
105
+ service.create(owner, "knobs", Repository.Visibility.PRIVATE, null);
106
+
107
+ given().when().get("/repos/vis-settings/knobs/settings")
108
+ .then().statusCode(200)
109
+ .body(containsString("/repos/vis-settings/knobs/visibility"))
110
+ .body(containsString("PRIVATE"));
111
+ }
112
+
113
+ @Test
114
+ @TestSecurity(user = "vis-svc")
115
+ void serviceRejectsNonAdminActor()
116
+ {
117
+ User owner = persistUser("vis-svc-owner-" + UUID.randomUUID().toString().substring(0, 8));
118
+ User stranger = persistUser("vis-svc-stranger-" + UUID.randomUUID().toString().substring(0, 8));
119
+ Repository repo = service.create(owner, "svc", Repository.Visibility.PRIVATE, null);
120
+
121
+ assertThrows(ForbiddenOperationException.class,
122
+ () -> service.changeVisibility(stranger, repo, Repository.Visibility.PUBLIC));
123
+ assertEquals(Repository.Visibility.PRIVATE, byId(repo.id).visibility);
124
+ }
125
+
126
+ // Clear the persistence context first: each HTTP call commits in its own transaction, so a
127
+ // re-read of the same repository within one test method must not return the stale L1-cached instance.
128
+ private Repository byId(UUID id)
129
+ {
130
+ em.clear();
131
+ return repositories.findById(id);
132
+ }
133
+
134
+ @Transactional
135
+ User persistUser(String name)
136
+ {
137
+ User existing = users.findByOidcSubOptional(name).orElse(null);
138
+ if (existing != null)
139
+ {
140
+ return existing;
141
+ }
142
+ User user = new User();
143
+ user.oidcSub = name;
144
+ user.username = name;
145
+ user.persist();
146
+ return user;
147
+ }
148
+}