gitshark

Clone repository

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

← Commits

✨ (repos): Let owners change repository visibility in settings

fda4a260e06b96186bff4db66a1e3379c8d1c289 · Michael Hainz · 2026-07-10T09:32:00Z

Changes

8 files changed, +240 -1

MODIFY README.md +1 -1
diff --git a/README.md b/README.md
index 5e8166e..60d49e6 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/README.md b/docs/README.md
index 7f555be..faa1215 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/maintainers/forgefed.md b/docs/maintainers/forgefed.md
index b9680fc..29930ad 100644
--- a/docs/maintainers/forgefed.md
+++ b/docs/maintainers/forgefed.md
@@ -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
diff --git a/docs/users/repository-visibility.md b/docs/users/repository-visibility.md
new file mode 100644
index 0000000..129310b
--- /dev/null
+++ b/docs/users/repository-visibility.md
@@ -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
diff --git a/src/main/java/de/workaround/git/GitRepositoryService.java b/src/main/java/de/workaround/git/GitRepositoryService.java
index 04959de..70ea047 100644
--- a/src/main/java/de/workaround/git/GitRepositoryService.java
+++ b/src/main/java/de/workaround/git/GitRepositoryService.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index bea2d1b..7f0e334 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/settings.html b/src/main/resources/templates/RepositoryResource/settings.html
index 55fde4e..a45ec58 100644
--- a/src/main/resources/templates/RepositoryResource/settings.html
+++ b/src/main/resources/templates/RepositoryResource/settings.html
@@ -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
diff --git a/src/test/java/de/workaround/web/RepositoryVisibilityTest.java b/src/test/java/de/workaround/web/RepositoryVisibilityTest.java
new file mode 100644
index 0000000..f8c62aa
--- /dev/null
+++ b/src/test/java/de/workaround/web/RepositoryVisibilityTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog